Play recordings as hits, loops or instruments, with controls for regions, pitch, speed and sample selection.
Hear it and change it#
A recording of one note needs a root pitch: the note that sounds at its original playback speed. The supplied piano sample has root C3. Declaring it with pitched ~C3 lets incoming notes choose the playback ratio.
Listen for: a recorded piano following a new melody.
tempo = 120bpm
timingVersion = 2
samplekeys = patch {
source = sample "piano" |> pitched ~C3,
ampEnv = adsr 3ms 300ms 0.6 180ms,
gain = vel * 0.4
}
play samplekeys "c4 eb4 g4 bb4"
Make it yours: move the melody down an octave. With a single recording, high notes become shorter and brighter and low notes become longer and darker. A multisampled instrument uses several recordings across the keyboard to reduce that change.
What it is#
Sample playback in two distinct shapes. A sample can be a synth source — source = sample "piano", a decoded audio buffer that drops into a patch's source field exactly where noise does, inheriting the filter, amp envelope, gain, macros, and sidechain. Or a sample can be a pattern payload — sound !"bd ~ sn bd", where the mini-notation tokens are sample names and each event triggers an ad-hoc one-shot sampler voice (the Strudel-style drum machine). The compiler only ever records a sample's name; the runtime owns fetching, decoding, and caching. Unknown names are a runtime warning, not a compile error — they play silence and audio continues.
Surface#
Sample as a synth source, with the five fold-in modifiers (signal-first, so they pipe):
kick = patch {
source = sample "bd", -- one-shot at native rate
ampEnv = adsr 0ms 0ms 1 400ms, -- near-passthrough; sample rings
cutoff = const 18000Hz, resonance = const 0.7, gain = vel * 1.0
}
keys = patch { source = sample "piano" |> pitched ~C3, -- repitch from C3 root
ampEnv = adsr 2ms 350ms 0.4 300ms, gain = vel * 0.5 }
swell = patch { source = sample "sn" |> reverse |> speed (const 0.7),
ampEnv = adsr 40ms 600ms 0.6 400ms, gain = vel * 0.5 }
slow = sample "amen" |> trim 0.0 0.5 |> loop -- loop first half
| Modifier | Effect |
|---|---|
pitched ~Root | repitch by note, relative to a root pitch |
pitched | repitch by note, relative to the entry's own rootPitch (C4, with an UntaggedRoot warning, when it has none); a flat entry's tune (cents) folds into that root |
pitched ~Root { tune = c } | fine-tune c cents (−2400…2400) on top of the written root: the read plays that sharp, on the plain, chop and granular readers alike |
warp / warp { bpm = N } | fit the loop to the project tempo (see Keep a loop in time) |
speed r | playback-rate multiplier (constant, LFO, envelope, or macro) |
loop | loop the buffer instead of one-shot; loop { start, end, xfade, mode, release } shapes a sustain loop (below) |
trim start end | play a sub-region [start, end] (buffer fractions) |
reverse | play the buffer backwards (on a track, |> reverse 1 reverses every hit — a sound track's way to reverse; Control patterns for individual events) |
Every modifier takes its arguments by name as well as by position — sample "x" |> trim { start = 0.2, end = 0.8 } — and reports one arity message naming the accepted form (`trim` takes at most 2 arguments after the signal: `start end`). The receiver is the first positional slot, so trim (sample "x") 0.2 0.8 and the pipe form are the same call.
Sample as a pattern payload — the sound track constructor, plus the :N variant index and chop breakbeat slicing:
kit = sound !"bd ~ sn bd, hh*8" -- tokens are sample names
roll = sound !"bd:0 bd:3 sn" -- :N picks the Nth file in a multi-file bank
chops = sound (chop 8 !"amen") -- split each event into 8 successive slices
A bare bd event reads the bank's first file, bd:0, while a synth graph's sample "bd" reads bd. The engine's bank writes a plain key under both spellings on every install (sample::bank_key_twin: bd ↔ bd:0), so a sample installed either way — by the disk loader, a recorded keep, a generated WAV or an imported file — plays for both readers. A non-zero variant (bd:3) is its own key.
Per event, the playable window can be narrowed and voices can choke one another — the begin / end / cut lanes of Control patterns for individual events § "Sample windows and choke groups":
chops = sound !"vox*8" |> begin !"0 0.125 0.25 0.375" |> end 0.6
halves = sound (chop 4 !"amen") |> begin 0.5 -- second half of each quarter
hats = sound !"hh*7 oh" |> cut 1 -- the next hat chokes the open hat
begin/end are fractions of the playable window — the trim window, or the chop/slice slice when the event has one — so they compose inside it. end <= begin plays nothing. A start that cuts into the waveform takes the note-on ramp of § "The window edges" exactly like a trim start.
sound takes a pattern whose tokens are sample names and returns a Track. Given a List Region of placed audio takes instead, it is the audio-track constructor (see Write a song as a timeline). All mini-notation operators (*, ,, <>, (k,n) euclid, nesting) are payload-agnostic and work on sample tokens unchanged; rev / every and other pattern combinators apply too.
Sample assets#
Sample names must resolve to available audio. A sample manifest can map a name to one file or a list of variants; rootPitch supplies its pitch reference and bpm supplies its original tempo for warping. A flat entry's rootPitch (the one drop-import detects) is what a bare sample "x" |> pitched repitches from: both hosts install it on the buffer itself (SampleData::with_root_midi — app-native/src/samples.rs::parse_manifest_entry on the desktop, studio-host.js::loadOneSample → set_sample_root in the browser), so it travels to offline exports, freezes and bounces with the PCM. pitched ~C3 never reads it, bit for bit, and neither does an unpitched sample. Zoned entries keep their per-zone roots (Multisampled instruments). See Make music from samples and Multisampled instruments for bringing in your own sounds.
Channels#
The bank carries stereo where the source has it (Stereo Samples). SampleData holds the mono downmix it always did, plus the original L/R pair for a 2-channel file; a mono file is byte-for-byte unchanged, and a ≥3-channel file keeps the all-channel downmix and is treated as mono.
What each verb does with a stereo entry:
| verb | channels |
|---|---|
plain playback, trim / loop / reverse / speed / warp | carried — one playhead, two reads |
chop / chopAt | carried — one grain schedule, two reads |
granular / grains | carried — each grain reads its own L/R and its pan rides on top |
stretch / pitchShift / warpLock | carried — both channels vocode against ONE phase field (mid), preserving the inter-channel phase difference |
slice onset analysis | analysed on the downmix (detection is a mono problem); playback carries the pair |
convolve with a stereo IR | true stereo space: L⊛IR_L, R⊛IR_R, one shared normalization |
resynth (additive) | collapses — a harmonic model is mono by construction |
| neural encode/decode | collapses — the RAVE models are mono |
A voice picks its stereo layout once per note from the bank (SynthTemplate::stereo_wide), so mono material costs exactly what it did before stereo existed.
The loop seam#
|> loop wraps the playhead over the trim window [start, end) and cross-fades the wrap, on the native engine, by this law (engine/src/voice/eval_sampler.rs, CompiledNode::Sample; the model is Mutable's looping_sample_player):
- The outgoing read runs up to 128 samples (or
span/8, whichever is smaller) past the loop point, into whatever the buffer holds beyond the window, and is faded into the incoming read onespanback — which is always inside[start, end). The loop period therefore stays exactlyspan: the fade does not shorten or lengthen the loop. - Where the overshoot has no material — a window anchored at the buffer's end going forward, or at its start going backwards — the fade moves to the other side of the loop point instead. Same partner, same period.
- The fade is equal gain (
a + b = 1), not equal power. The two reads are the same material one period apart, i.e. correlated, so ana² + b² = 1law would add +3 dB at every seam (measured: +2.90 dB, a level pulse on the loop clock). A window that IS the whole buffer has no material on either side of the seam, so its wrap is raw — and stays raw.
sample "x" |> loopwith notrimsteps by|x[n−1] − x[0]|once per loop (measured 23.4× the material's own p99 |Δx| on a file that does not loop cleanly), and no declick covers it either: a ramp there would notch every wrap instead of just the bad ones. Author the file to loop (matching, near-zero endpoints), ortriminside it so the crossfade has somewhere to read from. What atrim … |> loopdoes get is the note-on declick at its start edge — that is an onset, not a seam. The same applies tostretch … |> loop. Since 2026-08-05 a looped stretch is rendered as a loop (audit D13): the phase vocoder's overlap-add runs on a circular frame grid, so the buffer is exactly the musical length (n · ratio), its level is flat across the wrap, and each partial is snapped to the loop's own harmonic grid — a detune of at most half a loop-harmonic, 0.5 Hz on a one-second loop — so nothing cancels at the seam. It used to be a linear render whose under-overlapped ends the normalisation floor tapered to zero, which dipped a looped stretch 0.99 → 0.00 → 0.99 over 53–124 ms at every wrap — a breath, not a click (the step measured 1.2× the buffer's own p99 |Δx|), and one no player-side cross-fade could remove, because the ramps were the overlap-add sum.What the wrap carries now is exactly what the source's own loop point carries: a clean-looping file wraps continuously at full level (measured 1.2× p99, and the quietest 5 ms window anywhere in playback is 0.985× the median), and a file that does not loop cleanly keeps its own step, the same as the rule above. The fade to silence used to hide that — it was never absent.
Two consequences worth knowing:
loopnow changes the rendered PCM, so it is part of the stretch cache key: a looped and a non-loopedstretchof the same sample are two buffers, and a looped render costs one extra analysis pass (≈1.5× mono, 1.2× stereo). Non-looped renders are byte-for-byte what they were.- A looped stretch opens at full level, wherever the vocoder's phase landed, so the voice gives its note-on the same ≤128-frame position ramp the sample player gives a cut window edge — once, at the onset, never at a seam.
- Closing the seam costs pitch, and there is a cap on how much. A partial only meets itself across the wrap if it fits a whole number of cycles into the stretched loop, so the render nudges each partial onto the loop's own harmonic grid — spacing
1/L_outHz, so the price is up to1200·log2(1 + 1/(2·L_out·f))cents on a partial atfHz.L_outis the stretched loop (source · ratio), which is the length the partial actually has to fit:stretch 0.5on a one-second source loops in half a second, so its grid is 2 Hz apart and the price is up to 1 Hz, not 0.5. That is 0.4 c at 5 kHz in a one-second loop and a semitone on a 54 Hz partial in a 0.125 s one (measured 96 c).stretchpromises time without pitch, so the nudge is capped at 5 cents (MAX_CLOSURE_CENTS): above that the partial keeps its frequency and takes a notch at the wrap instead. In practice everything above roughly173/L_outHz closes — the whole spectrum of a 2 s loop from 87 Hz up — and a bass partial in a very short loop is the case that keeps its tuning and its seam.
Sustain loops: loop { … } and loop points#
loop takes five named options (or the same five positionally); at their defaults it is exactly bare loop, IR and render alike.
pad = sample "strings" |> loop { start = 0.42, end = 0.91, xfade = 40ms }
tone = sample "flute" |> loop { xfade = 25ms, release = "tail" }
ping = sample "vox" |> loop { mode = "pingpong" }
| option | default | meaning |
|---|---|---|
start, end | the sample's own loop points, else 0, 1 | the loop region, fractions of the playable (trim) window — in playback order, so under reverse they mirror like trim does |
xfade | 0ms | crossfade across the seam, seconds of source material, clamped to half the loop |
curve | "auto" | the xfade law: "auto" fits the gains to the measured correlation of the two crossfaded stretches; "power" equal-power sin/cos; "gain" linear equal-gain |
mode | "forward" | "forward" wraps end → start; "pingpong" turns at both edges |
release | "loop" | "loop" keeps looping through the amp release; "tail" leaves the loop at note-off and plays on to the window end |
The read starts at the window's start (an attack before the region plays once), then loops the region. How the seam is crossed:
xfadegiven — itscurve. A fixed law is right for one kind of material only: equal POWER keeps the level of two unrelated stretches but swells two similar ones (a sustained tone whose loop holds whole periods: +3 dB mid-seam), equal GAIN the reverse (noise: −3 dB mid-seam)."auto"— the default — measures the normalised correlationρof the two stretches once per note (≤512 evenly spaced frames of the left channel, clamped to 0..1, a pure function of the buffer, so every render agrees) and uses the linear shape scaled soa² + b² + 2ρab = 1: equal gain atρ = 1, equal power-like atρ = 0, level-true between. The fade reads, in order of preference, past the loop end (the overshoot law of the seam above: the period stays exactly the region length), else before the loop start (the fade ends at the loop end; same period), else inside the region — its lastxfadefades into its first, which shortens the period byxfade. The last is the only law a whole-buffer loop has.xfade = 0keeps bareloop's automatic seam fade —min(L/8, 128)frames, equal gain, outside material only, else raw.pingpongneeds no fade: a turn is position-continuous (the read reflects at the region's last frame and at its first), soxfadeis ignored.release = "tail"leaves the loop at the first sample after note-off that is outside a seam fade (never mid-blend), plays on through the rest of the window and ends with the usual window-end declick. You hear the difference only while the amp envelope still sounds after note-off: the tail plays for theampEnvrelease and is then faded like any note, so with a short release (under ~50 ms), or a loop that ends near the window end (little tail left to play),"tail"and"loop"sound alike. Give the patch a release at least as long as the material after the loop end (adsr … 600msfor a piano-like tail), and put the loop early enough that there is a tail.volta doctor(and the editor's problems) flags a tail loop under a plainadsrrelease shorter than 50 ms asInaudibleTail. A ping-pong read heading backwards at note-off turns once more at the loop start, then runs out forwards.
Seam level, measured (engine/tests/sampling_gaps.rs; a 1 s loop of a 2 s buffer, 100 ms xfade, mid-seam 20 ms RMS against the body): a 200 Hz tone (ρ = 1) reads auto −0.00 dB, power +2.97 dB, gain −0.00 dB; white noise reads auto +0.22 dB, power +0.23 dB, gain −2.73 dB. The automatic xfade = 0 fade stays equal gain (it is bare loop's, and the corpus renders it). Seam steps, measured (engine/tests/sample_sustain_loop.rs, max |Δx| against the render's own p99 |Δx|; offline, not listened to): bare loop over a 205.25 Hz buffer whose ends do not meet 37.3×; the same buffer with xfade = 40ms 1.22×; a region with material either side, xfade = 20ms 1.03× (and its period is exactly the region), the automatic fade 1.17×; a ping-pong turn 1.00×; a tail release 1.09×.
The buffer's own loop points. SampleData::loop_points holds a sustain loop [start, end) in frames, which start/end default to — so bare loop on a buffer that has one loops there (the attack plays once). Two sources, read the same way by both hosts:
- a WAV
smplchunk: its first loop, at decode (sample::wav_smpl_loop, called bySampleData::decode_wav_bytes/decode_wav_file— the one Rust decoder the desktop and the browser worklet share). The chunk's end is inclusive, so[dwStart, dwEnd + 1)is stored. Only loop types 0–2 count: Sound Forge's type-1024 "marker" loop, common in the stock Dirt banks, is ignored, as is a loop spanning the whole file (that is whatloopalready wraps); - a manifest
loopStart/loopEndon a flat entry (frames of each file,loopEndexclusive; either may be omitted — start → 0, end → the file's end). It replaces the chunk. The desktop applies it inapp-native/src/samples.rs(with_manifest_loop); the browser passes it asload_sample_wav(key, bytes, loopStart, loopEnd)fromstudio-host.js::manifestLoop. Per-zone points belong to the zone entry (Multisampled instruments).
A file's points outside the trim window clamp into it; a region under two frames falls back to the whole window. loop { … } options are refused on a chop/slice/granular read and on stretch (they loop with bare loop).
The chop slice edges#
chop plays one slice through a Hann overlap-add at 50 % overlap, and both ends of that slice are boundaries in exactly the sense above:
- The OLA is pre-rolled, so its window sum is 1 from the first output frame. Without that, a Hann pool needs two grains in flight before it sums to 1 and every slice faded itself in over one hop — measured 21.0 ms to reach unity, which swallows the transient of a chopped break (a 16th at 152 bpm is ~90 ms long). It is now 1.0 ms, and that 1 ms is the declick below and nothing else.
- Each slice edge carries a 1.33 ms (
min(slice_len/8, 64)frames) ramp — short deliberately: long enough to make a splice a first-order discontinuity (6 dB/octave rolloff instead of none), short enough that the slice still reads as a hit. The one head that is not ramped is a slice starting at the file's own first frame: like a one-shot's attack (below), it was never cut, so it keeps its transient — slice 0 of an untrimmed break is bit-identical to the plain player over its onset. chop n |> loopcross-fades its wrap, blending the incoming slice head with an outgoing read that carries on past the slice into the file's own material — the loop-seam law above, re-anchored just after the wrap because a chop wraps on a slice, not on a buffer. The seam window isdkframes either side of the wrap, and it is the only place a chop read leaves its slice; the body of the slice stays strictly confined.- The seam needs somewhere to read. Where the slice ends the user's window — the file's (or the
trim's) last slice, andchop 1 |> loop, whose slice IS the window — there is no continuation, and the wrap is raw instead, exactly as a whole-buffer|> loopis. Fading against a held edge sample there would put a 1.33 ms DC thump on every wrap and would disturb loops that are already seamless. Neither the seam read nor its stencil ever crosses thetrim.
Measured max |Δx| against the material's own p99 |Δx|: slice end 37.3× → 1.1×, chop 8 |> loop wrap 30.4× → 1.5× — and, since the seam law has to hold at any slice geometry rather than only an integer one, the same 1.5× (1.2× for chop 32) at every eighth of a fractional slice length. That last part is the shipped case: lagoon.volta and chopvox.volta both chop a 651 325-frame file into 8, so seven notes in eight read a slice 81 415.625 frames long.
The window edges: declick#
A window edge that no cross-fade covers is declicked instead — an equal-gain ramp of min(span/8, 128) frames (2.7 ms at 48 kHz). The two edges are decided separately, because they are not the same problem:
- the edge the playhead LEAVES through —
endgoing forward,startgoing backwards — ramps when the read does not loop (a loop cross-fades that edge instead). Ending a sample on a full-scale sample value is a zeroth-order step, the loudest artefact available, and it costs nothing to ramp frames that are about to become silence anyway. This covers both|> trim a b(which clicks atb) and a file that simply ends mid-waveform, which every hard-cut slice in a sampled bank does. - the edge it STARTS from ramps whenever
trim(or abeginlane, or a slice) cut into the file — looping or not, because a note-on is a note-on either way. It does not ramp at an uncut file start: a one-shot's own first frame is the attack the patch'sampEnvshapes, and ramping every drum transient in over 2.7 ms is a worse defect than the step it removes. The ramp fires once per note, not once per loop: a looped read crosses its start edge every period, and re-ramping there would punch a hole in the loop.
Measured max |Δx| at the boundary against the material's own p99 |Δx|, 205 Hz cut on a peak: 34.8× → 1.0× at a trim end and at a mid-waveform file end, 34.4× → 1.0× at the note-on of a trim … |> loop. An untrimmed, non-looping sample is otherwise bit-identical to what it was.
No read on this path can address outside the buffer: resample::hermite_read (under rate 1.25) folds the position into the buffer before the integer split, and SincRead::read (above it) clamps every tap index — either way an out-of-range finite position edge-holds instead of extrapolating. Both arms are NaN-proof at the primitive (the sinc read returns 0 for a non-finite position).
Two seam details, both deliberate: the fade guard is note-invariant — a note whose rate is a per-note constant (no speed modulator, no warp) sizes it from its own read stencil exactly (2 samples on the Hermite arm), so near-edge trims keep their crossfade; only a live rate lane forces the widest stencil (resample::MAX_SINC_HALF = 42), because the kernel can deepen per block and the window must never move under the playhead. And the switch from an f32 to an f64 end-of-window test means a non-looping sample now plays its final fractional frame (edge-held) where it previously dropped it — a one-frame, ≤−24 dB difference at buffer exhaustion, kept because playing the frame is the correct half of the rounding.
The playhead itself is f64 end to end (2026-08-04), including the value handed to the read and the seam's partner arithmetic. It used to reach the read as f32, and f32 position quantisation is proportional to the position — 0.015 of a frame at 96 k in, where a top-octave partial moves a full scale in half a frame — so the same material at the same rate rendered differently depending on where in the file it sat, by −38 dBc. Rates that are exact in binary (1, ½, 5/4, 2, 4…) are unaffected to the bit: an f32 held those positions exactly. |> reverse also opens on its first frame now; it used to be seeded on the play window's exclusive upper bound, so a reversed voice's first output frame was silence at every note-on.
Reading up: the anti-alias read#
Any read whose rate exceeds 1 pulls source content above Nyquist/rate back down into the audible band as inharmonic shimmer, and any read at a rate other than 1 leaves interpolation images behind. A 4-point Hermite suppresses neither well, so the native engine switches read kernel by rate:
| effective read rate | kernel | cost |
|---|---|---|
| any playable rate, unity included | a Kaiser-sinc read whose cutoff tracks the rate: 1.0 at or below unity (nothing can fold there, so the corner opens fully), ramping to 0.94/rate by rate 1.25 and 0.85/rate past 1.28, taken off one shared prototype table with JOS's ρL stride (resample::sinc_read_for) | 14 taps at unity cutoff, 20 around rate 1.25, 32 at +12 st, 84 at the rate-5.7 cap (82 at +30 st) |
| NaN (a broken modulator) | 4-point Hermite (resample::hermite_read), which is NaN-proof at the primitive | 4 taps |
The sub-unity half of that table is new (2026-08-06 audit X1). The read declined below rate 1.25 until then, on the argument that nothing can fold audibly there — true, but the images remain, and every bundled sample is 44.1 kHz, so at 48 kHz output the base rate is 0.919 and all unpitched and pitched-down playback took the Hermite arm. A source partial's image lands at 3.9 kHz + f there — squarely audible. Measured through the real Sample arm:
| source tone | image lands at | before | after |
|---|---|---|---|
| 8 kHz | 11.9 kHz | −32.7 dBc | −85.6 dBc |
| 12 kHz | 15.9 kHz | −21.1 dBc | −82.9 dBc |
| 16 kHz | 19.9 kHz | −12.3 dBc | −31.9 dBc |
| 18 kHz | 21.9 kHz | — | −18.7 dBc |
…and the top-octave droop that came with the 4-point read (−0.17 / −0.75 / −2.07 dB at 8 / 12 / 16 kHz) is gone with it (0.00 / +0.00 / −0.23 dB). A 22.05 kHz source (rate 0.459) went −32.6 → −85.5 dBc at 4 kHz. stretch's buffer read had no band-limited arm at any rate and now shares this one (audit X7), reading through the RING rather than edge-holding when the node loops — its buffer is circular by construction, and holding the wider stencil at the wrap was measurably (if inaudibly) wrong.
The last two rows were this kernel's ceiling — the plain player now reads them through the 2× copy (see Read quality below: −113 / −105 dBc, pinned in e_the_transition_band_floor_is_where_the_docs_say); chop, granular and file-loop reads still have this ceiling. A partial close to the source Nyquist images just above it — inside the Kaiser transition, since 14 taps at cutoff 1.0 only reach the full stopband at ≈1.36 of the source Nyquist — so those images get tens of dB, not eighty. They land above 16 kHz, which is exactly why the corner may sit at 1.0: the alternative is droop across the whole top octave to bury a spur nobody can hear. Widening the stencil (more Kaiser zeros) is the only way to improve them, and it is not worth the taps.
The prototype (Z = 6 zero-crossings per side, Kaiser β = 7.86 → ≈80 dB, 512 samples per zero-crossing) is built once on the control thread (VoiceArena::new) and read lock-free and allocation-free on the audio thread. The stride is q = round(c·512), an integer — which is what keeps the two table-lerp weights loop-invariant, so the inner loop stays the same 2-load lerp
- MAC a fixed polyphase table would run while the cutoff becomes continuous in the rate. All three sample readers go through it — plain playback,
chop, andgranular/grains— so the same buffer at the same rate aliases the same on every path. Measured alias level relative to the wanted signal, identical source and rate on all three:
| transposition | plain | granular | chop |
|---|---|---|---|
| +12 st (rate 2.000) | −46.1 dBc | −46.1 | −46.1 |
| +13 st (rate 2.119) | −68.9 dBc | −101.3 | −97.5 |
| +24 st (rate 4.000) | −67.9 dBc | −70.8 | −69.0 |
Four properties worth knowing:
- Unity is bit-identical, and it is NOT an exemption. A read at rate 1.0 from an integer start renders bit-for-bit what the 4-point read always gave (a render hash pins that in
engine/tests/sinc_continuity.rs) — because at cutoff 1.0 the kernel's off-centre taps land on sinc zeros, so an integer-position read is exactly the identity. X1 first shipped rate 1.0 as a literal exemption (sinc_read_forreturnedNone), and its D1 review removed that: the two readers agree only at INTEGER positions, while a static FRACTIONAL phase at unity — a fractionaltrimstart, achopAtslice start,grainPos, a fractional loop span — is ordinary authoring and never decays. The exemption measured as a permanent −3.13 dB notch at 16 kHz (phase 0.5), a 2.36 dB step across unity on an e2echopAtscene, and up to 3.90 dB between the same song played live at 48 kHz and exported at 44.1 kHz. Only a NaN rate reaches Hermite now. The law is likewise continuous through unity — aspeedlane crossing 1.0 steps ≤0.09 dB — because the margin ramps unity →0.94over rate 1 → 1.25 rather than jumping. Nothing can fold audibly below rate ≈1.274 (the first image of the top of the source band lands at ≥16.5 kHz), which is why the corner may stay that open there. - The cutoff follows the rate, so brightness is rate-invariant. The −3 dB corner sits at 0.78–0.80 of the output Nyquist at every transposition, and the audible-fold floor at −74…−82 dB. Until 2026-08-03 the cutoff was keyed to one of five fixed tiers, which put ~6 dB brightness cliffs at +3.86 / +5.83 / +12 / +17.83 / +24 st — on the bright side, so an octave-up note lost its top the moment a vibrato pushed it sharp — and left the read over-filtered by up to 1.43× at the bottom of a tier (the −3 dB corner swung 0.54…0.77 of the output Nyquist). Sweeping the rate now moves the corner ≤0.1 dB per block.
granularband-limits per grain;chopeffectively per note. A granular grain's rate is fixed at spawn, so its kernel is resolved once there.chopre-evaluates per block but its read rate is latched at note init, so in practice its kernel never changes mid-note. Both readers keep the wider stencil inside the window they read to within the slice-boundary rounding the Hermite read already had (floorof a fractional slice start can admit one adjacent sample — pre-existing, and smaller than the Hermite twin's leak); a granular grain wraps the ring exactly.- The plain player's own floor at a non-integer rate used to be its playhead, not its filter. Its read took the position as
f32, which at tens of thousands of frames is ~−50 dBc of position noise on a top-octave partial (the grain paths always carried f64). Fixed 2026-08-04 — the whole path is f64 now, and the plain player's floor is the filter's again.
The cost of tracking the rate is taps: a read at +12 st went from 22 to 32 of them and from ~22 to ~46 ns per frame per voice (Apple silicon, release, ~2.1×); +24 st ~35 → ~81 ns, +30 st ~41 → ~109 ns. Eight octave-up mono sample voices cost ~1.8 % of one core at 48 kHz against ~0.8 % before. Bringing the sub-unity rates onto the kernel (X1) costs 14 taps against 4: eight 44.1 kHz voices at 48 kHz measured 1.07 % → 1.53 % of one core, a granular cloud +16 %, a chop +9 % (Apple silicon, release). Unity joined them in X1's D1 review at 1.07 % → 1.62 % for eight unity-rate voices — accepted rather than special-cased, because a phase-gated fast path buys a fraction of a percent for a branch that has already been wrong once. Above the rate-5.7 cap the cutoff holds and the protection degrades — −68 dBc at rate 6, −56 at 8, effectively unfiltered by ~12; a > +30 st transpose is out of contract (finite and bounded, not clean).
Read quality (the band-limited read, and switching it off)#
Since evolve round 6 the plain player (sample …, |> pitched, trim, loop, speed — not chop, granular or loops with file/zone loop points, which keep the kernel above) reads through engine/src/play_read.rs:
- rates up to
2 − 40 kHz/fs_out(1.167 at 48 kHz, 1.093 at 44.1 kHz: every unpitched 44.1 kHz sample at 48 kHz, and all pitch-down) read a band-limited 2× copy of the sample with a 16-tap interpolator. Images of the top octave go from −16…−64 dB to ≤ −105 dB, and a partial at 0.95 of the source Nyquist reads −0.07 dB instead of −4; - faster rates read a precomputed kernel (one per 2 % of rate, per engine rate) whose transition lies between 20 kHz and the rate where folds land at 22 kHz: flat to 20 kHz (the table above droops −2…−5 dB there) with folds at ≤ −100 dB;
- buffer edges, NaN rates and samples without a copy use the kernel above, which also still sizes the loop-seam guard. A block at exactly unity reads integer positions from the source itself, so a 48 kHz file at 48 kHz is unchanged.
It is also faster in the engine (1.2–2× on sample-heavy tracks), but it costs memory: each sample keeps a 2× copy of its playback channels (+200 % of a mono file's PCM, +133 % of a stereo entry's mono + L + R), built on the control thread as the sample installs (≈2 ms per mono second), plus 1.4 MB of kernels per engine rate.
The setting — on by default on desktop and in the browser:
| layer | name | |
|---|---|---|
| engine | volta_engine::play_read::set_enabled(bool) / enabled() | process-wide |
| environment | VOLTA_SAMPLE_HQ=0 | starts the process with it off (CLI renders, tests, hosts) |
| desktop host | engine_host::audio::AudioConfig::sample_hq | applied before the engine builds |
| volta-studio | io panel → AUDIO OUT → hq samples; persisted as "sample_hq" in the audio-config sidecar (EngineBridge::set_sample_hq) | |
| browser / wasm | WasmEngine::set_sample_hq(bool) / sample_hq() |
Off plays every sample through the shipped kernel, bit-identical to the engine before round 6 (engine/tests/sample_hq_switch.rs pins main's hashes), and builds nothing: no copies, no kernel bank. Switching live takes effect for reads from the next block. Copies follow installs: turning it on builds the kernel bank at once but gives copies only to samples loaded from then on (older ones read through the shipped kernel below the image limit until they reload — reopen the song); turning it off frees a sample's copy only when that sample buffer is replaced (copies belong to the immutable buffer). To keep the memory unspent, switch it off before the samples load (the persisted setting and VOLTA_SAMPLE_HQ=0 both do).
Glide and legato#
A plain sample read follows the note pitch, so glide works on it like on an oscillator: source = sample "x" |> pitched ~C3, glide = 120ms ramps the READ RATE from the previous note's pitch (the same glideCurve ramp the oscillators read, per sample), and a zoned bank glides relative to its zone's root. A glide patch is mono (voices = 1 unless set), and the renote decides what the read does:
- legato — a slid note (
c3_ e3), or an overlapping one underglideMode = "legato"— keeps the playhead, the envelope and the multisample zone the note started on: the zone is latched when the playhead is seeded, so a C4 note sliding to C5 keeps reading the C4 recording, repitched an octave. A zone swap needs a new note. - re-articulated —
glideMode = "always", or a legato-mode note that does not overlap — restarts the read (playhead,sampleAtchoice, zone) at the note's onset; the jump settles under the ~2 ms steal declick.
A note-pitched granular cloud glides too: each grain latches the glided pitch at its own spawn sample, so a slide moves the cloud grain by grain; the cloud keeps running across the renote, a legato renote keeps the zones its note latched, and a re-articulated one re-takes the pick (retiring the grains in flight under the steal declick). Measured (engine/tests/sampling_gaps.rs): C4 → C5 under glide = 120ms reads 351 Hz 40–80 ms into the slide and 532 Hz by 300 ms (zero-crossing estimate over a grain cloud). A chop read still drops glide (and plays poly): its note-on picks a slice, a rhythmic choice that a legato renote would have to either keep (sounding the wrong slice) or retake (breaking the legato), and its grains time-stretch that one slice — so pitch-following there is not a coherent reading. A grain-motion cloud (grainSources, trajectories) drops glide for the same reason its trajectories carry their own pitch lanes. Measured (engine/tests/sample_glide.rs): C4 → C5 under glide = 120ms reads 283.7 Hz 60 ms in (282.8 Hz expected half-way) and 400.0 Hz by 300 ms; a legato renote holds the file level the playhead had reached (0.547 against 0.113 for a restart), and the zoned slide keeps the C4 zone's level (0.396) where a new note takes C5's (0.891).
Notes & limits#
- A patch with no
outfield is wiredsource → lowpass → ampEnv → gain, and the lowpass is added only whencutoffis set. A patch that declaresoutbuilds its own graph. For drum hits writeampEnv = adsr 0ms 0ms 1 <long>: the envelope holds full level while the note is held, and the long release lets the hit ring past the note's end. A one-shot goes silent once its buffer runs out. Asoundvoice has no amp envelope, so it rings up to two seconds past its event's end, or until its buffer runs out if that comes first. - The per-event
begin/endlanes narrow every sample reader: the plainSampleread, the granular reads, astretch-rendered source (as fractions of its rendered buffer — thetrimwindow in playback order, so the same fractions of the source; a looped one loops the sub-window with the automatic equal-gain seam fade, through the circular render), and thesample "x" |> chop nmodifier (as fractions of the chosen slice, at the whole slice's time-stretch).sound (chop n p)events compose the same way (Time-stretching and pitch-shifting,engine/tests/sampling_gaps.rs). soundis dry — no per-patch tone-shaping. For filter/envelope/macros use asample-source patch withplay. The two paths are deliberate (see The sampler §5.4).- Variant addressing ships the literal
:Nindex plus the dynamic:?(random) and:*(round-robin) sigils — see Choose sample variants. - Onset-detected
slice/sliceAtand true overlapping-graingranular/granularizegraduated to their own specs and ship there — see Slice a recording at its attacks (stages 1–3; UX overlay deferred) and Granular synthesis. Stage-3 per-event control patterns (gain/speed/panzipped onto asoundpattern) live in Control patterns for individual events.