VOLTAreference

Reference / Patterns and harmony

Control patterns for individual events

Patterns and harmony

Open in the workbench →
On this page · 15 sections

Give hits and notes changing strengths, positions, playback speeds and other values without rewriting their main rhythm.

Gain readers#

gain accepts a numeric signal or a track receiver. Numeric signals use the same unit and rate rules as multiplication. A track level is a plain number (a linear gain) or a dB value (-6dB, (-6dB) + (-3dB)), converted once as 10^(dB/20) exactly as a mixer level is; a dB literal inside a live level is its trim (fade * -6dB is 0.501 × fade). A live dB signal (such as macro -6dB) is refused — give the control linear travel and write the dB beside it. Gain patterns are linear levels ("1 0.5", not "0 -6"dB), and other physical units (Hz, seconds) are rejected.

On a source track, a constant, macro or affine macro expression reads at note-on and scales the new event's velocity. Numeric patterns also read at note-on. A general control graph such as level * level installs continuous gain; the legacy unipolar LFO form retains its depth behavior. gainTrack explicitly selects the note-on reader, including for a control graph. A bus or master receiver uses a continuous insert because it has no note events. Aliases, returned functions, function arguments and record/tuple fields retain these choices. Prefix calls such as trim = gainTrack t remain callable.

What it is#

Strudel-style parameter patterns zipped onto a sound (or play) track. Each control combinator takes a mini-notation pattern and applies one value per triggered event — the pattern is sampled stepwise at each note's onset and the sampled value rides into that event's voice. This gives per-hit dynamics (gain), playback rate (speed), stereo placement (pan), micro-timing (push), beat-matching (warp), pitch (pitch) and sample choice (variantP) without authoring a full patch around every variation.

The same "a pattern where a constant used to be, sampled by cycle" idea also reaches inside a patch: a cycle-locked modulator rate can follow a pattern (lfoSine @ !"<0.5 0.25>"c), and stepSeq accepts a pattern of step shapes. See §Cycle-rate lanes.

Named knob lanes (2026-09-02, Your first sound §2): on a play track any patch field can be such a lane — |> cutoff "<400 800>"Hz is a Track.param_lanes entry sampled exactly like gain, delivered to the voice as the event param a trackParam "cutoff" read in the (lifted) patch picks up.

Surface#

Twelve track combinators. All but pitch / pitchRoot take a Pattern Number.

A constant is the one-step lane, on every lane. Wherever a lane takes a numeric pattern it takes a plain compile-time constant too — |> speed 1.2, |> warp 1, |> reverse 1, |> variantP 3, |> push 8, |> cut 1, |> begin 0.25 — and pitch takes a pitch literal (|> pitch ~C4). The constant lowers to exactly the pattern !"v" resolves to (one event at onset 0 on a one-cycle lane), so |> speed 1.2 and |> speed !"1.2" are the same IR, and each lane's own value checks (warp's 0/1, push's ±120 ms, …) apply to it unchanged. A constant may be a literal, a binding that folds, or a macro "x" v (its initial value — a stepped lane cannot ride a live macro). gain, pan and velocity keep their own constant readings: a static level, a fixed position, velocity_const. On a signal receiver speed/warp are still the sample modifier — sample "bd" |> speed 1.2 sets the playback node's rate, not a lane. Inside a track function (\t -> t |> cut "1 0") a plain string is still the lane's mini-notation, on every lane: each lane port is declared a pattern.

LaneSignatureEffect
gain pTrack → Pattern Number → Trackmultiplies into the voice's effective velocity
speed pTrack → Pattern Number → Trackmultiplies into a sampler voice's playback rate
pan pTrack → Pattern Number → Trackstereo placement; overrides the event-level jux pan
push pTrack → Pattern Number → Tracksigned milliseconds of micro-timing (positive = late), bound to ±120 ms
warp pTrack → Pattern Number → TrackBoolean {0,1} — beat-match this hit (Keep a loop in time)
pitch pTrack → Pattern Pitch → Trackoverrides the trigger pitch
pitchRoot mTrack → Pitch → Trackthe recorded pitch pitch transposes from on a sound track (default 60)
variantP pTrack → Pattern Number → Tracksample-variant index (Choose sample variants)
cut gTrack → Pattern Number → Tracksong-wide choke group; 0 = none (§Sample windows and choke groups)
begin bTrack → Pattern Number → Tracksample start, a fraction of the playable window
end eTrack → Pattern Number → Tracksample end, a fraction of the playable window
reverse fTrack → Pattern Number → TrackBoolean: 1 plays that event's sample backwards (|> reverse 1, or !"0 1" per event)

