Every drum sound on my hardware rig — kick, snare, both hats — was secretly the same tom. I found out this morning, from a robot, and by dinner the fix was live and the entire synth engine was running in my phone's browser.
The problem #
My groovebox is a stack of Faust modules — each drum voice is a little DSP program — driven by an Ableton Push. For weeks my testing loop was: ask Claude to change a patch, have it render a WAV to disk, listen back, complain, repeat. Slow, and worse, vibes-based. I'd say "the kick feels thin" and neither of us had numbers to argue with.
So we built sonic, a CLI that renders a Faust patch offline and scores it against a reference sample — a weighted distance over spectral features. Pull a real 808 kick from Freesound, render my kick, get a number. Lower is closer.
The bug #
First real audit with it, my FM drum module scored 0.40 against an 808 kick — bad — and the spectral breakdown showed 99.7% of its energy in one mid band. No sub at all. For a kick drum that's not "needs tuning," that's "this is not a kick."
The cause was one line. Each voice is selected by converting the incoming frequency back to a MIDI note number:
midiNote = ba.hz2midikey(freq) : int; // truncates!
Faust hands you note 36 as a frequency, and inverting it gives 35.9999…. Truncation lands on 35. Which matches no voice, so every hit fell through to the fallback — a generic 300 Hz tom. Kick, snare, hats: all the same tom, forever. The fix is the oldest one in the book:
midiNote = ba.hz2midikey(freq) + 0.5 : floor : int; // round
The proof it was real: before the fix, rendering the four "different" drum voices produced four byte-identical WAV files. After it, the kick scored 0.21 against the 808 with 39% of its energy below 80 Hz. The rig will sound different tonight, and honestly I'm a little afraid of how long I played it broken.
The pivot: stop rendering, start touching #
Here's the part that changed my week. I kept reading Claude's reports — "45% of the energy is in the sub band" — and realized I didn't fully understand the sentences I was nodding along to. I didn't want better reports. I wanted to see it, poke it, and have it explained while I poke.
The blocker was supposedly that my synths are compiled C++-ish code and the web is the web. Turns out the Faust compiler itself ships as WebAssembly. Not the patches — the compiler. So the browser can fetch the exact .dsp files that run on my hardware, show me the source, and recompile it live when I edit a number.
An afternoon later there's a single-page lab where I can:
- hit KICK/SNARE/HAT pads (same MIDI notes the Push sends),
- watch a scrolling spectrogram, oscilloscope-green,
- see the same 8 frequency bands
sonicscores with — sub, bass, body, air — as live meters, - and read a plain-language line after every hit: "Most of that sound — 45% of its energy — sits in the sub band (60–80 Hz): the rumble you feel in your chest more than hear."
Edit the decay constant in the code, recompile, hit the pad, watch the bars move. That loop — ten seconds, on a phone — is the synthesis lesson I've been failing to give myself with offline renders. It's the one I want to hand students too: not a wall of preset knobs, but this number in the code moves that bar on the screen.
The AI's role was honest pair-programming: it wrote the WebAudio and analyser plumbing against a library neither of us had used, then verified itself by driving a headless browser — click power, compile, hit the kick, assert the sub band lit up — before the page ever reached my phone. The bug it caught earlier it also caught properly: not "this sounds off," but here are two renders that should differ and don't.
Where it landed #
The lab runs in any browser at touchingsound-synthlab.tuns.sh — tunneled off my desktop for now (nginx + an SSH tunnel in Docker, so it survives reboots), moving to a permanent home on my portfolio site soon. Free-tier hosting for a full synthesis rig, because after compilation it's just static files and your phone does the DSP.
What still doesn't work #
The live band meters are an approximation — a real-time analyser with smoothing doesn't match the offline scorer exactly, so the browser says 23% where sonic says 39%, and consistency-with-itself is all you get. The plain-language panel is rule-based templates, not the in-app AI explainer I actually want (that needs a small server so I'm not shipping API keys to your phone). And the step sequencer is JavaScript timers, not the sample-accurate C++ one from my hardware rig — porting that to WebAssembly is its own story. Next post, probably.