Initial Commit
This commit is contained in:
31
packages/media/audio-fx/audio-fx-plugin-sampler/README.md
Normal file
31
packages/media/audio-fx/audio-fx-plugin-sampler/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-sampler - sampler plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
sampler plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
Member of the `@suaveplan/audio-fx-plugin-*` family. Universal (browser / server / offline / worklet identical). Depends on `@suaveplan/audio-fx-plugin-base` for the `AudioFxPlugin` contract; declares no other `@suaveplan/audio-fx-plugin-*` deps.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [sampler](./src/sampler/sampler.md) — `SamplerPlugin` is the universal polyphonic sampler. `loadSample(note, url)` fetches a raw `Float32Array` PCM buffer fr…
|
||||
- [sampler](./src/sampler/sampler.md) — `SamplerPlugin` is the universal polyphonic sampler. `loadSample(note, url)` fetches a raw `Float32Array` PCM buffer fr…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, load options, and preset payload of `SamplerPlugin`. These run on…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, load options, and preset payload of `SamplerPlugin`. These run on…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-sampler";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./sampler` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
47
packages/media/audio-fx/audio-fx-plugin-sampler/package.json
Normal file
47
packages/media/audio-fx/audio-fx-plugin-sampler/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@suaveplan/audio-fx-plugin-sampler",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "sampler plugin for the @suaveplan/audio-fx-plugin-* family.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist", "README.md"],
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"lint": "biome check --max-diagnostics 500 src/",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.lib.json",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/audio-fx-plugin-base": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/audio-core": "workspace:^",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/typescript-config": "workspace:^",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
}
|
||||
}
|
||||
12
packages/media/audio-fx/audio-fx-plugin-sampler/src/index.ts
Normal file
12
packages/media/audio-fx/audio-fx-plugin-sampler/src/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { SamplerPlugin } from "./sampler/index.js";
|
||||
export type {
|
||||
SamplerLoadedSample,
|
||||
SamplerLoadOptions,
|
||||
SamplerPluginOptions,
|
||||
SamplerPresetData,
|
||||
} from "./sampler.types.js";
|
||||
export {
|
||||
SamplerLoadOptionsSchema,
|
||||
SamplerPluginOptionsSchema,
|
||||
SamplerPresetDataSchema,
|
||||
} from "./schemas/index.js";
|
||||
@@ -0,0 +1,67 @@
|
||||
# `sampler.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `SamplerPlugin`'s construction options, load options, loaded-sample introspection, and preset payload. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`.
|
||||
|
||||
## Features
|
||||
|
||||
- `SamplerLoadOptions` — accepts an `AbortSignal` to cancel an in-flight `loadSample` call.
|
||||
- `SamplerPluginOptions` — optional `initialMaxVoices` (voice pool size) and `initialReleaseMs` (default release-envelope time).
|
||||
- `SamplerLoadedSample` — diagnostic snapshot of a loaded base sample: `{ note, length }`.
|
||||
- `SamplerPresetData = Record<string, never>` — sampler has no plugin-specific preset payload.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SamplerLoadedSample,
|
||||
SamplerPluginOptions,
|
||||
} from "@suaveplan/audio-fx-plugin-sampler";
|
||||
|
||||
const opts: SamplerPluginOptions = { initialMaxVoices: 8 };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SamplerLoadOptions,
|
||||
SamplerLoadedSample,
|
||||
SamplerPluginOptions,
|
||||
} from "@suaveplan/audio-fx-plugin-sampler";
|
||||
|
||||
async function loadAll(
|
||||
sampler: SamplerPlugin,
|
||||
samples: Array<{ note: number; url: string }>,
|
||||
signal: AbortSignal
|
||||
) {
|
||||
const opts: SamplerLoadOptions = { signal };
|
||||
await Promise.all(samples.map((s) => sampler.loadSample(s.note, s.url, opts)));
|
||||
const loaded: ReadonlyArray<SamplerLoadedSample> = sampler.getLoadedSamples();
|
||||
return loaded.length;
|
||||
}
|
||||
|
||||
const presetState: SamplerPluginOptions = {
|
||||
initialMaxVoices: 24,
|
||||
initialReleaseMs: 60,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SamplerLoadOptions`
|
||||
- `readonly signal?: AbortSignal`
|
||||
- `SamplerPluginOptions`
|
||||
- `readonly initialMaxVoices?: number` (1..128, default 32)
|
||||
- `readonly initialReleaseMs?: number` (0..5000, default 30)
|
||||
- `SamplerLoadedSample`
|
||||
- `readonly note: number`
|
||||
- `readonly length: number`
|
||||
- `SamplerPresetData = Record<string, never>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SamplerPresetData` is typed as `Record<string, never>` rather than `{}` because the empty-record form is structurally distinct in TypeScript's exact-type checking: `{}` matches any non-null value, whereas `Record<string, never>` only matches an object literal with no enumerable properties. This means callers cannot accidentally pass a foreign preset payload (e.g., a `MixerPresetData`) and have it silently round-trip through the sampler.
|
||||
|
||||
The voice-pool size is capped at 128 in the schema. Beyond that, the linear voice-stealing scan starts to add noticeable per-attack overhead — and few real-world sampler instances need more than 64 simultaneous voices. A future "high-density" variant could swap the linear scan for a heap if a use case emerges.
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Options accepted by `SamplerPlugin.loadSample`. */
|
||||
export interface SamplerLoadOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Construction options for `SamplerPlugin`. */
|
||||
export interface SamplerPluginOptions {
|
||||
readonly initialMaxVoices?: number;
|
||||
readonly initialReleaseMs?: number;
|
||||
}
|
||||
|
||||
/** Snapshot of a loaded base sample. */
|
||||
export interface SamplerLoadedSample {
|
||||
readonly note: number;
|
||||
readonly length: number;
|
||||
}
|
||||
|
||||
/** Persisted preset payload — no plugin-specific persistent state. */
|
||||
export type SamplerPresetData = Record<string, never>;
|
||||
@@ -0,0 +1 @@
|
||||
export { SamplerPlugin } from "./sampler.js";
|
||||
@@ -0,0 +1,90 @@
|
||||
# `sampler`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## 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<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.
|
||||
@@ -0,0 +1,321 @@
|
||||
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import type {
|
||||
ChainContext,
|
||||
ChainHandle,
|
||||
} from "@suaveplan/audio-fx-plugin-base/base";
|
||||
import { defaultAmbientState } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import {
|
||||
AUDIO_FX_CODES,
|
||||
type AudioFxError,
|
||||
} from "@suaveplan/audio-fx-plugin-base/error-codes";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SamplerPlugin } from "./sampler.js";
|
||||
|
||||
const SAMPLE_RATE = 48_000;
|
||||
|
||||
function blockCtx(): BlockContext {
|
||||
return {
|
||||
blockOffsetSamples: 0,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
channels: 1,
|
||||
opts: {},
|
||||
};
|
||||
}
|
||||
|
||||
function newConfig(blockSize: number, channels = 1): ProcessorConfig {
|
||||
return { sampleRate: SAMPLE_RATE, channels, blockSize };
|
||||
}
|
||||
|
||||
function newCtx(channels = 1): ChainContext {
|
||||
const chain: ChainHandle = {
|
||||
id: "sampler-test",
|
||||
name: "sampler-test",
|
||||
channels,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
blockSize: 128,
|
||||
slotCount: 1,
|
||||
};
|
||||
return {
|
||||
chain,
|
||||
position: { index: 0, total: 1 },
|
||||
predecessors: [],
|
||||
successors: [],
|
||||
ambient: defaultAmbientState(),
|
||||
buses: { get: () => undefined, list: () => [] },
|
||||
};
|
||||
}
|
||||
|
||||
function sampleDataUrl(samples: number[]): string {
|
||||
const arr = Float32Array.from(samples);
|
||||
const b64 = Buffer.from(arr.buffer).toString("base64");
|
||||
return `data:application/octet-stream;base64,${b64}`;
|
||||
}
|
||||
|
||||
describe("SamplerPlugin: contract surface", () => {
|
||||
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
|
||||
const s = new SamplerPlugin();
|
||||
expect(s.metadata.id).toBe("sampler");
|
||||
expect(s.metadata.category).toBe("sampling");
|
||||
expect(s.parameters.map((p) => p.id)).toEqual(["gain"]);
|
||||
});
|
||||
|
||||
it("rejects malformed options with INVALID_OPTIONS", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
try {
|
||||
new SamplerPlugin({ initialMaxVoices: -1 });
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SamplerPlugin: triggerAttack rejects an unloaded note with AUDIO_FX.SAMPLE_NOT_LOADED", () => {
|
||||
it("no samples loaded → triggerAttack throws SAMPLE_NOT_LOADED", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const s = new SamplerPlugin();
|
||||
try {
|
||||
s.triggerAttack(60);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.SAMPLE_NOT_LOADED
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected SAMPLE_NOT_LOADED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SamplerPlugin: triggerAttack rejects an out-of-range MIDI note", () => {
|
||||
it("note=128 → triggerAttack throws AUDIO_FX.INVALID_NOTE", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const s = new SamplerPlugin();
|
||||
try {
|
||||
s.triggerAttack(128);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_NOTE
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_NOTE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SamplerPlugin: loadSample with a pre-aborted signal throws AUDIO_FX.ABORTED", () => {
|
||||
it("pre-aborted signal short-circuits before fetch", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const s = new SamplerPlugin();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
await s.loadSample(60, "data:application/octet-stream,", {
|
||||
signal: ac.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(AUDIO_FX_CODES.ABORTED);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected AUDIO_FX.ABORTED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SamplerPlugin: plays the loaded sample for a triggered note", () => {
|
||||
it("triggerAttack on the loaded base note plays the sample data", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const s = new SamplerPlugin();
|
||||
await s.loadSample(60, sampleDataUrl([1, 0.5, 0.25, 0.125]));
|
||||
await s.setup(newConfig(8), newCtx());
|
||||
s.triggerAttack(60);
|
||||
const output: Float32Array[] = [new Float32Array(8)];
|
||||
s.process([], output, blockCtx());
|
||||
// Sample length 4 → first 3 output samples linear-interp at native
|
||||
// pitch (ratio 1) should approximate the raw buffer.
|
||||
expect(output[0]![0]).toBeCloseTo(1, 5);
|
||||
expect(output[0]![1]).toBeCloseTo(0.5, 5);
|
||||
expect(output[0]![2]).toBeCloseTo(0.25, 5);
|
||||
});
|
||||
|
||||
it("triggerAttack with a non-base note pitches via linear interp", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const s = new SamplerPlugin();
|
||||
await s.loadSample(60, sampleDataUrl([1, 0.5, 0.25, 0.125]));
|
||||
await s.setup(newConfig(8), newCtx());
|
||||
s.triggerAttack(72); // +12 semitones → ratio 2x.
|
||||
const output: Float32Array[] = [new Float32Array(8)];
|
||||
s.process([], output, blockCtx());
|
||||
// At 2x speed, position advances 2 samples per output → outputs 0, 2.
|
||||
expect(output[0]![0]).toBeCloseTo(1, 5);
|
||||
expect(output[0]![1]).toBeCloseTo(0.25, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SamplerPlugin: loadSample succeeds with a non-aborted signal", () => {
|
||||
it("AbortController.signal that is not aborted lets the load complete", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const s = new SamplerPlugin();
|
||||
const ac = new AbortController();
|
||||
await s.loadSample(60, sampleDataUrl([1, 0.5, 0.25]), {
|
||||
signal: ac.signal,
|
||||
});
|
||||
expect(s.getLoadedSamples()).toEqual([{ note: 60, length: 3 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SamplerPlugin: voice survives across blocks when sample is longer than block", () => {
|
||||
it("after one block of a long sample the voice is still active", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const s = new SamplerPlugin({ initialReleaseMs: 5_000 });
|
||||
const long = new Array(SAMPLE_RATE).fill(0.5);
|
||||
await s.loadSample(60, sampleDataUrl(long));
|
||||
await s.setup(newConfig(64), newCtx());
|
||||
s.triggerAttack(60);
|
||||
const output: Float32Array[] = [new Float32Array(64)];
|
||||
s.process([], output, blockCtx());
|
||||
// Sample is 48_000 long, block is 64 -> voice still alive.
|
||||
expect(s.getActiveVoiceCount()).toBe(1);
|
||||
expect(output[0]![0]).toBeCloseTo(0.5, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SamplerPlugin: triggerRelease ends the voice", () => {
|
||||
it("after triggerRelease the active voice count drops to zero within a block", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const s = new SamplerPlugin({ initialReleaseMs: 0.1 });
|
||||
// Long sample so the voice isn't terminated by buffer end.
|
||||
const long = new Array(SAMPLE_RATE).fill(0.5);
|
||||
await s.loadSample(60, sampleDataUrl(long));
|
||||
await s.setup(newConfig(SAMPLE_RATE), newCtx());
|
||||
s.triggerAttack(60);
|
||||
expect(s.getActiveVoiceCount()).toBe(1);
|
||||
s.triggerRelease(60);
|
||||
const output: Float32Array[] = [new Float32Array(SAMPLE_RATE)];
|
||||
s.process([], output, blockCtx());
|
||||
expect(s.getActiveVoiceCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SamplerPlugin: voice stealing", () => {
|
||||
it("never exceeds maxVoices; oldest is reused when limit reached", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const s = new SamplerPlugin({ initialMaxVoices: 2 });
|
||||
await s.loadSample(60, sampleDataUrl(new Array(SAMPLE_RATE).fill(0.5)));
|
||||
await s.setup(newConfig(128), newCtx());
|
||||
s.triggerAttack(60);
|
||||
s.triggerAttack(61);
|
||||
s.triggerAttack(62);
|
||||
s.triggerAttack(63);
|
||||
expect(s.getActiveVoiceCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SamplerPlugin: reset clears active voices", () => {
|
||||
it("after reset getActiveVoiceCount is zero", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const s = new SamplerPlugin();
|
||||
await s.loadSample(60, sampleDataUrl(new Array(SAMPLE_RATE).fill(0.5)));
|
||||
await s.setup(newConfig(64), newCtx());
|
||||
s.triggerAttack(60);
|
||||
expect(s.getActiveVoiceCount()).toBe(1);
|
||||
s.reset();
|
||||
expect(s.getActiveVoiceCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SamplerPlugin: loaded-sample introspection", () => {
|
||||
it("getLoadedSamples lists registered notes and buffer length", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const s = new SamplerPlugin();
|
||||
await s.loadSample(60, sampleDataUrl([0.1, 0.2, 0.3]));
|
||||
const loaded = s.getLoadedSamples();
|
||||
expect(loaded).toEqual([{ note: 60, length: 3 }]);
|
||||
});
|
||||
|
||||
it("loadSample rejects out-of-range notes with INVALID_NOTE", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const s = new SamplerPlugin();
|
||||
try {
|
||||
await s.loadSample(-1, sampleDataUrl([1]));
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_NOTE
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_NOTE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SamplerPlugin: triggerRelease no-op cases", () => {
|
||||
it("triggerRelease on a note with no active voice leaves state unchanged", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const s = new SamplerPlugin();
|
||||
const long = new Array(SAMPLE_RATE).fill(0.5);
|
||||
await s.loadSample(60, sampleDataUrl(long));
|
||||
await s.setup(newConfig(32), newCtx());
|
||||
s.triggerAttack(60);
|
||||
s.triggerRelease(99); // not the voice's note
|
||||
expect(s.getActiveVoiceCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("triggerRelease twice on the same voice is idempotent", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const s = new SamplerPlugin({ initialReleaseMs: 100 });
|
||||
const long = new Array(SAMPLE_RATE).fill(0.5);
|
||||
await s.loadSample(60, sampleDataUrl(long));
|
||||
await s.setup(newConfig(32), newCtx());
|
||||
s.triggerAttack(60);
|
||||
s.triggerRelease(60);
|
||||
s.triggerRelease(60); // second call hits the !voice.releasing branch
|
||||
expect(s.getActiveVoiceCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SamplerPlugin: nearest-neighbour base-note selection", () => {
|
||||
it("picks the closest loaded sample when multiple are registered", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const s = new SamplerPlugin();
|
||||
await s.loadSample(48, sampleDataUrl([1, 0, 0, 0]));
|
||||
await s.loadSample(72, sampleDataUrl([0, 0, 0, 1]));
|
||||
await s.setup(newConfig(4), newCtx());
|
||||
// Note 50 is closer to 48 than 72 → uses the [1,0,0,0] sample.
|
||||
s.triggerAttack(50);
|
||||
const output: Float32Array[] = [new Float32Array(4)];
|
||||
s.process([], output, blockCtx());
|
||||
expect(output[0]![0]).toBeCloseTo(1, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SamplerPlugin: triggerRelease validates note range", () => {
|
||||
it("triggerRelease(-1) throws INVALID_NOTE", { timeout: 5_000 }, () => {
|
||||
const s = new SamplerPlugin();
|
||||
try {
|
||||
s.triggerRelease(-1);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_NOTE
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_NOTE");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import type { ChainContext } from "@suaveplan/audio-fx-plugin-base/base";
|
||||
import { AbstractAudioFxPlugin } from "@suaveplan/audio-fx-plugin-base/base";
|
||||
import {
|
||||
AUDIO_FX_CODES,
|
||||
AudioFxError,
|
||||
} from "@suaveplan/audio-fx-plugin-base/error-codes";
|
||||
import type {
|
||||
SamplerLoadedSample,
|
||||
SamplerLoadOptions,
|
||||
SamplerPluginOptions,
|
||||
} from "../sampler.types.js";
|
||||
import {
|
||||
SamplerPluginOptionsSchema,
|
||||
SamplerPresetDataSchema,
|
||||
} from "../schemas/index.js";
|
||||
|
||||
interface SamplerVoice {
|
||||
note: number;
|
||||
baseNote: number;
|
||||
buffer: Float32Array;
|
||||
position: number;
|
||||
velocity: number;
|
||||
releasing: boolean;
|
||||
releaseGain: number;
|
||||
releaseCoef: number;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_VOICES = 32;
|
||||
|
||||
/**
|
||||
* Polyphonic sampler. `loadSample(note, url)` registers a base sample
|
||||
* keyed by MIDI note; `triggerAttack(note)` 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. Voice stealing reuses the
|
||||
* oldest voice when the pool is exhausted.
|
||||
*/
|
||||
export class SamplerPlugin extends AbstractAudioFxPlugin {
|
||||
private readonly samples = new Map<number, Float32Array>();
|
||||
private voices: SamplerVoice[] = [];
|
||||
private maxVoices: number;
|
||||
private releaseMs: number;
|
||||
private releaseCoef = 0;
|
||||
private voiceCounter = 0;
|
||||
|
||||
public constructor(options: SamplerPluginOptions = {}) {
|
||||
const parsed = SamplerPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
"audio-fx.sampler-invalid-options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const opts = parsed.data;
|
||||
super({
|
||||
metadata: {
|
||||
id: "sampler",
|
||||
name: "Sampler",
|
||||
version: "1.0.0",
|
||||
category: "sampling",
|
||||
},
|
||||
schema: SamplerPresetDataSchema,
|
||||
io: {
|
||||
inputs: [{ count: 0 }],
|
||||
outputs: [{ count: 1 }],
|
||||
},
|
||||
parameters: [
|
||||
{
|
||||
id: "gain",
|
||||
name: "Gain",
|
||||
value: 1,
|
||||
min: 0,
|
||||
max: 4,
|
||||
default: 1,
|
||||
unit: "ratio",
|
||||
automatable: true,
|
||||
},
|
||||
],
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
this.maxVoices = opts.initialMaxVoices ?? DEFAULT_MAX_VOICES;
|
||||
this.releaseMs = opts.initialReleaseMs ?? 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a sample from `url` and register it under MIDI `note`.
|
||||
* Pre-aborted signal throws `AUDIO_FX.ABORTED` before any fetch.
|
||||
*/
|
||||
public async loadSample(
|
||||
note: number,
|
||||
url: string,
|
||||
opts: SamplerLoadOptions = {}
|
||||
): Promise<void> {
|
||||
this.assertNote(note);
|
||||
if (opts.signal?.aborted) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.ABORTED,
|
||||
"audio-fx.sampler-load-pre-aborted",
|
||||
{ context: { note, url } }
|
||||
);
|
||||
}
|
||||
const fetchInit: RequestInit = {};
|
||||
if (opts.signal !== undefined) {
|
||||
fetchInit.signal = opts.signal;
|
||||
}
|
||||
const response = await fetch(url, fetchInit);
|
||||
const buffer = await response.arrayBuffer();
|
||||
this.samples.set(note, new Float32Array(buffer.slice(0)));
|
||||
}
|
||||
|
||||
public triggerAttack(note: number, velocity = 1): void {
|
||||
this.assertNote(note);
|
||||
if (this.samples.size === 0) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.SAMPLE_NOT_LOADED,
|
||||
"audio-fx.sampler-no-samples",
|
||||
{ context: { note } }
|
||||
);
|
||||
}
|
||||
const baseNote = this.findNearestBaseNote(note);
|
||||
const buffer = this.samples.get(baseNote)!;
|
||||
const voice: SamplerVoice = {
|
||||
note,
|
||||
baseNote,
|
||||
buffer,
|
||||
position: 0,
|
||||
velocity,
|
||||
releasing: false,
|
||||
releaseGain: 1,
|
||||
releaseCoef: this.releaseCoef,
|
||||
sequence: ++this.voiceCounter,
|
||||
};
|
||||
if (this.voices.length >= this.maxVoices) {
|
||||
// Steal the oldest voice (lowest sequence number).
|
||||
let oldestIdx = 0;
|
||||
for (let i = 1; i < this.voices.length; i++) {
|
||||
if (
|
||||
this.voices[i]!.sequence < this.voices[oldestIdx]!.sequence
|
||||
) {
|
||||
oldestIdx = i;
|
||||
}
|
||||
}
|
||||
this.voices[oldestIdx] = voice;
|
||||
} else {
|
||||
this.voices.push(voice);
|
||||
}
|
||||
}
|
||||
|
||||
public triggerRelease(note: number): void {
|
||||
this.assertNote(note);
|
||||
for (const voice of this.voices) {
|
||||
if (voice.note === note && !voice.releasing) {
|
||||
voice.releasing = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getLoadedSamples(): ReadonlyArray<SamplerLoadedSample> {
|
||||
const out: SamplerLoadedSample[] = [];
|
||||
for (const [note, buffer] of this.samples) {
|
||||
out.push({ note, length: buffer.length });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public getActiveVoiceCount(): number {
|
||||
return this.voices.length;
|
||||
}
|
||||
|
||||
protected override setupState(
|
||||
config: ProcessorConfig,
|
||||
_chain: ChainContext
|
||||
): void {
|
||||
this.voices = [];
|
||||
const releaseSamples = Math.max(
|
||||
1,
|
||||
(this.releaseMs / 1000) * config.sampleRate
|
||||
);
|
||||
this.releaseCoef = 1 - Math.exp(-1 / releaseSamples);
|
||||
}
|
||||
|
||||
protected override resetState(): void {
|
||||
this.voices = [];
|
||||
}
|
||||
|
||||
protected processBlock(
|
||||
_input: Float32Array[],
|
||||
output: Float32Array[],
|
||||
_ctx: BlockContext
|
||||
): void {
|
||||
const gain = this.parameterValue("gain");
|
||||
const out = output[0]!;
|
||||
out.fill(0);
|
||||
const blockSize = out.length;
|
||||
const survivors: SamplerVoice[] = [];
|
||||
for (const voice of this.voices) {
|
||||
const ratio = 2 ** ((voice.note - voice.baseNote) / 12);
|
||||
let position = voice.position;
|
||||
let releaseGain = voice.releaseGain;
|
||||
let stillAlive = true;
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
if (position >= voice.buffer.length - 1) {
|
||||
stillAlive = false;
|
||||
break;
|
||||
}
|
||||
const i0 = Math.floor(position);
|
||||
const frac = position - i0;
|
||||
const s0 = voice.buffer[i0]!;
|
||||
const s1 = voice.buffer[i0 + 1]!;
|
||||
const sample = s0 + (s1 - s0) * frac;
|
||||
out[i] = out[i]! + sample * voice.velocity * releaseGain * gain;
|
||||
position += ratio;
|
||||
if (voice.releasing) {
|
||||
releaseGain *= 1 - voice.releaseCoef;
|
||||
if (releaseGain < 1e-4) {
|
||||
stillAlive = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
voice.position = position;
|
||||
voice.releaseGain = releaseGain;
|
||||
if (stillAlive) {
|
||||
survivors.push(voice);
|
||||
}
|
||||
}
|
||||
this.voices = survivors;
|
||||
}
|
||||
|
||||
private assertNote(note: number): void {
|
||||
if (!Number.isInteger(note) || note < 0 || note > 127) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_NOTE,
|
||||
"audio-fx.sampler-invalid-note",
|
||||
{ context: { note } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private findNearestBaseNote(note: number): number {
|
||||
// Caller ensures samples.size > 0 so the iterator yields at
|
||||
// least once and `best` is assigned a real note value.
|
||||
let best = 0;
|
||||
let bestDistance = Number.POSITIVE_INFINITY;
|
||||
for (const candidate of this.samples.keys()) {
|
||||
const d = Math.abs(candidate - note);
|
||||
if (d < bestDistance) {
|
||||
bestDistance = d;
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
SamplerLoadOptionsSchema,
|
||||
SamplerPluginOptionsSchema,
|
||||
SamplerPresetDataSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,54 @@
|
||||
# `sampler/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options, load options, and preset payload of `SamplerPlugin`. These run on the public boundary so any malformed object is rejected with `AUDIO_FX.INVALID_OPTIONS` / `AUDIO_FX.INVALID_PRESET` before it can corrupt runtime state.
|
||||
|
||||
Separating schemas from the plugin module lets `index.ts` re-export them so callers can introspect the option surface (for forms, RPC validation, or storybook controls) without importing the audio implementation.
|
||||
|
||||
## Features
|
||||
|
||||
- `SamplerPluginOptionsSchema` — `initialMaxVoices` integer 1..128, `initialReleaseMs` 0..5000.
|
||||
- `SamplerLoadOptionsSchema` — `.strict()` with `signal: AbortSignal | undefined`.
|
||||
- `SamplerPresetDataSchema` — empty strict object; the sampler has no plugin-specific preset payload.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SamplerPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-sampler";
|
||||
|
||||
const parsed = SamplerPluginOptionsSchema.parse({ initialMaxVoices: 16 });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SamplerLoadOptionsSchema,
|
||||
SamplerPluginOptionsSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-sampler";
|
||||
|
||||
// Validate load options from an RPC boundary before forwarding.
|
||||
function safeLoadOptions(blob: unknown) {
|
||||
return SamplerLoadOptionsSchema.parse(blob);
|
||||
}
|
||||
|
||||
// Driver for an editor form: pull the keys from the schema.
|
||||
const fields = SamplerPluginOptionsSchema.keyof().options;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SamplerPluginOptionsSchema: z.ZodObject<{ initialMaxVoices?, initialReleaseMs? }>`
|
||||
- `SamplerLoadOptionsSchema: z.ZodObject<{ signal? }, "strict">`
|
||||
- `SamplerPresetDataSchema: z.ZodObject<{}, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SamplerLoadOptionsSchema` uses `z.instanceof(AbortSignal)` for the signal field. `instanceof` is the only Zod check that can verify a value is a real `AbortSignal` rather than a structurally-similar object — a fake `{ aborted: false, addEventListener: () => {} }` would pass a duck-typed check but break the actual abort wiring. The constructor-identity check is reliable in any runtime that exposes `AbortSignal` as a global (browser, Node 18+, Bun).
|
||||
|
||||
The schemas are duplicated in concept between the OpenSpec and the enterprise main-branch spec; this implementation reflects the OpenSpec (subset of the enterprise spec). Future revisions extending the sampler to support multi-velocity layers or round-robin sample selection would extend these schemas with optional fields and update `SamplerPresetDataSchema` accordingly.
|
||||
@@ -0,0 +1,14 @@
|
||||
import z from "zod";
|
||||
|
||||
export const SamplerPluginOptionsSchema = z.object({
|
||||
initialMaxVoices: z.number().int().min(1).max(128).optional(),
|
||||
initialReleaseMs: z.number().finite().min(0).max(5_000).optional(),
|
||||
});
|
||||
|
||||
export const SamplerPresetDataSchema = z.object({}).strict();
|
||||
|
||||
export const SamplerLoadOptionsSchema = z
|
||||
.object({
|
||||
signal: z.instanceof(AbortSignal).optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"tests/**",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/audio-fx-plugin-sampler",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: { index: "src/index.ts" },
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({ testTimeout: 30_000 });
|
||||
Reference in New Issue
Block a user