push's lane can also be extracted from a real break instead of written — |> push (grooveOf "breaks125" 16). See §Extracted groove.

Each lane's operand has a NAME (push offsets, variantP index, warp flags, velocity amount, pitchRoot root, speed rate), so a lane takes a named-option record like any other builtin — |> push { offsets = !"0 8" } — and hover, completion and the arity message all spell that port. speed, warp and reverse are the names whose track lane is a SECOND reading of a sample modifier: on a sample chain |> speed r is a continuous rate read every active sample, and |> warp and |> reverse are flags with no argument at all; on a track |> reverse 1 plays every hit backwards, so a sound hit can be reversed without a patch. The receiver is what selects between the readings. A call that fits neither reading says so and names both. See Named options and parameter values.

hatTrack = sound !"hh*8"
        |> gain !"1.0 0.5 0.75 0.5"     -- accent pattern
        |> pan  !"-0.2 0.2 -0.1 0.2"    -- per-hit placement
snareTrack = sound !"~ sd ~ sd" |> push !"0 8"   -- lay the backbeat 8 ms back

kitTrack = sound !"bd ~ sn bd"
        |> variantP !"<0 3 5 7>"        -- a different kick each cycle
        |> warp     !"1 1 0 0"          -- beat-match the first two hits only

-- A melodic sampler line without leaving the `sound` surface:
melody = sound !"piano*4" |> pitchRoot ~C3 |> pitch !"c4 e4 g4 e4"

Real use: examples/songs/gristmill.volta (hatTrack with gain + pan; rimTrack with pan).

The sampling rule (one rule, both backends)#

A lane is sampled stepwise at the event's absolute cycle position — cycle_index + raw_onset, converted to lane phase through the track's cycle_beats.

  • Stepwise / latched at onset. A long note keeps the value it triggered with; there are no intra-note sweeps.
  • Time, not event count. A lane is read by position in the cycle, never by the ordinal of the note it lands on. Over sound "hh*8", gain "0.4 0.12" is a two-step lane: the first four hats read 0.4 and the last four 0.12. To alternate hit by hit, write the lane at the note grid — gain "[0.4 0.12]*4".
  • A lane shorter than the track wraps rather than stretching to fit (a 2-beat lane repeats twice over a 4-beat cycle).
  • A lane longer than one cycle advances between cycles. <a b c> lowers to a 3-cycle lane and genuinely alternates: cycle 0 → a, cycle 1 → b, … This is why the position is absolute rather than the bare onset fraction.
  • Before the first lane onset, the previous cycle's tail holds (the last event wraps around).
  • A rest holds the previous value. Rests drop out of the lane rather than zeroing it, which is what makes gain (degrade p) sane — a dropped step doesn't silence the note.
  • The last combinator wins when the same param is set twice in a pipe (left-to-right composition, ARCHITECTURE §3.4).

