VOLTAreference

Reference / Samples and recording

Multisampled instruments

Samples and recording

Open in the workbench →
On this page · 6 sections

Use recordings from different keyboard regions and dynamics so an instrument keeps its character across the keyboard and across velocities.

What it is#

A multisample is one named instrument backed by several recordings, each a zone covering part of the keyboard and part of the velocity range. At note-on the engine picks the zone (or a crossfaded pair of zones) for the note's key and velocity, repitches it from its recorded root, and plays it. It is purely a manifest + engine feature: the compiler sees one sample name and emits one unchanged Sample node.

Surface#

There is no language syntax. A multisample is a sample-manifest entry (runtime/public/samples/index.json, or a workspace index.json) with a zones array; a .volta source names it like any other sample:

inst = patch { source = sample "epiano", ampEnv = adsr 2ms 0ms 1 600ms, gain = vel }
lead = play inst !"c2 e3=0.4 g4 b5=0.9"

A zone:

{
  "file": "piano/C4_v80_rr2.wav",
  "rootPitch": "C4",              // required
  "loKey": "A3", "hiKey": "D#4",  // optional; absent → the Voronoi split
  "loVel": 64,   "hiVel": 103,    // optional, 1..127; absent → every velocity
  "rr": 2,                        // optional round-robin slot, 1-based
  "xfadeKeys": 2,                 // optional: semitones of key crossfade at each edge
  "xfadeVel": 8,                  // optional: velocity units of crossfade at each edge
  "trigger": "release",           // optional: "attack" (default) or "release"
  "gain": "-3dB",                 // optional (number = linear, string = dB)
  "tune": 12,                     // optional cents
  "offset": 1200, "end": 95999,   // optional playable window, frames (end = last frame played)
  "loopStart": 48000, "loopEnd": 96000, "loopMode": "sustain",  // played by `loop` (Notes)
  "ampAttack": 0.002, "ampDecay": 0, "ampSustain": 1, "ampRelease": 0.3   // release zones only
}

and on the entry, "rrMode": "random" draws round-robin slots instead of cycling them. Notes (rootPitch, loKey, hiKey) are names ("C4", "F#2", "Db3", "cs4") or MIDI numbers. One side of a key range alone defaults the other to 0 / 127, of a velocity range to 1 / 127. A zone of only file + rootPitch means exactly what it always has.

Selection#

Per note-on, a pure function of key, velocity, the round-robin ordinal and the coordinate RNG (Deterministic rendering):

  1. Key. A zone with loKey/hiKey covers that inclusive range. A zone without owns its root's Voronoi cell among the other unranged roots: every key nearer its root than any neighbour's, ties to the lower root. A key no zone covers is silent for that bank (an SFZ-style gap); an all-Voronoi table covers every key.
  2. Velocity. The note's velocity (0..1) is read on the 1..127 scale against [loVel, hiVel]. That is the engine's note velocity — the default 0.85 times the event's own (c4=0.5 is 0.425, i.e. 54), times any gain lane — so an unmarked note lands at 108.
  3. Round-robin. Zones sharing a cell (same key range or root, same velocity range, same trigger) rotate when any of them has rr, ordered by rr then manifest order. The ordinal counts note-ons per track and per bank as the scheduler fires them, and resets at the transport origin (a fresh Play, a seek or loop back to beat 0), so a replay and an export walk the same slots. Live notes — held keyboard / MIDI notes and audition one-shots — count on their own per-bank counter (AudioEngine::live_rr), advanced once per live note-on and reset at the same origin, so the same note-on sequence walks the same slots, and a take recorded from the origin replays as a track whose own counter walks them again. rrMode: "random" takes the slot from the RNG keyed on the note's (track, cycle, event); a live note has no cycle or event, so its live ordinal stands in for the event. A cell of several zones with no rr keeps only its first zone.
  4. Crossfades (SFZ's reading). With explicit ranges, neighbours overlap by the fade and the fade fills the overlap: xfadeKeys / xfadeVel is a width INSIDE each edge of the zone's range, over which its weight rises from 0 at the edge to 1 (an edge at the end of the scale — key 0/127, velocity 1/127 — does not fade). So zones 40..62 and 58..71 with xfadeKeys: 4 share keys 58..62, and B3 (59) is ¾ the first, ¼ the second. Unranged (Voronoi) cells cannot overlap, so their band is centred on the split. A note in a fade sounds both zones — one voice with two read heads, each with its own playhead, root and tune — at sqrt(weight), so complementary fades are an equal-power pair. At most two zones sound (the two heaviest).
  5. Release samples. trigger: "release" zones never sound at note-on. At note-off the arena spawns one one-shot voice — same patch, key, note-on velocity, round-robin ordinal, track and pan — that plays the matching release zone with its gate held for that zone's length. Like an SFZ release region (which plays through its own ampeg_*, not the held note's), the one-shot replaces the patch's ampEnv with its own envelope: full level after a 1 ms attack, held for the recording, so the recording's own decay is what sounds, then a 5 ms release (zones::RELEASE_ENV). A release zone's ampAttack, ampDecay, ampRelease (seconds) and ampSustain (0..1) replace those values one by one; attack zones ignore them. Only a plain adsr ampEnv is replaced (a gated or expression envelope still shapes the one-shot), the rest of the patch — gain (so velocity), filters, a filter envelope — applies as to any note. It takes only an idle slot (a release never steals a sounding note). Glide voices and persistent graphs spawn none. The one-shot keeps the note's cut group, so a later onset in the group chokes it; a note choked before its note-off spawns no release.
  6. Gain and tune scale and repitch each chosen zone; offset/end bound the frames it plays (trim, slices and the per-event begin/end lanes map inside that window, for both crossfade heads).

