Skip to content
AFM Studio
Personal ProjectAudio & DSPWeb App

Spectrogram

Turns sound into a picture in real time — speak, whistle or play into your mic and watch which pitches are present — with the underlying mathematics written from scratch, not borrowed from the browser

All projects4 min read

Role

Solo Developer

Timeframe

Aug 2026

On this page

The problem

A waveform — the squiggle everyone recognises — shows how loud a sound is, but not what pitch it is: a low hum and a high whistle look the same. A spectrogram answers the other question, and it's how speech recognition, music software, bird-call identification, and audio forensics all actually look at sound. Two audiences, and the project serves both:

  • A curious learner who has heard of "the Fourier transform" and wants it to become intuitive rather than an equation. The core lesson is the time–frequency tradeoff: you can't have sharp timing and sharp pitch at once — sharpening one blurs the other. Turned into a control you drag, it lands in about four seconds where a paragraph fails.
  • A technical reviewer evaluating whether the author can implement a non-trivial numerical algorithm correctly, not just call a library. The browser ships AnalyserNode, which draws a spectrogram in ten lines and proves nothing. This project's claim is the transform itself — and that it's provably right.

There's also a trust problem: any site asking for microphone access owes the visitor a straight answer about where the audio goes. Here the answer is "nowhere", and it's structural — zero network requests at runtime, verified by a test and by a build-time check that fails the deploy.

The approach

The transform was verified before any interface existed

The first six commits are the FFT and its tests; the first UI commit is the seventh. The reasoning is specific: a wrong FFT still draws a beautiful picture. A subtly broken transform produces a plausible, attractive spectrogram, and no amount of looking reveals the bug. So four independent correctness signals were built first, each catching what the others miss: a naive O(n²) DFT oracle (the literal textbook definition, checked across every size and a seeded random corpus); Parseval's identity (one scalar that must hold for every input, catching scaling and indexing bugs); a round trip (FFT → inverse → original, catching normalisation errors); and analytic fixtures (DC, bin-centred sine, unit impulse, and the exact leakage kernel at a half-bin offset — each checkable by hand).

Speed came from data layout, not cleverness

Real and imaginary parts live in separate Float64Arrays rather than {re, im} objects; the transform runs in place; twiddle tables, window coefficients, and output buffers are allocated once per configuration and reused. Nothing is allocated per frame, because a garbage-collection pause at 60fps reads to a user as a broken app — so the benchmark asserts flat heap growth over a minute of sustained analysis, not just throughput. Analysis runs off the main thread in a Worker; the plate is a scrolling bitmap — a ring buffer of columns blitted as one ImageData per frame, never a redraw of the history.

Privacy enforced by the build, not promised in copy

A test scans source for any outbound reference, and a finalize step scans the built output for anything the browser would resolve — src, <link href>, srcset, url(), @import — plus a known-tracker denylist, failing the build if one appears. The one exception is four footer profile links, pinned individually by URL in both guards, because an anchor is not a fetch. The naive version of this check (fail on any https://) was rejected as unusable — framework error strings and licence headers contain URLs never fetched — so it targets references the browser will actually resolve.

An honest proof page

The comparison view runs the hand-written FFT beside the browser's AnalyserNode on identical input, with per-bin difference and timing for both — and it's honest in its framing: the native one is faster, and speed was never the claim. Getting the comparison fair required matching the spec's Blackman window, matching its exact dB scaling with no one-sided doubling, and using a stationary bin-centred input so frame alignment stops mattering.

Outcome

Live and public, bilingual, fully offline-capable. It ships the live plate (mic or bundled audio, transformed and drawn as a scrolling spectrogram with the waveform above and the analysis window as a bracket tying the two together, hover giving an exact time/frequency/dB/bin readout in tabular figures); the tradeoff as a control (window size and overlap live, as three named positions with the raw parameters folded into an advanced disclosure); four switchable window functions; three frequency scales (linear, log, mel); a synthesis mode that closes the round trip (build a signal, transform it, recover exactly what you put in); and a proof page. Six bundled samples are generated in code, so the app is a complete experience even with the microphone denied.

Built solo in one ~4-hour session — ~8,400 lines of source, a ~990-line DSP core, 256 tests running in ~5.6s, on three runtime dependencies and zero DSP, audio, or charting libraries. Measured on desktop at the reference load: ~0.105 ms per column, ~101× real time, ~0.6 bytes of heap growth per column over a minute — that last number is the one that matters. AnalyserNode is linked warmly as the faster native option; the claim here is correctness, not speed.

Outcomes

Network requests at runtime — enforced by the build
0
Tests, incl. a naive-DFT correctness oracle
256
Real-time FFT, hand-written, zero DSP libraries
~101×
Independent proofs the transform is correct
4

Screenshots

Beranda
See Sound
Build a Sound
Check the Maths

Have a project like this?

If you need a system built with the same care — clear scope, solid execution — let's talk.

Start a project