Bouncing a plugin to disk without touching the audio thread
One of the boring-looking features in Slammer is a button labelled BOUNCE. Click it, pick a path, and a 44.1 kHz / 16-bit WAV of the current sound lands on disk. Producers don’t think twice about it; they are used to bouncing out of their DAW and they expect “export one hit” to be trivial.
It is not trivial. “Click → WAV” is three or four interesting engineering decisions wearing a trench coat, and I spent about three days getting the invariants right. Here is what is actually inside.
What the button has to guarantee
The feature spec had three non-negotiables:
- It must not interrupt live audio. Hit BOUNCE while the sequencer is running and the user should hear exactly what they were hearing. No dropouts, no clicks, nothing.
- It must match what the user hears. The bounced file goes through the full live chain: voices → saturation → EQ → master compressor → transformer drive → brickwall limiter → tube warmth → master volume. Skipping any of those is wrong.
- It must be deterministic. Two clicks in a row with the same preset should produce two bit-identical files. Otherwise the button is a random-output machine and the feature quietly undermines itself.
Those three pull in different directions. (1) says stay off the audio thread. (2) says reuse the live DSP. (3) says don’t touch anything mutable that other code paths might write to. The obvious implementation, “tee off the live signal when the button is clicked”, fails at least two.
The shape that worked
I built the whole bounce on the GUI thread, using fresh instances of every DSP stage, constructed on every click:
let mut engine = KickEngine::new(EXPORT_SR);
let mut master = MasterBus::new();
master.prepare(EXPORT_SR);
let mut tube = TubeWarmth::new();
Three new objects, one small allocation per click. Then trigger the engine once at velocity 1.0 and run a per-sample loop that mirrors the live chain:
for frame in block {
let voices = engine.process(¶ms);
let comped = master.process(voices, ...);
let warmed = tube.process(comped, master_gain);
*frame = warmed * master_gain;
}
Not calling into the live DSP objects solves all three problems at once:
- Thread-safe, because there is no shared mutable state. No locks, no atomics, no coordination with the audio thread.
- Deterministic, because the engine’s internal
DriftLCG starts from a fixed seed on everynew(). Same preset in, same bytes out. - Non-interrupting, because the audio thread is still doing its own thing on its own instances. It does not know the bounce is happening.
The cost is one small duplication: the per-sample mirror loop has to stay equal to plugin.rs line for line. I guarded it with a comment (must stay in sync with plugin.rs:331..=384) and a unit test that renders the same frames through both paths and diffs the output. If the live chain drifts and I forget to update the mirror, the test blows up in CI before a build ever reaches a user.
Three subtle problems I had to solve
1. When is the tail “done”?
A kick drum has a natural end: envelope closes, noise dies, any reverb tails out. But when it ends is a choice, not a fact. Render too short and you clip the decay. Render too long and you bloat the file with digital silence that a DAW will render back in anyway.
The stop condition I settled on, evaluated per 256-sample block:
- engine reports no active voices,
- block peak is below −80 dBFS,
- compressor reports zero gain reduction,
- all of the above true for 8 consecutive blocks,
- and we have rendered for at least 100 ms (the floor).
Hard cap at 3 seconds regardless. No Slammer preset can produce a tail longer than roughly 500 ms, so 3 s is safely generous against a pathological config that somehow never goes quiet.
The 8-block hysteresis is the non-obvious part. Kick envelopes can dip through zero briefly mid-body; a single silent block does not mean the tail is over. Eight consecutive 256-sample blocks at 44.1 kHz is about 46 ms of continuous sub-audible signal, which is empirically enough to be sure.
2. Loud but not clipping
“Loud but not clipping” was a literal line in the spec, and I did not want to trust the live limiter to have hit exactly the right ceiling on every preset. So the exporter does its own final pass:
- render the whole tail into a buffer,
- find the peak,
- scale the buffer so the peak lands at exactly −1 dBFS.
That target is the linear constant 0.8912509, which is 10^(-1/20). It is the first constant in the render file, commented with exactly that arithmetic, because otherwise in two months I will read 0.8912509 and wonder what kind of cursed magic number it is.
One dBFS below full-scale is the convention for mastered one-shots: enough headroom to survive inter-sample peaks through a real DAC, not so much that the file feels quiet next to commercial samples. It is also where the master-bus limiter sits, so the bounced file lines up tonally with live output.
A 5 ms linear fade-out on the trailing edge covers the one case the silence detector can still miss: a preset whose tail dips below −80 dBFS, triggers the 8-block stop, and then bounces back momentarily. The fade brings the last sample to exactly zero regardless, so nothing clicks.
3. The write has to be atomic
The last bug I fixed before shipping was the one I was most annoyed at myself for. A crash during the file write would leave a half-written WAV at the user’s chosen path. They would double-click to audition, their DAW would choke on a truncated header, and they would blame the plugin rather than the filesystem.
The fix is old news but worth stating plainly:
fs::write(&tmp_path, &bytes)?;
fs::rename(&tmp_path, &final_path)?;
Write to path.wav.tmp, then rename. fs::rename is atomic on every OS Slammer runs on, so either the final file exists fully or the old one does. A crash in between leaves a .tmp orphan, which is debris — not a landmine.
What “bit-identical” actually costs
Two of Slammer’s boring-looking invariants are bit-identical: bypass and bounce. “Bypass is bit-identical” means disengaging the plugin doesn’t shave 0.2 dB off or add a sample of latency. “Bounce is bit-identical” means the same preset bounces to the same bytes, always, across machines and across days.
Neither invariant changes how the plugin sounds. Both took a weekend I hadn’t planned on spending. The reason they are worth it is that once you have built a plugin that respects them, you can stop worrying about them — and your users can stop blaming the plugin for problems that are not the plugin. That is the whole deal with DSP trust: it is boring until it isn’t, and you don’t get it back once it’s gone.
→ Slammer is up now. Download is free, source is open, and the BOUNCE button lives on the right edge of the SAT/EQ row.