Use list operations, functions and records to build reusable musical structures beyond handwritten patterns.
The second wave of "Volta is a real programming language", shipped 2026-07-05. Design spec: Generate and transform musical structures (see its §14 for the shipped-vs-specced deltas). Everything here resolves at compile time into the static IR — no runtime VM, no closures in the IR.
Elaborator hygiene#
- Work limits — 200 000 β-reductions / builtin unfoldings per compile, plus a 128-deep cap on closure inlining.
EvaluationSessionowns these limits and its cancellation token; callers can tighten them for interactive work. Exhaustion and cancellation return errors immediately. Expression, static and collection evaluation propagate errors through their recursive calls, so later sibling expressions do not run after a failure. Call-frame guards restore the stack and depth on errors and unwinds. A failed query does not poison subsequent queries, although its work still consumes budget. - Lexical identities — local variables retain the identity assigned by checking through capture, substitution, kernel compilation and pattern transforms. Imported references retain their defining declaration's ID and source; aliases share that ID. Original spellings remain diagnostic labels. Verification rejects missing binders, duplicate declarations, inconsistent source ownership, mismatched builtin identities and type-only witnesses before executable lowering or static inspection; it also polls cancellation. Compilation-local IDs never become random salts or persistent engine-state keys.
- Static queries — a value that is not statically reducible is distinct from a failed computation. Kernel folding, musical receivers and the REPL preserve this distinction. Queries realize the target's dependencies, including forward references, with the full program's ambient settings. Unrelated values are not evaluated. Constructing a function does not run its body; consuming it includes its transitive dependencies. Closed value cycles, including cycles across conditional paths, produce a located error. There is no pending evaluator error channel for lowering to drain.
- Common calls — named, inline, local, returned and container-held functions use the same application and lexical capture rules. Partial applications keep their captures. Existing named builtin options and pipes normalize through shared call schemas; exact clock ratios survive calls. Instrument handles retain their identity when passed, returned or projected.
- Call-stack traces — every named-function inline records
(name, call-site, body-span). When a compile error's span falls inside an inlined body,CompileError::Traceframes render as= while elaboratingmkLeadat line 12, col 9under the normal caret. log : Str -> a -> a— identity on its value; side effect: a compile-time note carrying the value rendered byshow. Notes rideModule.diagnosticswithlevel: "note"(non-fatal): dotted underline + tooltip in the workbench, dimmednote:lines from the CLI, a· N note(s)mention in the status line.
Evaluated division by zero and signed 64-bit integer overflow produce spanned errors. The checker and elaborator share structural type judgements: known units survive callbacks, lists, record updates and conditional results. Annotations left unresolved by inference are checked when their values become known. Whole dimensionless ratios such as 4Hz / 2Hz work as integer counts; fractional ratios fail an Int obligation. Static short-circuiting still protects unevaluated arithmetic.
For map, filter, foldl, scan, iterate and unfold, callback argument/result types are checked before any elements are evaluated. filter requires a static boolean; folds keep compatible accumulator types between steps. Naming a callback, importing it, returning it or storing it in a record/list preserves those checks. Local and function annotations remain obligations even when the annotated value is unused. Nested branch results retain their live rates. iterate f seed n includes the seed and evaluates only the requested successors: one term calls f zero times. Both generators check the step's seed/state contract even when n is zero. unfold returns emitted values from (value, nextState) pairs (two-element lists also work). Scalar operation contracts distinguish compile-time-only operations from graph operations. == and % reject live operands during checking, including after function specialization. A live if chooses only scalar numbers/signals or booleans; structural choices fail even in an unused binding. Compatible branch units are converted before blending, and both branches still run. Open operand types retain deferred constraints in lexical schemes until specialization. Closure of every builtin's obligations remains in Language Foundations.
Unannotated lambda parameters have fresh inference identities. Let-bound and top-level schemes instantiate independently, while captured parameters retain their identity. Self-application (\x -> x x) is an infinite-type error, including in an unused binding. User functions may be partially applied. (make 2) 3 calls the function returned by make 2; (add 2) 3 also completes a two-parameter add. Builtin sections retain their documented receiver/argument conventions.
Tuples & destructuring#
pairs = zip [1, 2, 3] [10, 20, 30] -- List (Int, Int)
sums = map (\(a, b) -> a + b) pairs -- destructuring lambda param
z = let (m, n) = (2, 3) in m * n -- let destructuring
w = (head pairs).1 -- positional projection
(x, y) = head pairs -- top-level destructuring, in any
-- declaration position
zip returns tuples (the old two-element-list shape is gone). Tuple types spell (A, B). All tuples are compile-time values — one that survives to signal lowering is an error. Destructuring desugars at parse time to hidden bindings plus .0/.1 projections.
The projections — length, head, tail, nth — read a tuple as well as a list, so a zip pair opens either way (head p / p.0), and a named intermediate pair (first = head pairs) folds exactly like an inline one. That is what lets a computed song arrange pair generated widths with generated scene names; arrange also takes a zip pair directly as an entry:
widths = [16, 32, 16]
names = ["intro", "drop", "outro"]
spelled = map (\p -> [head p, head (tail p)]) (zip widths names) -- the projection spelling
set = song {
scenes = { intro = [bassTrack], drop = [bassTrack, leadTrack], outro = [bassTrack] },
arrange = arrange (zip widths names) -- the `zip` pair IS an entry
}
map / foldl / concat still mean "over a list" — the widening is on the projections only.
Strings as data#
show : a -> Str— stable rendering of any compile-time value:400Hz,0.5,Eb3,true,(1, 2),Accent 0.8. This format is API — it feeds splices andevents.++— string and list concatenation (compile-time).==— structural equality over compile-time values, including patterns (event-wise) and tuples. Numeric quantities convert comparable units; unrelated units and unrelated structural types are errors. Pattern equality includes velocity, sample selection, glide, tuning and per-note controls, while ignoring source offsets. Thus!"c4=0.2" == !"c4=1"is false.- Splices —
${expr}inside"…"and!"…"literals evaluates the hole and splices itsshown text before parsing (the mini parser is untouched; the static-plan property holds). A hole that doesn't reduce is a compile error at the hole's span.Pitch ± <n>st/<n>ctfolds to a transposed pitch at compile time, so!"${root} ~ ${root + 7st}"works.
kitOf = \name -> sound !"${name} ~ ${name} ${name}"
kicks = kitOf "bd-808"
Per-note params#
slot = trackParam "slot" -- default 0
amt = trackParam "amt" 0.4 -- declared default
bass = patch { source = sample "bass-sub" |> chopAt 8 slot, gain = vel }
b = play bass !"c2(slot=3) c2(slot=5, amt=0.9) c2(slot=1)"
(key=value[, …]) mini-notation tokens attach named numbers to events (TrackEvent.params); trackParam "key" [default] reads one as a Sig Control Normalized, constant per voice (NodeKind::TrackParam). Disambiguated from Euclid (3,8) by the ident= lookahead.
Polymeter#
!"{c2 e2 g2}%4" — the 3-step lane walks a 4-slot-per-cycle grid, wrapping (repeats after 3 cycles). Multiple lanes overlay: {bd sn, hh hh hh}; the grid defaults to the longest lane. mini.rs only.
Modulator completion#
| Builtin | Shape | Notes |
|---|---|---|
hold trig m | S&H | latches m on the trigger's rising edges, per sample |
quantizeTo [steps] m | snap | compile-time step list baked into the node |
cc "name" | MIDI CC | lowers to a MacroRef on a synthesised cc:<name> macro — knob + CC-learn for free |
linram from to over | one-shot ramp | linear from note-on; over is a duration in canonical seconds — a ms literal divides by 1000 like every other duration slot |
mkCurve a b c d | prepared spline | four fixed dimensionless points at input 0, 1/3, 2/3 and 1; Catmull-Rom interpolation with duplicated endpoint tangents |
Spline points must be finite in engine precision. Physical units, dB and live controls are rejected during checking, including through aliases, returned functions, partial calls and records. Named and local spline values pass to curve through ordinary value elaboration. The Curve Normalized type describes the input domain: curve clamps input to 0–1; the dimensionless output can overshoot. For example, curve (mkCurve 0 2 2 0) 0.5 yields 2.25.
Unit-carrying macros — macro "cutoff" 800Hz declares its unit from the initial's suffix; MacroDef gained unit (canonical spelling) and range (per-unit musical default, e.g. Hz → [0, 20000]). The workbench slider, MIDI CC mapping, shred SetMacro, and the (base + modulation) clamp all work in the macro's own range. Plain macros stay [0, 1] byte-identically.
Patterns as data#
events : Pattern a -> List (Event a)
patternData : Pattern a -> { period: Cycles, events: List (Event a) }
patternFromData : { period: Cycles, events: List (Event a) } -> Pattern a
patternOfEvents : List (Event a) -> Pattern a
seq : List (Pattern a) -> Pattern a -- concatenate in one cycle
par : List (Pattern a) -> Pattern a -- overlay (lcm-tiled)
shorten = \p ->
let data = patternData p in
patternFromData (data with {
events = map (\e -> e with { dur = e.dur / 2 }) data.events
})
With timingVersion = 2, par shares stack's rate-preserving common-period overlay. seq is the explicit fit operation: normalize each child's rate, then rescale its complete materialized period into an equal slot of one cycle. Thus seq [p] deliberately fits p to one cycle, while arrange [[1, p]] admits one cycle of its unchanged schedule. See Patterns: events over time §Timing versions; unversioned files use version 2 by default.
events returns complete sounding events (rests omitted; control-only events retained). An Event a supports these readable and writable fields:
| Field | Type | Meaning |
|---|---|---|
start, dur | Cycles | Onset and duration within the complete phrase |
value | a | Pitch, sample name, boolean or numeric value with its unit |
velocity | Float | Per-event factor, default 1; nonnegative |
pan | Float | Position in [-1, 1], default 0 |
slide | Bool | Slide into the following event |
params, controls | List (Str, Float) | Named per-note parameters and event controls |
e with { … } retains every unedited field, including opaque voice tags, glide overrides, chord roots/just intervals, sample variants/regions and source locations. Reading value on a control-only event is an error. Editing a payload does not refit its chord context; use the musical pitch combinators when a whole chord must transpose. Parameter/control updates replace the named list; duplicate keys are rejected. Events are compile-time values, not signals, and may be annotated Event Pitch, Event Hz, etc.
patternData / patternFromData preserve the period as well as events, so filtering the last note does not shorten the phrase. For example, distribute one expressive four-cycle motif between two instruments:
data = patternData !"<c3_=0.7 e3::a g3::b ~>"
first = patternFromData (data with {
events = filter (\e -> e.start < 2c) data.events
})
answer = patternFromData (data with {
events = filter (\e -> e.start >= 2c) data.events
})
Both parts retain four cycles, including trailing silence. Complete patterns also survive function arguments, closure capture, records and list operations without an event-record conversion. Equality includes musical metadata and period; source offsets do not affect it.
The period must be finite, positive and at most 65536 cycles. Up to 65536 events are accepted; their starts and durations must be finite and nonnegative, and each start must be strictly inside the period. Durations may extend beyond it. All event payloads must share a type and unit. An empty event list is valid. With timingVersion = 2, a fractional period is materialized into an integer common period, just like a fractional rate: a half-cycle phrase repeats twice in the queried one-cycle period.
Both constructors also accept plain { start, dur, value } records, which create events with default articulation. A record may also carry velocity, pan, slide, params and controls, read and validated exactly as an e with { … } update reads them; a field no event has (velocty) is refused. patternOfEvents infers its integer period from the last onset (one cycle for an empty list); it cannot recover trailing silent cycles. Rebuilding plain records intentionally drops the metadata that was not copied. Use the data pair and event with updates for lossless transformations. Design: Musical Phrases.
Inside the compiler (compiler/src/event_data.rs) an Event is a copy of the complete MiniEvent with its times in cycles, so a field-by-field copy must include follow (a chord-track follower's placeholder, resolved later in resolve_pattern_folding). A resolved pattern travels as its MiniPattern together with its rate multiplier. Projections keep Int, Bool and physical units rather than bare magnitudes, and collection predicates take the normal evaluator walk before static folding, so e.start and event updates resolve.
Bounded recursion#
rec tree = \depth p ->
if depth == 0 then p
else par [p, tree (depth - 1) (p |> fast 2)]
rec (soft keyword) makes the binding's own name visible in its body; termination is the gas/depth cap, whose error names the binding. Unmarked self-reference stays an early error suggesting rec.
Assertions & tests#
assert (length (events bassPat) == 4) "bass pattern lost a step"
up = \n p -> patternOfEvents (map (\e -> e with { value = e.value + n }) (events p))
@test
roundTrip = up 0st melody == melody
assert (cond) "msg" — top-level; the condition must fold to a static Bool; failure is a compile error (with elaboration trace); zero IR footprint. @test bindings must fold to Bool. Pattern equality compares compile-time pattern values — literals and patterns rebuilt through the patterns-as-data functions, as above; a pattern produced by a builtin transform such as transpose or fast is not a compile-time value, so transpose 0 melody == melody reports "did not reduce to a compile-time Bool" rather than a verdict. Test verdicts ride Module.diagnostics (kind: "Test", level ok/error, non-fatal). volta test <file> prints pass/FAIL per test and exits non-zero on failure; the workbench counts failures in the status line.
Named-effect collapse#
A named effect binding applied in signal position (source = saw (freq pitch) |> warmth) tags the nodes its inlining emitted into Synth.groups: [{ name, nodes }]. Audio arguments are pre-lowered outside the group, so the block contains only the effect's own nodes. The compiler still emits the groups, but the collapsed-block rendering (double border, double-click to expand) shipped in the TypeScript patchbay and was deleted with it 2026-08-09; the native rack (ui/src/rack.rs) does not read Synth.groups yet — an unported feature, tracked in Production Gaps.
Touch points#
Compiler: eval.rs (state, gas, traces, notes, show, tuples, patterns, equality), lex.rs (++, ==), parse.rs (tuples, destructuring, splices, rec, assert, bare annotations), check.rs (tuple/interp/Eq/Append typing), mini.rs ((key=value), {…}%N), ir.rs (TrackEvent.params, TrackParam/Hold/QuantizeTo/Linram nodes, MacroDef.unit/range, Synth.groups), builtins.rs, lower/{expr,pattern,track,application}.rs. Engine: engine/src/graph.rs, engine/src/voice/, engine/src/dsp.rs, engine/src/lib.rs. Browser page side: runtime/src/types.ts (the IR mirror), runtime/src/midi.ts. CLI: volta test, dimmed notes. (The TypeScript workbench's own scheduler/macros/signal-overlay/patchbay modules were deleted 2026-08-09; see git history.)