Use feedback from the running music to influence later choices and explore responsive generative patterns.
What it is#
Music that depends on a computed past, kept off the static-plan boundary by doing as much as possible at compile time. Three shipped tools:
- Compile-time generators — build a list from a seed and a step function, fully unrolled before the IR (
iterate,scan,unfold). No runtime state. - Seeded random walk —
rwalk, a deterministic walk over scale degrees baked into fixed pattern events; varies cycle to cycle, replays identically. - Seeded drunk modulation —
wander, a continuous control signal composed from a sample-and-hold smoothed by a slew (no new node kind).
Everything here is deterministic and reproducible: generators fold to literals, rwalk bakes into IR events, and wander reuses the seeded LfoSh. The timeline scrubber and visualizer therefore show exactly what plays, on both the native engine and the browser.
Surface#
-- iterate: x, f x, f (f x), … (n terms)
ramp = iterate (\x -> x * 1.5) 1 8
-- scan: running fold, keeps every intermediate (length xs + 1)
phases = scan (\a x -> a + x) 0 [0, 3, 5, 7, 2] -- [0, 0, 3, 8, 15, 17]
-- unfold: generate n terms; the step returns [emit, nextState] — a
-- two-element list, or equally a tuple (emit, nextState).
fib = unfold (\st -> [head st, [head (tail st), head st + head (tail st)]]) [0, 1] 12
-- feed any of them into a pattern (list bindings are reusable by name):
melody = patternOf (map (\i -> ~C3 + i * 1Semitone) fib)
-- rwalk: seeded walk over scale degrees → a pitch pattern. Resolved against
-- the ambient key; the optional 3rd arg unrolls a continuous multi-cycle walk.
lead = inScale (tonality "c minor") (rwalk 8 2 4) -- 8 steps, ±2 deg, 4 cycles
-- wander: a seeded "drunk" Sig Control Bipolar; the arg is its rate.
pad = patch { source = saw (freq pitch),
cutoff = const 800Hz + wander 0.12 * 600Hz, gain = vel * 0.4 }
examples/songs/generators.volta is the worked example (second half).
Signatures & semantics#
| Builtin | Shape | Result |
|---|---|---|
iterate f x n | (a -> a) -> a -> Int -> List a | [x, f x, f (f x), …], n terms |
scan f init xs | (b -> a -> b) -> b -> List a -> List b | [init, f init x0, …], len xs + 1 terms |
unfold f seed n | (s -> [a, s]) -> s -> Int -> List a (the step may return a tuple (a, s)) | up to n emitted values |
rwalk steps maxStep [cycles] | Int -> Int -> Int -> Pattern Pitch | a cycles-cycle pitch pattern |
wander rate | rate (Hz, a period, or a live rate) -> Sig Control Bipolar | a continuous seeded control signal |
- The generators' function/count ports are open (
PortTy::Open, likemap/range): the evaluator validates shape, not the checker — an explicit arrow would reject otherwise-fine bodies such as\x -> x + 2.iterate/unfoldneed a count that folds to a compile-timeInt(a literal or a static expression such asn * 2); a step or count that does not reduce leaves the call unreduced. The cap is 200 000 terms (shared withrange). rwalkclamps the running degree to ±14 (≈ ±2 octaves) so the walk meanders rather than drifts away. With no ambient key it defaults to C major; insideinScaleit uses the supplied tonality. The walk step is a seeded draw in[-maxStep, maxStep].cyclesdefaults to 1.wander ratesmooths a sample-and-hold whose hold period is1/ratewith a one-pole slew of τ =0.5/rate(half the period), turning the LFO's steps into a continuous curve. A live rate computes τ =0.5/rateper block from the rate node. Output is bipolar[-1, 1]; scale/offset it like any control signal.
How it's implemented#
- Stage 1 generators —
compiler/src/builtins/list_algebra.rsownsBuiltinKind::{Iterate, Scan, Unfold}with the rest of the compile-time list algebra (f x n/f init xs/f seed n, every port folded);compiler/src/eval.rstry_eval_list_builtinunrolls each into aListliteral by repeatedly emittingApp(f, …)and reducing it (same machinery asmap/foldl). No IR changes — the output is an ordinary list the existing lowering consumes. - List-binding reuse (a general fix that landed here) — a list-valued top-level binding has no scalar
CtValue, so the binding walk (compiler/src/lower.rs) stashes it asCtValue::Expr, andeval_expr'sVar(TopLevel)arm inlines it. This makesxs = range 0 8; map f xsandfib = unfold …; map f fibwork; scalar bindings are untouched. rwalk—BuiltinKind::Rwalk, a pattern constructor declared incompiler/src/builtins/pattern_constructors.rs;resolve_rwalkincompiler/src/lower/pattern.rsbuilds theMiniPattern. It reads the ambient key viamini::current_key()and resolves each degree withmini::resolve_degree.resolve_in_scalewas widened to accept anrwalkapplication (not only a!"…"literal) as its pattern argument. Multi-cycle unroll returnsmult = 1/cycles, which scales the track'scycle_beatsup so the walk plays at the intended rate (theiter/everymechanism).wander—BuiltinKind::Wander, a control descriptor incompiler/src/builtins/controls.rs;compiler/src/lower/expr/controls.rslowers it to anLfoShnode feeding aSlewnode (τ from the rate; a non-constant rate falls back to a live0.5 / ratequotient). Both node kinds already exist in the engine (engine/src/voice/eval_nodes.rs), so there is no engine/IR change — wander is pure compiler composition.
Determinism#
rwalk is deterministic by a splitmix-style hash of a call-site salt and the step index — the same substrate degrade / humanize use, and like them it does not read the module seed yet. The salt is whitespace-stable — the owning binding's name plus the occurrence index (lower/salt.rs::next_salt), not the byte offset — so a comment above the call cannot reshuffle the walk. Pin that coordinate before renaming a generative binding:
@identity "melody"
leadLine = rwalk 8 2
leadLine may change names while its baked draws retain the identity melody. To preserve an existing entry binding's exact draws, use its old name as the identity. Identities must be non-empty and unique among explicit identities in a module; imported identities are qualified by module path. Unannotated bindings retain the historical name-based behavior. This pins compile-time combinator randomness; runtime voice/node identity is separate. Because the walk bakes into fixed IR events, replay and the visualizer are exact. wander inherits the seeded LfoSh's per-voice (seed, synth, track, cycle, event, node_id, step) coordinate, so it replays bit-identically across the native engine and browser.
Tests#
compiler/src/lower/tests.rs—gen_iterate_unrolls_sequence,gen_scan_keeps_running_totals,gen_unfold_builds_fibonacci,gen_iterate_zero_terms_is_empty,gen_wander_composes_lfosh_into_slew,gen_rwalk_in_scale_stays_in_key,gen_rwalk_multicycle_spans_n_cycles,gen_rwalk_standalone_uses_ambient_key.engine/src/offline.rs—generative_feedback_wander_and_rwalk_renderrenders both on the native engine and asserts audible/finite/bounded.compiler/tests/snapshots/generators.json— golden IR for the example song.
Deferred#
markov table steps— the one remaining Stage-2 combinator. Its transition table (hatTable) wants a record/data type, so it waits on Define your own value types.- Stage 3 (per-note
trackParamcarry) — tracked in Functions and reusable ideas §12. - Stage 4 (runtime feedback in shreds) — tracked in Timed live processes §16; this is the only genuinely stateful case and lives in the shred interpreter, never in the audio graph. See Patterns that respond to playback §5 for the determinism contract.
- Browser wasm — the bundled wasm compiler must be rebuilt (
./build-wasm.sh) before the new builtins compile in the workbench.