Choose when each part enters, create contrasting sections, and shape the transitions that give a piece its form.
The horizontal axis of a song: the order/length of sections, the connective material between them, and parameter motion across them. Design history and rationale live in Arrange a song (design/shipped/); this is the as-built reference. The compiler model, live walk and offline render are shared by the native desktop and browser; the native Timeline and Bench Bay project the same song-time model.
Surface#
riserTrack = play lead !"c5"
set = song {
scenes = {
intro = [kickTrack],
groove = [kickTrack, bassTrack] with { air = 0.2 },
drop = [kickTrack, bassTrack, leadTrack] with { air = ramp 0.3 1.0 },
outro = [kickTrack]
},
edges = {
groove -> drop : [1, [riserTrack]], -- a 1-bar lead-in on this handoff
* -> drop : [1, [riserTrack]], -- …into the drop from anywhere
* -> * : [1, [kickTrack]] replace
},
arrange = arrange [[8, intro], [16, groove], [32, drop], [16, groove], [8, outro]]
|> rev -- deterministic algebra, baked at compile time
}
The legacy bare form (song { intro = [t], drop = [t] }) still compiles. With no arrange field it has no walk: its scenes are a launchable inventory, and ▶ starts every declared track.
The two forms read field names differently. A song that names scenes or timeline is in the configured form, whose whole vocabulary is parse.rs::SONG_FIELDS (scenes, arrange, edges, timeline, length, playback): any other name is refused at parse time with that list, so a typo cannot resolve as a scene. In the bare form every field but arrange/edges is a scene, so a misspelled field there is a scene of that name. timeline_form_contracts::every_song_field_is_read_by_the_lowering fails if the table gains a name the lowering does not read.
Only a declaration written name = song { … } (or songGraph { … }) in the entry file becomes the module's arrangement. A song in an imported file, one built by a function (demo = mkSong 8) or one nested in a record is a value and emits nothing. UnemittedSong names the binding: always for a built or nested song, and for an imported one when the module ends with no arrangement.
Phrase origins#
Choose how each track's phrase lines up when its section enters:
motif = !"<c3 e3 g3 ~>"
motifTrack = play "saw" motif |> phraseOrigin "entry"
| Origin | At an entry on bar 3, for the four-cycle motif above |
|---|---|
"transport" (default) | Plays the fourth, silent cycle; C3 arrives at bar 4 |
"entry" | Starts at C3 on bar 3, then E3 on bar 4 |
Bars here are zero-based. A track kept across consecutive scenes continues its phrase. Removing it and later re-entering starts a fresh entry phrase; reapplying the same scene does not restart it. Direct Stop then Start also starts a fresh phrase. With once, an entry-origin track plays its complete phrase once per activation.
The origin is the scheduled activation boundary, even if its command arrives late: the phrase catches up to that position. A direct Start uses the current transport position. A silent opening scene starts the transport, and silent sections keep it moving, including [] with { … } automation-only sections. The playhead advances through the rest and the following scene enters on time. Pause/resume, seeks and hot swaps retain an active track's origin. A seek changes its position within that phrase, rather than pretending it just entered. The origin is kept as a BAR, so a hot swap that edits meter / meterMap re-derives its beat from the new meter and the phrase stays on the new downbeats (Schedule events on a timeline § "A meter edit on a live transport").
A track that rests for a section really rests. When a scene applies after a track's quantized stop boundary has already passed, the engine drops that activation rather than cancelling its stop, even if the new scene names the track again; the track re-enters at the new scene's downbeat with a fresh entry phrase (engine/src/lib.rs, the scene-apply path).
A scene change is quantized to a bar boundary on the shared clock: outgoing tracks carry a stop barrier at that sample and incoming ones may not start before it, so the swap is sample-exact rather than immediate. The running set is therefore in flux across each downbeat. When you measure a rendered arrangement bar by bar (level, running tracks, spectrum), read the settled middle of each bar — roughly 0.25 to 0.75 of its length — not the edges.
Event onsets, durations, per-event control lanes and the engine's phase display share this origin. Tempo changes preserve its bar position; under a meter map, ordinary tracks follow the bar axis and polytempo tracks follow their scaled beat axis. MIDI export merges adjacent active windows to preserve the same continuation rule. Voices already sounding keep their normal tails.
at Nbars remains absolute placement with transport origin. Nonzero at and entry origin cannot be combined; at 0bars is an identity. Existing scores and IR without phrase_origin keep transport behavior. Design: Musical Phrases.
Landing a phrase on a section downbeat#
Scenes that share one track list (every base with { … } scene keeps the base's tracks) never re-enter those tracks, so neither origin restarts a phrase at their downbeats: a scene track's pattern keeps reading its phase from the transport. Material that must start on a section's first bar goes in the pattern itself:
- A whole-song pattern timeline — one pattern-level
arrangeper role (Patterns: events over time §Timing versions). Each cell starts its pattern at the cell's own first bar, never on the global grid, and repeats it to fill the cell'snbars (Tidal'sarrange; the weight buys a region, it does not stretch).[4, p]plays a one-barpfour times; to spread it across the cell write[4, p |> slow 4](rates survive a cell under timing version 2). Silence before a late entry is a rest cell,[a, !"~"]. - A written timeline (Write a song as a timeline) places content at an absolute bar with
phase "entry"as its default:region 34bars 8bars p { rate = (1/8) }startspon ruler bar 35 and stretches one cycle over the eight bars;rateis the explicit stretch.
Scene algebra — scenes as set expressions#
A scene value may be a set expression over scenes defined above it (both song forms):
hatTrack = play hat !"hh*8"
shimmerTrack = play pad !"[c5,g5]"
set = song {
scenes = {
groove = [kickTrack, hatTrack, bassTrack],
drop = groove + [leadTrack, shimmerTrack] - [hatTrack],
quiet = drop - [leadTrack] with { air = 0.2 },
outro = groove -- a pure alias
},
arrange = [[8, groove], [16, drop], [8, quiet], [8, outro]]
}
+ is an order-preserving union (duplicates dropped); - removes by track name (an absent track is a no-op — set difference); a referenced scene contributes only its track list, never its with { … } automation. A trailing with { … } applies to the whole expression. Forward references are parse-time errors. Resolved entirely in the parser (the same encoding trick as arrange/edges), so the IR and both backends see plain track lists. Design: Scene Algebra.
Visual layers as scene members#
A scene may also name vis.layer bindings (visual-synthesis §3.4): body = [bass, light, halo] lowers to the section's tracks plus a separate typed visual_layers list of LayerIds — ordinary lists stay homogeneous, and the output's layer order, not scene order, decides compositing. Algebra, lead-in fills and quantization treat a layer exactly like a track: the runner materializes layers with the same scene/edge/overlay rule and stamps each change on the boundary sample the tracks change on (ArrangementRunner::layer_activation, LayerActivation { active, since_sample }); a layer kept across scenes has no transition. A vis.output as a member is an error; a scene layer no output lists and a layer no arranged scene names are warnings (VisualLayerNoOutput, VisualLayerNeverArranged). What a host does with the activation is Visual synthesis § Arrangement.
arrange — order & length (Stage 1)#
arrange = [[bars, scene], …](the bare list and thearrange […]combinator spelling are both accepted). Weight is bars (= pattern cycles; a bar is one Volta cycle,CYCLE_QUANTUM_BEATS— the same unit the offline export uses; the live walk previously mis-scaled it by the song bar). A scene name resolves againstscenes; an inline[track, …]list materializes an anonymous section.
Computed schedules#
A schedule is an ordinary compile-time expression, so the list algebra that generates patches and patterns generates form too. Three slots fold: the bars of each step, the factor of a slow / fast op, and the schedule itself.
verse = 8 -- a named length
reps = 2 -- a named repeat count
widths = [8, 16, 8, 32]
form = map (\n -> [n, "groove"]) widths -- a generated schedule
set = song {
scenes = { intro = [kickTrack], groove = [kickTrack, bassTrack] },
arrange = arrange [[verse, intro]] |> slow reps -- folded factors too
}
Two rules keep the literal spelling working unchanged:
- A bare identifier in a scene slot is always a scene name, never a variable —
[[8, intro]]means the scene calledintro, as it always has. In a computed schedule you name the scene with a string ([n, "groove"]); any other expression in that slot folds, and a resulting string names a scene while a list becomes an inline anonymous one. - The
|> opchain is parsed by the song grammar, not the expression parser, becausearrange/rev/slow/fastare also pattern combinators — left toparse_exprthe chain would read as pattern algebra. The parser drops toparse_and(the precedence level directly under|>), so the base expression can be anything but never swallows the chain.
Everything folds before lowering: the IR still holds a flat resolved schedule, so both runtimes, the goldens, and the offline export are unchanged. A slot that doesn't fold is a compile error at its own span ("expected a constant scalar" for a live value like a macro).
Sub-bar weights (the beat grid)#
A cell's weight is a length in bars, and it may be sub-bar (Production Gaps Z5) — ArrangeStep.bars is an f64 on the beat grid:
set = song {
scenes = { build = [kickTrack], hush = [bassTrack], drop = [kickTrack, bassTrack, leadTrack] },
arrange = [[8, build], [1Beat, hush], [16, drop]] -- a BEAT of tension silence
}
Four spellings, all meaning a quarter of a bar at 4 beats to the bar:
| spelling | reading |
|---|---|
1Beat / 1beat / 1b | one beat |
0.5Bars / 0.5bar | half a bar |
0.25c / 0.25Cycles | a quarter cycle |
0.25 | a bare number is bars, as it always was |
Note 1/4 is integer division and folds to 0 long before the arrangement sees it — write the decimal.
A weight that is not a whole number of beats is a compile error at its own span ("an arrange weight has to land on a whole beat"), because the beat is the finest boundary the scene machinery resolves; a zero or negative weight keeps the old "bars must be a positive integer" text. |> fast n floors at a beat rather than a bar.
It reaches the sound: the walk plans on a fractional bar axis and stamps ApplyScene.at_bar at the next step boundary rather than the next downbeat (for an all-whole schedule that is bit-identically cur_bar + 1), and AudioEngine::bar_boundary_at_or_after takes an explicitly sub-bar target literally instead of quantizing it up to the enclosing cycle. Whole-bar weights still serialize as JSON integers, so pre-Z5 IR is byte-identical.
Still bar-resolution: the song map and the Bay's arrange lane are bar-grid overviews, so a sub-bar step draws at its nearest bar (minimum one) — ui::arrange::total_bars is the authority on the real schedule. The Bay's schedule write-back declines a schedule containing a sub-bar weight ("bar weight is computed; only positive integer literals are draggable"), which is the safe failure: it never silently rewrites a beat as a bar. Deferred: sub-bar edges lead-ins (Transition.bars is still a u32), and drag-editing a sub-bar cell.
- The live walk paces on the beat axis through the tempo clock: the engine's own arrangement runner (the same in both shells, below) plans against the transport's beat, which is already integrated through the map, and times ramps with
RunnerState::bars_seconds. Under a Tempo changes and independent clocks map, step lengths, transition lead-ins,sectionPos/songPosramps and the ribbon play-head all follow the curve; at constant tempo the arithmetic is identical to before. - A scene may recur at different lengths (
[16, groove]then[8, groove]) — a reprise. - The deterministic algebra is baked at compile time:
|> rev(retrograde),|> slow n/|> fast n(scale every step's bars),|> once(stop at the end; looping is the default). - Deferred: nesting (sub-arrangements),
every,stack, and generativedegrade/seeded form (a runtime-RNG follow-on).
Schedules as values#
set.arrange reads a song binding's schedule back as a compile-time value: the flat list of (bars, "scene") tuples the walk plays, after rev / slow / fast (lower/expr.rs::substitute_schedule_reads, which mirrors application.rs::lower_arrange). Bars are notational bars; a sub-bar weight reads as a fraction of a bar (1Beat is 0.25, on the same four-beat grid the weight check uses); an inline [track, …] section reads as the scene "". Tuples, because a mixed list [8, "intro"] does not fold element by element:
kick = sound !"bd*4"
set = song {
scenes = { intro = [kick], drop = [kick] },
arrange = [[8, intro], [16, drop]] |> slow 2
}
total = foldl (\acc step -> acc + step.0) 0 set.arrange -- 48
Form then lives in one place: tempo and meter derived from it follow a length edit (Tempo changes and independent clocks § Computed tempo and meter, and the examples/songs/conductor.volta library). Only a song written directly as song { … } with an arrange field is read; a song a function builds, or a songGraph walk, is not (the read stays unresolved and its consumer says the value did not fold).
edges — transitions on a handoff (Stage 2)#
from -> to : [bars, scene] [replace]. Endpoints are scene names or*(wildcard). The scene is connective material (a name or inline track list).- On every handoff (fixed
arrangeadvance or a live scene jump) the runtime resolves the transition by specificity: exactfrom->to, then*->to, thenfrom->*, then*->*, else none. It applies the transitionbarsbefore the downbeat as an overlay (current ∪ transition tracks; the default) or a replace (transition tracks only), then the target scene on the downbeat. songGraph { scenes = {…}, a -> b : [n, s], … }marks a walk-driven song (graph: true, no fixedarrange); navigation is by edges. Walked (Runtime Control Flow Stage 4): the engine's arrangement runner (below; the same in every shell) dwells each scene@dwellbars (an annotation on the song binding, default 4) and at each boundary takes the first matching edge — exactfrombefore wildcard, declaration order; no match = dwell again (the implicit self-edge). Edge bars may be 0 (no connective material).whenconditions:groove -> drop when energy > 0.7 : [1, build]— a macro-affine selector compared with a constant (thechooseByselector envelope), read against live macro state as late as possible (one bar before the chosen material must start).ir::Transition.when.- The decision log: graph navigation is stateful, so the runner records each dwell period's scene and exiting edge; the workbench reads its tail off the viz frame (
Walk::decision_log). Seeking backward replays the log bit-exactly; seeking past the frontier decides forward from live macro state.arrangemode keeps its pure seek-free planner untouched. - Export: an offline render folds every
whenat macro initials into a linear schedule and warns (engine/src/arrangement.rs::graph_export_note; bothvolta exportand the desktop dialog print it). Exact reproduction of a performance = replay its captured macro lanes through the live walk. - Still deferred: the
pickupsugar, the seeded random walk, the node-graph visualization and per-scene dwell overrides. The legacy product-page TS walker is no longer on this list:arrangement-schedule.tswas DELETED in Unified Timeline Stage 7, after a parity gate compared the engine's walk against it bar by bar, and the product pages now read the walk off the viz frame like the workbench does.
songPos / sectionPos + automation — motion (Stage 3)#
Scene automation keys resolve lexical macro bindings, including selective and aliased imports. A binding such as tone = macro "cutoff" 0.5 is addressed as with { tone = 0.75 }; the compiler emits the correct engine macro identity. A macro used only by a scene remains part of the compiled module.
songPosandsectionPosareSig Control Normalizedmodulators usable anywhere a modulator goes (cutoff = const 400Hz + songPos * 6000Hz).songPosramps 0→1 across the whole arrangement;sectionPos0→1 within the current section. They read 0 when no arranged song is playing. On a written timeline they mean the same thing measured against written form:songPosover the declaredlength,sectionPoswithin the current marker span (the whole song when there are no markers), both read straight off the transport rather than armed by a walk — see below.- A scene pins or ramps the macros it owns via
with { macro = value }/with { macro = ramp from to }, applied for the section's lifetime. Both endpoints are compile-time scalars, so a named constant works (with { air = ramp 0.2 peak }). Arampre-asserts the base every block, so for its span it outranks a shred:=lane and a live knob alike; a=is a one-shot at the downbeat that either can overwrite. Writing a macro from both a scene and a shred warns at compile time — see Macros and automation §1.1 for the measured table. - The same entry may address a mixer site by dotted path instead of a macro name —
with { padStrip.sends.hall = ramp 0.1 0.6 },with { master.plate.mix = ramp 0.2 0.7 }; an imported module's bus keeps its namespace (padStrip.sends.pipeworks.room) — which the compiler desugars to an internal macro substituted at that site, so it rides this very path. Grammar, collision rule and limits: Mixing tracks and buses §1. songPos/sectionPosand scene macrorampautomation run on both backends, live and offline: the live runner arms them from inside the block (ramp_macro_id, an interned id, so the audio thread does no lookup), the offline renderer throughramp_macro.- A host that applies a scene ahead of its boundary (libvolta's driver, a page's scene chip) sends the scene's pins and ramps in the same breath with
RampStart::NextBar(Command::SetMacroAt/RampMacro/RampSongPos;set_macro_next_bar/ramp_*_next_baron the wasm engine): the engine holds them and starts them in the block holding the boundary the scene lands on, so no host times the bar itself (engine/tests/ramp_start.rs). - Both backends take the same macro path through a scene change: a
=and aramp'sfromare targets the engine's ~15 ms macro smoother glides to, never snapped — a snapped wet amount steps the mix. Only an export's first segment (bar 0, or the pre-roll step after a seek) snaps, since there is nothing to glide from (engine/tests/export_macro_glide.rs).
A written timeline does not walk#
A song can instead be written as placements — song { timeline = [ … ] }, region tracks and automate lanes (Write a song as a timeline). The two are different schedulers for one song and are rejected together: timeline with arrange, edges or songGraph is a compile error. scenes may coexist with either, which is how a written bed carries launchable bundles over it.
What changes when a song declares a timeline:
| Scripted walk | Written timeline | |
|---|---|---|
| Transport ▶ | applies the opening scene, then arms the runner | seats one activation per region, from silence |
| What advances the form | the runner, per block, on the transport's beat | nothing: every region's own admission window decides when it sounds |
| End of song | loops, unless |> once | stops, unless playback = "loop" |
songPos / sectionPos | ramp over the arrangement / within the scene | ramp over the declared length / within the current marker span, read straight off the transport |
| Suspend / Resume arrangement | the four suspending gestures below | nothing to suspend |
| Studio | the Map, drawing contiguous scene occupancy | Arrange, drawing sparse region containers |
The host does not need to know which it has: transport ▶ sends the same ResumeArrangement { restart: true }, and the engine answers false from arm_arrangement when the module declares no scripted walk, which is exactly when start_timeline owns ▶. playback = "loop" arms the ordinary transport loop over [0, length), so a declared loop and a dragged brace are one mechanism.
Offline, a timeline song goes through the flat export path (which seats the timeline and takes its extent from the declared length) rather than render_export_arrangement; --solo and stem selection still narrow which receivers are audible.
The transport follows the form#
A Run that gives a playing song with no form a form (Arrange · 8 bars, the DAW guide's step 5, a song { … } typed by hand) hands the song to it at once. The playhead moves back by whole bars to the earliest bar of the form where every playing pattern is where it already is, so nothing jumps (one-bar patterns: the form's first bar, the beat kept), and the form plays from there (SeekBy, then the ▶ arming without its opening scene; ui/src/app/form_follow.rs). FREE PLAY means only that a hand launched something while a form exists. Losing the form while playing (its Undo) plays the loop again; losing it while paused plays the loop when the transport resumes.
A form that does not loop (|> once, playback = "once") stops at its end: no new notes from its last bar, the annunciator reads END while the last ones ring (until the master is quiet, two seconds at most), then the transport stops, back at bar 1, where ▶ plays from. A looping form loops.
Native arrangement authoring#
Timeline and Bench Bay consume ui/src/songmap.rs, one compiler-backed model and bar geometry. Bay's collapsed strip expands in Arrange mode into scene bands, occurrence cards, markers, seek/playhead geometry and per-track clips. Selection is view state; only the separate apply ▶ action changes the live scene. Clip selection and graph-card selection are bidirectional.
The projection is occurrence-true:
- every fixed-schedule occurrence gets its own
sectioncard, including a reprise or a later scene made wholly from shared declarations; - shared declarations appear once on a labeled trunk which names all occurrences it feeds;
- each adjacent boundary reports entering, leaving, continuing, transition-only and automation-change sets, plus changed declarations when a take diff is active;
- the retained Graph fold is labeled
FOLD · PARTITION›first use; it is a crossing-reduction view, never an ownership claim; - a graph-only song is shown as a state machine with its conditional edges, not flattened into a schedule it does not have.
The expanded dock writes only conservative, local source forms. Drag reorder (while you drag, the other occurrences close up and make room, the dragged one follows the pointer, and its landing slot is outlined), integer resize, duplicate/insert/delete and outer loop/once changes target the smallest literal schedule span; a legacy scene-only song can preview and materialize an inferred schedule. Computed, transformed and imported schedules remain visible and navigate to their exact source. Every release re-resolves the semantic target, checks its expected text, analyzes one complete candidate and commits one editor undo entry.
The activity matrix edits scene definitions rather than occurrences. It distinguishes local, inherited, explicitly removed, absent and opaque track membership. Literal lists preserve comments/comma style; recognized scene algebra gets a trailing + [track] or - [track] without simplification. Click writes one cell, drag batches all painted cells into one analysis/undo, and scene add/clone/rename/delete first shows collisions and dependent schedule/transition references. Opaque or foreign membership is source-only.
Resolved transition markers expose wildcard endpoints, predicate, lead-in and overlay/replace mode. Local literal transitions can be added, retargeted, resized, toggled or removed. Collapsible section-relative automation lanes show compiler Set/Ramp values using the destination macro's range and unit; literal endpoints have write-backed handles, while computed values open Source. Transition selection highlights its connective scene closure; automation selection highlights the macro cable and reachable destinations.
Make a variation for one section. Scene membership names a track, so a phrase that plays in several sections through one track cannot be changed in one of them by editing the phrase. A clip-opened roll states the reach ("editing it changes 3 placements") and offers make variation for this occurrence for the section that was double-clicked; the selected section's inspector offers the same for each shared phrase it plays (vary here only: bassIdea in bassT). One undo step writes, for section 4 of [[4, intro], [4, groove], [4, intro], [4, groove], …]:
``volta norun an excerpt where…` elides the rest of the song bassIdea2 = !"c2 ~ g2 ~" -- a copy of bassIdea's text bassT2 = play bassVoice bassIdea2 |> plate 0.3 -- bassT, pipes and all scenes = { …, groove2 = [kicks, bassT2] }, -- groove, bassT swapped arrange = arrange [[4, intro], [4, groove], [4, intro], [4, groove2], …] mixer { bassT2 = { level = -6dB, pan = 0.2 } } -- bassT's row, copied
The names follow the scene-add rule (the first free `name2`, `name3`, … over
bindings and scenes); the pattern name is editable before the click. When the
section's scene plays only there and no other scene names it, the swap is
written on that scene instead of a clone. The roll reopens on the copy. It
refuses, by owner, an imported or computed track head, an inline or computed
pattern argument, an imported pattern, a scene the planner cannot read, an
anonymous scene cell and a computed mixer row. Not copied: `edges` lead-ins
written for the original scene (the notice says so), and group or sidechain
references to the original track. On a written timeline the region is the
occurrence: the copy is repointed into that one region, and a pass a
combinator repeats is refused.
`loop selection` is temporary audition state: the native transport seeks and
wraps inside the chosen occurrence without changing the program. `commit
loop` is the explicit source operation.
## Backends
The order/length walk, transitions, and `songPos`/`sectionPos` run wherever the
workbench runs. Since the TypeScript workbench was deleted the
browser workbench *is* the `volta-ui` crate compiled to wasm, so desktop and
browser run the **same** walk — one implementation, two shells.
**The engine's arrangement runner** (`engine/src/arrangement/runner.rs`) walks
the form. It ticks once per audio block from the transport's own beat, so a
fixed arrangement advances with **no host polling at all**: a stalled, busy or
entirely absent workbench changes nothing about when a scene lands. Desktop,
the browser AudioWorklet and the plugin target run the same runner, because the
prepared arrangement rides on `CompiledModule` — scenes resolved to track
indices, scene automation to interned macro ids, edges to scene indices, all
built control-side at module load.
**Every scene apply quantizes on the transport's own bar** — the walk's, a
manual launch from any host (desktop, browser, plugin, libvolta, OSC, room
follow), a bundle launch and a queued stop-all alike. The engine owns the
meter: the transport clock carries the module's `meter` and any `meterMap`,
and the boundary is the next whole bar on that clock's bar axis
(`AudioEngine::next_bar_boundary` / `bar_boundary_at_or_after`), so a
`meter = 7/8` song lands scenes on its 3.5-beat downbeats and a `meterMap` song
on whatever bar length the map gives at that position. No entry point takes a
bar length (`EngineHandle::apply_scene(tracks)`,
`apply_scene_at(tracks, at_bar)`, `stop_all_at()`; wasm `apply_scene(json,
atBar)`). Hosts used to pass one, and the literal `4.0` they passed — the UI's
cycle quantum `ui::arrange::BEATS_PER_BAR`, or libvolta's `BAR_BEATS` — made
the quantum a fraction of a bar under any other meter. Pinned by
`engine/src/offline.rs`'s `scene_applies_quantize_on_a_seven_eight_bar` and
`scene_applies_follow_a_meter_map_bar_length_change`, and for the C ABI by
`libvolta/tests/ffi.rs::song_driver_walks_seven_eight_bars`. A hot swap that
edits the meter moves the running clock onto the new bar axis at once (the
playhead keeps its beat; boundaries already queued re-quantize onto the new
downbeat; the walk re-plans from the bar the playhead now reads) —
[[reference/linear-time]] § "A meter edit on a live transport",
`engine/src/meter_swap_tests.rs`.
Applies still carry explicit target bars, preserving lookahead without
confusing it with the engine's manual-launch boundary grace.
A fresh runner is **dormant**. A host arms it explicitly — transport ▶ or
Resume arrangement — because the offline renderers and every flat "start every
track" path drive the engine directly and a self-arming walk would fight them.
A module swap carries the armed flag across, so a recompile mid-song keeps
walking without resurrecting a hand-suspended walk.
Live decisions arrive as ordinary engine commands. Exactly four gestures
**suspend the walk inside the engine** on their way through the queue: a manual
scene apply (one with no target bar — scene selection from the transport,
Cycles, Map or Bench), a per-track **start**, a per-track **stop**, and setting
a transport loop. Only the re-arming needs a command of its own
(`UiCommand::ResumeArrangement { restart }` → `EngineHandle::arm_arrangement`;
`restart` also clears committed graph decisions, which is what transport ▶
does).
**Clearing a transport loop does not resume the walk.** Suspension is an
explicit handover to the player, so only Resume arrangement or ▶ gives it back
— turning the loop off leaves the song where the player left it rather than
silently taking the wheel again.
**All of this is the LEGACY scope, and it is legacy on purpose.** A scripted
walk's scenes name track sets; it has no track identity, so there is nothing
to hand back one track at a time and a hand takes the whole song. A song with
a written `timeline` has that identity — every region names its receiver — and
gets **per-track** ownership instead: a manual launch takes only its explicit
destination set, each receiver returns alone or with Resume arrangement, and
everything else keeps playing its written row. `volta convert --timeline`
([[reference/interchange]]) is what moves a song from one scope to the other.
The rules are [[reference/timeline]] § "Live launch and the written row".
**A host that applies its own opening scene must arm *after* it**, because the
apply is one of the four suspending gestures. Transport ▶, the performance-take
recorder and the notebook's transport restore all send the scene and then
`ResumeArrangement`, in that order.
**The workbench** (`ui/src/arrange.rs`, read from `ui/src/app/shell.rs`) sends
those commands and reads a projection back: the viz frame's `AR01` trailer
carries following, step, scene name, bars-into-step, the riding transition, and
a bounded tail of the graph decision log with its absolute offset. `Walk` layers
one optimistic local hint on it, so a transport chip reads ■ from the click that
pressed it rather than one poll later; the first frame that agrees clears the
hint. The planning functions that remain in that module — `plan_bar`,
`total_bars`, `resolve_transition`(`_index`), `next_step_index` — are
thin wrappers over the engine's planner, for *drawing* bars the transport is
nowhere near (the song map, the Bay's arrange lane).
**One planner** (`engine/src/arrangement/planner.rs`) makes every decision the
live runner, the offline export resolver and the workbench projection depend
on: step lookup, `songPos` brackets, transition specificity, lead-in clamping,
overlay/replace and graph edge selection. It is generic over three small read
traits, so the engine's IR types and the workbench's display structs share the
logic without sharing a struct. Before [[unified-timeline]] Stage 1 these
decisions existed twice.
Empty chooser branches consume real cycles and resume when the selector changes.
**Timeline Map** plans fractional scenes and overlay/replace spans, preserving
entry phase across passes. Graphs show states, predicates, edges and committed
decisions; bare scenes show an inventory. Folded automation lists set/ramp
ranges. Manual playback is separate from the plan.
**Timeline Cycles** shows the current live pattern, with chooser alternatives
on hover. Dim notes preview static material; outlines mark possible futures;
solid notes are accepted events with resolved pitch, velocity and timing.
Lane previews share scheduler sampling. Grooves and external triggers show
performed timing; current choices never repaint past cycles.
History follows workspaces, capped at 8,192 observed notes and 256 visits,
subject to telemetry limits. Seeks, new runs and recompiles clear it. Recent
notes do not extend active highlights. Native/Wasm support the optional note
trailer; old frames retain legacy highlighting.
**Product pages** (`runtime/src/native-runtime.ts`, driven from
`song-player.ts` — radio, embeds, room view) play the same plan the workbench
does, through the same engine runner. `playSong` sends two commands: the
opening scene, applied from silence because there is no boundary to sync
against, and then `arm_arrangement`, which hands the form to the engine. The
order is load-bearing — a manual apply suspends the walk on its way through the
command queue, so an arm sent first would be cancelled by its own opening
scene. `arm_arrangement` covers a scripted `arrange`, a `songGraph` and a
written `timeline` alike, and answers false only for a song that declares none
of them; the page then starts every declared track, which is what the
workbench's play does too. A manual scene pick (`selectScene`) and the queued
stop-all land on the next bar of the module's meter, like every other host;
the page's chip-flip timer mirrors that bar through its meter-aware
`TempoClock`.
The page has no scheduler and no timer chain. It READS the walk back off each
viz frame's `AR01` trailer through `runtime/src/arrangement-frame.ts` (the
mirror of `ui/src/frame.rs`'s reader, attached to the decoded snapshot by
`runtime/src/viz-frame.ts`), and moves its scene, running-track set and ribbon
play-head from the engine's own numbers: the scene owning the bar, the index of
the edge riding it, and bars-into-step. The set a lead-in makes audible is
materialised from that edge index by the same overlay/replace rule the engine
decided it with. `LV01`, the live-launch trailer that follows `AR01`, decodes
the same way. A page that has stopped receiving playing frames after the walk
was steering is hearing the end of a `|> once` schedule, which the runner marks
by applying an empty scene.
`songPos`/`sectionPos` compile to `MacroRef` at two reserved slots the engine
appends to each block's macro vector (`engine/src/lib.rs` `macros_ext`), ramped
by `ramp_song_pos`; scene macro ramps ride `ramp_macro_id`
(`engine/src/lib.rs` `PosRamp` for both) — the runner calls both directly, from
inside the block. A ramp's length is the wall time from the playhead to the
bar it is armed to reach, integrated through the transport clock
(`RunnerState::bars_seconds`), so a step that crosses a `meterMap` change or a
`tempoMap` curve still ends on its boundary bar. **Manual launches suspend the walk** — the same four gestures
listed under "Backends" above. Recompiling re-plans an active walk without
resuming a suspended one. Play re-arms the
opening scene; loading never auto-plays. Queue indicators clear from engine frames at every window
size. Named selection distinguishes scenes with identical track sets. A newer
launch replaces unheard queued entrances; retained playing tracks keep their
phase. Timeline highlights actual playback while Map keeps the written plan.
Tests: `engine/tests/arrangement_runner.rs` — a frozen oracle of what the
pre-Stage-1 workbench walk commanded for the design's eight fixture songs,
which the runner must reproduce command for command; the ported contracts
(lookahead and its target-bar stamp, sub-bar boundaries, the fractional
finite retirement, ramp arming after a seek, manual suspend,
recompile-does-not-resurrect, the graph decision log and its backward-seek
replay); and a stalled-UI test that drives `process` alone.
`engine/src/arrangement/planner.rs` unit tests (specificity, lead-in clamping,
the beat grid, `songPos` brackets, graph edge selection).
`engine/tests/rt_safety.rs::the_arrangement_walk_is_allocation_free_in_the_callback`
and `::a_duplicated_scene_track_does_not_allocate_under_an_overlay`.
`every_manual_override_suspends_the_engine_walk` (the stop and loop rows, the
clear-does-not-resume rule), `a_rebuilt_engine_restores_the_sounding_scene_and_keeps_walking`
(the device switch), `a_non_four_four_song_lands_its_scenes_on_real_bar_starts`,
`a_far_graph_seek_catches_up_over_blocks_and_the_cap_reports_itself`.
`ui/tests/silent_scenes.rs` drives a real engine through decoded viz frames.
`ui/src/arrange.rs` and `ui/src/app/form_follow.rs` unit tests.
`runtime/scripts/arrangement-parity-test.mjs` (the gate that retired the
page-side scheduler; the old planner is kept there as the witness),
`runtime/src/native-arrangement.test.ts` (the page host's two ▶ commands, in
order) and `runtime/src/viz-frame.test.ts` (the `AR01`/`LV01` layout against
`engine/src/viz.rs`).
`engine/src/offline.rs::walk_apply_with_target_bar_is_immune_to_the_downbeat_grace`,
`app-native/src/bridge.rs::the_walk_advances_scenes_and_ramps_macros_on_a_real_engine`
(a real engine playing `automata.volta`) and
`engine/src/offline.rs::song_pos_modulator_ramps_a_gated_gain`.
### Offline export — the whole arrangement
Audio export ([[reference/connect]] § Surface) renders a full song, not a single scene.
A resolver flattens `arrange` (looping/truncating to the requested bars) into an
ordered list of *segments* — each a bar span with the exact track set active
during it (transition lead-ins folded in as track-set changes) plus the motion to
arm at its downbeat. A `|> once` form asked for MORE bars than it holds (a
span render reaching into the reverb tail, an export longer than the form) is
closed with one more segment: no tracks and no motion. Every track retires at
the form's end, as the live runner retires them (sounding voices and effect
tails ring out, no new note starts, a whole-song timeline never wraps back to
bar 1), and the final scene's macro values hold. No silent padding bars are
needed after the last section. There are two ports of it, behaviorally identical:
- **Rust** — `engine/src/arrangement.rs::resolve_arrangement_timeline`, reading
the `songs[]` slice of the IR JSON (not the `CompiledModule`'s prepared
runner plan, which holds track indices and interned ids). This is the one
every shipped export path uses.
- **TypeScript** — `runtime/src/arrangement-timeline.ts::resolveArrangementTimeline`,
the original. It has no production caller left, but its test file is the shared
behavioral spec both ports are held to.
Consumers:
- **CLI** (`volta export stems`): resolves once, renders `master.wav` from the
full timeline and each stem from `timeline_for_track` — so a stem is silent
exactly where the form says its track is not playing, and the set still sums to
the master. With an arrangement, `--cycles` defaults to the **whole form**.
- **Desktop** (File → Export Audio…): the same, per render. The dialog's
`cycles` field pre-fills to the form's length rather than the 8-bar default.
- **Browser** (`engine-wasm/src/lib.rs::export_wav`): resolves internally. The
`timeline_json` argument, by which a caller may hand in its own resolved
timeline, is still honoured but no shipped caller passes one.
- **Manifest renderer** (`engine/examples/render-manifest.rs`): the
sample-aware CLI twin of the desktop export — loads `runtime/public/samples/
index.json` the way the shell does (flat files, `name:N` variants, keymapped
`zones`, `bpm`) and walks the form through
`render_export_arrangement_zoned`, with a `--lufs` solve, a `--solo` track
filter and a JSON sidecar of per-second windows; it errors on any sample key
the manifest lacks. The four world albums were verified with it
([[notes/world-banks-2026-09-07]]).
- **Native renderer** (`engine/src/offline.rs::render_export_arrangement`):
replays the timeline sample-clocked, calling `apply_scene_idx_at` (each
segment's own downbeat as the explicit target bar) + `ramp_song_pos` +
`ramp_macro` at each boundary — the engine calls the live runner makes per
scene change. When an export differs from live
playback, suspect this walker before the DSP.
One deliberate exception: `measure_audio`'s default render
(`app-native/src/export.rs::run_measure`) renders flat; only its
`section`/`bars`/`whole_song` spans walk the form (`render_span`). It measures what each track *sounds
like*, so it has to hear every track; walking the form would report a drop-only
lead as near-silence and send the copilot chasing a mix problem that isn't there.
Tests: `runtime/src/arrangement-timeline.test.ts` (the TS resolver — disjoint
windows, clamped fills, looping, the closing segment of a `once` form),
`engine/tests/export_endings.rs::a_once_form_rendered_past_its_end_starts_no_new_notes`
/ `a_once_forms_final_macro_state_holds_after_the_end` (the export and span
walks past a `once` form's end), `engine/src/arrangement.rs`'s own unit tests (the Rust port — gating,
lead-in tails, `|> once`, wildcard specificity, stem restriction),
`offline.rs::an_arranged_song_exports_its_form_not_every_track` (source → samples:
the second scene's track is silent through bar 0), and
`offline.rs::arrangement_render_switches_scenes` /
`arrangement_ramp_macro_sweeps_a_section` (the native walk + macro ramp).
## IR
`SongDef` (`ir/src/ir.rs`; runtime `types.ts`) gains:
arrange?: { steps: [{ scene, bars }], loop } // resolved schedule transitions: [{ from?, to?, scene, bars, replace, when? }] // null = wildcard graph: bool // songGraph
`SectionDef` gains `automation: [{ kind:"set", macro, value } | { kind:"ramp",
macro, from, to }]` and `visual_layers: [LayerId]`. New `NodeKind`s `SongPos` / `SectionPos` (control,
Normalized). All fields are omitted when empty, so existing song IR/goldens are
unchanged.
## Where it lives
- **Compiler:** parsing — `parse.rs` (`parse_song` intercepts `song`/`songGraph
{`; encodes `arrange`/`edges` as `List`/`StringLit`/`Int`, so no new AST/
resolve/check surface; `parse_arrange_value` takes the literal list *or* any
expression, and `parse_weighted_pair` takes an expression for `bars`).
Lowering — `lower/application.rs` (`try_lower_song`, `lower_arrange`,
`lower_transition`, `lower_scene_value`, and `song_scalar` / `song_u32`, which
fold a slot through `eval::evaluate_static`; `SongScope` bundles the scene
table + evaluator context). `lower.rs`'s pass 2b pre-reduces the song decl with
`eval::evaluate` first, so a computed schedule has folded to the literal shape
before `lower_arrange` reads it. `songPos`/`sectionPos` nodes in
`lower/expr.rs`; builtins in `builtins.rs`; range in `refine.rs`.
- **Product-page runtime:** `runtime/src/native-runtime.ts` sends the opening
`applyScene` and then `armArrangement`, and mirrors the engine's walk off each
frame's `AR01` trailer (`runtime/src/arrangement-frame.ts`) for its scene
chip, running-track set and `songPlayhead()`; it schedules nothing itself.
- **Engine runtime** (desktop, AudioWorklet and plugin alike): the shared
planner in `engine/src/arrangement/planner.rs`, the walk in
`engine/src/arrangement/runner.rs`, its prepared plan on
`graph::CompiledModule::arrangement`, ticked from
`AudioEngine::tick_arrangement` inside `process_slice`, and steered by
`Command::Arrangement` / `Command::ArrangementStart`. The projection is
published in `viz::VizArrangement` and encoded as the frame's `AR01` trailer.
- **Workbench runtime** (native *and* wasm): the read model and the drawing
helpers in `ui/src/arrange.rs`, read from `app/shell.rs`'s frame loop; the
commands route through `ui/src/command_table.rs` to
`app-native/src/{bridge,main}.rs`, `ui-wasm/src/web.rs` (→
`runtime/public/studio-host.js` → `engine-wasm`'s `arm_arrangement`) and
`plugin-target/src/bridge.rs`. Schedule, transitions, and per-scene
automation are read off the IR in `ui/src/moduleinfo.rs` (`TransitionInfo`,
`MacroAutoInfo`) for display.
- **Workbench UI:** the shared read model/geometry in `ui/src/songmap.rs`; the
Timeline projection in `ui/src/timeline.rs`; the Bay occurrence, activity,
transition, automation and delta surfaces in `ui/src/benchbay/arrange.rs`;
conservative source plans in `ui/src/bayedit.rs`, routed through the Bench's
candidate-analysis/CAS/undo seam in `ui/src/bench.rs`.