VOLTAreference

Reference / Compose and perform

Change music while it plays

Compose and perform

Open in the workbench →
On this page · 6 sections

Develop patterns and sounds during playback, using live editing and visual feedback to keep track of the result.

What it is#

The substrate that makes a Volta performance reproducible and observable. Three shipped capabilities sit on top of one keystone:

  • Per-synth note duration — a voice rings for the length of its pattern event, not a fixed 0.5s.
  • Deterministic RNG — every random draw (randomPerNote, sample-and-hold LFOs, granular jitter, indexed noise streams) is a pure function of (seed, voice coordinate). The same source, recompiled, plays bit-identically on first play, on replay, and after a page reload.
  • The visualizer protocol — a stable per-block viz frame published by the engine and consumed by every monitoring surface; see The harmony Lens.
  • Replay scrubbing (strategy A) — a ring buffer of recent snapshots the visualizer can be dragged back over for post-hoc visual inspection. Audio keeps playing live; the scrub is an inspection overlay, not a seek. It shipped on the deleted TypeScript timeline and has no native equivalent; the browser page host still keeps the ring (runtime/src/viz.ts).

Hot-swap recompile#

Every accepted edit recompiles, and the engine swaps the new module in without resetting the performance (Hot Swap; AudioEngine::swap_prepared in engine/src/lib.rs):

  • running tracks are remapped to the new module by name and keep their cursor; a track the new module no longer declares stops. The track's firing order is rebuilt on the next audio block, so edited notes take effect from there. The cursor is rebased only when the track's cycle length, clock ratio, anchor or phrase origin changed (or a meter edit moved the bar axis), so events keep landing relative to "now".
  • sounding voices keep the template they started with (each holds a snapshot of its synth); new notes use the new graph. When the edit to a synth is value-only — its live shape (volta_ir::live::synth_shape: the span-free voice identity with every live site masked) is unchanged — the swap also writes the new values into the cells of every older template of that synth something still holds, however many installs ago it was installed, so a text edit, a case release or an Undo reaches notes already sounding: a LiveConst glides (a ~15 ms chase), an envelope time takes effect from the next note (live_const::RetuneWatch plans the writes control-side from weak references; the audio thread does atomic stores and retires the list). A held preview voice of such a synth is moved onto the new template. A structurally edited synth keeps today's rule: its held notes finish on their old graph, and a held preview voice stops. Glide slots do not survive a recompile: a gliding voice keeps ringing, but the next note for that synth starts a fresh voice.
  • an unchanged effect chain (same-named track, bus or master insert chain with an identical spec, even when comments shifted its source offsets) keeps its live chain, so delay and reverb tails and compressor state ring straight through (engine/tests/fx_reuse.rs).
  • an effect chain whose only change is constant values (a reverb mix, a delay feedback) that its effects read every block is kept and retuned: the live chain takes the new values and its tails ring on, as if the knob had been turned (Retune in FxReuse, decided on the audio thread against the live chain). A plate-mix nudge measured a 2.9 dB dip in the wet tail before this and none after (engine/tests/swap_audibility.rs).
  • an edited effect chain is not cut: the old chain keeps processing the same dry input in parallel for 80 ms while its output fades out and the new chain's fades in (FADE_SECONDS). The two gains are linear and sum to one, not equal-power, because both chains carry the same dry component — a linear blend passes it through unchanged, where an equal-power blend would bump it by up to 3 dB mid-fade. The old tail therefore fades rather than stopping, and the new chain's character arrives promptly.

Macro values carry across by name when the unit and range contract is unchanged (a mid-ramp macro keeps ramping); track faders, mixer strips, the master strip and varispeed carry their smoothed values and ramp to the new declarations. The transport never resets; a tempo edit retimes the clock from the current beat. Compile errors change nothing — audio continues on the previous module.

Measured offline, not listened to (engine/tests/swap_audibility.rs): a swap of an identical program is bit-identical to no swap, with a sustained plain patch, behind track effects and beside a bounceOf print; a one-literal swap puts no jump past the song's own p99 at the swap; the block that consumes a swap costs what any block costs (an optimized cathode: 2.3 ms against a 2.1 ms median, all tracks started). A value-only swap also changes a sounding note: a held chord's installed cutoff edit converges to the edited program's own render, a reinstall of the original values (an Undo) returns it, and a structural edit leaves it on its old graph (engine/tests/held_retune.rs).

Live constants: a number moved without a swap#

