VOLTAreference

Reference / Design a sound

Unison and layered voices

Design a sound

Open in the workbench →
On this page · 5 sections

Stack related voices to make a sound fuller or wider, balancing detuning, stereo spread and processing cost.

What it is#

A one-word oscillator-stacker. sig |> unison count spread plays count detuned copies of a pitched signal at once, blends the sides at 0.85, and fans them across 0.6 of the stereo field — the supersaw / super-square fattener that otherwise has to be hand-spelled as a pile of oscillators (see the five-oscillator stack in examples/songs/carve.volta).

Surface#

-- seven detuned saws, ±9 cents (18 total) around the centre pitch
lead = saw (freq pitch) |> unison 7 18

-- a tighter five-voice square stack
stab = square (freq pitch) |> unison 5 8

-- unison 1 is a pass-through: no detune, no mix
clean = saw (freq pitch) |> unison 1 0

-- the supersaw move: a live, sweepable detune. `spread` is a macro, so the
-- stack fans open from unison as you ride it through a build-up. A macro is
-- normalized 0..1 and `spread` is in CENTS — scale it into a musical range
-- (`* 50` → ±25 ¢ at the top of the slider).
spread = macro "spread" 0
wall   = saw (freq pitch) |> unison 7 (spread * 50)

-- the full Serum-style control set: live detune, blend, stereo width
big = saw (freq pitch) |> unison 7 (spread * 50) 0.8 0.6

-- final optional arg controls per-voice analog wander; zero disables the
-- default 2.5-cent drift for a deliberately stationary stack
steady = saw (freq pitch) |> unison 7 18 1 0.6 0

unison : Sig Audio Bipolar -> count -> spread -> [blend] -> [width] -> [drift] -> Sig Audio Bipolar. It is signal-first, so it pipes. count is the voice count (a whole number, 1–16); spread is the total detune in cents, applied symmetrically about the centre pitch (so unison 7 18 runs from −9 ¢ to +9 ¢). The centre voice of an odd count sits exactly on pitch.

spread, blend, and width each take a fast path when they are a bare numeric literal (baked into the desugar, as before) and a live path otherwise — a macro, LFO, or envelope is lowered once to a shared node and injected per voice as a signal, so the width tracks the knob in real time. This is the headline supersaw gesture: count stays fixed (changing the oscillator count live is expensive and nobody does it), but the detune opens live.

  • blend (0..1, optional; default 0.85) — relative level of the centre voice vs the detuned sides. blend 1 is uniform (every voice full); lower values fade the outer voices toward silence, leaving the centre. Per voice the mix gain is scaled by (1 − side) + side · blend, where side is 0 at the centre and 1 at the outermost.
  • width (0..1, optional; default 0.6) — stereo spread. Voice k is panned to frac_k · 2 · width across the field (the centre stays centred, the edges fan fully out at width 1), riding the stereo voice graph (Voice signal flow). The pan law is the engine's single constant-power law, √2·(cos θ, sin θ) with θ = (p+1)·π/4 (Mixing tracks and buses §3.5): unity at centre, L² + R² = 2 at every position. So width moves the stack around the field without changing how much of each voice there is. width 0 deliberately centres the stack; the default width 0.6 opens it without adding a per-voice gain stage.

    Release note (2026-08-05). It was not, before. The law used to be the raw (cos θ, sin θ), so every voice of a stack with a width argument was multiplied by 0.7071 at centre and the whole stack sat exactly 3.010 dB below the same stack written without one — supersaw 7 24 0.5 0 was 3.01 dB under supersaw 7 24 0.5 by construction (dsp-quality-audit-2026-08-03.md §A4). Adding a width to an existing patch was a level change you then had to undo on the fader. Measured on the identical graph before and after, supersaw 7 24 0.5 0 moves by exactly +3.010 dB; against a no-width stack the residual is the node-id-keyed oscillator phase reseeding, not the law (7 voices, 8 spreads from 12 to 54 ¢: −0.41 … +1.56 dB, and width 1 sits within 0.19 dB of width 0 at every spread — the constant-power property). Every shipped patch with a width argument is now 3 dB louder; if a stack sits too far forward, that is the fader to ride.

    The 1/√n stack normalisation below is unchanged and independent of this.

  • drift (cents, optional; default 2.5) — independent free-running analog pitch wander per generated voice, using the same deterministic Drift node as an explicit freq pitch + drift 4Cents. A live signal may ride the depth; pass 0 for the former perfectly stationary stack.

unisonWith — write the per-voice law#

unison spaces its voices evenly and supersaw/jp8000 bake one fixed curve. unisonWith lets the song say what each voice does:

