VOLTAreference

Reference / Compose and perform

Timed live processes

Compose and perform

Open in the workbench →
On this page · 4 sections

Schedule a sequence of actions over musical time, alongside the repeating patterns in a song.

What it is#

A shred is the imperative half of Volta's authoring surface — a ChucK-flavoured block of statements that advances musical time explicitly and does things at each moment. Where a Pattern is a function from cycle phase to events, a shred reads top-to-bottom: set this macro, wait four beats, fire this note, loop. It is meant for the gestures patterns are awkward at — long evolving sweeps, read-modify-write over macros, hand-sequenced melodies.

A shred is not a runtime coroutine. The compiler lowers a do { … } block to a flat list of ops in the JSON IR, and the engine walks that list with a cursor against the same TempoClock the transport uses. So a shred inherits tempo-elasticity for free: a tempo = 90bpm edit retimes every in-flight wait without the shred knowing.

Two faces: sequence and performer#

A shred is one construct with two faces, and the compiler tells them apart from the ops it contains (volta_ir::score, Timed live processes §11):

  • A sequence has only time-determined statements: wait, :=, !"…" lanes, trigger, times, loop, wait for marker, record, start/stop. Its whole future is a function of the beat, like a pattern.
  • A performer has at least one statement that depends on live state: read, an if on a macro, wait until. Everything before the first one (its static prefix) is still known ahead. After it, the shred is only known live.

The face decides behaviour:

  • Timeline. A sequence draws its exact row. A performer draws its prefix, then a hatched "live from here" tail.
  • Seek, loop wrap and editing while playing. A sequence is replayed to the new position: it lands where an uninterrupted run would be, fires no notes or recordings, and each macro takes its final value or latest lane. A performer replays inside its prefix.
  • MCP reports the unroll, with a "live from" point for a performer.

No keyword selects the face. One read turns a sequence into a performer, and the Timeline shows where.

Surface#

A shred is any top-level binding whose value is a do { … } block (no score keyword — the lowerer detects shreds by value shape). Statements are ;-separated. Every top-level shred auto-launches when the first track starts.

bright    = macro "bright" 0.3
swirl     = macro "swirl"  0.5
duck      = macro "duck" 0.4
padBright = macro "padBright" 0.4

-- Stage 1: straight-line wait / set
sweep = do {
  bright := 0.1;
  swirl  := 0.2;
  wait 4beats;
  bright := 0.85;
  wait 4beats;
  bright := 0.3;
  wait 4beats
}

-- Stage 2 (partial): loop / times. A combinator wraps a block INSIDE the
-- shred, so the binding's own value stays a `do { … }` block.
bassRiff = do {
  loop do {
    trigger bass ~C2 1beat;   -- Stage 4: fire a synth voice
    wait 1beat;
    trigger bass ~Eb2 1beat;
    wait 1beat
  }
}
pump = do { times 16 do { duck := 0.25; wait 1beat } }

-- Stage 3 (minimal): read one macro's value into another
follow = do { loop do { padBright := read bright; wait 1beat } }

Shipped statement forms:

