abusing.technology

Sep 28, 2026

The audio thread cannot wait

birdsynth is a wavetable synthesizer for the browser, built to roughly the feature set of a modern software synth: three oscillators with five types each, 63 filters, a 64-slot modulation matrix, three effect racks, a sequencer, a wavetable editor. It also has an explainer layer, because a synth is a pile of signal processing you cannot see.

None of that is the hard part. The hard part is that all of it runs on a thread that is not allowed to pause.

One rule

An AudioWorklet asks for 128 frames at a time. At 48 kHz that is 2.7 milliseconds, and if you do not return in time the speakers get a gap. Not a slow frame or a dropped request. An audible click, in the middle of a note someone is holding.

So the audio thread does not allocate. Allocation can take a lock, the lock can be held by another thread, and the other thread has no idea you are on a deadline. Every real-time audio codebase knows this rule. Most of them enforce it by being careful, which works right up until someone adds a Vec in a code path that only runs when you enable the third LFO.

birdsynth enforces it with a counting allocator. The Rust engine installs a GlobalAlloc that increments an atomic on every allocation, which turns the rule into something a test can assert:

it('never allocates while rendering 60 s of random notes')

There is a matching one for playing back samples, granular and spectral oscillators, and another on the JavaScript side where the worklet’s test harness subclasses the typed arrays to catch anything process() tries to allocate. The assertion is always toBe(0).

A rule you can only follow is a style guide. A rule you can measure is a gate.

No imports

engine.wasm imports nothing. The worklet instantiates it with {}.

That sounds like minimalism for its own sake until you look at what an AudioWorklet actually provides, which is very little. There is no DOM, no fetch, and no TextDecoder. That last one has a consequence I enjoyed finding: when the engine panics, the Rust panic hook writes the message into a static buffer as ASCII, one byte per character, because the host that has to read it has no way to decode anything else.

The wasm ends up at 354 KiB, and the host drives it by writing command batches into a buffer in wasm memory, calling wt_apply and then wt_render, and reading the output back out. No imports also means no import boundary to cross per block, and no host functions that could block.

Everything lands on a grid

Parameter changes do not take effect the instant you turn a knob. They land on a fixed 16-frame control grid, which at 48 kHz means a change is at most a third of a millisecond late. Blocks are not split at events, so the DSP loops stay tight.

Note-ons are the exception, because they are the one thing you would hear being quantised. A new voice starts on its exact frame, achieved by masking its first sub-block.

That distinction is the whole design in miniature. Quantise what nobody can hear, pay for precision only where it lands on the ear.

Showing the signal

The explainer layer needs live data, and the same real-time constraint applies to getting it out. Once per 1024 frames the worklet sends back a pooled, transferable block carrying telemetry and up to sixteen selected taps. Pooled, so sending it allocates nothing. Transferable, so it moves rather than copies.

Each block is stamped with the frame it came from. That stamp is what makes the scopes trustworthy: the FLOW page can draw sixteen nodes of the signal path and each one is showing what you are hearing now, not what the engine felt like reporting. A pen stroke in the wavetable editor reaches the speakers in 23 to 29 milliseconds, and the tap block showing it reaches the page 10 to 21 milliseconds after the stroke.

If the engine traps, the worklet re-instantiates it inside one quantum and the host resends the parameters and the held notes. You get a glitch instead of a dead synth.

Numbers, not vibes

Every phase of the build ended with numeric gates rather than a demo. Pitch within ±0.5 cent from C0 to C8. Saw aliasing at or below −60 dBc. Filters within ±0.3 dB of their analytic response. Zero-latency convolution matching direct convolution below −100 dB. The SIMD path bit-identical to the scalar one, which is the only way to actually trust a hand-vectorised kernel.

Performance is gated the same way. A typical patch costs 2.7 to 3.0% of real time against a 5% budget; a heavy one with twelve effects including a 2.6 second convolution costs 22.3 to 23.2% against 30%.

And when you exceed it anyway, a CPU guard notices. With it off, a 32-voice FM patch at 4× oversampling runs the pane at 87% load and produces 36 underruns in five seconds. With it on, it intervenes within 0.75 seconds, drops oversampling until load falls to 36%, and steps back up after ten quiet seconds.

The engine is Rust, the UI is Svelte 5, and the whole thing is BSD-3. Source is on GitHub. Press ? in the app for explain mode, or just hold a chord and turn something.