One implementation, shared by every lane and by velocity / panTrack: engine/src/lib.rs::sample_pattern (pinned by that file's sample_pattern_is_stepwise_and_wraps).

Composition into the trigger#

LaneComposition
gainmultiplies into base × trackFactor × eventFactor × gainEvent × gainTrack × gainCtrl, clamped to [0, 1] (Velocity and accents §2)
speedmultiplies into the plain Sample node's playback rate
panoverrides the event-level jux pan
pushadds to the swing/nudge-adjusted onset
warpORs with the Sample node's static warp flag
pitchoverrides the trigger pitch
variantPoverrides TrackEvent.variant only where that is 0
cutan onset in group G cuts every voice already sounding in G, song-wide
begin / endnarrow the event's sample window (trim / slice) as fractions of it
reverseORs with the Sample node's static reverse flag

Sample windows and choke groups#

The sampling-workflow lanes (Sampling Workflow Stages 1–2), named after Tidal/Strudel's cut, begin and end:

hats  = sound !"hh*7 oh" |> cut 1               -- the next hat chokes the open hat
kit   = sound !"bd oh ~ hh" |> cut !"0 1 0 1"
sampled = patch { source = sample "piano" |> pitched ~C3, gain = vel }
pad   = play sampled !"c4 e4" |> cut 2          -- `play` tracks too
chops = sound !"vox*8" |> begin !"0 0.125 0.25 0.375" |> end 0.6
halves = sound (chop 4 !"amen") |> begin 0.5    -- second half of each quarter

cut g — a non-negative integer group per event (rounded; negative is a compile error), 0 = no group. Groups are song-wide, not per track (Tidal's are per orbit; Volta has none): an onset in group G ends every voice already sounding in G on any track over the engine's end-of-voice declick (EDGE_FADE, 256 frames ≈ 5.3 ms at 48 kHz, a linear fade to zero), and the new voice itself is untouched. Voices that start on the same sample in one group (a chord) do not choke each other. The cut is the same click-free cut a seek uses (VoiceArena::choke beside cut_track). The result does not depend on track order or block size: the onset chokes the voices already in the arena, and a group member triggered after a LATER onset of its group was scheduled (tracks are scheduled one after another within a block) is cut at that onset when it is allocated. Glide tracks' onsets choke others, but a persistent glide voice is never itself choked.

begin b / end e — fractions in [0, 1] (outside is a compile error) of the event's playable window: the trim window of a sample source, narrowed first by a chop/slice region when the event has one. A lone begin ends at 1, a lone end starts at 0. end <= begin plays nothing (the window collapses empty; there is no reversed read — that is reverse). The fractions are in buffer time, so on a |> reverse source begin 0.5 still names the buffer's midpoint. A start inside the waveform takes the sampler's note-on declick (The sampler § "The window edges").

cut, begin and end work on sound tracks and on play (and playOn) tracks; begin/end act on a patch's sample sources — plain, granular, stretch-rendered (fractions of the rendered buffer, i.e. of the source window) and chop-modifier reads (fractions of the chosen slice) — and are a no-op on a patch without one. They step per event, so a continuous signal (begin (lfoSaw 4)) is refused with the lanes' usual hint — write the ramp as a pattern (begin !"0 0.25 0.5 0.75").

Per token. begin, end, cut and reverse can also be written on one token as a (key=value) attribute — sound !"breaks152(begin=0.25,end=0.27) sn(cut=1) bd(reverse=1)". The value is that event's own lane value: it renders bit-identically to the lane spelling, and it overrides a lane on the same track for that one event (the token is the more specific spelling, as bd:N is over variantP). The ranges are the lanes': begin=2, cut=-1 or reverse=2 is a compile error. The other lane names (speed, warp, pitch, pan, gain, push, variant, variantP, velocity) are NOT read per token, so bd(speed=2) is refused with the |> speed … spelling — unless the track's synth declares that name as its own trackParam, which then reads it as usual.

An empty window warns. When a constant lane, a pattern lane or a token leaves a hit's window empty (end <= begin at its onset), the compile emits one warning[EmptySampleWindow] per track naming the first such hit and its cycle (track "kit": 2 hit(s) in 1 cycle(s) play nothing … First:bdat cycle 1 (onset 0), begin 0.5, end 0.25). The lanes are sampled as the engine samples them, over as many cycles as the lanes take to come round (at most 64). The hit still plays nothing — the warning only says so.

Measured offline (engine/tests/sampling_lanes.rs): after a group-1 onset the open hat's residual is exactly zero from EDGE_FADE + 1 frames on, while group-0 and group-2 tones are bit-identical to a render without the choke; the fade's max per-sample step equals the material's own p99 (a hard stop measured 10.2× it); a begin landing on a full-scale peak ramps in at 1.0× p99 (7.6× without the ramp).

Higher-order combinators#

A lane's argument is an ordinary pattern, so the §9 combinators apply with no extra machinery — they fold through resolve_pattern before the lane is built:

sound !"hh*4"
   |> gain     (every 2 rev !"1 0.4 0.8 0.4")   -- alternating accents
   |> variantP (!"0 3" |> fast 2)               -- twice per cycle

every n f unrolls to an n-cycle lane, which the cycle-aware sampling rule then actually walks. degradeBy x is the probabilistic sibling and composes identically (its holes are rests, so the previous value holds); sometimesBy x f transforms the same seeded subset instead of dropping it.

Continuous signals in the pan/gain slots (unified-time)#

Since 2026-08-20 the two lanes whose parameters are continuous by nature accept a control signal where they take a pattern (a constant-rate LFO inline, anything richer as a persistent control graph) — |> pan (lfoSine (8c)) is a bus auto-pan ride, |> gain (lfoSine 4) a bus tremolo — while the stepped lanes (speed/warp/pitch/variantP/push) reject a continuous signal with a hint. Canonical doc: Musical time for patterns and signals §2; everything below about pattern lanes is unchanged.

Cycle-rate lanes (the same idea, on a MODULATOR)#

The lanes above ride a track. The same machinery also drives a cycle-locked modulator rate inside a patch: hand @ a numeric pattern instead of a constant and the LFO's rate follows it, sampled at cycle boundaries.

-- THE dubstep wobble vocabulary — half, quarter, dotted eighth — from one
-- patch on one track. Each step is a PERIOD in cycles: `0.5` IS `1/2c`.
wob = patch {
  source = saw (freq pitch),
  cutoff = const 300Hz
         + (lfoSine @ !"<0.5 0.25 0.1875 0.25>"c |> scale [0, 1]) * 2600Hz,
  gain   = vel
}

Before this, @ 1/2c folded to one constant, so switching the wobble by bar cost a duplicate patch and a second track with complementary arrange rests (examples/songs/ferrofluid.volta still spells it that way on purpose; examples/songs/torsion.volta is the same move with the lane).

Units — the scalar table, but only on a DIRECT pattern#

@'s scalar form already accepts 1/2c / 5Hz / 250ms; the lane accepts the same units as a pattern suffix (Patterns with units §5), reading each step through the identical conversion — literally the same function (lower/expr.rs::rate_hz_from_unit) as the scalar slot and the continuous track-lane LFO (track.rs::lane_rate_hz):

LaneStep means!"0.5" step →
!"…"ca period in cycles — 0.5 ≡ 1/2c1 Hz at 120 BPM
!"…"Hzan absolute rate0.5 Hz
!"…"ms / !"…"sa period in milliseconds / seconds2000 Hz / 2 Hz
!"…" (no suffix)cycles — see below1 Hz at 120 BPM

An unsuffixed lane reads as cycles, not Hz. That is the one place the lane departs from the scalar rule (a bare @ 3 is 3 Hz), and it is deliberate: the lane exists for the cycle-locked case — an absolute Hz rate is already expressible as a live signal expression (@ (const 5.5Hz + mod * 2Hz)). Write the c when you can: it is self-documenting and it keeps the editor's unit chip honest.

The scalar rate slot reads a constant EXPRESSION with its unit too (2026-08-22, production-gaps §28 X1). lfoSine @ 1/8c, lfoSine (1/8c), r = 1/8c … lfoSine r, (3c/8) and (2 * 4c) all fold to the same (magnitude, Cycles) pair and take the table above; only @ used to. And a rate that folds to 0 is a spanned error naming (Nc) / NHz instead of a silently frozen modulator, in a patch field and in a lane step alike. 1/8 is 0.125; integer division no longer truncates a fractional result. Named transforms, local aliases and closure factories retain known numeric pattern units in both bound and inline rate lanes (Read and write pattern pipes).

Numeric pattern units survive ordinary transforms, local aliases and closure factories. !"2 4"Hz |> fast 2 retains Hz. A mixed-unit fold such as stack [!"2 4"Hz, !"250"ms] has no coherent payload unit and fails the closed clock-source contract. Write a lane with one explicit unit instead; no unresolved payload is assigned a default rate unit.

Sampling — cycle boundaries, free-running phase#

  • The lane follows the live transport beat clock. One held note walks the whole <…> alternation, and voices read the same rate at the same moment. Musical periods remain explicit through rendering; Hz stays absolute. Tempo/meter changes adjust increments without resetting oscillator phase.
  • A rest holds the previous rate (!"<0.5 ~ 0.25>"c keeps wobbling at 1/2c through the rest), and the region before the first onset carries the lane's LAST step rather than a default. For a rate lane that rest rule is also a safety property: falling through to 0 Hz would freeze the modulator outright.
  • Phase free-runs across a rate change — no retrigger. The LFO's phase accumulator simply integrates the new rate from wherever it is, so a 1/2c → 1/4c hand-off is continuous and cannot click (engine/tests/rate_lane.rs measures the seam step against the material's own slew). Retriggering was rejected: the rates that matter divide the cycle, so free-running phase already lands on 0 at the boundary, and the ones that don't (the dotted eighth's 5⅓) are exactly where a forced reset would put a step discontinuity into a sustained bass. Phase is still zeroed per voice at note-on. This is a property of the phase accumulator, not of rate lanes in general — which is exactly why the lane is confined to the modulators that have one (next section).
  • For periodic LFOs, a one-step lane is the constant it replaces. @ !"0.5"c and @ 1/2c render the same, to within PatternMod's shared 5 ms anti-pop glide at the cycle wrap.

