Tzopilotl
Docs
GitHub

Writing SynthDefs

10. Sample Buffers

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.)

Graph-side API

FunctionDescription
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 lengthThe buffer's content length in frames, as an init-rate signal.

Filling buffers from the engine

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");

Sample banks: pitch/velocity zones

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.

FunctionDescription
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 rootKeyThe resolved sample's root key (MIDI note of unshifted playback).
h sampleRateThe resolved sample's source-file sample rate.
h lengthThe resolved sample's length in frames.
h loopStart, h loopEndThe 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 hasLoop1 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