Task 5 of 5
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.
output: [FRAMES] — one thread per frame, scanning all this.constants.bins binsconsole.log it — bins are SR / WIN Hz apart, frames HOP / SR seconds apartTwo 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.
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.
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.
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.
This page is an interactive exercise — the editor, the GPU runner and your saved progress need JavaScript. The text above is the full brief.