Sources that accept rate lanes#

lfoSine, lfoSaw, lfoSquare, lfoTri, lfoSH and wander accept numeric rate lanes. Their live readers integrate phase across rate changes. The sample-and-hold reader retains its seed and advances its draw only when that accumulated phase wraps; wander smooths the held value. Fixed absolute-rate sample-and-hold sources retain their legacy absolute-time grid.

drift still addresses an absolute-time hash grid and rejects rate lanes: a rate step would move its grid index discontinuously. Its existing continuous rate-signal input remains supported.

stepSeq takes a pattern too#

The step-sequencer modulator accepts a mini-notation pattern as well as a list, which is how a 16-step trancegate swaps to its fill shape per bar — a list literal is one fixed cycle of constants and cannot:

gain = vel * stepSeq !"<[1 0 1 0 1 0 1 0] [1 1 0 1 1 0 1 0]>"

Same node, same rules (rests hold; <…> spans cycles; combinators compose). Constant lists such as stepSeq [0.2, 0.5, 0.9, 0.5] retain their existing sound. Live list entries latch at step boundaries; interpolate selects hold, linear or a 5 ms glide.

Since 2026-09-01 (Production Gaps AB1) both stepSeq and signal also accept a structural (boolean) pattern and read it as 1/0-valued windows — t → 1, ~ → 0, with the rest law's hold suppressed for this read, so a false window actually reads zero:

