Files
skills/docs/media/audio-fx/audio-fx-plugin-sampler/src/sampler/sampler.md
2026-05-29 08:20:56 +01:00

5.4 KiB
Raw Blame History

sampler

(no submodules)

Purpose

SamplerPlugin is the universal polyphonic sampler. loadSample(note, url) fetches a raw Float32Array PCM buffer from a URL and registers it under a MIDI note key; triggerAttack(note, velocity?) finds the nearest loaded base sample and plays it back at the pitch ratio 2^((note − baseNote) / 12) using linear interpolation. Concurrent voices are summed into the mono output channel, with deterministic voice stealing when the configured pool size is exceeded.

The plugin is a source (io.inputs[0].count === 0), so it slots into chains at position 0 — the chain's IO validator rejects placing it elsewhere. The processing path is allocation-free; only the voice pool array is mutated per block.

Features

  • Source plugin (io.inputs[0].count === 0), mono output (io.outputs[0].count === 1).
  • One automatable parameter: gain (0..4, default 1) for master-out level.
  • loadSample(note, url, { signal? }) — fetches url as a raw Float32Array buffer; signal aborts pre-fetch (AUDIO_FX.ABORTED) or mid-fetch.
  • triggerAttack(note, velocity?) — throws AUDIO_FX.SAMPLE_NOT_LOADED if no samples are registered; throws AUDIO_FX.INVALID_NOTE for note ∉ [0, 127] integer.
  • triggerRelease(note) — switches the voice into release mode; one-pole exponential decay ends the voice cleanly.
  • Voice pool (default 32, configurable via initialMaxVoices up to 128). Stealing is deterministic: the lowest-sequence voice is replaced.
  • getLoadedSamples(), getActiveVoiceCount() accessors for diagnostics and meters.

Usage: Basic Example

import { SamplerPlugin } from "@suaveplan/audio-fx-plugin-sampler";

const sampler = new SamplerPlugin();
await sampler.loadSample(60, "/sounds/piano-c4.raw");
chain.add(sampler);
sampler.triggerAttack(60); // play at native pitch
sampler.triggerAttack(72); // play one octave up

Usage: Advanced Example

import { SamplerPlugin } from "@suaveplan/audio-fx-plugin-sampler";

const sampler = new SamplerPlugin({
    initialMaxVoices: 16,
    initialReleaseMs: 40,
});
await sampler.setup(
    { sampleRate: 48_000, channels: 1, blockSize: 512 },
    ctx
);

// Load a piano sample bank, key-mapped across the keyboard.
const ac = new AbortController();
try {
    await Promise.all([
        sampler.loadSample(48, "/sounds/piano-c3.raw", { signal: ac.signal }),
        sampler.loadSample(60, "/sounds/piano-c4.raw", { signal: ac.signal }),
        sampler.loadSample(72, "/sounds/piano-c5.raw", { signal: ac.signal }),
    ]);
} catch (error) {
    if (error instanceof AudioFxError && error.code === "AUDIO_FX.ABORTED") {
        console.warn("sample bank load aborted");
    } else {
        throw error;
    }
}

// triggerAttack(65) picks the nearest base (60), pitch-shifts +5 semitones.
sampler.triggerAttack(65, 0.8);

API Reference

  • SamplerPlugin (class extends AbstractAudioFxPlugin)
    • new SamplerPlugin(options?: SamplerPluginOptions) — throws AUDIO_FX.INVALID_OPTIONS on malformed options.
    • loadSample(note, url, { signal? }): Promise<void> — fetches and registers a base sample.
    • triggerAttack(note, velocity?: number): void
    • triggerRelease(note: number): void
    • getLoadedSamples(): ReadonlyArray<SamplerLoadedSample>
    • getActiveVoiceCount(): number
    • Inherited accessor for gain.

Implementation Notes

The pitch ratio is 2^((note − baseNote) / 12) — the standard equal-temperament pitch-shifting formula. We use linear interpolation between adjacent samples in the source buffer, which is the cheapest interpolator that avoids audible quantisation artefacts. For high-fidelity pitch shifts (e.g., spanning more than an octave from the base), callers should load multiple base samples spaced an octave apart so the nearest-neighbour lookup keeps the interpolation distance short.

Voice stealing uses a monotonic sequence counter on each voice. When the pool fills, we scan for the lowest sequence and replace it. The cost is O(maxVoices) per attack — for the default pool size of 32, this is 32 comparisons, well under the per-attack budget of a real-time engine. Faster algorithms (heap, ring buffer) would lower the constant factor but add bookkeeping complexity; the linear scan is the right MVP.

Release uses a one-pole exponential decay: releaseGain *= 1 − releaseCoef per sample with releaseCoef = 1 − exp(-1/releaseSamples). When the gain falls below 1e-4, the voice is removed from the pool. This is the same envelope-decay form used by the gate plugin and gives a perceptually-correct "soft release" rather than a click-prone hard cut.

loadSample mirrors the reverb's loadImpulseResponse pattern: pre-aborted signal throws AUDIO_FX.ABORTED before any fetch; signal aborted mid-fetch surfaces as the same error, leaving the existing sample map untouched. The signal field is built conditionally in fetchInit to satisfy exactOptionalPropertyTypes — RequestInit.signal is AbortSignal | null, and assigning undefined would be a type error.

The plugin output is mono (io.outputs[0].count === 1) so callers can chain a MixerPlugin or StereoWidenerPlugin downstream to place the sampler in stereo space. Spatial positioning is the consumer's responsibility — the sampler intentionally does not pan internally so a single instance can drive multiple voices that are panned differently downstream.