Short, tested recipes for writing your own filters, delays, reverbs, dynamics and distortion in Volta — each with the reason it is written that way.
Every recipe here is a law — a lambda the engine runs once per sample — and most are shipped, ready to import, in the dsplib library:
import "dsplib" (tptSvf, zdfLadder, dattorro, compressor, tapeSat)
bass = patch {
source = saw (freq pitch) |> zdfLadder { cutoff = 500.0, resonance = 0.6 },
ampEnv = adsr 2ms 200ms 0.5 80ms,
gain = vel * 0.4
}
bassT = play bass !"a1 ~ a1 c2 ~ a1 e2 g1" |> tapeSat { drive = 0.3 } |> compressor { threshold = -20.0 }
Listen for the ladder's resonant edge on each note and the compressor holding the line steady. Make it yours: open dsplib.volta in the Reference panel, copy a law into your song, and change one line.
The contract in six lines#
process f—fmaps a sample to a sample; stateless.process2 f init—ftakes the sample, then one binder per state slot, and returns[out, next](one cell) or[out, [n1, n2, …]]. The init seeds the slots:0.0,[0.0, 0.0], ordline 500msfor a delay line. A record init ({ pre = 0.0, gr = 0.0 }) is named state: the law takes the state as one binder, readsst.pre, and returns a record (Write a custom audio processor § Named state).processS f init— one program over both channels:\l r … -> [outL, outR, next].sampleRateis readable only inside a kernel. Aletthat depends only on constants andsampleRateruns once per block, not per sample.- A
macroused inside the law is a live parameter, read every sample. effect name (param: Type = default, …) on track | on voice = bodymakes it a named, documented, automatable processor (Write a custom audio processor).
Filters#
Tune a one-pole in Hz — and why the zero-delay form#
The textbook one-pole y = s + a·(x − s) with a = 1 − e^(−2π·fc/sr) is cheap and fine for smoothing, but its cutoff drifts as fc nears Nyquist. The TPT (topology-preserving transform, Zavalishin, The Art of VA Filter Design ch. 3) integrator uses the prewarped gain g = tan(π·fc/sr) and lands the −3 dB point exactly on fc at any frequency:
tanPrewarp = \cut -> let w = 3.141592653589793 * cut / sampleRate in sin w / cos w
tptLowpass = \cut x s ->
let g = tanPrewarp cut in
let v = (x - s) * (g / (1.0 + g)) in
let lp = v + s in
[lp, lp + v]
cookLead = patch { source = saw (freq pitch) |> process2 (tptLowpass 1200.0) 0.0 }
cookLeadT = play cookLead !"c3"
x − lp is the highpass and lp − (x − lp) the allpass from the same cell. Kernels have no tan, so it is sin w / cos w. Library: onePole { cutoff, mode } — measured within 0.05 dB of the closed form 1/√(1 + (tan(πf/fs) / tan(πfc/fs))²) at 250 Hz … 12 kHz.
Clamp the cutoff before the prewarp — max 20.0 (min (0.49 * sampleRate) cut) or the if spelling dsplib's tptG uses; both check. (Before 2026-09-25 the max/min spelling around a sin in a shared helper exhausted the type checker's arena at the caller — fixed; tptG keeps its if because the library is append-only.)
The state-variable filter: four responses from one solve#
The Simper/Zavalishin SVF solves two TPT integrators and a feedback loop in closed form, and gives lowpass, bandpass, highpass and notch at once:
``volta norun the solve alone —svfLaw` in dsplib is the whole kernel k = 1/Q; a1 = 1/(1 + g(g + k)); a2 = g·a1; a3 = g·a2 v3 = x − ic2; v1 = a1·ic1 + a2·v3; v2 = ic2 + a2·ic1 + a3·v3 ic1' = 2·v1 − ic1; ic2' = 2·v2 − ic2 lp = v2; bp = v1; hp = x − k·v1 − v2; notch = lp + hp
**Why it matters:** the state is the integrators' charge, not past outputs,
so the cutoff can move every sample — an envelope, an audio-rate LFO — and
the filter stays consistent. A direct-form biquad whose coefficients are
swept that fast does not. Library: `tptSvf { cutoff, q, mode }` (mode 0–3,
fractions morph) matches the builtin `svf` to f32 rounding in all four
modes (max |Δ| ≤ 2e-5), and still matches it with the cutoff swept 200 Hz …
8.2 kHz by a 1 kHz sine through a signal port. `svfSweepLaw` puts the cutoff
on a port; `envFilter` drives it from an envelope follower inside the same
kernel (an auto-wah).
### The zero-delay ladder
Four TPT one-poles in series with the output fed back. Written naively the
feedback arrives a sample late and detunes the resonance near Nyquist. The
ZDF ladder solves the loop instead: each stage is `y = G·u + sᵢ(1 − G)`, so
the whole cascade is `y4 = G⁴·u + S`, and `u = x − k·y4` gives
`u = (x − k·S)/(1 + k·G⁴)`. One Padé `tanh` on `u` keeps self-oscillation
bounded. Library: `zdfLadder { cutoff, resonance }` — the builtin `ladder`'s
drive-0 path, max |Δ| ≤ 2e-5. The builtin oversamples 4× from resonance 0.8;
wrap the port in `oversample 2` or `4` when you push it there.
### A biquad from the cookbook
For a static EQ band or a fixed filter, Robert Bristow-Johnson's *Audio EQ
Cookbook* in transposed direct form II is the cheapest second-order filter
(`kernels.volta`'s `biquadLp`, `biquadHp`, `biquadBp`; the Learn lesson *A
biquad from the cookbook* types it in). It costs 1.5× the builtin `lpf`
(the only port inside the 2× target, [[reference/user-kernels]] § Cost).
Prefer the SVF when the cutoff moves.
## Delays
### A line, a fractional read, a feedback path
`dline 1s` in the init makes a slot a delay line; `dtapi line d` reads `d`
whole samples back (`d = 1` is the previous sample), `dtap line d` reads
between samples with 4-point Hermite interpolation, and the slot's element
of the next-state list is what is pushed in. Push the output back in and the
repeats feed themselves:
```volta
cookEcho = \seconds fb x line ->
let back = dtapi line (floor (seconds * sampleRate + 0.5)) in
[x + 0.4 * back, x + fb * back]
cookPluck = patch { source = pulse (freq pitch) 0.3, ampEnv = adsr 1ms 150ms 0 60ms }
cookPluckT = play cookPluck !"e4 ~ g4 ~" |> audioFx (\bus -> bus |> process2 (cookEcho 0.375 0.5) [dline 1s])
Put echoes on the track (audioFx, or an effect … on track): a voice kernel's line starts empty at every note. A one-pole inside the loop — on what is pushed, not what is heard — darkens each repeat: darkEcho, bit-identical to the builtin delayTrack with damp.
Chorus and flanger: a moving tap#
Both read the line at a delay that an LFO moves: ~10 ms swinging a few ms is a chorus (chorusLine), ~3 ms with feedback a flanger (flangeLine). Two details make them clean:
- The LFO is a wrapped phase cell —
[…, p − floor p]withp = ph + rate/sampleRate— not a sample counter: an f32 counter stops counting at 2²⁴ samples (5.8 minutes at 48 kHz). - Fade the wet tap in over 4 ms from the note start. The line starts silent, and the tap crossing out of the silence is a step — a click on every note. The builtins do the same; the ports count samples in a cell capped at a million.
Both track their builtins (chorus, flanger) to within 2e-3 on a mono voice; the residual is the LFO phase accumulating in f32 where the builtins integrate in f64.
Space#
The allpass diffuser#
A Schroeder allpass over a line — y = d − g·x, push x + g·y, d the line read D samples back — has a flat magnitude and a smeared phase: in series, four of them turn a click into a dense burst. diffuser { size, amount } uses Dattorro's input-diffuser lengths; the DSP probe measures its magnitude flat to within ±0.5 dB.
One line for an allpass and the delay after it#
The line holds the allpass's inner node v, so its output j samples ago is y[n−j] = (v[n−j−D] − g·v[n−j]) / (1 − g²) — two taps on the same line. A delay that follows an allpass needs no line of its own. That is how dattorro fits Dattorro's figure-8 tank (two allpass + delay pairs per half) into the four lines one kernel may have. When D is modulated, read the tap with D evaluated at the tap's own time (the LFO phase j samples back).
The plate#
dattorro { decay, wet, damp } is Jon Dattorro's plate (Effect Design Part 1, JAES 1997): a bandwidth one-pole and four input diffusers per channel (process2, 4 lines), then the tank as one processS kernel (4 lines, a damper cell per half, the LFO phase). decay is a true RT60 — the tank gain 10^(−3·T_loop / (4·decay)). Against the builtin plate (the same tank plus early reflections) the tails are the same signal after 250 ms — residual −46 dB under the tail, T60 equal to four figures.
Dynamics#
Detect in dB, smooth the gain, not the signal#
``volta norun the pieces —compressorLaw` in dsplib assembles them level = 20·log10(max(|l|, |r|)) -- stereo-linked target = gain computer (threshold, ratio, knee) -- dB of reduction c = exp(−1 / (t · sampleRate)) -- t = attack if target rises, else release gr = c·gr + (1 − c)·target -- one cell out = x · 10^((makeup − gr)/20)
The soft knee (Giannoulis, Massberg & Reiss, JAES 2012) is a quadratic
across `knee` dB. Smoothing the *reduction* in dB rather than the detected
level keeps attack and release independent of how far over the threshold
the signal is. Library: `compressor { threshold, ratio, knee, attack,
release, makeup }` settles on the closed form — a −6 dBFS tone at −20 dB
threshold and 4:1 is reduced (1 − 1/4)·14 = 10.5 dB; `linkedComp`
re-implements the builtin `compress` curve.
- **Feedback** (`fbComp`): the detector hears the previous output sample, so
the effective ratio is `2 − 1/R` and the knee softens — the vintage bus
sound. Measured on its closed form, `(1 − 1/R)(P − T)/(2 − 1/R)`.
- **Program-dependent release** (`optoComp`): a second cell charges while
reduction is held and stretches the release with it — a snare recovers in
a fraction of a second, a long swell over seconds.
### A lookahead limiter: a loop, a line and a declared latency
Delay the audio N samples; push each frame's required reduction
`r = 1 − ceiling/peak` into a line; apply to the frame leaving the delay the
minimum over the window of `1 − r·w`, `w` a smoothstep of the frame's age
that is 1 for the frame leaving (the ceiling guarantee) and 0 for a frame
that just arrived (no step). `foldl (\acc k -> min acc (…)) 1.0 (range 1 65)`
compiles to one loop, and `effect … on track latency 64` tells the mixer
about the delay. `brickLimit` holds its sample-peak ceiling exactly and is a
pure 64-sample delay below it (the builtin `limiter` detects true peak, 4×
oversampled). **The loop is the cost:** its body runs 64 times a sample, so
keep it to a few ops — `brickLimit` is the most expensive effect in the
library (see the cost table in [[reference/user-kernels]]).
### Transient shaping and control-rate holds
`transients { attack, sustain }` is the builtin `transient`'s detector cell
for cell: a crest-riding envelope, a lagging reference (attack = envelope
over reference), two crest-followers a decade apart (sustain = slow over
fast). Its gain is recomputed every fourth sample — a phase cell counting
`0, 1, 2, 3` with `if ph >= 2.5 then 0.0 else ph + 1.0` — and slewed. Seven
cells of eight.
## Colour
### Saturation that does not alias
- `tanh(k·x)` is the smooth clipper; `tanh(k·x + b) − tanh(b)` adds even
harmonics (asymmetry) without a DC step at silence.
- **ADAA**: output the mean of the curve between the previous input and this
one, `(F(x) − F(x₁)) / (x − x₁)` with `F` the antiderivative (`ln cosh` for
`tanh`), falling back to the midpoint when the two are close — one cell,
about 9 dB less alias power at 1× (`adaaTanh`, `adaaClip` in `kernels`).
- **`oversample 2|4|8`** around the stage: the law runs at N·sr between
halfband filters (reference: a hard clip's aliases fall from −16.9 dB at
1× to −49.1 dB at 4×). The two stack.
- **Tape** (`tapeSat`): pre-emphasis before the curve so highs saturate
first, the matching de-emphasis after, a `tone` lowpass for head loss,
all at 2×.
### Bits and sample rate
`bitcrusher { bits, downsample }` quantises with round-half-away-from-zero
(`sign · floor(|v| + ½)`, which `floor(v + 0.5)` is not for negative ties)
and holds every `downsample`-th sample — identical to the builtin `bitcrush`
at the same bit depth. `ringer { hz, mix }` multiplies by its own sine
carrier; `x·(1 + mix·(c − 1))` is the builtin `ringMod`'s crossfade law.
## Oscillators
A source kernel makes the sound instead of processing one:
`freq pitch |> processO law init` hands the law the note's frequency in Hz
every sample, then its state ([[reference/user-kernels]] § Source kernels).
Everything below is shipped: the band-limiting helpers and laws in the kernel
stdlib (`kernels.volta`), finished instruments in `osclib`
(`examples/songs/osclib.volta`, played by `osclab.volta`). The Learn lessons
*Your own oscillator* and *A plucked string* type the first and the last of
these in.
### Phase accumulation
One cell holds the phase in cycles, `0 ≤ ph < 1`; each sample adds the step
`hz / sampleRate` and wraps with `p − floor p`. Every shape is a function of
that phase:
```volta
phaseSaw = \hz ph ->
let next = ph + hz / sampleRate in
[2.0 * ph - 1.0, next - floor next]
cookSaw = patch { source = freq pitch |> processO phaseSaw 0.0, ampEnv = adsr 2ms 200ms 0.6 120ms }
cookSawT = play cookSaw !"a3"
- Output the phase before stepping it, so a note starts at the
initphase exactly (state re-seeds at every note start). oscStep hz(inkernels) is the step clamped to ±0.45·sr, as the builtins clamp it — a glide or FM that overshoots Nyquist cannot fold the phase backwards;oscNext ph stepwraps in both directions (negative frequencies from through-zero FM).- An f32 phase is exact enough for pitch (the wrap keeps it small), but a cell that COUNTS time drifts:
t + 1/srwas 1.6e-2 off a sine at 0.5 s. For envelopes usenoteTime, computed in f64 from the voice's start sample (matchedsine … |> phase 0to 3.4e-5).
Band-limiting: polyBLEP and polyBLAMP#
The naive saw above jumps from +1 to −1 in one sample; that step has harmonics far past Nyquist, which fold back as inharmonic aliases (the naive saw at 2.49 kHz: −11.7 dB of alias power against the harmonics). A polyBLEP subtracts a small polynomial residual on the samples either side of each jump, so the step is rounded exactly as much as a band-limited step would be:
import "kernels" (polyBlep)
blepSaw = \hz ph ->
let dt = hz / sampleRate in
let next = ph + dt in
[2.0 * ph - 1.0 - polyBlep ph dt, next - floor next]
cookBlep = patch { source = freq pitch |> processO blepSaw 0.0, ampEnv = adsr 2ms 200ms 0.6 120ms }
cookBlepT = play cookBlep !"a5"
polyBlep t dtis the two-point residual;polyBlep4 t dtthe four-point cubic-B-spline kernel the builtin oscillators run (Esqueda & Välimäki, DAFx-16) — quieter aliases, two samples either side.- A square or pulse has two steps per cycle: add one residual at the rising edge (
ph) and subtract one at the falling edge (phaseph − w, wrapped).blSquare,blPulse width. - A triangle has corners, not steps — a jump in slope. Its residual is the integrated one, polyBLAMP, scaled by the slope change (8·dt per corner for a ±1 triangle):
blTri. - The laws
blSaw,blSquare,blPulse,blTrialias within 0.01 dB of the builtinsaw,square,pulse,triangleat every pitch measured, 110 Hz – 4.97 kHz (Write a custom audio processor § Source kernels, measured offline).oversample 4around a naive law is the blunt alternative: a 4.97 kHz naive saw reads −20.3 dB oversampled against −8.1 dB at 1×.
Pulse-width modulation#
blPulse width takes the duty cycle as its first argument, so a macro — or any expression over one — passed there is PWM, read every sample inside the law. Keep the width off the rails (blPulse clamps it to 0.0001…0.9999): at 0 or 1 the two edges coincide and the pulse vanishes. When both edges' correction windows would overlap (a narrow pulse at a high note) blPulse falls back from the four-point to the two-point kernel. Library: polyPulse { width }.
import "osclib" (polyPulse)
pw = macro "pw" 0.3
cookPwm = patch { source = polyPulse (freq pitch) { width = 0.1 + pw * 0.8 }, ampEnv = adsr 2ms 200ms 0.6 120ms }
cookPwmT = play cookPwm !"a2"
Hard sync#
A slave saw at ratio × the note is reset whenever the master (the note frequency) wraps. The reset lands between samples, so it is a step at a fractional time τ: the two-point polyBLEP's "before" half, h/2·(1 − τ)², goes on this sample and its "after" half, −h/2·τ², is carried in a cell to the next — the builtin sync's scheme. The slave's own wrap is an event of the same kind; syncSaw ratio handles both, in one sample, in order. Sweep ratio for the scream: osclib's syncLead { ratio }, addressed from a scene as lead.syncLead.ratio = ramp 1.5 3.8. Measured: within 0.01 dB of saw (f·2.37) |> sync f.
FM (phase modulation)#
Two sine operators: the modulator's output is added to the carrier's PHASE, sin (2π·pc + index · sin (2π·pm)). Phase modulation rather than frequency modulation keeps the carrier's pitch exact whatever the index (no DC in the frequency). Feeding the modulator back on itself (sin (2π·pm + fb·m), DX7 operator 6) adds a saw-like edge; average the last two outputs in the feedback path or the loop hunts at high feedback. Library: fmPair { ratio, index, feedback } — integer ratios are harmonic, 1.41 or 3.5 bell-like. A fixed-ratio pair aliases only where its sidebands pass Nyquist; keep index modest on high notes, or wrap the law in oversample 2.
Karplus–Strong: a string from a delay line#
One period of noise into a delay line sampleRate / hz samples long, recirculated through the average of two neighbouring taps:
ksLaw = \decay hz n buf ->
let burst = if noteTime < 1.0 / hz then n * vel else 0.0 in
let d = sampleRate / hz - 0.5 in
let g = exp (0.0 - 6.907755278982137 / (decay * hz)) in
let y = burst + g * 0.5 * (dtap buf d + dtap buf (d + 1.0)) in
[y, y]
oscillator string (decay: Float = 2.0) = \hz -> hz |> processO (ksLaw decay) (dline 50ms) noise
cookPluck = patch { source = string (freq pitch) { decay = 3.0 }, ampEnv = adsr 1ms 3s 0 200ms, gain = 0.25 }
cookPluckT = play cookPluck !"e3 b3 e4"
- The two-tap average is a lowpass that damps high harmonics more on every trip — the string darkens as it rings. It delays half a sample, so the read is at
d − 0.5to stay in tune (dtapinterpolates fractional delays). g = e^(−6.91 / (T60 · f))loses 60 dB inT60seconds at any pitch (the loop runsftimes a second).noteGate(1 while held) lets the release damp the string:if noteGate > 0.5 then decay else 0.08.- The line's length caps the lowest note:
dline 50msholds 20 Hz. osclib'sksPluck { decay, bright }adds a tap balance for tone (its delay compensated) and Wichmann–Hill noise — three small LCGs whose products stay below 2²⁴, so every step is exact in f32 and the pluck is the same on every host — seeded per note from arandomPerNoteport. Measured: a 220 Hz pluck's fundamental on the 220 Hz bin.
Drums from note time#
A drum is a source whose every envelope is a function of noteTime: a sine body whose frequency falls from (1 + sweep)× the note to the note, hz · (1 + sweep · e^(−40·t)), decaying as e^(−6.91·t / decay), plus a noise snap e^(−90·t). Library: noiseDrum { sweep, decay, noise }.
Limits worth knowing#
| limit | value | what to do |
|---|---|---|
binders + lets + inlined helper arguments | 32 per kernel | write big laws out in full (phaserLaw, tankLaw); helpers spend slots |
| state slots (cells and lines) | 8 | transients uses 7 |
| delay lines | 4 per kernel, 2 s of memory | fold a delay into the allpass before it |
| parameters (macros + ports) | 32 | |
| output | NaN-flushed, soft-limited to ±2 (identity on ±1) | keep levels under 1 when comparing against a builtin |
if | eager: both arms always run | no need to guard a division — x/0 is 0 |
| integer literals | 4/3 is 1 | write 4.0/3.0 |
Measure what you wrote#
- Put the caret on a kernel call or inside an
effect: the DSP probe dock draws the response, impulse, step and transfer curves and names instabilities ("unstable above cut = 0.64×Nyquist") — Write a custom audio processor § See your DSP.volta probeand the MCPprobe_kerneltool return the same report. - The ports-vs-builtins cost table (
cargo bench -p volta-engine --bench dsp -- ports/) is in Write a custom audio processor § Cost.