VOLTAreference

Reference / Language and libraries

Functions and reusable ideas

Language and libraries

Open in the workbench →
On this page · 4 sections

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. CtValue is the folded value type (Int/Float/Bool/Str/Pitch/UnitNum, plus SynthRef and Expr for the deferred cases, and Variant for Define your own value types sum-type values). EvalEnv is a lexical scope stack; SubstCtx exposes synth_fields (for p.cutoff patch-transform inlining), top_bindings (foldable constants), and top_lambdas. eval_expr is the structural substitution/fold pass; try_eval_static is the "reduce to a literal or bail" path used by if/case.
  • Conditionals (Stage 2): TExprKind::If / Case in eval_expr. A static condition picks the branch outright; a live ordered comparison lowers to an exact Compare and arithmetic selection, with both arms running. A bare numeric signal is not a boolean condition. case arms match via pattern_matches (CasePat::{Wildcard,Bind,Ctor,Int,Float,Bool,Str,Pitch} — Bind/Ctor are the variable- and constructor-patterns added by Define your own value types).
  • Lambdas / closures (Stage 3): App in eval_expr inlines both top-level lambdas (top_lambdas) and anonymous (\x -> body) arg, binding args as CtValue::Expr and 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 in compiler/src/check.rs (TExprKind::Lambda, typed Ty::Arrow).
  • Lists (Stage 4): builtins Range/Map/ListFilter/Zip/Length/Head/ Tail/Nth are descriptor-owned in compiler/src/builtins/list_algebra.rs — the compile-time family, whose every port is Compilation; Concat is described in compiler/src/builtins/shapes.rs because it also composes timelines. The checker carries list element types through projections. check/specialize.rs checks map/filter/foldl/ scan callbacks using types, without unrolling lists or running effects. Authored obligations in TExpr.constraints survive substitution and deferred computations; Length returns Int. The generative-feedback list-producers Iterate/Scan/Unfold ride the same path. eval::try_eval_list_builtin unfolds them all into TExprKind::List literals before lowering; range/iterate/unfold are capped at 200k elements.
  • List reductions: Mix unfolds to a left-nested + chain typed with the elements' type (so the lowered Sum keeps the signals' audio rate/unit); Foldl unrolls to nested Apps. Both in eval::try_eval_list_builtin. In a bare signal slot they self-elaborate: the Mix/Foldl arm of lower_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/Silence builtins; resolve_pattern_of/resolve_pure_pattern (compiler/src/lower/pattern.rs) and the Silence arms (lower/pattern.rs in-pattern, lower.rs for a bare top-level decl) produce mini::MiniPattern values, the same type mini-notation literals lower to.
  • Modulator builders (Stage 6): Smooth lowers to the runtime Slew node (signal-first alias of withSlew, the real one-pole smoother); StepSeq pre-walks its list arg, then emits a PatternMod with one evenly-spaced event per element (a numeric or boolean pattern argument steps per cycle instead). Both are descriptors in compiler/src/builtins/controls.rs, lowered in compiler/src/lower/expr/controls.rs.
  • Typed effect lambdas (Stage 7): Ty::Effect(in, out) in check.rs is structurally an Arrow [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 in parse.rs (before the binding) into Decl::Binding.annotations (ast.rs); plumbed verbatim onto Synth/Track/MacroDef/SongDef/ScoreDef in ir.rs. Opt-in metadata: nothing in the shipped workbench reads @doc/@category back off the IR (ui/src/moduleinfo.rs consumes only the song graph's dwell annotation; the song browser's ui/src/songmeta.rs scans @category from the source text, not the IR). The TypeScript "This module" panel that did read @doc was deleted 2026-08-09; see git history.
  • Tests (Stage 0): compiler/tests/golden_ir.rs snapshots IR to tests/snapshots/ for a hand-maintained golden_songs!{…} list of examples/songs/*.volta (a new song must be added to the list — it does not glob the dir) — re-bless with UPDATE_SNAPSHOTS=1; compiler/tests/types_props.rs property-tests the types.rs unit/rate algebra (add_units/mul_units/join_rate).

Notes & limits#

  • The evaluator is partial: anything touching a runtime signal (pitch, vel, an oscillator, an lfo) is deferred as expression structure rather than folded. if accepts live boolean comparisons with eager branches; case still requires a compile-time choice.
  • Explicit rec bindings 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.
  • smooth is the seconds-first spelling of the signal-first withSlew (smooth t m = m |> withSlew t) and lowers to the identical Slew IR node — a one-pole lag with time constant seconds (k = exp(-1/(fs·seconds)), y = y·k + x·(1−k)) — the engine's Slew node on every target (the Web Audio IIRFilterNode build 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). stepSeq takes a literal list (or one produced by range/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 warmth currently 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.