VOLTAreference

Reference / Design a sound

Voice signal flow

Design a sound

Open in the workbench →
On this page · 5 sections

Understand how an instrument's source, filter, envelope and output connect, and when to define that path explicitly.

What it is#

A Volta voice has no fixed graph. The audio path a note plays is whatever the synth's out field expresses — an arbitrary signal DAG of oscillators, filters, envelopes, arithmetic, and effect nodes. A classic five-field patch (source / cutoff / resonance / ampEnv / gain) still works: the compiler synthesizes an equivalent out from those fields, so old songs sound identical. But a patch may instead declare out directly and own its whole graph — filters anywhere, any number, no filter at all, effects inline. Audio can also be written without patch { } ceremony (top-level audio bindings, audio-valued lambdas), routed through first-class per-voice effects (drive, delay, bitcrush, chorus, eq), widened to a real two-channel path with stereo l r, and sent through per-track effects on the mixer bus (reverb, pingpong, compress).

Surface#

-- Classic five-field patch — unchanged; desugars to source→lpf→×ampEnv→×gain.
sub = patch {
  source = sine (freq pitch), ampEnv = adsr 50ms 0ms 1.0 400ms,
  cutoff = const 240Hz, resonance = const 1, gain = vel * 0.55
}

-- Opt-out patch: declare `out`, own the graph. Per-voice effects pipe inline.
lead = patch {
  ampEnv = adsr 4ms 280ms 0.35 220ms, gain = vel * 0.4,
  out = saw (freq pitch)
      |> lpf (const 1200Hz) (const 4)
      |> mods.gain ampEnv
      |> drive 0.4
      |> delay (1/8c) 0.35      -- an eighth of a CYCLE; (1c/8) and 0.125c are the same
}

-- Top-level audio binding (no patch literal) — lowers to a single-`out` synth.
-- …unless nothing but another expression's operand slot ever reads it: see
-- "An operand is not an instrument" below.
warm : Signal Audio Bipolar =
  let osc = saw (freq pitch) in osc |> lpf (const 3000Hz) (const 1) |> drive 0.3

-- First-class effect chain, named via Ty::Effect. Structurally an Arrow [a] b.
crunch : Effect (Signal Audio Bipolar) (Signal Audio Bipolar) =
  \s -> s |> drive 0.4 |> chorus 0.3 |> eq 2 0 (-1)
bell = patch { ampEnv = adsr 1ms 320ms 0 260ms, out = crunch (sine (freq pitch)) }

-- Stereo voice: two mono signals merged into a real 2-channel path.
wide = patch { out = stereo (saw (freq pitch + 7Cents)) (saw (freq pitch - 7Cents)) }

-- Per-track effects live on the mixer bus; a track-transform lambda names a chain.
hall = \t -> t |> reverb 0.85 |> pingpong (1/8c) 0.4
leadTrack = play lead leadPart |> hall

Gotcha — apply the envelope yourself in an out patch. The five-field form auto-applies ampEnv/gain (it desugars to source → lpf → ×ampEnv → ×gain). An explicit-out patch does not — it owns its whole graph, so you must fold the envelope in with |> mods.gain ampEnv (as lead does above; on a signal, |> gain ampEnv reads the same way). Forget it and the voice has no amplitude envelope: notes hard-switch on/off and click. The compiler now warns (UnusedField) when a patch declares ampEnv/gain that its out graph never references — refine.rs.

See examples/songs/effects.volta (effect-typed lambdas, track transforms), examples/songs/drift.volta (shared hall reverb), examples/songs/morph.volta (per-track ping-pong).