-- superlib's JP-8000 curve as a law: clustered inner voices, a wide outer
-- pair, the side voices a touch under the centre
jpCurve = [-1.0, -0.57, -0.18, 0.0, 0.18, 0.57, 1.0]
jpLaw   = \cents i n -> (nth i jpCurve * cents, if i == 3 then 1.0 else 0.8)
lead    = saw (freq pitch) |> unisonWith 7 (jpLaw 22) 0.8

-- a bare number is a detune at unit gain; intervals convert to cents
octaves = saw (freq pitch) |> unisonWith 3 (\i n -> i * 12st) 0.4 0

unisonWith : Sig Audio Bipolar -> count -> law -> [width] -> [drift] -> Sig Audio Bipolar. count is unison's (a whole number, 1–16). law is a two-argument function \i n -> cents or \i n -> (cents, gain): i is the voice index 0 .. n-1 and n the voice count, both plain numbers. The first result is the voice's detune in cents (a bare number is cents; st/Cents intervals convert); the optional second is the voice's relative gain (default 1; unclamped, so a law may boost a voice or flip its polarity with a negative gain). width (default 0.6) and drift (default 2.5 cents) are exactly unison's, including their live path.

The law is a compile-time operand, like shape's: the static evaluator applies it to (i, n) once per voice and only the numbers survive, as each voice's constant pitch bias and a constant gain stage — the graph unison builds for a constant spread, with nothing extra at run time. So the law must fold: a macro, a signal or a note parameter in it is refused by name. It may be written inline, as a top-level (or imported) lambda binding, or as a partial application of one (jpLaw 22 above). In the law, i and n are floats, but integer literals still divide as integers (1/2 is 0) — write 0.5.

Everything else is unison's: each voice re-lowers the input (decorrelated phases and random draws), voices pan by ORDER across width (the one-voice stack sits centred), each gets its own drift, and the sum is scaled by 1/√n. Where the laws coincide the IR is identical, node for node: unisonWith n (\i n -> (i / (n - 1) - 0.5) * s) is unison n s 1, and the same law returning unison's blend gain (1 − side) + side · 0.85 (with side = 2·|frac|) is unison n s (compiler/tests/unison_with.rs). engine/tests/unison_with.rs measures a two-sine stack offline — a Hann single-bin DFT finds the second voice at the law's +702 ¢ at the law's 0.25 gain and nothing at unison's symmetric ±351 ¢. Example song: examples/songs/unisonlaw.volta.

How it's implemented#

Two lowerings with one law. A stack of a bare saw — saw F where F is freq P with P built from note parameters, macros, song position, constants and arithmetic, optionally ± interval terms of the same kind (- fall * 24Semitones) and trailing + drift d [r] terms with constant depth and rate — lowers to one SawBank IR node (unison, supersaw and unisonWith; not jp8000). Everything else (other shapes, |> phase, a saw whose frequency carries an LFO or other per-voice source, a live source drift, a stack nested in another stack's voices) keeps the per-voice desugar below.

  • SawBank (ir/src/ir.rs, engine/src/voice/saw_bank.rs, engine/src/saw_bank.rs): the compiler bakes each voice's detune (spread·d_k, or the law's cents), level ((1 − side) + side·blend, or the law's gain) and pan (2·frac·width) exactly where the desugar bakes them, lowers F once, and keeps a live spread / blend / width / drift depth as shared ports with per-voice coefficients. The engine renders every voice in one event-driven bank (evolve volta_unison): one running sum per channel, a band-limited step per wrap from the free-running saw's own 16-tap kernel (sinc_blep), zero latency. Seeding mirrors the desugar — each voice draws its own initial phase and each drift its own salted wander — but from the bank's node id plus the voice index, so the drawn values differ from the desugar's. A block whose frequency or live control moves (glide, a live spread) renders in 16-sample stretches at each stretch's mean frequency; drift is read once per stretch. Live gestures move the baked per-voice numbers in place (ir/src/live.rs fields). Sound differences against the desugar, measured (engine/tests/evolve_quality.rs): with the default drift the desugar's voices ran the four-point polyBLEP (−3.7 dB at 12 kHz, −6.8 dB at 16 kHz, alias −83 dB below 12 kHz at C6); the bank keeps the sinc-BLEP's flat top octave and alias floor on every voice, drifting or not. Detune is computed in f64 (the desugar's f32 FreqDetune was off by ~0.01 cent).

