# `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 ```ts 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 ```ts 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` — fetches and registers a base sample. - `triggerAttack(note, velocity?: number): void` - `triggerRelease(note: number): void` - `getLoadedSamples(): ReadonlyArray` - `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.