VOLTAreference

Reference / Effects and mixing

EQ and frequency balance

Effects and mixing

Open in the workbench →
On this page · 5 sections

Shape the balance of bass, mids and treble, inspect a spectrum, and make space for different parts of a mix.

1. eqTrack — the console EQ#

drums = play kit beat |> eqTrack [highpass 40Hz, peak 350Hz -2.5dB 1.2, highShelf 9kHz 2dB]

bus air = eq [highShelf 10kHz 3dB] |> reverb 0.5
mixer {
  master = { inserts = [eq [highpass 24Hz], limiter] }
}

A cascade of 1–12 biquad bands on the mixer bus (RBJ down low, Vicanek matched up top — see "Decramping" below). Track pipes use eqTrack (the delayTrack convention — the voice eq is the fixed 3-band Eq3 and can't overload on a pipe); bus declarations and master inserts accept eq [ … ] or eqTrack [ … ] (the delay/delayTrack precedent). Strip entries stay level/pan/sends-only by Mixing tracks and buses's design.

Band constructors#

ConstructorArgsDefault QNotes
peak f g [q]freq, gain dB, Q1.0the bell
lowShelf f gfreq, gain dB—fixed-slope (RBJ S = 1)
highShelf f gfreq, gain dB—fixed-slope
lowpass f [q]freq, Q0.70712 dB/oct
highpass f [q]freq, Q0.70712 dB/oct
notch f [q]freq, Q1.0
  • A shelf given a Q is refused at check time as an arity error (lowShelf and highShelf take at most two arguments, freq and gain) — shelves are fixed-slope; use peak for a bandwidth-controlled bell.
  • A band constructor is an EqBand value, so using one outside a band list (as a track pipe stage, or where a number or signal is expected) is a type error.
  • Bound band lists fold: bands = [peak …] … |> eqTrack bands works (the evaluator-retry trick).

Negative gains#

peak 200Hz -3dB 1.4 parses as binary subtraction (application binds tighter than - — the limiter -3dB artifact), but a band value can't be subtracted from, so the band-list lowerer re-associates the Sub chain back into constructor args. Cuts read naturally; (-3dB) also works. In mixer/bus band lists the dedicated band parser handles the - directly (and is unit-aware: kHz scales ×1000 — as the top-level insert-arg loop also is since Named options and parameter values Stage 2).

The per-voice eq lowDb midDb highDb re-associates the same way (production-gaps §31 Z4 — every serial-distortion chain wants |> eq (-2dB) 4dB (-3dB) between its stages, and eight sites in one song batch had to spell it (0dB - 3dB)): the checker folds a - chain over an arity-short eq back into negated gain args, and the pipe desugar threads the receiver into the chain's left edge so sig |> eq 0dB 5dB -3dB and the prefix spelling land identically. A COMPLETE 4-arg eq minus something is still real subtraction.

Live bands (macros)#

Every slot (freq / gain / Q) is a [Param]:

  • Const — surface units (Hz/kHz, dB, Q/bare). Compiler clamps: freq 10 Hz–20 kHz, gain ±24 dB, Q 0.05–36.
  • Macro / macro-affine — the value after the affine map is the knob's normalized [0, 1] travel, mapped across the slot's full range — freq and Q logarithmically (20 Hz → 20 kHz, 0.3 → 18), gain linearly (−24 → +24 dB; 0.5 = flat). The Place/Width convention, so peak sweepKnob -4dB 2 sweeps the whole spectrum and peak (m * 0.5) … sweeps the bottom half of it.

Engine (engine/src/eq.rs): stereo TDF2 cascade, f32 coefficients (the track-side precedent — Filters: shape brightness and resonance "Precision") and f64 state (f32 state left a −75 dB noise floor on a 28 Hz master highpass; f64 is −145 dB), targets smoothed ~5 ms (freq/Q in the log domain), coefficients recomputed only when a smoothed value moved — a const EQ never recomputes; a ridden band sweeps click-free (measured: the sweep's max|Δx| stays within 3× the static case). A peak/shelf band at |gain| < 0.01 dB with no macro is skipped entirely. Minimum-phase, zero latency, no PDC interaction.

While a band is gliding the coefficients step on a fixed 44-sample grid anchored to audio time, not once per process() call: live 128-frame blocks and an offline render handed 4800 frames at a time take the identical coefficient trajectory (bit-identical output — pinned in eq.rs's tests; before the fix the two diverged by 0.55 full-scale, and a gain move landing inside an offline chunk stepped 25 dB harder than it does now). Idle bands keep the one-pass-per-call fast path.

Decramping (Vicanek matched designs)#

peak / lowShelf / highShelf centred above 1162.5 Hz are matched biquads — impulse-invariant poles plus magnitude-matched zeros (Vicanek, chowdsp's Decramped calculators) — so a bell's upper skirt and a shelf's plateau keep the analog prototype's shape instead of cramping into Nyquist. Below 600 Hz the RBJ bilinear designs are kept unchanged (bit for bit: better conditioned, and nothing that ever shipped changes sound down there); the two crossfade in between so a swept band never steps (< 0.05 dB). (Grid, crossover and the matched lowpass/highpass below: evolve volta_biquad #32; they were 32 samples, 700 Hz–1.4 kHz and RBJ.) lowpass and highpass bands are matched at every frequency (DC, Nyquist and the cutoff matched: a lowpass 7kHz was 9.8 dB off its analog prototype at 18 kHz under RBJ, now within 0.6 dB); notch stays RBJ. Coefficients and the TDF2 state are f64, and a stereo cascade runs L and R together, sample-outer. Measured at 48 k, a +12 dB Q 2 bell at 10 kHz vs its analog prototype: RBJ is up to 2.04 dB low across 6–20 kHz, matched 0.27 dB; a +12 dB high shelf at 8 kHz: RBJ 0.84 dB off, matched 0.04 dB. The bell prototype stays RBJ's (s² + (A/Q)s + 1)/(s² + s/(AQ) + 1), so Q means what it always did.

One engine renders these coefficients everywhere, so there is no browser/native curve split any more (the Web Audio EQ, RBJ-only with no matched designs, was deleted with the compatibility graph — see Filters: shape brightness and resonance and One audio engine across hosts). The UI draws the engine's own numbers: ui/src/benchscope.rs reads volta_engine::eq::band_coeffs / coeffs_response_db instead of recomputing RBJ locally, pinned by eq_curves_come_from_the_engine_not_a_local_rbj_recompute.

Mid/side legs — eqMid / eqSide#

Shipped 2026-09-01. Closes Production Gaps G4.

pad  = play keys chords |> eqMid  [peak 250Hz -2.5dB 1.2]   -- clean the centre
air  = play keys chords |> eqSide [highShelf 9kHz 3dB]      -- open the sides
mono = play bass line   |> eqSide [highpass 120Hz]          -- mono below 120

Same band list, same constructors, same macro-ridable slots, same 12-band cap as eqTrack — the only difference is which leg of the stereo image the cascade runs on. The engine encodes the bus

(m, s) = ((l + r)/2, (l − r)/2)

in place, runs the cascade on the named leg alone (through band-state slot 0; the other slot idles), then decodes (l, r) = (m + s, m − s). The pair is exact, so an M/S EQ with a flat band list is a bit-accurate bypass, and the untouched leg passes through unfiltered — the two legs stay phase-aligned everywhere the cascade does not move.

This is the tone half of the M/S toolkit, distinct from the image half: width is a side highpass with a live corner plus a gain, and decorrelate is a mono-safe widener; both move the image. eqSide [highShelf 8kHz 3dB] instead opens air on the sides only — the frequency-selective width move a width knob cannot make. eqMid [peak 250Hz -2.5dB] carves the centre's mud without narrowing the sides or touching a reverb that lives out there.

Buses and master inserts accept eqMid [ … ] / eqSide [ … ] alongside eq and eqTrack. In the IR this is one EqLeg field on TrackEffect::ParamEq, defaulted to Stereo and skipped when it is — so every EQ that shipped before this stays byte-identical.

Not shipped: M/S-*split limiting* (production-gaps §19 flags it as an image risk in its own right) — the limiter is still stereo-linked.

2. saturate / crush — the waveshaper lift#

drums = play kit beat |> saturate 0.5 "tape" 0.6   -- parallel: 60 % wet
bus grit = saturate 0.7 "fold"
mixer { master = { inserts = [saturate 0.15 "tape"] } }
kit2 = play kit beat |> crush 0.5 0.35             -- NIN parallel-crushed kit

saturate amount ["shape"] [mix] — one gain-compensated saturator instead of five lifted voice names. Args disambiguate by kind (saturate 0.5 0.3 is amount+mix; the shape is always a string).

  • Shapes → the shipped ADAA kernels at the voice drive mappings, except that the bus clipper moves its threshold perceptually: "drive" (tanh, k = 1 + a·24, default) · "tube" (asymmetric, even harmonics, DC-blocked) · "clip" (k = 25^a, a 0…−28 dBFS threshold; hard ceiling with a narrow C²-rounded bus knee; voice clip remains truly hard) · "fold" (second-order-ADAA wavefolder, k = 1 + a·8) · "tape" (the tanh kernel inside a ±3 dB pre/de-emphasis tilt pair at ~3 kHz — highs saturate first).
  • Why clip is dB-scaled → drive and threshold are reciprocals. The former affine law made amount 0.2 already mean k = 5.8 (+15.3 dB, threshold −15.3 dBFS), so a mastering-sized setting crushed ordinary −9 dBFS pad material and exposed its in-band intermodulation as crackle. The new law keeps the same endpoints but makes 0.2 +5.6 dB: it shaves genuinely hot peaks without turning a moderate-level bus into distortion.
  • RMS gain compensation is built in, calibrated at −12 dBFS (the tape 0 VU convention): a reference-level sine holds its loudness within ~1 dB across the amount range (recomputed numerically from the memoryless transfer when the smoothed amount moves), and low drive is near-transparent on quiet material. Full-scale-probe compensation was tried first and rejected — it left quiet bus material boosted by the transfer's slope at zero (~k×, +11 dB measured on a −30 dBFS pad at amount 0.8), which pegged a whole mix to −2 LUFS through a master saturate 0.12. At heavy drive the floor still lifts — that slope IS the sustain of heavy saturation — but the calibration claws back 10+ dB of it.
  • mix (default 1) is a linear dry/wet crossfade after compensation — parallel distortion in one insert; mix 0 nulls exactly.
  • amount and mix are Params (macro-ridable), and both ride smoothly: mix lerps between the block's endpoints per sample, and amount runs its 10 ms smoother per sample against the same endpoint pair (2026-08-04 — it used to advance once per block, i.e. a filtered staircase, which put a 4.9× step at every block seam under a sweep; see Macros and automation §4). The RMS compensation is too expensive to re-derive per sample, so it is read from a 513-point Catmull-Rom table over 1/comp with each block's endpoints pinned to the exact integral — accurate to 0.014 dB across the whole knob travel, and identical at any host buffer size. A settled knob (or a literal) takes the original hoisted loop bit-for-bit, so nothing about a static patch changed.

crush amount [mix] — the voice bitcrush map (levels = 2^(16 − round(a·15))) on the stereo bus. Deliberately not antialiased. Both new names join the track-pipe arms AND the bus/master whitelist (and mixcommit's reorder set); drive/tube/bitcrush on a mixer insert now diagnose pointing at their track-rate siblings.

3. The analyser#

The bench's analyser ▸ chip (per track) and the toolbar's analyser toggle (master) open the pane (ui/src/analyzer.rs):

  • Data: bus-tap frame v2 — f32 samples (v1's u8 floored at ~−48 dB), window 4096 (11.7 Hz bins at 48 kHz). The frame is self-describing; v1 still decodes for one release. engine/src/preview.rs::BUS_TAP_LEN, ui/src/taps.rs.
  • Display: log-f 20 Hz–20 kHz, dB grid to −90, 1/6-octave folding, fast-attack (30 ms) / slow-release (300 ms) exponential averaging, a 2 s peak-hold trace (12 dB/s decay), freeze, and the 4.5 dB/oct tilt (default on — pink noise reads flat, the Pro-Q convention). Cursor readout: Hz · note name (±cents) · dB.
  • EQ overlay: when the strip runs a ParamEq, the composite |H(f)| draws over the spectrum (its own ±24 dB axis) with one handle per band. Drags are axis-locked at gesture start (dominant initial delta picks freq vs gain); ⇧-drag edits Q multiplicatively. Every gesture rewrites the source literal through the bench's snapshot-aware splice seam (DockEdit → one undo entry per gesture), so a drag is an ordinary compile-verified edit and Change music while it plays's FxReuse keeps the swap silent. Macro-driven slots draw hollow read-only handles — the knob is their editor.
  • Write-back spans: the IR EqBand carries per-arg (offset, len) spans (freq_span/gain_span/q_span; absent for defaults and macro slots). A re-associated negative gain's span covers the unsigned literal — the UI scans back for the - and extends, so a drag can cross the sign cleanly (-3dB → 2.0dB). A handle is draggable only when its span resolves inside the strip's own declaration in the open song (the track's binding; the mixer block for master) and the literal there reads as the compiled value (ui/src/span_target.rs): an imported track, a bus strip and a band an imported function supplies draw read-only. Master/bus spans live in the mixer/bus blocks, which aren't notebook cells — the dock-edit router falls back to splicing the master buffer directly.
  • A/B: the pane's bypass chip toggles the insert at once (SetInsertBypass, track-owned EQs) and writes bypassed where the stage is on the track's own line, as the Mixer's insert dot does.

4. The dynamic bell — dynEq#

bassBus = play bass line |> dynEq 180Hz 0.5      -- duck the 180 Hz bell when it rings
voxBus  = play vox mel |> dynEq 3.2kHz 0.6 4     -- tame the one squealing band
mixer { master = { inserts = [dynEq 240 0.4] } }

track |> dynEq freq amount [q] — a program-relative dynamic bell CUT (production-gaps §30 G2): the de-esser's mechanism at an arbitrary centre. While the band sits inside the mix nothing moves — the wet path is exactly the identity at rest, not a "nearly flat" filter. When the band spikes above the rest of the spectrum (a room mode, a wolf tone, a resonant-filter squeal) the bell ducks it, proportionally, and lets go when it recedes — the de-resonance move a static peak … -6dB can't make without paying that cut all the time.

  • Detector: a BP² cascade at freq/q, normalized to unity at the realized centre — it hears the band and nothing else. 1.5 ms attack / 120 ms release (slower than the de-esser's: the target rings up slower than an ess, and a 60 ms release at 100 Hz would pump on the waveform).
  • Threshold: derived from the band-excluded program — the bell's own bandpass output subtracted from the input, read through a second exclusion notch so the band's bilinear/f32 residual and the bell's own onset ring can't pose as program (the deess body_s lesson; without the second pass a solo'd band measured a phantom program 25 dB over the true residual and ran to the cap). Because the band appears nowhere in its own threshold, a hotter spike is never reduced less (the deess D1 law, pinned by dyneq_reduction_is_monotone_in_the_band).
  • No-program fallback: band-only material (a solo'd 808, a sine test) crossfades the threshold to the band's own slow reference minus a margin — a bounded few-dB cut instead of the 20 dB cap (≈ margin·(1 − 1/ratio)).
  • Application: y = x − k·BP(x) — RBJ's peaking cut with the pole Q fixed when frequency/Q are literals; scalar k moves per sample (control-rate recompute + de-zipper, the house dynamics pattern), so a live macro on amount glides without a click and idle really is bit-identical dry.
  • Curve (amount = a): threshold program − (2 + a·16) dB, ratio 2 + a·6, 6 dB knee, cut capped at 4 + a·20 dB. q defaults to 2.5 (narrower than peak's 1.0 — the use case is a ringing resonance, not broadband tone), clamped 0.3–12. freq clamps 30 Hz–16 kHz. Cut only, by design: a "dynamic boost" keyed the same way is an expander with a feedback problem, and the guide moves this exists for are all cuts.
  • Cold start: transparent for a seed window that scales with the bell's ring time (6.9·2q/ω₀ + the de-esser's 21 ms read) — a Q 12 bell at 60 Hz gets ~460 ms of grace rather than a mis-seeded reference.

freq and q also accept composed controls in their surface units. A 5 ms smoother drives coefficient updates every 16 samples on a clock that survives callback boundaries. Existing bandpass, exclusion, detector and gain states are retained. Frequencies also respect the processor's sample-rate rail. Literal values keep their original coefficient path.

Measured (48 kHz, engine/tests/transient_dyneq.rs): a band 8 dB over the rest is tamed ≥ 4 dB while its neighbours an octave-plus away move < 1.2 dB; a band 12 dB under the program moves nothing > 0.4 dB; the same spike-to-program ratio gets the same cut 10 dB down (±1 dB); the reduction is monotone in the band level. The bench dynamics meter reads its live reduction like the rest of the compressor family (gr_db, the bell-centre figure).

Multiple resonances = multiple instances: |> dynEq 180 0.5 |> dynEq 3200 0.4 6 — each bell carries its own detector and reference, which is what you want when the two ring independently.

5. Tests#

  • engine/tests/parametric_eq.rs — flat-band exact null, shelf/peak/HP/ notch labelled-dB delivery, the click-free macro sweep probe, per-shape RMS stability, mix-zero null, third-harmonic growth, tube DC-freedom, crush lattice.
  • engine/tests/gaps_dsp_2026_09.rs — the mid/side legs: a side-only cut really cuts the side and leaves the mid within 5 %, and a flat band list on either leg nulls to < 1e-5.
  • engine/tests/transient_dyneq.rs — the dynamic bell's five laws (quiet band untouched, spike cut + neighbours spared, program-relative, no-program bounded, monotone in the band).
  • compiler/src/lower/tests.rs — all surface forms, spans, defaults, re-association, arity/shape diagnostics, the mixer round-trip.
  • ui/tests/analyzer.rs — chip → dock, master strip, drag-rewrites-source (sign + unit preserved), master-insert write-back, sign crossing.
  • ui/src/analyzer.rs unit tests — overlay target resolution (signed span extension, macro read-only, gainless kinds).