Spectrogram
A live spectrogram that draws the pitches in your voice, using a transform I wrote and proved correct

- network requests at runtime, enforced by the build
- 0
- faster than real time on desktop
- ~101×
- independent checks that the transform is correct
- 4
- audio samples generated in code
- 6
On this page
The problem
A waveform, the squiggle everyone recognises, shows how loud a sound is. It doesn't show pitch: a low hum and a high whistle look much the same. A spectrogram shows which pitches are present over time. It's how speech recognition, music software and bird-call apps look at sound.
I built this for two kinds of visitor. The first has heard of the Fourier transform and wants it to feel intuitive. The key idea is a trade-off: you can't have sharp timing and sharp pitch at once. The second is a technical reviewer. Browsers ship a built-in analyser that draws a spectrogram in ten lines, and that proves nothing about the person who used it. I wanted to write the transform myself and show that it's correct.
What I built
I proved the maths before building any screen
A wrong transform still draws a convincing picture, and no amount of looking reveals the bug. So I wrote the transform and its tests first, and the interface came after. I set up four independent checks: a slow textbook version to compare against, an energy rule that must hold for every input, a round trip back to the original signal, and hand-checkable test signals. Each one catches mistakes the others miss.
The trade-off is a control you drag
The main screen draws sound from your microphone or a sample as a scrolling picture, with the waveform above it. Hovering gives the exact time, pitch and loudness. The timing-versus-pitch trade-off is a control with three named positions, from "sharper in time" to "sharper in pitch". Dragging it teaches the idea faster than a paragraph. The raw settings sit behind an "advanced" fold.
Smooth on screen because nothing is thrown away
At 60 frames a second, a memory clean-up pause looks like a broken app. I set up all the working memory once per setting and reused it, so the app allocates nothing per frame. The analysis runs in a background thread. The test that guards this measures memory growth over a full minute, as well as raw speed.
The audio never leaves the device
Any site that asks for your microphone owes you an answer about where the audio goes. Here the answer is nowhere, and the build enforces it. A check scans the finished site for anything the browser would fetch and fails the deploy if it finds one. The only exceptions are four links to my own profiles, each listed by exact address.
A proof page that admits the browser is faster
The proof page runs my transform beside the browser's built-in analyser on the same input and shows the difference at every frequency. The built-in one is faster, and the page says so. Speed was never the claim. To make the comparison fair, I matched its window shape and its loudness scale exactly.
Result
Spectrogram is live and public, in English and Indonesian. It offers four window shapes, three pitch scales and a synthesis mode where you build a signal from pure tones and watch the spectrum recover exactly what you put in. Six bundled samples are generated in code, so the site works even if you deny the microphone. On a desktop, the transform runs about 101 times faster than real time. I built it in a single session of about four hours on 6 August 2026.
Under the hoodTechnical detail for engineers
- The DSP core in
lib/dsp/is about 990 lines: an in-place radix-2 Cooley-Tukey FFT and inverse, cached twiddle and bit-reversal tables, four windows (rectangular, Hann, Hamming, Blackman) from Harris (1978), dB conversion, linear/log/mel bin mapping, framing and additive synthesis. - Correctness signals: a naive O(n²) DFT oracle over every size and a seeded random corpus; Parseval's identity in every suite; FFT → inverse round trip; analytic fixtures (DC, bin-centred sine, unit impulse, the Dirichlet leakage kernel at a half-bin offset). Tolerance was set once from the fixtures and never widened.
- Real and imaginary parts live in separate
Float64Arrays. The plate is a ring buffer of columns blitted as oneImageDataper frame. - The PRD called for analysis in an
AudioWorkletProcessor, but worklets can't reliably use ES imports, which would mean a second, untested copy of the FFT. Analysis moved to a regular Worker; the worklet is a capture tap only, with aScriptProcessorfallback. scripts/finalize-export.mjsscans the built output forsrc,<link href>,srcset,url()and@import, plus a tracker denylist. W3C XML namespaces are whitelisted as inert.- Proof-page fairness: Blackman α = 0.16 and
20·log10(|X[k]|/fftSize)scaling with no one-sided doubling, on a stationary bin-centred input. tests/ui/tokens.test.tsfails if the Tailwind palette and the canvas colour module drift. A dictionary test fails if the two locales drift.- Benchmark at 2048 points, 75% overlap, 48 kHz: about 0.105 ms per column, about 101× real time, about 0.6 bytes of heap growth per column over a minute.
- Next.js 14 static export, 4 routes × 2 locales, three runtime dependencies and no DSP, audio or charting library. 256 tests in 13 files, about 5.6 s. About 8,400 lines, 32 commits.
Screenshots
Hiring, or building something similar?
I'm open to full-time roles and select freelance work.