puff = !"t ~ t t"          -- one pattern, five readings
gain = vel * signal puff   -- …including the gate

A pitch pattern is still rejected. See Patterns: events over time § "Structural (boolean) patterns".

Cycle choosers — chooseBy / whenSig (runtime-control-flow Stage 3)#

The first cycle-boundary sampler: runtime selection among compile-time- elaborated pattern alternatives, decided once per cycle.

bassPat = chooseBy energy [calm, driving, frantic]   -- energy: a macro
hats    = groove |> whenSig fill (every 1 (fast 2))  -- p vs (f p), by fill ≥ 0.5
  • Every alternative flattens at compile time exactly as a plain pattern; the Track carries all N event lists (ChooseDef) plus the macro-affine selector (the Param envelope), and Track.events holds the alternative selected at macro initials so chooser-blind readers still see a sane lane.
  • The engine re-reads the selector at every per-cycle firing-order rebuild — the cycle boundary, plus hot-swap and reseek, which force the same rebuild — and scans the active alternative. The active index is floor(sel · N) clamped to [0, N−1]. A mid-cycle knob move never chops the running cycle (engine/src/offline.rs::choose_by_swaps_alternatives_only_at_cycle_boundaries).
  • Mixed-length alternatives tile to a common span (the LCM of their spans), so all alternatives share one cycle length: the uniform cycle grid, seek purity, and the hold-until-span-completes rule all fall out. Decisions land on the shared boundary. The search is bounded by what the tiling would store — the shared 65 536-event budget, and a 65 536-cycle span ceiling — and each refusal names which of the two it hit. (Before 2026-09-21 the bound was a bare k ≤ 64 multiplier, which refused chooseBy sel [p7, p11, p13] while stack of the same three periods — and the same 3 003 tiled events — compiled; see compiler/benches/temporal.rs.)
  • RT-safety: the firing-order buffer reserves the union worst case across alternatives once, so switching to a denser alternative never allocates on the audio thread (lib.rs::choose_by_reservation_covers_the_densest_alternative).
  • Determinism: the selector reads macro state — captured macro lanes replay the same choices bit-exactly. Cost reports the max across alternatives (one variant schedules at a time).
  • Restrictions (v1): top-level only (the pattern argument of play/sound, inline or through a binding); alternatives are a closed list; the selector is macro-affine (an audio feature needs the deferred pseudo-macro tap, runtime-control-flow §10). Timeline and Bench/Bay retain the whole choice deck: the engine publishes its cycle-latched active index, the active alternative is drawn solid, and the other alternatives remain visible as ghost rails. Mixed-length alternatives use the shared tiled span, so the display changes at the same boundary as the sound.

A live swing amount#

|> swing v is a track control, not a per-event lane: the compiler writes the amount into the track's IR and the engine shuffles the odd 1/8 slots of each cycle by v / 24 of a cycle when it schedules them. v used to have to be a constant, so an app with a swing knob had to regenerate and recompile its source to move it (synthtoys' DRUMBOX did exactly that).

v may now be anything a track-effect parameter may be — a constant, a macro, an affine macro expression, or a control-rate signal:

drag = macro "drag" 0.2
hats = sound !"hh*8" |> swing (drag * 0.9)          -- a live swing knob
drift = sound !"rim*8" |> swing (lfoSine @ 16c |> scale [0.1, 0.6])
tight = sound !"bd*4"  |> swing 0.55                -- still a plain constant

