For contributors: the automated render checks for silence, numerical failures, clicks, decay and repeatability.
As-built reference for engine/tests/audio_gates.rs, the CI gate that measures finished song audio. Established 2026-08-15.
Related: ARCHITECTURE §5.2 (the offline render path this drives), engine/tests/golden_audio.rs (the per-DSP-model hashes), engine/tests/export_endings.rs (how a render ends).
Why it exists#
Volta's recurring escaped-bug class is audible but CI-invisible: a click at a scene boundary, a mix that jumps several dB when a knob moves, an arrangement that ends and leaves the engine buzzing. Every one of those is a property of the finished audio of a whole song, and until this suite landed nothing in the test tree looked at that.
The two suites divide the space:
golden_audio.rs | audio_gates.rs | |
|---|---|---|
| unit | one clip per DSP model | a real song from examples/songs/ |
| render path | render_scene_stereo | compile → render_export_arrangement (scenes, transitions, macro automation, ring-out) |
| verdict | FNV-1a hash of the sample bits | measured statistics vs a baseline with tolerance bands |
| answers | "did this filter change?" | "does this song still sound like a song?" |
| stability | pinned to the debug profile + this machine | profile- and platform-stable by construction |
What it renders#
Twelve songs, each truncated to the first few bars of its own arrange schedule — chosen to cross at least one scene boundary, because a scene change is where the track set, the macro ramps and the level all move at once. Every song is deterministic (no live input, no record, no plugin, no neural model) and all but one is sample-free; glossolalia loads runtime/public/samples/piano.wav explicitly so the granular path is gated at all.
Coverage spans drum circuits (machinedrum, gridline), wavetable + FM (glasswing), compile-time-baked shape waveshapers (sculpt), subtractive/ladder (torsion, helix, hydraulic), physical modelling (nineteen), an FM stack (lamellae), reverb-dense mixing (monolith), a curved tempoMap over the full mastering chain (flagship) and granular + formant vocals (glossolalia). The roster and each song's reason are printed by roster_says_why_each_song_is_here.
What it measures#
Per song, over the body (bar 0 → the arrangement horizon):
Baselined — pinned in engine/tests/snapshots/audio_gates.json with a tolerance band:
| column | band | what a move means |
|---|---|---|
peak_dbfs | ±0.25 dB | a level jump, a limiter that stopped limiting |
rms_dbfs | ±0.25 dB | the mix balance moved |
lufs | ±0.30 LU | ditto, perceptually weighted (BS.1770) |
click_max | ±10 % | the largest sample-to-sample jump — a discontinuity appeared |
click_ratio | ±15 % | that jump over the body's p99 jump (scale-free) |
stereo_corr | ±0.03 | the image collapsed to mono, or flipped polarity |
tail_dbfs | ±2 dB | where the ring-out ends up |
Absolute — invariants that need no baseline, and are not blessable:
- every sample finite; peak audible (> −30 dBFS) and inside full scale;
|DC| ≤ 0.002per channel;- the local click ceiling: the worst jump inside any 10 ms window, over that window's own p99 jump, ≤ 40×;
- the ending: the ring-out decays ≥ 12 dB across itself, lands ≥ 15 dB under the body, and the file's last sample is within 1e-6 of zero;
- the stop button:
stop_allon the live engine, then the output must fall ≥ 15 dB and reach ≤ −42 dBFS within 2.5 s; - determinism: two renders of the same source in one process are bit-identical;
- cost: the render set stays under 90 s of the rendering thread's CPU time — not the wall clock, which on a machine shared with other builds measures the load (232 s wall for renders that took 26 s of CPU). Not asserted under
CI, where the runner's speed is not a property of the suite.
The click statistic, and why there are two of them#
The repo's click metric is max |Δx| judged against the signal's own motion (docs/reference/glide.md; export_endings.rs). A saw's period reset is 0.2 and perfectly musical; the same 0.2 in a sine pad is a pop.
The global ratio (click_max / p99|Δx| over the whole body) is what the baseline pins. It is sensitive but it cannot stand alone as an absolute gate: measured on torsion, a note attacking out of a near-silent passage reads 156× the body's p99, because the p99 of a sparse piece describes its silence. The local form — the worst jump inside a 10 ms window over that window's own p99, ignoring windows whose largest jump is under 0.01 — reads the same event at 4.7×, and is therefore the one that carries an absolute ceiling. Measured when the suite landed, the set's worst is 18.3× (gridline, a drum transient at 7.49 s); median 2.7; the ceiling is 40×. Two songs (monolith, lamellae) have no jump anywhere over 0.01 and so report 0 — their click gate is the baselined click_max alone, which is the honest answer for a piece with no transients in it.
Hunting a click by hand#
The same statistic, by hand: render offline and rank 10 ms windows by max |x[n]−x[n−1]|, printing the median and p99 beside it. A raw saw's own reset edge is ~0.05–0.13, so the absolute number means nothing: a click is max ≫ p99, and after a real fix max ≈ p99. Print the exact sample index, not a rounded time, and prepend silence — a voice starting on frame 0 hides its note-on step. source = const 1 makes the waveform BE the amp envelope, the cleanest envelope probe, but the native per-voice DC blocker reads a DC probe as ~0, so use sine natively; any stateful oracle must likewise stay distinguishable after the output filters settle (a DC-blocked ramp can hide a reset). Check both backends separately — they click for different reasons — and confirm each finding by reverting the fix and re-measuring: a regression test that passes on the old code proves nothing. Bugs mask each other; a fix that makes a neighbouring case worse suggests a second bug, not a revert.
The ending, and why "silent" means "decaying"#
render_export_arrangement stops scheduling at the horizon, release_alls every sounding voice, and rings out. stop_all (the stop button) instead re-anchors the transport and cuts every voice on the lifetime gate's 256-sample (5.3 ms) fade — a declick, not a release: the hard drop it replaced stepped 4.7× the note's own p99 at the stop sample (engine/tests/stop_declick.rs) — but in both cases the track and bus effects keep ringing, and a bus reverb or a ping-pong doing so is correct. So the gate is the shape the bug class actually has: a flat post-stop level is always a bug; a decaying one is music. Both gates therefore assert decay plus an absolute floor, not instant digital silence.
It is not vacuous#
Mutation-tested against the engine when it landed (2026-08-15). Each mutation was applied to engine/src/, the gate run, then reverted:
| mutation | result |
|---|---|
MASTER_GAIN 0.7 → 0.73 (+0.37 dB) | caught — peak_dbfs / rms_dbfs / lufs on all twelve songs |
MASTER_GAIN 0.7 → 0.72 (+0.24 dB) | not caught — the ±0.25 dB band is the detection threshold, as designed |
PatternMod anti-pop glide 5 ms → 0 (a per-step click) | caught — hydraulic peak +0.71 dB |
offline::FINAL_FADE_SEC → 0 (no closing fade) | caught — the end-of-file step gate, on 9 of 12 songs |
EDGE_FADE 256 → 1 (no end-of-voice declick) | not caught — no song in the set ends a voice on the lifetime gate rather than an envelope release; glide_retrigger.rs covers that path |
Blessing#
UPDATE_SNAPSHOTS=1 cargo test -p volta-engine --test audio_gates
rewrites engine/tests/snapshots/audio_gates.json from the current engine, and prints the table first. Review it, decide the move is intended, listen if it is a DSP change, then commit the new baseline together with the change that caused it. Two rules:
- The profile does not matter. Unlike
golden_audio, blessing in debug and in--releaseproduces a byte-identical baseline — measured 2026-08-15 to nine decimal places on every column of every song. (volta-engineis built atopt-level = 3in the dev profile too, per the rootCargo.toml, so there is barely a profile difference to forgive in the first place.) - The absolute gates are not blessable.
UPDATE_SNAPSHOTS=1still fails on them and writes nothing: a baseline records what the audio is, and those constants say what it must never be.
Adding a song: add a row to songs(), say in its why which feature family it buys, bless, and check the suite's printed runtime.
Where it runs#
cargo test -p volta-engine --test audio_gates, so it rides make test-core (cargo test --workspace), CI's Full native gate, and make gate-smart whenever a change can reach volta-engine. It is also its own step in CI's Rust fast gate and in make test-fast — the PR-sized signal — because a gate that only fires after merge does not gate anything, and the bug class it exists for is exactly the one review misses. Keep the CI job and make test-fast in step with each other.
Measured cost (Apple-silicon Mac, debug, when the suite landed): 245 s of rendered audio in ~27 s, ~9× realtime; the whole suite, all five tests, in ~33 s. Every run prints its own figure ([audio gates] … s of audio in … s of CPU).