Workbench gestures that turn a literal — the Rack case's knobs, Routes' depths — move the engine's compiled numbers in place while they move, with no compile reaching the engine and no swap. volta_ir::live (ir/src/live.rs) names those numbers: one walk over a compiled module lists its sites — every voice Const node, the numbers written inline on voice nodes (volta_ir::live's node_fields: ADSR times and punch, decay time, a pitch interval folded into the note (pitch + 7Semitones, a unison detune), drift depth and rate, a written LFO rate (@ 5Hz) or cycle period (@ 8c), comb feedback, follower times, tol bounds, an as3340's character), a synth's glide time, and every constant effect parameter of a track, bus or master chain — keyed by owner and ordinal. The key is valid between two modules exactly when their shape (the fingerprint with every site value, and the diagnostics, masked) agrees, so live::diff(installed, candidate) returns the sites a candidate changed or refuses a structural edit. The workbench diffs probe compiles once per gesture (ui/src/livemap.rs: changes that are lines in the literal, as nearly all are, need no compile per move) and sends UiCommand::SetLive; the engine resolves keys control-side (EngineHandle::set_live) and the audio thread writes cells (Command::SetLive, allocation-free).

The cells live with what reads them (engine/src/live_const.rs): a live Const compiles to CompiledNode::LiveConst, read per block and, when it moved, chased on the macro knobs' ~15 ms one-pole (live_const::chase) with a linear ramp inside each block — so a held note follows with a glide, not a staircase of one-block ramps (the one-block ramp splattered −48.5 dBc 250 Hz off a 1 kHz tone on a +12 dB gain edit, the chase −66.4; a track-insert cell chases the same way); an inline number is read from its cell where the evaluator uses it — per block for an interval, drift, LFO rate or period, comb feedback, follower time or tol bound (a held note follows: a pitch interval steps at the block edge, an LFO keeps its phase and changes speed) — or when a note latches it: an envelope time or punch, an as3340's character (configured once per voice) and the glide time take effect from the next note. A number with a *_node twin (a morph's ADSR fallback, a comb whose feedback is an expression) stays pinned, and a glide cannot be moved to or from zero (the shape keeps "glides" distinct). An effect parameter binds to its chain's cells only where the effect reads it per block (ParamBind { live }) — a parameter an effect bakes at build (a reverb's size) has no cell and is heard on install. A chain's cells are parked until one moves (engine/src/effect/live.rs, Parked): a slot sees no live table until a SetLive changes one of its cells, so every bound parameter reads as absent and the effect keeps the values it was built with; effects that branch on whether a parameter is bound (delays, ping-pong, tape, BBD delay and chorus, duck, duckBy, dynamic EQ, de-ess, mono-below, OTT's bit-exact depth-0 bypass, tune, width) also hold their bindings back and get them on the first move. A parked chain therefore renders bit-identically to one built with no cells, and the first move arms it (moving values, allocating nothing); a swap that only changes such constants keeps the chain and its tails (FxReuse::retune). Two numbers still get no cell (volta_ir::live::param_takes_cell): a tape bias (a bound bias builds a 33-point hysteresis calibration table — hundreds of ms on a rate's first build — for every tape) and the mix of a reverb/plate whose mix follows a macro-driven decay (a placeholder, not an authored number); those are heard on install, and a value edit to them rebuilds the chain. Once armed, a moved delay time, BBD clock or tuner carries its path: a BBD whose clock moved runs the integrated bucket-clock model, and the tuner's grain phase never re-aligns, so those approach the candidate's render rather than match it. A decay's constant peak stays pinned (its fast path). A build-time budget (a voice's ring-out from its comb feedback) keeps the installed value until the release install. Parked voice cells are bit-identical to the constants they replace (live_const::parked_voice_cells_are_bit_identical_across_songs). Because the cells belong to the template, a voice still ringing on the old template after the release install keeps the value it was moved to. LiveConst serializes as the Const it replaced, so render recipes and print keys do not change.

Live scrub (opt-in). An Alt-drag on a number in the Source editor only rewrites text by default; Run applies it. Display ▸ Editor & notebook ▸ Hear scrubs live (shell pref livescrub=1, off unless set) makes it audible: when the buffer at the grab is the installed program (the same gate the Rack's Routes and case use), the scrub is planned like a case knob — a line in the literal sends SetLive per move with no compile, otherwise each move compiles on a worker (latest wins), and a number that shapes the program is heard only on release. Nothing installs while the pointer is down; release installs the scrubbed buffer once through the shell's Run path, Escape puts the text and the moved cells back, and Undo (or Redo) of that one step reinstalls, so source and sound move together (ui/src/app/live_scrub.rs). The browser build has no compile worker for this, so the toggle is disabled there and a scrub only rewrites text.

The per-track keep / pattern-at-cycle-seam / rebuild classifier and its swap: 3 kept · 1 pattern@bar · 1 rebuilt status line belonged to the deleted TypeScript scheduler; runtime/src/hotswap.ts survives only for its sig helper.

Surface#

Mostly invisible — the substrate works without new syntax. What a user sees:

  • Note length now follows the pattern. A staccato pattern produces short voices; a slow-stretched pad blooms over its full event length.
  • A fixed seed is emitted per compile (0x5EED), so playback is reproducible by default. There is no seed N builtin yet.
  • The patch inspector and Lens pulse from live data. The native timeline has no visual-replay scrubber (see Stage D below).
-- the pad rings for its full (slowed) event length, not 0.5s
pad  = patch { source = saw (freq pitch), ampEnv = adsr 800ms 300ms 0.7 1200ms }
lead = play pad (!"c4 e4 g4 b4" |> slow 2)

How it's implemented#

One Rust engine (engine/) renders everywhere, so all four capabilities are engine-side; the browser page host around the wasm workbench keeps the small TypeScript pieces named below.

Stage A — note duration

  • The IR event carries a cycle-fraction duration (ir.rs TrackEvent). The engine maps it through the track's clock ratio in event_dynamics (engine/src/lib.rs) to a release sample, so a track under slow 2 or a polytempo clock (p/q) gets the right note length by construction. There is no fixed 0.5 s; one-shots (keyboard / MIDI / ⏵ audition) carry their own explicit duration_sec.

Stage B — deterministic RNG

  • Compiler: Module.seed: u64 (ir/src/ir.rs), defaulted to 0x5EED by default_seed(), set during lowering.
  • Engine: engine/src/rng.rs — a stateless, coordinate-addressed hash-PRNG, a bit-for-bit port of runtime/src/rng.ts (it reproduces JavaScript's Math.imul, ToInt32, Math.round and UTF-16 string folding exactly, and is pinned by vectors in engine/src/rng_vectors.in). draw(seed, …coords) folds each coordinate through a splitmix32-style avalanche into [0,1); there is no mutable stream, so draws are order-independent across voices.
  • The voice coordinate is (synth, track, cycle, event, node, …), so a voice rebuilt for the same performance position draws identical values on first play, on replay, and on another machine. A voice with no timeline coordinate (a live keyboard hit, a one-shot audition) is deliberately outside the deterministic timeline.
  • What this means for songs and for new DSP: Deterministic rendering.
  • Determinism test: runtime/scripts/determinism-test.mjs asserts purity, bit-identical replay, range, coordinate decorrelation, order significance, no string/number fold collisions, rough uniformity, and seed sensitivity, plus bit-identical curved tempo-map integration and its beats↔seconds round-trip.

Stage C — visualizer protocol

  • Engine: engine/src/viz.rs — the audio thread fills a fixed-size, allocation-free VizScratch each block and publishes it through a wait-free triple buffer. The control thread reads the latest and encodes a hand-rolled little-endian binary frame (encode_frame) plus a name table (encode_names) emitted once per module load and keyed by a generation counter. EngineHandle::read_viz_frame is the pump. A JSON VizSnapshotDto resolver is kept for compatibility.
  • Contents: transport (songClock), per-track cycle / phase / active events / RMS, macro values, and the master analysis. Per-node values are emitted only when a panel registers interest (Command::SetVizNodes — the rack / bench inspectors), because sampling every node per block is not free.
  • Consumers: ui/src/frame.rs decodes the frame into VizFrame / TrackFrame, and every native surface (timeline, rack, scopes, lens, backdrop, playing-step overlay) reads it. In the browser the page host (runtime/src/viz.ts, runtime/src/native-runtime.ts) additionally keeps the historical VizBus ring for page-side consumers.

Stage D — replay scrubbing (strategy A)

  • Shipped on the deleted TypeScript timeline only (a scrubber over the VizBus ring); see git history. The Rust workbench has no visual replay scrubber — Save ideas, compare versions and record takes's audible take scrub is a different feature (it re-renders, it does not replay frames).

Notes & limits#

  • Audio is never seeked. Strategy-A scrubbing is a visual overlay only; audio keeps playing live. Hearing a past cycle again (strategy B — re-rendering deterministic input via OfflineAudioContext with recorded module references + macro automation) is not implemented; see Change music while it plays §5.3.
  • The release tail rides on top of duration. Short events with long releases still overlap — handled by the polyphony cap, not by truncation.
  • One seed, no override. The seed is a fixed compile-time constant (0x5EED). The seed N builtin and the question of whether it is a binding, a song field, or a live runtime control are deferred — see Change music while it plays §3.1 / §6.
  • Live gestures are non-deterministic by design. Keyboard one-shots have no song coordinate; a module that reads live audio input is flagged Module.nondeterministic and its replay/export is not exact.
  • Per-node values need registered interest. With no inspector open, frames still carry cheap track/macro/analysis data but the per-node array is empty.
  • One cadence. The frame is published per audio block; a per-cycle consumer resamples from songClock + per-track phase.
  • The browser Lens pop-out page was deleted 2026-08-21; volta-viz is the surviving out-of-process visualizer — see The harmony Lens.