Functions may return a do-block to a top-level binding: make = \v -> do { bright := v; wait 500ms }, then sweep = make 0.8. Local bindings and computed constants inside it use ordinary lexical evaluation.

  • wait <duration> — advance shred-local time. Durations accept beats (4beats/4b), cycles (1c), seconds (1s), milliseconds (500ms); all fold to beats at lower time. Named constants, local expressions and function results work like literals. Durations depending on live reads remain outstanding — see Timed live processes §4. Unrelated units such as Hz are errors.
  • target := value — schedule a macro change at the current shred time. target is a macro's binding name or its string-literal name; value is a constant scalar. Macros only — synth-port targets are outstanding (Timed live processes §9).
  • target := read source — copy one macro's current value into another (Stage 3 minimal). Assignments, patterns and reads check the destination macro's unit and convert compatible scales (cents/semitones, seconds/ms); a bare number adopts the unit the macro was AUTHORED in, so gap := 20 over gap = macro "gap" 50ms is 20 ms, as gap + 20 is in a graph. No arithmetic on the read, no sample of a non-macro signal — both outstanding (Timed live processes §4).
  • trigger <instrument> <pitch> <duration> — fire a synth voice at the current beat, routed like a keyboard one-shot. Pitch and duration are constants; velocity defaults to 0.85 (Stage 4 partial). The instrument is an ordinary value, resolved the way play's is: a patch binding, an alias of one (bell = keys), an imported name, a factory result (bell = mk 440Hz) and a binding holding a kit field (bell = kit.voice) all reach the instrument they denote, never a copy. The verb takes a bare NAME, so spell a projection as its own binding rather than trigger kit.voice. Routing. trigger lead ~C4 0.45beats on: leadStrip sounds through that track's mixer strip: fader, inserts, sends, mute/solo, meters, and the track's velocity/gain/panTrack scalars. on: and vel: take either order, once each. on: is a track operand, like record … from: a track binding, a qualified import or the track's name as a string. It errors if the name is not a track. With no on:, the note routes through the one track that plays the same instrument. A chooseBy deck counts. bounceOf sources, plugin tracks and MIDI-out tracks don't. If no track plays the instrument, the note goes to the default bus. If several do, it also goes to the default bus, and the doctor reports AmbiguousTriggerRoute and suggests on:. A muted strip silences the note. A stopped track still passes it, because every strip is mixed and the shred owns the timing. A rest-only track (leadStrip = play lead !"~" |> pingpong …) is a strip, not silence, and the doctor skips it.
  • record "name" <duration> [from track] — the looper: capture the live input (or a track's bus) into the sample bank under name, starting at the current beat. Non-blocking (doesn't advance shred time); replaces any prior take. See Record a loop.
  • loop do { … } — repeat forever; times N do { … } — repeat N times.
  • stop ends this shred (it also satisfies loop's no-spin rule). stop name stops another shred. start name (re)launches another one from the current beat. An unknown name is a lowering error. These keep a shred a sequence.
  • @manual on a shred binding: it never auto-launches. Only start, the workbench chip or a scene starts it.
  • Scene-owned shreds. Naming a shred in a scene's member list (drop = [kickTrack, bassTrack, follower]) makes the scene own it. It starts on the scene's entry boundary and stops when the scene leaves, on the same sample the tracks change on. It never auto-launches. This works in song + arrange, songGraph, the opening scene and offline export. A manual scene-chip apply doesn't start or stop shreds yet.
  • target := !"…" — drive a macro from a looping numeric pattern anchored at the current beat (ScoreOp::SetMacroPattern); see Macros and automation.

Every shred verb is a CONTEXTUAL keyword. wait, trigger, loop, times, read, record and start/stop are recognised only in statement position inside a do { … } block (record by the record <string> shape). None of them is reserved, so all of them may name a top-level binding, a patch field, or a scene: record = bounceOf 4c (…) and a scene named record both parse.

  • if <cond> do { … } [else do { … }] (Runtime Control Flow Stage 5) — a runtime branch, evaluated against LIVE macro state when the shred reaches it. The condition compares a macro with a constant (if read energy > 0.6 do { lift := 1.0 } else do { lift := 0.4 } — the leading read is optional sugar). The constant reads like any comparison with the macro: a bare number in the macro's authored unit, any two scalar tags ordered (if knob > 0.5Ratio). Computed conditions ride Timed live processes Stage 3's evaluator when it ships. Structural like loop: it pushes one arm's frame and never gates on the lookahead horizon.
  • wait for marker "name" — jump shred time to a marker's bar (Schedule events on a timeline). Resolved at fire time against the module's marker table (a live marker edit retimes the wait); time never rewinds, so an already-passed marker is a no-op (and it does NOT satisfy loop's no-spin rule).
  • wait until onsets track|"input" [sens] — PARK the shred until the source's next transient. A trigger-conditioned wait has no known fire beat, so it lives outside the horizon model: the shred parks (Waiting state) and the block loop resumes it at the block start after the detector fires (the playOn detector, hoisted to the runner — same ~2-block latency and 50 ms refractory). Re-parks on every loop pass, so it satisfies the no-spin rule. onsets "input" marks the module non-deterministic and triggers the lazy mic open, exactly like record.
markers = form.markers [[16, "verse2"]]
vocals  = play vox mel          -- the track `onsets` watches
energy  = macro "energy" 0.5
lift    = macro "lift" 0.5
air     = macro "air" 0.5

conductor = do {
  wait for marker "verse2";
  if read energy > 0.6 do { lift := 1.0 } else do { lift := 0.4 };
  wait until onsets vocals;
  air := 0.8
}
glow = macro 0.15
lift = macro 0.0
kick = patch { source = sine (freq pitch), ampEnv = adsr 1ms 180ms 0.0 60ms }
bell = patch { source = sine (freq pitch), ampEnv = adsr 2ms 260ms 0.0 200ms }
kickTrack = play kick !"a1 ~ a1 ~"

follower = do { loop do { wait until onsets kickTrack 0.6; glow := 0.85; wait 0.25beats; glow := 0.25 } }

@manual
sparkle = do { loop do { trigger bell ~E5 0.25beats; wait 0.5beats } }

conductor = do { wait 32beats; start sparkle; lift := 0.7; wait 16beats; stop sparkle; stop }

main = song {
  scenes  = { intro = [kickTrack], drop = [kickTrack, follower] },
  arrange = [[4, intro], [8, drop]]
}

Demo songs:

  • examples/songs/shred.volta ("Shred", Stage 1).
  • examples/songs/shredgroove.volta: loop, times, trigger … on:, read.
  • examples/songs/shredstage.volta: a scene-owned performer, @manual, start/stop.

How it's implemented#

  • Compiler:
    • compiler/src/lex.rs — Tok::KwDo (do), Tok::ColonEq (:=).
    • compiler/src/ast.rs — ExprKind::Do(Vec<ScoreStmt>); the ScoreStmt enum (Set, SetFromRead, Wait, Trigger, Loop { count, body }).
    • compiler/src/parse.rs — parse_do; compiler/src/check.rs — TExprKind::Do typing; compiler/src/resolve.rs — Do exhaustiveness.
    • ir/src/ir.rs — ScoreDef { name, ops, source_offset, source_len, annotations }, listed in Module::scores. ScoreOp (tagged op) has Wait { beats }, SetMacro { name, value }, SetMacroFromMacro { name, source, scale }, Trigger { synth, pitch_midi, velocity, duration_beats, track } (track is the on: route, explicit or inferred in pass 2c route_score_triggers; skipped when None), Stop { target } / Start { target }, SetMacroPattern { name, events, length_beats }, Record { name, duration_beats, from } (the looper, Record a loop), If { cond, then_ops, else_ops }, WaitMarker { marker }, WaitTrigger { source, sensitivity }, and Loop { body, count: Option<u64> } (count = None ⇒ unbounded loop). A scene's shred members land in SectionDef.shreds. Every op carries source_offset/source_len for the editor flash.
    • compiler/src/lower/score.rs — pass 1b lower_score_decl / lower_score_stmt; fold_beats reduces all duration forms to beats (parallel to fold_seconds). Validation: a := target/source that isn't a macro errors (`foo` is not a macro — v0 shreds can only set macros); loop with no recursive wait (via body_has_wait) is rejected (would spin forever in one tick); times N without waits is admitted. trigger targets are re-pointed in pass 2a (resolve_trigger_instruments), which runs each one through lower::instruments::elaborate — the same instrument-receiver elaborator play uses — and then validated in pass 2c (validate_score_triggers), after the synth set is complete, so a shred can fire any instrument declared anywhere in the file.
    • runtime/src/types.ts — mirrors ScoreDef / ScoreOp for the browser product pages.
  • Shared IR helpers: ir/src/score.rs.
    • ScoreOp::is_reactive and ScoreDef::face() give the sequence/performer face.
    • unroll(score, anchor, horizon, marker_beat) is the static walk: notes, sets, lanes, records and start/stop cues by absolute beat, with a loop repeat index. It ends Done, Horizon or Live { span } at the first reactive op.
    • The Timeline, MCP and tests all use this one implementation.
  • Engine (one implementation for desktop, browser and the plugin target):
    • engine/src/shreds.rs: ShredRunner, stepped once per render block.
      • An instance is a cursor over an Arc<ScoreDef>: a frame stack of (op_index, remaining, arm) plus next_beat, anchor_beat and a ShredState (Idle, Running, Parked, Done, Stopped). Stepping never clones ops.
      • If evaluates its EdgeWhen against live macro state.
      • WaitTrigger parks the shred until the onset detector fires.
      • MAX_OPS_PER_STEP = 50_000 is the backstop against a runaway loop.
    • Hot reload (PreparedShreds, built control-side, so the audio thread allocates nothing) matches instances by name and compares a span-free shape_hash.
      • Unchanged: the cursor carries over.
      • A changed sequence replays from its anchor to now with side effects collapsed. No Trigger or Record fires. Each macro gets its final value. The latest lane is installed at its own anchor.
      • A changed performer replays if now is inside its prefix. Otherwise it finishes its current top-level loop iteration under the old ops, then continues under the new ones.
      • A seek or loop wrap replays the same way. Past a performer's prefix, it restarts at the target.
    • Commands StartTransport, StartShred, StopShred and StopAllShreds. StartTransport lets a song with no tracks run.
      • The audio thread publishes a per-shred state table each block (ShredStatePub), read through EngineHandle::shred_states().
      • Scene membership arrives as SectionDef.shreds. The arrangement runner applies it on the boundary sample (RunnerSink::apply_scene_shreds), and the offline export does too (ExportSegment.shreds).
    • The runner emits ShredActions, and the engine (engine/src/lib.rs) applies each at its exact fire sample.
      • Macro changes land at that sample.
      • A Trigger voice goes onto its routed track's strip, or the default bus when track_idx is None.
      • SetMacroPattern runs as a MacroLane. The lane holds its events by reference (PatternEvents: the score's Arc and the frame path to the op), so starting or replacing one allocates nothing on the callback.
    • Viz: each instance keeps a fixed ring of its last four fired ops (ShredOpMark). publish_viz copies ShredRunner::views() into the scratch (MAX_VIZ_SHREDS = 16). The frame carries them as the SH01 trailer, and the JSON snapshot as shreds: [{ name, state, beat, opOffset, opLen, nextBeat, anchorBeat, recent }].
  • Workbench:
    • Timeline (ui/src/timeline_shreds.rs): a SHREDS band below the tracks, one row per shred.
      • Drawn from unroll: notes placed by pitch, set lollipops coloured by macro, stepped lane curves, record windows, ▶/■ start/stop cues, ↺ where an unbounded loop's second pass begins, and the hatched tail of a performer.
      • The engine cursor draws as a tick, plus a "waiting for <source>" badge while the shred is parked.
      • The gutter shows ≈ name and the face. Rows are inert.
      • Rows are cached per module, span and anchor.
    • Editor flash (ui/src/playhead.rs): each op in the cursor's recent ring flashes its source span once the audible beat reaches it. Sets, reads, lanes, records and waits hold for 140 ms. A trigger holds for its note length, clamped to 140–600 ms.
    • ui/src/perform.rs lanes_ui: each lane <name> chip is a per-shred toggle. A lit chip (running or parked) stops its shred on click. A dark chip starts it from now, and starts the clock if the transport is stopped. Hover shows parked, done and stopped.
      • A transport chip, ■ SHREDS, stops every shred while the tracks play on.
      • State comes from EngineBridge::shred_states(). Hosts without it (browser, plugin) read it from the viz frame while the transport plays.
    • MCP song_structure (app-native/src/mcp/song.rs) adds shreds: per shred the name, face, whether it is endless, the macros it writes, the instruments it triggers, and a bounded unroll. The unroll is up to 48 ops, by bar and beat, with on: routing and start/stop cues, plus loop_restart and a performer's live_from.

Notes & limits#

  • Targets are macros only. Synth-port addressing (filter.cutoff := …) is unresolved (Timed live processes §9).
  • read is the minimal slice. target := read source copies one macro's value, converting units where needed. Still outstanding (Timed live processes §4), because they need the runtime expression evaluator: arithmetic on reads, lerp, sample of a non-macro signal, and live wait durations.
  • Stage 2 is partial: loop, times and stop/start ship. par and spawn do not (Timed live processes §3).
  • Lifecycle edges (Timed live processes §12 R3):
    • A manual scene-chip apply doesn't start or stop scene-owned shreds. Only the arrangement walk does.
    • Stopping a transport-launched shred by hand doesn't survive a seek; the seek replays it.
    • stop name takes effect at the end of the block, so another shred stepped earlier in the same block may fire up to one block late.
  • Performers can't be scrubbed past their prefix. A seek there restarts them at the target, because their past depended on live input.
  • Stage 5 (algebraic-effects surface) is outstanding. It waits on type-system work (Timed live processes §6). The sequence/performer face is its first, coarse effect row.
  • Determinism caveat: a read shred reproduces across re-render only if the source macro's automation is preserved. Under live scrubbing it re-runs with the macro's current value (Timed live processes §4.4).