VOLTAreference

Reference / Movement and control

Macros and automation

Movement and control · playable examples

Open in the workbench →
On this page · 6 sections

Connect controls to sound parameters, automate their movement, and shape several musical changes with one gesture.

Hear it and change it#

A macro is a named value. Studio presents it as a control you can adjust. Use expressions to give that one control several related jobs.

Listen for: a resonant, rounded bass. Copy this program to Studio and turn the bright macro to hear the range of the instrument. The short preview uses the written starting value.

Listen to this example · four bars, then the tail
tempo = 120bpm
timingVersion = 2
bright = macro 0.3
play "saw" "c3 ~ [g3 c4] ~"
  |> cutoff (400Hz + bright * 3200Hz)
  |> resonance (1 + bright * 3)
  |> release 180ms

Make it yours: change 3200Hz to 1200Hz for a gentler knob. Design its range so the low and high positions are both useful, then give it a name that describes what you hear.

1. The macro bus#

Macro initial values and range endpoints must remain finite in engine precision. Ranges must be ordered; an explicitly flat range is valid, but a nonzero range that collapses when converted to engine precision is rejected. Preparation does not clamp the authored initial value to the knob range.

Native execution plans carry typed macro and automation-destination units. JSON keeps the default Normalized unit and accepts legacy spellings such as s, ms, Db and the empty dimensionless unit; re-encoding uses canonical Seconds, Milliseconds, dB and (). Unknown units and graph reads whose unit differs from their macro declaration reject before installation, including reads inside shared controls, modulation graphs and nested effects.

On recompile, a macro keeps its live target, smoothed value, takeover and session automation state only when its registry name, unit and authored range match. Reordering declarations or changing a default/display annotation keeps that state. New names and changed units/ranges start from the new initial value in Read mode, unarmed, with no retained gesture or return ramp.

An already-sounding note keeps its original patch graph. Its macro reads follow the current destination with the same registry name and unit/range contract, even after reordering; live knob moves and dense modulation remain live. While a destination is removed or incompatible, that old voice holds its last read value (the original initial if it has not read yet). New notes use the new patch and controls. songPos and sectionPos always read the current transport positions, independently of how many macros exist.

Modulation voices also compare their graph, blend rule and the unit/range contracts of macro dependencies, including reads through other modulators and shared controls. Compatible voices keep phase across unrelated insertions; incompatible voices restart. Persistent shared controls use the same dependency check. Insert chains rebuild when their captured macro table, macro contracts or control-graph table changes. During the outgoing chain's short crossfade, its macro and graph controls hold their final effective values. It never reads the replacement table's slots or loops the last modulation block; the new chain follows the new controls immediately.

depth   = macro 0.5                                          -- the knob is named `depth`
bright  = macro 0.4 (lfoSine @ 1/4c |> scale [0, 0.3])
shimmer = macro 0.2 (!"0 0.3 0.1 0.5" * depth)
wob     = macro "wobble" 0.5                                 -- an explicit registry name

A macro's optional third argument is a modulation graph: any Sig Control expression. The knob (slider, MIDI CC, shred :=) owns the base; the graph's output is combined with it — by default summed — and the result clamps to the macro's range ([0, 1] for a plain macro; the declared unit's range for a unit-carrying one). §1.0 is the whole set of combining rules. The familiar depth-knob idiom is unchanged and complementary: macro "wob" 0.5 * !"0.3 1 1 1" at a use site makes the knob scale a pattern instead.

Allowed in a modulation graph — anything note-context-free and control-rate: LFOs (lfoSine/lfoSaw/lfoSquare/lfoTri/lfoSH, @-rates), numeric mini-notation patterns, drift, gate / curve / slew, arithmetic, delayed followers, applicable simulations/chaos, explicitly shared controls, explicit follow conversion and other macros. See Bring a sound to life for scope and lifecycle. Compile errors: note-triggered envelopes/withDecay/withDelay, keytrack, vel/pitch, per-note random, unconverted audio-rate sources/effects ("runs once at module level with no note context"), and macro-reference cycles (a → b → a, reported with the span). Source, native IR and JSON apply these rules to every stored node and macro name, including __repl…. Explicitly gated ADSR can run without a note; notebook envelope previews use an offline note probe.

The graph is instantiated once per module (not per voice) and runs for the session: LFOs free-run, patterns loop on the global cycle grid (phase-aligned with per-voice PatternMods of the same pattern), S&H and drift draw deterministic streams from the module seed on an absolute-time grid.

A pattern lane steps with a ~5 ms anti-pop glide, except at the very start. Each step of a numeric pattern in a graph ramps over ~5 ms from the value the lane held a sample earlier, so a lane driving a gain or a cutoff does not click. At the cycle wrap that earlier value is the pattern's last event — but on the FIRST cycle nothing played before, so an onset written at beat zero is in force from sample zero rather than climbing out of a value that never sounded. (Until 2026-09-16 it climbed, and a lane whose burst began at the transport's exact zero started one sub-slot late on cycle one and was right on every cycle after; engine/tests/macro_pattern_lane.rs pins both halves.)