The rules:

  • Sampled once per cycle, where the scheduler builds that cycle's firing order — the same place a chooseBy selector is read. So set_macro / ramp_macro land on the next downbeat rather than re-sorting a cycle that is already half played; one cycle always plays one groove.
  • Clamped to [0, 1], exactly as a constant is clamped at lowering, so no live value can ask for a displacement a written one could not.
  • A constant is unchanged, byte for byte. Track.swing is a Param, and a Param::Const serialises as the bare number it always was, so every golden IR and every golden render is identical (compiler/tests/automation.rs).
  • Offline consumers read the amount's initial value. MIDI export (engine/src/interchange.rs) and the workbench's pattern preview are not running the transport, so they fold the macro's declared initial — for a lfoSine-style control graph that initial is 0, i.e. an unswung grid. The audible render is the authority.
  • The scheduler reserves its per-cycle event buffer against the worst case (swing = 1) whenever the amount is live, so turning the knob up never allocates on the audio thread.
  • The bench groove panel only edits literals. Its source rewriter matches |> swing <number> textually (ui/src/benchmodel.rs), so a track with a live swing reads as "no swing" there and the panel leaves it alone rather than overwriting the expression. Drive a live swing from its macro.

Extracted groove — push (grooveOf "brk" [n])#

push !"0 8 -4 0" is a groove you wrote. grooveOf is a groove you took:

kitTrack = sound !"bd ~ sn bd ~ bd sn ~"
        |> push (grooveOf "breaks125")        -- 16ths by default
        |> push (grooveOf "breaks125" 8)      -- or name the grid

It runs the same onset detector slice uses over the named sample, quantizes each transient to the nearest of n steps, and hands push the signed milliseconds each hit misses its step by. A quantized pattern then inherits the donor break's feel without the donor having to play — the drum recipe "extract the groove from a real breakbeat and apply it to programmed hits" (production-gaps §27 V2).

Sourcea sample name, sample "name", or a bounceOf / sampleOf / comp handle — the shapes partialsOf accepts, so a break you printed yourself can lend its feel
Grid na constant in 2..=64, default 16. Finer than 64 is below the detector's hop resolution
Valuesthe donor's OWN milliseconds, bounded to push's ±120 ms. Tempo-independent: "this hit sits 12 ms behind the grid" transfers to any project tempo
Empty stepsa step with no transient near it stays 0 — the lane is a correction, and "no hit here" must not move a programmed event
Collisionstwo onsets on one step (a flam, a ghost beside a backbeat) → the stronger wins; ties break toward the smaller deviation
Wrapa hit that anticipates the loop point belongs to step 0, a few ms EARLY, not to a phantom step n

grooveOf is legal only as push's argument; anywhere else is a compile error naming the right spelling. It is a closed value: gx = grooveOf "brk" 16 then push gx (or a let, record field or function argument) lowers exactly like the inline call; a transformed groove (gx |> fast 2) is refused as not known at compile time.

Why it resolves in the engine, not the lowerer#

The compiler has never seen a sample's PCM. So push (grooveOf …) lowers to two things: a neutral, all-zero push_pattern laid out on the requested grid (pure arithmetic, which the lowerer can do), and a push_groove request naming the donor. volta_engine::groove::resolve_groove fills the values in at load, from the one onset detector both hosts share — the same "lowerer emits a sentinel, the runtime resolves it against the buffer" plan slice uses for RegionSpec::Onset.

Two consequences worth knowing:

  • No host skew. Baking the numbers compiler-side would need a second detector on the browser's side of the wasm boundary, and the two would swing differently. One detector, one set of values, every host.
  • A missing donor is a no-op, never a wrong groove. An unresolved request leaves the zero lane in place, so the track plays exactly as it would with no push at all, and volta doctor reports the missing sample by name. If the donor arrives after the module (a bounceOf baked post-load), the next load_samples re-resolves it — the same retry stretch nodes get.

The engine sharpens each coarse 256-sample onset marker to the loudest sample in the two frames that follow it (the refinement stretch.rs already applies for transient alignment), which is the difference between a template quantized to ≈5.8 ms and one good to about a millisecond.