How it's implemented#

  • Compiler — compiler/src/lower/expr.rs:
    • lower_synth_decl: if a patch has no out, synthesizes one from the legacy fields using existing NodeKind::Filter + NodeKind::Product (resonance defaults to Const 1.0); a patch with neither source nor out is a compile error. Otherwise the declared out is the authoritative path.
    • lower_audio_synth_decl: a top-level audio-typed binding lowers to a synth with a lone out field. Audio-valued functions use the common compile-time application path in compiler/src/eval/callables.rs. Named and anonymous calls share their audio inputs; named effect groups preserve presentation. Instrument receivers use compiler/src/lower/instruments.rs to reuse an existing handle or construct a fresh instance from a computed value.
    • compiler/src/lower/publish.rs: an operand is not an instrument. What the lowerer produced above is published only if something addresses it as an instrument, or if nothing reads it at all — see the next section.
    • Per-voice effect builtins: Drive { input, amount, bias }, Delay { input, time, feedback, mix }, Bitcrush, Chorus, Eq3 { low_db, mid_db, high_db }, Stereo { left, right }. delay/pingpong fold Cycles literals to seconds through the module's cycle length (lower/score.rs::fold_seconds).
    • Per-track effects: lower_track_expr (compiler/src/lower/track.rs) recognizes play … |> reverb/pingpong/compress and pushes onto Track.effects: Vec<TrackEffect> (TrackEffect::Reverb { decay, mix }, PingPong, Compress).
  • Type — compiler/src/types.rs Ty::Effect(Box<Ty>, Box<Ty>), parsed from Effect A B annotations and treated as structurally equivalent to Arrow [a] b; used for naming/documenting effect chains. Effect builtins themselves keep their signal-first endomorphism signatures in their descriptors (compiler/src/builtins/controls.rs for the per-voice effects, compiler/src/builtins/track_effects.rs for the bus ones).
  • Engine — engine/src/voice/: a voice compiles exactly the out field's DAG (engine/src/graph.rs's CompiledNode list); there is no hard-coded filter/amp/gain block. Envelopes are found wherever they sit in the DAG and are triggered/released by the note. The per-voice effect arms are spread over engine/src/voice/shaper.rs (drive, bitcrush), engine/src/voice/eval_nodes.rs (delay, stereo), engine/src/voice/modfx.rs (chorus) and engine/src/voice/filters.rs (eq); CompiledNode::Stereo widens the path to two channels, which then flows through the voice sum into the track bus.
  • Engine — engine/src/mixer.rs + engine/src/effect.rs: per-track effects are built once per track (EffectState) and run on the bus, so reverb/delay tails overlap across notes. reverb is an 8-line FDN (engine/src/fdn.rs, Householder feedback, HF damping, input diffusion, decorrelated taps) rather than a convolution.

An operand is not an instrument#

A top-level binding becomes an instrument the module PUBLISHES — that is what makes play warm ptn work for warm = sine 220Hz with no patch { } around it. Since 2026-09-22 (an owner decision, compiler/src/lower/publish.rs) that happens unless the binding is only an operand:

  • a binding some other expression consumes (a builtin port, a patch field, a mixer/effect section, an inline modulator, a with / patch from base, a morph side) and that nothing addresses as an instrument is not published. m = sine 220Hz with p = patch { source = fm m 2 } lowers exactly what patch { source = fm (sine 220Hz) 2 } lowers — one synth, p;
  • "addresses as an instrument" means the finished IR names it: a track's synth (from play / playOn / sound, however the receiver is spelled), a shred's trigger, a bus voice route. Play it as well as consume it and it is published, once;
  • a binding nothing references is still published. "Unused" was never the test — a sound you are sketching has to stay in the rack;
  • a kit field (kit.tone) follows the same rule under its own published name; a kit reached whole keeps every field, because which field a consumer will project is not knowable from the reference graph;
  • an unpublished operand still owns its declared verdicts. A type annotation is a claim about the annotated binding's value: bass : WarmBass with a cutoff outside WarmBass's 800Hz <= x <= 4000Hz is an error-level Refinement diagnostic (spanned at the predicate), and a ?hole in bass is solved to its satisfying interval, whether bass is played or only consumed by bass2 = bass with {…}, a knob-sugar lift, morph, or a kit field — word for word the same verdicts either way. The dropped synth is kept as an analysis-only graph for exactly those two passes (refine::check_module_with_operands, solve::annotate_holes); it gets no convention lint, cost stamp or rack card. A derived synth does not inherit the base's obligation: bass2 is not annotated, so it claims nothing (annotate it to check it). The diagnostic's synth names bass, the owner of the claim, not an instrument. Any later pass keyed by synth name that must see operands reads the dropped synths lower() gets back from lower/publish.rs, as those two passes do, or declared verdicts vanish.

