VOLTAreference

Reference / Movement and control

Make one sound follow another

Movement and control

Open in the workbench →
On this page · 9 sections

Use the changing level of an audio signal to control a parameter, creating musical movement between parts.

What it is#

follower is Volta's cross-track sidechain primitive. It reads a track after its strip and before pipe effects, or an aux bus after its effects, and computes an RMS envelope of that track's level, and exposes the result as a control signal you can multiply into a voice parameter — typically to duck one track whenever another is loud (classic kick-ducks-bass sidechaining). The detected level depends only on the source track and the transport position, never on the voice reading it: one boxcar per source track feeds every follower that names it, and each reading node runs its own (attack, release) one-pole over that shared series.

Detection runs on the audio thread, inside the engine's own render loop — in the browser that engine is itself an AudioWorklet. So the ducking envelope keeps running when the page is occluded, tracks its declared attack/release in real milliseconds at any display refresh rate, and catches transients shorter than a video frame. See Follower Worklet for the full account of why the original main-thread requestAnimationFrame detector was wrong for live use.

Surface#

follower track attack release — RMS envelope follower of the named track's master bus, returning a control-rate Normalized signal. attack and release accept live duration controls in seconds or milliseconds. The source is the track's binding (follower kickTrack …, qualified through an import follower drums.kick …), or — legacy — its name as a string, or a prepared string from a binding or function. Reader aliases and partial calls keep the same argument requirements. Fixed attack/release values must fit finite engine precision; their legacy 1 ms floor remains. Live timings retain their 0–60 second bounds.

-- duck the bass by up to 60% whenever the kick track is loud
bass = patch {
  source = saw (freq pitch),
  gain = vel * (const 1 - follower kickTrack 8ms 100ms * 0.6),
  -- …
}

(examples/songs/acidclinic.volta uses exactly this shape: gain = vel * 0.5 * (1 - follower kickTrack 5ms 130ms * 0.9).)

duck — the same sidechain, on the mix surface#

follower works in patch fields and persistent effect/macro controls. Its lifetime follows that context: per voice in a patch, once per destination in an insert, or explicitly shared with @shared (Bring a sound to life). duck remains the convenient track-effect adapter (production-gaps §31 Z1), legal on track pipes, bus chains and master inserts:

p = play pad chords |> duck kickTrack 0.6             -- classic pump, no patch surgery
bus hall = reverb 0.8 0.4 |> duck kickTrack 0.7 5ms (1/4c)  -- §21's DUCKED RETURN
mixer { master = { inserts = [duck kickTrack 0.4] } }       -- whole-mix pump

