VOLTAreference

Reference / Samples and recording

Keep a loop in time

Samples and recording

Open in the workbench →
On this page · 5 sections

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 as warp in compiler/src/builtins/samplers.rs — a signal-first modifier with no arguments, alongside loop / reverse. The two-argument track form |> warp p is the per-event lane below.
    • NodeKind::Sample carries warp: bool (default false) and warp_bpm: Option<f64> (the { bpm = N } option, 1–999, a foldable constant; omitted from the IR when absent) (ir/src/ir.rs); TrackEvent.warp_pattern carries 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 Warp modifier folds by setting warp = true on the wrapped Sample, mirroring the loop_ arm (compiler/src/lower/expr.rs; the track-effect peer is in compiler/src/lower/track.rs). Golden test warp_records_flag_on_sample_node (compiler/src/lower/tests.rs).
  • Engine (one implementation — desktop, browser and plugin alike):
    • CompiledNode::Sample carries the same warp: bool; the rate is sample-rate conversion × repitch × warp × the per-event speed lane (engine/src/voice/eval_sampler.rs).
    • A stated warp_bpm is 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 a name:variant → name fallback), fed by EngineHandle::set_sample_bpms (browser: runtime/public/studio-host.js → WasmEngine::set_sample_bpm; desktop: app-native/src/samples.rs) and by offline::render_export's bpms argument, 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 bank bpm by EngineHandle::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, into LoadedSamples.bpms); the browser carries it in a saved idea's SoundResources.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.ts parses SampleEntry.bpm and SampleRegistry.bpm(name) returns it (stripping any :N variant suffix); runtime/public/studio-host.js forwards each positive bpm to 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 stock runtime/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 to EngineHandle::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. warp couples pitch and time through playbackRate; warping down a tone shifts pitch down a tone. The pitch-preserving variant is warpLock — 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 with speed (const r) for half/double-time.
  • Untagged loops play at native rate (with the UntaggedWarp warning above); warp { bpm = N } is the fix in source.
  • No BPM detection at play time. The manifest bpm field 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 a bpm into that folder's index.json only when onset analysis finds a stable grid (import_files in app-native/src/samples.rs); see Make music from samples for the browser's import.