The Synth Sounded Fine When Nobody Was Listening
DEV Community

The Synth Sounded Fine When Nobody Was Listening

This is my entry for DEV's Summer Bug Smash - Smash Stories. There is a specific kind of bug that makes you doubt your own ears. I had a browser-based audio engine that played fine in export and sounded broken in real time. Hit play, and the output was thin, muffled, strangled - like someone had thrown a blanket over the speakers. Hit "render to file" on the exact same project data, and the resulting audio was perfect. Full, rich, every note sustaining exactly as written. Same notes. Same instruments. Same code, as far as I knew. One path sounded right and one path sounded wrong, and I could reproduce both on demand, forever.

Why This Was So Hard to Trust

The maddening part of an audio bug is that "muffled" isn't an error message. Nothing threw. Nothing logged. There was no stack trace, no failed assertion, no red text anywhere. The app was, by every measure I had instrumented, working.

My first instinct was the obvious one: it's a filter. Something in the live path has a lowpass on it that the offline path doesn't. So I went hunting for a rogue filter node, a wrong cutoff default, a gain stage set too low. I found nothing, because there was nothing to find.

My second instinct was that it was a performance problem - that live playback was simply too expensive and the audio thread was starving. That felt plausible. It was also wrong, and chasing it cost me real time. CPU was fine. The audio thread was not underrunning.

The thing that finally broke the case open was reframing the question. I stopped asking "what is different about the sound?" and started asking "what is structurally different about the two code paths?" Because in Web Audio, offline rendering and live playback are genuinely different machines. An offline render runs as fast as it can through a deterministic timeline. Live playback runs on a scheduler - a loop that wakes up, looks a short distance into the future, and schedules whatever notes fall inside that window. Offline had no scheduler at all. Live did. That was the only meaningful structural difference. So the scheduler became the suspect.

Counting the Bodies

Rather than reading the scheduler and trying to reason about it, I instrumented it. I counted every voice it created and - crucially - every voice it destroyed. The number came back at roughly 6,636 audio nodes killed in a single short run.

That was the moment the bug stopped being mysterious and became almost funny. I wasn't listening to muffled audio. I was listening to audio that was being murdered and resurrected roughly sixty times a second, and what reached my ears was only ever the first few milliseconds of each note's life, over and over.

The sound wasn't filtered. It was amputated. Every note was being cut off during its attack, before the envelope had any chance to develop into an actual tone. That is exactly what "thin and strangled" sounds like, and once I knew it, I couldn't un-hear it.

The Actual Root Cause

The scheduler had a lookahead window of 0.28 seconds. Every tick, it scheduled all voices falling inside the next 0.28s of the timeline. Standard, sensible design. It also had a seek detector. This is a normal and necessary thing to have: if the user drags the playhead backwards, every note you already scheduled into the future is now wrong, so you cancel everything and reschedule from the new position. Also standard, also sensible.

The bug lived in the relationship between those two features. The seek detector decided "the user has seeked backwards" by comparing the current transport position against the position it last saw - using a threshold that was smaller than the lookahead window itself. Read that again, because it's the whole bug.

The scheduler's own job is to run ahead of the playhead. It deliberately pushes its working position up to 0.28s into the future. So on the next tick, the actual playhead is naturally "behind" where the scheduler had last been working - by an amount governed by the size of the lookahead window. To the seek detector, that ordinary, intended, by-design gap was indistinguishable from a user dragging the playhead backwards. So it fired. It cancelled every scheduled voice and rebuilt them from scratch. Then the scheduler ran ahead again - because that is its entire purpose - which re-created the same backwards gap, which tripped the detector again, which cancelled everything again.

The scheduler was seeing its own lookahead as a seek and panicking about it, on every single frame. Two features that were each individually correct, wired together into a perfect self-sustaining loop. Neither one was buggy on its own. The bug existed only in the space between them.

And it explained the offline path perfectly: the offline render never ran the live scheduler, so the seek detector never existed, so nothing ever got cancelled, so every note lived its full life. The "working" path wasn't working because it was better. It was working because it never touched the broken code at all.

The Fix

The fix was to make the backward-seek threshold larger than the lookahead window, so that the scheduler's own intended forward progress could never be mistaken for a user seek. One constant. Two bytes. That is the entire patch.

Six thousand dead audio nodes per run, weeks of a product sounding subtly wrong, an entire wrong theory about CPU starvation - all resolved by making one number bigger than another number.

What I Actually Took Away From This

The two-path comparison is the most underrated debugging tool there is. I burned time hunting for a difference in the audio graph when the real difference was in the control flow. Whenever you have one path that works and one that doesn't, the fastest question is not "what's wrong with the broken one" - it's "what does the broken one do that the working one never touches?" That question pointed straight at the scheduler, and the scheduler was the answer.

Count things. I could have read that scheduler top to bottom and still argued myself into believing it was correct, because every individual piece of it was correct. I couldn't argue with 6,636 destroyed nodes. A counter turned an unfalsifiable "it sounds bad" into a hard number, and the hard number named the culprit immediately. When a bug has no error message, instrumentation is how you manufacture one.

Symptoms lie about their category. This presented as an audio quality problem. Every instinct said filter, gain, DSP. It was a timing problem wearing an audio costume. I've started treating my first instinct about a bug's category as a hypothesis to disprove rather than a place to start digging.

Bugs love the seams. The most expensive bugs I've hit aren't inside a function. They're in the interaction between two functions that are both fine. A lookahead window and a seek threshold are each trivially correct in isolation. Nobody reviewing either one alone would blink. The defect only exists when you hold them up together, and no unit test that tests them separately will ever catch it.

The synth sounds right now. It sounded right in export the whole time - it just needed everyone to stop killing it sixty times a second.

Comments

No comments yet. Start the discussion.