track |> duck source depth [attack] [release] — source is the track's binding (bare or qualified, in a pipe and in a mixer insert list alike), or a string for what has no binding: "input" for the live hub, or a bus name. A track's name as a string (duck "kick" 0.6) is accepted legacy. depth ∈ [0, 1] is macro-ridable and sets the maximum cut (gain = 1 − depth·min(1, env·1.5) — the follower's exact law); attack/release default 5 ms / 250 ms and accept live duration controls, so a cycle-relative release ((1/4c)) recovers just before the next beat at any tempo — the guide's release rule as a unit, not a number. The insert-list spelling reads the same durations since Named options and parameter values Stage 2 — duck "kick" 0.6 5ms 250ms; a bare number there is seconds, as in the pipe (before Stage 2 it was milliseconds). Live duck attack clamps to 0.5–200 ms and release to 5 ms–4 s; the detector retains its 1 ms minimum time constant. A moving depth (a turned knob, a control or LFO) is ramped per sample across the whole block and the duck hears every sample of its sidechain; a settled depth takes the single block pass (effect/live.rs, evolve volta_duck). Both passes evaluate the envelope a block at a time (effect/duck.rs, evolve volta_duck #115: speculative release groups validated against the levels, exact 4-step attack/release jumps where they fail) rather than one sample at a time; the gain matches the per-sample law to within 6e-5 (measured), not bit for bit, and differs in rounding between aarch64 (fused multiply-adds) and other targets.

Same detector as the follower — the absolute-time fixed window below, one window behind — so it is feedback-safe on any routing (a track can even duck under itself), renders identically at every device period, and arms the same detector rings (uses_follower). A source name that does not resolve is rejected before preparation. The bench dynamics meter reads its live gain reduction like the compressor family. engine/tests/bus_sends.rs pins the return-duck, the track form, the master insert, silent-source unity, and rejection of an unknown source.

How it's implemented#

  • Compiler: the follower descriptor (compiler/src/builtins/controls.rs) takes a source (a track binding or a prepared string) and two live Seconds controls bounded 0–60 s, returns a control-rate Normalized signal, and lowers to a Follower IR node carrying source_track, scalar timing fallbacks and optional attack_node/release_node dependencies.
  • Graph build: engine/src/graph.rs resolves source_track to a SourceBus — a track slot, an appended aux-bus slot, or the reserved "input" live hub — and emits CompiledNode::Follower { source, attack, release }. A name that matches nothing is refused by IR validation (ir/src/validation.rs) before preparation; SourceBus::None (silence) is only the graph builder's fallback for an unvalidated module.
  • Detection: engine/src/lib.rs::detector_push advances the fixed DET_WIN-sample boxcar over each track's post-strip bus and publishes its mean square into a ring keyed by absolute sample index. The rings exist only for a module whose graph contains a follower or duck (CompiledModule::uses_follower).
  • Per-voice read: eval_follower (engine/src/voice/detect.rs) reads TrackLevels::detect(slot, t) once per sample, floors each tau at 1 ms, runs the one-pole at k = exp(-1 / (sampleRate · tau)), and writes min(1, env · 1.5). duck runs the identical law in DuckFx (engine/src/effect.rs) on a track/bus/master insert instead of in a voice.
  • One engine renders all of this on the desktop, in the browser AudioWorklet and in the plugin. The TypeScript FollowerRegistry + follower-processor.js were deleted with the Web Audio engine (2026-07-24); see git history.

Notes & limits#

  • Source-track bus, post-strip only. The follower taps the track's bus after its own strip. Pre-fader and per-contributor following are out of scope.
  • Attack/release update per sample, without resetting the envelope or changing the detector window/calibration. Live times clamp to 0–60 seconds, with the existing 1 ms minimum time constant. Invalid controls use the declared fallback. Constant durations retain the historical DSP path.
  • Source validation. Voice graphs, persistent controls and duck inserts reject missing track/bus sources before preparation. A rejected replacement does not stop the already installed performance. A declared, stopped source still reads silence and leaves ducking at unity.
  • Not a compressor. This is an RMS envelope follower feeding a gain term — no look-ahead, no true sidechain detector. That is a separate, larger design; see Follower Worklet §6.

The detector window is 128 samples, not the host's block (2026-08-05)#

The native engine's follower used to read track_rms — one scalar per track, the RMS of the previous block. Its window length was therefore whatever the host handed the audio callback: the offline renderer always feeds 128 frames and the live host sub-blocks the device period, so the same song ducked differently on export and on playback (audit-3 E4b). Measured on a kick-ducks-pad scene against the 128-frame render, max |Δ| on a 0.34 peak: 5.8e-3 at a 96-frame period, 1.4e-2 at 64, 1.9e-2 at 37, 2.0e-2 at 32.

The detector is now a fixed 128-sample boxcar ([voice::DET_WIN]), advanced one sample at a time over the post-strip bus in lib.rs::detector_push, its mean square published into a ring keyed by absolute sample index and read DET_WIN samples behind the sample being rendered (voice::TrackLevels::detect). Same window width, same ~1-block latency — but the level series is a pure function of transport time, so no device period can move it. All four periods above are now bit-identical to the 128-frame render (engine/tests/bus_sends.rs::follower_duck_is_block_size_invariant).

Two consequences worth knowing:

  • the level is a sliding window now, read per sample, instead of one scalar held flat across a block. The detector is that much smoother, and its average group delay is ~64 samples (1.3 ms) longer than the old held read — well inside any usable attack time, and the reason this is not a bit-identical change for a song that does duck;
  • the rings are only maintained for a module that actually contains a follower (CompiledModule::uses_follower), so the ~1.5 ns/sample/track they cost is charged to nothing else. Measured on the duck scene, the whole render moved 14.0 → 14.5 µs/block (48 kHz, release build); an 8-track scene with no follower is unchanged at 32.8 µs.

Onsets (onsets track, the playOn trigger) still reads the per-block meter RMS: it is a block-rate energy-*flux* trigger by construction and says so in voice/mod.rs.

Causality#

A follower reads the source track's post-fader bus DET_WIN samples behind the sample being rendered, so its output lags the source by exactly one render block (128 samples ≈ 2.7 ms at 48 kHz). This is the Spec §5.3 Causal obligation — "the duck fires after the kick has rendered" — and the previous-block read discharges it by construction: the source's last block is already settled, so no track ordering is needed and a cross-track cycle (A → B → A) is feedback-safe (the block latency breaks the loop). The compiler now says so: refine.rs's render-order pass (Refinements §6.3) emits an ok-level Causal diagnostic per follower / tap edge naming that 1-block latency and names cross-track feedback loops and their periods. Execution validation rejects missing audio sources. A zero-latency read (tapLive, Route audio between tracks Stage 2) would not discharge this for free — it would demand a real topological render order.

Follow any audio expression#

audio |> follow { attack = 5ms, release = 150ms } measures the audio at that point in a graph. The result is a control in linear amplitude, without the legacy follower's 1.5 boost or upper clamp. Reading a detector does not make its source audible. A local binding feeding both the detector and the audible path evaluates once per graph instance.

This filter follows its own source before the filter:

tempo = 110bpm
wah = patch {
  source = let audio = saw (freq pitch) in
    let level = audio |> follow { attack = 5ms, release = 120ms } in
    audio |> lpf (500Hz + level * 2500Hz) 2,
  ampEnv = adsr 5ms 150ms 0.6 180ms
}
play wah "c3 eb3 g3 bb3"
OptionDefaultBehavior
attack5msLive one-pole rise time, 63.2% of a level step after one time constant
release150msLive one-pole fall time; 36.8% remains after one time constant
mode"rms"Static "rms" boxcar measurement or "peak" absolute sample magnitude
link"linked"Linked channel energy for RMS, larger magnitude for peak; "independent" keeps two control channels
window5msStatic RMS duration from 0 to 1 second, rounded to samples with a minimum of one; unused by peak

Unknown mode/link names reject; their supported spellings are case-insensitive. The fixed window must be finite and within 0–1 seconds, including peak mode. Source compilation and module loading enforce the same reader contract.

Timing is clamped to 0–60 seconds; zero is immediate. Timing edits update coefficients while retaining the window and envelope state. RMS starts with zero history. The detector runs on the sample clock, independent of callback partitioning. Linked stereo combines energy from the two channels, so antiphase signals retain their level; a signal on one channel contributes half the stereo energy. Independent mode keeps channel envelopes separate inside audio arithmetic and shared references; scalar parameter and macro adapters read the left control channel.

A delayed tap can feed this primitive in a persistent control. Its audio keeps the legacy tap's source point, level scaling and callback delay, so this is not a sample-exact substitution for named follower: before one spelling becomes a compatibility alias for the other, prove it with calibrated render-equivalence tests across callback partitions. Local audio adds no routing delay. Persistent graphs reject implicit note fields; tapLive in a persistent control awaits the complete routing stage. Changing a static option rebuilds that graph; compatible edits retain its state.

Duck with an existing control#

Use duckBy control depth when you already have a level or rhythmic control. It applies 1 − depth × control, bounding both inputs to 0–1. It creates no detector and adds no routing delay. For example, bus hall = reverb 0.8 1 |> duckBy (lfoSine 2Hz |> scale [0, 1]) 0.6 continuously ducks the reverb already ringing in that return.

The same operation works inside an audio function: audio |> duckBy level 0.6. With independent stereo controls, the audio form applies each channel's gain; track, return and master scalar controls read the left envelope. To follow an insert's own input, place follow in an audioFx function (see Bring a sound to life). The existing named-source duck keeps its original detector and calibration.