Building BEAT LAB: A Visualizer That Sees the Drop Coming
Every music visualizer you've ever seen is one frame late. It reads the current slice of audio and draws it, so the big visual "hit" always lands a beat or two after the sound. You feel it even if you can't name it. The picture is always chasing the music.
I wanted the opposite. I wanted visuals that anticipate: visuals that know a drop is coming, build tension through the riser, and hard-cut on the exact beat you hear. That's BEAT LAB, a music player that runs entirely in your browser and turns each song into live comic-book print: misregistered ink plates, panel cuts on the big hits, sound words, ink figures dancing on the beat.
The twist is that a visualizer that anticipates isn't a rendering problem. It's three harder ones.
You can't anticipate what you can't read
To know a drop is coming, you have to understand the song first. So the real work happens before you press play. BEAT LAB decodes every track once, on your device, and figures out the beat grid, where the sections are, where the drops land, and the musical key, then stores that and plays the visuals from it like a script.
The tempting move here is to grab a library. Essentia has a perfectly good beat tracker, and I was already bundling it for mood detection. I wrote all the DSP by hand anyway: a small radix-2 FFT, spectral-flux onsets, autocorrelation for tempo, dynamic-programming beat tracking, the Foote method for sections.
Why? Determinism. Hand-written DSP means a synthetic 128 BPM click track asserts 128 ± 1 in a unit test, every time. A flat track produces no drop. A C-major triad reads C major. When your whole product is "land the cut on the right beat," you need to pin the analyser to exact numbers and keep it there, not hope a black-box WASM blob agrees with you after the next version bump. The trade-off is honest: my key detection is simpler than Essentia's, and it's more code to own. Worth it.
One detail I'm weirdly proud of: integer autocorrelation lags make a 128 BPM track read as 123 or 129. A little parabolic interpolation between lags fixes it, and 128 reads as 128. Small thing. It's the difference between the cut feeling on versus nearly.
Landing on the beat when the browser won't help you
Here's the problem nobody warns you about: HTML audio isn't sample-accurate. <audio>.currentTime is approximate, play() doesn't start exactly when you ask, and there's real latency between "the sample plays" and "you hear it." So even with a perfect beat grid, scheduling a cut naively lands it in the wrong place.
The fix is a latency-compensated song clock: deck.currentTime + AudioContext.outputLatency. You schedule the cut for when the drop is heard, not when the sample plays. And the cut itself is edge-triggered: it fires on the first frame at or past the drop time, exactly once. That one property is what makes it testable frame by frame, and testable is what makes it trustworthy.
And when there's no analysis yet, or the beat confidence is low, or you're piping in a live mic or a browser tab? It falls straight back to reacting, like every other visualizer. The fancy path is the exception, not the foundation.
Mixing tracks like a DJ
Once you know every track's tempo and downbeats, beat-matching almost falls out for free. On shuffle, tempo-compatible songs hand off like a DJ set. They're tempo-matched at the nearest octave (so a 70 BPM track under a 140 BPM one plays at rate ≈ 1.0, not stretched to death), started on the outgoing track's downbeat, with a bass swap on the beat.
Almost for free. Because play() isn't sample-accurate, two tracks that start "aligned" drift. So there's a small phase-lock loop that measures deck B against deck A's grid and nudges playbackRate by up to ±0.5% until they're within 10 milliseconds. It's unit-tested to converge a 40 ms error down under 10. Mismatched or low-confidence tracks skip all that and do a clean crossfade.
The constraints were the design
Three rules were non-negotiable from day one, and they shaped everything:
- Your audio never leaves the device. The only network call in the whole app is an optional lyrics lookup. No backend, no uploads, no copyright exposure, no hosting bill. The privacy decision also happens to make the architecture simpler.
- A strict Content-Security-Policy.
script-src 'self', noevalanywhere on the page. The mood model needseval(Essentia compiles its bindings withnew Function), so only its worker gets a scoped exception; the page itself never does. My own analysis worker is pure DSP with noevalat all. - Photosensitivity guards you can't switch off. Full-frame flashes are rationed (3 per second, 1 per second while recording a clip), and a luminance monitor dims the stage if it flickers too hard. This visualizer flashes for a living, and clips get posted publicly. Making the safety optional was never on the table.
That last one is the one I'd defend hardest. Accessibility isn't a setting. It's an invariant.
What I'm not pretending
The synthetic tests are airtight: click tracks, build-to-drops, the phase-lock convergence. What's still open is the number that actually matters to a listener, the drop hit-rate on real songs across genres. I built the measurement harness and a little annotate mode to score it; filling in that number against a hand-labelled set is the next job, and I'll publish it failures included. Beat-match can also drift on very long blends, and clip recording is real-time, so a slow phone can drop frames.
I'd rather ship it saying that than pretend the analyser is magic.
BEAT LAB is about 15,000 lines of strict TypeScript (Canvas 2D, Web Audio, Web Workers), with every bit of the analysis written by hand so it stays deterministic and tested. No runtime backend at all.
Try it live (bring a song with a real drop), or read the code on GitHub. Turn your volume up and watch the riser. That little "WAIT FOR IT… 3… 2… 1" is the whole project in one moment.