Before this, every top-level audio binding published a synth, so naming an operand added a voiceless SynthTemplate the engine compiled at load and a card the workbench rack drew. Across the song corpus the rule removed 137 such entries from 68 songs and moved no audio (compiler/tests/golden_ir.rs; the before/after masters and per-track stems render bit-identically — measured offline, not listened to). The two operand kinds that still publish something extra are a play … operand (an extra, audible track) and a partialsOf … operand (an extra load-time analysis); both are named in compiler/tests/exit_matrix.rs's NAMED_VALUE_CHANGES_THE_GRAPH.

Notes & limits#

  • Effects split two ways by where they belong: drive/delay/bitcrush/ chorus/eq are per-voice (out-DAG nodes, one instance per note); reverb/pingpong/compress are per-track (one instance on the mixer bus). A per-voice reverb would cut its own tail and is deliberately not built.
  • delay/pingpong time folds Cycles constants via the cycle length, and since 2026-08-22 every spelling of one length agrees: delay (1/8c) fb, delay (1c/8) fb and delay 0.125c fb are all an eighth of a cycle. (Before that the fold converted each leaf to seconds first, so (1/8c) came out as 1 / (8 · cycle_seconds) — tempo-INVERTED; production-gaps §28 X1.) A bare (1/8) has no unit, so it reads as 0.125 seconds — a fixed time that does not follow the tempo; write the c for a cycle fraction.
  • The two track echoes take an optional 4th arg — delayTrack time feedback [damp] / pingpong time feedback [damp], damp ∈ [0, 1], default 0: a one-pole low-pass inside the feedback loop, so each repeat is darker than the last. damp = 0 builds no filter and is the old clean line, bit-for-bit (The effects reference). The per-voice delay has no such knob — its repeats live inside one note.
  • drive amount [bias] takes an optional DC bias (≈-1..1) added before the tanh. A nonzero bias makes the curve asymmetric, which adds even harmonics (tube/transformer warmth a symmetric tanh can't produce). The static offset is subtracted at the shaper so the curve still passes through 0; the residual dynamic DC is caught by the per-voice 10 Hz DC blocker. bias = 0 is the old symmetric drive, bit-for-bit.
  • drive also takes three optional split-path options, usually named in braces — drive 0.5 { keepLows = 200Hz, mix = 0.8, trim = -4dB }. keepLows (Hz) splits the input with a 2-pole Butterworth low-pass and its exact complement: the low band bypasses the shaper and only the band above it is driven, so a bass keeps its fundamental and octave (on a 55 Hz saw at drive 0.5, h2 re h1 measures −32.7 dB plain and −3.5 dB with keepLows = 200Hz, against −5.9 dB raw). mix (0–1, default 1) blends the driven band with its own dry, and trim (dB, default 0) scales the driven band after the blend. The second-order ADAA output is one sample late, so the dry and low bands are delayed one sample to meet it: at mix = 0 the output is the input one sample late, exactly. Inside a feedback loop the split runs first-order with no added delay, like the plain shaper there. All three omitted is the plain shaper, with the same IR (engine/tests/drive_split.rs).
  • stereo l r produces a real 2-channel voice path, but v0 does not track channels in the type — both inputs and the output are Signal Audio Bipolar. The type-level channel index (Signal Audio 2 Bipolar, channel-polymorphic Effect Mono Stereo) is a focused follow-up — see Voice signal flow §6.