How it's implemented#

  • Compiler
    • The builtin descriptors — Gain in builtins/amplitude.rs, Speed / Warp / pitch in builtins/samplers.rs, Pan in builtins/controls.rs, Push / VariantP / PitchRoot / Velocity in builtins/track_modifiers.rs. Speed / Pan / Warp / pitch are overloaded: the same name is a signal-context modifier or a per-event track lane, with a separate descriptor row per reading (Receiver::Sig vs Receiver::Track); the receiver selects the reading.
    • lower/track.rs — one arm per lane, each folding its mini-pattern to a VelocityPattern (cycle_beats = bar_beats() / rate_mult, lower.rs::cycle_beats_for). Lane validation lives here: warp rejects anything but 0/1, push bounds to ±120 ms, variantP rejects negatives and rounds, pitch requires a pitch payload, everything else a numeric one. Every lane but gain/pan/velocity reads its value through one shared resolve_lane_folding, which turns a constant (or a pitch literal) into the one-step pattern before the arm's validation runs — the single place the "constant = one-step lane" rule lives.
    • lower.rs — validate_ctrl_destinations → token_lane_attribute_error checks per-token lane attributes; empty_sample_windows raises EmptySampleWindow.
    • ir.rs — Option<VelocityPattern> fields on Track: gain_pattern, speed_pattern, pan_pattern, push_pattern, warp_pattern, pitch_pattern, variant_pattern, cut_pattern, begin_pattern, end_pattern, plus sampler_root: Option<f64> and push_groove: Option<GrooveRef> (the unresolved grooveOf request).
    • lower.rs — groove_request peels grooveOf src [n] off push's argument slot (None → an ordinary written lane), reading the donor through groove_source_name; doctor.rs reports a donor that is in no manifest.
    • lower/expr.rs — rate lanes become LivePattern controls on the transport grid. Musical/sample units feed PeriodRate; absolute units become Hz. Rests hold, and erased unit suffixes diagnose. lfoSH/wander/drift accept live scalar rates; their direct pattern-rate overload remains rejected. stepSeq accepts live list entries and explicit interpolation; see Bring a sound to life for scopes and the live segment bridge.
  • Browser page side (the wasm workbench's host, not a second renderer)
    • runtime/src/types.ts — the mirrored Track fields; NoteParams.speedEvent / warpEvent.
    • runtime/src/native-runtime.ts — enumerates every name:variant a lane can reach and ships those files (plus the manifest bpm) to the engine.
  • Engine (desktop, browser AudioWorklet and plugin target alike)
    • graph.rs — the lanes on TrackPlan; build_sampler_synths builds one template per reachable (name, variant) and sets root_midi from sampler_root; CompiledNode::Sample carries warp.
    • lib.rs — event_controls samples the pitch/speed/warp/variant lanes and event_dynamics the velocity ones; the trigger passes a voice::EventCtl (speed, warp, tempo snapshot) into the voice.
    • lib.rs::event_sample_lanes samples cut/begin/end (a token's TrackEvent.params value overriding the lane); the trigger calls VoiceArena::choke (voice/arena.rs) before allocating, and passes EventCtl { cut, window }; EventCtl::play_window (voice/mod.rs) composes the window every sample reader takes.
    • voice/eval_sampler.rs — the Sample arm's rate is sample-rate conversion × repitch × warp × ctl.speed.
    • sample.rs — SampleBank::bpm / set_bpm, with a name:variant → name fallback.
    • groove.rs — groove_offsets (the extractor) and resolve_groove (the load-time pass that fills push_pattern's values and clears the request), called beside resolve_stretch in Engine::load / Engine::swap / EngineHandle::load_module; groove_pending extends reresolve_stretch so a late donor still binds.
  • Tests — compiler/src/lower/tests.rs (gain_speed_pan_per_event_patterns, warp_pitch_variant_per_event_patterns, control_patterns_compose_with_higher_order_combinators, plus the rejection cases); compiler/tests/dual_reading_contracts.rs (a_constant_on_a_track_lane_is_the_one_step_pattern — constant ≡ !"v" per lane, inline, bound and in a lambda — and a_constant_on_a_signal_receiver_keeps_the_modifier_reading); engine/tests/pattern_controls.rs (audible behaviour: rate, pitch, which file sounded, cross-cycle advance); engine/tests/push_timing.rs (onset positions to the sample, including the three grooveOf cases — feel transferred, late donor, unresolved donor); engine/src/groove.rs's unit tests (signed deviations off a synthetic donor with known offsets, wrap, empty steps, the ±120 ms bound); compiler/tests/automation.rs (groove_of_* — the lane + request the lowerer emits, and every rejection). The sampling arithmetic itself is pinned by engine/src/lib.rs's sample_pattern_is_stepwise_and_wraps (the TypeScript pattern-controls.test.ts it replaced was deleted with that workbench).

Where the source tempo comes from#

warp needs project_bpm / source_bpm, and the source tempo lives in the sample manifest (runtime/public/samples/index.json) — which the engine never reads. Whoever owns a manifest hands the tempos over:

HostPath
Browsernative-runtime.ts reads SampleRegistry.bpm → loadSamples({bpm}) → WasmEngine::set_sample_bpm
Desktopapp-native/src/samples.rs parses index.json → EngineHandle::set_sample_bpms
Offline exportoffline::render_export(…, bpms, …) → Engine::set_sample_bpm, so an export warps like live playback
volta CLIdecodes a flat WAV directory with no manifest → no tempos → native rate

A bank with no declared bpm plays at native rate. The browser warns once per name (SampleRegistry.reportWarpFallback); the native engine has no warning channel and is silent.

Notes & limits#

  • speed and warp are sampler-specific. On a play track whose patch has no sample source there is no playback rate to scale — the lane is a no-op, not an error. gain / pan / push / pitch apply to both sound and play. (On sound tracks gain is audible because the fabricated $sampler scales its output by NoteParam(vel) — see Velocity and accents §4; on a play track the patch must read vel.)
  • gain overshoot is accepted (no per-knob clamp); only the final velocity product is clamped to [0, 1], so large gains saturate at 1.
  • pitch on a sound track needs pitchRoot to be musically meaningful — the lane transposes relative to the sample's recorded pitch, and the default root is middle C.
  • bd:0 is indistinguishable from a bare bd in the IR, so variantP fills in over an explicit bd:0. Any other explicit :N wins.
  • push composes additively with swing/nudge/humanize inside apply_timing, sampled at the RAW onset. The ms → cycle-fraction conversion uses the module's BASE tempo (cycle_seconds), so under a tempo map the shift is a fixed musical fraction rather than exact wall-clock ms (the bounce precedent).
  • Timing offsets are SIGNED and absolute — they cross the cycle seam rather than wrapping inside it. A nudge (-0.01) or a push !"-20 0 0 0" on the downbeat plays the event 20 ms before its cycle starts, i.e. at the tail of the previous cycle, and a late offset spills into the next one. Because that reorders the pattern (swing alone does: its shift is an absolute swing/24, so on hh*32 at swing ≥ 0.75 the last event of a 1/8 slot overtakes the first of the next), the engine re-sorts each cycle's events by their adjusted onset before walking them (engine/src/lib.rs::cycle_firing_order). Every event fires exactly once, under any combination — with the single time-origin exception below.
  • The one exception is the time origin. An event of source cycle 0 dragged before the track's own zero is clamped to 0 — it fires on the downbeat rather than being dropped or wrapped. An event of a later source cycle that still lands before zero (only reachable with |nudge| ≥ 1) belongs strictly before the start of time and is dropped: clamping it too would pile cycles 1, 2, … onto the downbeat as a chord nobody wrote. So a nudge (-2.3) puts exactly the first cycle's events on beat 0 and starts the audible grid from wherever the offset lands it. MIDI export (engine/src/interchange.rs) folds the same signed offset, applies the same origin rule and scans the same number of cycles, so a bounce matches the render.
  • nudge is bounded by the scheduler's reach. The scheduler scans ±MAX_TIMING_REACH = 128 cycles around the cycle it is filling, so a larger displacement could not be found at all; the lowerer rejects |nudge| > 120 (the reach less headroom for the swing/push that stack on top) with a diagnostic rather than letting the track fall silent. Everything inside that bound fires — nudge 40 plays the whole pattern 40 cycles late (engine/tests/timing_census.rs::nudge_beyond_one_cycle_fires_every_event). nudge is micro-timing; to move a track in the arrangement, use at.
  • A bare or c nudge is a fraction of the EXPANDED cycle; ms/beats/bars are absolute. 0.01 after slow 8 is 0.32 beats; nudge 10ms or (1beat/4) is that offset on any cycle span, through a clock written before it (timing_census.rs::an_absolute_nudge_*). ms reads at the song tempo, beats are exact under a tempoMap. warning[NudgeCycleSpan] flags a cycle nudge under 1/16 beat against one bar that its cycle makes ≥ 1/16 beat.
  • Hosted-plugin parameters pattern too, with a deliberate semantic twist: |> pluginParam <id|"Name"> !"0.2 0.8" is a stepwise lane that fires at its own pattern-event onsets against the transport (a plugin parameter is device state, not a per-voice value), rather than zipping onto note onsets like the combinators above. They also take signal expressions (pluginParam "Cutoff" (lfoSine @ 1/2c * 0.4 + 0.5), or a macro for a live knob), evaluated block-rate. Native desktop only — see Host instrument and effect plugins.
  • What this still isn't (see Control patterns for individual events §7): per-event modulation of patch parameters, per-event effect parameters (the bus is shared), and continuous intra-note sweeps. The general form is Pattern Structure Operators.