Task 5 of 5

Payoff: Read the Sweep Off the Picture

A spectrogram is not only for looking at. This signal is a chirp — a tone sliding steadily upward — and everything needed to characterise it is already in the picture. For each frame, find the bin carrying the most energy. The sequence of winners is the chirp's ridge, and the slope of that ridge is the sweep rate, in hertz per second, recovered from nothing but the image.

The GPU shape: one thread per frame, each scanning its own column of 128 bins for the largest value and returning the index, not the value. An argmax along an axis is a reduction — Reductions climbs the general log₂ ladder for the case where the axis is huge — but with 128 values per column, one thread walking them is the right call. What matters more is what it does not need: no atomics, no shared memory, no thread writing to a cell it does not own. Every thread pulls its own column and writes its own answer.

Where would this come from on a real page? A microphone, and the wiring is short:

const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const audio = new AudioContext({ sampleRate: 48000 });
await audio.audioWorklet.addModule('tap.js');   // posts Float32Array frames
const tap = new AudioWorkletNode(audio, 'tap');
audio.createMediaStreamSource(stream).connect(tap);

const gpu = new GPU();
const spectrogram = gpu.createKernel(/* the kernel you already wrote */);

tap.port.onmessage = event => {
  const spec = spectrogram(event.data);   // event.data is the ring buffer
  paint(spec, peakOf(spec));
};

That code cannot run here, and this course is not going to pretend otherwise. Your code executes inside a Web Worker — which is what lets a runaway kernel be killed instead of freezing the page — and a Worker has no AudioContext, no OfflineAudioContext and no navigator.mediaDevices. There is no microphone to reach and nothing to play back through. The buffers this module builds are the stand-in, and they buy something a microphone never could: every assertion below is exact. Every kernel you have written works unchanged the day you paste it onto a page with the wiring above.

Goal: find the ridge with a kernel, then turn its slope into a sweep rate in hertz per second and log it.

Requirements

Hint 1 — tracking an argmax

Two locals: the best value seen so far, and the index it came from. Update both together, and return the index. Returning best instead of bestBin is the classic slip, and it looks plausible right up until the numbers turn out to be 0.2 rather than 40.

Hint 2 — the kernel
let best = 0;
let bestBin = 0;
for (let b = 0; b < this.constants.bins; b++) {
  const v = spec[b][this.thread.x];
  if (v > best) {
    best = v;
    bestBin = b;
  }
}
return bestBin;

Note spec[b][this.thread.x]: the column belongs to this thread, and b walks down it.

Hint 3 — the arithmetic
const binsPerFrame = (line[FRAMES - 1] - line[0]) / (FRAMES - 1);
const hzPerSecond = (binsPerFrame * (SR / WIN)) / (HOP / SR);

Bins are 4096 / 256 = 16 Hz apart and frames are 64 / 4096 = 15.625 ms apart, so one bin per frame is 1,024 Hz per second — which is exactly what went in.

Same idea elsewhere

Ridge tracking is how a radar measures range rate, how a guitar tuner follows a bent note, how a birdsong classifier segments syllables and how a bearing monitor spots a harmonic drifting with load. The primitive is argmax along an axis: torch.argmax(dim=…), cub::DeviceSegmentedReduce with ArgMax, simd_max plus a ballot in Metal. The awkward part on every platform is the same one you just stepped around — a maximum is easy, but carrying the index of the maximum through a parallel reduction means reducing pairs, not numbers.

All tasks in Spectrograms

  1. One Spectrum, No Clock
  2. Slice, Window, Transform
  3. Fine in Time, or Fine in Frequency
  4. Decibels, Then Colour
  5. Payoff: Read the Sweep Off the Picture

This page is an interactive exercise — the editor, the GPU runner and your saved progress need JavaScript. The text above is the full brief.