VOLTAreference

Reference / Patterns and harmony

Patterns that respond to playback

Patterns and harmony

Open in the workbench →
On this page · 7 sections

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#

BuiltinShapeResult
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 Pitcha cycles-cycle pitch pattern
wander raterate (Hz, a period, or a live rate) -> Sig Control Bipolara continuous seeded control signal
  • The generators' function/count ports are open (PortTy::Open, like map/range): the evaluator validates shape, not the checker — an explicit arrow would reject otherwise-fine bodies such as \x -> x + 2. iterate/unfold need a count that folds to a compile-time Int (a literal or a static expression such as n * 2); a step or count that does not reduce leaves the call unreduced. The cap is 200 000 terms (shared with range).
  • rwalk clamps the running degree to ±14 (≈ ±2 octaves) so the walk meanders rather than drifts away. With no ambient key it defaults to C major; inside inScale it uses the supplied tonality. The walk step is a seeded draw in [-maxStep, maxStep]. cycles defaults to 1.
  • wander rate smooths a sample-and-hold whose hold period is 1/rate with 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/rate per 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.rs owns BuiltinKind::{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.rs try_eval_list_builtin unrolls each into a List literal by repeatedly emitting App(f, …) and reducing it (same machinery as map/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 as CtValue::Expr, and eval_expr's Var(TopLevel) arm inlines it. This makes xs = range 0 8; map f xs and fib = unfold …; map f fib work; scalar bindings are untouched.
  • rwalk — BuiltinKind::Rwalk, a pattern constructor declared in compiler/src/builtins/pattern_constructors.rs; resolve_rwalk in compiler/src/lower/pattern.rs builds the MiniPattern. It reads the ambient key via mini::current_key() and resolves each degree with mini::resolve_degree. resolve_in_scale was widened to accept an rwalk application (not only a !"…" literal) as its pattern argument. Multi-cycle unroll returns mult = 1/cycles, which scales the track's cycle_beats up so the walk plays at the intended rate (the iter/every mechanism).
  • wander — BuiltinKind::Wander, a control descriptor in compiler/src/builtins/controls.rs; compiler/src/lower/expr/controls.rs lowers it to an LfoSh node feeding a Slew node (τ from the rate; a non-constant rate falls back to a live 0.5 / rate quotient). 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_render renders 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 trackParam carry) — 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.