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, anifon 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.targetis a macro's binding name or its string-literal name;valueis 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, sogap := 20overgap = macro "gap" 50msis 20 ms, asgap + 20is in a graph. No arithmetic on the read, nosampleof 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 wayplay's is: apatchbinding, 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 thantrigger kit.voice. Routing.trigger lead ~C4 0.45beats on: leadStripsounds through that track's mixer strip: fader, inserts, sends, mute/solo, meters, and the track'svelocity/gain/panTrackscalars.on:andvel:take either order, once each.on:is a track operand, likerecord … 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 noon:, the note routes through the one track that plays the same instrument. AchooseBydeck counts.bounceOfsources, 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 reportsAmbiguousTriggerRouteand suggestson:. 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 undername, 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.stopends this shred (it also satisfiesloop's no-spin rule).stop namestops 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.@manualon a shred binding: it never auto-launches. Onlystart, 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 insong+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 leadingreadis 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 likeloop: 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 satisfyloop'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 (Waitingstate) and the block loop resumes it at the block start after the detector fires (theplayOndetector, hoisted to the runner — same ~2-block latency and 50 ms refractory). Re-parks on everylooppass, so it satisfies the no-spin rule.onsets "input"marks the module non-deterministic and triggers the lazy mic open, exactly likerecord.
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>); theScoreStmtenum (Set,SetFromRead,Wait,Trigger,Loop { count, body }).compiler/src/parse.rs—parse_do;compiler/src/check.rs—TExprKind::Dotyping;compiler/src/resolve.rs—Doexhaustiveness.ir/src/ir.rs—ScoreDef { name, ops, source_offset, source_len, annotations }, listed inModule::scores.ScoreOp(taggedop) hasWait { beats },SetMacro { name, value },SetMacroFromMacro { name, source, scale },Trigger { synth, pitch_midi, velocity, duration_beats, track }(trackis theon:route, explicit or inferred in pass 2croute_score_triggers; skipped whenNone),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 }, andLoop { body, count: Option<u64> }(count = None⇒ unboundedloop). A scene's shred members land inSectionDef.shreds. Every op carriessource_offset/source_lenfor the editor flash.compiler/src/lower/score.rs— pass 1blower_score_decl/lower_score_stmt;fold_beatsreduces all duration forms to beats (parallel tofold_seconds). Validation: a:=target/source that isn't a macro errors (`foo` is not a macro — v0 shreds can only set macros);loopwith no recursivewait(viabody_has_wait) is rejected (would spin forever in one tick);times Nwithout waits is admitted.triggertargets are re-pointed in pass 2a (resolve_trigger_instruments), which runs each one throughlower::instruments::elaborate— the same instrument-receiver elaboratorplayuses — 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— mirrorsScoreDef/ScoreOpfor the browser product pages.
- Shared IR helpers:
ir/src/score.rs.ScoreOp::is_reactiveandScoreDef::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 looprepeatindex. It endsDone,HorizonorLive { 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)plusnext_beat,anchor_beatand aShredState(Idle,Running,Parked,Done,Stopped). Stepping never clones ops. Ifevaluates itsEdgeWhenagainst live macro state.WaitTriggerparks the shred until the onset detector fires.MAX_OPS_PER_STEP = 50_000is the backstop against a runaway loop.
- An instance is a cursor over an
- Hot reload (
PreparedShreds, built control-side, so the audio thread allocates nothing) matches instances by name and compares a span-freeshape_hash.- Unchanged: the cursor carries over.
- A changed sequence replays from its anchor to now with side effects collapsed. No
TriggerorRecordfires. 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,StopShredandStopAllShreds.StartTransportlets a song with no tracks run.- The audio thread publishes a per-shred state table each block (
ShredStatePub), read throughEngineHandle::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 audio thread publishes a per-shred state table each block (
- 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
Triggervoice goes onto its routed track's strip, or the default bus whentrack_idxisNone. SetMacroPatternruns as aMacroLane. The lane holds its events by reference (PatternEvents: the score'sArcand 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_vizcopiesShredRunner::views()into the scratch (MAX_VIZ_SHREDS = 16). The frame carries them as theSH01trailer, and the JSON snapshot asshreds: [{ 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
≈ nameand the face. Rows are inert. - Rows are cached per module, span and anchor.
- Drawn from
- Editor flash (
ui/src/playhead.rs): each op in the cursor'srecentring 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.rslanes_ui: eachlane <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) addsshreds: 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, withon:routing and start/stop cues, plusloop_restartand a performer'slive_from.
- Timeline (
Notes & limits#
- Targets are macros only. Synth-port addressing (
filter.cutoff := …) is unresolved (Timed live processes §9). readis the minimal slice.target := read sourcecopies 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,sampleof a non-macro signal, and livewaitdurations.- Stage 2 is partial:
loop,timesandstop/startship.parandspawndo 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 nametakes 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
readshred 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).