# Fine in Time, or Fine in Frequency

*Task 3 of 5 · [Spectrograms](https://gpu.rocks/learn/spectrograms-9ecd2295.md) · GPU.js Learn*

Shorten the window and the time axis sharpens. Lengthen it and the frequency axis
does. You cannot have both, and this is not a gpu.js limitation or a numerical one — it is
a theorem. A window *w* samples long cannot tell two frequencies apart unless they
differ by roughly `2 · SR / w`, and it cannot place an event in time to better
than *w* samples. Multiply those two together and the window length cancels: the
product is a constant, and picking a window is choosing which end of it to spend.

This signal has both kinds of detail. Two steady tones, 512 Hz and 640 Hz, run the whole
way through; and at 0.254 s there is a single sample driven hard — a **click**,
the sharpest event a sampled signal can hold. Build the same spectrogram twice: same hop,
same 64 frames, one with a 64-sample window and one with a 256-sample window. Then measure
both, on both axes. The two measurements are already written; do not take anyone's word for
which window wins, because neither of them does.

One kernel *source*, two compilations. `this.constants.win` is fixed
when a kernel is created — which is precisely why it is legal as a loop bound — so handing
`createKernel` a different constants object turns the same function body into a
different kernel. Same trick a CUDA template parameter plays.

## Figures

- **sharpen one axis and you have spent the other — there is no third option**

## Goal

**Goal:** compile the spectrogram twice — once with a 64-sample window
and once with 256 — and let the two measurements say what changed.

## Requirements

- `shortSpec` uses `win: 64` and keeps `32` bins
- `longSpec` uses `win: 256` and keeps `128` bins
- Both keep the same hop (`32`) and the same `64` frames, so their time axes line up

## Hint 1 — how many bins?

A window of *w* real samples has *w*/2 bins below Nyquist, each
`SR / w` hertz wide. A 64-sample window gives 32 bins 64 Hz apart; a
256-sample window gives 128 bins 16 Hz apart.

## Hint 2 — the two calls

```js
const shortSpec = makeSpectrogram(64, 32);
const longSpec = makeSpectrogram(256, 128);
```

`makeSpectrogram` is plain JavaScript — `win` and `bins` reach
the kernel through its settings object, never through a closure, which is why this compiles at
all.

## Hint 3 — what you should see

The short window finds **one** peak where there are two tones, and
pins the click to **2** frames. The long window resolves
**two** peaks and smears the same click across **6**. Neither
is the right answer; they are the same information spent differently.

## Same idea elsewhere

Window length is the first knob in every audio pipeline, and the choice is always
this trade: a speech recogniser takes 25 ms windows with a 10 ms hop because phonemes move
fast, a music transcriber takes 100 ms because it needs to tell a semitone from its
neighbour, and a vibration monitor looking for a bearing fault takes seconds. Recompiling
one kernel source against different compile-time constants is equally universal —
`template<int WIN>` in CUDA, `override` constants in WGSL,
function constants in Metal. The specialisation is what lets the compiler unroll the loop.

## Starter code

```js
// One kernel source, two window lengths. Everything else held equal.
const gpu = new GPU({ mode });

const SR = 4096;    // samples per second
const HOP = 32;     // both spectrograms slide by this much
const FRAMES = 64;  // and both are this wide

// Already written: the STFT body from the last task, with the window length
// left as a constant so the same source can be compiled more than once.
function makeSpectrogram(win, bins) {
  return gpu.createKernel(function (signal) {
    const frame = this.thread.x;
    const bin = this.thread.y;
    const start = frame * this.constants.hop;
    let re = 0;
    let im = 0;
    for (let t = 0; t < this.constants.win; t++) {
      const w = 0.5 - 0.5 * Math.cos((2 * Math.PI * t) / (this.constants.win - 1));
      const s = signal[start + t] * w;
      const angle = (-2 * Math.PI * bin * t) / this.constants.win;
      re += s * Math.cos(angle);
      im += s * Math.sin(angle);
    }
    return Math.sqrt(re * re + im * im) / this.constants.win;
  }, {
    output: [FRAMES, bins],
    constants: { win: win, hop: HOP },
  });
}

// TODO: one SHORT window and one LONG one. Right now this is the same
// kernel twice, which measures the same thing twice.
const shortSpec = makeSpectrogram(256, 128);
const longSpec = makeSpectrogram(256, 128);

// Already written. FREQUENCY resolution: how many separate peaks stand up
// between 400 Hz and 800 Hz in a quiet frame? Two tones live in there.
function tonePeaks(spec, win) {
  const lo = Math.round((400 * win) / SR);
  const hi = Math.round((800 * win) / SR);
  const f = 20;
  let peak = 0;
  for (let b = 0; b < spec.length; b++) peak = Math.max(peak, spec[b][f]);
  let n = 0;
  for (let b = lo; b <= hi; b++) {
    const v = spec[b][f];
    if (v > 0.1 * peak && v >= spec[b - 1][f] && v > spec[b + 1][f]) n++;
  }
  return n;
}

// Already written. TIME resolution: how many frames does the one-sample
// click light up? It is broadband, so look above the tones.
function clickFrames(spec) {
  const top = Math.floor(spec.length / 2);
  const energy = [];
  let peak = 0;
  for (let f = 0; f < FRAMES; f++) {
    let e = 0;
    for (let b = top; b < spec.length; b++) e += spec[b][f];
    energy.push(e);
    peak = Math.max(peak, e);
  }
  let n = 0;
  for (const e of energy) if (e > 0.1 * peak) n++;
  return n;
}

const shortPic = shortSpec(signal);
const longPic = longSpec(signal);

console.log('short window:', tonePeaks(shortPic, 64), 'tone peaks,', clickFrames(shortPic), 'frames of click');
console.log('long  window:', tonePeaks(longPic, 256), 'tone peaks,', clickFrames(longPic), 'frames of click');
```

---

Interactive version: https://gpu.rocks/learn/spectrograms-9ecd2295/3

[Previous task](https://gpu.rocks/learn/spectrograms-9ecd2295/2.md) · [Next task](https://gpu.rocks/learn/spectrograms-9ecd2295/4.md)