The per-voice desugar is still pure compiler desugaring. The live path reuses the existing freq-port detune routing and the Pan node; only the width of the stack becomes dynamic.

  • compiler/src/builtins/controls.rs — the unison descriptor: count (a constant, 1–16), spread (cents), blend (0.85), width (0.6) and drift (2.5 cents), the last four bare literals or live signals.
  • compiler/src/lower/expr.rs and compiler/src/lower/expr/stacks.rs:
    • a pitch_bias_cents field on Lowerer, added at the BuiltinKind::Freq chokepoint as a constant PitchAdd (cents → semitones) before the pitch→Hz conversion — the constant-spread fast path, unchanged.
    • a pitch_bias_signal field — a Sig Control Cents node id. When set, the Freq arm sums it into the freq port, where the engine folds a Cents summand into the oscillator's instantaneous frequency. This is the live path: the spread expression is lowered once to a shared node, and voice k gets Const(frac_k) · spread cents.
    • the Unison arm (stacks.rs) folds count to a constant; for spread / blend / width / drift it picks the fast path when const_scalar matches a bare literal and the live path otherwise (so a macro — which fold_const_scalar would freeze at its initial — correctly goes live). It re-lowers its argument once per voice, applies the per-voice blend gain (apply_unison_blend) and stereo pan (apply_unison_width), sums the copies, and scales by 1/√n (incoherent voices sum in power). unison 1 returns the bare signal. Each voice re-lowers with a fresh top-level-inline cache so its bindings (and any randomPerNote draws) are independent — the decorrelation that makes a stack sound wide.
  • unisonWith is a function-operand builtin (compiler/src/builtins/fn_operands.rs, arrow lambda::VOICE_LAW); lower/expr/stacks.rs::lower_unison_with folds the law per voice (eval_voice_laws) and runs the constant-spread path with those values.

Example songs: examples/songs/supersaw.volta (static), examples/songs/superwide.volta (live spread/blend/width through a build-up), examples/songs/acidwall.volta (jp8000, the thesis-exact Szabo model), examples/songs/unisonlaw.volta (unisonWith, written laws).

jp8000 detune mix [width] [drift] is the third member of the family: the measured Szabo JP-8000 supersaw — 7 fixed voices at the published offset fractions, the 11th-order polynomial detune curve, and the measured centre/side mix law in place of 1/√n. Its detune/mix knobs are 0..1 and take the family's fast/live split (production-gaps §31 Z3): a bare literal folds the polynomial and the log-ratio→cents map at compile time; a macro / LFO / envelope bakes each voice's whole knob→cents law — 1200·log2(1 + off·g(x)), never the small-angle linearization — into a 2049-entry Shaper LUT over the knob's [0, 1] travel (and the mix law's two polynomials into two more), so riding the ONE knob from lush pad to rave swarm is exactly the hardware's move. The non-linear curve — flat to ~0.5, steep past ~0.9 — is what the macro actually traverses. Unlike unison/supersaw it also ends in a keytracked highpass at the played note (Q = 1/√2, on the summed stack), the thesis's last element — it strips the sub-fundamental intermodulation rumble the seven beating saws make, and is much of why the hardware stays clean in the low mids. See Analog character §7.

Its optional width has the same 0.6 default as unison and supersaw.

Phase decorrelation covers every free-standing oscillator. saw/square/ sine/triangle (Osc), pulse (PWM), and wavetable sources all draw a seeded per-voice initial phase, so any of them stack wide under unison. (fmOp operators and additive partials deliberately stay phase-0 — their internal phase relationships are the timbre.)

Deliberate layers can opt out with |> phase p, where p is a compile-time phase in cycles. For example, (saw f |> phase 0) + (sine f |> phase 0) restarts coherently on every note; leaving phase absent preserves the seeded decorrelation described above.

Notes & limits#

  • count is a compile-time constant; spread / blend / width may be live. N real oscillators are allocated at lowering (matching Serum's fixed voice-count setting). Changing the count live is out of scope — only the width/blend/pan go live.
  • A live control is lowered once and shared. An LFO in the spread slot runs a single time across the whole stack (lowering it per voice would run N out-of-phase copies — not what's wanted).
  • Drift is generated per voice. The optional depth expression is shared, but every generated voice gets a separately salted free-running wander.
  • Detunes pitched sources only. The bias is injected at freq, so a signal with no freq pitch inside produces identical copies (just louder, then level-compensated). unison is for oscillators.
  • Cost. A bare-saw stack is one SawBank whose cost grows with the number of wraps, not voices × samples. Any other source is n real oscillators per voice; mind the per-voice node count at high polyphony. width adds one Pan per voice on that path.
  • A filter inside a unison argument is instantiated per voice (the honest reading of the desugar) — N filter instances, N× the cost. Place the filter after the stack if you want one shared filter.