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 1is 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, wheresideis 0 at the centre and 1 at the outermost.width(0..1, optional; default 0.6) — stereo spread. Voicekis panned tofrac_k · 2 · widthacross the field (the centre stays centred, the edges fan fully out atwidth 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² = 2at every position. Sowidthmoves the stack around the field without changing how much of each voice there is.width 0deliberately centres the stack; the defaultwidth 0.6opens 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 awidthargument 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 0was 3.01 dB undersupersaw 7 24 0.5by construction (dsp-quality-audit-2026-08-03.md§A4). Adding awidthto 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 0moves 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, andwidth 1sits within 0.19 dB ofwidth 0at every spread — the constant-power property). Every shipped patch with awidthargument is now 3 dB louder; if a stack sits too far forward, that is the fader to ride.The
1/√nstack 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 deterministicDriftnode as an explicitfreq pitch + drift 4Cents. A live signal may ride the depth; pass0for 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, lowersFonce, 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 (evolvevolta_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.rsfields). 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 f32FreqDetunewas 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— theunisondescriptor:count(a constant, 1–16),spread(cents),blend(0.85),width(0.6) anddrift(2.5 cents), the last four bare literals or live signals.compiler/src/lower/expr.rsandcompiler/src/lower/expr/stacks.rs:- a
pitch_bias_centsfield onLowerer, added at theBuiltinKind::Freqchokepoint as a constantPitchAdd(cents → semitones) before the pitch→Hz conversion — the constant-spread fast path, unchanged. - a
pitch_bias_signalfield — aSig Control Centsnode id. When set, theFreqarm sums it into the freq port, where the engine folds aCentssummand into the oscillator's instantaneous frequency. This is the live path: the spread expression is lowered once to a shared node, and voicekgetsConst(frac_k) · spreadcents. - the
Unisonarm (stacks.rs) foldscountto a constant; forspread/blend/width/driftit picks the fast path whenconst_scalarmatches a bare literal and the live path otherwise (so amacro— whichfold_const_scalarwould 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 by1/√n(incoherent voices sum in power).unison 1returns the bare signal. Each voice re-lowers with a fresh top-level-inline cache so its bindings (and anyrandomPerNotedraws) are independent — the decorrelation that makes a stack sound wide.
- a
unisonWithis a function-operand builtin (compiler/src/builtins/fn_operands.rs, arrowlambda::VOICE_LAW);lower/expr/stacks.rs::lower_unison_withfolds 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#
countis a compile-time constant;spread/blend/widthmay 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
spreadslot 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 nofreq pitchinside produces identical copies (just louder, then level-compensated).unisonis for oscillators. - Cost. A bare-saw stack is one
SawBankwhose cost grows with the number of wraps, not voices × samples. Any other source isnreal oscillators per voice; mind the per-voice node count at high polyphony.widthadds onePanper voice on that path. - A filter inside a
unisonargument 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.