Smoothing a lane: smooth t m, or m |> smooth t. smooth is seconds-first and withSlew is signal-first — they are the same one-pole — so smooth 120ms (!"0 1 0 1" |> slow 2) and !"0 1 0 1" |> slow 2 |> smooth 120ms are the same graph: a piped receiver is read as the SIGNAL, never as the time. (Until 2026-09-16 the pipe threaded the lane into the seconds slot and the time literal into the signal slot. Both slots accept a control signal, so it compiled, and the macro read a flat 0.12 — a measured peak of 0.12 against the unsmoothed lane's 1.0.)

1.0 How the graph meets the knob: { blend = … }#

orbit = macro "orbit" 0.5 (lfoSine @ 1/4c |> scale [0, 1]) { blend = "offset" }
depth = macro "depth" 0.0 (lfoTri @ 1/8c |> scale [0, 1]) { blend = "scale"  }
both  = macro "both"  0.3 (lfoSine @ 1/2c |> scale [0, 0.2])  -- blend = "sum"

Write the macro's own range as [lo, hi] — [0, 1] unless the initial carries a unit — with midpoint mid = (lo + hi) / 2 and the knob's normalized travel t = (base − lo) / (hi − lo). Then, per sample:

blendvalue before the clampthe knob is…
"sum" (default)base + modan offset the graph adds to
"offset"base + (mod − mid)the modulation's centre
"scale"t × modthe modulation's depth

All three then clamp to [lo, hi]. "sum" is what every macro did before 2026-09-16 and is still what you get by writing nothing, so no existing song changed by a sample; { blend = "sum" } spelled out is bit-identical to omitting it (engine/tests/macro_blend.rs).

Pick by what the knob should mean:

  • "offset" when the graph already fills the range. An LFO scaled to [0, 1] is a complete orbit; under "sum" a knob at 0.8 pins its top against the ceiling and two different modulator values come out as the same clipped 1.0. Under "offset" the knob slides the whole orbit and the shape survives. (This is the mode synthtoys' ORRERY had to fake by ramping the macro from the page frame by frame and compiling a second variant for export.) A bipolar modulator (a bare lfoSine, perlin, drift) already centres itself on the base, so "sum" is the right mode for it — "offset" would shift it down by mid.
  • "scale" when the knob is a depth. At 0 the modulation is gone entirely, at 1 it passes whole, and the base itself never reaches the destination. x = macro "x" 0 (…) { blend = "scale" } is a mod-wheel.
  • "sum" for everything else — a knob that sets a level with some motion on top.

Only a macro that HAS a modulation graph may name a blend; macro "x" 0.5 { blend = "offset" } is a compile error naming the missing graph. The mode is an ordinary named option (Named options and parameter values), so it can come from a shared options record, and a misspelling is a check-time error listing "sum", "offset" and "scale".

A blend changes only what the macro's own value is. libvolta, the engine's wasm surface, MIDI learn and every set_macro / ramp_macro caller still write and read the base, in the macro's declared range, exactly as before.

1.1 Who owns the base when two automators want it#

The base has four writers — the knob/MIDI, a shred := lane (§2), a scene with { m = … } / with { m = ramp a b } motion (Arrange a song §4.2), and a written automate lane (§1.2) — and all of them write the same cell, AudioEngine::macro_values[id]. There is no arbitration and no writer priority: the outcome falls out of where each write sits inside one process block, and it is not symmetric.

Two writers on one macroWinnerWhy
scene ramp + shred lanethe scene ramp, for the ramp's whole spanapply_macro_ramps runs at the top of every block, before smooth_macros; the lane fires later in the same block, so its value sits in the base for exactly one block and is overwritten before it can be smoothed into audio
scene = + shred lanethe shred lanea scene = is a one-shot at the section downbeat, so the next lane event overwrites it and every one after
knob/MIDI + shred lanelast write wins, per eventboth are discrete; §2's "your value holds until the next event fires"
knob/MIDI + armed scene rampthe scene rampsame per-block re-assert as row 1

Measured, not inferred: engine/tests/macro_writers.rs renders a live scene ramp against a looping := lane on one macro and reads both views per block. Over a 2 s ramp the effective value (what voices and effects read) went 0.002 → 0.992 with a worst reversal of 0.0000 — the lane's 0/1 steps never reach audio, even though they are visible in the base cell for 8 of the blocks (figures the test prints when it was written; it asserts looser bounds). With a scene = instead of a ramp, the lane reclaims the value immediately.

Because one of the two automations you wrote is doing nothing either way, the compiler warns at lowering when a macro is written from both places (MacroWriterConflict, blamed on the :=). The clean design — and what every song that automates from both places should do — is disjoint knob ownership: give the scene and the shred their own macros.

1.2 A written automation lane#

A song written as a timeline automates with an automate lane — curve regions aimed at one destination. This section is the base-ownership half; the whole lane surface (parameter addresses beyond macros, curve segments, base composition, attachment, and the recording modes) is Draw a change, and record one, and where a lane's regions are placed is Write a song as a timeline:

tone = macro 620Hz
opening = automation { points = [[0bars, 620Hz], [24bars, 5600Hz]],
                       interpolation = "linear" }
brightness = automate tone [region 8bars 24bars opening]

A lane is a base writer, like the three above, with these differences:

  • It writes its destination's base every block, evaluated analytically from the transport beat — so seeking to bar 20 reads exactly what playing to bar 20 would have read, and a @gap "hold" value does not depend on whether its region happened to play.
  • Its curve is in the destination's own unit (Hz for macro 620Hz), and interpolates in that unit — hold, linear, exp or monotone cubic, per point.
  • Its destination is a declared macro or a parameter address ("bassT.reverb.mix", "bassT.level", "bassT.sends.hall"), which the lowerer resolves by name to an internal mix:<address> macro. So a mixer or insert lane rides the same base bus as every other automator here, and everything in §1.1 applies to it unchanged.
  • Between regions it writes the macro's declared base; @gap "hold" on the automate binding writes the preceding region's final value instead.
  • The reported owner is pattern — the same "lane" owner a shred := reports, reused rather than adding a fifth state to the wire format.
  • An explicit manual takeover still wins: the lane skips a destination that is under takeover. What RELEASE does is the destination's automation mode (Draw a change, and record one § Modes), and it is the only thing the modes change: Read and Touch hand the destination back at the current musical position over a configurable 20 ms, an armed Latch and an armed Write inside its punch range retain the value (unarmed, they hand back like Read), and Off makes the lane skip the destination entirely so the manual base stands while modulation keeps running.
  • Stopping the song stops the lane. Stop must be silent for base writers too, so the lane releases its destinations rather than pinning them at their last value.

Static conflicts are compile errors, not warnings, because a written placement is knowable before anything plays. All three name both writers — and all three now name a fix that works, because @compose "crossfade" / "priority" on the lane says how two overlapping base regions combine:

ConflictFix the compiler suggests
Two curve regions overlap on one lanemove or shorten one, or compose them into a single curve
Two lanes aim at one macromerge them into one lane
A scene with { m = … } / ramp and a lane aim at one macrogive the scene its own macro, or move the gesture into the lane

A shred := lane on the same macro is not statically refused, and it is the same trap §1.1 describes — the timeline lane re-asserts every block, so the discrete writes never reach audio. Disjoint knob ownership is still the rule.

Explicit manual takeover#

The legacy writer order above is unchanged. Hosts can opt into EngineHandle::takeover_macro(name, value) and release_macro(name); the wasm engine exposes the same methods. Takeover holds only the base. Modulation keeps running and combines with the held base under the macro's blend as usual. Pattern and scene clocks continue while their writes are suppressed; release resumes a still-active scene ramp at its current position or lets the next pattern event write. If neither writer remains, the manual value holds. Hard stop releases ownership; compatible edits preserve it by macro identity. Independently automated layers should still use separate bases.

AudioEngine::macro_base_owner and visualization telemetry report manual, pattern, scene or takeover. Plain manual ownership is the legacy default and is omitted from the optional wire field. The native frame and wasm JSON carry the same state.

SceneMacroInert — a knob wired to nothing#

The other half of the same silent failure (2026-09-01, production-gaps.md §31 AC4): with { trance = ramp 0.5 1.0 } on a macro that no patch field, track pipe, bus, insert, or mixer strip reads compiles and does nothing. Lowering now warns (SceneMacroInert, naming the scene and the macro). The read set is collected by walking the finished Module for every macro reference — a Param's macro_ref anywhere in the track/bus/mixer/insert tree, a synth or modulation node's macro_name, a shred's read source — rather than from a hand-listed set of slots, so a newly added Param slot can never produce a false "inert" on a knob that works. A declaration, a scene entry and a shred WRITE all use different keys, so none of them counts as a read. Advisory, never fatal: parking a knob for a later edit is legal, the warning just says so out loud.

1.3 modulate — a graph from outside the declaration#

cut = macro 0.3
acidVoice = patch { source = saw (freq pitch), cutoff = 200Hz + cut * 2000Hz, gain = vel }
acidLine = play acidVoice !"c2*8"

modulate cut (lfoSine @ 1/4c |> scale [0, 0.4])

modulate <macro> (<graph>) is a top-level statement that adds a modulation graph to a macro that is already declared — the control-*in* cable macro's own third argument only offers at the point of declaration. It is the one spelling that reaches a macro of an imported module, whose declaration you do not own:

``volta norun needs anacid` module to import import "acid" as acid bass = acid.line

modulate acid.cut (lfoSine @ 0.25Hz * 0.2) -- a control signal modulate acid.cut (!"0.1 0.9 0.4 0.7") { blend = "sum" } -- a numeric pattern modulate "acid.cutoff" (meta) -- by registry name


The target is the macro **binding** (`cut`; `acid.cut` for one flattened out
of an alias-imported module, [[reference/namespaces]] §3) or its **registry
name as a string** — the only spelling a `priv`-bound macro has. Any other
target (`modulate (head xs) (…)`) is still read as a `modulate` statement and
refused at the target, never swallowed by the line above. The graph is
the same expression language `macro`'s third argument takes, lowered by the
same code with the same rules: §1's allowed set, the note-context refusal, the
unit checks and the macro-reference cycle check all apply unchanged, and a
cycle a `modulate` introduces is reported on the `modulate` line.

**Every graph aimed at one macro is merged into ONE graph.** The
declaration's own first, then each `modulate` in source order, summed and
lowered once. There is no IR change and no engine change: a macro that four
cables reach still has a single `MacroDef.modulation` and a single modulation
voice. The merge is at the graph-node level, so a lone numeric pattern still
lowers to exactly the `PatternMod` it lowered to before, whether it stands
alone or is one summand of four.

**One blend per macro.** `MacroDef.blend` is one field, and that is a
semantic decision, not a shortcut: `"sum"` and `"scale"` **distribute** over a
sum of graphs (`base + (a + b)` = `(base + a) + b`; `t·(a + b)` = `t·a + t·b`),
so merging under either is exact — `"offset"` does **not** (`base + (a+b) −
mid` and `(base + a − mid) + (b − mid)` differ by the range midpoint `mid`).
So a `modulate` may name a blend only when the declaration named none — it
then sets the macro's blend — or named the same one. Two disagreeing
spellings are a compile error that prints both. A `modulate` that names no
blend inherits whatever the macro has.

**It is a modulation layer, not a fifth base writer.** The writer-precedence
table in §1.1 does not move: the knob/MIDI, a shred `:=`, a scene motion and a
written `automate` lane still own the base between them, and a `modulate`
graph rides on top of whatever they wrote, exactly as a declared graph does.

**Entry-only**, like `automate` lanes and `mixer` sections: a library's own
`modulate` statement does not travel into a song that imports it. A module
that wants its knob to wobble by default says so in its `macro` declaration;
`modulate` is the song's cable, not the library's.

## 2. Shred pattern automation

```volta
wob = macro "wob" 0.5
auto = do {
  wob := !"0.2 0.8 <0.5 1>";   -- looping automation, anchored here
  wait 8beats;
  wob := 0.5                   -- a plain set replaces the automation
}

m := <numeric pattern> (inline literal or a named top-level pattern) starts a looping automation lane: from the op's beat, the macro's base steps through the pattern's events, repeating every cycles bars (cycles × beats-per-bar; 4 beats per cycle in 4/4), until a later := (constant, read, or another pattern) on the same macro replaces it. Writes are discrete events, so grabbing the slider between events works — your value holds until the next event fires. (Against an armed scene ramp it does not hold: the ramp re-asserts every block and outranks both the lane and the slider for its span — see §1.1.) Pitch/sample patterns are a compile error ("macro automation needs a numeric pattern").

3. Reading the rack#

Each macro is a compact knob card in the Macros strip: the name and a MIDI-learn socket on one head row, the knob, and the readout.

  • The knob's lit arc and pointer show the base (what you / MIDI / shreds set); a hot dot on the ring marks the live value.
  • The readout shows the live value (base + modulation, clamped) — shred automation and modulation wobble are both visible.
  • A thin inner track just inside the ring spans the range the live value has covered over the last 180 painted frames (about 3 s at 60 fps — the breathing range; nothing while the value is static or the transport is stopped).
  • While the shown value is moving — a drag, automation, modulation — the arc and dot glow, scaled by speed (a crawl under 2 % of the span per second stays dark), and the glow decays over roughly a third of a second once it settles. A settled strip requests no extra repaints.
  • The strip's mod matrix rows put a small level bar in the source's hue in its own column left of the aligned value, with a tick and a dimmed (declared) value when the live value has moved off the declared base.
  • A modulated macro has no separate badge: its live dot leaves the pointer and the breathing range appears on the ring.
  • := read other samples the other macro's base (its last set value), not its modulated live value.
  • The mod matrix below the tiles opens as a summary — one line per source (▸ motion → 7 routes); click a source to unfold its routes, click again to fold them (the open set is remembered). Unfolded, a source lists one row per destination it reaches, read as synth.field (e.g. haze.cutoff). Sources are macros, vel, and generators bound to their own synth field (envelopes, LFOs, drift and random, named by that field, e.g. filtEnv). Inline generators, followers and track-effect bindings are not listed. Macro rows carry the level bar and the live value; other rows show only the path.

4. Track-effect parameters (live)#

A macro reference — bare, affine (wet * 0.5 + 0.1) or a persistent control graph — at a track-effect parameter binds the knob to that parameter live — not just the macro's initial value. Most effect amount, mix and feedback slots accept one (see The effects reference); for example:

EffectSurfaceModulatable param
pingpong… |> pingpong (1/8c) pingfeedback (clamped ≤ 0.95)
delayTrack… |> delayTrack 80ms fbfeedback (clamped ≤ 0.95)
compress… |> compress amtamount → threshold/ratio/knee/makeup
convolve… |> convolve "ir" wetwet/dry mix
ping = macro "ping" 0.45
padTrack = play pad chordPart |> pingpong (1/8c) ping |> reverb 0.25

Dragging ping now changes the echo's feedback in real time. The binding is visible on the effect's own control and in the Rack, not in the Macros pane's mod matrix, which lists synth-field routes only.

Delay times accept persistent seconds/milliseconds controls; fixed cycle, beat and bar durations compile against the song's timing context. Delay damping updates live without rebuilding the line. Feedback on pingpong, delayTrack, bbdDelay and tape reads every sample, even when it is the only animated parameter. Reverb amount and wet mix also accept persistent controls; tank storage remains prepared. See The effects reference for per-effect boundaries.

Voice-level construction scalars (live)#

The same "a knob, not a baked number" move applies inside a patch, but with different plumbing: a voice graph already has node refs, so a live construction scalar lowers its expression to a modulator node and the IR node stores a *_node ref beside the constant. Like a persistent Param graph, this admits a macro, an affine macro expression, an LFO, a chaos source, anything control-rate — and it is the mechanism drift's depth/rate have used since C3. Closed by the gap/const-args batch (production-gaps §13 H2 / §20):

SurfaceFieldRead as
additive … |> ensemble depthAdditive/AdditiveSpectrum.ensemble_nodeper block, one-pole ~30 ms on the derived detune
sig |> tol amountRandomUniform.span_nodelatched at note-on (the draw stays latched; only its scale rides)
sig |> comb time feedbackComb.feedback_nodeper block, linear ramp across the block
crackle densityCrackle.density_nodeper block (exact for a Poisson process)
gaussSpectrum count centre widthSpectrum::Gauss.bw_nodeper block, one-pole ~30 ms
sig |> scale [lo, hi](no IR field — pure sugar)continuous; the affine coefficients become nodes

Every one of these keeps a constant fast path: a literal lowers exactly as before, the *_node field is skip_serializing_if = "Option::is_none", and the scalar beside it carries the expression's value at the macros' initials so a reader without live support degrades to a sane number instead of erroring. The smoothing rule follows the DSP reference corpus (Surge, chowdsp, Mutable): latch the random shape at note-on and read the amount live; smooth the derived quantity — a delay time, a detune, an amplitude — never the knob.

In the IR the track-effect parameter is an ir::Param — #[serde(untagged)], so a constant serializes as a bare number (unchanged) and a macro as { "macro_ref", "initial" }, an affine macro as { "macro_ref", "scale", "offset", "initial" }, and a persistent graph as { "control_ref", "unit", "scale", "offset", "initial" }. The engine resolves the macro to its interned id at build and refreshes the effect's value each block from macro_ramp in TrackFx::process (process_effect → EffectState::apply_macros).

Control rate: which params ramp per sample#

macro_ramp carries a (block-start, block-end) pair per macro (the previous block's end IS this block's start, so the pair is continuous at every seam). How an effect reads that pair depends on what the parameter costs:

  • Per-sample ramp (ParamBind::span) — the cheap scalars that multiply audio directly. The rule is mechanical: if the knob ends up multiplying a full-scale signal, it ramps. That is panTrack's auto-pan gains, width's side gain, the wet/dry crossfades of plate / reverb / spring / convolve / crush / saturate / neural, harmonize's gain on the added voice sum, the bbd and ensembleChorus bus blends (their modulation depth is fixed by the circuit — the knob is purely a gain), decorrelate's scaling of the synthesized side, sympathetic's drive into the string bank, and saturate's drive (2026-08-04 — see the note under the per-block list). These lerp between the block's endpoints per sample, the same reconstruction the per-voice MacroRef uses, so an effect knob and a voice knob driven by the same macro move on the same trajectory. Before this (audit-2 #8), a 6 Hz panTrack sweep put −38.9 dB of sideband energy on the 375 Hz block grid — a fixed-pitch buzz that does not move with the music — and a knob flick stepped 2.9× the signal's own p99 |Δx|; both are now at the −68 dB / 1.0× floor set by the macro's own reconstruction. The same measurement on the ones converted by the #8 review pass (neural excepted — it needs a model file and the neural feature), swept → after, with each effect's own parked floor in brackets: harmonize −90.8 → −118.5 [−120.4], decorrelate −44.8 → −81.2 [−137.8], bbd −43.8 → −74.6 [−81.9], ensembleChorus −49.3 → −68.8 [−75.0], sympathetic −75.8 → −83.2 [−82.3] dB, block seams 4.8–29.7× → at the parked render's own 1.1–1.4×.
  • Sub-slice step (ParamBind::at) — parameters whose re-derivation is expensive or quantised. A settled one is read once per block; a MOVING one (endpoints differ, or a modulation curve is loaded) runs its effect on 16-sample slices (effect::SUB_BLOCK), each reading its own slice end — a smaller host period, which every effect supports. Under a 7 Hz LFO macro the 375 Hz block line fell from −46.5 to −101 dBc (fatEq), −53 → −102 (mackity), −54 → −147 (cornerClip mix), −53 → −82 (fet), −65 → −122 (ott), −60 → −108 (optical), −38.5 → −54 (pyewacket output, now under its own modulation sidebands); engine/tests/effect_param_motion.rs, measured offline. Each keeps its own grid inside a slice: EQ band coefficients (their own 32-sample grid), the compressor/OTT/optical/FET/de-esser curves, crush's integer level count and the STFT parameters of tune / formantShift / spectral (quantised to the analysis hop regardless), place's HRTF coordinates, neural's latent bias lanes. The step lands on a coefficient there, not on a full-scale multiply. haas is a third case: it slews its delay length per sample with a 5 ms one-pole, because a delay step is a phase discontinuity rather than a gain step.

    saturate's drive used to be on this list, on the grounds that it has its own 10 ms smoother. That was wrong: the smoother was advanced once per process call, so the drive was a filtered staircase, not a continuous ride — a 6 Hz sweep put a 4.9× step at every block seam (tube). As of 2026-08-04 the one-pole ticks per sample and chases the span pair, and the expensive half — the RMS gain compensation, a 64-point numerical integral of the transfer curve — is read from a 513-point Catmull-Rom table over 1/comp plus a linear correction that pins each block's two endpoints to the exact integral (2026-08-05; the first cut interpolated it with a per-block quadratic, which was exact at the seams and still carried up to −4.19 dB of in-block gain error on clip — a table is accurate to 0.014 dB and, being a pure function of the amount, carries no block-length term at all). The drive coefficient itself is affine in the amount, so it is exact per sample. Block seams on the same 6 Hz sweep, before → after: drive 1.63 → 1.19, tube 4.91 → 1.09, fold 2.77 → 1.85, clip 1.84 → 1.54, tape 2.28 → 1.21; block-grid sideband energy away from the shaper's own harmonics, tube −73.4 → −105.3 dB against a −133.5 dB parked floor. (The plain band metric does not move — −53.3 → −53.4 dB — because a saturator's harmonics land in those bands and a swept drive legitimately modulates them; that measures the effect working, not the staircase.)

    Note also that harmonize's macro slot is its wet mix, not an STFT parameter — it is in the per-sample list above.

A settled knob (start == end, which includes every constant parameter) takes each effect's original hoisted-scalar loop bit-for-bit, so nothing about a static patch changed.

The ramp also makes the swept params ~host-period independent: a knob move rendered at a 480-frame device buffer (sub-blocked 128/128/128/96) now matches the offline 128-frame render to ≤ 6e-4 instead of ~1e-2. It does not make them exactly period-independent, and the bound is not a constant: what remains is the macro one-pole's own frames-dependent coefficient, which a macro on a voice parameter carries identically. Measured max |Δ| against the 128-frame render — voice reference / width / plate wet / reverb wet — is 2.0e-4 / 1.2e-4 / 2.5e-4 / 2.2e-4 at a 480-frame period and 1.6e-4 / 1.7e-4 / 2.8e-4 / 2.6e-4 at 96. panTrack is the exception at 5.5e-4 / 1.2e-3: its per-block chord through the pan law leaves a term that goes as the square of the travel per block, so schedules of different block lengths round the arc differently (see the note in AutoPan::process). The auto-pan used to split each block wherever the sweep crossed centre, because the W3C stereo-input law it ran is only C⁰ there; the 2026-08-05 pan-law unification (Mixing tracks and buses §3.5) deleted the kink instead — the law is now √2·(cos θ, sin θ) and is smooth across the whole travel, so one span is endpoint-exact everywhere and the split is gone. The chord term above is unchanged by that. Regression suite: engine/tests/macro_ramp.rs, which bounds each effect relative to the voice reference on the same rig.

A macro's modulation graph is delivered per sample (2026-08-05)#

The (start, end) pair is the right reconstruction for a knob: the macro smoother is a one-pole whose within-block trajectory is linear by construction. It is the wrong one for a modulation graph, which is a curve. Until 2026-08-05 eval_macro_mods rendered each graph into a full block-length buffer and then kept only buf[0] and buf[frames-1], so a macro "wob" 0.5 (lfoSine 30Hz …) reached the audio on a 375 Hz linearly-interpolated grid — 2.2 % shape error at 30 Hz, essentially destroyed above the 187 Hz block Nyquist, and, because the grid is the host's block, a different shape on export than on playback (audit-3 E7 / E4a's remaining term).

