Slow audio toward a stop or bring it up to speed as a transition or expressive gesture.
What it is#
Three named playback-rate gestures that ride the existing Sample.speed modulator slot — no new audio path, just sugar over an exponential ramp on the sampler's playback rate:
brake— a one-shot exponential decay from unity to ~0.02 (the vinyl "wind down to a stop"), fired at note onset.spinUp— the dual: a one-shot rise from 0 to unity ("spin up into" an event).wow— a continuous sinusoidal drift around unity (analog tape wow & flutter).
Plus a track-level gesture, brakeTrack, that winds a whole track (bus gain + every active voice's playback rate) down to zero at marked pattern moments.
Surface#
brake / spinUp / wow are signal sources that compose into a sample's speed port. They are Sig Control Ratio, so they multiply into the playback rate.
braked = patch { source = sample "amen" |> speed (brake 600ms), ampEnv = adsr 1ms 600ms 0.8 100ms }
spun = patch { source = sample "drop" |> speed (spinUp 200ms), ampEnv = adsr 1ms 2s 1 200ms }
worn = patch { source = sample "tape" |> speed (wow 6Hz 0.04), ampEnv = adsr 1ms 2s 1 200ms }
Builtin signatures:
brake : Const Seconds -> Sig Control Ratio
spinUp : Const Seconds -> Sig Control Ratio
wow : Const Hz -> Const Normalized -> Sig Control Ratio
brakeTrack : Track -> Const Seconds -> Pattern -> Track -- pipe-last
brakeTrack is a track combinator (pipe-last, like swing / nudge); the boolean pattern marks the cycle-fraction moments the brake fires:
kit = sound !"bd sn bd sn" |> brakeTrack 800ms !"~ ~ ~ 1"
IR#
Both per-voice envelopes lower to control-rate Ratio nodes referenced by the Sample.speed slot:
{ "op": "RateEnvelope", "shape": "Brake" | "SpinUp", "duration": <seconds> }
{ "op": "Wow", "rate_hz": <hz>, "depth": <0..1> }
Note: the wow rate field is
rate_hz, notrate— a bareratewould collide with theNode.ratesignal-rate tag in the flattened node JSON.
brakeTrack sets a field on the Track:
"brake": { "duration": <seconds>, "onsets": [<cycle-fraction>, ...] }
Runtime#
One engine renders these everywhere — desktop, browser (as volta-engine-wasm in an AudioWorklet) and the plugin target.
CompiledNode::RateEnvelope { start, target, tc }(engine/src/graph.rs) →eval_rate_envelope(engine/src/voice/eval_nodes.rs):target + (start − target)·exp(−t/tc)over note-local time. The graph build prepares the coefficients (ir/src/envelope_values.rs): Brake 1→0.02, SpinUp 0→1.0,tc = duration/4— ~98 % byduration(4 τ ≈ "audibly stopped").CompiledNode::Wow { rate_hz, depth }→eval_wow,1 + depth·sin(2π·rate·t)on the same clock: unity at onset, within ±depth.- Both feed the sampler's playback-rate modulator read per sample (
engine/src/voice/eval_sampler.rs), so a time-varying brake ramps the read pointer rather than stepping it per block. Unit pins:engine/src/voice/eval.rs'smod tests. brakeTrackis not wired up yet. It lowers toTrack.brake = TrackBrake { duration, onsets }(ir/src/ir.rs,compiler/src/lower/track.rs), but no engine code reads that field, so the gesture is inaudible today. Per-voicebrakeis the working spelling.
Refinement advisories#
Range warnings (not errors): duration ⊂ [10ms, 4s], wow.depth ⊂ [0, 0.5], wow.rate ⊂ [0.1Hz, 20Hz].
v0 limitations#
- No pitch-locked brake —
brakecouples pitch and time (it ridesplaybackRate). A pitch-preserving brake needs Time-stretching and pitch-shifting. - No looping brake — once
brakereaches zero the voice is silent; the loop must be re-triggered. - No
crash— the drum-stick-against-platter sound is a sample, not an envelope; layer a crash sample under|> brake 200ms. - The per-voice
speedslot folds to one modulator; composingbrakewithwowon the same sample needs signal-level arithmetic (deferred).