Prepare a finished mix for export, with controls for dynamics, loudness, peak level, format and the final tail.
As-built reference for the shipped Mastering and delivery stages. Design + full stage list: Mastering and delivery.
Stage 1 — the true-peak limiter#
mixdown = play pad chords |> limiter -- −1 dBTP, 100 ms
club = play pad chords |> limiter (-0.3dB) 40ms
Also a bus and master insert:
mixer {
master = { inserts = [limiter -1dB 80ms] }
}
Ceiling, timing and motion#
The always-on device safety limiter remains separate from the authored limiter insert. The insert uses a stereo-linked 4× true-peak detector, 2 ms lookahead and a 0.5 dB internal margin. Its attack is the fixed lookahead window; release controls recovery. Each delayed frame retains its own gain requirement. The minimum over the raised-cosine attack window bounds that frame's output sample without depending on the release smoother. A master limiter (-1dB) therefore peaks near −4.6 dBFS in an analysis render (measure_audio, take-diff): its ceiling less the margin, then the 0.7 (−3.1 dB) output gain Mixer::write applies after the inserts.
Ceiling and release accept composed control expressions on tracks, returns and the master. They smooth over 5 ms; release coefficients update every 16 samples. Ceiling requests clamp to −60..0 dB, release to 1..2000 ms. A literal ceiling above zero remains a compile error. Invalid live values hold the prior finite value. The applied ceiling belongs to the frame emerging from lookahead, so it can differ from the requested ceiling. Latency stays 2 ms.
pressure = lfoSine 0.2Hz |> scale [0, 1]
mixer { master = { inserts = [limiter (-12dB + pressure * 11dB) (40ms + pressure * 160ms)] } }
The sample bound does not guarantee a reconstructed intersample peak: the limiter's changing gain can create peaks its input detector did not see. The existing dense-HF test reports 57 of 240 realtime prints above a −1 dBTP target, with a worst +0.502 dB breach. The realtime margin remains 0.5 dB. For delivery, offline::limit_to_ceiling measures the rendered result at 8×, corrects the target against the original audio and verifies again. That export path, rather than the realtime detector alone, enforces the delivered ceiling.
Use |> limiter, |> limiter (-3dB) or |> limiter (-3dB) 60ms. The default is −1 dB with 100 ms release. At very low sample rates the attack window can be shorter than the detector history; the window then becomes a plain minimum with no attack shaping.
Integration#
- Latency — the lookahead is reported through
EffectState::latency, so PDC compensates it and a limiter on one parallel branch can't phase-smear the mix. This is why the whole output is delayed rather than mixing a delayed wet path against a dry one: a real, compensable delay beats a smeared one. - Gain reduction — reported like the compressor family, so the master meter shows it.
- Bypass — the lookahead line holds up to 2 ms of undelivered audio, which is a replayable tail, so engaging bypass clears it (the mixing §3.3 rule).
Tests#
engine/tests/limiter.rs — the invariants, measured:
- the ceiling holds in true peak, on a probe engineered to overshoot between samples, asserted with the same BS.1770 measurement the spec means (and the test first asserts the probe really does overshoot, so it can't pass vacuously);
- the ceiling holds across settings (−0.1 / −1 / −3 / −6 dBTP);
- below the ceiling it is a null — max |Δ| < 1e−6 against the input, offset by the lookahead, so quiet passages are provably uncoloured;
- the attack is a ramp, not a step — on the audit's own transient probe the gain trace's max per-sample step stays under the ramp's own structural slope and no output sample exceeds the ceiling, the two properties together, at 44.1 / 48 / 96 kHz (
the_lookahead_attack_is_a_ramp_not_a_step,the_ramped_attack_leaves_no_pre_clickon both a 300 Hz and a 40 Hz bed, inengine/tests/mastering_fx.rs; the unit testthe_limiter_slope_bound_tracks_the_sample_rateinengine/src/effect.rspins the bound itself to π/2-over-fade); - the ceiling bound is exact, not within-the-margin — over 24 adversarial signal × release combinations no emitted sample sits above the internal target at all (
the_limiter_ceiling_bound_is_exact_for_every_emitted_sample); - gain reduction is reported (and reads ~0 when not limiting);
- the gain releases back to unity after a burst;
- latency is reported for PDC;
- end to end through a real song — compile → scheduler → voices → strip → chain → master — with the ceiling still held at the master bus;
- a positive ceiling is a compile error whose message teaches the paren form.
The ceiling is also re-measured at 8× — twice the detector's own oversampling, so it sees exactly what the detector cannot — on the two probes that used to breach it (fs/4 tone under hard drive, dense inharmonic HF programme); that is what TP_MARGIN_DB was sized against, and the_ceiling_holds_when_re_measured_at_eight_times is that measurement, with an 8× interpolator built inside the test (32 taps/phase, Blackman-Harris, interior-only) rather than borrowed from the engine, so it cannot share the detector's blind spot. §1.2 point 4 carries the numbers, the swept exposure that one probe understated, and why the guarantee lives on the export path instead of in a wider margin. (Earlier revisions of this page, and of effect.rs, credited those measurements to a dynamics_quality.rs that has never existed — audit §B10. The living tests are engine/tests/limiter.rs and engine/tests/mastering_fx.rs.)
Stage 3 — loudness-targeted render + report#
Print this at −14 LUFS integrated with a −1 dBTP ceiling, and show me the report.
The native export dialog opens with target OFF (an untargeted print under the −1 dBTP ceiling), so a quiet sketch is never silently gained up and limited; ticking target turns on −14 LUFS, and −16 / −9 (or a dragged value) override it. The CLI exposes the same policy as --target-lufs <LUFS|off> and --ceiling-dbtp <DBTP>. The print then carries a solve report. The ceiling control stays visible and active when targeting is off: an untargeted master is still a delivery print, not an unclipped raw render.
Browser export currently uses a fixed 16-bit WAV preset with a two-second tail and −1 dBTP ceiling, without a loudness target or embedded source archive. Its dialog hides unsupported delivery controls. Current-source and dependency snapshot rules for both hosts: Mixing tracks and buses §5.2.
target -14.0 → -14.0 LUFS · +8.3 dB · limited 2.1 dB · cost 1.2 LU · 3 passes
limited is the worst instantaneous gain reduction; cost (loss_lu) is the integrated loudness the limiter took back — the gain applied minus the loudness it bought, gain_db − (achieved − source). The quiet sections keep all of the gain and the loud ones lose cost and more, so it is the number that says how much contrast the print gave away.
A loudness-loss budget#
Without a budget the solve meets the target whatever the limiter costs: each correction adds gain the limiter partly takes back. On a mix whose loud sections have a high peak-to-loudness ratio (PLR, true peak − LUFS) that is several LU off every drop — 4.5 LU on one measured cinematic song at −14. offline::apply_loudness_target_budgeted(…, Some(max_loss)) caps it: when the target needs the limiter to take more than max_loss LU, it lowers the gain instead (a bracketed secant search between a gain that puts the render's 8× true peak on the ceiling and the unbudgeted solve), keeps the loudest gain whose loss fits within 0.1 LU, and reports budget_limited: true, met: false and the shortfall (shortfall_lu). A target that fits the budget solves exactly as without one, and no budget is the default everywhere — the same entry point, bit for bit.
The budget is --max-loss <LU> on render-manifest and on volta export stems, max_loss on the MCP export_song / render_audio (whole song), and ExportOptions::max_loss_lu in the native export (the dialog has no control yet; its report prints cost and loss budget hit).
render-manifest also reports per-section loudness before and after: for each marker (else each arrangement step) the raw render's LUFS, 8× true peak and PLR, the delivered print's, and gain_kept_db / limiter_took_db — so "the limiter took 4.8 dB out of every drop" is read off the sidecar, not inferred. It warns when every section lies within 2 LU (offline::FLAT_SECTION_SPREAD_LU), raw or printed: a flat form, the signature of a master clipper or of the print's limiter. How to lower a drop's PLR instead: The production handbook § "Gain staging for a print".
Why this is a solve and not a guess#
A realtime DAW makes hitting a target an iterated human loop: pick a gain, print, measure, adjust, reprint. Volta's render is deterministic and already in memory, so it is a numerical problem: measure, correct, verify. The report is the point — the print becomes a claim that can be checked.
offline::apply_loudness_target iterates rather than closed-forming, because the two constraints are coupled:
- Loudness is not exactly linear in gain. BS.1770's absolute −70 LUFS gate means quiet material moves in and out of the measurement as the gain changes, so a computed correction lands close but not on.
- The ceiling limiter reduces loudness, so engaging it undoes part of the gain that made it necessary.
Each pass re-measures (and re-limits) a buffer already in memory — no re-render — so convergence is cheap. Typically 2–3 passes; the cap is 12.
The rules that keep it honest#
- Every pass works from the ORIGINAL render. Re-limiting an already-limited buffer is a different (and worse) processor, so limiting must never compound across passes.
- The correction converges from below when limiting is engaged: more gain buys less loudness, so residual correction under-shoots rather than oscillating.
- The target never overrides the ceiling. Pushing to an aggressive target engages more limiting; it does not raise the ceiling.
- The solve happens before bit-depth quantization, so dither is applied to the final level rather than encoded at the wrong one.
- Silence is returned untouched and reported
met: false. There is no gain that makes silence −14 LUFS, and amplifying it into noise would be worse than saying so. A print shorter than one 400 ms gating block is solved, and labelled. BS.1770 defines no integrated loudness below one gating block, and the meter used to report that emptiness as
SILENCE_FLOOR— so a 0.30 s one-shot asking for −14 LUFS measured −100.00 LUFS, gotgain_db: 0.0, iterations: 0, met: false, and came back untouched (audit §B11). The meter now falls back to an ungated short-window mean-square for that case alone (a long render whose blocks all fall under the −70 LUFS absolute gate is still silence), which is exact and linear in gain, so these solve in a single pass. The report setsshort_ungatedand the export dialog, the CLI'sexport stemsreport and the browser's all printLUFS (ungated, <400ms)rather than passing the figure off as LUFS-I: it is a measurement of the material, not an integrated loudness in the sense a delivery spec means.The denominator is the buffer, not the sound. An ungated mean-square averages over everything the buffer holds, so trailing silence pulls the figure down — the same 60 ms burst measures −13.24 LUFS in an 80 ms buffer and −20.22 in a 399 ms one, a 7 dB spread that says nothing about the material. Gating is what normally protects a measurement from its own silence, and below one gating block there is none; trim a one-shot to what it plays before reading anything into its level.
- A readout that clamped the meter says so. Above +24 LUFS the histogram's top bin catches everything (audit §B9), so the integrated figure becomes a lower bound:
LoudnessReport::saturated/ExportRender::lufs_saturated_blockscarry that through to the dialog, which printsLUFS ≥ (meter saturated)in the alert colour rather than a number it does not have. - Stems are not individually targeted. Each would get its own gain and the set would stop summing to the master. A stem's job is to reproduce the mix elsewhere.
Tests#
engine/tests/loudness_target.rs — quiet material brought up and loud brought down, both landing inside the 0.1 LU tolerance; the ceiling held under an aggressive −8 LUFS target (with limiting reported); a gentle target reporting no limiting; idempotence (re-targeting an already-targeted render is a no-op — a solver that drifted would make a second export disagree with the first); silence left alone; mono; and all four common targets (−9/−14/−16/−23) hit exactly with the ceiling held. The loss budget: without one a peaky probe (a quiet body under a loud short hit, PLR > 15 dB) meets −12 and reports its loss_lu, and None prints bit-identically to the old entry point; with budgets of 0.5 / 1 / 2 LU the loss stays within 0.1 LU of the budget, the print lands quieter and says so; a target inside the budget is untouched by it. Per section: a quiet intro keeps all the gain while a peaky drop keeps ≥ 2 dB less, and the spread ignores silent and ungated sections. These are synthetic signals, not music. Plus app-native/src/export.rs::a_targeted_export_hits_the_target_and_reports_it end to end through the real export path.
Stage 2 — mastering dynamics & stereo tools#
ott amount [depth] — compression in both directions#
Every other dynamics processor in the engine is downward only: it turns loud things down. The move that makes a mix sound finished is the other one — upward compression, turning quiet things up until a band's range sits still. mbcomp cannot do it, and there was no way to spell it.
Same Linkwitz-Riley split as mbcomp (200 Hz / 2 kHz); each band runs an OttBand with:
- Two detectors, deliberately. Downward gain must react fast to catch transients; upward gain must react slowly or it pumps the noise floor between hits. One shared envelope cannot do both.
- Thresholds that move in opposite directions with
amount— down goes lower (catch more peaks), up goes higher (lift more quiet material). Moving both down means the knob does less upward work the further you turn it, which is what the first implementation did until a test measured it. - A cap on upward gain. Without it, a silent band's floor is lifted into audible hiss — the classic upward-compression failure.
depth (default 1, literal 0 a true bypass) scales how far each band's gain travels from unity — upward compression at full strength is an effect rather than a polish, so the useful settings are usually partial. It is not a wet/dry blend, and that distinction is the whole of it: an LR crossover's band sum is not the input but the allpass P(−jω)/P(jω) (Zavalishin §10.8 eq. 10.27) — flat in magnitude, −180° at each crossover — so mixing it against undelayed dry is a comb whose null sits exactly on 200 Hz and 2 kHz. depth 0.5 measured −23.05 dB at 200 Hz and −42.10 dB at 2200 Hz, worst −61.9 dB at 2190 Hz: the knob deleted the low-mid fundamentals and the presence band from the record. Applying depth to the gains instead (g' = 1 + (g − 1)·depth, bands always summed at full weight) gives the same musical control with the reconstruction left intact, flat to 0.12 dB at every depth. The blend is in linear gain, not dB, so intermediate depths keep more of the upward half than the downward half (depth 0.5 at amount 0.6: upward keeps 60 % of its dB travel, downward 29 %) — ride amount if you want both directions to shrink together. The GR meter reads the depth-scaled gains, so a half-depth ott no longer reports full-depth numbers. (Audit 2026-08-03 §B1. mbcomp, deess and monoBelow were checked for the same topology and are clean: each sums its bands at full weight with no dry path.)
Depth accepts composed sample controls. At a live zero, unity band gains keep the crossover and detector histories running; it is phase-rotated unity, while a literal zero retains the historical exact bypass. A moving low anchor or monoBelow crossover also preserves its filters: 5 ms smoothing, with a 16-sample coefficient grid for the LR4 crossover and sample updates for width.
width amount [lowAnchorHz] — the side-channel anchor#
width's optional second argument highpasses the side channel only, so the bottom of the image folds toward the centre while the top keeps its spread — the gentler sibling of monoBelow's hard crossover. 100–250 Hz is the useful range; 0 (absent) is no filter. Full description in The effects reference § width.
monoBelow freq — the elliptical EQ#
Everything below the crossover sums to mono; the rest of the image is untouched. Out-of-phase bass makes a cutting lathe skip and a club sub-array cancel, so this is the club/vinyl non-negotiable.
The split is Linkwitz-Riley, so the reconstruction is flat: this is a phase change, not a tonal one, and already-mono bass passes through at level. The crossover clamps to 20 Hz–1 kHz — above that it stops being an elliptical EQ and starts being "make the record mono".
vs toVinyl. toVinyl midHz sideHz (The effects reference) is the other elliptical EQ: it high-passes mid and side at separate corners through a 26-stage staggered cascade instead of collapsing them at a crossover. Measured on hard-panned 41 Hz bass, toVinyl 22Hz 120Hz takes the side/mid ratio to 0.18 — narrow, never zero — over a wide transition, where monoBelow 120Hz takes it to exactly 0 with a crossover edge. Reach for monoBelow when the cutting spec needs a guarantee; reach for toVinyl when you want the narrowing to sound like a record. They compose: |> toVinyl 22Hz 90Hz |> monoBelow 40Hz.
decorrelate amount — width that survives a mono fold#
Widening by boosting the side signal only works when there is one; on a near-mono source it does nothing. This instead decorrelates the channels with a 90°-phase-difference (Hilbert) polyphase-IIR pair — two allpass branches A₀/A₁ that stay a quarter cycle apart across the whole audio band:
mid = ½(L+R) side = ½(L−R)
L' = A₀(mid) + [A₀(side) + amount·A₁(mid)]
R' = A₀(mid) − [A₀(side) + amount·A₁(mid)]
Combining two unit-magnitude allpasses in quadrature makes both consequences frequency-independent, which is the whole point. For a correlated (mono) source, inside the 90° band:
- each channel is exactly
√(1+amount²)— flat, and identical in both speakers. Measured ±0.011 dB over 40 Hz–16 kHz at every amount; - the L/R correlation is exactly
(1−amount²)/(1+amount²), i.e. 0 atamount 1(0.8824 / 0.6000 / 0.0000 at 0.25 / 0.5 / 1, measured); - the impulse response is short — 90 % of its energy inside 16 samples (0.33 ms), so a transient stays a transient.
Both qualifiers are load-bearing, and the next two sections say why.
The mono fold: ½(L'+R') = A₀(mid), computed once. A₀ is an allpass, so the fold is magnitude-exact — a mono collapse reproduces the input's spectrum at level with the added side content cancelling completely (pinned within 0.01 dB per FFT bin, 20 Hz–20 kHz, at every amount, on both a mono and a hard-panned source). It is not sample-exact: A₀ is on the direct path by construction, because quadrature is a relation between two branches and neither can be left as a bare wire. The same is true at amount 0 — the channels stay bit-identical to each other, but both have been through A₀.
The pair's coefficients come from the same designer as the halfband decimator's (hiir PolyphaseIir2Designer, read out by its PhaseHalfPi structure rather than Downsampler2x): 12 coefficients, transition bandwidth 20/48000. The coefficients are in normalised frequency, so the 90° band's lower edge is fs/4800 and scales with the sample rate — 9.2 Hz at 44.1 kHz, 10 Hz at 48, 20 Hz at 96 — up to its mirror below Nyquist, with the phase difference within 0.11° of 90° across it.
What it does to already-stereo material#
amount only ever scales A₁(mid); the side path is A₀(side), which the knob never touches. So the two laws above are the mono-input case, and the effect does progressively less as the source is already wider. Measured on white noise at amount 1:
| input | L/R correlation | channel gain |
|---|---|---|
correlated (L = R) | 0.000 | +3.01 dB |
| uncorrelated | −0.333 | +1.75 dB |
anti-correlated (L = −R) | −1.000 (unchanged) | 0.00 dB |
The last row is exact, not approximate: L = −R means mid ≡ 0, and there is no mid to take the quadrature of, so decorrelate is a complete no-op at every amount on pure side content. Pinned in engine/tests/stereo_interp.rs::decorrelate_on_stereo_input_is_scoped_to_the_mid.
Below the 90° band it becomes a balance control#
As frequency → 0 both branches tend to +1, so the quadrature collapses to 0° and the law degenerates to L = mid·(1+amount), R = mid·(1−amount). At amount 1 a DC input arrives entirely in the left channel — measured, 0.5 DC in gives L 1.000000 / R 0.000000. The imbalance decays fast up the band, and because the band edge scales with the rate, so does the recovery:
| 1 Hz | 2 Hz | 5 Hz | 10 Hz | 20 Hz+ | |
|---|---|---|---|---|---|
| 44.1 kHz | +11.7 dB | +6.2 | +1.0 | −0.01 | ±0.01 dB |
| 48 kHz | +12.4 dB | +6.8 | +1.3 | +0.02 | ±0.02 dB |
| 96 kHz | +18.3 dB | +12.4 | +5.2 | +1.3 | ±0.02 dB |
This is infrasonic, and it is deliberately not patched. Highpassing the amount·A₁(mid) injection to remove it was built and measured and is strictly worse: the imbalance is Re(H·A₁/A₀), and in band A₁/A₀ = −j, so Re(…) becomes Im(H) — any causal minimum-phase highpass leaks its own phase error straight into the audio band. A 20 Hz corner measured +2.13 dB of imbalance at 40 Hz and +0.86 dB at 100 Hz, and it raised 5 Hz from +1.3 to +7.7 dB; even a 1 Hz corner costs +0.10 dB at 40 Hz. Filtering the common path cancels out of the L/R ratio and fixes nothing. If a bus genuinely carries DC, DC-block it upstream — decorrelate is the wrong place to do that silently.
Phase, headroom, and parallel paths#
A₀ is magnitude-transparent but never phase-transparent, and at amount 0 that is the only thing the effect does. Three practical consequences, all measured:
- Group delay rises steeply into the bass — 0.053 ms at 10 kHz, 0.388 ms at 1 kHz, 3.66 ms at 100 Hz, 8.49 ms at 40 Hz, 15.0 ms at 20 Hz. On its own this is inaudible (a smooth allpass, not a delay).
- Do not put it on one leg of a parallel path. Sum a decorrelated bus back with a correlated dry twin and the group delay above becomes a comb: −3.29 dB at 40 Hz against the dry sum, with nulls to −78 dB up top. Inside a single serial chain there is nothing to comb against and this does not apply — but a parallel "dry + widened" blend is exactly the move to avoid.
- It moves peak more than RMS. The
+3.01 dBatamount 1is an RMS figure; scrambling phase changes the crest factor, so the peak moves further and by a material-dependent amount. Measured peak change: +3.0 dB on a sine, +7.1/+8.1 dB on a limited-master fixture, +10.9/+10.1 dB on peak-normalised white noise. And even atamount 0, where the RMS change is exactly zero, the peak moved +0.0 / +4.5 / +6.1 dB on those same three. Leave headroom on that basis, not on the RMS number, whendecorrelatesits ahead of a limiter.
Four bugs the measurements caught#
Worth recording, because every one of them was inaudible-by-inspection, one was completely silent, and two shipped:
- The widener did nothing at all. An early version used the same coefficient set in opposite orders in its two chains. An allpass cascade is LTI and therefore commutative, so both channels got an identical response and the difference — the entire effect — was exactly zero.
- First-order allpasses were the wrong primitive. Even with genuinely different coefficients, their phase barely moves across the musical range at 48 kHz, so the chains differed by too little to hear.
- Synthesizing the side from a difference of two allpasses is not an allpass (2026-08-03 DSP audit §A2). The delay-based (Schroeder) chains that replaced #2 were each flat on their own, but
1 ± a·½(H_l − H_r)is not: measured on the shipped code,amount 1combed each channel down to −47.9 dB at 5014 Hz (9.5 % of 40 Hz–16 kHz more than 6 dB down, 0.7 % more than 20 dB), left the L/R correlation at 0.37, and gave every transient a 65 ms tail — a hollow, reverby kick in either speaker alone while the mono meter said everything was fine. The Hilbert pair above is the fix; DAFx-17 paper 96 §4.2 asks for exactly this ("as spectrally flat as possible while randomizing the phase"), and §1's four criteria are now all met rather than one. - The knob's "off" left the state frozen (§A9).
processearly-returned atamount ≤ 0without ticking its filters, so bringing adecorrelatemacro back up from zero replayed up to 65 ms of the last open passage — −4.2 dBFS peak out of a silent input. The branches now run at every amount, including 0, so a silent input can only ever produce a silent output. It costs 18 first-order sections per sample on a bus, which is not a number worth protecting.
A fifth failure was the test's fault, not the DSP's: comparing whole-channel RMS across two different frequencies conflated the band that should fold with the band that shouldn't, so it would have passed or failed for the wrong reason. Each band is now probed on its own.
Tests#
engine/tests/mastering_fx.rs — ott lifting quiet material, taming loud material, and (the number that means "OTT") narrowing the range between them; depth 0 a bit-identical null; the crossovers flat to 0.5 dB at every intermediate depth (the §B1 comb guard, swept 150–270 Hz and 1850–2400 Hz at amount 0, where every band gain is exactly 1 and only the sum topology is under test); and depth still narrowing the range — and moving the GR meter — monotonically. monoBelow centring hard-left bass, leaving hard-left treble where it was, and passing already-mono bass at level. decorrelate widening a mono source, folding to mono at level, and adding no side at 0. Plus the language surface: all three lowering in chain order with their defaults, and the crossover clamp.
engine/tests/stereo_interp.rs pins the widener's four measured properties directly (2026-08-03 audit §A2/§A9): per-channel flatness at √(1+a²) across 40 Hz–16 kHz to ±0.05 dB, the two channels matched to 0.05 dB, the correlation law (1−a²)/(1+a²) to 0.01, 90 % of the impulse energy inside 32 samples, the mono fold magnitude-exact per FFT bin on both a mono and a hard-left source, and a knob closed to 0 for a second of silence and re-opened producing nothing above −120 dBFS.
engine/tests/golden_audio.rs pins the sound itself: ott_full_depth is the default-depth render, unmoved by the depth rework (bit-identical hash across the fix), and ott_half_depth the partial-depth render the comb used to hollow out (+3.46 dB RMS once the crossovers came back).
Stage 4 — delivery#
The export dialog now picks a format (WAV / AIFF), takes title and artist, and sets the ring-out — a tail in seconds (master, plus an extra for stems) or auto, which renders until the tail has actually decayed.
What every print guarantees#
Three properties hold for any export, targeted or not — the desktop dialog, volta export stems, and the browser workbench's export all go through the same seam. All three were repairs from docs/notes/dsp-quality-audit-2026-08-03.md §B2/§B3/§B7.
The report describes the file. ExportRender.lufs_integrated / true_peak_max are measured over the render's own finished buffer (offline::measure_loudness, in finish_render), not read off the engine's master meter. The engine's meter used to tap before Mixer::write — before the 0.7 output gain, the safety limiter and the soft clip — so the printed report was 3.1 LU hot on gentle material and up to 7.5 dB hot on true peak once the limiter engaged. Loading the file in any BS.1770 meter disagreed with the dialog. (The live meters moved too; see mixing.md §3.)
An untargeted print is held at −1 dBTP. Nothing in the realtime master chain is true-peak aware: soft_clip and the final clamp bound samples, so a bright, dense mix can sit at −0.03 dBFS and still reconstruct at +1.5 dBTP — which is exactly what an AAC or Opus transcode then clips. A print with no loudness target now runs ExportRender::apply_print_ceiling (offline::DEFAULT_CEILING_DBTP = −1.0, the same BrickLimiter a targeted print uses) and re-measures. Two deliberate exemptions:
- Targeted prints are untouched here —
apply_loudness_targetalready solves loudness and ceiling together, from the unlimited render, and pre-limiting would make it a solve over a different signal. - Stems are untouched — see What a stem set sums to below, which states the consequence rather than the (previously inverted) excuse.
- Analysis renders are untouched — a render that is measured instead of delivered skips the whole delivery stage. The desktop take-diff (
app-native/src/export.rs::run_pair) analysesrender_export's buffer directly; the browser reaches the same path throughWasmEngine::export_wav_raw, whichstudio-host.js::renderPairand the conflict scan's per-track stems use. Measuring through the delivery chain measures the limiter: the browser's A/B null on an unchanged pair read −61.4 dB against the desktop's −126.9 dB, and a level edit came back smaller than it was — ~5 dB on a real song, 2.1 dB on the engine-side reproduction (a −5 dB edit nulls at −7.24 dB raw, −9.32 dB through the delivery chain). (export_wav— the download — keeps the ceiling and the dither../build-engine-wasm.shmust be re-run for the browser to see the split.)
The realtime Mixer is deliberately not involved: the fix lives on the export path only, so live playback and every golden-audio hash are unchanged.
Where the ceiling actually starts working: −1.5 dBTP. The BrickLimiter builds its gain against ceiling − TP_MARGIN_DB (0.5 dB, engine/src/effect.rs), never against the declared ceiling itself — the 4× BS.1770 detector systematically under-reads the true inter-sample peak (an external 8× measurement reads up to ~0.3 dB higher than the shipped detector), and the limiter's own time-varying gain multiplies new content in after the detector looked. So "a bit-exact no-op below the ceiling" means below −1.5 dBTP, not −1.0: bisected on the HF-dense fixture, first touch is at exactly −1.5000 dBTP; a −1.51 dBTP print comes out bit-identical, a −1.49 dBTP one gets 0.008 dB of reduction and prints at −1.500 dBTP. The half dB is the price of a ceiling you can rely on; the alternative is a second 8× detector stage in the hot loop.
What a stem set sums to#
Stems are printed raw — float32 by default, or quantized at the explicitly selected depth — but not held to the delivery ceiling — and it is worth being exact about which way that cuts, because this document used to have it backwards ("ceiling-ing each stem would stop the set summing to the master"). The truth:
- A raw stem set sums to the un-ceiling'd mix, not to
master.wav. On a mix whose master path stays linear the reconstruction is exact: Σstems − mix measures −140 dB relative to peak. master.wavalone carries delivery processing. The ceiling limiter is a time-varying gain applied to the summed master, so once it engages nothing the stems do can follow it. Measured on an HF-dense two-track fixture, Σstems − master goes from −17.8 dB to −7.6 dB rel. peak when the ceiling is applied — ~10 dB of divergence that is the limiter's, and only the limiter's.- Ceiling-ing the stems would not repair that; it would add a second, differently-timed nonlinearity per stem on top. So stems stay raw, which preserves the mix the mixer produced — which is what a stem set is for (re-mixing, re-mastering, hand-off to a mastering house).
- The remaining −17.8 dB in that measurement is not the ceiling: the mixer's own safety limiter and soft clip are nonlinear too, and they see a different signal for one track alone than for the sum. A stem set reconstructs the mix exactly only while the master path stays linear.
There is deliberately no per-stem ceiling switch. If a session needs the mix the stems do sum to, that file is the sum of the stems.
cli/src/interchange.rs::stems_sum_to_the_unceilinged_mix pins all of the above.
If a stem set is explicitly exported at 16-bit, the dither is the same stream in every file. It is fixed-seed by design (reproducibility), so over passages where a stem is digitally silent its dither is not independent — summing an *N*-stem set stacks N identical noise sequences coherently, +20·log₁₀ N (≈ +18 dB on an eight-stem set) rather than the +10·log₁₀ N of independent noise. It sits around −93 dBFS per file. The default float32 stem set has no quantization or dither floor.
Every delivered 16-bit file is dithered. offline::encode_wav takes a dither: bool and the choice tracks what the bytes are for:
| bytes | dither | why |
|---|---|---|
bounce cache (~/.volta/bounces) | no | an intermediate that is decoded straight back into the sample bank — dithering would fold a fresh noise floor in per generation |
raw ExportRender.wav as rendered | no | not yet a delivery; pinned hashes are taken on it |
any file the user keeps (encode_print, encode_wav_as(Int16)) | yes | ±1 LSB TPDF, fixed-seed LCG |
volta export stems used to write the undithered quantizer straight to disk while the desktop dialog dithered — the same render, 22–25 dB more correlated (harmonic) error in the CLI's bytes. Measured on a 750 Hz tone, harmonic sum relative to the fundamental:
| tone level | undithered | TPDF |
|---|---|---|
| −70 dBFS | −33.2 dB | −56.1 dB |
| −80 dBFS | −23.5 dB | −47.2 dB |
| −90 dBFS | −11.2 dB | −35.9 dB |
The dither stream is fixed-seed, so dithering costs nothing in reproducibility: a re-export of the same render is byte-identical, and the CLI and the dialog produce the same 16-bit bytes for the same samples.
Delivery-depth defaults are contextual. The desktop dialog opens at 24-bit. volta export stems writes a 24-bit master and float32 stems by default: the former is a conventional mastering deliverable, while the latter preserves the raw buffers a downstream mixer needs. --bit-depth {16,24,float} explicitly overrides both kinds of file. This choice is made after rendering and loudness solving through encode_wav_as; raw ExportRender.wav, analysis renders, and bounce caches retain their existing 16-bit-undithered intermediate semantics.
One deliberate exception: cargo run -p volta-engine --example peakprobe keeps writing the raw render (undithered, no ceiling). It is a measurement probe, and a probe that quietly limited would hide the overshoot it exists to find.
AIFF#
Hand-rolled, no new dependency: AIFF is the same chunked shape as WAV with the bytes the other way round. FORM/COMM/SSND, big-endian throughout, and the sample rate stored as an IEEE-754 80-bit extended float — the format's one genuine oddity and the easy thing to get wrong, so it is decoded back in a test rather than assumed.
- 16-bit and 24-bit write plain AIFF; 16-bit draws from the same
offline::tpdf_ditherstream and the samequantize_i16as the WAV path, so a 16-bit AIFF and a 16-bit WAV of one render are the same audio in two containers — sample for sample. That was the claim from the start, but until the Audit-2 #6 review (D5) the AIFF branch rolled its own 32-bit LCG: the same TPDF distribution, a different sequence, and 45.8% of samples differed between the two containers (by up to 2 LSB). Unifying the stream leaves the WAV bytes untouched and both encoders byte-deterministic. - 32-bit float writes AIFC with the mandatory
FVERand anfl32compression type, because plain AIFF has no float encoding.
Externally validated: macOS afinfo — a decoder that didn't write the file — reports all three correctly (AIFF/AIFC, 2 ch, 48000 Hz, the right depth and endianness). engine/tests/delivery.rs::dump_files_for_external_validation is #[ignore]d and writes the probe files on demand.
Metadata#
WAV gets a LIST/INFO chunk (INAM/IART/IPRD/ICMT); AIFF gets NAME/AUTH/ANNO. Both fix the container size after appending, so the file stays parseable, and both omit empty fields rather than writing blank tags.
The vlta provenance stamp (Versions, history and comparisons) is a RIFF chunk, so WAV carries it as before; on AIFF the content hash rides in the ANNO comment instead.
Marker cues#
A correction to this spec's own earlier claim: append_marker_cues was not uncalled — render_export and render_export_arrangement both call it. The real gap was narrower and worse: the desktop export re-encodes at the chosen bit depth, rebuilding the container and silently dropping the cues the engine had already written. Every delivered file above 16-bit was losing its section markers. That path now re-attaches the render's own ExportRender::cues, which are placed through its varispeed map; marker_cues_for remains for a caller holding only the module (it assumes an un-retimed render).
How a render ends (ring-out)#
An export used to stop writing rather than let the music finish. Three parts, all in engine/src/offline.rs (production-gaps.md §19, Batch N).
The horizon releases the voices, it does not cut them. Both export arms call AudioEngine::release_all at the last bar, not stop_all. The difference is not cosmetic: stop_all re-anchors the transport (clock = None), and the whole voice-render pass in process lives inside the Some(clock) guard — so after stop_all the arena is not rendered at all and every voice goes to digital silence inside one block. That is right for a stop button (the repo's "post-stop output is silent" contract) and fatal at an export horizon, where the tail render exists precisely to receive what is still sounding. Measured on a !"c3" |> slow 8 drone (gate 8 cycles, 3 s release): −19.1 dBFS → digital silence across one 5 ms window, step |Δx| 0.0614 against a body max of 0.00299 — a click at the end of every drone/pad export. After: the horizon's |Δx| is 0.00299, i.e. exactly the piece's own maximum sample-to-sample motion, and the release rings into the tail at −26.1 dBFS and decays.
release_all stops the scheduler (running tracks, shreds, any queued stop-all barrier) and note-offs every voice — including hosted plugin instruments, whose queued note-offs are pulled forward. It deliberately leaves the clock, the macro ramps and the song-position ramps alone: a close fade still in flight keeps falling through the ring-out, which is what an ending is.
The tail is a setting, and auto means "until it's gone". offline::Tail is either Fixed(sec) or Auto { floor_dbfs, settle_sec, cap_sec }. The master arm used to hardcode 2 s whatever the dialog said (the dial was gated behind if d.stems and only reached stems), so a long outro just ended mid-tail. Tail::AUTO = under −80 dBFS for 250 ms, capped at 30 s: −80 dBFS is ~13 dB below the 16-bit dither floor, so a tail that reaches it has nothing left that survives the delivery quantizer, and the cap is what makes auto safe to offer (a self-oscillating patch never settles). The test is a sustained run below the floor, not a block RMS, so a tremolo or delay tail cannot end the file in one of its own gaps. Measured on a reverb 0.97 0.9 outro struck on the last beat: final 200 ms −52.8 dBFS at 6.00 s under the fixed 2 s tail → −91.2 dBFS at 9.57 s under auto.
A 10 ms raised-cosine fade closes every render. fade_out_tail, in finish_render, so it runs before the WAV encode and before the loudness measurement — the report describes the file that was written. This is the safety net, not the fix: a tail can always outlast its window (a 6 s swell under a 2 s tail, a comb that never reaches auto's floor before the cap), and whatever is still sounding would otherwise be cut on the sample. Raised cosine rather than linear so the fade's own onset is slope-continuous; applied per frame across all channels, so the stereo image is untouched. 10 ms is under one 20 Hz period, so no musical decay is altered by it.
Where the setting reaches. Native (app-native/src/export.rs::tail_of) and the CLI take it. The browser export is still on Tail::DEFAULT: its export_audio bridge (ui-wasm/src/web.rs) emits a reduced event that has never carried stems, format, artist or the stem tail either, so the shared dialog's ring-out control is native-only for now — no behaviour change there, just a control the browser ignores.
Stem tails. Stems still get stem_tail_sec on top of the master's tail under a fixed setting — the reason stems have their own tail is that they must survive being placed past the master's boundary in someone else's session. On the flat (non-arranged) path that extra is extra scheduled cycles, not ring-out, because a flat render has no form to run out of; under an arrangement, extra cycles would loop the top of the song back into the tail, so it rides as seconds instead.
Those extra cycles are sized from the scheduled length (offline::horizon_sec), never from the render's duration_sec — which now includes the ring-out, and so reports a cycle as longer than it is. Dividing by it shrank the stem tail by the tail's own share: 1.78 s instead of 2 s under the default 2 s ring-out, and 0.7 s (~65% short) at auto's 30 s cap.
Under auto every file gets the same policy, not the same length: each render stops when its own output has settled, so a sparse stem finishes seconds before a reverb-drenched one and before the master, and an auto stem set has ragged tail lengths. That is the intent — nothing truncated, no dead air — and the set stays usable because every file is head-aligned: all start at sample 0 of the same bar-0 origin, so dropping them at one point on a timeline lines them up. A workflow that needs equal-length stems wants a fixed tail. (Padding auto stems to a common length is recorded as an open question in docs/TODO.md.)
Tests#
engine/tests/export_delivery.rs — the report matches a BS.1770 measurement of the decoded WAV (before and after the ceiling moves the audio); an HF-dense render that ships +1.56 dBTP raw lands under −1 dBTP once printed, without being over-limited; the ceiling is a bit-exact no-op on material that already fits and right up to its real −1.5 dBTP boundary (ceiling_no_op_holds_to_its_real_boundary: bit-exact at −1.51, touched at −1.49); dither buys ≥15 dB of decorrelation at −70/−80/−90 dBFS; dithered encodes are byte-deterministic and agree across both public encoders; and the undithered path is still exactly round(s·32767), sample for sample.
engine/tests/export_endings.rs — the ring-out, measured rather than listened to. gated_drone_releases_into_the_tail_instead_of_being_cut reproduces the old stop_all path through the public engine API and asserts both numbers in the same test (horizon |Δx| 0.0614 → 0.00299, tail RMS 0 → −26.1 dBFS decaying); stop_all_silences_while_release_all_rings_out pins that the two stop verbs stay different verbs; auto_tail_runs_a_reverb_outro_out_to_silence and auto_tail_terminates_on_material_that_never_settles cover both ends of the auto policy (runs to −91.2 dBFS, and stops dead at the cap on material that never settles); closing_fade_tapers_a_tail_that_outlasts_its_window proves the safety net on a deliberately-too-short tail; fixed_tail_length_is_exact is the guard that an existing export's length is unchanged.
engine/tests/delivery.rs::aiff_16bit_matches_the_wav_16bit — the two 16-bit containers hold identical samples, and the AIFF encode is stable across runs.
engine/tests/master_section.rs::live_meter_reads_the_delivered_output — the live half of §B2: the engine driven device-style at four host periods (two of them not multiples of BLOCK), with AudioEngine::loudness_integrated / loudness_true_peak_max checked against a BS.1770 measurement of the captured output. Delta 0.000 LU / 0.000 dB at every period.
cli/src/interchange.rs::export_delivery_defaults_and_overrides_reach_written_files drives the real volta export stems command and decodes the results: the default headers are 24-bit master / float32 stems, target and ceiling overrides reach the audio, and float stems round-trip exactly. …::explicit_untargeted_16_bit_export_preserves_legacy_delivery proves the opt-out still writes the old ceiling'd, deterministically dithered bytes and is byte-identical across runs. …::stems_sum_to_the_unceilinged_mix pins what the set sums to (above).
Deferred, with reasons#
- FLAC / MP3 — each needs a dependency (a FLAC encoder crate, LAME bindings). WAV + AIFF is what a mix engineer or a mastering house asks for; MP3 is a distribution format the distribution platform makes anyway. Worth revisiting if Share music and collaborate sharing makes it load-bearing.
- Loudness-matched reference A/B — the remainder of Stage 3.
Stage 5 — whole-mix varispeed#
Shipped 2026-08-16, closing production-gaps §27 V1. Native engine is the reference; the browser runs the same Rust engine in its worklet, so there is no second implementation to keep in step.
mixer {
master = { level = -0.5dB, inserts = [varispeed 0.75] } -- print it slow
}
varispeed k slows (or speeds) the entire record, tempo and pitch together. It is the move a tape deck's capstan makes and the one Volta could not spell: speed lives on a sample source, so a slowed mix had to be assembled per-passage out of bounceOf prints, and nothing that stayed live — drums, sub, sends — could come along. Now everything below the master drags at once: note onsets, drum transients, reverb tails, formants, swing, tuning. That simultaneity is the whole point; it is what makes the result sound printed rather than transposed.
The one insert that is not a filter#
Every other master insert is a same-rate box — frames in, frames out, in place on mix_l/mix_r. Varispeed is not: at ratio k the mix must advance k song samples per device sample, and no per-block filter can do that. So varispeed is declared as a master insert and then hoisted by the engine:
- the compiler lowers it to
TrackEffect::Varispeed { ratio: Param }insidemaster.insertslike any other entry; EffectState::buildgives its chain slot aDryThroughplaceholder, so indices still line up 1:1 with the declaration (the bypass vector, the workbench insert list andset_plugin_paramall assume that);AudioEngine::processreads the ratio and, when it is not a settled 1.0, fills the device block fromengine/src/varispeed.rs's ring instead of rendering into it directly.
song domain device domain
process_block() ──push──▶ [ ring ] ──sinc read at rate k──▶ out
(BLOCK frames, on demand) (one sample per device frame)
process_block is the entire unchanged engine — scheduler, voices, buses, sends, master strip, master inserts, the safety limiter. Because it is pulled only when the read head runs out of song, the transport itself slows, and every clock-derived quantity slows with it for free: the engine never learns it is being re-timed. The re-pitch then falls out of the read — walking a buffer at k samples per output sample multiplies every frequency by k.
The ratio is live#
Shipped as a full Param, not a constant: a demand-pull ring precomputes no length, so a ratio that moves is just a read head that walks at a different speed. Wow, drift and drop-the-whole-mix gestures all work.
- Every spelling is the ratio itself; 1 is normal speed. A constant (
varispeed 0.75is three-quarter speed), a macro (varispeed (drag)at drag 0.5 is half speed), a macro expression (varispeed (1 - drag * 0.95)is unity at drag 0), a control graph and a scene address (breakdown = [everything] with { master.varispeed.ratio = ramp 1.0 0.6 }) all read the same way. The slot is a raw-control slot over the ratio rails (TrackEffect::raw_control_range), so a scene address mints a knob that can hold 1.16 or 2.0. A bare[0, 1]macro reaches at most unity; a knob that should sweep stopped → normal → double is writtenvarispeed (drag * 2). - Glide. The ratio one-poles per sample on the same 15 ms constant every other knob uses. A stepped read rate is a phase discontinuity — i.e. a click — so this is not cosmetic.
- Anti-aliasing. Reading above 1× folds everything past
Nyquist/kback into the band, so the read usesresample::sinc_read_for— the same continuous JOSρL-stride kernel the sample player uses, cutoff tracking the rate. It is re-derived only when the smoothed ratio moves more than 0.1 %, which is under the kernel's own stride quantisation.
Rails, and why each one is there#
| rail | value | reason |
|---|---|---|
| minimum ratio | 0.05 | Liveness, not taste. At ratio 0 the read head never advances, so the ring is never pulled, so process_block never runs — and commands and macro smoothing both live inside process_block. A zero ratio is a deadlock the engine could not be told to leave. |
| maximum ratio | 4.0 | CPU: the engine renders k seconds of song per second of output. |
| one per mix | — | Two would each want to re-time the same render and only the first is hoisted; the compiler says so instead. |
| master only | — | There is no per-track form. A track effect is a same-rate box; |> varispeed diagnoses and points here. Same for a bus. |
What it costs, stated#
- Latency: the sinc stencil plus at most one inner block,
(BLOCK + 42)/kdevice samples — 3.5 ms atk = 1, 7 ms at 0.5, 71 ms at the 0.05 floor. A mix running 20× slow has a 20× slower control loop; that is the semantics. - Peak safety: the ring holds post-limiter, post-soft-clip audio, and a band-limited read of it can overshoot ±1 by a fraction of a dB, so the driver re-asserts the device rail with the same
clamp(-1, 1)Mixer::writeuses. - Metering (LUFS / true-peak / the viz snapshot) is taken inside the inner render, i.e. in song time. The numbers describe the same audio; the integration window is the song's second, not the wall's.
- Offline renders follow the song clock, whatever moves the ratio — a constant, a knob (
varispeed (drag * 2)) or a scene ride (with { master.varispeed.ratio = ramp 1 0.5 }). Every offline path (volta export, the Studio exporter, MCPmeasure_audio/render_audio/export_song,render-manifest,bounceOf … master, passage and history previews) renders throughAudioEngine::process_observed, whose hook runs before each inner song block the ring pulls: the walk applies scenes and releases at the horizon on the SONG clock there — where the live arrangement runner sits — and records avarispeed::Retime(song sample → device frame, read off the read head). The export therefore runs exactly as long as playback (two bars at 1× plus two at 0.5× is 12 s at 120 bpm), and section cuts, marker cues and MCP spans map bars through the map (ExportRender::frame_at_bar).varispeed::length_factorsurvives only as a pre-render ESTIMATE for budgets (1.0 for a moving ratio); gate onvarispeed::retimes. A varispeed song's MCP spans render from bar 0 and are not cached, and their per-track stems are solo renders. - Determinism. Inner blocks are always exactly
BLOCKframes at 2 channels, so an offline bake is byte-identical run to run.
Transport and swap discipline#
- Stop empties the ring. It holds up to
BLOCK + 2·42song samples of the take that just ended — tens of milliseconds of the previous performance at a slow ratio, which would replay under the next ▶ ("post-stop output is silent and artifact-free"). The smoothed ratio survives: it is a mix setting, not transport state. - A hot-swap keeps both ring and smoothed ratio, so editing a song while it plays half-speed does not snap the mix back to pitch mid-phrase. A fresh load seeds the ratio at its declared value, so ▶ starts at speed rather than gliding down to it.
Browser#
Nothing browser-specific. Since the 2026-08 engine unification the worklet runs this same Rust engine, and the ring sits inside AudioEngine::process, which is the one entry point engine-wasm calls — so the browser gets the identical render. (The worklet's own device-rate conversion is a separate, later hop; the two do not interact.)
Tests#
Unit, engine/src/varispeed.rs::tests — unity_ratio_is_the_identity (a unity ring returns exactly what was pushed), half_ratio_consumes_half_the_song, the_ratio_rail_holds.
End-to-end, engine/tests/varispeed.rs — the measurements, not vibes:
varispeed_half_halves_the_pitch_and_doubles_the_duration— 400 Hz reads 200 Hz by zero-crossing rate; one second of output covers half the beats; and abounceOf 2c masterprint comes out twice as many samples.varispeed_one_is_bit_identical— the neutral declaration changes nothing.a_macro_ratio_sweeps_without_a_click— a knob walked unity → quarter speed → unity, asserting both that no sample step exceeds 4× a clean sine's own and (so it cannot pass vacuously) that the pitch actually moved.a_stop_inside_a_varispeed_block_still_empties_the_ring— the borrow-out hazard: commands drain inside the inner render, so a stop lands while the ring is out of the engine. Post-stop output must be silent anyway.a_scene_ridden_varispeed_exports_the_length_it_plays/…_exports_what_live_playback_plays— a scene-ridden ratio's arranged export is 12 s, lands bars 2/3/4 at 4/8/12 s, and matches the realtime runner's render window by window (400 Hz, then 200 Hz).a_scene_ramped_varispeed_*— aramp 1 0.4exports ∫ ds/r(s), its clicks land there and match live.a_macro_varispeed_master_print_follows_the_ratioanda_macro_varispeed_flat_export_runs_its_bars_at_speedpin the knob case forbounceOf … masterand the flat export.
Compiler, compiler/src/lower/tests.rs::a_bus_or_track_varispeed_is_a_diagnostic — the legal spelling lowers with a Param ratio and clamps to the rails; a bus, a track pipe, and a second one on the master each diagnose.
Deferred#
- PDC. The master chain has never been in the delay compensation graph (only
track_fxlatencies feedcompute_track_comp), so the ring's stencil latency is not reported either. It is sub-millisecond at any musical ratio. - Per-bus varispeed. Not deferred so much as impossible under one transport: two buses at different rates are two songs.