first commit
This commit is contained in:
31
docs/media/audio-fx/audio-fx-plugin-sampler/README.md
Normal file
31
docs/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`.
|
||||
@@ -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,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,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.
|
||||
Reference in New Issue
Block a user