A table whose zones carry no optional field is legacy: nearest root, ties low, re-resolved every block (a glide across a split still swaps buffers there, as before). Any other table latches its pick at note-on.

How it's implemented#

  • Compiler: no change (an ordinary NodeKind::Sample, root_midi = None).
  • Engine — engine/src/zones.rs: ZoneMeta::from_json (the one parser of every optional field), manifest_zones, ZoneTable::new (selection structures precomputed control-side) and the allocation-free ZoneTable::pick. The scheduler's RunningTrack::rr counts note-ons per bank into EventCtl::rr; Voice::sample_heads (voice/eval_sampler.rs) gives the player its heads; VoiceArena::spawn_releases (voice/arena.rs) fires release one-shots, handing each its zone's ZoneMeta::release_env for the patch's ampEnv node (SynthTemplate::amp_env, read in voice/eval_nodes.rs::eval_adsr); live notes take their ordinal from AudioEngine::live_rr in preview_trigger and the OneShot command; Voice::granular_heads gives a static granular cloud its two heads.
  • Desktop — app-native/src/samples.rs calls manifest_zones (a zone without a parseable rootPitch, or whose file fails to decode, is skipped) and registers the FIRST listed zone under the flat keys (epiano, epiano:0) for un-pitched reads (a sound hit, convolve, waveforms).
  • Browser — studio-host.js::loadZoneTable stages each zone with its root and the zone object's JSON text (stage_sample_zone(name, root, bytes, meta)), then commit_sample_zones(name, rrMode); engine-wasm parses the text with the same ZoneMeta::from_json, so both shells install one table from one manifest. A bank:N drum request still takes files[N % count].
  • Exports carry the same table (render_export_zoned and its arranged, passage and span twins; the desktop dialog snapshots the live table). Freeze/bounce keys and history captures (SoundResources::zone_meta) add zone metadata only for non-legacy tables, so existing keys are unchanged.
  • Assets: 74 stock banks are zoned, all legacy tables.

Verification#

engine/tests/multisample_depth.rs nulls zoned renders against single-buffer references: explicit ranges over Voronoi and a silent gap, velocity layers, gain + tune, key and velocity crossfades as sqrt(¾), sqrt(¼) and sqrt(½) pairs, an offset/end window (near-null past the onset declick), and a release zone at note-off at note-on velocity. Round- robin reads 1, 2, 3, 1… across a cycle seam, identical in two exports and two live passes; seven held live notes read 1, 2, 3, 1, 2, 3, 1 twice over and audition one-shots rotate the same way (engine/tests/sampling_gaps.rs); a release one-shot nulls (≤ 2.4e-8) against the release recording through adsr 1ms 0ms 1 5ms under a patch whose own ampEnv is adsr 300ms 100ms 0.1 5ms, and against adsr 50ms 20ms 0.5 10ms with those zone fields; rrMode: "random" repeats across renders and live. A chromatic run through a crossfade has no step above its window's p99 (worst 1.02×) and a largest note-to-note level ratio of 2.05× where the hard split jumps 4.01×. rt_safety covers it all with zero allocations; legacy tables are pinned by export_zones.rs and golden audio; the shared fixture engine/tests/fixtures/zone-manifest.json is parsed by both hosts' tests. Measured offline, not listened to.

Notes & limits#

  • A zone's own root wins over pitched ~Cn: pitched on a zoned name matters only where the bank has no table.
  • Round-robin counts note-ons: scheduled notes per track, live notes (keyboard, MIDI, auditions) on the engine's live counter. Glide voices and shreds read slot 1; a seek to anywhere but beat 0 keeps counting. The live counter is per engine, so notes from two MIDI devices interleave on it.
  • Granular readers sound the crossfaded pair too: a static cloud's grains read both zones of the note's pick at the same position FRACTION of each zone's reader, each at its own root, rate, tune and equal-power gain inside its offset/end window; the pick latches at note-on (and on a re-articulated glide renote). A grain-motion cloud (Granular synthesis § Travel between recordings) keeps its heaviest zone per source. Measured: a B3 cloud across a xfadeKeys: 4 overlap carries the two zones at a 1.70 amplitude ratio (√3 = 1.73 expected; incoherent band power over 40 ms frames), max step 1.34× p99.
  • Loop points (loopStart/loopEnd, loopEnd exclusive) become the zone buffer's loop region (replacing a WAV smpl loop), read by |> loop. loopMode turns the loop on without one: continuous loops through the release, sustain leaves it at note-off for the tail, oneShot or none plays through; an explicit loop/loop { … } in the song wins. Each crossfade head loops its own zone.
  • Zone order matters only to un-pitched reads: the flat key is the FIRST zone listed.
  • volta export stems and guide-production read a flat WAV directory, not the manifest, so a zoned name is silent there unless a same-named WAV exists; engine/examples/render-manifest.rs is the CLI for a zoned print (it calls manifest_zones too).
  • Capture a layered set with volta autosample — Capture an instrument as samples.
  • Converting an SFZ instrument into a zoned bank is volta import sfz (or dropping the .sfz on the desktop Studio) — Import an SFZ instrument. It writes the full Stage 5 zone schema (velocity layers, rr, release zones, crossfades, loop points); everything but the loop points plays through the Stage 5 path above.