Let attacks in one audio signal trigger another instrument, connecting a recorded or live rhythm to new sounds.
Composed and inline triggers#
playOn accepts patternTrig, mergeTrig, filterTrig, quantizeTrig and onCount graphs, including named shared triggers. These emit one-sample pulses: coincident hits coalesce, adjacent-sample hits remain distinct. Each event fires at its reported sample offset; see Bring a sound to life.
onsets audioExpr [sensitivity] analyses an ordinary audio graph, so a filtered tapLive or input can feed the detector directly. It combines stereo energy in fixed 128-sample windows, reports on the window's final sample, and keeps the existing 50 ms refractory law. The source endpoint's routing delay is additional. Track/string onsets retains its legacy block detector contract. Sensitivity must be finite and within [0, 1], for both node forms and the legacy playOn detector. Source compilation and module loading share this check.
What it is#
The event arrow, reversed. Patterns normally make audio; onsets lets audio make events: it watches a track's bus (or the live mic) with a causal onset detector and emits a live Trigger stream — one trigger per transient. Two consumers ship:
- as a modulator — a trigger stream composes with the trigger-shaped control vocabulary (
hold (onsets drums) mlatchesmon every hit, exactly likehold noteTrig mlatches per note); - as a track's rhythm —
playOn synth trig patfires the pattern's next event per trigger: the drums play the bassline.
It is the event-direction sibling of the Route audio between tracks family (tap/follower/vocode): the same previous-block bus read, one generalization further (signal → trigger stream).
Family note. The other reading of a source bus is pitch, not rhythm: Follow the pitch of live audio (pitchOf track or pitchOf "input") runs a per-block McLeod autocorrelation over the same previous-block bus and exposes the detected fundamental as a Sig Control Hz — the control-side sibling of these triggers (saw (pitchOf lead) shadows a melody the way playOn shadows a rhythm).
Surface#
drums = sound !"bd ~ ~ bd sn ~ bd ~"
hits = onsets drums -- a Trigger stream (default sensitivity 0.5)
hot = onsets drums 0.8 -- fires on softer hits
live = onsets "input" -- the live mic (marks the module non-deterministic)
-- each trigger fires the NEXT pattern event in order, cycling:
bassT = playOn bass hits !"e1 e1 g1 a1"
-- trigger streams are ordinary control inputs:
shimmer = macro 0.5
pad = patch {
source = saw (freq pitch),
ampEnv = adsr 200ms 400ms 0.8 600ms,
cutoff = const 400Hz + hold (onsets drums) shimmer * 3000Hz,
gain = vel * 0.4
}
onsets source [sensitivity]—sourceis the track's binding (onsets drums, or qualified through an import,onsets kit.drums), an aux-bus name as a string, or"input"; a track's name as a string (onsets "drums") is accepted legacy (Language reference §12).sensitivityis a compile-time constant in [0, 1] (default 0.5; 0 = only massive transients, 1 = hair trigger). Returns aTrigger(thenoteTrigshape): the output is 1.0 across the render block containing an onset, else 0.0, sohold's rising-edge latch composes.playOn synth trig pitchPat— a track constructor, sibling ofplay.trigmust be anonsets …stream (written inline or through a top-level binding). The pattern's events fire in order, cycling; their own onsets are ignored (the trigger supplies the rhythm) but each event's duration still sets the fired note's length, and velocity composes through the standard chain (velocity/ event marks /gainpattern /gainTrack). Rests are skipped — a trigger always sounds — and an all-rest pattern is a compile error. Everything else is a normal track:|>effect chains,mixerstrips,panTrack, scenes (quantized scene start/stop gates the firing; the detector keeps running so its adaptive state is warm).
Detection (as built)#
engine/src/voice/detect.rs::OnsetDetector — causal, block-rate (128 frames), fed by the source's previous-block post-fader RMS (track_rms, the follower's exact read; input.level for the mic). Per block:
- flux =
max(0, level − prev_level)(half-wave-rectified energy flux); - adaptive gate — flux must exceed a one-pole running mean of the flux (~0.5 s tc) by
ratio(7.5 → 1.5 as sensitivity 0 → 1) and an absolutefloor(0.0105 → 0.0005); - peak gate — the level must clear a decaying peak-hold of the recent level (~120 ms tc) by
margin(1.9 → 1.1). This is what rejects beating/wobble in overlapping sampled tails (kick + snare ringing together) without waiting out a long refractory; - refractory — 50 ms; one hit fires once.
Timing is block-quantized: detection reads the previous block's RMS and fires at the next block's start, so a trigger lands within ~2 blocks (≈ 5.3 ms at 48 kHz) of the audio transient — the Route audio between tracks latency doctrine, inaudible as rhythm and feedback-safe by construction.
How it's implemented#
- Compiler:
BuiltinKind::Onsets(onsets : Track → Trigger, optional sensitivity validated at lowering —compiler/src/lower/track.rs::onsets_args, shared by both surfaces) lowers toNodeKind::Onsets { source_track, sensitivity }.BuiltinKind::PlayOnlowers a normalTrackplusTrack.on_trigger: { source, sensitivity }(ir/src/ir.rs::OnsetTrigger). Trigger-typed top-level bindings (hits = onsets drums) ride the free-floating-modulator map soplayOnand patch bodies resolve them. - Diagnostics:
refine.rs::check_causaltreatsonsets(node) andplayOn(track) reads as causal edges likefollower/tap— ok-level discharge naming the 1-block latency. Shared execution validation rejects missing track/bus sources before installation.onsets "input"in either position setsModule.nondeterministic(theinputrule); an internal track source does not — detection on the engine's fixed 128-frame blocks is reproducible run-to-run at a given sample rate, though the trigger times are block-grid, not sample-exact, quantities. - Engine:
CompiledNode::Onsets(per-voice detector state, source resolved viaresolve_bus— tracks, aux buses,"input", missing → silence) emits the trigger stream in the voice graph.TrackPlan.on_trigger(OnsetTriggerPlan, resolved at load) routes a running track toschedule_on_trigger(engine/src/lib.rs) instead of the clock scheduler: one detector per running track (kept across hot-swaps), on fire the next non-rest event triggers through the sameevent_dynamics/track_pan_atcomposition as scheduled notes, onto the track's own bus.onsets "input"counts towardCompiledModule::uses_live_input, so the desktop host lazily opens the mic. - Browser: the same engine compiled to wasm (
engine-wasm/) hosts the detector in an AudioWorklet, soonsets drumsandonsets "input"both work. Live input arrives throughWasmEngine::push_input, which the worklet calls withinputs[0]beforeprocess— the browser peer of the desktop's cpal capture thread (see Live audio input).
The trigger algebra (count / toggle / onCount / choose)#
Runtime Control Flow Stage 2 — Ty::Trigger's consumers, all shaped like hold (per-sample rising-edge state over any trigger-shaped control signal: noteTrig, onsets, a gate output, an onCount result):
kickT = play kick !"c2 ~ c2 ~"
snareT = play tom !"~ c3 ~ c3"
-- a live bar-position-by-kicks: 0,1,2,3,0,…
beat = count (onsets kickT) 4
-- step a modulator through a table, one kick at a time:
cutoffMod = choose [0.0, 0.3, 0.7, 1.0] (count (onsets kickT) 4)
-- flip a state per snare:
flip = toggle (onsets snareT)
-- every 4th kick, as a trigger again (a clock divider):
accents = onCount 4 (onsets kickT)
count trig [n] : Sig Control— rising-edge counter. Withn, counts modulon; without, saturates at 2^24 (f32's exact-integer ceiling, documented rather than wrapped).nis a fixed dimensionless integer from 1 through 16,777,216, finite and exact in engine precision. The result is dimensionless. Gate inputs follow the same contract as toggle.toggle trig : Sig Control Normalized— 0/1 flip per edge. Inputs may be explicit triggers, booleans, or dimensionless control gates; audio and physical quantities require an explicit conversion first.onCount n trig : Trigger— passes every n-th rising edge; the output mirrors a legacy gate’s high phase; pulse inputs produce one-sample outputs, so it composes withhold/countdownstream. Its fixed dimensionless integer divisor must lie from 1 through 4,294,967,295. It accepts the same gate inputs as toggle.choose [v…] sel : Sig Control— stepwise table read,table[clamp(round( sel))]— the runtimenththat compile-timenth/%cannot be. The table contains fixed compatible numeric values, finite in engine precision. The control output retains their common unit, with representation conversion:choose [500ms, 1s] 1yields one second;choose [1st, 300Cents] 1yields three semitones. The dimensionless control selector is read each sample.
These contracts survive aliases, factories, imports and records. Both counter forms remain available through the same stored value: f = count accepts f trig and f trig n, including higher-order calls. f trig is a completed counter, so (f trig) n cannot add a modulus afterward. Table structure and divisor values prepare before graph emission; checking retains their obligations without evaluating producers. Required-argument functions also support sections. The source compiler and native/JSON preparation share counter, divisor and table value checks. Invalid values in any executable graph reject the replacement before installation; the running performance continues.
Semantics inherited and documented rather than hidden: onsets triggers are block-rate with a 50 ms refractory, so counters advance with ~2-block quantization; noteTrig is constant-1 for a voice's life, so a per-voice count noteTrig is degenerate (it reads 1 forever) — the useful counters run on onsets and on gate-derived triggers. Determinism: counter state is a pure function of the trigger history, which is seeded (patterns) or already non-determinism-flagged (mic) — no new flags. The refinement checker knows the ranges (count trig n ∈ [0, n−1], toggle/onCount ∈ [0, 1], choose spans its table).
Boundary latching, seeded randomness, hysteresis (the quick wins)#
Shipped 2026-08-20 as the first follow-on to Runtime Control Flow: five small builtins that round out the decision vocabulary without touching the scheduler.
-- commit a drifting modulator once per cycle (2 = every 2 cycles):
depth = latchCycle 2 ((lfoSine 0.3Hz) * 0.4 + 0.5)
-- one uniform draw per cycle / per note — replay-exact, seek-safe:
cutoffMod = choose [0.0, 0.4, 0.7, 1.0] (cycleRand "form" * 3)
pan = noteRand "spread" * 2 - 1
-- threshold decisions that don't chatter:
voxT = play vox !"c4 ~ e4 ~"
open = holdFor 250ms (schmitt 0.25 0.6 (follower voxT 5ms 120ms))
latchCycle cycles m : Sig Control u— cycle-boundary sample-and-hold; passesm's unit through (theholdrule).cyclesis a required, fixed positive integer; uselatchCycle 1 mfor each cycle, or bindeveryOther = latchCycle 2. The boundary grid derives from absolute transport time (thePatternModclock convention — block-size independent, seek lands on the same grid; the same live-tempo caveat applies). One cycle is one bar on Volta's grid, so this is the bar latch too.cycleRand "id" : Sig Control Normalized— one uniform [0, 1) draw per cycle:hash(module seed, id, cycle index), a stateless counter-based RNG (rng::stream_bipolar). No history means no drift: replay is bit-exact, and every reader of the sameidsees the same draw (a shared selector).noteRand "id" : Sig Control Normalized— one draw per note, constant for the voice's life, keyed on the deterministic voice coordinate (track, cycle, event) — the seeded-noise-stream convention.schmitt lo hi m : Sig Control Normalized— hysteresis comparator: on whenmrises throughhi, off when it falls throughlo, held in between. Wheregatere-decides per sample at one ADAA-smoothed threshold,schmittlatches — a hovering signal cannot flap the decision. Thresholds lift to the live-scalar path (macro-affine expressions ride live).holdFor dur g : Sig Control Normalized— release debounce: 1 whileg ≥ 0.5and fordurafter it last was (the countdown refills on every high sample).duracceptsms/s/cycle units.
Verification: engine/tests/control_quickwins.rs renders through the real engine and pins the contracts — latchCycle flat within a cycle and re-committing at boundaries, cycleRand bit-identical across re-renders and host block sizes, schmitt/holdFor producing clean 0/1 spans with the hold extending the open phase.
Notes & limits#
- Broadband detection. Energy flux hears the loud transients; a quiet hi-hat under a sustained pad on the same bus can be masked (the peak gate). Put the trigger material on its own track (the demo keeps hats off the watched bus), or wait for the spectral-flux stage (Turn audio into musical events §7.1).
- Sensitivity is compile-time. A
macro-ridden sensitivity is a cheap follow-on (Turn audio into musical events §8). - The cursor is purely sequential. Trigger count drift shifts the pitch cycle's phase against the bar — musically intended; a bar-locked variant is an open question (Turn audio into musical events §8).
- Stopped source → silence, no triggers. A stopped source track's bus reads 0, so the bass rests. A missing source is an invalid execution plan.
Verification#
engine/src/offline.rs renders the full engine offline and asserts: play_on_bass_fires_on_kick_onsets_and_cycles_pitches (a synthetic kick panned left drives a playOn sine bass panned right — every bass note starts within 4 blocks of its kick and the pitches cycle c3→e3→g3→c3, measured by zero-crossings); onsetjam_demo_song_bass_follows_the_sampled_drums (the shipped demo against the real bd/sn WAVs — per-hit sounding windows, first trigger ≤ 6 blocks after the first kick, inter-hit decay, first note ≈ e1). Detector unit tests live in engine/src/voice/detect.rs.
Example#
examples/songs/onsetjam.volta ("Onset Jam") — a sampled kick/snare groove drives a sub bass via playOn, with hold (onsets drums) shimmer stepping the bass filter to a new color per hit. It plays whole on both targets.