Use a recording's source tempo to keep loop playback aligned with the project's tempo.
What it is#
warp is a sample modifier that beat-matches a loop to the project tempo. It reads the loop's recorded source BPM from the sample manifest, divides it into the current project tempo, and sets the playback rate so one source cycle plays in one project cycle. You write warp instead of pasting a hand-computed speed constant, and the loop re-tempos automatically when the project tempo changes. Pitch drops with the rate (it is cheap re-pitch playback, not time-stretching).
Surface#
slowBreak = sample "amen" |> warp -- fit one cycle of "amen" to one project cycle
midspeed = sample "amen" |> warp |> speed (const 0.5) -- half-time over the warped rate
detuned = sample "amen" |> warp |> pitched ~G3 -- warp the rate, then retune by note
warp is signal-first. The target BPM is the project tempo; the source BPM is, in order: the tempo you state (warp { bpm = 138 }), the tempo a recording was made at (stamped on looper record captures and tracking takes), or the bank's manifest bpm:
mine = sample "myloop" |> warp { bpm = 138 } -- an untagged loop: say its tempo
take = sample "rec.t1" |> warp -- a take: its recorded tempo
The positional spelling warp 138 is accepted too on a sample chain; the named form is canonical, because a second positional argument on a track is the per-event lane (|> warp !"1 0"). A loop declares its source tempo in runtime/public/samples/index.json via an optional bpm field:
{
"amen": { "file": "breaks/amen.wav", "bpm": 138 },
"piano": { "file": "piano-c3.wav", "rootPitch": "C3" } // no bpm — one-shot
}
How it's implemented#
- Compiler:
BuiltinKind::Warp(compiler/src/builtins.rs), whose schema is registered aswarpincompiler/src/builtins/samplers.rs— a signal-first modifier with no arguments, alongsideloop/reverse. The two-argument track form|> warp pis the per-event lane below.NodeKind::Samplecarrieswarp: bool(defaultfalse) andwarp_bpm: Option<f64>(the{ bpm = N }option, 1–999, a foldable constant; omitted from the IR when absent) (ir/src/ir.rs);TrackEvent.warp_patterncarries the per-event lane. The option is NAMED for reading selection (Descriptor::positional_schema), so an unproven receiver with a second positional argument still selects the lane.- The
Warpmodifier folds by settingwarp = trueon the wrappedSample, mirroring theloop_arm (compiler/src/lower/expr.rs; the track-effect peer is incompiler/src/lower/track.rs). Golden testwarp_records_flag_on_sample_node(compiler/src/lower/tests.rs).
- Engine (one implementation — desktop, browser and plugin alike):
CompiledNode::Samplecarries the samewarp: bool; the rate issample-rate conversion × repitch × warp × the per-event speed lane(engine/src/voice/eval_sampler.rs).- A stated
warp_bpmis read first at every rate site (warp_bpm.or_else(|| samples.bpm(name))), then the bank table below. - The engine holds no manifest, so source tempos are handed to it:
SampleBank::set_bpm/bpm(engine/src/sample.rs, with aname:variant → namefallback), fed byEngineHandle::set_sample_bpms(browser:runtime/public/studio-host.js→WasmEngine::set_sample_bpm; desktop:app-native/src/samples.rs) and byoffline::render_export'sbpmsargument, so an offline export warps like live playback. A key with no entry plays at native rate; the warning for that is a compile-time one (below), as the engine has no warning channel. - Recordings carry their tempo. A completed looper capture or tracking take (
command::CompletedTake.bpm, the transport tempo when it closed) is stamped as its bankbpmbyEngineHandle::poll_takes(and on the audio thread at the looper's swap block), so it reaches both hosts. The desktop writes the tempo into the take WAV's provenance stamp (samples::stamp_with_bpm) and reads it back on reopen (samples::take_stamp_bpm, intoLoadedSamples.bpms); the browser carries it in a saved idea'sSoundResources.bpms(engine-wasm/src/lib.rs::history_loop_updates). - Behavioural tests:
engine/tests/warp_import.rs(the option beats the bank tempo; looper and tracking takes report and install their tempo). - Project tempo is snapshotted per note into
voice::EventCtl.tempo_bpm. - Behavioural tests:
engine/tests/pattern_controls.rs(warp_lane_beat_matches_from_the_manifest_tempo).
- Manifest side (browser page host):
runtime/src/samples.tsparsesSampleEntry.bpmandSampleRegistry.bpm(name)returns it (stripping any:Nvariant suffix);runtime/public/studio-host.jsforwards each positivebpmto the engine as its bank loads. The desktop twin (app-native/src/samples.rs) reads the same manifest fields from every bank it loads — the stockruntime/public/samples/index.json(walk-up from the song, or the app bundle's copy),~/Documents/Volta/samples, and the song's own<song dir>/samples— and hands the tempos toEngineHandle::set_sample_bpms. - Per-event
|> warp p— a Boolean lane that ORs into the static flag, so one track can mix native-rate one-shots with beat-matched loops. See Control patterns for individual events.
What happens to an untagged loop#
A warp whose bank declares no tempo — no { bpm = N }, no manifest bpm, not a recording — plays at its native rate, and the compiler says so: a host that holds a sample manifest (both workbenches, volta --samples=…, the MCP server) reports an UntaggedWarp warning in the problems panel naming the bank and the fix (compiler/src/doctor.rs::untagged_samples, run by sample_diagnostics / diagnose). It covers sample … |> warp nodes and the sound events a |> warp lane actually turns on (sampled stepwise like the engine). Module-made buffers (bounces, bakes, comps) and looper record targets are exempt; a host with no manifest (a bare volta compile) reports nothing, and the IR's golden diagnostics are unchanged.
Notes & limits#
- Pitch drops with the rate.
warpcouples pitch and time throughplaybackRate; warping down a tone shifts pitch down a tone. The pitch-preserving variant iswarpLock— see Time-stretching and pitch-shifting. - No mid-note retune. Tempo is read at trigger time and held for the voice's life; only new triggers pick up a tempo change.
- One source cycle = one project cycle. No multi-cycle (
cycles: N) fitting; compose withspeed (const r)for half/double-time. - Untagged loops play at native rate (with the
UntaggedWarpwarning above);warp { bpm = N }is the fix in source. - No BPM detection at play time. The manifest
bpmfield is authoritative. The one detector in the path runs at import: dropping an audio file onto the desktop Studio copies it into<song dir>/samples/and writes abpminto that folder'sindex.jsononly when onset analysis finds a stable grid (import_filesinapp-native/src/samples.rs); see Make music from samples for the browser's import.