Give a repeated musical idea a name, turn it into a function, and combine it with other values.
What it is#
Volta source resolves entirely at compile time into a static IR — there is no runtime VM or runtime closure. Live signal choices select among static graph branches. The piece that does that resolution is the compile-time evaluator (the elaborator): it walks the typed AST, folds everything it can statically determine, and leaves the rest as expression structure for the lowerer to emit as IR nodes. Stages 0–8 grow that evaluator from an ad-hoc β-reducer into a small functional meta-language — conditionals, lambdas-with-closures, lists, pattern values, modulator builders, typed effect chains, and a doc-string surface — without ever giving up the "every compile produces one complete, inspectable plan" property the runtime, hot-reload, and visualizer all depend on. Companion notes: Expressiveness.
Surface#
Conditionals fold when their condition is static; the discarded arm is not evaluated. Comparisons over live signals select between eagerly running arms. An unselected live arm keeps advancing its state, so selecting it does not restart its oscillators or stateful kernels:
section = "drop"
hatGain = \v -> if v > 0.6 then 0.8 else 0.4
pitchMod = case section of {
"intro" -> 0,
"drop" -> 4,
_ -> -3
}
Lambdas with closures, partial application, patch / effect / pattern builders:
mkPluck = \brightness -> patch {
source = saw (freq pitch),
cutoff = const 400Hz + brightness * 2000Hz,
}
softPluck = mkPluck 0.3 -- top-level lambda inlined at the call site
brightPluck = mkPluck 0.9
Patch updates use the same rule through a function as at the call site:
base = patch { source = saw (freq pitch) }
filtered = base with { cutoff = 1000Hz }
addFilter = \p -> p with { cutoff = 1000Hz }
alsoFiltered = addFilter base
Both patches contain the filter. Updates replace existing fields and add new ones, including through imported, returned and locally bound functions. Saved @knobs settings survive updates. Reading a missing base field is an error; the compiler does not discard that update.
An application needs a callable value. Bare saw is a control signal, so osc = saw binds a signal and osc 440Hz is an error. To name the oscillator function, write osc = \frequency -> saw frequency. A callable builtin alias such as osc = lfoSine retains its musical inputs (osc (1/4c) works); an explicit signature such as osc : Hz -> Signal Control Bipolar = lfoSine restricts that helper to the authored frequency domain. A voice track whose instrument does not resolve to an emitted synth fails compilation.
Lists with range / map / filter / zip / head / tail / length / concat, plus the reductions mix (sum a signal list) and foldl (general left fold), consumed by patterns and arithmetic:
steps = range 0 16 -- [0, 1, …, 15]
chromatic = map (\i -> ~C3 + i * 1Semitone) steps
arp = patternOf chromatic -- list → Pattern
-- mix collapses a generated list of signals into one — a supersaw with no
-- builtin: map the detuned voices, mix them back together.
super = mix (map (\s -> saw (freq pitch + s * 25Cents))
[-1.0, -0.5, 0.0, 0.5, 1.0]) |> gain 0.45
total = foldl (\a x -> a + x) 0 (range 1 9) -- compile-time value: 36
mix unfolds to s1 + … + sn and lowers to one Sum tagged with the elements' audio rate/unit; foldl unrolls to nested applications. Summing signals can use mix or a fold: arithmetic types and rates are refreshed when the lambda's actual operands become known. See examples/songs/superlib.volta.
Each of these names has a parameter schema, so its arguments can be given as named options (map { f = \i -> i * 2, xs = steps }) and giving too many names the accepted form (`map` takes at most 2 arguments: `f xs`). The element types are not part of that schema: they are inferred per call, so one name carries numbers, units, strings and functions in turn.
map checks its function against every possible element type. filter needs a function returning a compile-time Bool; a numeric result or live comparison is an error. foldl and scan preserve a compatible accumulator structure and unit between steps. These checks also apply to named, imported, returned and container-held functions. An annotation on an unused local still has to hold. An Int callback rejects fractional arguments, and an integer annotation also rejects a fractional result exposed by folding (1 / 2, including in an aggregate). An exact quotient such as 4 / 2 still satisfies Int. An Hz contract rejects cents, semitones and cycle periods through these same paths, including declared constructor payloads and numeric pattern suffixes. Every branch must have a compatible physical domain. Seconds/milliseconds and cents/semitones are compatible scales; annotations retain the value's scale, so they do not normalize its representation. Range constraints remain separate: Normalized alone does not prove bounds of 0–1 or clamp a value into that range. Existing gain and resonance compatibility (Linear/Db, Q/Normalized) remains supported.
Pattern values, modulator builders, typed effect lambdas, annotations:
quiet = silence
hit = pure ~C4 -- one event per cycle
env = smooth 50ms (macro "cutoff" 0.5) -- one-pole slew
seq8 = stepSeq [0.2, 0.5, 0.8, 1.0] -- cycle-aligned control steps
warmth : Effect (Sig Audio Bipolar) (Sig Audio Bipolar) =
\sig -> sig |> drive 0.3 |> chorus 0.4
@doc "Low-pass filtered saw — the lead patch."
@category "synth"
lead = patch { source = saw (freq pitch) |> warmth }
How it's implemented#
- Evaluator:
compiler/src/eval.rs.CtValueis the folded value type (Int/Float/Bool/Str/Pitch/UnitNum, plusSynthRefandExprfor the deferred cases, andVariantfor Define your own value types sum-type values).EvalEnvis a lexical scope stack;SubstCtxexposessynth_fields(forp.cutoffpatch-transform inlining),top_bindings(foldable constants), andtop_lambdas.eval_expris the structural substitution/fold pass;try_eval_staticis the "reduce to a literal or bail" path used byif/case. - Conditionals (Stage 2):
TExprKind::If/Caseineval_expr. A static condition picks the branch outright; a live ordered comparison lowers to an exactCompareand arithmetic selection, with both arms running. A bare numeric signal is not a boolean condition.casearms match viapattern_matches(CasePat::{Wildcard,Bind,Ctor,Int,Float,Bool,Str,Pitch}—Bind/Ctorare the variable- and constructor-patterns added by Define your own value types). - Lambdas / closures (Stage 3):
Appineval_exprinlines both top-level lambdas (top_lambdas) and anonymous(\x -> body) arg, binding args asCtValue::Exprand re-walking the body. Partial user application captures supplied arguments in a closure with the remaining parameters; applying a known non-callable is an error. Checked incompiler/src/check.rs(TExprKind::Lambda, typedTy::Arrow). - Lists (Stage 4): builtins
Range/Map/ListFilter/Zip/Length/Head/Tail/Nthare descriptor-owned incompiler/src/builtins/list_algebra.rs— the compile-time family, whose every port isCompilation;Concatis described incompiler/src/builtins/shapes.rsbecause it also composes timelines. The checker carries list element types through projections.check/specialize.rschecksmap/filter/foldl/scancallbacks using types, without unrolling lists or running effects. Authored obligations inTExpr.constraintssurvive substitution and deferred computations;LengthreturnsInt. The generative-feedback list-producersIterate/Scan/Unfoldride the same path.eval::try_eval_list_builtinunfolds them all intoTExprKind::Listliterals before lowering;range/iterate/unfoldare capped at 200k elements. - List reductions:
Mixunfolds to a left-nested+chain typed with the elements' type (so the loweredSumkeeps the signals' audio rate/unit);Foldlunrolls to nestedApps. Both ineval::try_eval_list_builtin. In a bare signal slot they self-elaborate: theMix/Foldlarm oflower_app(compiler/src/lower/expr.rs) runs the evaluator on the reconstructed application, then lowers the result (erroring if it didn't unfold — a non-literal/empty list). - Pattern values (Stage 5):
PatternOf/PurePat/Silencebuiltins;resolve_pattern_of/resolve_pure_pattern(compiler/src/lower/pattern.rs) and theSilencearms (lower/pattern.rsin-pattern,lower.rsfor a bare top-level decl) producemini::MiniPatternvalues, the same type mini-notation literals lower to. - Modulator builders (Stage 6):
Smoothlowers to the runtimeSlewnode (signal-first alias ofwithSlew, the real one-pole smoother);StepSeqpre-walks its list arg, then emits aPatternModwith one evenly-spaced event per element (a numeric or boolean pattern argument steps per cycle instead). Both are descriptors incompiler/src/builtins/controls.rs, lowered incompiler/src/lower/expr/controls.rs. - Typed effect lambdas (Stage 7):
Ty::Effect(in, out)incheck.rsis structurally anArrow [in] out, so an effect-typed closure inlines through the Stage-3 lambda path wherever an arrow is expected. - Annotations (Stage 8):
@key "value"parsed inparse.rs(before the binding) intoDecl::Binding.annotations(ast.rs); plumbed verbatim ontoSynth/Track/MacroDef/SongDef/ScoreDefinir.rs. Opt-in metadata: nothing in the shipped workbench reads@doc/@categoryback off the IR (ui/src/moduleinfo.rsconsumes only the song graph'sdwellannotation; the song browser'sui/src/songmeta.rsscans@categoryfrom the source text, not the IR). The TypeScript "This module" panel that did read@docwas deleted 2026-08-09; see git history. - Tests (Stage 0):
compiler/tests/golden_ir.rssnapshots IR totests/snapshots/for a hand-maintainedgolden_songs!{…}list ofexamples/songs/*.volta(a new song must be added to the list — it does not glob the dir) — re-bless withUPDATE_SNAPSHOTS=1;compiler/tests/types_props.rsproperty-tests thetypes.rsunit/rate algebra (add_units/mul_units/join_rate).
Notes & limits#
- The evaluator is partial: anything touching a runtime signal (
pitch,vel, an oscillator, anlfo) is deferred as expression structure rather than folded.ifaccepts live boolean comparisons with eager branches;casestill requires a compile-time choice. - Explicit
recbindings support bounded recursion. Unmarked self-reference is rejected; see Generate and transform musical structures for limits. - Full general inference over every builtin and generator remains open. Ordinary list/callback types are checked before evaluation; unknown types retain obligations until the value becomes known. Empty lists can retain an unknown element type. Type specialization is bounded independently from evaluator gas; excessive nesting is an error.
smoothis the seconds-first spelling of the signal-firstwithSlew(smooth t m=m |> withSlew t) and lowers to the identicalSlewIR node — a one-pole lag with time constantseconds(k = exp(-1/(fs·seconds)),y = y·k + x·(1−k)) — the engine'sSlewnode on every target (the Web AudioIIRFilterNodebuild died with that path); module-level macro graphs use the identical coefficient. Smoothing a folded constant is a no-op. Piping into it —m |> smooth 120ms— puts the receiver in the SIGNAL slot, not in the seconds slot the pipe would otherwise fill, so both spellings mean the same one-pole (compiler/src/check.rs,infer_app; before 2026-09-16 the piped form silently smoothed the time literal with the signal as its time constant).stepSeqtakes a literal list (or one produced byrange/map) or a numeric/boolean pattern; any other argument is a type error.- Stage 7 only composes existing built-in effects; it does not let users author new DSP. Named-effect IR collapse for the visualizer is still outstanding (see Functions and reusable ideas §10.2) — an inlined
warmthcurrently appears as its expansion. - Stages 10–13 shipped under sibling specs after this doc was written — Arrange a song (which supersedes Functions and reusable ideas §13), Configurable library modules §4, Velocity and accents, Timed live processes — leaving only Stage 9 (per-note
trackParams). The Functions and reusable ideas spec's own Stage 9–13 markers are not re-synced to reflect this.