A BufferVar is a slot for an externally provided block of sample
data — a loaded sound file, a wavetable, recorded audio. Unlike delays, buffer
contents are owned by the engine and can be swapped at runtime without recompiling the
synth. Buffers are multichannel; frames are indexed from 0, buffers may be any length
(no power-of-two requirement), and an out-of-range index wraps cyclically at the
buffer's actual frame count — a 100-frame buffer read at index 100 yields frame 0.
(Delay lines, which are ring buffers, keep their internal power-of-two masks.)
| Function | Description |
|---|---|
bufferVar() | Declare a buffer slot. Slots are numbered in declaration order: the first bufferVar() is bufID 0, the next is 1, … |
b read(index, chans = 1, startChan = 0) | Read chans channels starting at channel startChan, at fixed integer frame index. |
b vread(index, interp = Interpolation.cubic, chans = 1, startChan = 0) | Interpolated read at a signal-valued frame index — the basis of sample playback and wavetable oscillators. |
b write(value, index, chans = 0, startChan = 0) / value write(b, index, ...) | Write value at frame index (for recording / live-buffer effects). |
b length | The buffer's content length in frames, as an init-rate signal. |
After creating a node, bind data to its buffer slots with the audio_engine
bridge: loadBuffer(nodeID, bufID, path) loads an audio file (read on the
non-real-time side, swapped in sample-accurately),
fillBuffer(nodeID, bufID, numChans, data) pushes a computed [Float]
(channel-concatenated: data[c*frames + i], so a single-channel buffer is just
the sample array — e.g. a wavetable built with the ifft builtin or the
wavetables module), and
resizeBuffer(nodeID, bufID, numChannels, length) allocates an empty buffer for
recording. All three require an active begin() bundle. Until a buffer is
bound, reads return 0. The plugin exports its buffer slots
(name, element type, expected channels) so browsers and tools can inspect them.
-- one-shot sample player: trigger restarts the playhead
fn samplePlayer() S {
let buf = bufferVar();
let t = trigger("play");
let speed = control("speed", ControlSpec { lo: 0.25, hi: 4.0, init: 1.0, warp: ControlWarp.exponential });
let pos = delayVar();
let newPos = select2(t > 0, 0, min(pos(1) + speed, buf length f32));
pos <- newPos;
buf vread(newPos, Interpolation.cubic, 2) |> outlet
}
samplePlayer defSynthX("samplePlayer") await;
-- engine side, after newNode("samplePlayer", 100):
loadBuffer(100, 0, "samples/kick.wav");
A SampleBankVar is a slot for a set of samples with a
(pitch, velocity) zone map — the multi-sample instrument counterpart of a single
buffer. The bank is described engine-side as a list of zones, each a sound file
covering an inclusive pitch range and velocity range (MIDI 0–127) with a
root key (the note at which the sample plays back unshifted). Each zone's
pitch×velocity rectangle is a tile: no two tiles may cover the same
(pitch, velocity) cell, but zones may share a pitch range when their velocity ranges
are disjoint — the usual way to build velocity layers — and vice versa.
Cells no zone covers read as silence. Like a buffer, the whole bank swaps at runtime
without recompiling the synth, and swapping is safe while notes sound.
In the graph, bank lookup(pitch, velocity) resolves one sample of the
bank. The lookup runs at Rate.reset — once per note-on inside a
voicer (once at init in a non-voicer graph). Its inputs are latched at that
moment, so they must be note params, controls, constants, or init-rate values. The
result is a BankSample: read it with read/vread
exactly like a buffer, and use its metadata accessors to compute the playback rate.
| Function | Description |
|---|---|
sampleBankVar() | Declare a bank slot. Slots are numbered in declaration order (bankID 0, 1, …). |
bank lookup(pitch, velocity) | Resolve (pitch, velocity) to one sample, once per note-on. Returns a BankSample. |
h read(index, chans = 1, startChan = 0) | Fixed-index read of the resolved sample. |
h vread(index, interp = Interpolation.cubic, chans = 1, startChan = 0) | Interpolated read of the resolved sample — the sample-playback primitive. |
h rootKey | The resolved sample's root key (MIDI note of unshifted playback). |
h sampleRate | The resolved sample's source-file sample rate. |
h length | The resolved sample's length in frames. |
h loopStart, h loopEnd | The resolved sample's sustain loop in frames (loopEnd exclusive, possibly fractional). Without a loop the range is 0…length, so it is always usable. |
h hasLoop | 1 if the resolved sample has a sustain loop, else 0. |
loopPhasor(h, rate, sustain) | A per-voice looping playhead: advances rate frames per engine sample from 0 at note-on; while sustain > 0 (and the sample has a loop) it wraps at the loop end, after that it runs on toward the end of the sample. Feed it to vread. |
The wrap subtracts the exact loop length rather than resetting to the loop start, so the fractional overshoot carries through and the loop period is sub-sample accurate at any playback rate — a loop whose length is not a whole number of frames stays phase-locked indefinitely.
Engine side, loadSampleBank(nodeID, bankID, zones) binds a bank: the
files load and the zones validate on the calling thread at bundle submit (a bad spec
or missing file returns errBadSampleBank synchronously), and the built
bank is swapped in with a single pointer store. Build zones with
sampleZone(path, loKey, hiKey = loKey, loVel = 0, hiVel = 127, rootKey = -1,
loopStart = -1.0, loopEnd = -1.0). A negative rootKey,
loopStart, or loopEnd means "take it from the file's
instrument metadata" — the WAV smpl chunk or AIFF
INST/MARK chunks, as written by most samplers and audio
editors; where the file has none either, the root key defaults to the zone's
loKey and the zone has no loop. Explicit values always win over the
file's.
-- a voicer sampler: each note picks its sample by (pitch, velocity) and
-- plays it at the rate implied by the sample's root key and source rate
fn sampler() S {
let bank = sampleBankVar();
voicer(8, fn() S {
let g = gate();
let pitch = noteParam("pitch", ControlSpec { lo: 0.0, hi: 127.0, init: 60.0, warp: ControlWarp.linear });
let vel = noteParam("vel", ControlSpec { lo: 0.0, hi: 127.0, init: 100.0, warp: ControlWarp.linear });
let h = bank lookup(pitch, vel);
let rate = ((pitch f64 - (h rootKey) f64) / 12.0) exp2 * (h sampleRate) * (T() f64);
let d = delayVar(); -- per-voice playhead, zeroed at note-on
let pos = d read(1);
(pos + rate) -> d;
h vread(pos, Interpolation.cubic) f32 * g
}) sum(1) outlet
}
sampler defSynthX("sampler") await;
-- engine side, after newNode("sampler", 100):
loadSampleBank(100, 0, [
sampleZone("samples/piano_c4.wav", 0, 65, 0, 127, 60),
sampleZone("samples/piano_c5.wav", 66, 127, 0, 127, 72),
]);
For sustained instruments, replace the free-running playhead with a
loopPhasor driven by the gate: the sample loops its sustain region while
the note is held and plays out its natural release when the note is released. With
zones like sampleZone("samples/flute_c5.wav", 0, 127) the root key and
loop points come straight from the file's metadata:
-- a looping sampler: sustain loop while the gate is held, run-out on release
fn loopSampler() S {
let bank = sampleBankVar();
voicer(8, fn() S {
let g = gate();
let pitch = noteParam("pitch", ControlSpec { lo: 0.0, hi: 127.0, init: 60.0, warp: ControlWarp.linear });
let vel = noteParam("vel", ControlSpec { lo: 0.0, hi: 127.0, init: 100.0, warp: ControlWarp.linear });
let h = bank lookup(pitch, vel);
let rate = ((pitch f64 - (h rootKey) f64) / 12.0) exp2 * (h sampleRate) * (T() f64);
h vread(loopPhasor(h, rate, g), Interpolation.cubic) f32 * (g lag(0.005, 0.1))
}) sum(1) outlet
}
↑ Back to top