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):
- Key. A zone with
loKey/hiKeycovers 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. - 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.5is 0.425, i.e. 54), times anygainlane — so an unmarked note lands at 108. - Round-robin. Zones sharing a cell (same key range or root, same velocity range, same trigger) rotate when any of them has
rr, ordered byrrthen 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 norrkeeps only its first zone. - Crossfades (SFZ's reading). With explicit ranges, neighbours overlap by the fade and the fade fills the overlap:
xfadeKeys/xfadeVelis 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 withxfadeKeys: 4share 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 — atsqrt(weight), so complementary fades are an equal-power pair. At most two zones sound (the two heaviest). - 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 ownampeg_*, not the held note's), the one-shot replaces the patch'sampEnvwith 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'sampAttack,ampDecay,ampRelease(seconds) andampSustain(0..1) replace those values one by one; attack zones ignore them. Only a plainadsrampEnvis 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'scutgroup, so a later onset in the group chokes it; a note choked before its note-off spawns no release. - Gain and tune scale and repitch each chosen zone;
offset/endbound the frames it plays (trim, slices and the per-eventbegin/endlanes 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-freeZoneTable::pick. The scheduler'sRunningTrack::rrcounts note-ons per bank intoEventCtl::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'sZoneMeta::release_envfor the patch'sampEnvnode (SynthTemplate::amp_env, read invoice/eval_nodes.rs::eval_adsr); live notes take their ordinal fromAudioEngine::live_rrinpreview_triggerand theOneShotcommand;Voice::granular_headsgives a static granular cloud its two heads. - Desktop —
app-native/src/samples.rscallsmanifest_zones(a zone without a parseablerootPitch, 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 (asoundhit,convolve, waveforms). - Browser —
studio-host.js::loadZoneTablestages each zone with its root and the zone object's JSON text (stage_sample_zone(name, root, bytes, meta)), thencommit_sample_zones(name, rrMode);engine-wasmparses the text with the sameZoneMeta::from_json, so both shells install one table from one manifest. Abank:Ndrum request still takesfiles[N % count]. - Exports carry the same table (
render_export_zonedand 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:pitchedon 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,
tuneand equal-power gain inside itsoffset/endwindow; 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 axfadeKeys: 4overlap 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,loopEndexclusive) become the zone buffer's loop region (replacing a WAVsmplloop), read by|> loop.loopModeturns the loop on without one:continuousloops through the release,sustainleaves it at note-off for the tail,oneShotor none plays through; an explicitloop/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 stemsandguide-productionread a flat WAV directory, not the manifest, so a zoned name is silent there unless a same-named WAV exists;engine/examples/render-manifest.rsis the CLI for a zoned print (it callsmanifest_zonestoo).- 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.sfzon 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.