VOLTAreference

Reference / For contributors

DSP cookbook

For contributors

Open in the workbench →
On this page · 6 sections

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 — f maps a sample to a sample; stateless.
  • process2 f init — f takes 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], or dline 500ms for a delay line. A record init ({ pre = 0.0, gr = 0.0 }) is named state: the law takes the state as one binder, reads st.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].
  • sampleRate is readable only inside a kernel. A let that depends only on constants and sampleRate runs once per block, not per sample.
  • A macro used inside the law is a live parameter, read every sample.
  • effect name (param: Type = default, …) on track | on voice = body makes 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] with p = 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 init phase exactly (state re-seeds at every note start).
  • oscStep hz (in kernels) 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 step wraps 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/sr was 1.6e-2 off a sine at 0.5 s. For envelopes use noteTime, computed in f64 from the voice's start sample (matched sine … |> phase 0 to 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 dt is the two-point residual; polyBlep4 t dt the 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 (phase ph − 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, blTri alias within 0.01 dB of the builtin saw, square, pulse, triangle at every pitch measured, 110 Hz – 4.97 kHz (Write a custom audio processor § Source kernels, measured offline). oversample 4 around 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.5 to stay in tune (dtap interpolates fractional delays).
  • g = e^(−6.91 / (T60 · f)) loses 60 dB in T60 seconds at any pitch (the loop runs f times 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 50ms holds 20 Hz.
  • osclib's ksPluck { 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 a randomPerNote port. 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#

limitvaluewhat to do
binders + lets + inlined helper arguments32 per kernelwrite big laws out in full (phaserLaw, tankLaw); helpers spend slots
state slots (cells and lines)8transients uses 7
delay lines4 per kernel, 2 s of memoryfold a delay into the allpass before it
parameters (macros + ports)32
outputNaN-flushed, soft-limited to ±2 (identity on ±1)keep levels under 1 when comparing against a builtin
ifeager: both arms always runno need to guard a division — x/0 is 0
integer literals4/3 is 1write 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 probe and the MCP probe_kernel tool 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.