That buffer is now kept (effect::MacroCurves, carried to consumers in effect::MacroTable alongside the pairs) and read per sample by CompiledNode::MacroRef and by every ramped effect parameter (effect:: RampCurve, filled through the parameter's own clamp/scale in apply_macros). A modulated macro and the same LFO written straight into the patch are now the same modulator: measured max |Δ| between the two, on a 0.16-peak render, 4.3e-5 (−71.3 dB) at 4 Hz, 2.1e-3 (−37.3 dB) at 30 Hz, 2.9e-2 (−14.6 dB) at 120 Hz → exactly zero at all three.

Consequently a modulated macro is block-size invariant: the same module renders bit-identically at 128, 96, 64, 37 and 32 frames per callback, on voice parameters and on the ramped effect parameters alike. Before, against the 128-frame render:

consumer96643732
voice gain2.08e-32.14e-32.14e-32.14e-3
plate wet3.09e-33.09e-33.03e-33.09e-3
width + panTrack7.13e-37.48e-37.38e-37.52e-3
crush mix6.4e-56.5e-56.6e-56.6e-5
saturate drive8.2e-51.34e-41.63e-41.67e-4

All zero after, except saturate at ≤ 4e-7 — and that residual is not the modulation: its RMS compensation is a table read plus a linear correction that pins each block's two endpoints to the exact integral, so a different block length re-anchors the correction. panTrack under a modulation graph now evaluates the pan law per sample rather than chording between two positions, which removes the dp² term described above for that case too (a sin_cos per sample, paid only when a graph actually drives it).

Two properties are pinned so the fix cannot cost anything it shouldn't:

  • a constant modulation graph (macro "x" 0.5 (const 0.2)) renders bit-identically to a bare knob parked at the sum, because the curve is built as knob_ramp(i) + graph[i] and a settled knob makes the first term a constant (under the default "sum" blend; the sum is clamped). No golden can move for a parked modulation;
  • cost: the graph was always rendered in full, so keeping it is a copy, not a computation. Measured through AudioEngine::process on a scene with one modulated macro driving a voice gain, a plate wet and a width, the whole render moved 19.0 → 19.3 µs/block (48 kHz, 128-frame blocks, release build) — and the difference between the modulated scene and the same scene with a static knob went 1.9 → 1.8 µs, i.e. the per-sample delivery is free within measurement noise.

The pair is still written for the block-rate consumers (EQ coefficients, plugin params, the strip's pan/width; see the enumeration below) — now with a seam-continuous start: it used to pair this block's first modulator sample with its last, dropping one sample of the modulator at every block boundary; it now carries the previous block's last sample, matching the knob's own convention.

Regression: engine/tests/macro_ramp.rs §4 — a_modulation_graph_reaches_the_audio_per_sample, a_modulation_graph_render_is_block_size_invariant, a_constant_modulation_graph_is_bit_identical_to_the_bare_knob.

Lifecycle: a curve belongs to one module and one running transport#

The curve is per-block state, and two ordinary live-coding actions used to leave it armed with data that described nothing (found by the adversarial review, fixed 2026-08-05):

  • a hot SWAP. The row table was rebuilt only when the macro COUNT changed, and reset_macro_mods did not clear it — so swapping between two modules with the same count but different modulated sets left it stale. A macro that lost its graph kept a row nothing rewrites and replayed the outgoing module's last curve forever (0.97 dB of permanent block-rate wobble on what is now a static knob, against 0.09 dB for the same module loaded fresh); a macro that gained one got no row, so E7 silently reverted to the endpoint pair and took the block-size dependence back with it (2.55e-3 at a 96-frame period where a fresh load measures 0). The table is now cleared on every load/swap AND its staleness test reads the module's actual modulated set, not just the count.
  • a stopped transport. stop_all nulls the clock but leaves the module installed, so process skips eval_macro_mods while the effect chains keep running — and the last evaluated block's curve replayed on every block of the tail: a frozen 128-sample loop, i.e. a 375 Hz buzz, against the repo's rule that post-stop output is silent and artifact-free (5.30 dB of block-phase swing on a stopped plate tail, against 0.29 dB for the same scene with a static knob; the two now agree to three places). The curves are disarmed at the start of every block (process_slice, right after smooth_macros) and re-armed only by eval_macro_mods actually running, which closes the same window between a load and the first start_track.

Regression: engine/tests/macro_ramp.rs §5 — a_macro_that_loses_its_graph_on_a_swap_stops_modulating, a_macro_that_gains_a_graph_on_a_swap_is_still_block_size_invariant, a_stopped_transport_replays_no_modulation.

What is still on a block grid — the whole list#

The rule is the campaign's own: if the knob ends up multiplying or quantising a full-scale signal, a modulation graph reaches it per sample; if it re-derives a coefficient, a curve, or an analysis frame, it keeps its existing grid. The first list was measured and closed; the second is measured and deliberate. Numbers are max |Δ| between a 128-frame render and a 96-frame one of the same module under a 30 Hz modulation graph, with the render's own peak alongside.

Per sample — and the whole effect is then block-size invariant (max |Δ| exactly 0 at 96/64/37/32 unless noted): voice parameters (MacroRef, e.g. a gain), panTrack, width, the wet/dry crossfades of plate / reverb / spring / convolve / crush / saturate / neural, saturate's drive (1.2e-7 — the comp table's per-block endpoint anchor, four orders below the modulation), bbd and ensembleChorus's blend, decorrelate, sympathetic (the wet/level gains of the later inserts — shimmer, rotary, galactic, crunchcoat, dubly, kplate, srsly, tripleSpread, fatEq out, clipOnly ceiling, modalBody — are also read per sample, but are not measured for block-size invariance), crush's amount, and the mixer strip's level and send gains (the last three promoted by the review — see below).

The four delay feedback controls also read every sample.

Still block-rate, by design. Measured on a 261.6 Hz tone at a 96-frame device period against the 128-frame render; the CONST column is the same insert with a literal instead of a macro, which is what proves the step is the macro path and not the effect:

consumermax abs Δ (peak)CONSTwhy it keeps the grid
formantShift semis1.78e-2 (0.192)0re-tunes the STFT formant warp
ott amount1.40e-2 (0.396)0re-derives three bands' thresholds/ratios
compress amount1.34e-2 (0.223)0re-derives the gain-computer curve
place azimuth1.28e-2 (0.181)0re-selects an HRTF pair
voice Filter cutoff1.23e-2 (0.149)—re-derives filter coefficients
fet amount8.94e-3 (0.099)0gain-computer curve
mbcomp amount7.90e-3 (0.180)0″
optical amount6.36e-3 (0.229)0″
mixer strip pan2.05e-3 (0.120)0endpoint mix matrix, lerped per sample (mixing.md §2.3)
mixer strip width4.47e-8 (0.178)0″ — near-linear in the knob, so the lerp is nearly exact
deess / haas amount0 (0.178)0block-rate by construction; no measurable step on this fixture
EQ bandsown 32-sample grid—A3/A6

The voice filter is the clearest demonstration that these are the consumer's grid and not the macro path's: the same scene with the LFO written inline in the patch instead of behind a macro measures the identical figure.

Two effects are not block-size invariant for a reason that has nothing to do with macros at all, and this table would mislead without saying so: their pitch shifters aren't. Rendered with a literal mix and no macro anywhere, harmonize [3, 7] 0.5 measures 1.18e-1 at 128 vs 96 — 48 % of its own peak — and tune 0.5 measures 5.3e-4. harmonize's wet gain IS delivered per sample (that part works); the shifter underneath it re-frames on a grid tied to the block. Pre-existing, out of this batch's scope, and filed as a known-remaining in the audit note.

Three were promoted out of that list by the review (2026-08-05), because they met the rule and were merely missed. crush's amount was the worst offender in the entire family — 8.75e-2 on a 0.176 peak, 50 % of peak — because its integer level count jumped a whole bit-depth step at a block boundary; it now indexes a 16-entry table per sample. The mixer strip's level and the send gains (2.51e-3) now move their one-pole's target per sample. In all three, a plain KNOB keeps its block-rate path exactly: crush would only dither its quantiser step, and the strip one-pole was designed around a block-rate target.

Regression: engine/tests/macro_ramp.rs::crush_and_the_strip_binds_follow_a_modulation_graph_per_sample.

Range: the curve rails where the pair rails#

An effect parameter's macro bind is affine (saturate (wob * 2)), so it can leave the parameter's own range. Every curve is loaded through the same map the endpoint pair goes through — apply_macros passes the parameter's clamp into RampCurve::load — so the two paths hit the same rail. saturate's drive and sympathetic's amount were loaded with the identity map when the curves first landed and drove to 1.8 where the pair stopped at 1.0; fixed 2026-08-05 (effect::tests::an_out_of_range_bind_is_clamped_on_the_curve_as_well_as_the_pair).

5. Per-preset macro snapshot (@knobs, C6)#

A macro's initial value is a single module-wide default, so a preset library where 30 patches share one bright/space/air can't ship "this pad sits at bright 0.4, that one at bright 0.1". The @knobs suffix lets a patch carry the knob positions it was voiced at (synthesis-palette §7, option A — a snapshot map, not patch-scoped macros):

warmAnalog = patch {
  source = saw (freq pitch),
  cutoff = const 600Hz + bright * 3000Hz,
  ampEnv = adsr 800ms 0ms 1 1.4s
} @knobs { bright: 0.30, space: 0.35 }

The parser reads the @knobs { name: value, … } suffix (only the literal @knobs; a bare @ after a patch stays the infix rate operator) into ExprKind::Patch { …, knobs }, threaded through the checker to the lowerer, which writes Synth.knob_snapshot (a BTreeMap<macroName, f64>, skip_serializing_if = "BTreeMap::is_empty" — a patch without @knobs carries an empty, omitted map, so no golden churns).

The snapshot survives into the IR and into runtime/src/types.ts (knobSnapshot). The engine does not apply it on load: a patch's @knobs values are how it was voiced, applied when you ask. In the Rack's case view (ui/src/rackcase.rs, Patch Bay and Rack § See a patch as a case), save knobs writes the macros' current positions onto the selected patch as its @knobs suffix and recall knobs moves the macros to them.

The same suffix carries two workbench-owned forms (rack-case §7, round 3). A quoted key names a literal knob of the patch by its case identity ("lpf.cutoff": 1200, the value in the literal's written unit), and a name before the brace makes another snapshot — @knobs a { … } @knobs b { … } — that the case's morph slides between. A patch has at most one unnamed @knobs (a second is a parse error); both new forms parse and are dropped there, so only the unnamed snapshot's macro keys reach knobSnapshot and neither form changes the IR. The formatter keeps @knobs [name] { together.

The case reads named snapshots three ways (rack-case-next row C): a/b are the morph slider's ends; c/d add the corners of a four-corner morph pad; a snapshot named after a scene is recalled when that scene launches (macros moved, literals heard in place, nothing written) and put back when it leaves (literals to their source values, macros to where they were unless the incoming scene's snapshot places them). Any identifier names a snapshot, so none of this is new syntax.

Showcase: warmAnalog/ambientWash/chaos/granularPad/darkSinister in examples/songs/pads.volta each carry an @knobs snapshot.

Deferred: a per-preset knob-panel UI (showing a focused preset's saved positions), and option B — patch-scoped macros (a macro declared inside a patch is local), which is the "right" model but reworks macro identity (Macro Namespacing).

6. Implementation map#

PieceWhere
IRMacroDef.modulation: { root, nodes }; ScoreOp::SetMacroPattern { name, events, length_beats } (ir/src/ir.rs)
Effect paramsir::Param (ir/src/ir.rs); fold_param (lower/expr.rs) at the pingpong/delayTrack/compress/convolve slots (lower/track.rs)
Effect wiringengine/src/effect.rs (per-effect macro id + EffectState::apply_macros, fed macro_ramp from mix_buses in lib.rs); the curve/pair carriers are MacroCurves / MacroTable / RampCurve
Loweringpass 1c in compiler/src/lower.rs; lower_macro_mods + whitelist + cycle check in lower/expr.rs; pattern := in lower/score.rs
modulatestarts_modulate_decl / parse_modulate_decl (compiler/src/parse.rs); extract_modulate_stmt (lower/application.rs); collection, target resolution and the one-blend rule in compiler/src/lower.rs
WarningsMACRO_WRITER_CONFLICT_KIND / SCENE_MACRO_INERT_KIND (compiler/src/lower.rs)
Mod voicesengine/src/graph.rs (compile_macro_mods, topo order); engine/src/lib.rs (eval_macro_mods — per-slot render in topo order, macro_ramp = clamped effective)
Base writersengine/src/lib.rs (tick_timeline_lanes, then apply_macro_ramps, then smooth_macros, at the top of each block in process_slice)
Lanesengine/src/shreds.rs (ShredAction::SetMacroPattern) + engine/src/lib.rs (MacroLane, process_macro_lanes)
Written lanescompiler/src/lower/timeline.rs (lower_lane, the static conflict checks) + engine/src/timeline.rs (PreparedLane::value_at) + engine/src/lib.rs (tick_timeline_lanes)
UIui/src/perform.rs::macros_ui (base-tracking knob, live dot + breathing range, motion glow, MIDI-learn socket; mod_matrix_ui level bars); ui/src/moduleinfo.rs (mod_routes — the strip's mod matrix); ui/src/modgraph.rs (extract_mod_graph — the Rack's matrix view and dock rings, a pure function of the synth's IR JSON)

Offline renders (engine/src/offline.rs, desktop and browser alike) schedule the whole modulation horizon up front, so exports capture automation exactly.