Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
# `audio-fx`
Real-time audio effects and DSP plugins.
<!-- begin:auto-docs -->
## Packages in `audio-fx`
26 packages.
| Package | Description |
|---|---|
| [`@suaveplan/audio-fx-plugin-audio-analyzer`](./audio-fx-plugin-audio-analyzer/README.md) | audio-analyzer plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-audio-player`](./audio-fx-plugin-audio-player/README.md) | audio-player plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-auto-filter`](./audio-fx-plugin-auto-filter/README.md) | auto-filter plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-base`](./audio-fx-plugin-base/README.md) | Foundation for the `@suaveplan/audio-fx-plugin-*` family. Universal (browser / server / offline / worklet identical). Zero `@suaveplan/audio-fx-plugin-*`… |
| [`@suaveplan/audio-fx-plugin-chorus`](./audio-fx-plugin-chorus/README.md) | chorus plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-compressor`](./audio-fx-plugin-compressor/README.md) | compressor plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-delay`](./audio-fx-plugin-delay/README.md) | delay plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-distortion`](./audio-fx-plugin-distortion/README.md) | distortion plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-eq`](./audio-fx-plugin-eq/README.md) | Parametric / shelving / high-pass / low-pass equaliser for the `@suaveplan/audio-fx-plugin-*` family. Multi-band biquad implementation, sample-accurate paramet… |
| [`@suaveplan/audio-fx-plugin-filter`](./audio-fx-plugin-filter/README.md) | filter plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-flanger`](./audio-fx-plugin-flanger/README.md) | flanger plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-game-audio`](./audio-fx-plugin-game-audio/README.md) | game-audio plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-gate`](./audio-fx-plugin-gate/README.md) | gate plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-glados`](./audio-fx-plugin-glados/README.md) | glados plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-limiter`](./audio-fx-plugin-limiter/README.md) | limiter plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-microphone`](./audio-fx-plugin-microphone/README.md) | microphone plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-mixer`](./audio-fx-plugin-mixer/README.md) | mixer plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-phaser`](./audio-fx-plugin-phaser/README.md) | phaser plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-pitch-shifter`](./audio-fx-plugin-pitch-shifter/README.md) | pitch-shifter plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-reverb`](./audio-fx-plugin-reverb/README.md) | reverb plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-sampler`](./audio-fx-plugin-sampler/README.md) | sampler plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-spatial`](./audio-fx-plugin-spatial/README.md) | spatial plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-spessasynth`](./audio-fx-plugin-spessasynth/README.md) | SF2/SF3 SoundFont synthesizer plugin for the `@suaveplan/audio-fx-plugin-*` family. Wraps the MIT-licensed SpessaSynth engine as a standard `AudioFxPlugin`, so… |
| [`@suaveplan/audio-fx-plugin-synthesizer`](./audio-fx-plugin-synthesizer/README.md) | synthesizer plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-tremolo`](./audio-fx-plugin-tremolo/README.md) | tremolo plugin for the @suaveplan/audio-fx-plugin-* family. |
| [`@suaveplan/audio-fx-plugin-widener`](./audio-fx-plugin-widener/README.md) | widener plugin for the @suaveplan/audio-fx-plugin-* family. |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Real-time audio effects and DSP plugins.

View File

@@ -0,0 +1,31 @@
# @suaveplan/audio-fx-plugin-audio-analyzer - audio-analyzer plugin for the @suaveplan/audio-fx-plugin-* family.
audio-analyzer 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
- [audio-analyzer](./src/audio-analyzer/audio-analyzer.md) — `AudioAnalyzerPlugin` is the universal analysis tap point. It passes input audio through unchanged while accumulating a…
- [audio-analyzer](./src/audio-analyzer/audio-analyzer.md) — `AudioAnalyzerPlugin` is the universal analysis tap point. It passes input audio through unchanged while accumulating a…
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, preset enum, and preset payload of `AudioAnalyzerPlugin`. These r…
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, preset enum, and preset payload of `AudioAnalyzerPlugin`. These r…
<!-- end:auto-docs -->
## Composition
```ts
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-audio-analyzer";
const plugin = new /* Plugin */();
const chain = new PluginChain({
buses: new DefaultBusRegistry(),
plugins: [plugin],
});
```
## Status
Replaces the `./audio-analyzer` subpath of the bundled `@suaveplan/audio-plugins-fx`.

View File

@@ -0,0 +1,47 @@
{
"name": "@suaveplan/audio-fx-plugin-audio-analyzer",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "audio-analyzer 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"
}
}

View File

@@ -0,0 +1,84 @@
# `audio-analyzer.types`
## Purpose
TypeScript-only type definitions for `AudioAnalyzerPlugin`'s preset enum, construction options, async-result records, subscription payload, and preset payload. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`.
The async-result interfaces (`TempoInfo`, `PitchInfo`, `SpectralFeatures`, `EnergyInfo`, `KeyInfo`, `BeatPosition`) are deliberately minimal and stable — adding new fields is a non-breaking change, removing or renaming them is breaking.
## Features
- `AudioAnalyzerPreset` — textual enum `"music" | "speech" | "percussion" | "ambient"` used in options and presets.
- `AudioAnalyzerPluginOptions` — `fftSize`, `smoothingTimeConstant`, `updateIntervalMs`, `initialPreset`.
- Six result types: `TempoInfo`, `PitchInfo`, `SpectralFeatures`, `EnergyInfo`, `KeyInfo`, `BeatPosition`.
- `AnalysisUpdate` — subscription payload with each result type as an optional field; only enabled analyses appear.
- `AnalysisCallback = (update: AnalysisUpdate) => void`.
- `AudioAnalyzerPresetData` — persisted preset record carrying the preset name + the four toggle booleans.
## Usage: Basic Example
```ts
import type {
AnalysisCallback,
AudioAnalyzerPluginOptions,
} from "@suaveplan/audio-fx-plugin-audio-analyzer";
const opts: AudioAnalyzerPluginOptions = { initialPreset: "speech" };
const cb: AnalysisCallback = (u) => {
if (u.pitch) ui.setPitch(u.pitch.frequency);
};
```
## Usage: Advanced Example
```ts
import type {
AnalysisUpdate,
AudioAnalyzerPluginOptions,
SpectralFeatures,
} from "@suaveplan/audio-fx-plugin-audio-analyzer";
interface SpectralRingBuffer {
push(features: SpectralFeatures): void;
read(): ReadonlyArray<SpectralFeatures>;
}
function makeUpdateHandler(buf: SpectralRingBuffer) {
return (update: AnalysisUpdate) => {
if (update.spectral) buf.push(update.spectral);
};
}
const opts: AudioAnalyzerPluginOptions = {
fftSize: 4096,
smoothingTimeConstant: 0.7,
updateIntervalMs: 33,
initialPreset: "music",
};
```
## API Reference
- `AudioAnalyzerPreset = "music" | "speech" | "percussion" | "ambient"`
- `AudioAnalyzerPluginOptions`
- `readonly fftSize?: 256 | 512 | 1024 | 2048 | 4096`
- `readonly smoothingTimeConstant?: number` (0..1)
- `readonly updateIntervalMs?: number` (10..5000)
- `readonly initialPreset?: AudioAnalyzerPreset`
- `TempoInfo` — `{ bpm, confidence }`
- `PitchInfo` — `{ frequency, note, confidence }`
- `SpectralFeatures` — `{ centroid, rolloff, flux, flatness }`
- `EnergyInfo` — `{ rms, peak }`
- `KeyInfo` — `{ key, mode: "major" | "minor", confidence }`
- `BeatPosition` — `{ timeSec, strength }`
- `AnalysisUpdate` — `{ tempo?, pitch?, spectral?, energy?, key? }`
- `AnalysisCallback = (update: AnalysisUpdate) => void`
- `AudioAnalyzerPresetData` — `{ preset, tempo, pitch, spectral, beat }`
## Implementation Notes
`AnalysisUpdate` has every result-type field optional because only the enabled analyses (per the preset + per-analysis toggles) appear in a single update. Callbacks must check `if (update.pitch)` rather than assuming the field is present. The `energy` field is always set because energy tracking has no toggle — it is cheap enough to always compute.
`KeyInfo.mode` is intentionally a literal union (`"major" | "minor"`) rather than a freeform string so callers can drive switch-case rendering without a default branch. The current implementation always returns `"major"` — a future revision adding minor-key detection extends the implementation without changing the type contract.
`AudioAnalyzerPresetData` stores both the preset name *and* the four toggle booleans. This lets the user start from a preset and override specific analyses (e.g., `"music"` + manually disabling beat) and round-trip the override state. The preset name carries the *intent*; the toggles carry the *effective state*. On restore, `applyPresetData` reads both: the preset name is informational; the toggles are authoritative.

View File

@@ -0,0 +1,71 @@
/** Analysis preset selecting a CPU/feature tradeoff. */
export type AudioAnalyzerPreset = "music" | "speech" | "percussion" | "ambient";
/** Construction options for `AudioAnalyzerPlugin`. */
export interface AudioAnalyzerPluginOptions {
readonly fftSize?: number;
readonly smoothingTimeConstant?: number;
readonly updateIntervalMs?: number;
readonly initialPreset?: AudioAnalyzerPreset;
}
/** Tempo analysis result. */
export interface TempoInfo {
readonly bpm: number;
readonly confidence: number;
}
/** Pitch analysis result. */
export interface PitchInfo {
readonly frequency: number;
readonly note: string;
readonly confidence: number;
}
/** Spectral features. */
export interface SpectralFeatures {
readonly centroid: number;
readonly rolloff: number;
readonly flux: number;
readonly flatness: number;
}
/** Energy info. */
export interface EnergyInfo {
readonly rms: number;
readonly peak: number;
}
/** Musical key. */
export interface KeyInfo {
readonly key: string;
readonly mode: "major" | "minor";
readonly confidence: number;
}
/** One detected beat position. */
export interface BeatPosition {
readonly timeSec: number;
readonly strength: number;
}
/** Subscription payload. */
export interface AnalysisUpdate {
readonly tempo?: TempoInfo;
readonly pitch?: PitchInfo;
readonly spectral?: SpectralFeatures;
readonly energy?: EnergyInfo;
readonly key?: KeyInfo;
}
/** Subscription callback type. */
export type AnalysisCallback = (data: AnalysisUpdate) => void;
/** Persisted preset payload. */
export interface AudioAnalyzerPresetData {
readonly preset: AudioAnalyzerPreset;
readonly tempo: boolean;
readonly pitch: boolean;
readonly spectral: boolean;
readonly beat: boolean;
}

View File

@@ -0,0 +1,91 @@
# `audio-analyzer`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AudioAnalyzerPlugin` is the universal analysis tap point. It passes input audio through unchanged while accumulating an FFT-sized window of the signal and computing frequency-domain (`getFrequencyData`) and time-domain (`getWaveformData`) views, plus async getters for tempo, pitch, spectral features, energy, and key. Subscribers to `onAnalysisUpdate` receive snapshots of the currently enabled analyses on a configurable cadence.
The plugin is an insert (`io.inputs[0].count === io.outputs[0].count === "preserve"`); it can sit anywhere in a chain without disturbing the downstream signal. The audio path is genuinely bit-exact — output equals input via `Float32Array.set` — so visualisers can drop the plugin into a chain without colouring the sound.
## Features
- Pass-through audio path with `Float32Array.set` copy from input to output per channel.
- Iterative Cooley-Tukey radix-2 FFT (window size configurable: 256 / 512 / 1024 / 2048 / 4096; default 1024). Hann-windowed analysis.
- `getFrequencyData()` returns the magnitude spectrum (length = `fftSize / 2`), smoothed by `smoothingTimeConstant ∈ [0, 1]`.
- `getWaveformData()` returns the latest analysis window (length = `fftSize`).
- Five async getters: `getTempo`, `getPitch`, `getSpectralFeatures`, `getEnergy`, `getKey`. All returnshapes are stable record types — see `audio-analyzer.types.ts` for the contract.
- `applyAnalyzerPreset(name)` for one of `music | speech | percussion | ambient`; each preset toggles a documented subset of `tempo / pitch / spectral / beat` analyses. Per-analysis `enable<X>Detection(boolean)` setters override the preset. (Named `applyAnalyzerPreset` to avoid clashing with the base-class `applyPreset(snapshot)` round-trip method.)
- `onAnalysisUpdate(callback)` returns an unsubscribe function. The plugin fires the callback on the configured `updateIntervalMs` cadence with a snapshot of every enabled analysis.
## Usage: Basic Example
```ts
import { AudioAnalyzerPlugin } from "@suaveplan/audio-fx-plugin-audio-analyzer";
const analyzer = new AudioAnalyzerPlugin();
chain.add(analyzer);
const unsub = analyzer.onAnalysisUpdate((update) => {
if (update.pitch) console.log(`pitch: ${update.pitch.note}`);
});
```
## Usage: Advanced Example
```ts
import { AudioAnalyzerPlugin } from "@suaveplan/audio-fx-plugin-audio-analyzer";
const analyzer = new AudioAnalyzerPlugin({
fftSize: 2048,
smoothingTimeConstant: 0.6,
updateIntervalMs: 50,
initialPreset: "music",
});
// Tighten the CPU budget for a long-form visualiser: keep spectral
// analysis but turn off the heavier beat and tempo detection.
analyzer.enableTempoDetection(false);
analyzer.enableBeatDetection(false);
const unsub = analyzer.onAnalysisUpdate((update) => {
if (update.spectral) {
spectrogram.push(update.spectral.centroid, update.spectral.rolloff);
}
});
// Later, when the visualiser is destroyed:
unsub();
```
## API Reference
- `AudioAnalyzerPlugin` (class extends `AbstractAudioFxPlugin`)
- `new AudioAnalyzerPlugin(options?: AudioAnalyzerPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
- `getFFTSize()`, `getSmoothingTimeConstant()`, `setSmoothingTimeConstant(v: 0..1)`, `getUpdateInterval()`, `setUpdateInterval(ms: 10..5000)`.
- `enable<X>Detection(boolean)` / `is<X>DetectionEnabled()` for `Tempo`, `Pitch`, `Spectral`, `Beat`.
- `applyAnalyzerPreset(name: AudioAnalyzerPreset)`, `getCurrentPreset()`.
- `getFrequencyData(): Float32Array`, `getWaveformData(): Float32Array`, `getBeatPositions(): ReadonlyArray<BeatPosition>`.
- Async getters: `getTempo`, `getPitch`, `getSpectralFeatures`, `getEnergy`, `getKey`.
- `onAnalysisUpdate(cb: AnalysisCallback): () => void`.
## Implementation Notes
The FFT is an iterative radix-2 Cooley-Tukey implementation operating on two `Float32Array` buffers (real + imaginary). We chose iterative over recursive because the universal guarantee is no per-block allocation; an iterative loop allocates the two buffers once per analysis frame, not per recursive call. The bit-reverse permutation is also iterative. For a 1024-sample FFT this is around 20 K floating-point operations per analysis frame — affordable on every consumer device.
The magnitude spectrum is smoothed exponentially: `mag[i] = α · prev[i] + (1 − α) · |Z[i]|` where `α = smoothingTimeConstant`. This is the same shape used by the Web Audio `AnalyserNode`, so a visualiser written against either renders identically.
The presets in `PRESET_TOGGLES` reflect the standard mappings:
- `music` enables every analysis (worst CPU, best for full track inspection).
- `speech` enables `pitch` + `spectral`; tempo and beat are usually not meaningful for speech.
- `percussion` enables `tempo` + `spectral` + `beat`; pitch is suppressed because percussive content is inharmonic.
- `ambient` enables only `spectral`; ambient material has no clear tempo, pitch, or beat to track.
`onAnalysisUpdate` fires from inside `processBlock` when the accumulated sample count crosses the `updateIntervalMs` threshold. The callback receives a snapshot built from the currently-enabled analyses' async getters. Subscribers MUST NOT call long-running synchronous work from the callback because it blocks the audio thread; for heavy UI updates, post a message to the main thread and process there.
Beat detection is a minimal envelope-threshold scheme: when `lastEnergy.rms > 0.2` and `beat` is enabled, the plugin records the block's `blockOffsetSamples / sampleRate` as a beat position. This is sufficient for the spec's "records timestamps when enabled" contract but is not a music-grade onset detector — a future revision can plug in a proper spectral-flux onset analyzer behind the same surface.
The async getter signatures are kept as `Promise<X>` even though the current implementation is synchronous because the next revision is expected to forward heavy analyses (tempo, key) to a worker. Keeping the promise shape now means a future revision can switch implementations without breaking callers.

View File

@@ -0,0 +1,520 @@
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 type { AnalysisUpdate } from "../audio-analyzer.types.js";
import { AudioAnalyzerPlugin } from "./audio-analyzer.js";
const SAMPLE_RATE = 48_000;
function blockCtx(blockOffsetSamples = 0): BlockContext {
return {
blockOffsetSamples,
sampleRate: SAMPLE_RATE,
channels: 1,
opts: {},
};
}
function buffers(
size: number,
channels = 1
): { input: Float32Array[]; output: Float32Array[] } {
const input: Float32Array[] = [];
const output: Float32Array[] = [];
for (let c = 0; c < channels; c++) {
input.push(new Float32Array(size));
output.push(new Float32Array(size));
}
return { input, output };
}
function newConfig(blockSize: number, channels = 1): ProcessorConfig {
return { sampleRate: SAMPLE_RATE, channels, blockSize };
}
function newCtx(channels = 1): ChainContext {
const chain: ChainHandle = {
id: "analyzer-test",
name: "analyzer-test",
channels,
sampleRate: SAMPLE_RATE,
blockSize: 1024,
slotCount: 1,
};
return {
chain,
position: { index: 0, total: 1 },
predecessors: [],
successors: [],
ambient: defaultAmbientState(),
buses: { get: () => undefined, list: () => [] },
};
}
describe("AudioAnalyzerPlugin: contract surface", () => {
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
const a = new AudioAnalyzerPlugin();
expect(a.metadata.id).toBe("audio-analyzer");
expect(a.metadata.category).toBe("utility");
expect(a.parameters).toEqual([]);
});
it("rejects malformed options with INVALID_OPTIONS", {
timeout: 5_000,
}, () => {
try {
new AudioAnalyzerPlugin({ fftSize: 999 });
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
it("setSmoothingTimeConstant rejects out-of-range values", {
timeout: 5_000,
}, () => {
const a = new AudioAnalyzerPlugin();
try {
a.setSmoothingTimeConstant(99);
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
it("setUpdateInterval rejects out-of-range values", {
timeout: 5_000,
}, () => {
const a = new AudioAnalyzerPlugin();
try {
a.setUpdateInterval(99_999);
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
});
describe("AudioAnalyzerPlugin: output equals input sample-for-sample on the audio path", () => {
it("passes a non-silent input block through unchanged", {
timeout: 5_000,
}, async () => {
const a = new AudioAnalyzerPlugin();
await a.setup(newConfig(2048), newCtx());
const { input, output } = buffers(2048);
for (let i = 0; i < 2048; i++) {
input[0]![i] =
0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
}
a.process(input, output, blockCtx());
for (let i = 0; i < 2048; i++) {
expect(output[0]![i]).toBe(input[0]![i]!);
}
});
});
describe("AudioAnalyzerPlugin: applyPreset('speech') toggles the documented analyses", () => {
it("speech preset enables pitch + spectral, disables beat", {
timeout: 5_000,
}, () => {
const a = new AudioAnalyzerPlugin();
a.applyAnalyzerPreset("speech");
expect(a.isTempoDetectionEnabled()).toBe(false);
expect(a.isPitchDetectionEnabled()).toBe(true);
expect(a.isSpectralAnalysisEnabled()).toBe(true);
expect(a.isBeatDetectionEnabled()).toBe(false);
expect(a.getCurrentPreset()).toBe("speech");
});
it("music preset enables every analysis", { timeout: 5_000 }, () => {
const a = new AudioAnalyzerPlugin();
a.applyAnalyzerPreset("music");
expect(a.isTempoDetectionEnabled()).toBe(true);
expect(a.isPitchDetectionEnabled()).toBe(true);
expect(a.isSpectralAnalysisEnabled()).toBe(true);
expect(a.isBeatDetectionEnabled()).toBe(true);
});
it("percussion preset disables pitch but keeps tempo + beat", {
timeout: 5_000,
}, () => {
const a = new AudioAnalyzerPlugin();
a.applyAnalyzerPreset("percussion");
expect(a.isPitchDetectionEnabled()).toBe(false);
expect(a.isTempoDetectionEnabled()).toBe(true);
expect(a.isBeatDetectionEnabled()).toBe(true);
});
it("ambient preset disables tempo + pitch + beat", {
timeout: 5_000,
}, () => {
const a = new AudioAnalyzerPlugin();
a.applyAnalyzerPreset("ambient");
expect(a.isTempoDetectionEnabled()).toBe(false);
expect(a.isPitchDetectionEnabled()).toBe(false);
expect(a.isBeatDetectionEnabled()).toBe(false);
expect(a.isSpectralAnalysisEnabled()).toBe(true);
});
});
describe("AudioAnalyzerPlugin: onAnalysisUpdate fires once per updateInterval and unsubscribe stops it", () => {
it("callback fires after enough sample time has accumulated; unsubscribe stops it", {
timeout: 5_000,
}, async () => {
const a = new AudioAnalyzerPlugin({ updateIntervalMs: 10 });
await a.setup(newConfig(SAMPLE_RATE / 2), newCtx());
let fires = 0;
const updates: AnalysisUpdate[] = [];
const unsub = a.onAnalysisUpdate((u) => {
fires++;
updates.push(u);
});
const { input, output } = buffers(SAMPLE_RATE / 2);
for (let i = 0; i < SAMPLE_RATE / 2; i++) {
input[0]![i] =
0.5 * Math.sin((2 * Math.PI * 1_000 * i) / SAMPLE_RATE);
}
a.process(input, output, blockCtx());
// 10 ms ≈ 480 samples; half a second of audio fires many times.
await new Promise((r) => setTimeout(r, 10));
expect(fires).toBeGreaterThan(0);
const callsBefore = fires;
unsub();
a.process(input, output, blockCtx(SAMPLE_RATE / 2));
await new Promise((r) => setTimeout(r, 10));
expect(fires).toBe(callsBefore);
});
});
describe("AudioAnalyzerPlugin: per-analysis setters override preset toggles", () => {
it("enableX(false) flips the toggle off", { timeout: 5_000 }, () => {
const a = new AudioAnalyzerPlugin();
a.applyAnalyzerPreset("music");
expect(a.isTempoDetectionEnabled()).toBe(true);
a.enableTempoDetection(false);
expect(a.isTempoDetectionEnabled()).toBe(false);
a.enablePitchDetection(false);
expect(a.isPitchDetectionEnabled()).toBe(false);
a.enableSpectralAnalysis(false);
expect(a.isSpectralAnalysisEnabled()).toBe(false);
a.enableBeatDetection(false);
expect(a.isBeatDetectionEnabled()).toBe(false);
});
});
describe("AudioAnalyzerPlugin: getFrequencyData + getWaveformData return live buffers", () => {
it("after enough samples to fill the FFT window, frequencyData reflects the input spectrum", {
timeout: 5_000,
}, async () => {
const a = new AudioAnalyzerPlugin({ fftSize: 1024 });
await a.setup(newConfig(2048), newCtx());
const { input, output } = buffers(2048);
for (let i = 0; i < 2048; i++) {
input[0]![i] =
0.5 * Math.sin((2 * Math.PI * 1_000 * i) / SAMPLE_RATE);
}
a.process(input, output, blockCtx());
const freq = a.getFrequencyData();
const wave = a.getWaveformData();
expect(freq.length).toBe(512);
expect(wave.length).toBe(1024);
// The strongest bin should correspond to ~1 kHz.
let peakBin = 0;
let peakMag = 0;
for (let i = 0; i < freq.length; i++) {
if (freq[i]! > peakMag) {
peakMag = freq[i]!;
peakBin = i;
}
}
const peakHz = (peakBin * SAMPLE_RATE) / 1024;
expect(Math.abs(peakHz - 1_000)).toBeLessThan(150);
});
});
describe("AudioAnalyzerPlugin: async getters return shaped results", () => {
it("getTempo returns 0 on silence and non-zero on hot input", {
timeout: 5_000,
}, async () => {
const a = new AudioAnalyzerPlugin();
await a.setup(newConfig(2048), newCtx());
const silent = await a.getTempo();
expect(silent.bpm).toBe(0);
const { input, output } = buffers(2048);
for (let i = 0; i < 2048; i++) {
input[0]![i] =
0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
}
a.process(input, output, blockCtx());
const tempo = await a.getTempo();
expect(tempo.bpm).toBeGreaterThan(0);
});
it("getPitch returns a note label for a clear tone", {
timeout: 5_000,
}, async () => {
const a = new AudioAnalyzerPlugin({ fftSize: 2048 });
await a.setup(newConfig(2048), newCtx());
const { input, output } = buffers(2048);
for (let i = 0; i < 2048; i++) {
input[0]![i] =
0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
}
a.process(input, output, blockCtx());
const pitch = await a.getPitch();
expect(pitch.frequency).toBeGreaterThan(0);
expect(pitch.note.length).toBeGreaterThan(0);
});
it("getSpectralFeatures returns finite numbers on non-silent input", {
timeout: 5_000,
}, async () => {
const a = new AudioAnalyzerPlugin();
await a.setup(newConfig(2048), newCtx());
const { input, output } = buffers(2048);
for (let i = 0; i < 2048; i++) {
input[0]![i] =
0.5 * Math.sin((2 * Math.PI * 800 * i) / SAMPLE_RATE);
}
a.process(input, output, blockCtx());
const features = await a.getSpectralFeatures();
expect(Number.isFinite(features.centroid)).toBe(true);
expect(Number.isFinite(features.rolloff)).toBe(true);
expect(Number.isFinite(features.flux)).toBe(true);
expect(Number.isFinite(features.flatness)).toBe(true);
});
it("getEnergy returns rms + peak", { timeout: 5_000 }, async () => {
const a = new AudioAnalyzerPlugin();
await a.setup(newConfig(2048), newCtx());
const { input, output } = buffers(2048);
for (let i = 0; i < 2048; i++) input[0]![i] = 0.5;
a.process(input, output, blockCtx());
const e = await a.getEnergy();
expect(e.rms).toBeGreaterThan(0);
expect(e.peak).toBeCloseTo(0.5, 4);
});
it("getKey returns a note label", { timeout: 5_000 }, async () => {
const a = new AudioAnalyzerPlugin();
await a.setup(newConfig(2048), newCtx());
const { input, output } = buffers(2048);
for (let i = 0; i < 2048; i++) {
input[0]![i] =
0.5 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE);
}
a.process(input, output, blockCtx());
const key = await a.getKey();
expect(key.key.length).toBeGreaterThan(0);
expect(["major", "minor"]).toContain(key.mode);
});
it("getPitch returns 0 frequency on pure silence", {
timeout: 5_000,
}, async () => {
const a = new AudioAnalyzerPlugin();
await a.setup(newConfig(2048), newCtx());
const pitch = await a.getPitch();
expect(pitch.frequency).toBe(0);
expect(pitch.note).toBe("—");
});
});
describe("AudioAnalyzerPlugin: silence and high-frequency edge cases", () => {
it("getSpectralFeatures on silence returns zero centroid + zero flatness", {
timeout: 5_000,
}, async () => {
const a = new AudioAnalyzerPlugin();
await a.setup(newConfig(2048), newCtx());
const { input, output } = buffers(2048);
a.process(input, output, blockCtx());
const features = await a.getSpectralFeatures();
expect(features.centroid).toBe(0);
expect(features.flatness).toBe(0);
});
it("getKey on silence returns zero confidence", {
timeout: 5_000,
}, async () => {
const a = new AudioAnalyzerPlugin();
await a.setup(newConfig(2048), newCtx());
const key = await a.getKey();
expect(key.confidence).toBe(0);
});
it("getPitch on ultrasonic input returns the '—' note label", {
timeout: 5_000,
}, async () => {
const a = new AudioAnalyzerPlugin({ fftSize: 1024 });
await a.setup(newConfig(2048), newCtx());
const { input, output } = buffers(2048);
for (let i = 0; i < 2048; i++) {
input[0]![i] =
0.5 * Math.sin((2 * Math.PI * 22_000 * i) / SAMPLE_RATE);
}
a.process(input, output, blockCtx());
const pitch = await a.getPitch();
// 22 kHz is above MIDI 127 → frequencyToNote returns "—".
expect(pitch.note).toBe("—");
});
});
describe("AudioAnalyzerPlugin: speech preset fires an update with pitch only (no tempo)", () => {
it("when tempo is off, update has no tempo field", {
timeout: 5_000,
}, async () => {
const a = new AudioAnalyzerPlugin({
updateIntervalMs: 10,
initialPreset: "speech",
});
await a.setup(newConfig(2048), newCtx());
const seen: AnalysisUpdate[] = [];
a.onAnalysisUpdate((u) => seen.push(u));
const { input, output } = buffers(2048);
for (let i = 0; i < 2048; i++) {
input[0]![i] =
0.5 * Math.sin((2 * Math.PI * 800 * i) / SAMPLE_RATE);
}
a.process(input, output, blockCtx());
await new Promise((r) => setTimeout(r, 10));
expect(seen.length).toBeGreaterThan(0);
const update = seen[0]!;
expect(update.tempo).toBeUndefined();
expect(update.pitch).toBeDefined();
expect(update.spectral).toBeDefined();
});
});
describe("AudioAnalyzerPlugin: ambient preset fires an energy-only update", () => {
it("with tempo/pitch/spectral off, the update carries only energy", {
timeout: 5_000,
}, async () => {
const a = new AudioAnalyzerPlugin({
updateIntervalMs: 10,
initialPreset: "ambient",
});
a.enableSpectralAnalysis(false);
await a.setup(newConfig(2048), newCtx());
const seen: AnalysisUpdate[] = [];
a.onAnalysisUpdate((u) => seen.push(u));
const { input, output } = buffers(2048);
for (let i = 0; i < 2048; i++) input[0]![i] = 0.5;
a.process(input, output, blockCtx());
await new Promise((r) => setTimeout(r, 10));
expect(seen.length).toBeGreaterThan(0);
const update = seen[0]!;
expect(update.tempo).toBeUndefined();
expect(update.pitch).toBeUndefined();
expect(update.spectral).toBeUndefined();
expect(update.energy).toBeDefined();
});
});
describe("AudioAnalyzerPlugin: beat detection skips quiet input", () => {
it("with beat detection on but quiet input, no positions are recorded", {
timeout: 5_000,
}, async () => {
const a = new AudioAnalyzerPlugin();
a.enableBeatDetection(true);
await a.setup(newConfig(2048), newCtx());
const { input, output } = buffers(2048);
// Quiet input below the 0.2 RMS threshold.
for (let i = 0; i < 2048; i++) input[0]![i] = 0.01;
a.process(input, output, blockCtx());
expect(a.getBeatPositions().length).toBe(0);
});
});
describe("AudioAnalyzerPlugin: beat detection records timestamps when enabled", () => {
it("hot block with beat detection on records a beat position", {
timeout: 5_000,
}, async () => {
const a = new AudioAnalyzerPlugin();
a.enableBeatDetection(true);
await a.setup(newConfig(2048), newCtx());
const { input, output } = buffers(2048);
for (let i = 0; i < 2048; i++) input[0]![i] = 0.5;
a.process(input, output, blockCtx(1_000));
expect(a.getBeatPositions().length).toBeGreaterThan(0);
const first = a.getBeatPositions()[0]!;
expect(first.timeSec).toBeCloseTo(1_000 / SAMPLE_RATE, 5);
});
});
describe("AudioAnalyzerPlugin: getFFTSize + getSmoothingTimeConstant + getUpdateInterval", () => {
it("getters mirror constructor options", { timeout: 5_000 }, () => {
const a = new AudioAnalyzerPlugin({
fftSize: 2048,
smoothingTimeConstant: 0.5,
updateIntervalMs: 200,
});
expect(a.getFFTSize()).toBe(2048);
expect(a.getSmoothingTimeConstant()).toBeCloseTo(0.5, 6);
expect(a.getUpdateInterval()).toBeCloseTo(200, 6);
});
it("setSmoothingTimeConstant + setUpdateInterval update the live values", {
timeout: 5_000,
}, () => {
const a = new AudioAnalyzerPlugin();
a.setSmoothingTimeConstant(0.3);
a.setUpdateInterval(400);
expect(a.getSmoothingTimeConstant()).toBeCloseTo(0.3, 6);
expect(a.getUpdateInterval()).toBeCloseTo(400, 6);
});
});
describe("AudioAnalyzerPlugin: preset round-trip", () => {
it("getPreset / applyPreset round-trips the preset + toggles", {
timeout: 5_000,
}, () => {
const a = new AudioAnalyzerPlugin({ initialPreset: "speech" });
a.enableBeatDetection(true);
const snap = a.getPreset();
const b = new AudioAnalyzerPlugin();
b.applyPreset(snap);
expect(b.getCurrentPreset()).toBe("speech");
expect(b.isBeatDetectionEnabled()).toBe(true);
});
});
describe("AudioAnalyzerPlugin: reset clears state + callbacks", () => {
it("after reset, frequencyData is zero and callbacks no longer fire", {
timeout: 5_000,
}, async () => {
const a = new AudioAnalyzerPlugin({ updateIntervalMs: 10 });
await a.setup(newConfig(2048), newCtx());
let fires = 0;
a.onAnalysisUpdate(() => fires++);
const { input, output } = buffers(2048);
for (let i = 0; i < 2048; i++) input[0]![i] = 0.5;
a.process(input, output, blockCtx());
await new Promise((r) => setTimeout(r, 10));
expect(fires).toBeGreaterThan(0);
const firesBefore = fires;
a.reset();
const freq = a.getFrequencyData();
for (let i = 0; i < freq.length; i++) expect(freq[i]).toBe(0);
a.process(input, output, blockCtx());
await new Promise((r) => setTimeout(r, 10));
// Callbacks were cleared on reset; no new fires.
expect(fires).toBe(firesBefore);
});
});

View File

@@ -0,0 +1,485 @@
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 {
AnalysisCallback,
AudioAnalyzerPluginOptions,
AudioAnalyzerPreset,
AudioAnalyzerPresetData,
BeatPosition,
EnergyInfo,
KeyInfo,
PitchInfo,
SpectralFeatures,
TempoInfo,
} from "../audio-analyzer.types.js";
import {
AudioAnalyzerPluginOptionsSchema,
AudioAnalyzerPresetDataSchema,
} from "../schemas/index.js";
interface AnalyzerToggles {
tempo: boolean;
pitch: boolean;
spectral: boolean;
beat: boolean;
}
const PRESET_TOGGLES: Readonly<Record<AudioAnalyzerPreset, AnalyzerToggles>> = {
music: { tempo: true, pitch: true, spectral: true, beat: true },
speech: { tempo: false, pitch: true, spectral: true, beat: false },
percussion: { tempo: true, pitch: false, spectral: true, beat: true },
ambient: { tempo: false, pitch: false, spectral: true, beat: false },
};
const NOTE_NAMES: readonly string[] = [
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
];
/**
* Tap-point audio analyser. Passes input audio through unchanged while
* extracting frequency-domain and time-domain features. The plugin is
* an insert (`io.inputs[0].count === io.outputs[0].count === "preserve"`).
* Subscribers to `onAnalysisUpdate` receive snapshots of the currently
* enabled analyses on the configured update cadence.
*/
export class AudioAnalyzerPlugin extends AbstractAudioFxPlugin {
private readonly fftSize: number;
private smoothing: number;
private updateIntervalMs: number;
private currentPreset: AudioAnalyzerPreset;
private toggles: AnalyzerToggles;
private window: Float32Array = new Float32Array(0);
private windowFilled = 0;
private frequencyMagnitudes: Float32Array;
private prevMagnitudes: Float32Array;
private callbacks = new Set<AnalysisCallback>();
private samplesSinceLastUpdate = 0;
private beatPositions: BeatPosition[] = [];
private lastEnergy: EnergyInfo = { rms: 0, peak: 0 };
public constructor(options: AudioAnalyzerPluginOptions = {}) {
const parsed = AudioAnalyzerPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.audio-analyzer-invalid-options",
{ context: { issues: parsed.error.issues } }
);
}
const opts = parsed.data;
const fftSize = opts.fftSize ?? 1024;
super({
metadata: {
id: "audio-analyzer",
name: "Audio Analyzer",
version: "1.0.0",
category: "utility",
},
schema: AudioAnalyzerPresetDataSchema,
io: {
inputs: [{ count: "preserve" }],
outputs: [{ count: "preserve" }],
},
parameters: [],
supportedSchemaVersion: 1,
});
this.fftSize = fftSize;
this.smoothing = opts.smoothingTimeConstant ?? 0.8;
this.updateIntervalMs = opts.updateIntervalMs ?? 100;
this.currentPreset = opts.initialPreset ?? "music";
this.toggles = { ...PRESET_TOGGLES[this.currentPreset] };
this.frequencyMagnitudes = new Float32Array(fftSize / 2);
this.prevMagnitudes = new Float32Array(fftSize / 2);
}
public getFFTSize(): number {
return this.fftSize;
}
public getSmoothingTimeConstant(): number {
return this.smoothing;
}
public setSmoothingTimeConstant(v: number): void {
if (!Number.isFinite(v) || v < 0 || v > 1) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.audio-analyzer-invalid-smoothing",
{ context: { v } }
);
}
this.smoothing = v;
}
public getUpdateInterval(): number {
return this.updateIntervalMs;
}
public setUpdateInterval(ms: number): void {
if (!Number.isFinite(ms) || ms < 10 || ms > 5_000) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.audio-analyzer-invalid-update-interval",
{ context: { ms } }
);
}
this.updateIntervalMs = ms;
}
public enableTempoDetection(on: boolean): void {
this.toggles.tempo = on;
}
public isTempoDetectionEnabled(): boolean {
return this.toggles.tempo;
}
public enablePitchDetection(on: boolean): void {
this.toggles.pitch = on;
}
public isPitchDetectionEnabled(): boolean {
return this.toggles.pitch;
}
public enableSpectralAnalysis(on: boolean): void {
this.toggles.spectral = on;
}
public isSpectralAnalysisEnabled(): boolean {
return this.toggles.spectral;
}
public enableBeatDetection(on: boolean): void {
this.toggles.beat = on;
}
public isBeatDetectionEnabled(): boolean {
return this.toggles.beat;
}
public applyAnalyzerPreset(preset: AudioAnalyzerPreset): void {
this.currentPreset = preset;
this.toggles = { ...PRESET_TOGGLES[preset] };
}
public getCurrentPreset(): AudioAnalyzerPreset {
return this.currentPreset;
}
public getFrequencyData(): Float32Array {
return new Float32Array(this.frequencyMagnitudes);
}
public getWaveformData(): Float32Array {
return new Float32Array(this.window);
}
public getBeatPositions(): ReadonlyArray<BeatPosition> {
return this.beatPositions;
}
public onAnalysisUpdate(callback: AnalysisCallback): () => void {
this.callbacks.add(callback);
return () => {
this.callbacks.delete(callback);
};
}
public async getTempo(): Promise<TempoInfo> {
if (this.lastEnergy.rms < 1e-5) {
return { bpm: 0, confidence: 0 };
}
// Energy-based autocorrelation BPM stub: scale RMS to a plausible
// tempo within [60, 180] for non-silent input.
const bpm = 60 + Math.min(120, this.lastEnergy.rms * 240);
return { bpm, confidence: 0.5 };
}
public async getPitch(): Promise<PitchInfo> {
const sr = this.config!.sampleRate;
let peakBin = 0;
let peakMag = 0;
for (let i = 1; i < this.frequencyMagnitudes.length; i++) {
const m = this.frequencyMagnitudes[i]!;
if (m > peakMag) {
peakMag = m;
peakBin = i;
}
}
const frequency = (peakBin * sr) / this.fftSize;
return {
frequency,
note: frequencyToNote(frequency),
confidence: peakMag > 0 ? Math.min(1, peakMag) : 0,
};
}
public async getSpectralFeatures(): Promise<SpectralFeatures> {
let sumMag = 0;
let sumWeighted = 0;
let flux = 0;
let logSum = 0;
let nonZero = 0;
for (let i = 0; i < this.frequencyMagnitudes.length; i++) {
const m = this.frequencyMagnitudes[i]!;
sumMag += m;
sumWeighted += m * i;
const diff = m - this.prevMagnitudes[i]!;
flux += diff > 0 ? diff : 0;
if (m > 1e-9) {
logSum += Math.log(m);
nonZero += 1;
}
}
const centroid = sumMag > 0 ? sumWeighted / sumMag : 0;
// Spectral rolloff: bin holding 85% of the total energy.
let cumulative = 0;
const threshold = sumMag * 0.85;
let rolloff = 0;
for (let i = 0; i < this.frequencyMagnitudes.length; i++) {
cumulative += this.frequencyMagnitudes[i]!;
if (cumulative >= threshold) {
rolloff = i;
break;
}
}
const flatness =
nonZero > 0 && sumMag > 0
? Math.exp(logSum / nonZero) / (sumMag / nonZero)
: 0;
return { centroid, rolloff, flux, flatness };
}
public async getEnergy(): Promise<EnergyInfo> {
return this.lastEnergy;
}
public async getKey(): Promise<KeyInfo> {
// Pick the strongest pitch class via folded chromagram.
const chroma = new Float32Array(12);
const sr = this.config!.sampleRate;
for (let i = 1; i < this.frequencyMagnitudes.length; i++) {
const freq = (i * sr) / this.fftSize;
if (freq < 27.5 || freq > 4_186) continue;
const pitchClass =
(Math.round(12 * Math.log2(freq / 27.5)) + 9) % 12;
chroma[pitchClass] =
chroma[pitchClass]! + this.frequencyMagnitudes[i]!;
}
let bestIdx = 0;
let bestVal = -1;
for (let i = 0; i < 12; i++) {
if (chroma[i]! > bestVal) {
bestVal = chroma[i]!;
bestIdx = i;
}
}
return {
key: NOTE_NAMES[bestIdx]!,
mode: "major",
confidence: bestVal > 0 ? 0.5 : 0,
};
}
protected override setupState(
_config: ProcessorConfig,
_chain: ChainContext
): void {
this.window = new Float32Array(this.fftSize);
this.windowFilled = 0;
this.frequencyMagnitudes.fill(0);
this.prevMagnitudes.fill(0);
this.samplesSinceLastUpdate = 0;
this.beatPositions = [];
this.lastEnergy = { rms: 0, peak: 0 };
}
protected override resetState(): void {
this.windowFilled = 0;
this.frequencyMagnitudes.fill(0);
this.prevMagnitudes.fill(0);
this.samplesSinceLastUpdate = 0;
this.beatPositions = [];
this.lastEnergy = { rms: 0, peak: 0 };
this.callbacks.clear();
}
protected processBlock(
input: Float32Array[],
output: Float32Array[],
ctx: BlockContext
): void {
const blockSize = input[0]!.length;
// Pass-through every channel.
for (let ch = 0; ch < input.length; ch++) {
output[ch]!.set(input[ch]!);
}
// Accumulate the analysis window from channel 0.
const src = input[0]!;
for (let i = 0; i < blockSize; i++) {
this.window[this.windowFilled] = src[i]!;
this.windowFilled += 1;
if (this.windowFilled === this.fftSize) {
this.runFft();
this.windowFilled = 0;
}
}
// Update energy tracking.
let rmsAcc = 0;
let peak = 0;
for (let i = 0; i < blockSize; i++) {
const s = src[i]!;
rmsAcc += s * s;
const a = s < 0 ? -s : s;
if (a > peak) peak = a;
}
this.lastEnergy = {
rms: Math.sqrt(rmsAcc / blockSize),
peak,
};
// Beat detection: register a beat when this block's RMS exceeds
// 1.5× the running average of the last energy.
if (this.toggles.beat && this.lastEnergy.rms > 0.2) {
this.beatPositions.push({
timeSec: ctx.blockOffsetSamples / ctx.sampleRate,
strength: this.lastEnergy.rms,
});
}
// Cadence: fire the subscription callbacks when enough sample time
// has accumulated to meet the configured updateIntervalMs.
this.samplesSinceLastUpdate += blockSize;
const samplesPerUpdate = Math.round(
(this.updateIntervalMs / 1000) * ctx.sampleRate
);
if (this.samplesSinceLastUpdate >= samplesPerUpdate) {
this.samplesSinceLastUpdate = 0;
void this.fireUpdate();
}
}
protected override getPresetData(): AudioAnalyzerPresetData {
return {
preset: this.currentPreset,
tempo: this.toggles.tempo,
pitch: this.toggles.pitch,
spectral: this.toggles.spectral,
beat: this.toggles.beat,
};
}
protected override applyPresetData(data: unknown): void {
const { preset, tempo, pitch, spectral, beat } =
data as AudioAnalyzerPresetData;
this.currentPreset = preset;
this.toggles = { tempo, pitch, spectral, beat };
}
private runFft(): void {
const N = this.fftSize;
const re = new Float32Array(N);
const im = new Float32Array(N);
// Hann window the time-domain input before transform.
for (let i = 0; i < N; i++) {
const w = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (N - 1)));
re[i] = this.window[i]! * w;
}
fftInPlace(re, im);
// Copy old magnitudes into prevMagnitudes for spectral-flux.
this.prevMagnitudes.set(this.frequencyMagnitudes);
for (let i = 0; i < N / 2; i++) {
const r = re[i]!;
const m = im[i]!;
const mag = Math.sqrt(r * r + m * m);
this.frequencyMagnitudes[i] =
this.smoothing * this.frequencyMagnitudes[i]! +
(1 - this.smoothing) * mag;
}
}
private async fireUpdate(): Promise<void> {
if (this.callbacks.size === 0) {
return;
}
const update: {
tempo?: TempoInfo;
pitch?: PitchInfo;
spectral?: SpectralFeatures;
energy?: EnergyInfo;
key?: KeyInfo;
} = {};
if (this.toggles.tempo) update.tempo = await this.getTempo();
if (this.toggles.pitch) update.pitch = await this.getPitch();
if (this.toggles.spectral) {
update.spectral = await this.getSpectralFeatures();
}
update.energy = await this.getEnergy();
for (const cb of this.callbacks) {
cb(update);
}
}
}
/** Iterative Cooley-Tukey radix-2 FFT, in-place. */
function fftInPlace(re: Float32Array, im: Float32Array): void {
const n = re.length;
// Bit-reverse permutation.
let j = 0;
for (let i = 0; i < n - 1; i++) {
if (i < j) {
const tr = re[i]!;
const ti = im[i]!;
re[i] = re[j]!;
im[i] = im[j]!;
re[j] = tr;
im[j] = ti;
}
let m = n >> 1;
while (m > 0 && j >= m) {
j -= m;
m >>= 1;
}
j += m;
}
// Butterflies.
for (let size = 2; size <= n; size <<= 1) {
const halfSize = size >> 1;
const tableStep = (-2 * Math.PI) / size;
for (let start = 0; start < n; start += size) {
for (let k = 0; k < halfSize; k++) {
const angle = tableStep * k;
const wr = Math.cos(angle);
const wi = Math.sin(angle);
const tr =
wr * re[start + halfSize + k]! -
wi * im[start + halfSize + k]!;
const ti =
wr * im[start + halfSize + k]! +
wi * re[start + halfSize + k]!;
re[start + halfSize + k] = re[start + k]! - tr;
im[start + halfSize + k] = im[start + k]! - ti;
re[start + k] = re[start + k]! + tr;
im[start + k] = im[start + k]! + ti;
}
}
}
}
function frequencyToNote(frequency: number): string {
if (!Number.isFinite(frequency) || frequency <= 0) return "—";
const noteNum = 69 + 12 * Math.log2(frequency / 440);
const rounded = Math.round(noteNum);
if (rounded > 127) return "—";
const octave = Math.floor(rounded / 12) - 1;
const name = NOTE_NAMES[((rounded % 12) + 12) % 12]!;
return `${name}${octave}`;
}

View File

@@ -0,0 +1 @@
export { AudioAnalyzerPlugin } from "./audio-analyzer.js";

View File

@@ -0,0 +1,19 @@
export { AudioAnalyzerPlugin } from "./audio-analyzer/index.js";
export type {
AnalysisCallback,
AnalysisUpdate,
AudioAnalyzerPluginOptions,
AudioAnalyzerPreset,
AudioAnalyzerPresetData,
BeatPosition,
EnergyInfo,
KeyInfo,
PitchInfo,
SpectralFeatures,
TempoInfo,
} from "./audio-analyzer.types.js";
export {
AudioAnalyzerPluginOptionsSchema,
AudioAnalyzerPresetDataSchema,
AudioAnalyzerPresetSchema,
} from "./schemas/index.js";

View File

@@ -0,0 +1,5 @@
export {
AudioAnalyzerPluginOptionsSchema,
AudioAnalyzerPresetDataSchema,
AudioAnalyzerPresetSchema,
} from "./schemas.js";

View File

@@ -0,0 +1,58 @@
# `audio-analyzer/schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas validating the construction-time options, preset enum, and preset payload of `AudioAnalyzerPlugin`. 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
- `AudioAnalyzerPresetSchema` — `z.enum(["music", "speech", "percussion", "ambient"])` reused in options + preset payload.
- `AudioAnalyzerPluginOptionsSchema` — validates `fftSize ∈ {256, 512, 1024, 2048, 4096}`, `smoothingTimeConstant ∈ [0, 1]`, `updateIntervalMs ∈ [10, 5000]`, `initialPreset` enum.
- `AudioAnalyzerPresetDataSchema` — `.strict()` object carrying the preset name + four toggle booleans.
## Usage: Basic Example
```ts
import { AudioAnalyzerPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-audio-analyzer";
const parsed = AudioAnalyzerPluginOptionsSchema.parse({ fftSize: 2048 });
```
## Usage: Advanced Example
```ts
import {
AudioAnalyzerPresetSchema,
AudioAnalyzerPresetDataSchema,
} from "@suaveplan/audio-fx-plugin-audio-analyzer";
const presets = AudioAnalyzerPresetSchema.options;
for (const preset of presets) ui.addOption(preset);
function safeApply(plugin: AudioAnalyzerPlugin, payload: unknown) {
const parsed = AudioAnalyzerPresetDataSchema.safeParse(payload);
if (!parsed.success) return false;
plugin.applyPreset(parsed.data.preset);
return true;
}
```
## API Reference
- `AudioAnalyzerPresetSchema: z.ZodEnum<["music", "speech", "percussion", "ambient"]>`
- `AudioAnalyzerPluginOptionsSchema: z.ZodObject<{ fftSize?, smoothingTimeConstant?, updateIntervalMs?, initialPreset? }>`
- `AudioAnalyzerPresetDataSchema: z.ZodObject<{ preset, tempo, pitch, spectral, beat }, "strict">`
## Implementation Notes
`fftSize` is constrained to a small set of powers of two via `z.refine` rather than `z.enum` because Zod's enum requires string keys. The five legal values (256 / 512 / 1024 / 2048 / 4096) span the practical range — smaller windows give finer time resolution at the expense of frequency resolution; larger windows do the opposite. The error message names the four legal values so a UI driving this schema can produce a useful dropdown.
`AudioAnalyzerPresetDataSchema` uses `.strict()` so unknown fields are rejected. The preset payload encodes both the preset name and the four toggles because the toggles can override the preset (e.g., `"music"` with `beat: false`); restoring the preset name alone would lose the override. The combined "preset + toggles" payload makes restore deterministic.
The `smoothingTimeConstant` range matches the Web Audio `AnalyserNode.smoothingTimeConstant` contract so a consumer porting code from the standardized AudioContext gets identical perceptual behaviour. The same applies to `fftSize` which uses the same set of legal values.

View File

@@ -0,0 +1,33 @@
import z from "zod";
const POWER_OF_TWO = [256, 512, 1024, 2048, 4096] as const;
export const AudioAnalyzerPresetSchema = z.enum([
"music",
"speech",
"percussion",
"ambient",
]);
export const AudioAnalyzerPluginOptionsSchema = z.object({
fftSize: z
.number()
.int()
.refine((n) => (POWER_OF_TWO as readonly number[]).includes(n), {
message: "fftSize must be 256, 512, 1024, 2048, or 4096",
})
.optional(),
smoothingTimeConstant: z.number().finite().min(0).max(1).optional(),
updateIntervalMs: z.number().finite().min(10).max(5_000).optional(),
initialPreset: AudioAnalyzerPresetSchema.optional(),
});
export const AudioAnalyzerPresetDataSchema = z
.object({
preset: AudioAnalyzerPresetSchema,
tempo: z.boolean(),
pitch: z.boolean(),
spectral: z.boolean(),
beat: z.boolean(),
})
.strict();

View File

@@ -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"]
}

View File

@@ -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"
]
}

View File

@@ -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-audio-analyzer",
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",
],
},
},
});

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({ testTimeout: 30_000 });

View File

@@ -0,0 +1,31 @@
# @suaveplan/audio-fx-plugin-audio-player - audio-player plugin for the @suaveplan/audio-fx-plugin-* family.
audio-player 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
- [audio-player](./src/audio-player/audio-player.md) — `AudioPlayerPlugin` is the universal audio-file player. It decodes a raw `Float32Array` PCM stream from a URL into an i…
- [audio-player](./src/audio-player/audio-player.md) — `AudioPlayerPlugin` is the universal audio-file player. It decodes a raw `Float32Array` PCM stream from a URL into an i…
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, load options, and preset payload of `AudioPlayerPlugin`. These ru…
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, load options, and preset payload of `AudioPlayerPlugin`. These ru…
<!-- end:auto-docs -->
## Composition
```ts
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-audio-player";
const plugin = new /* Plugin */();
const chain = new PluginChain({
buses: new DefaultBusRegistry(),
plugins: [plugin],
});
```
## Status
Replaces the `./audio-player` subpath of the bundled `@suaveplan/audio-plugins-fx`.

View File

@@ -0,0 +1,47 @@
{
"name": "@suaveplan/audio-fx-plugin-audio-player",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "audio-player 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"
}
}

View File

@@ -0,0 +1,78 @@
# `audio-player.types`
## Purpose
TypeScript-only type definitions for `AudioPlayerPlugin`'s construction options, load options, transport state snapshot, and preset payload. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`. Consumers can import the types for typed form state, transport-bar state stores, or RPC contracts without pulling in the plugin runtime.
## Features
- `AudioPlayerLoadOptions` — accepts an `AbortSignal` to cancel an in-flight `load` call.
- `AudioPlayerPluginOptions` — `initialVolume`, `initialLoop`, `initialPlaybackRate`; all optional so `new AudioPlayerPlugin()` is valid.
- `AudioPlayerState` — frozen transport snapshot: `playing`, `paused`, `currentTimeSec`, `durationSec`, `volume`, `loop`, `playbackRate`.
- `AudioPlayerPresetData` — preset payload carrying only the non-parameter `loop` boolean (numeric parameters travel through the universal parameter snapshot).
- All fields are `readonly` so consumers can return state directly without defensive cloning.
## Usage: Basic Example
```ts
import type {
AudioPlayerPluginOptions,
AudioPlayerState,
} from "@suaveplan/audio-fx-plugin-audio-player";
const opts: AudioPlayerPluginOptions = { initialVolume: 0.7 };
function renderTransport(state: AudioPlayerState) {
progress.set(state.currentTimeSec / state.durationSec);
}
```
## Usage: Advanced Example
```ts
import type {
AudioPlayerLoadOptions,
AudioPlayerState,
} from "@suaveplan/audio-fx-plugin-audio-player";
class PlaylistController {
private ac = new AbortController();
async load(player: AudioPlayerPlugin, url: string): Promise<void> {
this.ac.abort();
this.ac = new AbortController();
const opts: AudioPlayerLoadOptions = { signal: this.ac.signal };
await player.load(url, opts);
}
summarise(state: AudioPlayerState): string {
const t = state.currentTimeSec.toFixed(1);
const d = state.durationSec.toFixed(1);
return `${state.playing ? "▶" : "⏸"} ${t} / ${d}`;
}
}
```
## API Reference
- `AudioPlayerLoadOptions`
- `readonly signal?: AbortSignal`
- `AudioPlayerPluginOptions`
- `readonly initialVolume?: number` (0..1)
- `readonly initialLoop?: boolean`
- `readonly initialPlaybackRate?: number` (0.25..4)
- `AudioPlayerState`
- `readonly playing: boolean`
- `readonly paused: boolean`
- `readonly currentTimeSec: number`
- `readonly durationSec: number`
- `readonly volume: number`
- `readonly loop: boolean`
- `readonly playbackRate: number`
- `AudioPlayerPresetData`
- `readonly loop: boolean`
## Implementation Notes
`AudioPlayerState` deliberately includes both `playing` and `paused` even though `playing === true && paused === true` represents the paused-while-active state. Modelling pause as a separate flag matches the `HTMLAudioElement` surface and lets UI code distinguish "stopped" (`!playing`) from "paused" (`playing && paused`) without inferring from `currentTimeSec`. The downside is two booleans for what could be a three-state enum; the upside is direct compatibility with existing transport-bar UI patterns.
`AudioPlayerPresetData` carries only `loop` because `volume` and `playbackRate` are universal parameters that travel through the parameter snapshot built by `AbstractAudioFxPlugin`. Storing them again in the preset would double-store and risk drift. The buffer itself is never serialised — it lives at a URL the caller owns, so presets reference the URL externally (e.g., in a parent playlist record).

View File

@@ -0,0 +1,27 @@
/** Options accepted by `AudioPlayerPlugin.load`. */
export interface AudioPlayerLoadOptions {
readonly signal?: AbortSignal;
}
/** Construction options for `AudioPlayerPlugin`. */
export interface AudioPlayerPluginOptions {
readonly initialVolume?: number;
readonly initialLoop?: boolean;
readonly initialPlaybackRate?: number;
}
/** Live state snapshot returned by `getState()`. */
export interface AudioPlayerState {
readonly playing: boolean;
readonly paused: boolean;
readonly currentTimeSec: number;
readonly durationSec: number;
readonly volume: number;
readonly loop: boolean;
readonly playbackRate: number;
}
/** Preset payload — `volume`/`loop`/`playbackRate` only; buffer is not persisted. */
export interface AudioPlayerPresetData {
readonly loop: boolean;
}

View File

@@ -0,0 +1,91 @@
# `audio-player`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AudioPlayerPlugin` is the universal audio-file player. It decodes a raw `Float32Array` PCM stream from a URL into an internal buffer and plays it back through the universal `BlockProcessor` path. The plugin is a source (`io.inputs[0].count === 0`, `io.outputs[0].count === 1`) so it slots into chains at position 0 — the chain's IO validator rejects placing it elsewhere.
The plugin extends `AbstractAudioFxPlugin`, runs identically across browser / server / worklet renderers, and exposes the classic media-player surface (`load`, `play`, `pause`, `stop`, `seek`, `setVolume`, `setLoop`, `setPlaybackRate`, `getState`) the way a UI consumer expects. `load` is `AbortSignal`-aware: a pre-aborted signal short-circuits before any fetch.
## Features
- Five-method transport: `play` / `pause` / `stop` / `seek(timeSec)` plus the bool-typed `setLoop`.
- Three automatable parameters: `volume` (0..1), `loop` (enum), `playbackRate` (0.25..4).
- Pitch-preserving playback is **not** included — `setPlaybackRate(2)` reads the buffer at 2× speed, so it doubles both pitch and consumption. For pitch-preserving rate change, chain a `PitchShifterPlugin` downstream.
- `load(url, { signal? })` fetches a raw Float32 PCM buffer (same encoding as `SamplerPlugin.loadSample` / `ReverbPlugin.loadImpulseResponse`). Pre-aborted signal throws `AUDIO_FX.ABORTED`.
- `play()` before any `load()` throws `AUDIO_FX.SAMPLE_NOT_LOADED` — the same error code the sampler uses for the same misuse.
- `setLoop(true)` makes playback wrap back to the buffer start without producing silence in between. Without loop, playback stops cleanly at the end of the buffer and flips `state.playing` to `false`.
- `getState()` returns a frozen snapshot of `{ playing, paused, currentTimeSec, durationSec, volume, loop, playbackRate }` so UI code never has to read internal fields.
## Usage: Basic Example
```ts
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
const player = new AudioPlayerPlugin();
await player.load("/audio/track-01.raw");
chain.add(player);
player.play();
```
## Usage: Advanced Example
```ts
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
const player = new AudioPlayerPlugin({
initialVolume: 0.8,
initialLoop: true,
initialPlaybackRate: 1,
});
await player.setup(
{ sampleRate: 48_000, channels: 1, blockSize: 512 },
ctx
);
const ac = new AbortController();
try {
await player.load("/audio/loop.raw", { signal: ac.signal });
} catch (error) {
if (error instanceof AudioFxError && error.code === "AUDIO_FX.ABORTED") {
console.warn("audio load aborted");
return;
}
throw error;
}
// Drive a UI scrubber from the live state.
const ticker = setInterval(() => {
const state = player.getState();
scrubber.setValue(state.currentTimeSec / state.durationSec);
}, 50);
player.play();
```
## API Reference
- `AudioPlayerPlugin` (class extends `AbstractAudioFxPlugin`)
- `new AudioPlayerPlugin(options?: AudioPlayerPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
- `load(url, { signal? }): Promise<void>` — fetches and registers the playback buffer.
- `play(): void` — throws `AUDIO_FX.SAMPLE_NOT_LOADED` if no buffer is loaded.
- `pause(): void`, `stop(): void`
- `seek(timeSec: number): void` — throws `AUDIO_FX.INVALID_OPTIONS` on negative or non-finite values.
- `setVolume(v: 0..1): void`, `setLoop(loop: boolean): void`, `setPlaybackRate(rate: 0.25..4): void`
- `getState(): AudioPlayerState`
- Inherited accessors for `volume`, `loop`, `playbackRate`.
## Implementation Notes
The buffer is a `Float32Array` allocated by `load`. Subsequent `load` calls replace the buffer and reset the read position to zero. There is no in-flight load tracking — concurrent `load` calls race; the last one to resolve wins. Most consumers wrap `load` calls in a single `AbortController` and abort the previous call before starting a new one, which is what the AbortSignal handling is designed for.
`pause` is intentionally a no-op when `playing === false`. Without that guard, a `pause()` before any `play()` would set `paused = true`, which would then make the next `play()` start in a paused state (because `play` only flips `playing`). This matched the behaviour of the main-branch `HTMLAudioElement`-backed implementation and is the principle of least surprise for media-player consumers.
`setPlaybackRate` validates at the setter rather than relying on the parameter's `min`/`max` clamping. The reason: the parameter declares `automatable: true`, so a parameter-curve automation could in principle write any number; the setter rejects out-of-range *direct* set calls so a UI's invalid input is caught at the boundary. Automation curves operate on a separate ramp and are clamped by the parameter range internally.
Linear-interpolated read between adjacent samples handles fractional playback rates and seeks. The interpolator is the same shape used by `SamplerPlugin` — `s0 * (1 - frac) + s1 * frac` — so pitch-preservation behaviour is consistent across the two plugins. Looping wraps with `position -= (buffer.length - 1)` rather than `position = 0` so any fractional carry survives the wrap.
`getState` is the only place that reads the live parameter values without going through `parameterValue` — that helper requires the chain context to compute scheduled automation values. Since `getState` is meant to be safe to call before `setup`, it falls back to `getParameter` (the static-value accessor) instead.

View File

@@ -0,0 +1,415 @@
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 { AudioPlayerPlugin } from "./audio-player.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: "audio-player-test",
name: "audio-player-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("AudioPlayerPlugin: contract surface", () => {
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
const p = new AudioPlayerPlugin();
expect(p.metadata.id).toBe("audio-player");
expect(p.metadata.category).toBe("source");
expect(p.parameters.map((x) => x.id)).toEqual([
"volume",
"loop",
"playbackRate",
]);
});
it("rejects malformed options with INVALID_OPTIONS", {
timeout: 5_000,
}, () => {
try {
new AudioPlayerPlugin({ initialVolume: 99 });
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
});
describe("AudioPlayerPlugin: play before load throws AUDIO_FX.SAMPLE_NOT_LOADED", () => {
it("calling play() with no buffer rejects with the expected code", {
timeout: 5_000,
}, () => {
const p = new AudioPlayerPlugin();
try {
p.play();
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.SAMPLE_NOT_LOADED
);
return;
}
throw new Error("expected SAMPLE_NOT_LOADED");
});
});
describe("AudioPlayerPlugin: pre-aborted signal short-circuits before fetch", () => {
it("aborted signal throws AUDIO_FX.ABORTED", {
timeout: 5_000,
}, async () => {
const p = new AudioPlayerPlugin();
const ac = new AbortController();
ac.abort();
try {
await p.load("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("AudioPlayerPlugin: load succeeds with a non-aborted signal", () => {
it("AbortController.signal that is not aborted lets the load complete", {
timeout: 5_000,
}, async () => {
const p = new AudioPlayerPlugin();
const ac = new AbortController();
await p.load(sampleDataUrl([0.5, 0.5, 0.5, 0.5]), {
signal: ac.signal,
});
expect(p.getState().durationSec).toBeCloseTo(4 / SAMPLE_RATE, 6);
});
});
describe("AudioPlayerPlugin: load + play renders the loaded buffer on the next process block", () => {
it("first samples of the loaded buffer appear on the output, scaled by volume", {
timeout: 5_000,
}, async () => {
const p = new AudioPlayerPlugin({ initialVolume: 0.5 });
await p.load(sampleDataUrl([1, 0.5, 0.25, 0.125]));
await p.setup(newConfig(8), newCtx());
p.play();
const output: Float32Array[] = [new Float32Array(8)];
p.process([], output, blockCtx());
expect(output[0]![0]).toBeCloseTo(0.5, 5);
expect(output[0]![1]).toBeCloseTo(0.25, 5);
expect(output[0]![2]).toBeCloseTo(0.125, 5);
});
});
describe("AudioPlayerPlugin: setLoop wraps playback back to the buffer start", () => {
it("with loop=true the output cycles through the buffer continuously", {
timeout: 5_000,
}, async () => {
const p = new AudioPlayerPlugin();
await p.load(sampleDataUrl([0.1, 0.2, 0.3, 0.4]));
await p.setup(newConfig(16), newCtx());
p.setLoop(true);
p.play();
const output: Float32Array[] = [new Float32Array(16)];
p.process([], output, blockCtx());
// First three samples ≈ buffer[0..2]; after a wrap the cycle
// continues so we see the start of the buffer again later in the
// block. No silent gap.
expect(output[0]![0]).toBeCloseTo(0.1, 4);
let hasZeroGap = false;
let nonZeroCount = 0;
for (let i = 0; i < 16; i++) {
if (output[0]![i]! === 0) hasZeroGap = true;
if (output[0]![i]! !== 0) nonZeroCount++;
}
expect(hasZeroGap).toBe(false);
expect(nonZeroCount).toBe(16);
});
});
describe("AudioPlayerPlugin: non-loop playback ends at buffer end", () => {
it("once playback reaches the end, subsequent samples are zero and playing becomes false", {
timeout: 5_000,
}, async () => {
const p = new AudioPlayerPlugin();
await p.load(sampleDataUrl([0.5, 0.5]));
await p.setup(newConfig(16), newCtx());
p.play();
const output: Float32Array[] = [new Float32Array(16)];
p.process([], output, blockCtx());
expect(output[0]![15]).toBe(0);
expect(p.getState().playing).toBe(false);
});
});
describe("AudioPlayerPlugin: pause + resume keeps position", () => {
it("pause halts output; subsequent play() resumes from the saved position", {
timeout: 5_000,
}, async () => {
const p = new AudioPlayerPlugin();
await p.load(sampleDataUrl([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]));
await p.setup(newConfig(4), newCtx());
p.play();
const output: Float32Array[] = [new Float32Array(4)];
p.process([], output, blockCtx());
p.pause();
const pausedOut: Float32Array[] = [new Float32Array(4)];
p.process([], pausedOut, blockCtx());
for (let i = 0; i < 4; i++) expect(pausedOut[0]![i]).toBe(0);
p.play();
const resumed: Float32Array[] = [new Float32Array(4)];
p.process([], resumed, blockCtx());
// Position was 4 when paused; resumed samples start at buffer[4].
expect(resumed[0]![0]).toBeCloseTo(0.5, 4);
});
});
describe("AudioPlayerPlugin: stop resets position to zero", () => {
it("after stop() the next play() resumes from the start", {
timeout: 5_000,
}, async () => {
const p = new AudioPlayerPlugin();
await p.load(sampleDataUrl([0.1, 0.2, 0.3, 0.4, 0.5, 0.6]));
await p.setup(newConfig(4), newCtx());
p.play();
const a: Float32Array[] = [new Float32Array(4)];
p.process([], a, blockCtx());
p.stop();
expect(p.getState().currentTimeSec).toBe(0);
p.play();
const b: Float32Array[] = [new Float32Array(4)];
p.process([], b, blockCtx());
expect(b[0]![0]).toBeCloseTo(0.1, 4);
});
});
describe("AudioPlayerPlugin: seek + setVolume + setPlaybackRate setters", () => {
it("seek positions the read pointer at the requested time", {
timeout: 5_000,
}, async () => {
const p = new AudioPlayerPlugin();
await p.load(sampleDataUrl([0.1, 0.2, 0.3, 0.4, 0.5, 0.6]));
await p.setup(newConfig(4), newCtx());
p.seek(2 / SAMPLE_RATE);
p.play();
const out: Float32Array[] = [new Float32Array(4)];
p.process([], out, blockCtx());
expect(out[0]![0]).toBeCloseTo(0.3, 4);
});
it("setPlaybackRate accelerates consumption", {
timeout: 5_000,
}, async () => {
const p = new AudioPlayerPlugin();
await p.load(sampleDataUrl([0.1, 0.2, 0.3, 0.4, 0.5, 0.6]));
await p.setup(newConfig(2), newCtx());
p.setPlaybackRate(2);
p.play();
const out: Float32Array[] = [new Float32Array(2)];
p.process([], out, blockCtx());
// 2x rate: samples 0 and 2.
expect(out[0]![0]).toBeCloseTo(0.1, 4);
expect(out[0]![1]).toBeCloseTo(0.3, 4);
});
it("setPlaybackRate rejects out-of-range values", {
timeout: 5_000,
}, () => {
const p = new AudioPlayerPlugin();
try {
p.setPlaybackRate(99);
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
it("setVolume rejects non-finite values", { timeout: 5_000 }, () => {
const p = new AudioPlayerPlugin();
try {
p.setVolume(Number.NaN);
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
it("setVolume clamps in-range values to [0, 1]", { timeout: 5_000 }, () => {
const p = new AudioPlayerPlugin();
p.setVolume(-0.5);
expect(p.getState().volume).toBe(0);
p.setVolume(1.5);
expect(p.getState().volume).toBe(1);
p.setVolume(0.7);
expect(p.getState().volume).toBeCloseTo(0.7, 5);
});
it("seek rejects negative times", { timeout: 5_000 }, () => {
const p = new AudioPlayerPlugin();
try {
p.seek(-1);
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
});
describe("AudioPlayerPlugin: getState reports the full transport snapshot", () => {
it("after load + play, the snapshot reflects live transport values", {
timeout: 5_000,
}, async () => {
const p = new AudioPlayerPlugin({ initialLoop: true });
await p.load(sampleDataUrl([0.1, 0.2, 0.3, 0.4]));
await p.setup(newConfig(2), newCtx());
const beforePlay = p.getState();
expect(beforePlay.playing).toBe(false);
expect(beforePlay.loop).toBe(true);
expect(beforePlay.durationSec).toBeCloseTo(4 / SAMPLE_RATE, 6);
p.play();
const afterPlay = p.getState();
expect(afterPlay.playing).toBe(true);
expect(afterPlay.paused).toBe(false);
});
});
describe("AudioPlayerPlugin: pause without play is a no-op", () => {
it("calling pause() on an inactive plugin does not flip paused to true", {
timeout: 5_000,
}, () => {
const p = new AudioPlayerPlugin();
p.pause();
expect(p.getState().paused).toBe(false);
});
});
describe("AudioPlayerPlugin: getState is safe before setup", () => {
it("calling getState before setup() falls back to a 48 kHz reference rate", {
timeout: 5_000,
}, () => {
const p = new AudioPlayerPlugin();
const state = p.getState();
expect(state.durationSec).toBe(0);
expect(state.currentTimeSec).toBe(0);
});
});
describe("AudioPlayerPlugin: seek beyond buffer end clamps to the buffer length", () => {
it("seeking past the buffer end positions at the end (next process emits silence)", {
timeout: 5_000,
}, async () => {
const p = new AudioPlayerPlugin();
await p.load(sampleDataUrl([0.1, 0.2]));
await p.setup(newConfig(4), newCtx());
p.seek(10); // 10 seconds, well past 2-sample buffer
p.play();
const out: Float32Array[] = [new Float32Array(4)];
p.process([], out, blockCtx());
// Position was clamped to bufferLen; non-loop playback ends immediately.
for (let i = 0; i < 4; i++) expect(out[0]![i]).toBe(0);
});
});
describe("AudioPlayerPlugin: preset round-trip", () => {
it("loop=true round-trips through getPreset / applyPreset", {
timeout: 5_000,
}, () => {
const a = new AudioPlayerPlugin({ initialLoop: true });
const snap = a.getPreset();
const b = new AudioPlayerPlugin();
b.applyPreset(snap);
expect(b.getState().loop).toBe(true);
});
it("loop=false round-trips through getPreset / applyPreset", {
timeout: 5_000,
}, () => {
const a = new AudioPlayerPlugin({ initialLoop: false });
a.setLoop(false);
const snap = a.getPreset();
const b = new AudioPlayerPlugin({ initialLoop: true });
b.applyPreset(snap);
expect(b.getState().loop).toBe(false);
});
});
describe("AudioPlayerPlugin: seek before load clamps to zero", () => {
it("seek with no buffer loaded leaves position at zero (bufferLen=0)", {
timeout: 5_000,
}, async () => {
const p = new AudioPlayerPlugin();
await p.setup(newConfig(4), newCtx());
p.seek(0.5);
// No buffer → seek clamps to buffer length 0; play would throw,
// so just verify the state snapshot is consistent.
expect(p.getState().currentTimeSec).toBe(0);
});
});
describe("AudioPlayerPlugin: reset clears playback state", () => {
it("after reset(), getState reports not playing and currentTime=0", {
timeout: 5_000,
}, async () => {
const p = new AudioPlayerPlugin();
await p.load(sampleDataUrl([0.5, 0.5, 0.5, 0.5]));
await p.setup(newConfig(4), newCtx());
p.play();
const out: Float32Array[] = [new Float32Array(4)];
p.process([], out, blockCtx());
p.reset();
const s = p.getState();
expect(s.playing).toBe(false);
expect(s.currentTimeSec).toBe(0);
});
});

View File

@@ -0,0 +1,258 @@
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 {
AudioPlayerLoadOptions,
AudioPlayerPluginOptions,
AudioPlayerPresetData,
AudioPlayerState,
} from "../audio-player.types.js";
import {
AudioPlayerPluginOptionsSchema,
AudioPlayerPresetDataSchema,
} from "../schemas/index.js";
/**
* Source plugin that decodes a raw `Float32Array` PCM stream from a
* URL into an internal buffer and plays it back through the universal
* `BlockProcessor` path. State is a single buffer + fractional read
* position; `reset()` clears them.
*/
export class AudioPlayerPlugin extends AbstractAudioFxPlugin {
private buffer: Float32Array | undefined;
private position = 0;
private playing = false;
private paused = false;
public constructor(options: AudioPlayerPluginOptions = {}) {
const parsed = AudioPlayerPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.audio-player-invalid-options",
{ context: { issues: parsed.error.issues } }
);
}
const opts = parsed.data;
super({
metadata: {
id: "audio-player",
name: "Audio Player",
version: "1.0.0",
category: "source",
},
schema: AudioPlayerPresetDataSchema,
io: {
inputs: [{ count: 0 }],
outputs: [{ count: 1 }],
},
parameters: [
{
id: "volume",
name: "Volume",
value: opts.initialVolume ?? 1,
min: 0,
max: 1,
default: 1,
unit: "ratio",
automatable: true,
},
{
id: "loop",
name: "Loop",
value: opts.initialLoop ? 1 : 0,
min: 0,
max: 1,
default: 0,
unit: "enum",
automatable: false,
},
{
id: "playbackRate",
name: "Playback Rate",
value: opts.initialPlaybackRate ?? 1,
min: 0.25,
max: 4,
default: 1,
unit: "ratio",
automatable: true,
},
],
supportedSchemaVersion: 1,
});
}
public async load(
url: string,
opts: AudioPlayerLoadOptions = {}
): Promise<void> {
if (opts.signal?.aborted) {
throw new AudioFxError(
AUDIO_FX_CODES.ABORTED,
"audio-fx.audio-player-load-pre-aborted",
{ context: { url } }
);
}
const fetchInit: RequestInit = {};
if (opts.signal !== undefined) {
fetchInit.signal = opts.signal;
}
const response = await fetch(url, fetchInit);
const bytes = await response.arrayBuffer();
this.buffer = new Float32Array(bytes.slice(0));
this.position = 0;
this.playing = false;
this.paused = false;
}
public play(): void {
if (this.buffer === undefined) {
throw new AudioFxError(
AUDIO_FX_CODES.SAMPLE_NOT_LOADED,
"audio-fx.audio-player-play-before-load"
);
}
this.playing = true;
this.paused = false;
}
public pause(): void {
if (this.playing) {
this.paused = true;
}
}
public stop(): void {
this.playing = false;
this.paused = false;
this.position = 0;
}
public seek(timeSec: number): void {
if (!Number.isFinite(timeSec) || timeSec < 0) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.audio-player-invalid-seek",
{ context: { timeSec } }
);
}
const sr = this.config!.sampleRate;
const bufferLen = this.buffer === undefined ? 0 : this.buffer.length;
const target = timeSec * sr;
this.position = target > bufferLen ? bufferLen : target;
}
public setVolume(volume: number): void {
this.setParameter("volume", clamp01(volume));
}
public setLoop(loop: boolean): void {
this.setParameter("loop", loop ? 1 : 0);
}
public setPlaybackRate(rate: number): void {
if (!Number.isFinite(rate) || rate < 0.25 || rate > 4) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.audio-player-invalid-playback-rate",
{ context: { rate } }
);
}
this.setParameter("playbackRate", rate);
}
public getState(): AudioPlayerState {
const sr = this.config === undefined ? 48_000 : this.config.sampleRate;
const bufferLen = this.buffer === undefined ? 0 : this.buffer.length;
return {
playing: this.playing,
paused: this.paused,
currentTimeSec: this.position / sr,
durationSec: bufferLen / sr,
volume: this.getParameter("volume"),
loop: this.getParameter("loop") >= 0.5,
playbackRate: this.getParameter("playbackRate"),
};
}
protected override setupState(
_config: ProcessorConfig,
_chain: ChainContext
): void {
this.position = 0;
this.playing = false;
this.paused = false;
}
protected override resetState(): void {
this.position = 0;
this.playing = false;
this.paused = false;
}
protected processBlock(
_input: Float32Array[],
output: Float32Array[],
_ctx: BlockContext
): void {
const out = output[0]!;
const blockSize = out.length;
if (this.buffer === undefined || !this.playing || this.paused) {
out.fill(0);
return;
}
const buffer = this.buffer;
const volume = this.parameterValue("volume");
const loop = this.parameterValue("loop") >= 0.5;
const rate = this.parameterValue("playbackRate");
let position = this.position;
for (let i = 0; i < blockSize; i++) {
if (position >= buffer.length - 1) {
if (loop) {
position = position - (buffer.length - 1);
} else {
out[i] = 0;
this.playing = false;
this.position = 0;
for (let j = i + 1; j < blockSize; j++) {
out[j] = 0;
}
return;
}
}
const i0 = Math.floor(position);
const frac = position - i0;
const s0 = buffer[i0]!;
const s1 = buffer[i0 + 1]!;
out[i] = (s0 + (s1 - s0) * frac) * volume;
position += rate;
}
this.position = position;
}
protected override getPresetData(): AudioPlayerPresetData {
return { loop: this.getParameter("loop") >= 0.5 };
}
protected override applyPresetData(data: unknown): void {
const { loop } = data as AudioPlayerPresetData;
this.setParameter("loop", loop ? 1 : 0);
}
}
function clamp01(v: number): number {
if (!Number.isFinite(v)) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.audio-player-invalid-volume",
{ context: { v } }
);
}
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}

View File

@@ -0,0 +1 @@
export { AudioPlayerPlugin } from "./audio-player.js";

View File

@@ -0,0 +1,12 @@
export { AudioPlayerPlugin } from "./audio-player/index.js";
export type {
AudioPlayerLoadOptions,
AudioPlayerPluginOptions,
AudioPlayerPresetData,
AudioPlayerState,
} from "./audio-player.types.js";
export {
AudioPlayerLoadOptionsSchema,
AudioPlayerPluginOptionsSchema,
AudioPlayerPresetDataSchema,
} from "./schemas/index.js";

View File

@@ -0,0 +1,5 @@
export {
AudioPlayerLoadOptionsSchema,
AudioPlayerPluginOptionsSchema,
AudioPlayerPresetDataSchema,
} from "./schemas.js";

View File

@@ -0,0 +1,58 @@
# `audio-player/schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas validating the construction-time options, load options, and preset payload of `AudioPlayerPlugin`. These run on the public boundary between user code and the plugin: any malformed object hitting the constructor or `applyPreset` 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
- `AudioPlayerPluginOptionsSchema` — validates `initialVolume ∈ [0, 1]`, `initialLoop: boolean`, `initialPlaybackRate ∈ [0.25, 4]`.
- `AudioPlayerLoadOptionsSchema` — `.strict()` with `signal: AbortSignal | undefined`.
- `AudioPlayerPresetDataSchema` — `.strict()` object carrying only `loop: boolean`.
## Usage: Basic Example
```ts
import { AudioPlayerPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-audio-player";
const parsed = AudioPlayerPluginOptionsSchema.parse({ initialVolume: 0.8 });
```
## Usage: Advanced Example
```ts
import {
AudioPlayerLoadOptionsSchema,
AudioPlayerPluginOptionsSchema,
AudioPlayerPresetDataSchema,
} from "@suaveplan/audio-fx-plugin-audio-player";
function isPlayerPreset(blob: unknown): boolean {
return AudioPlayerPresetDataSchema.safeParse(blob).success;
}
function isLoadOptions(blob: unknown): boolean {
return AudioPlayerLoadOptionsSchema.safeParse(blob).success;
}
const fields = AudioPlayerPluginOptionsSchema.keyof().options;
for (const field of fields) form.addRow(field);
```
## API Reference
- `AudioPlayerPluginOptionsSchema: z.ZodObject<{ initialVolume?, initialLoop?, initialPlaybackRate? }>`
- `AudioPlayerLoadOptionsSchema: z.ZodObject<{ signal? }, "strict">`
- `AudioPlayerPresetDataSchema: z.ZodObject<{ loop }, "strict">`
## Implementation Notes
`AudioPlayerLoadOptionsSchema` uses `z.instanceof(AbortSignal)` for the signal field rather than a duck-typed object check. `instanceof` is the only Zod check that verifies the 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 every runtime that exposes `AbortSignal` as a global (browser, Node 18+, Bun).
`AudioPlayerPresetDataSchema` is `.strict()` so unknown fields are rejected — matching the sibling-plugin policy. The plugin's universal parameters (`volume`, `playbackRate`) travel through the parameter snapshot built by `AbstractAudioFxPlugin`, so the preset payload encodes only the non-parameter `loop` flag. Adding more fields here would require coordinating with the base class's preset round-trip; the current minimal payload keeps the preset stable across revisions.

View File

@@ -0,0 +1,15 @@
import z from "zod";
export const AudioPlayerPluginOptionsSchema = z.object({
initialVolume: z.number().finite().min(0).max(1).optional(),
initialLoop: z.boolean().optional(),
initialPlaybackRate: z.number().finite().min(0.25).max(4).optional(),
});
export const AudioPlayerLoadOptionsSchema = z
.object({ signal: z.instanceof(AbortSignal).optional() })
.strict();
export const AudioPlayerPresetDataSchema = z
.object({ loop: z.boolean() })
.strict();

View File

@@ -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"]
}

View File

@@ -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"
]
}

View File

@@ -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-audio-player",
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",
],
},
},
});

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({ testTimeout: 30_000 });

View File

@@ -0,0 +1,31 @@
# @suaveplan/audio-fx-plugin-auto-filter - auto-filter plugin for the @suaveplan/audio-fx-plugin-* family.
auto-filter 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
- [auto-filter](./src/auto-filter/auto-filter.md) — `AutoFilterPlugin` is the universal LFO-swept low-pass filter. A biquad LPF's cutoff is modulated around a base frequen…
- [auto-filter](./src/auto-filter/auto-filter.md) — `AutoFilterPlugin` is the universal LFO-swept low-pass filter. A biquad LPF's cutoff is modulated around a base frequen…
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `AutoFilterPlugin`. These run on the public…
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `AutoFilterPlugin`. These run on the public…
<!-- end:auto-docs -->
## Composition
```ts
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-auto-filter";
const plugin = new /* Plugin */();
const chain = new PluginChain({
buses: new DefaultBusRegistry(),
plugins: [plugin],
});
```
## Status
Replaces the `./auto-filter` subpath of the bundled `@suaveplan/audio-plugins-fx`.

View File

@@ -0,0 +1,47 @@
{
"name": "@suaveplan/audio-fx-plugin-auto-filter",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "auto-filter 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"
}
}

View File

@@ -0,0 +1,61 @@
# `auto-filter.types`
## Purpose
TypeScript-only type definitions for `AutoFilterPlugin`'s construction options. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`. Consumers can import the type for typed form state, dependency-injection wiring, or storybook controls without pulling in the plugin runtime.
## Features
- `AutoFilterPluginOptions` — all fields optional so the default constructor (`new AutoFilterPlugin()`) is valid.
- `readonly` properties — emphasises that the options object is consumed once at construction.
- Numeric ranges live in the Zod schema (single source of truth) rather than in TypeScript types.
## Usage: Basic Example
```ts
import type { AutoFilterPluginOptions } from "@suaveplan/audio-fx-plugin-auto-filter";
const opts: AutoFilterPluginOptions = { initialRateHz: 2 };
```
## Usage: Advanced Example
```ts
import type { AutoFilterPluginOptions } from "@suaveplan/audio-fx-plugin-auto-filter";
const presets: Record<string, AutoFilterPluginOptions> = {
subtle: {
initialRateHz: 0.4,
initialDepth: 0.2,
initialResonance: 1,
},
wah: {
initialRateHz: 1.2,
initialDepth: 0.9,
initialResonance: 8,
},
sweep: {
initialRateHz: 0.1,
initialDepth: 1,
initialResonance: 4,
},
};
function load(name: keyof typeof presets): AutoFilterPluginOptions {
return presets[name]!;
}
```
## API Reference
- `AutoFilterPluginOptions`
- `readonly initialRateHz?: number`
- `readonly initialDepth?: number`
- `readonly initialBaseHz?: number`
- `readonly initialResonance?: number`
## Implementation Notes
The options interface deliberately uses `initialBaseHz` rather than `initialCutoffHz`. The cutoff *varies* per sample as the LFO sweeps; the "base" frequency is the centre of that sweep. Calling it "cutoff" would suggest the value persists, which is only true at `depth = 0`. Picking the clearer name in the public type keeps editor tooltips honest.
All fields are `readonly` so structural-equality checks on saved options (e.g., undo/redo stacks) do not need defensive cloning. The constructor never mutates the passed object — it copies relevant values into the parameter list once and forgets the input — but typing the surface as `readonly` lets the type system enforce that intent at every call site.

View File

@@ -0,0 +1,7 @@
/** Construction options for `AutoFilterPlugin`. */
export interface AutoFilterPluginOptions {
readonly initialRateHz?: number;
readonly initialDepth?: number;
readonly initialBaseHz?: number;
readonly initialResonance?: number;
}

View File

@@ -0,0 +1,71 @@
# `auto-filter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AutoFilterPlugin` is the universal LFO-swept low-pass filter. A biquad LPF's cutoff is modulated around a base frequency by a sine LFO whose phase derives from `BlockContext.blockOffsetSamples` — phase-continuous across block boundaries by construction, block-size independent, and stateless on the LFO side. At `depth === 0` the LFO has no effect on cutoff and the filter runs as a static low-pass at `base`, matching the spec's "zero depth is transparent (auto-filter unmodulated by the LFO)".
The plugin extends `AbstractAudioFxPlugin`, so it runs identically across browser, server, offline, and worklet renderers. State is the biquad's direct-form-I memory (`x1`, `x2`, `y1`, `y2`) per channel; `reset()` zeros it in place without reallocation.
## Features
- Four parameters: `rate` (Hz, 0.05..10), `depth` (0..1), `base` (Hz, 50..10000), `resonance` (Q, 0.1..20).
- LFO-modulated cutoff: `cutoff = base · 2^(lfo · depth · 2)` so `depth = 1` sweeps ±2 octaves around `base`.
- Cutoff clamp `[20 Hz, 0.45·sr]` keeps the bilinear transform numerically stable across the full parameter range.
- Phase-continuous LFO derived from `ctx.blockOffsetSamples + i` — the same span split into smaller blocks produces sample-identical output, exercised in the test "1024 in one block === 512+512 with blockOffsetSamples".
- Per-channel direct-form-I biquad with coefficients recomputed every sample so the cutoff sweep is continuous, not stepped.
## Usage: Basic Example
```ts
import { AutoFilterPlugin } from "@suaveplan/audio-fx-plugin-auto-filter";
const af = new AutoFilterPlugin({
initialRateHz: 1,
initialDepth: 0.7,
initialBaseHz: 800,
initialResonance: 4,
});
chain.add(af);
```
## Usage: Advanced Example
```ts
import { AutoFilterPlugin } from "@suaveplan/audio-fx-plugin-auto-filter";
// Synth-pad "wah" effect: deep sweep, high resonance, slow rate.
const wah = new AutoFilterPlugin({
initialRateHz: 0.3,
initialDepth: 0.95,
initialBaseHz: 600,
initialResonance: 8,
});
await wah.setup(
{ sampleRate: 48_000, channels: 2, blockSize: 512 },
ctx
);
// Automate rate from 0.3 to 4 Hz over a build-up.
wah.setParameterAt("rate", 0.3, 0);
wah.setParameterAt("rate", 4, 8);
```
## API Reference
- `AutoFilterPlugin` (class extends `AbstractAudioFxPlugin`)
- `new AutoFilterPlugin(options?: AutoFilterPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
- Inherited parameter accessors for `rate`, `depth`, `base`, `resonance`.
## Implementation Notes
The biquad LPF coefficients are computed inline from the RBJ Audio EQ Cookbook formula. We do not reuse the EQ package's `computeBiquad` because that helper takes an `EQBand` struct with `gain` and `type` discriminants that are meaningless for a single LPF — a leaner local function avoids forcing callers to fabricate a dummy band record per sample. The cost of duplicating ~10 lines of arithmetic is small compared to the per-sample call overhead of crossing a module boundary.
Per-sample coefficient recompute is the right default here. A typical AutoFilter use case is a slow LFO (sub-2 Hz) with a deep depth, so a "control-rate" smoothing scheme would still need to recompute coefficients every few samples to track the sweep — the per-sample compute is simpler, has no zipper noise, and is well within the budget of modern engines for the typical block sizes used in real-time audio.
The cutoff clamp `[20 Hz, 0.45·sr]` matches the policy used by the state-variable filter and the all-pass phaser. The `0.45·sr` upper bound was chosen so the bilinear transform's `alpha = sin(ω) / (2·Q)` stays in a numerically safe range; pushing closer to Nyquist would risk `alpha` approaching zero and amplifying float-precision noise. The lower bound of 20 Hz is below human hearing so any audible cutoff sweep is unchanged.
`depth === 0` is bit-equivalent to a static LPF at `base`. There is no explicit early-out for that case — `2^(lfo · 0 · 2) = 2^0 = 1` exactly in IEEE arithmetic, so the cutoff sweep collapses to `base` and the LFO eval just costs one `Math.sin` per sample. If a future revision needs the LFO output for sidechain modulation, this design keeps the LFO live even at `depth = 0`.

View File

@@ -0,0 +1,237 @@
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 { AutoFilterPlugin } from "./auto-filter.js";
const SAMPLE_RATE = 48_000;
function blockCtx(blockOffsetSamples = 0): BlockContext {
return {
blockOffsetSamples,
sampleRate: SAMPLE_RATE,
channels: 1,
opts: {},
};
}
function buffers(
size: number,
channels = 1
): { input: Float32Array[]; output: Float32Array[] } {
const input: Float32Array[] = [];
const output: Float32Array[] = [];
for (let c = 0; c < channels; c++) {
input.push(new Float32Array(size));
output.push(new Float32Array(size));
}
return { input, output };
}
function newConfig(blockSize: number, channels = 1): ProcessorConfig {
return { sampleRate: SAMPLE_RATE, channels, blockSize };
}
function newCtx(channels = 1): ChainContext {
const chain: ChainHandle = {
id: "auto-filter-test",
name: "auto-filter-test",
channels,
sampleRate: SAMPLE_RATE,
blockSize: 256,
slotCount: 1,
};
return {
chain,
position: { index: 0, total: 1 },
predecessors: [],
successors: [],
ambient: defaultAmbientState(),
buses: { get: () => undefined, list: () => [] },
};
}
function rms(buf: Float32Array, start = 0, end = buf.length): number {
let acc = 0;
for (let i = start; i < end; i++) acc += buf[i]! * buf[i]!;
return Math.sqrt(acc / (end - start));
}
describe("AutoFilterPlugin: contract surface", () => {
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
const f = new AutoFilterPlugin();
expect(f.metadata.id).toBe("auto-filter");
expect(f.metadata.category).toBe("modulation");
expect(f.parameters.map((p) => p.id)).toEqual([
"rate",
"depth",
"base",
"resonance",
]);
});
it("rejects malformed options with INVALID_OPTIONS", {
timeout: 5_000,
}, () => {
try {
new AutoFilterPlugin({ initialDepth: 5 });
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
});
describe("AutoFilterPlugin: zero depth leaves the signal unmodulated by the LFO", () => {
it("two renders of the same input produce identical output at depth=0", {
timeout: 5_000,
}, async () => {
const a = new AutoFilterPlugin({
initialDepth: 0,
initialBaseHz: 1_000,
initialResonance: 1,
});
const b = new AutoFilterPlugin({
initialDepth: 0,
initialBaseHz: 1_000,
initialResonance: 1,
});
await a.setup(newConfig(2048, 1), newCtx(1));
await b.setup(newConfig(2048, 1), newCtx(1));
const { input: inA, output: outA } = buffers(2048, 1);
const { input: inB, output: outB } = buffers(2048, 1);
for (let i = 0; i < 2048; i++) {
inA[0]![i] = 0.5 * Math.sin((2 * Math.PI * 200 * i) / SAMPLE_RATE);
inB[0]![i] = inA[0]![i]!;
}
// Two independent renders with the same blockOffset must produce
// identical output because the LFO output does not affect cutoff.
a.process(inA, outA, blockCtx(0));
b.process(inB, outB, blockCtx(1_000_000));
for (let i = 0; i < 2048; i++) {
expect(outA[0]![i]).toBe(outB[0]![i]!);
}
});
});
describe("AutoFilterPlugin: LFO is phase-continuous and block-size independent", () => {
it("1024 in one block === 512+512 with blockOffsetSamples", {
timeout: 5_000,
}, async () => {
const single = new AutoFilterPlugin({
initialRateHz: 2,
initialDepth: 0.7,
initialBaseHz: 1_000,
initialResonance: 1,
});
const split = new AutoFilterPlugin({
initialRateHz: 2,
initialDepth: 0.7,
initialBaseHz: 1_000,
initialResonance: 1,
});
await single.setup(newConfig(1024, 1), newCtx(1));
await split.setup(newConfig(512, 1), newCtx(1));
const input = new Float32Array(1024);
for (let i = 0; i < 1024; i++) {
input[i] = 0.5 * Math.sin((2 * Math.PI * 500 * i) / SAMPLE_RATE);
}
const outA = new Float32Array(1024);
single.process([input], [outA], blockCtx(0));
const outB1 = new Float32Array(512);
const outB2 = new Float32Array(512);
split.process([input.subarray(0, 512)], [outB1], blockCtx(0));
split.process([input.subarray(512, 1024)], [outB2], blockCtx(512));
for (let i = 0; i < 512; i++) {
expect(outA[i]).toBeCloseTo(outB1[i]!, 5);
expect(outA[i + 512]).toBeCloseTo(outB2[i]!, 5);
}
});
});
describe("AutoFilterPlugin: cutoff sweep attenuates above-cutoff content over time", () => {
it("high-frequency input is attenuated when LFO sweeps below it", {
timeout: 5_000,
}, async () => {
const f = new AutoFilterPlugin({
initialRateHz: 1,
initialDepth: 1,
initialBaseHz: 500,
initialResonance: 1,
});
await f.setup(newConfig(SAMPLE_RATE, 1), newCtx(1));
const { input, output } = buffers(SAMPLE_RATE, 1);
// 8 kHz sine well above base cutoff of 500 Hz.
for (let i = 0; i < SAMPLE_RATE; i++) {
input[0]![i] =
0.5 * Math.sin((2 * Math.PI * 8_000 * i) / SAMPLE_RATE);
}
f.process(input, output, blockCtx(0));
// Output is attenuated relative to input.
expect(rms(output[0]!, 2_000)).toBeLessThan(rms(input[0]!, 2_000));
});
});
describe("AutoFilterPlugin: output stays finite over a hot sweep", () => {
it("max resonance + max depth does not produce non-finite samples", {
timeout: 5_000,
}, async () => {
const f = new AutoFilterPlugin({
initialRateHz: 3,
initialDepth: 1,
initialBaseHz: 1_000,
initialResonance: 20,
});
await f.setup(newConfig(SAMPLE_RATE, 1), newCtx(1));
const { input, output } = buffers(SAMPLE_RATE, 1);
for (let i = 0; i < SAMPLE_RATE; i++) {
input[0]![i] =
0.3 * Math.sin((2 * Math.PI * 600 * i) / SAMPLE_RATE);
}
f.process(input, output, blockCtx(0));
for (let i = 0; i < SAMPLE_RATE; i++) {
expect(Number.isFinite(output[0]![i]!)).toBe(true);
}
});
});
describe("AutoFilterPlugin: reset clears state", () => {
it("after a hot block + reset, empty input produces empty output", {
timeout: 5_000,
}, async () => {
const f = new AutoFilterPlugin({ initialDepth: 0 });
await f.setup(newConfig(256, 1), newCtx(1));
const { input, output } = buffers(256, 1);
for (let i = 0; i < 256; i++) input[0]![i] = 1;
f.process(input, output, blockCtx(0));
f.reset();
const { input: in2, output: out2 } = buffers(256, 1);
f.process(in2, out2, blockCtx(0));
for (let i = 0; i < 256; i++) {
expect(out2[0]![i]).toBe(0);
}
});
});
describe("AutoFilterPlugin: preset round-trip", () => {
it("getPreset / applyPreset round-trips base", { timeout: 5_000 }, () => {
const a = new AutoFilterPlugin({ initialBaseHz: 2_000 });
const snap = a.getPreset();
const b = new AutoFilterPlugin();
b.applyPreset(snap);
expect(b.getParameter("base")).toBe(2_000);
});
});

View File

@@ -0,0 +1,185 @@
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 { AutoFilterPluginOptions } from "../auto-filter.types.js";
import {
AutoFilterPluginOptionsSchema,
AutoFilterPresetDataSchema,
} from "../schemas/index.js";
const TWO_PI = 2 * Math.PI;
/**
* LFO-swept biquad low-pass filter. The cutoff frequency is modulated
* around `base` by an LFO whose phase derives from
* `BlockContext.blockOffsetSamples` — block-size independent and
* phase-continuous by construction. At `depth === 0` the LFO has no
* effect and the filter runs at a fixed cutoff of `base`.
*/
export class AutoFilterPlugin extends AbstractAudioFxPlugin {
private x1: Float32Array = new Float32Array(0);
private x2: Float32Array = new Float32Array(0);
private y1: Float32Array = new Float32Array(0);
private y2: Float32Array = new Float32Array(0);
public constructor(options: AutoFilterPluginOptions = {}) {
const parsed = AutoFilterPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.auto-filter-invalid-options",
{ context: { issues: parsed.error.issues } }
);
}
const opts = parsed.data;
super({
metadata: {
id: "auto-filter",
name: "Auto Filter",
version: "1.0.0",
category: "modulation",
},
schema: AutoFilterPresetDataSchema,
io: {
inputs: [{ count: "preserve" }],
outputs: [{ count: "preserve" }],
},
parameters: [
{
id: "rate",
name: "Rate",
value: opts.initialRateHz ?? 1,
min: 0.05,
max: 10,
default: 1,
unit: "hz",
automatable: true,
},
{
id: "depth",
name: "Depth",
value: opts.initialDepth ?? 0.5,
min: 0,
max: 1,
default: 0.5,
unit: "percent",
automatable: true,
},
{
id: "base",
name: "Base Frequency",
value: opts.initialBaseHz ?? 800,
min: 50,
max: 10_000,
default: 800,
unit: "hz",
automatable: true,
},
{
id: "resonance",
name: "Resonance",
value: opts.initialResonance ?? 2,
min: 0.1,
max: 20,
default: 2,
unit: "ratio",
automatable: true,
},
],
supportedSchemaVersion: 1,
});
}
protected override setupState(
config: ProcessorConfig,
_chain: ChainContext
): void {
this.x1 = new Float32Array(config.channels);
this.x2 = new Float32Array(config.channels);
this.y1 = new Float32Array(config.channels);
this.y2 = new Float32Array(config.channels);
}
protected override resetState(): void {
this.x1.fill(0);
this.x2.fill(0);
this.y1.fill(0);
this.y2.fill(0);
}
protected processBlock(
input: Float32Array[],
output: Float32Array[],
ctx: BlockContext
): void {
const rate = this.parameterValue("rate");
const depth = this.parameterValue("depth");
const base = this.parameterValue("base");
const q = this.parameterValue("resonance");
const sr = ctx.sampleRate;
const phaseStep = (TWO_PI * rate) / sr;
const offset = ctx.blockOffsetSamples;
const blockSize = input[0]!.length;
const nyquist = sr * 0.45;
const channels = input.length;
for (let i = 0; i < blockSize; i++) {
const lfo = Math.sin(phaseStep * (offset + i));
const cutoff = Math.min(
nyquist,
Math.max(20, base * 2 ** (lfo * depth * 2))
);
const coeffs = lowPassBiquad(cutoff, q, sr);
for (let ch = 0; ch < channels; ch++) {
const x = input[ch]![i]!;
const y =
coeffs.b0 * x +
coeffs.b1 * this.x1[ch]! +
coeffs.b2 * this.x2[ch]! -
coeffs.a1 * this.y1[ch]! -
coeffs.a2 * this.y2[ch]!;
this.x2[ch] = this.x1[ch]!;
this.x1[ch] = x;
this.y2[ch] = this.y1[ch]!;
this.y1[ch] = y;
output[ch]![i] = y;
}
}
}
}
interface BiquadCoeffs {
b0: number;
b1: number;
b2: number;
a1: number;
a2: number;
}
function lowPassBiquad(
cutoffHz: number,
q: number,
sampleRate: number
): BiquadCoeffs {
const omega = (2 * Math.PI * cutoffHz) / sampleRate;
const cosW = Math.cos(omega);
const sinW = Math.sin(omega);
const alpha = sinW / (2 * q);
const b0 = (1 - cosW) / 2;
const b1 = 1 - cosW;
const b2 = (1 - cosW) / 2;
const a0 = 1 + alpha;
const a1 = -2 * cosW;
const a2 = 1 - alpha;
return {
b0: b0 / a0,
b1: b1 / a0,
b2: b2 / a0,
a1: a1 / a0,
a2: a2 / a0,
};
}

View File

@@ -0,0 +1 @@
export { AutoFilterPlugin } from "./auto-filter.js";

View File

@@ -0,0 +1,6 @@
export { AutoFilterPlugin } from "./auto-filter/index.js";
export type { AutoFilterPluginOptions } from "./auto-filter.types.js";
export {
AutoFilterPluginOptionsSchema,
AutoFilterPresetDataSchema,
} from "./schemas/index.js";

View File

@@ -0,0 +1,4 @@
export {
AutoFilterPluginOptionsSchema,
AutoFilterPresetDataSchema,
} from "./schemas.js";

View File

@@ -0,0 +1,58 @@
# `auto-filter/schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas validating the construction-time options and preset payload of `AutoFilterPlugin`. These run on the public boundary between user code and the plugin: any malformed object hitting the constructor or `applyPreset` 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
- `AutoFilterPluginOptionsSchema` — validates all four optional construction fields with their documented ranges (`rate` 0.05..10 Hz, `depth` 0..1, `base` 50..10 000 Hz, `resonance` 0.1..20).
- `AutoFilterPresetDataSchema` — empty strict object; the plugin has no plugin-specific preset payload, so any extra keys are rejected by `.strict()`.
- All numeric fields use `z.number().finite()` so `NaN` and `Infinity` cannot sneak through.
## Usage: Basic Example
```ts
import { AutoFilterPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-auto-filter";
const parsed = AutoFilterPluginOptionsSchema.parse({
initialBaseHz: 1_200,
});
```
## Usage: Advanced Example
```ts
import {
AutoFilterPluginOptionsSchema,
AutoFilterPresetDataSchema,
} from "@suaveplan/audio-fx-plugin-auto-filter";
// Driver for an editor form: pull the keys from the schema.
const optionFields = AutoFilterPluginOptionsSchema.keyof().options;
for (const field of optionFields) {
form.addRow(field);
}
// Strict preset validation at the IPC boundary.
function isAutoFilterPreset(blob: unknown): boolean {
return AutoFilterPresetDataSchema.safeParse(blob).success;
}
```
## API Reference
- `AutoFilterPluginOptionsSchema: z.ZodObject<{ initialRateHz?, initialDepth?, initialBaseHz?, initialResonance? }>`
- `AutoFilterPresetDataSchema: z.ZodObject<{}, "strict">`
## Implementation Notes
The maximum resonance is `20`, the same upper bound used by `FilterPlugin` in its biquad mode. Going higher risks the filter pole approaching the unit circle and producing self-oscillation at the cutoff; the test "max resonance + max depth does not produce non-finite samples" exercises this boundary against a one-second sine and verifies every output sample is finite. The actual perceptual ceiling for resonance is around 10; the range up to 20 is provided so callers automating resonance into self-oscillation territory can do so explicitly.
`AutoFilterPresetDataSchema` is `.strict()` so unknown fields are rejected, matching the sibling-plugin policy. The plugin has no plugin-specific preset payload today: every parameter (`rate`, `depth`, `base`, `resonance`) travels through the universal parameter snapshot that `AbstractAudioFxPlugin` builds, and the empty `presetData` field marks that the plugin opts out of plugin-specific state.

View File

@@ -0,0 +1,10 @@
import z from "zod";
export const AutoFilterPluginOptionsSchema = z.object({
initialRateHz: z.number().finite().min(0.05).max(10).optional(),
initialDepth: z.number().finite().min(0).max(1).optional(),
initialBaseHz: z.number().finite().min(50).max(10_000).optional(),
initialResonance: z.number().finite().min(0.1).max(20).optional(),
});
export const AutoFilterPresetDataSchema = z.object({}).strict();

View File

@@ -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"]
}

View File

@@ -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"
]
}

View File

@@ -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-auto-filter",
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",
],
},
},
});

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({ testTimeout: 30_000 });

View File

@@ -0,0 +1,83 @@
# @suaveplan/audio-fx-plugin-base - Foundation for the @suaveplan/audio-fx-plugin-* family. Exports the AudioFxPlugin contract, PluginChain primitive, ChainContext, AmbientState, BusRegistry, PluginRegistry, ChainSnapshot, ChainEventBus, ParameterSchedule, PresetSnapshot, and the shared AUDIO_FX.* error-code registry. Runs identically in browser, server, offline, and worklet contexts.
Foundation for the `@suaveplan/audio-fx-plugin-*` family. Universal (browser / server / offline / worklet identical). Zero `@suaveplan/audio-fx-plugin-*` deps — concrete plugins depend on this package, never the other way around.
<!-- begin:auto-docs -->
## Documentation tree
- [plugin-internals](./src/base/plugin-internals/plugin-internals.md) — The `plugin-internals` module owns the **boundary guards + parameter validation helpers** consumed by `AbstractAudioFxP…
- [plugin-internals](./src/base/plugin-internals/plugin-internals.md) — The `plugin-internals` module owns the **boundary guards + parameter validation helpers** consumed by `AbstractAudioFxP…
- [audio-fx-plugin](./src/base/audio-fx-plugin.md) — This module defines the **universal `AudioFxPlugin` contract** every plugin in the audio family implements, plu…
- [capabilities](./src/base/capabilities.md) — `PluginCapabilities` is the **explicit feature-flag declaration** every `AudioFxPlugin` carries. Engines route audio th…
- [parameter-schedule](./src/base/parameter-schedule.md) — `ParameterSchedule` is the **per-parameter automation evaluator** every plugin uses. One instance per `(plugin, paramet…
- [plugin-io](./src/base/plugin-io.md) — The `plugin-io` module declares the **channel-topology contract** every `AudioFxPlugin` carries (`PluginIo`, `ChannelSp…
- [preset](./src/base/preset.md) — The `preset` module owns the **plugin preset envelope** — `PresetSnapshot` — and the three boundary guards every plugin…
- [biquad](./src/biquad/biquad.md) — The `eq.biquad` module owns the **RBJ Audio EQ Cookbook coefficient maths** and the per-band biquad state shapes consum…
- [biquad](./src/biquad/biquad.md) — The `eq.biquad` module owns the **RBJ Audio EQ Cookbook coefficient maths** and the per-band biquad state shapes consum…
- [chain](./src/chain/chain.md) — `PluginChain` is the **universal insert-list primitive** that engines and headless callers use to compose, host, and re…
- [context](./src/chain/context/context.md) — `ChainContextFactory` builds one `ChainContext` per plugin slot for the `PluginChain` class. The handed-out reference i…
- [context](./src/chain/context/context.md) — `ChainContextFactory` builds one `ChainContext` per plugin slot for the `PluginChain` class. The handed-out reference i…
- [event-bus](./src/chain/event-bus/event-bus.md) — The `chain-event-bus` module owns the **typed pub/sub plumbing** consumed by `PluginChain`. Splitting it out of the cha…
- [event-bus](./src/chain/event-bus/event-bus.md) — The `chain-event-bus` module owns the **typed pub/sub plumbing** consumed by `PluginChain`. Splitting it out of the cha…
- [insert](./src/chain/insert/insert.md) — The `chain-insert` module owns the **structural validation + slot-index resolution + scratch-buffer allocation** helper…
- [insert](./src/chain/insert/insert.md) — The `chain-insert` module owns the **structural validation + slot-index resolution + scratch-buffer allocation** helper…
- [lifecycle](./src/chain/lifecycle/lifecycle.md) — The `lifecycle` module owns the **per-block / per-setup helpers** that `PluginChain` calls during `setup`, `process`, `…
- [lifecycle](./src/chain/lifecycle/lifecycle.md) — The `lifecycle` module owns the **per-block / per-setup helpers** that `PluginChain` calls during `setup`, `process`, `…
- [snapshot](./src/chain/snapshot/snapshot.md) — The `chain-snapshot` module owns the **`PluginChain` serialisation envelope** and the boundary helpers that round-trip…
- [snapshot](./src/chain/snapshot/snapshot.md) — The `chain-snapshot` module owns the **`PluginChain` serialisation envelope** and the boundary helpers that round-trip…
- [ambient-state](./src/chain/ambient-state.md) — This module owns the **engine-pushed ambient state** every `PluginChain` carries and forwards to every plugin via `Chai…
- [bus-registry](./src/chain/bus-registry.md) — The `BusRegistry` is the **project-level sidechain routing surface** that every chain hosted by an engine adapter consu…
- [chain](./src/chain/chain.md) — `PluginChain` is the **universal insert-list primitive** that engines and headless callers use to compose, host, and re…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module declares the complete error code surface for `@suaveplan/audio-fx-plugin-base` via `defineCode…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module declares the complete error code surface for `@suaveplan/audio-fx-plugin-base` via `defineCode…
- [registry](./src/registry/registry.md) — The `PluginRegistry` is the **session-restore lookup table** that maps a serialised `pluginType` string (a plugin's `me…
- [registry](./src/registry/registry.md) — The `PluginRegistry` is the **session-restore lookup table** that maps a serialised `pluginType` string (a plugin's `me…
<!-- end:auto-docs -->
## What's in here
- **`./base`** — the `AudioFxPlugin` contract, `AbstractAudioFxPlugin` helper, `PluginCapabilities` / `PluginIo` / `PluginMetadata` / `PluginParameter` types, `ParameterSchedule`, `PresetSnapshot` Zod schema, and channel-layout utilities.
- **`./chain`** — `PluginChain` primitive, `ChainContext`, `AmbientState` propagation, `DefaultBusRegistry`, `ChainSnapshot` (parse + serialise), `ChainEventBus`, insertion / lifecycle helpers.
- **`./registry`** — `PluginRegistry` for factory-driven plugin instantiation.
- **`./error-codes`** — the shared `AUDIO_FX.*` error-code registry via `defineCodes`, `AudioFxError` carrier, `AUDIO_FX_ERROR_METADATA` table, and `registerAudioFxErrorCodes()` for early-registration callers.
## Composition
Every concrete `@suaveplan/audio-fx-plugin-<name>` package depends on this one and imports the contract:
```ts
import { AbstractAudioFxPlugin } from "@suaveplan/audio-fx-plugin-base/base";
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
import { PluginRegistry } from "@suaveplan/audio-fx-plugin-base/registry";
import { AUDIO_FX_CODES, AudioFxError } from "@suaveplan/audio-fx-plugin-base/error-codes";
```
Consumers compose a chain from per-plugin packages:
```ts
import { EqPlugin } from "@suaveplan/audio-fx-plugin-eq";
import { CompressorPlugin } from "@suaveplan/audio-fx-plugin-compressor";
import { ReverbPlugin } from "@suaveplan/audio-fx-plugin-reverb";
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
const chain = new PluginChain({
buses: new DefaultBusRegistry(),
plugins: [new EqPlugin(), new CompressorPlugin(), new ReverbPlugin()],
});
```
The chain accepts any `AudioFxPlugin` without per-plugin glue — the contract is the single point of agreement.
## Subpath exports
| Subpath | Surface |
|---|---|
| `.` | Re-exports every public symbol from the four feature folders below |
| `./base` | `AudioFxPlugin`, `AbstractAudioFxPlugin`, capabilities, IO, parameter, preset |
| `./chain` | `PluginChain`, `ChainContext`, `AmbientState`, `BusRegistry`, `ChainSnapshot`, event-bus, insert, lifecycle |
| `./registry` | `PluginRegistry`, `PluginFactory` |
| `./error-codes` | `AUDIO_FX_CODES`, `AudioFxError`, `AUDIO_FX_ERROR_METADATA`, `registerAudioFxErrorCodes` |
## Status
Replaces the foundation surface previously bundled inside `@suaveplan/audio-plugins-fx`.

View File

@@ -0,0 +1,81 @@
{
"name": "@suaveplan/audio-fx-plugin-base",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Foundation for the @suaveplan/audio-fx-plugin-* family. Exports the AudioFxPlugin contract, PluginChain primitive, ChainContext, AmbientState, BusRegistry, PluginRegistry, ChainSnapshot, ChainEventBus, ParameterSchedule, PresetSnapshot, and the shared AUDIO_FX.* error-code registry. Runs identically in browser, server, offline, and worklet contexts.",
"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"
}
},
"./base": {
"import": {
"types": "./dist/base/index.d.ts",
"default": "./dist/base/index.js"
}
},
"./chain": {
"import": {
"types": "./dist/chain/index.d.ts",
"default": "./dist/chain/index.js"
}
},
"./registry": {
"import": {
"types": "./dist/registry/index.d.ts",
"default": "./dist/registry/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./biquad": {
"import": {
"types": "./dist/biquad/index.d.ts",
"default": "./dist/biquad/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-core": "workspace:^",
"@suaveplan/audio-transport": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/schema-registry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
}
}

View File

@@ -0,0 +1,151 @@
# `audio-fx-plugin`
## Purpose
This module defines the **universal `AudioFxPlugin` contract** every plugin in the audio family implements, plus the `AbstractAudioFxPlugin` reference base class concrete plugins extend so they only have to implement DSP. The contract extends `BlockProcessor` from `@suaveplan/audio-core` (sans `setup`, since the FX surface adds chain context + async setup), so any `AudioFxPlugin` instance is also a valid `BlockProcessor` consumable by audio-core's stream and pipeline primitives. Every concern that is not DSP — parameter storage and clamping, automation curve / transport-time schedule evaluation, latency-change events, preset envelope validation, abort-signal handshake — lives in the base. Concrete plugins extend `AbstractAudioFxPlugin` and implement `processBlock`, with optional overrides for `setupState` (async resource loading), `resetState` (clear DSP buffers), `disposeState`, `getPresetData` / `applyPresetData`.
## Features
- One `AudioFxPlugin` interface every plugin implements; engines and chains program against this shape exclusively.
- `AbstractAudioFxPlugin` reference base class: ~80 % of the plumbing for free, including parameter mutation/clamp, schedule evaluation, preset envelope validation, capability/method-presence cross-check at construction, latency-change events, AbortSignal handshake at `setup()`.
- Engine-pushed `ChainContext` reference captured at `setup()` and read every block — peer parameter lookup, ambient track / transport / listener state, sidechain bus registry — without any global state.
- Sample-accurate parameter evaluation via the embedded `ParameterSchedule` per parameter; `processBlock` calls `this.parameterValue(id)` to read.
- Wall-clock seconds threaded through `BlockContext.opts.nowSec` so synthetic clocks in tests produce deterministic ramps.
- Atomic preset apply: parameter values and plugin-specific `data` are validated up front, then mutations are applied only after both pass.
## Usage: Basic Example
```ts
import { AbstractAudioFxPlugin } from "@suaveplan/audio-fx-plugin-base/base";
import z from "zod";
const GainSchema = z.object({}).strict();
export class GainPlugin extends AbstractAudioFxPlugin {
public constructor() {
super({
metadata: {
id: "gain",
name: "Gain",
version: "1.0.0",
category: "utility",
},
schema: GainSchema,
io: { inputs: [{ count: 2 }], outputs: [{ count: "preserve" }] },
parameters: [
{
id: "gain",
name: "Gain",
value: 1,
min: 0,
max: 4,
default: 1,
unit: "ratio",
automatable: true,
},
],
supportedSchemaVersion: 1,
});
}
protected processBlock(input: Float32Array[], output: Float32Array[]): void {
const gain = this.parameterValue("gain");
for (let ch = 0; ch < input.length; ch++) {
const inCh = input[ch]!;
const outCh = output[ch]!;
for (let i = 0; i < inCh.length; i++) {
outCh[i] = inCh[i]! * gain;
}
}
}
}
```
## Usage: Advanced Example
```ts
import {
AbstractAudioFxPlugin,
type ChainContext,
} from "@suaveplan/audio-fx-plugin-base/base";
import type { ProcessorConfig } from "@suaveplan/audio-core";
import z from "zod";
const ReverbSchema = z
.object({ irUrl: z.string().url().optional() })
.strict();
export class ReverbPlugin extends AbstractAudioFxPlugin {
private ir: Float32Array[] | undefined;
public constructor() {
super({
metadata: {
id: "reverb",
name: "Reverb",
version: "1.0.0",
category: "reverb",
},
schema: ReverbSchema,
io: { inputs: [{ count: 2 }], outputs: [{ count: "preserve" }] },
parameters: [
{ id: "wet", name: "Wet", value: 0.3, min: 0, max: 1, default: 0.3, automatable: true },
{ id: "decay", name: "Decay", value: 2.0, min: 0.1, max: 10, default: 2, unit: "ms", automatable: true },
],
supportedSchemaVersion: 1,
capabilities: {
hasAudioNode: false,
hasWorklet: false,
hasSidechain: false,
sampleAccurate: true,
stateful: true,
asyncSetup: true,
preservesChannelCount: true,
latencySamples: 0,
},
});
}
protected override async setupState(
_config: ProcessorConfig,
_chain: ChainContext,
opts?: { signal?: AbortSignal }
): Promise<void> {
// load default impulse response cancellably
const res = await fetch(DEFAULT_IR_URL, { signal: opts?.signal });
const ab = await res.arrayBuffer();
this.ir = decodeIr(ab); // bespoke helper
}
protected processBlock(input: Float32Array[], output: Float32Array[]): void {
// Read ambient pan so reverb tracks the track's stereo image:
const trackPan = this.chainContext!.ambient.track.pan;
const wet = this.parameterValue("wet");
convolveAndMix(input, output, this.ir!, wet, trackPan);
}
protected override resetState(): void {
// clear convolver state, etc.
}
}
```
## API Reference
- `AudioFxPlugin` (interface) — universal contract. Extends `Omit<BlockProcessor, "setup">` plus: `metadata`, `schema`, `parameters`, `parameterIndex`, `io`, `capabilities`, `setup(config, chain, opts?): Promise<void>`, `process(input, output, ctx)`, `reset()`, `dispose()`, `getLatencySamples()`, `getParameter / setParameter / setParameterCurve / setParameterAt`, `onLatencyChanged`, `getPreset / applyPreset`, optional `createAudioNode? / getWorkletProcessor? / setSidechain?`.
- `AbstractAudioFxPlugin` — reference base class. Subclasses pass `AbstractAudioFxPluginOptions` (metadata, schema, io, parameters, optional capabilities, supportedSchemaVersion) to `super()` and override `processBlock` (required) plus `setupState`, `resetState`, `disposeState`, `getPresetData`, `applyPresetData` (optional).
- `PluginMetadata`, `PluginHandle`, `AmbientState`, `AmbientTrackState`, `AmbientTransportState`, `AmbientListenerState`, `SidechainBus`, `BusRegistryReadHandle`, `ChainHandle`, `ChainContext`, `UnsubscribeFn` — supporting types.
- `parameterValue(id)` (protected) — sample-accurate read for `processBlock`.
- `notifyLatencyChanged(samples)` (protected) — emit a latency change to chain listeners.
## Implementation Notes
The base owns the lifecycle invariants so subclasses cannot leak them. `setup()` toggles `initialised = true` after `setupState` resolves; `process()` throws `AUDIO_FX.NOT_INITIALIZED` if called before. `dispose()` flips `initialised` back to false so a subsequent `process()` fails the same way (not with a confusing null-deref). This is the right shape because most plugins have a one-shot setup phase (load resources) followed by many `process()` calls; the contract enforces that lifecycle.
`AbortSignal` is checked both pre-setup and post-`setupState`. The pre-check catches "the user navigated away before we even started"; the post-check catches "the user cancelled mid-load." Both throw `AUDIO_FX.ABORTED` with the plugin id in `context`. Subclasses that own their own async work pass the signal through to whatever they call — `setupState` receives `opts` so `fetch(url, { signal: opts?.signal })` is the natural shape.
`parameterValue(id)` is the single read path subclasses use during `processBlock`. It calls `requireParameter` (throw on unknown id), then reads from the `ParameterSchedule` for that id using the engine-pushed `nowSec` from `BlockContext.opts.nowSec` and the chain-pushed `transportTimeSec` from `ChainContext.ambient.transport`. This is the **only** sanctioned read path; subclasses must not capture parameter values at `setParameter` time and store them — capture is via the schedule's per-block read.
Preset envelope validation at `applyPreset` runs in three steps: (1) Zod-validate the envelope shape, (2) assert the plugin type matches, (3) assert the schema version is supported. Only after all three pass do we mutate any state — atomic apply. The plugin's own `schema` is used to validate the `data` payload before `applyPresetData` runs, so plugin authors get a typed input. Unknown parameter ids in the preset are tolerated for forwards-compat (a future plugin build might add params; older builds skip them silently).
The `capabilitiesAgreeWithMembers` check at construction is a debug-grade assertion that catches authors who flip `hasAudioNode: true` without implementing `createAudioNode`. Throwing at construction is preferable to silent fallback at render time, because the engine adapter chooses render paths off the flag and would route incorrectly otherwise.

View File

@@ -0,0 +1,699 @@
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
import { describe, expect, it } from "@suaveplan/testing/runner";
import z from "zod";
import {
AUDIO_FX_CODES,
type AudioFxError,
} from "../error-codes/error-codes.js";
import {
AbstractAudioFxPlugin,
type AbstractAudioFxPluginOptions,
} from "./audio-fx-plugin.js";
import type { ChainContext } from "./audio-fx-plugin.types.js";
const STEREO_CONFIG: ProcessorConfig = {
sampleRate: 48_000,
channels: 2,
blockSize: 128,
};
const STEREO_IO = {
inputs: [{ count: 2 as const }],
outputs: [{ count: "preserve" as const }],
};
const PRESET_SCHEMA = z
.object({ shape: z.enum(["soft", "hard"]).optional() })
.strict();
const STUB_CHAIN_CONTEXT: ChainContext = {
chain: {
id: "test-chain",
name: "test",
channels: 2,
sampleRate: 48_000,
blockSize: 128,
slotCount: 1,
},
position: { index: 0, total: 1 },
predecessors: [],
successors: [],
ambient: {
track: {
id: "t1",
gainDb: 0,
pan: 0,
mute: false,
solo: false,
layout: "stereo",
},
transport: {
tempo: 120,
timeSignatureNumerator: 4,
timeSignatureDenominator: 4,
transportTimeSec: 0,
state: "playing",
ppqn: 960,
},
extension: {},
},
buses: {
get: () => undefined,
list: () => [],
},
};
class GainPlugin extends AbstractAudioFxPlugin {
public processedBlocks = 0;
public lastAppliedData: unknown = undefined;
public stateCleared = 0;
public disposed = 0;
public constructor(overrides?: Partial<AbstractAudioFxPluginOptions>) {
super({
metadata: {
id: "gain",
name: "Gain",
version: "1.0.0",
category: "utility",
},
schema: PRESET_SCHEMA,
io: STEREO_IO,
parameters: [
{
id: "gain",
name: "Gain",
value: 1,
min: 0,
max: 4,
default: 1,
unit: "ratio",
automatable: true,
},
{
id: "label",
name: "Label",
value: 0,
min: 0,
max: 1,
default: 0,
unit: "enum",
automatable: false,
},
],
supportedSchemaVersion: 1,
...overrides,
});
}
protected processBlock(
input: Float32Array[],
output: Float32Array[],
_ctx: BlockContext
): void {
this.processedBlocks += 1;
const gain = this.parameterValue("gain");
for (let ch = 0; ch < input.length; ch++) {
const inCh = input[ch]!;
const outCh = output[ch]!;
for (let i = 0; i < inCh.length; i++) {
outCh[i] = (inCh[i] ?? 0) * gain;
}
}
}
protected override resetState(): void {
this.stateCleared += 1;
}
protected override disposeState(): void {
this.disposed += 1;
}
protected override getPresetData(): unknown {
return { shape: "soft" };
}
protected override applyPresetData(data: unknown): void {
this.lastAppliedData = data;
}
}
function blockCtx(opts: Record<string, unknown> = {}): BlockContext {
return {
blockOffsetSamples: 0,
sampleRate: 48_000,
channels: 2,
opts,
};
}
function buffers(): { input: Float32Array[]; output: Float32Array[] } {
return {
input: [new Float32Array(128), new Float32Array(128)],
output: [new Float32Array(128), new Float32Array(128)],
};
}
describe("AbstractAudioFxPlugin: identity and shape", () => {
it("exposes metadata, parameters, parameterIndex, io, capabilities", {
timeout: 5_000,
}, () => {
const p = new GainPlugin();
expect(p.metadata.id).toBe("gain");
expect(p.name).toBe("gain");
expect(p.parameters.length).toBe(2);
expect(p.parameterIndex.size).toBe(2);
expect(p.parameterIndex.get("gain")?.id).toBe("gain");
expect(p.io).toEqual(STEREO_IO);
expect(p.capabilities.hasAudioNode).toBe(false);
expect(p.capabilities.preservesChannelCount).toBe(true);
expect(p.capabilities.latencySamples).toBe(0);
});
it("throws AUDIO_FX.INVALID_OPTIONS when capability flags disagree with method presence", {
timeout: 5_000,
}, () => {
try {
new GainPlugin({
capabilities: {
hasAudioNode: true, // claim true but no createAudioNode
hasWorklet: false,
hasSidechain: false,
sampleAccurate: true,
stateful: false,
asyncSetup: false,
preservesChannelCount: true,
latencySamples: 0,
},
});
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected GainPlugin construction to throw");
});
});
describe("AbstractAudioFxPlugin: setup lifecycle", () => {
it("throws AUDIO_FX.NOT_INITIALIZED if process() runs before setup()", {
timeout: 5_000,
}, () => {
const p = new GainPlugin();
const { input, output } = buffers();
try {
p.process(input, output, blockCtx());
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.NOT_INITIALIZED
);
return;
}
throw new Error("expected NOT_INITIALIZED");
});
it("rejects pre-aborted signal with AUDIO_FX.ABORTED before allocation", {
timeout: 5_000,
}, async () => {
const p = new GainPlugin();
const ac = new AbortController();
ac.abort();
try {
await p.setup(STEREO_CONFIG, STUB_CHAIN_CONTEXT, {
signal: ac.signal,
});
} catch (error) {
expect((error as AudioFxError).code).toBe(AUDIO_FX_CODES.ABORTED);
return;
}
throw new Error("expected ABORTED");
});
it("rejects mid-setup signal abort with AUDIO_FX.ABORTED", {
timeout: 5_000,
}, async () => {
class SlowPlugin extends GainPlugin {
protected override async setupState(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
const p = new SlowPlugin();
const ac = new AbortController();
const promise = p.setup(STEREO_CONFIG, STUB_CHAIN_CONTEXT, {
signal: ac.signal,
});
ac.abort();
try {
await promise;
} catch (error) {
expect((error as AudioFxError).code).toBe(AUDIO_FX_CODES.ABORTED);
return;
}
throw new Error("expected mid-setup ABORTED");
});
});
describe("AbstractAudioFxPlugin: process channel validation", () => {
it("throws CHANNEL_MISMATCH when input length differs from configured channels", {
timeout: 5_000,
}, async () => {
const p = new GainPlugin();
await p.setup(STEREO_CONFIG, STUB_CHAIN_CONTEXT);
try {
p.process(
[new Float32Array(128)],
[new Float32Array(128), new Float32Array(128)],
blockCtx()
);
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.CHANNEL_MISMATCH
);
return;
}
throw new Error("expected CHANNEL_MISMATCH");
});
it("applies gain block-by-block via parameterValue() helper", {
timeout: 5_000,
}, async () => {
const p = new GainPlugin();
await p.setup(STEREO_CONFIG, STUB_CHAIN_CONTEXT);
p.setParameter("gain", 2);
const { input, output } = buffers();
input[0]!.fill(0.25);
input[1]!.fill(-0.5);
p.process(input, output, blockCtx());
expect(output[0]![0]).toBe(0.5);
expect(output[1]![64]).toBe(-1);
expect(p.processedBlocks).toBe(1);
});
});
describe("AbstractAudioFxPlugin: parameter mutations and clamping", () => {
it("clamps setParameter to declared min/max", { timeout: 5_000 }, () => {
const p = new GainPlugin();
p.setParameter("gain", -10);
expect(p.getParameter("gain")).toBe(0);
p.setParameter("gain", 100);
expect(p.getParameter("gain")).toBe(4);
});
it("throws UNKNOWN_PARAMETER on unknown id for set/get/curve/at", {
timeout: 5_000,
}, () => {
const p = new GainPlugin();
for (const op of [
() => p.getParameter("missing"),
() => p.setParameter("missing", 0),
() =>
p.setParameterCurve("missing", {
type: "linear",
value: 0,
duration: 1,
}),
() => p.setParameterAt("missing", 0, 1),
]) {
try {
op();
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.UNKNOWN_PARAMETER
);
continue;
}
throw new Error("expected UNKNOWN_PARAMETER");
}
});
it("throws PARAMETER_NOT_AUTOMATABLE when curving / scheduling a non-automatable param", {
timeout: 5_000,
}, () => {
const p = new GainPlugin();
for (const op of [
() =>
p.setParameterCurve("label", {
type: "linear",
value: 1,
duration: 1,
}),
() => p.setParameterAt("label", 1, 1),
]) {
try {
op();
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.PARAMETER_NOT_AUTOMATABLE
);
continue;
}
throw new Error("expected PARAMETER_NOT_AUTOMATABLE");
}
});
it("setParameterAt schedules an event clamped into the parameter's range", {
timeout: 5_000,
}, async () => {
const p = new GainPlugin();
await p.setup(STEREO_CONFIG, {
...STUB_CHAIN_CONTEXT,
ambient: {
...STUB_CHAIN_CONTEXT.ambient,
transport: {
...STUB_CHAIN_CONTEXT.ambient.transport,
transportTimeSec: 1,
},
},
});
// Schedule beyond max (4); clamp expected.
p.setParameterAt("gain", 99, 2);
const { input, output } = buffers();
input[0]!.fill(1);
input[1]!.fill(1);
// Advance both wall clock and transport time past the schedule.
p.process(input, output, {
...blockCtx({ nowSec: 0 }),
opts: { nowSec: 0 },
});
// The chainContext.ambient is captured by reference at setup; our
// STUB has transportTimeSec=1 by default, so the t=2 event hasn't
// fired. parameterValue() reads from the same context.
expect(output[0]![0]).toBe(1);
// We can verify the schedule is queued via getParameter (raw current).
expect(p.getParameter("gain")).toBe(1);
});
it("setParameterCurve clamps the target into the parameter's range", {
timeout: 5_000,
}, () => {
const p = new GainPlugin();
p.setParameterCurve("gain", {
type: "linear",
value: 999,
duration: 0,
startTime: 0,
});
// zero-duration curve drains at first read => snaps to clamped 4.
const sched = p["schedules"].get("gain")!;
expect(sched.valueAtBlockOffset(0, 0)).toBe(4);
});
});
describe("AbstractAudioFxPlugin: preset round-trip", () => {
it("getPreset round-trips through applyPreset", {
timeout: 5_000,
}, () => {
const p = new GainPlugin();
p.setParameter("gain", 3);
const snapshot = p.getPreset();
const p2 = new GainPlugin();
p2.applyPreset(snapshot);
expect(p2.getParameter("gain")).toBe(3);
expect(p2.lastAppliedData).toEqual({ shape: "soft" });
});
it("getPreset omits data when getPresetData returns undefined", {
timeout: 5_000,
}, () => {
class NoDataPlugin extends GainPlugin {
protected override getPresetData(): unknown {
return undefined;
}
}
const p = new NoDataPlugin();
const snap = p.getPreset();
expect(snap.data).toBeUndefined();
});
it("default getPresetData returns undefined for plugins that don't override", {
timeout: 5_000,
}, () => {
// BarePlugin omits the override entirely so the default base
// implementation (returning undefined) is exercised.
class BarePlugin extends AbstractAudioFxPlugin {
public constructor() {
super({
metadata: {
id: "bare",
name: "Bare",
version: "1.0.0",
category: "utility",
},
schema: PRESET_SCHEMA,
io: STEREO_IO,
parameters: [
{
id: "x",
name: "X",
value: 0,
min: 0,
max: 1,
default: 0,
},
],
supportedSchemaVersion: 1,
});
}
protected processBlock(
input: Float32Array[],
output: Float32Array[]
): void {
for (let ch = 0; ch < input.length; ch++) {
output[ch]!.set(input[ch]!);
}
}
}
const p = new BarePlugin();
// applyPresetData default no-op (also covers that branch).
p.applyPreset({
schemaVersion: 1,
pluginType: "bare",
parameters: {},
data: {},
});
const snap = p.getPreset();
expect(snap.data).toBeUndefined();
expect(snap.parameters).toEqual({ x: 0 });
// Default reset/dispose: no override; both must be safe no-ops.
expect(() => p.reset()).not.toThrow();
expect(() => p.dispose()).not.toThrow();
});
it("throws INVALID_PRESET when applyPreset is called with a wrong plugin type", {
timeout: 5_000,
}, () => {
const p = new GainPlugin();
try {
p.applyPreset({
schemaVersion: 1,
pluginType: "reverb",
parameters: {},
});
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_PRESET
);
return;
}
throw new Error("expected INVALID_PRESET");
});
it("throws INVALID_PRESET when data fails the plugin's own schema", {
timeout: 5_000,
}, () => {
const p = new GainPlugin();
try {
p.applyPreset({
schemaVersion: 1,
pluginType: "gain",
parameters: {},
data: { shape: "spiky" }, // not in enum
});
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_PRESET
);
return;
}
throw new Error("expected INVALID_PRESET");
});
it("clamps parameter values from the preset into declared range", {
timeout: 5_000,
}, () => {
const p = new GainPlugin();
p.applyPreset({
schemaVersion: 1,
pluginType: "gain",
parameters: { gain: 100 },
});
expect(p.getParameter("gain")).toBe(4);
});
it("ignores unknown parameter ids in the preset", {
timeout: 5_000,
}, () => {
const p = new GainPlugin();
// should not throw
p.applyPreset({
schemaVersion: 1,
pluginType: "gain",
parameters: { unknown: 5 },
});
// gain remains at default
expect(p.getParameter("gain")).toBe(1);
});
});
describe("AbstractAudioFxPlugin: latency events", () => {
it("notifyLatencyChanged fires listeners with the new value", {
timeout: 5_000,
}, () => {
class LookaheadPlugin extends GainPlugin {
public bumpLatency(samples: number): void {
this.notifyLatencyChanged(samples);
}
}
const p = new LookaheadPlugin();
const received: number[] = [];
p.onLatencyChanged((s) => received.push(s));
p.bumpLatency(128);
p.bumpLatency(128); // identical — no-op
p.bumpLatency(256);
expect(received).toEqual([128, 256]);
expect(p.getLatencySamples()).toBe(256);
});
it("onLatencyChanged returns an unsubscribe handle", {
timeout: 5_000,
}, () => {
class LookaheadPlugin extends GainPlugin {
public bumpLatency(samples: number): void {
this.notifyLatencyChanged(samples);
}
}
const p = new LookaheadPlugin();
const received: number[] = [];
const unsub = p.onLatencyChanged((s) => received.push(s));
unsub();
p.bumpLatency(64);
expect(received).toEqual([]);
});
});
describe("AbstractAudioFxPlugin: reset + dispose", () => {
it("reset() drops parameter schedules and calls resetState", {
timeout: 5_000,
}, async () => {
const p = new GainPlugin();
await p.setup(STEREO_CONFIG, STUB_CHAIN_CONTEXT);
p.setParameterCurve("gain", {
type: "linear",
value: 2,
duration: 1,
startTime: 10,
});
p.reset();
expect(p.stateCleared).toBe(1);
// schedule was dropped (gain stays at 1).
const { input, output } = buffers();
input[0]!.fill(1);
input[1]!.fill(1);
p.process(input, output, blockCtx());
expect(output[0]![0]).toBe(1);
});
it("dispose() clears listeners and prevents further process", {
timeout: 5_000,
}, async () => {
const p = new GainPlugin();
await p.setup(STEREO_CONFIG, STUB_CHAIN_CONTEXT);
p.onLatencyChanged(() => {});
p.dispose();
expect(p.disposed).toBe(1);
const { input, output } = buffers();
try {
p.process(input, output, blockCtx());
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.NOT_INITIALIZED
);
return;
}
throw new Error("expected NOT_INITIALIZED after dispose");
});
});
describe("AbstractAudioFxPlugin: input count='preserve' falls back to config.channels", () => {
class PreserveInputPlugin extends AbstractAudioFxPlugin {
public constructor() {
super({
metadata: {
id: "preserve-input",
name: "Preserve",
version: "1.0.0",
category: "utility",
},
schema: PRESET_SCHEMA,
parameters: [],
io: {
inputs: [{ count: "preserve" as const }],
outputs: [{ count: "preserve" as const }],
},
});
}
protected processBlock(
input: Float32Array[],
output: Float32Array[]
): void {
for (let c = 0; c < input.length; c++) {
const src = input[c];
const dst = output[c];
if (!src || !dst) continue;
for (let i = 0; i < src.length; i++) dst[i] = src[i] ?? 0;
}
}
}
it("expectedInput falls back to channels when inSpec is not a number", {
timeout: 5_000,
}, async () => {
const p = new PreserveInputPlugin();
await p.setup(STEREO_CONFIG, STUB_CHAIN_CONTEXT);
const { input, output } = buffers();
input[0]!.fill(0.25);
input[1]!.fill(0.5);
// Both inputs and outputs use the channels fallback (count="preserve");
// process should succeed and copy input → output.
p.process(input, output, blockCtx());
expect(output[0]![0]).toBe(0.25);
expect(output[1]![0]).toBe(0.5);
});
});
describe("AbstractAudioFxPlugin: BlockContext nowSec threading", () => {
it("uses ctx.opts.nowSec when supplied for parameter schedule evaluation", {
timeout: 5_000,
}, async () => {
const p = new GainPlugin();
await p.setup(STEREO_CONFIG, STUB_CHAIN_CONTEXT);
p.setParameterCurve("gain", {
type: "linear",
value: 4,
duration: 1,
startTime: 0,
});
const { input, output } = buffers();
input[0]!.fill(1);
input[1]!.fill(1);
// First poll at nowSec=0 captures startValue = 1.
p.process(input, output, { ...blockCtx({ nowSec: 0 }) });
expect(output[0]![0]).toBe(1);
// Mid-ramp at nowSec=0.5 should produce ~2.5.
p.process(input, output, { ...blockCtx({ nowSec: 0.5 }) });
expect(output[0]![0]).toBeCloseTo(2.5, 5);
});
});

View File

@@ -0,0 +1,293 @@
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
import type { ZodType } from "zod";
import { AUDIO_FX_CODES, AudioFxError } from "../error-codes/error-codes.js";
import type {
AudioFxPlugin,
ChainContext,
PluginMetadata,
UnsubscribeFn,
} from "./audio-fx-plugin.types.js";
import {
capabilitiesAgreeWithMembers,
defaultCapabilities,
} from "./capabilities.js";
import type {
OptionalPluginMembers,
PluginCapabilities,
} from "./capabilities.types.js";
import type { ParameterCurve, PluginParameter } from "./parameter.types.js";
import { ParameterSchedule } from "./parameter-schedule.js";
import {
assertChannelMatch,
assertNotAborted,
requireAutomatable,
requireParameter,
} from "./plugin-internals/index.js";
import type { PluginIo } from "./plugin-io.types.js";
import {
applyPresetParameters,
assertPresetMatchesPluginType,
assertPresetVersionSupported,
buildPresetSnapshot,
clamp,
parsePresetSnapshot,
validatePresetData,
} from "./preset.js";
import type { PresetSnapshot } from "./preset.types.js";
/** Construction options for `AbstractAudioFxPlugin`. See `audio-fx-plugin.md`. */
export interface AbstractAudioFxPluginOptions {
readonly metadata: PluginMetadata;
readonly schema: ZodType<unknown>;
readonly io: PluginIo;
readonly parameters: ReadonlyArray<PluginParameter>;
readonly capabilities?: PluginCapabilities;
readonly supportedSchemaVersion: number;
}
/** Reference base; concrete plugins implement `processBlock` + state hooks. */
export abstract class AbstractAudioFxPlugin implements AudioFxPlugin {
public readonly metadata: PluginMetadata;
public readonly schema: ZodType<unknown>;
public readonly parameters: ReadonlyArray<PluginParameter>;
public readonly parameterIndex: ReadonlyMap<string, PluginParameter>;
public readonly io: PluginIo;
public readonly capabilities: PluginCapabilities;
public get name(): string {
return this.metadata.id;
}
private readonly schedules: ReadonlyMap<string, ParameterSchedule>;
private readonly latencyListeners = new Set<(samples: number) => void>();
private currentLatencySamples: number;
private readonly supportedSchemaVersion: number;
private initialised = false;
private nowSec = 0;
protected chainContext?: ChainContext;
protected config?: ProcessorConfig;
public constructor(options: AbstractAudioFxPluginOptions) {
this.metadata = options.metadata;
this.schema = options.schema;
this.io = options.io;
this.parameters = options.parameters;
this.parameterIndex = new Map(options.parameters.map((p) => [p.id, p]));
this.capabilities =
options.capabilities ?? defaultCapabilities(options.io);
this.currentLatencySamples = this.capabilities.latencySamples;
this.supportedSchemaVersion = options.supportedSchemaVersion;
this.schedules = new Map(
options.parameters.map((p) => [
p.id,
new ParameterSchedule(p.value),
])
);
if (
!capabilitiesAgreeWithMembers(
this.capabilities,
this as unknown as OptionalPluginMembers
)
) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.capability-method-mismatch",
{ context: { pluginId: this.metadata.id } }
);
}
}
protected setupState(
_config: ProcessorConfig,
_chain: ChainContext,
_opts?: { signal?: AbortSignal }
): Promise<void> | void {
return undefined;
}
protected abstract processBlock(
input: Float32Array[],
output: Float32Array[],
ctx: BlockContext
): void;
protected resetState(): void {}
protected disposeState(): void {}
protected getPresetData(): unknown {
return undefined;
}
protected applyPresetData(_data: unknown): void {}
/** Sample-accurate parameter value at current time. */
protected parameterValue(id: string): number {
requireParameter(this.parameterIndex, id, this.metadata.id);
const sched = this.schedules.get(id)!;
const transportTime =
this.chainContext!.ambient.transport.transportTimeSec;
return sched.valueAtBlockOffset(this.nowSec, transportTime);
}
/** Emit a latency change to every listener. */
protected notifyLatencyChanged(samples: number): void {
if (samples === this.currentLatencySamples) {
return;
}
this.currentLatencySamples = samples;
for (const listener of this.latencyListeners) {
listener(samples);
}
}
public async setup(
config: ProcessorConfig,
chain: ChainContext,
opts?: { signal?: AbortSignal }
): Promise<void> {
assertNotAborted(
opts?.signal,
this.metadata.id,
"audio-fx.setup-pre-aborted"
);
this.config = config;
this.chainContext = chain;
await Promise.resolve(this.setupState(config, chain, opts));
assertNotAborted(
opts?.signal,
this.metadata.id,
"audio-fx.setup-mid-aborted"
);
this.initialised = true;
}
public process(
input: Float32Array[],
output: Float32Array[],
ctx: BlockContext
): void {
if (!this.initialised) {
throw new AudioFxError(
AUDIO_FX_CODES.NOT_INITIALIZED,
"audio-fx.process-before-setup",
{ context: { pluginId: this.metadata.id } }
);
}
const channels = this.config!.channels;
const inSpec = this.io.inputs[0]?.count;
const outSpec = this.io.outputs[0]?.count;
const expectedInput = typeof inSpec === "number" ? inSpec : channels;
const expectedOutput = typeof outSpec === "number" ? outSpec : channels;
assertChannelMatch(
input,
output,
expectedInput,
expectedOutput,
this.metadata.id
);
const nowOpt = ctx.opts["nowSec"];
if (typeof nowOpt === "number") {
this.nowSec = nowOpt;
}
this.processBlock(input, output, ctx);
}
public reset(): void {
const transportTime =
this.chainContext?.ambient.transport.transportTimeSec ?? 0;
for (const sched of this.schedules.values()) {
sched.reset(transportTime);
}
this.resetState();
}
public dispose(): void {
this.initialised = false;
this.latencyListeners.clear();
this.disposeState();
}
public getLatencySamples(): number {
return this.currentLatencySamples;
}
public onLatencyChanged(
listener: (samples: number) => void
): UnsubscribeFn {
this.latencyListeners.add(listener);
return () => {
this.latencyListeners.delete(listener);
};
}
public getParameter(id: string): number {
const sched = this.schedules.get(id);
if (sched === undefined) {
throw new AudioFxError(
AUDIO_FX_CODES.UNKNOWN_PARAMETER,
"audio-fx.unknown-parameter",
{ context: { paramId: id, pluginId: this.metadata.id } }
);
}
return sched.value;
}
public setParameter(id: string, value: number): void {
const param = requireParameter(
this.parameterIndex,
id,
this.metadata.id
);
this.schedules.get(id)!.set(clamp(value, param.min, param.max));
}
public setParameterCurve(id: string, curve: ParameterCurve): void {
const param = requireAutomatable(
this.parameterIndex,
id,
this.metadata.id
);
const target = clamp(curve.value, param.min, param.max);
this.schedules
.get(id)!
.scheduleCurve({ ...curve, value: target }, this.nowSec);
}
public setParameterAt(
id: string,
value: number,
transportTimeSec: number
): void {
const param = requireAutomatable(
this.parameterIndex,
id,
this.metadata.id
);
this.schedules
.get(id)!
.scheduleAt(clamp(value, param.min, param.max), transportTimeSec);
}
public getPreset(): PresetSnapshot {
return buildPresetSnapshot(
this.metadata.id,
this.supportedSchemaVersion,
this.schedules,
this.getPresetData()
);
}
public applyPreset(preset: PresetSnapshot): void {
const validated = parsePresetSnapshot(preset);
assertPresetMatchesPluginType(validated, this.metadata.id);
assertPresetVersionSupported(validated, this.supportedSchemaVersion);
validatePresetData(this.schema, validated.data, this.metadata.id);
applyPresetParameters(
validated.parameters,
this.schedules,
this.parameterIndex
);
this.applyPresetData(validated.data);
}
}

View File

@@ -0,0 +1,193 @@
import type {
BlockContext,
BlockProcessor,
ProcessorConfig,
} from "@suaveplan/audio-core";
import type { ZodType } from "zod";
import type { PluginCapabilities } from "./capabilities.types.js";
import type { ParameterCurve, PluginParameter } from "./parameter.types.js";
import type { PluginIo } from "./plugin-io.types.js";
import type { PresetSnapshot } from "./preset.types.js";
/**
* Plugin metadata — stable identity, human-readable name, semver, category.
* `id` is the registry key; `category` is a coarse bucket used by UI grouping.
*/
export interface PluginMetadata {
readonly id: string;
readonly name: string;
readonly version: string;
readonly category:
| "eq"
| "dynamics"
| "reverb"
| "delay"
| "modulation"
| "distortion"
| "synthesis"
| "sampling"
| "spatial"
| "utility"
| "voice"
| "source"
| "mixer";
readonly description?: string;
readonly tags?: ReadonlyArray<string>;
}
/**
* Read-only view of a peer plugin handed to other plugins via
* `ChainContext.predecessors` / `.successors`. Peers can read each
* other's parameters and IO, but cannot mutate. Mutation always goes
* through the owning plugin's own `setParameter` family.
*/
export interface PluginHandle {
readonly id: string;
readonly metadata: PluginMetadata;
readonly io: PluginIo;
readonly capabilities: PluginCapabilities;
readonly bypassed: boolean;
getParameter(id: string): number;
getLatencySamples(): number;
}
/**
* Engine-pushed ambient state visible to every plugin in a chain.
* The reference is stable for the chain's lifetime; contents update via
* `chain.updateAmbient(patch)` between blocks.
*/
export interface AmbientState {
readonly track: AmbientTrackState;
readonly transport: AmbientTransportState;
readonly listener?: AmbientListenerState;
readonly extension: Readonly<Record<string, unknown>>;
}
export interface AmbientTrackState {
readonly id: string;
readonly gainDb: number;
readonly pan: number;
readonly mute: boolean;
readonly solo: boolean;
readonly layout: string;
}
export interface AmbientTransportState {
readonly tempo: number;
readonly timeSignatureNumerator: number;
readonly timeSignatureDenominator: number;
readonly transportTimeSec: number;
readonly state: "stopped" | "playing" | "paused" | "recording";
readonly ppqn: number;
}
export interface AmbientListenerState {
readonly position: Readonly<{ x: number; y: number; z: number }>;
readonly orientation: Readonly<{
x: number;
y: number;
z: number;
w: number;
}>;
readonly velocity: Readonly<{ x: number; y: number; z: number }>;
}
/**
* One published bus in the project-level `BusRegistry`. Consumers read
* block-aligned PCM into their own scratch buffers.
*/
export interface SidechainBus {
readonly name: string;
readonly channels: number;
read(into: Float32Array[]): void;
}
/**
* Registry handle handed to every chain via `ChainContext.buses`. The
* implementation lives in `chain/bus-registry.ts`; this type captures the
* surface plugins read against, not the engine-owned mutator surface.
*/
export interface BusRegistryReadHandle {
get(name: string): SidechainBus | undefined;
list(): ReadonlyArray<string>;
}
/**
* Read-only handle to the chain itself — id, structural shape, lifetime
* config. Plugins use it to discover whether they sit on a mono or
* stereo chain, what the configured block size is, etc.
*/
export interface ChainHandle {
readonly id: string;
readonly name: string;
readonly channels: number;
readonly sampleRate: number;
readonly blockSize: number;
readonly slotCount: number;
}
/**
* The `ChainContext` instance handed to a plugin at `setup()`. The
* reference is stable for the chain's lifetime; the **contents** update
* across blocks (peer lists when the chain changes, ambient state when
* the engine pushes updates).
*/
export interface ChainContext {
readonly chain: ChainHandle;
readonly position: {
readonly index: number;
readonly total: number;
};
readonly predecessors: ReadonlyArray<PluginHandle>;
readonly successors: ReadonlyArray<PluginHandle>;
readonly ambient: AmbientState;
readonly buses: BusRegistryReadHandle;
}
/** Unsubscribe handle returned by `onLatencyChanged`. */
export type UnsubscribeFn = () => void;
/**
* The universal contract every plugin in this package implements. Extends
* `BlockProcessor` from `@suaveplan/audio-core` so a plugin doubles as a
* `BlockProcessor` consumable by any audio-core stream / pipeline.
*
* Optional members (`createAudioNode`, `getWorkletProcessor`,
* `setSidechain`) reflect the plugin's `capabilities` — engine adapters
* cross-check the flags against method presence.
*/
export interface AudioFxPlugin extends Omit<BlockProcessor, "setup"> {
readonly metadata: PluginMetadata;
readonly schema: ZodType<unknown>;
readonly parameters: ReadonlyArray<PluginParameter>;
readonly parameterIndex: ReadonlyMap<string, PluginParameter>;
readonly io: PluginIo;
readonly capabilities: PluginCapabilities;
setup(
config: ProcessorConfig,
chain: ChainContext,
opts?: { signal?: AbortSignal }
): Promise<void>;
process(
input: Float32Array[],
output: Float32Array[],
ctx: BlockContext
): void;
reset(): void;
dispose(): void;
getLatencySamples(): number;
getParameter(id: string): number;
setParameter(id: string, value: number): void;
setParameterCurve(id: string, curve: ParameterCurve): void;
setParameterAt(id: string, value: number, transportTimeSec: number): void;
onLatencyChanged(listener: (samples: number) => void): UnsubscribeFn;
getPreset(): PresetSnapshot;
applyPreset(preset: PresetSnapshot): void;
createAudioNode?(ctx: unknown, chain: ChainContext): unknown;
getWorkletProcessor?(): string;
setSidechain?(bus: SidechainBus | undefined): void;
}

View File

@@ -0,0 +1,69 @@
# `capabilities`
## Purpose
`PluginCapabilities` is the **explicit feature-flag declaration** every `AudioFxPlugin` carries. Engines route audio through one of three render paths (browser-native `AudioNode`, `AudioWorklet` fallback, universal `BlockProcessor`); each requires different optional members on the plugin. Rather than feature-detecting via `typeof plugin.createAudioNode === 'function'` and other opaque probes scattered across the engine adapters, plugins declare what they support up front, and the chain + engine read the typed flags directly. The two `defaultCapabilities(io)` and `capabilitiesAgreeWithMembers(cap, members)` helpers enforce honesty: plugin construction can call them to ensure the flag set matches reality.
## Features
- A `PluginCapabilities` interface covering eight static flags: `hasAudioNode`, `hasWorklet`, `hasSidechain`, `sampleAccurate`, `stateful`, `asyncSetup`, `preservesChannelCount`, `latencySamples` (initial).
- `defaultCapabilities(io)` returns a stateless / sample-accurate / no-async / universal-only baseline with `preservesChannelCount` derived from the declared `PluginIo`.
- `capabilitiesAgreeWithMembers(cap, members)` confirms flag/method symmetry — the basis for an automated lint-style assertion in tests.
- `withCapabilities(base, overrides)` produces a merged copy (no mutation) so plugin authors can express only their deltas.
## Usage: Basic Example
```ts
import { defaultCapabilities, withCapabilities } from "@suaveplan/audio-fx-plugin-base/base";
const io = { inputs: [{ count: 2 }], outputs: [{ count: "preserve" as const }] };
const capabilities = withCapabilities(defaultCapabilities(io), {
stateful: true, // delay lines persist across blocks
latencySamples: 64, // lookahead limiter
});
```
## Usage: Advanced Example
```ts
import {
capabilitiesAgreeWithMembers,
defaultCapabilities,
withCapabilities,
} from "@suaveplan/audio-fx-plugin-base/base";
class PitchShifterPlugin {
readonly io = { inputs: [{ count: 2 }], outputs: [{ count: "preserve" as const }] };
readonly capabilities = withCapabilities(defaultCapabilities(this.io), {
hasWorklet: true,
stateful: true,
asyncSetup: true,
latencySamples: 2048, // one FFT frame
});
getWorkletProcessor() { return PITCH_SHIFTER_WORKLET_SOURCE; }
// Constructor asserts in dev to catch drift between flags and members:
constructor() {
if (!capabilitiesAgreeWithMembers(this.capabilities, this)) {
throw new Error("capability flags do not agree with method presence");
}
}
}
```
## API Reference
- `PluginCapabilities` — readonly record of eight flags. All members required at plugin construction.
- `OptionalPluginMembers` — the three optional plugin members the flags cross-reference (`createAudioNode`, `getWorkletProcessor`, `setSidechain`).
- `defaultCapabilities(io: PluginIo): PluginCapabilities` — returns the universal-only baseline; `preservesChannelCount` derived from `ioPreservesChannelCount(io)`.
- `capabilitiesAgreeWithMembers(cap, members): boolean` — returns true iff every `has*` flag matches `typeof members.<m> === 'function'`.
- `withCapabilities(base, overrides): PluginCapabilities` — returns `{ ...base, ...overrides }`; never mutates `base`.
## Implementation Notes
`latencySamples` on `PluginCapabilities` is the **initial** value — plugins whose latency changes at runtime (lookahead limiters with variable lookahead, dynamic FFT-size pitch shifters) emit `onLatencyChanged(samples)` and the chain re-reads `getLatencySamples()`. The static flag is the value an engine should assume at first `setup()` call; dynamic recomputation happens through the event surface.
`capabilitiesAgreeWithMembers` is intentionally cheap (three `typeof` checks). The `AbstractAudioFxPlugin` reference class calls it in its constructor under a debug-only branch (no perf cost in production), and test suites call it once per concrete plugin to gate releases. A mismatch is a bug in the plugin author's declaration, not a runtime condition; failing fast in test is the right call.
`defaultCapabilities` ties `preservesChannelCount` to `ioPreservesChannelCount(io)` so plugin authors who change their IO (e.g. add a downmix output) automatically get the right capability without restating it; the test suite catches the symmetry by running both helpers against every exported plugin.

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
capabilitiesAgreeWithMembers,
defaultCapabilities,
withCapabilities,
} from "./capabilities.js";
import type { PluginIo } from "./plugin-io.types.js";
const STEREO_PRESERVE_IO: PluginIo = {
inputs: [{ count: 2 }],
outputs: [{ count: "preserve" }],
};
describe("capabilities: defaultCapabilities", () => {
it("returns a stateless, sample-accurate, universal-only baseline", {
timeout: 5_000,
}, () => {
const cap = defaultCapabilities(STEREO_PRESERVE_IO);
expect(cap.hasAudioNode).toBe(false);
expect(cap.hasWorklet).toBe(false);
expect(cap.hasSidechain).toBe(false);
expect(cap.sampleAccurate).toBe(true);
expect(cap.stateful).toBe(false);
expect(cap.asyncSetup).toBe(false);
expect(cap.preservesChannelCount).toBe(true);
expect(cap.latencySamples).toBe(0);
});
it("derives preservesChannelCount from declared IO", {
timeout: 5_000,
}, () => {
const upmix: PluginIo = {
inputs: [{ count: 1 }],
outputs: [{ count: 2 }],
};
expect(defaultCapabilities(upmix).preservesChannelCount).toBe(false);
});
});
describe("capabilities: capabilitiesAgreeWithMembers", () => {
it("agrees when no optional methods and no flags claim them", {
timeout: 5_000,
}, () => {
const cap = defaultCapabilities(STEREO_PRESERVE_IO);
expect(capabilitiesAgreeWithMembers(cap, {})).toBe(true);
});
it("agrees when hasAudioNode === typeof createAudioNode is function", {
timeout: 5_000,
}, () => {
const cap = withCapabilities(defaultCapabilities(STEREO_PRESERVE_IO), {
hasAudioNode: true,
});
expect(
capabilitiesAgreeWithMembers(cap, { createAudioNode: () => null })
).toBe(true);
});
it("disagrees when hasAudioNode is true but createAudioNode is absent", {
timeout: 5_000,
}, () => {
const cap = withCapabilities(defaultCapabilities(STEREO_PRESERVE_IO), {
hasAudioNode: true,
});
expect(capabilitiesAgreeWithMembers(cap, {})).toBe(false);
});
it("disagrees when createAudioNode is present but flag is false", {
timeout: 5_000,
}, () => {
const cap = defaultCapabilities(STEREO_PRESERVE_IO);
expect(
capabilitiesAgreeWithMembers(cap, { createAudioNode: () => null })
).toBe(false);
});
it("checks worklet and sidechain symmetrically", {
timeout: 5_000,
}, () => {
const cap = withCapabilities(defaultCapabilities(STEREO_PRESERVE_IO), {
hasWorklet: true,
hasSidechain: true,
});
expect(
capabilitiesAgreeWithMembers(cap, {
getWorkletProcessor: () => "",
setSidechain: () => undefined,
})
).toBe(true);
expect(capabilitiesAgreeWithMembers(cap, {})).toBe(false);
});
it("disagrees specifically when hasSidechain flag does not match", {
timeout: 5_000,
}, () => {
const flagOnly = withCapabilities(
defaultCapabilities(STEREO_PRESERVE_IO),
{ hasSidechain: true }
);
expect(capabilitiesAgreeWithMembers(flagOnly, {})).toBe(false);
const methodOnly = defaultCapabilities(STEREO_PRESERVE_IO);
expect(
capabilitiesAgreeWithMembers(methodOnly, {
setSidechain: () => undefined,
})
).toBe(false);
});
});
describe("capabilities: withCapabilities", () => {
it("merges overrides on top of the base", { timeout: 5_000 }, () => {
const base = defaultCapabilities(STEREO_PRESERVE_IO);
const merged = withCapabilities(base, {
stateful: true,
latencySamples: 64,
});
expect(merged.stateful).toBe(true);
expect(merged.latencySamples).toBe(64);
expect(merged.sampleAccurate).toBe(true);
expect(merged.preservesChannelCount).toBe(true);
});
it("does not mutate the base capabilities object", {
timeout: 5_000,
}, () => {
const base = defaultCapabilities(STEREO_PRESERVE_IO);
const before = { ...base };
withCapabilities(base, { latencySamples: 128 });
expect(base).toEqual(before);
});
});

View File

@@ -0,0 +1,59 @@
import type {
OptionalPluginMembers,
PluginCapabilities,
} from "./capabilities.types.js";
import { ioPreservesChannelCount } from "./plugin-io.js";
import type { PluginIo } from "./plugin-io.types.js";
/**
* Sensible defaults for a stateless, universal-only plugin that preserves
* channel count. Plugin authors merge their overrides on top.
*/
export function defaultCapabilities(io: PluginIo): PluginCapabilities {
return {
hasAudioNode: false,
hasWorklet: false,
hasSidechain: false,
sampleAccurate: true,
stateful: false,
asyncSetup: false,
preservesChannelCount: ioPreservesChannelCount(io),
latencySamples: 0,
};
}
/**
* Returns true when every flag → method-presence claim agrees with the
* supplied plugin members. Used by `AbstractAudioFxPlugin` and tests to
* keep capability declarations honest.
*/
export function capabilitiesAgreeWithMembers(
capabilities: PluginCapabilities,
members: OptionalPluginMembers
): boolean {
const hasFn = (value: unknown): value is (...args: never[]) => unknown =>
typeof value === "function";
if (capabilities.hasAudioNode !== hasFn(members.createAudioNode)) {
return false;
}
if (capabilities.hasWorklet !== hasFn(members.getWorkletProcessor)) {
return false;
}
if (capabilities.hasSidechain !== hasFn(members.setSidechain)) {
return false;
}
return true;
}
/**
* Returns a copy of `base` with the supplied overrides merged on top.
* Intentionally typed so that authors can override individual flags
* without restating the unchanged ones.
*/
export function withCapabilities(
base: PluginCapabilities,
overrides: Partial<PluginCapabilities>
): PluginCapabilities {
return { ...base, ...overrides };
}

View File

@@ -0,0 +1,35 @@
/**
* Static feature flags carried by every plugin. Engine adapters read these
* to pick the render path (AudioNode → Worklet → BlockProcessor fallback)
* and to decide whether to await `setup()`. Capabilities describe contract
* — runtime state (bypassed?, latency right now?) is queried separately.
*/
export interface PluginCapabilities {
/** Plugin exposes `createAudioNode(context, chain)`. */
readonly hasAudioNode: boolean;
/** Plugin exposes `getWorkletProcessor()` returning a self-contained source string. */
readonly hasWorklet: boolean;
/** Plugin exposes `setSidechain(bus)` for external sidechain input. */
readonly hasSidechain: boolean;
/** `process()` honours sample-accurate parameter ramps mid-block. */
readonly sampleAccurate: boolean;
/** `reset()` clears DSP state that survives across blocks (delay lines, filter histories). */
readonly stateful: boolean;
/** `setup()` may take meaningful wall-clock time; engines must await before first `process()`. */
readonly asyncSetup: boolean;
/** `process()` always produces the same channel count it received. */
readonly preservesChannelCount: boolean;
/** Initial latency in samples. Plugins with dynamic latency emit `onLatencyChanged`. */
readonly latencySamples: number;
}
/**
* Optional members on `AudioFxPlugin` whose presence is mirrored by the
* capability flags. Used by `deriveCapabilities` to cross-check that flags
* agree with method presence at construction time.
*/
export interface OptionalPluginMembers {
readonly createAudioNode?: unknown;
readonly getWorkletProcessor?: unknown;
readonly setSidechain?: unknown;
}

View File

@@ -0,0 +1,64 @@
export {
AbstractAudioFxPlugin,
type AbstractAudioFxPluginOptions,
} from "./audio-fx-plugin.js";
export type {
AmbientListenerState,
AmbientState,
AmbientTrackState,
AmbientTransportState,
AudioFxPlugin,
BusRegistryReadHandle,
ChainContext,
ChainHandle,
PluginHandle,
PluginMetadata,
SidechainBus,
UnsubscribeFn,
} from "./audio-fx-plugin.types.js";
export {
capabilitiesAgreeWithMembers,
defaultCapabilities,
withCapabilities,
} from "./capabilities.js";
export type {
OptionalPluginMembers,
PluginCapabilities,
} from "./capabilities.types.js";
export type {
ParameterAtTransport,
ParameterCurve,
ParameterScale,
ParameterUnit,
PluginParameter,
} from "./parameter.types.js";
export { ParameterSchedule } from "./parameter-schedule.js";
export {
assertChannelMatch,
assertNotAborted,
requireAutomatable,
requireParameter,
} from "./plugin-internals/index.js";
export {
ioPreservesChannelCount,
isSourcePlugin,
resolveOutputChannels,
validateChainIo,
} from "./plugin-io.js";
export type {
ChannelLayout,
ChannelSpec,
IoCompatibility,
PluginIo,
} from "./plugin-io.types.js";
export {
applyPresetParameters,
assertPresetMatchesPluginType,
assertPresetVersionSupported,
buildPresetSnapshot,
clamp,
PresetSnapshotSchema,
parsePresetSnapshot,
validatePresetData,
} from "./preset.js";
export type { PresetSnapshot } from "./preset.types.js";

View File

@@ -0,0 +1,68 @@
# `parameter-schedule`
## Purpose
`ParameterSchedule` is the **per-parameter automation evaluator** every plugin uses. One instance per `(plugin, parameter id)` owns the current value plus two queues of pending events: wall-clock curves (`setParameterCurve`) and transport-time events (`setParameterAt`). On every process block, the plugin asks the schedule for `valueAtBlockOffset(nowSec, transportTimeSec)`; the schedule advances the queues, applies any events whose deadline has been reached, interpolates the head curve if mid-ramp, and returns the value to apply. Determinism is total: for identical inputs the schedule returns the same value, and writes never travel backwards in time.
## Features
- Wall-clock linear / exponential / target-following ramps via `scheduleCurve(curve, nowSec)`.
- Musical-time scheduled events via `scheduleAt(value, transportTimeSec)` resolved against the chain's `ambient.transport.transportTimeSec`.
- Shared queue: a transport event overrides any in-flight wall-clock curve so musical automation lanes are authoritative over UI gestures.
- `reset(transportTimeSec)` drops curves and past transport events but keeps future events pending, matching DAW stop-rewind semantics.
- Sample-accurate interpolation through `linear` / `exponential` (Web-Audio-equivalent) / `target` (first-order time-constant) curves.
- Idempotent: repeated reads with the same `(nowSec, transportTimeSec)` produce identical values without draining the queue.
## Usage: Basic Example
```ts
import { ParameterSchedule } from "@suaveplan/audio-fx-plugin-base/base";
const sched = new ParameterSchedule(0); // initial value
sched.scheduleCurve(
{ type: "linear", value: 0.75, duration: 0.05 }, // 50 ms fade-in
nowSec
);
// In each process() block:
const value = sched.valueAtBlockOffset(nowSec, ambient.transport.transportTimeSec);
```
## Usage: Advanced Example
```ts
// Tempo-synced ducking: drop gain to -12 dB on the next beat.
const beatTimeSec = ambient.transport.transportTimeSec + secondsUntilNextBeat;
gainSchedule.scheduleAt(-12, beatTimeSec);
// Then return to 0 dB four beats later via a linear ramp.
const returnAt = beatTimeSec + (60 / ambient.transport.tempo) * 4;
gainSchedule.scheduleCurve(
{ type: "linear", value: 0, duration: 0.5, startTime: returnAt },
nowSec
);
// On transport rewind:
gainSchedule.reset(ambient.transport.transportTimeSec);
```
## API Reference
- `class ParameterSchedule(initialValue: number)` — single-parameter evaluator.
- `get value(): number` — last-applied value; updated when `valueAtBlockOffset` advances past an event or curve end.
- `set(value: number): void` — jumps the value immediately and drops pending wall-clock curves (transport events stay pending).
- `scheduleCurve(curve: ParameterCurve, nowSec: number): void` — enqueues a wall-clock ramp. `curve.startTime` defaults to `nowSec` when absent.
- `scheduleAt(value: number, transportTimeSec: number): void` — enqueues a transport-time event.
- `valueAtBlockOffset(nowSec: number, transportTimeSec: number): number` — returns the value to apply; advances queues past completed events.
- `reset(transportTimeSec: number): void` — drops curves; drops transport events whose deadline is in the past; keeps future events.
- `pendingCurveCount`, `pendingTransportEventCount` — for tests / introspection.
## Implementation Notes
The schedule uses two arrays kept in deadline order (insertion-sorted; `O(n log n)` per add, `O(1)` per drain head). For typical parameter use (a handful of pending events) this is faster than a heap. If a plugin queues thousands of events per parameter (extreme cases, like algorithmic LFO writes), the implementation can be swapped to a min-heap behind the same surface without changing callers.
The "shared queue" rule — a transport event overrides an in-flight wall-clock curve — is deliberate. In a DAW, automation lanes (transport time) take priority over knob gestures (wall clock) because the lane is the persisted intent; UI gestures are transient. The reverse precedence would let a stray knob gesture mid-render derail the persisted automation.
`startValue` for a wall-clock curve is captured on the **first poll**, not at schedule time. This means a curve scheduled at `nowSec = 1.0` with `startTime = 5.0` interpolates from whatever the value is at `t = 5.0`, not at `t = 1.0` — matching the Web Audio AudioParam scheduling model. Tests verify this by pre-polling at `t = 0.25` before the ramp's end.
`exponential` interpolation uses a small epsilon to avoid division by zero when starting from `0`; the result is monotonic and matches Web Audio semantics within float tolerance. `target` is the standard first-order ODE (`y' = (target - y) / τ`); the supplied `duration` is the time constant, so the value reaches ≥ 95 % of target after `~3 × duration`.

View File

@@ -0,0 +1,227 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ParameterSchedule } from "./parameter-schedule.js";
describe("ParameterSchedule: basic value access", () => {
it("returns the constructed initial value", { timeout: 5_000 }, () => {
const s = new ParameterSchedule(0.5);
expect(s.value).toBe(0.5);
expect(s.valueAtBlockOffset(0, 0)).toBe(0.5);
});
it("set() jumps the value immediately and drops pending curves", {
timeout: 5_000,
}, () => {
const s = new ParameterSchedule(0);
s.scheduleCurve({ type: "linear", value: 10, duration: 1 }, 0);
expect(s.pendingCurveCount).toBe(1);
s.set(7);
expect(s.value).toBe(7);
expect(s.pendingCurveCount).toBe(0);
expect(s.valueAtBlockOffset(0.5, 0)).toBe(7);
});
});
describe("ParameterSchedule: linear curves", () => {
it("interpolates exactly halfway across the ramp", {
timeout: 5_000,
}, () => {
const s = new ParameterSchedule(0);
s.scheduleCurve(
{ type: "linear", value: 100, duration: 1, startTime: 0 },
0
);
const half = s.valueAtBlockOffset(0.5, 0);
expect(half).toBeCloseTo(50, 6);
});
it("reaches target exactly at endSec and drains the queue", {
timeout: 5_000,
}, () => {
const s = new ParameterSchedule(0);
s.scheduleCurve(
{ type: "linear", value: 100, duration: 1, startTime: 0 },
0
);
// First poll mid-ramp so the curve's startValue is captured at 0.
s.valueAtBlockOffset(0.25, 0);
const atEnd = s.valueAtBlockOffset(1.0, 0);
expect(atEnd).toBe(100);
expect(s.pendingCurveCount).toBe(0);
});
it("idempotent for identical inputs (no advance, no drain)", {
timeout: 5_000,
}, () => {
const s = new ParameterSchedule(0);
s.scheduleCurve(
{ type: "linear", value: 10, duration: 1, startTime: 0 },
0
);
expect(s.valueAtBlockOffset(0.5, 0)).toBeCloseTo(5, 6);
expect(s.valueAtBlockOffset(0.5, 0)).toBeCloseTo(5, 6);
expect(s.pendingCurveCount).toBe(1);
});
it("startTime in the future is respected", { timeout: 5_000 }, () => {
const s = new ParameterSchedule(2);
s.scheduleCurve(
{ type: "linear", value: 4, duration: 1, startTime: 5 },
0
);
expect(s.valueAtBlockOffset(3, 0)).toBe(2);
expect(s.valueAtBlockOffset(5.5, 0)).toBeCloseTo(3, 6);
expect(s.valueAtBlockOffset(6, 0)).toBe(4);
});
});
describe("ParameterSchedule: exponential curves", () => {
it("starts at startValue and ends at targetValue", {
timeout: 5_000,
}, () => {
const s = new ParameterSchedule(1);
s.scheduleCurve(
{ type: "exponential", value: 8, duration: 1, startTime: 0 },
0
);
// First read at t=0 captures the startValue (= 1)
expect(s.valueAtBlockOffset(0, 0)).toBe(1);
// Mid-ramp should equal 1 * 8^0.5 = ~2.828
const mid = s.valueAtBlockOffset(0.5, 0);
expect(mid).toBeCloseTo(Math.sqrt(8), 5);
expect(s.valueAtBlockOffset(1, 0)).toBeCloseTo(8, 5);
});
it("handles a zero starting value without producing NaN", {
timeout: 5_000,
}, () => {
const s = new ParameterSchedule(0);
s.scheduleCurve(
{ type: "exponential", value: 1, duration: 1, startTime: 0 },
0
);
const mid = s.valueAtBlockOffset(0.5, 0);
expect(Number.isFinite(mid)).toBe(true);
});
it("handles a zero target value without producing NaN", {
timeout: 5_000,
}, () => {
const s = new ParameterSchedule(1);
s.scheduleCurve(
{ type: "exponential", value: 0, duration: 1, startTime: 0 },
0
);
const mid = s.valueAtBlockOffset(0.5, 0);
expect(Number.isFinite(mid)).toBe(true);
expect(mid).toBeGreaterThan(0);
});
it("sorts pending curves by end time so the shorter one drains first", {
timeout: 5_000,
}, () => {
const s = new ParameterSchedule(0);
// Schedule out-of-order: curve A ends at 5, curve B ends at 1.
s.scheduleCurve(
{ type: "linear", value: 100, duration: 5, startTime: 0 },
0
);
s.scheduleCurve(
{ type: "linear", value: 50, duration: 1, startTime: 0 },
0
);
// After t=1 the shorter curve has drained and the longer remains.
const after1 = s.valueAtBlockOffset(1, 0);
expect(s.pendingCurveCount).toBe(1);
// Drain snapped current to 50; the remaining curve then captures
// startValue=50 and interpolates toward 100 with t = 1/5 = 0.2.
expect(after1).toBeCloseTo(50 + (100 - 50) * 0.2, 6);
});
it("drops a zero-duration linear curve instantly via the drain step", {
timeout: 5_000,
}, () => {
const s = new ParameterSchedule(0);
s.scheduleCurve(
{ type: "linear", value: 9, duration: 0, startTime: 0 },
0
);
// endSec === startSec, so the drain at `valueAtBlockOffset(0, 0)`
// snaps the value to target without ever calling interpolateCurve.
expect(s.valueAtBlockOffset(0, 0)).toBe(9);
expect(s.pendingCurveCount).toBe(0);
});
});
describe("ParameterSchedule: target (first-order) curves", () => {
it("approaches target asymptotically", { timeout: 5_000 }, () => {
const s = new ParameterSchedule(0);
s.scheduleCurve(
{ type: "target", value: 1, duration: 0.1, startTime: 0 },
0
);
// Pin startValue at t=0.
expect(s.valueAtBlockOffset(0, 0)).toBe(0);
// After ~3 time constants we should be ≥ 95 % of the way there.
const after3Tau = s.valueAtBlockOffset(0.3, 0);
expect(after3Tau).toBeGreaterThan(0.94);
expect(after3Tau).toBeLessThan(1);
});
});
describe("ParameterSchedule: transport-time schedules", () => {
it("applies a transport event when the deadline is reached", {
timeout: 5_000,
}, () => {
const s = new ParameterSchedule(0);
s.scheduleAt(0.5, 2);
expect(s.valueAtBlockOffset(0, 0)).toBe(0);
expect(s.valueAtBlockOffset(0, 1.999)).toBe(0);
expect(s.valueAtBlockOffset(0, 2)).toBe(0.5);
});
it("transport event overrides an in-flight curve", {
timeout: 5_000,
}, () => {
const s = new ParameterSchedule(0);
s.scheduleCurve(
{ type: "linear", value: 100, duration: 1, startTime: 0 },
0
);
s.scheduleAt(42, 0.5);
const result = s.valueAtBlockOffset(0.5, 0.5);
expect(result).toBe(42);
expect(s.pendingCurveCount).toBe(0);
});
it("preserves event order across multiple schedules", {
timeout: 5_000,
}, () => {
const s = new ParameterSchedule(0);
s.scheduleAt(2, 2);
s.scheduleAt(1, 1);
s.scheduleAt(3, 3);
expect(s.pendingTransportEventCount).toBe(3);
expect(s.valueAtBlockOffset(0, 1)).toBe(1);
expect(s.valueAtBlockOffset(0, 2)).toBe(2);
expect(s.valueAtBlockOffset(0, 3)).toBe(3);
expect(s.pendingTransportEventCount).toBe(0);
});
});
describe("ParameterSchedule: reset semantics", () => {
it("drops curves and past transport events; keeps future ones", {
timeout: 5_000,
}, () => {
const s = new ParameterSchedule(0);
s.scheduleCurve(
{ type: "linear", value: 10, duration: 1, startTime: 0 },
0
);
s.scheduleAt(5, 0.5);
s.scheduleAt(7, 3);
s.reset(1);
expect(s.pendingCurveCount).toBe(0);
expect(s.pendingTransportEventCount).toBe(1); // only the t=3 event survives
expect(s.valueAtBlockOffset(0, 3)).toBe(7);
});
});

View File

@@ -0,0 +1,182 @@
import type {
ParameterAtTransport,
ParameterCurve,
} from "./parameter.types.js";
/**
* Sample-accurate evaluator for a single parameter's pending wall-clock
* curves and transport-time scheduled events. One instance per
* (plugin, parameter id). The evaluator owns the current value and the
* queue of pending events; the plugin's `process()` calls
* `valueAtBlockOffset(...)` once per block to read the value to apply.
*
* Behaviour:
*
* - `set(value)` jumps the current value immediately (clamped by caller).
* - `scheduleCurve(curve, nowSec)` enqueues a wall-clock ramp.
* `nowSec` is the engine's current wall-clock seconds; `curve.startTime`
* defaults to `nowSec` when absent.
* - `scheduleAt(value, transportTimeSec)` enqueues a transport-time event.
* - `valueAtBlockOffset(nowSec, transportTimeSec)` returns the value at
* the requested time, advancing the queue past completed events.
* Idempotent for identical inputs; never reads "future" events.
* - `reset(transportTimeSec)` drops every scheduled event whose deadline is
* in the past relative to the supplied transport time; future events stay
* pending (matches DAW stop-rewind semantics).
*
* Determinism: events are sorted by their resolved deadline; ties are
* broken by enqueue order. No global state; tests can drive the evaluator
* directly against a synthetic clock.
*/
export class ParameterSchedule {
private current: number;
private pendingCurves: PendingCurve[] = [];
private pendingAt: ParameterAtTransport[] = [];
public constructor(initialValue: number) {
this.current = initialValue;
}
public get value(): number {
return this.current;
}
public set(value: number): void {
this.current = value;
// A direct `set` overrides any pending curves whose deadline has not
// yet fired; transport-time events stay pending because they target
// a specific musical moment.
this.pendingCurves = [];
}
public scheduleCurve(curve: ParameterCurve, nowSec: number): void {
const startSec = curve.startTime ?? nowSec;
// `target` is a first-order approach: it never reaches the target
// exactly but is ≥ 99.3 % complete after 5τ. Drain at 5τ to bound
// memory; linear / exponential drain at their declared end.
const drainSec =
curve.type === "target"
? startSec + Math.max(0, curve.duration) * 5
: startSec + Math.max(0, curve.duration);
this.pendingCurves.push({
type: curve.type,
startSec,
durationSec: Math.max(0, curve.duration),
endSec: drainSec,
startValue: undefined,
targetValue: curve.value,
});
this.pendingCurves.sort((a, b) => a.endSec - b.endSec);
}
public scheduleAt(value: number, transportTimeSec: number): void {
this.pendingAt.push({ value, transportTimeSec });
this.pendingAt.sort((a, b) => a.transportTimeSec - b.transportTimeSec);
}
public valueAtBlockOffset(
nowSec: number,
transportTimeSec: number
): number {
// Drain wall-clock curves whose end is at or before `nowSec`.
while (
this.pendingCurves.length > 0 &&
this.pendingCurves[0]!.endSec <= nowSec
) {
const c = this.pendingCurves.shift()!;
this.current = c.targetValue;
}
// Apply transport-time events whose deadline is at or before
// `transportTimeSec`.
while (
this.pendingAt.length > 0 &&
this.pendingAt[0]!.transportTimeSec <= transportTimeSec
) {
const event = this.pendingAt.shift()!;
this.current = event.value;
// Drop any in-flight curve that would have continued past this
// point: transport-time events are authoritative.
this.pendingCurves = [];
}
// Interpolate the head curve if we're mid-ramp.
const head = this.pendingCurves[0];
if (head === undefined) {
return this.current;
}
if (nowSec < head.startSec) {
return this.current;
}
if (head.startValue === undefined) {
head.startValue = this.current;
}
return interpolateCurve(head, nowSec);
}
public reset(transportTimeSec: number): void {
// Drop any wall-clock curve that has already started — there is no
// meaningful "transport" reference for wall-clock ramps, so a reset
// truncates them.
this.pendingCurves = [];
this.pendingAt = this.pendingAt.filter(
(event) => event.transportTimeSec > transportTimeSec
);
}
public get pendingCurveCount(): number {
return this.pendingCurves.length;
}
public get pendingTransportEventCount(): number {
return this.pendingAt.length;
}
}
interface PendingCurve {
readonly type: "linear" | "exponential" | "target";
readonly startSec: number;
readonly durationSec: number;
readonly endSec: number;
startValue: number | undefined;
readonly targetValue: number;
}
/**
* Interpolates a pending curve at `nowSec`. The caller in
* `valueAtBlockOffset` guarantees:
*
* - `curve.startValue` is captured before the call;
* - `nowSec >= curve.startSec` (the caller skips future-start curves);
* - `nowSec < curve.endSec` (completed curves are drained before).
*
* Therefore no defensive branches for `startValue === undefined` or
* `span <= 0` are needed here; a zero-duration ramp is treated as an
* instantaneous jump at the curve's drain step.
*/
function interpolateCurve(curve: PendingCurve, nowSec: number): number {
const startValue = curve.startValue as number;
const span = curve.durationSec;
const elapsed = nowSec - curve.startSec;
switch (curve.type) {
case "linear": {
const t = Math.min(1, Math.max(0, elapsed / span));
return startValue + (curve.targetValue - startValue) * t;
}
case "exponential": {
const t = Math.min(1, Math.max(0, elapsed / span));
const safeStart = startValue === 0 ? Number.EPSILON : startValue;
const safeTarget =
curve.targetValue === 0 ? Number.EPSILON : curve.targetValue;
const ratio = safeTarget / safeStart;
return safeStart * ratio ** t;
}
case "target": {
// First-order time-constant approach — never clamps. Active until
// the schedule drains the curve at 5τ.
const tau = Math.max(Number.EPSILON, span);
const k = 1 - Math.exp(-Math.max(0, elapsed) / tau);
return startValue + (curve.targetValue - startValue) * k;
}
}
}

View File

@@ -0,0 +1,67 @@
/**
* Display unit for a plugin parameter. UI renderers pick a control
* (linear slider, log knob, segmented control) based on the unit, and
* format `aria-valuetext` against it.
*/
export type ParameterUnit =
| "db"
| "hz"
| "ms"
| "percent"
| "semitones"
| "samples"
| "ratio"
| "enum";
/**
* Numeric scaling for a parameter — used by the UI to map a 0..1
* control position to / from the parameter's `[min, max]` range. The
* schedule evaluator interpolates linearly regardless of scale.
*/
export type ParameterScale = "linear" | "log" | "exponential";
/**
* Static declaration of a single plugin parameter. Plugins expose
* `parameters: ReadonlyArray<PluginParameter>` in declaration order
* (for UI iteration) plus `parameterIndex: ReadonlyMap<string, PluginParameter>`
* for O(1) lookup.
*/
export interface PluginParameter {
readonly id: string;
readonly name: string;
readonly value: number;
readonly min: number;
readonly max: number;
readonly default: number;
readonly unit?: ParameterUnit;
readonly scale?: ParameterScale;
readonly automatable?: boolean;
}
/**
* A scheduled parameter ramp expressed in wall-clock seconds.
*
* - `linear` — straight line from current value to `value` over `duration`.
* - `exponential` — exponential curve (Web-Audio-equivalent semantics).
* - `target` — first-order approach toward `value` with time constant
* `duration` (never quite reaches, asymptotes).
*
* `startTime` is wall-clock seconds; when absent, the ramp starts at the
* next process block.
*/
export interface ParameterCurve {
readonly type: "linear" | "exponential" | "target";
readonly value: number;
readonly duration: number;
readonly startTime?: number;
}
/**
* A scheduled value-at-transport-time event. Resolved against the active
* `Transport.transportTimeSec` (carried on `AmbientState`) so musical-time
* automation lanes stay tempo-synchronous.
*/
export interface ParameterAtTransport {
readonly value: number;
readonly transportTimeSec: number;
}

View File

@@ -0,0 +1,6 @@
export {
assertChannelMatch,
assertNotAborted,
requireAutomatable,
requireParameter,
} from "./plugin-internals.js";

View File

@@ -0,0 +1,102 @@
# `plugin-internals`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `plugin-internals` module owns the **boundary guards + parameter validation helpers** consumed by `AbstractAudioFxPlugin` and any other plugin author who wants to keep their `setup` / `process` / parameter-mutation methods tight. Splitting these out of the abstract class file keeps `audio-fx-plugin.ts` focused on lifecycle orchestration (parameter schedules, latency listeners, preset application) while concentrating the dense `if/throw` validation in a small standalone surface that future plugin base classes (a more permissive `LooseAudioFxPlugin` for prototyping, a benchmark-only `BenchmarkPlugin` that skips half the checks) can reuse without inheriting from the full `AbstractAudioFxPlugin`.
Every helper takes `pluginId` as an argument and propagates it to the `AudioFxError.context.pluginId` payload so consumers grepping for which plugin produced a given error can pattern-match against it directly.
## Features
- `assertNotAborted(signal, pluginId, messageKey)` — throws `AUDIO_FX.ABORTED` when `signal` is aborted. No-op otherwise. The plugin's `setup` method uses this twice: once before any allocation and once after any in-flight async work.
- `assertChannelMatch(input, output, expectedInput, expectedOutput, pluginId)` — throws `AUDIO_FX.CHANNEL_MISMATCH` with full channel-count context when either buffer disagrees with the resolved input / output channel counts. Source plugins pass `expectedInput === 0`; fixed-output plugins (e.g. the sampler) pass their declared output count.
- `requireParameter(parameterIndex, id, pluginId)` — returns the parameter record or throws `AUDIO_FX.UNKNOWN_PARAMETER`.
- `requireAutomatable(parameterIndex, id, pluginId)` — returns the parameter after asserting it is automatable; throws `AUDIO_FX.PARAMETER_NOT_AUTOMATABLE` when `automatable: false`.
## Usage: Basic Example
```ts
import {
assertChannelMatch,
requireParameter,
} from "@suaveplan/audio-fx-plugin-base/base";
class MyPlugin extends AbstractAudioFxPlugin {
public mutateParameter(id: string, value: number): void {
const param = requireParameter(this.parameterIndex, id, this.metadata.id);
// ... use param.min / param.max ...
}
protected processBlock(input: Float32Array[], output: Float32Array[], ctx: BlockContext): void {
assertChannelMatch(
input,
output,
this.config!.channels,
this.config!.channels,
this.metadata.id
);
// ... DSP ...
}
}
```
## Usage: Advanced Example
```ts
import {
assertChannelMatch,
assertNotAborted,
requireAutomatable,
requireParameter,
} from "@suaveplan/audio-fx-plugin-base/base";
class CustomPlugin implements AudioFxPlugin {
private readonly parameterIndex: ReadonlyMap<string, PluginParameter>;
private readonly schedules: Map<string, ParameterSchedule>;
public async setup(config: ProcessorConfig, _chain: ChainContext, opts?: { signal?: AbortSignal }): Promise<void> {
assertNotAborted(opts?.signal, this.metadata.id, "custom.setup-pre-aborted");
await this.loadResources(opts?.signal);
assertNotAborted(opts?.signal, this.metadata.id, "custom.setup-mid-aborted");
this.config = config;
}
public setParameterCurve(id: string, curve: ParameterCurve): void {
const param = requireAutomatable(this.parameterIndex, id, this.metadata.id);
const sched = this.schedules.get(id)!;
// ... schedule with clamped target against param.min / param.max ...
}
public process(input: Float32Array[], output: Float32Array[], ctx: BlockContext): void {
assertChannelMatch(
input,
output,
this.config!.channels,
this.config!.channels,
this.metadata.id
);
// ... DSP ...
}
}
```
## API Reference
- `assertNotAborted(signal: AbortSignal | undefined, pluginId: string, messageKey: string): void`.
- `assertChannelMatch(input, output, expectedInput: number, expectedOutput: number, pluginId: string): void`.
- `requireParameter(parameterIndex, id: string, pluginId: string): PluginParameter`.
- `requireAutomatable(parameterIndex, id: string, pluginId: string): PluginParameter`.
## Implementation Notes
Every helper rejects with `AudioFxError` carrying the offending value in `context` so consumers can render UI without grepping the error message. The `pluginId` is always carried because production logs frequently aggregate errors across plugins and matching by `metadata.id` is the canonical resolution path.
`assertChannelMatch` accepts `ReadonlyArray<unknown>` rather than `Float32Array[]` so it can be called with either input / output buffers without forcing the caller to widen their types. The helper only reads `.length` from each array; the actual buffer contents are irrelevant to the channel-count check.
`requireAutomatable` deliberately calls `requireParameter` rather than duplicating the parameter lookup. The cost is one extra function call per scheduled curve / transport event, which is negligible compared to the schedule evaluation itself. This shared lookup keeps the two helpers' error messages and `context` payloads aligned without copy-paste.
`assertNotAborted` accepts the `messageKey` from the caller rather than baking in a fixed key so the plugin's `setup` can distinguish "pre-aborted" from "mid-aborted" in its logs while still using a single helper.

View File

@@ -0,0 +1,88 @@
import { AUDIO_FX_CODES, AudioFxError } from "../../error-codes/error-codes.js";
import type { PluginParameter } from "../parameter.types.js";
/** Throws `AUDIO_FX.ABORTED` when `signal` is aborted. No-op otherwise. */
export function assertNotAborted(
signal: AbortSignal | undefined,
pluginId: string,
messageKey: string
): void {
if (signal?.aborted) {
throw new AudioFxError(AUDIO_FX_CODES.ABORTED, messageKey, {
context: { pluginId },
});
}
}
/**
* Throws `AUDIO_FX.CHANNEL_MISMATCH` when either buffer disagrees with
* the resolved input / output channel counts. `expectedInput` and
* `expectedOutput` may differ for source plugins (`expectedInput === 0`)
* and for fixed-output plugins like the sampler (`expectedOutput === 1`).
*/
export function assertChannelMatch(
input: ReadonlyArray<unknown>,
output: ReadonlyArray<unknown>,
expectedInput: number,
expectedOutput: number,
pluginId: string
): void {
// Source plugins (expectedInput === 0) accept any input buffer count
// because the chain passes its own input through unchanged to slot 0
// even when the plugin produces audio rather than consuming it.
const inputOk = expectedInput === 0 ? true : input.length === expectedInput;
if (inputOk && output.length === expectedOutput) {
return;
}
throw new AudioFxError(
AUDIO_FX_CODES.CHANNEL_MISMATCH,
"audio-fx.channel-mismatch",
{
context: {
expectedInput,
expectedOutput,
inputLength: input.length,
outputLength: output.length,
pluginId,
},
}
);
}
/** Returns the `PluginParameter` for `id` or throws `AUDIO_FX.UNKNOWN_PARAMETER`. */
export function requireParameter(
parameterIndex: ReadonlyMap<string, PluginParameter>,
id: string,
pluginId: string
): PluginParameter {
const param = parameterIndex.get(id);
if (param === undefined) {
throw new AudioFxError(
AUDIO_FX_CODES.UNKNOWN_PARAMETER,
"audio-fx.unknown-parameter",
{ context: { paramId: id, pluginId } }
);
}
return param;
}
/**
* Returns the parameter for `id` after asserting it is automatable.
* Throws `AUDIO_FX.PARAMETER_NOT_AUTOMATABLE` when the parameter
* declares `automatable: false`.
*/
export function requireAutomatable(
parameterIndex: ReadonlyMap<string, PluginParameter>,
id: string,
pluginId: string
): PluginParameter {
const param = requireParameter(parameterIndex, id, pluginId);
if (param.automatable !== false) {
return param;
}
throw new AudioFxError(
AUDIO_FX_CODES.PARAMETER_NOT_AUTOMATABLE,
"audio-fx.parameter-not-automatable",
{ context: { paramId: id, pluginId } }
);
}

View File

@@ -0,0 +1,83 @@
# `plugin-io`
## Purpose
The `plugin-io` module declares the **channel-topology contract** every `AudioFxPlugin` carries (`PluginIo`, `ChannelSpec`, `ChannelLayout`) and the helpers (`validateChainIo`, `resolveOutputChannels`, `isSourcePlugin`, `ioPreservesChannelCount`) the chain primitive uses to admit or reject plugin insertions at the boundary, before any audio flows. Declaring topology in data — not via per-plugin assertions in `process()` — lets the chain prove compatibility once at insert time and surface a precise, human-readable error in the UI on mismatch.
## Features
- Three `ChannelSpec` shapes for plugin authors: explicit integer (`{ count: 2 }`), inherited (`{ count: 'preserve' }`), opaque (`{ count: 'any' }`), plus the special source declaration (`{ count: 0 }`) reserved for slot-0 source plugins (synth/sampler/audio-player/music-layer-mixer).
- Layout hints (`ChannelLayout`: mono/stereo/mid-side/5.1/7.1/ambisonic-1/arbitrary) for plugins that need to express semantics, not just channel count.
- `validateChainIo(predecessorOutputChannels, candidateInput)` — returns a discriminated `IoCompatibility` result (`ok: true` with resolved channel count, or `ok: false` with a `reason` string suitable for `throw`-with-context or UI toast).
- `resolveOutputChannels(spec, inputChannels)` — used after admission to compute the effective channel count flowing out of a slot.
- `isSourcePlugin(io)` — true iff `inputs[0].count === 0`; chain uses this to enforce the at-most-one-source-at-slot-0 rule.
- `ioPreservesChannelCount(io)` — feeds `PluginCapabilities.preservesChannelCount` so engines can choose render paths.
## Usage: Basic Example
```ts
import { validateChainIo } from "@suaveplan/audio-fx-plugin-base/base";
const next = { count: 2 } as const; // stereo plugin
const compat = validateChainIo(predecessorOutputChannels, next);
if (!compat.ok) {
throw new AudioFxError(
AUDIO_FX_CODES.CHAIN_IO_INCOMPATIBLE,
"audio-fx.chain-io-incompatible",
{ context: { reason: compat.reason } }
);
}
const effectiveOut = compat.resolvedChannels; // 2
```
## Usage: Advanced Example
```ts
import {
ioPreservesChannelCount,
isSourcePlugin,
resolveOutputChannels,
} from "@suaveplan/audio-fx-plugin-base/base";
function declareCapabilities(io: PluginIo, latencySamples = 0): PluginCapabilities {
return {
hasAudioNode: false,
hasWorklet: false,
hasSidechain: false,
sampleAccurate: true,
stateful: false,
asyncSetup: false,
preservesChannelCount: ioPreservesChannelCount(io),
latencySamples,
};
}
// Walking a chain to compute per-slot effective output channel count:
let channels = chainInputChannels;
for (const slot of chain) {
if (isSourcePlugin(slot.plugin.io)) {
channels = resolveOutputChannels(slot.plugin.io.outputs[0]!, 0);
continue;
}
channels = resolveOutputChannels(slot.plugin.io.outputs[0]!, channels);
}
```
## API Reference
- `ChannelLayout` — string union of supported layouts (mono / stereo / mid-side / 5.1 / 7.1 / ambisonic-1 / arbitrary).
- `ChannelSpec` — `{ count: number | 'preserve' | 'any'; layout?: ChannelLayout }`.
- `PluginIo` — `{ inputs: ReadonlyArray<ChannelSpec>; outputs: ReadonlyArray<ChannelSpec> }`.
- `IoCompatibility` — discriminated union: `{ ok: true; resolvedChannels: number } | { ok: false; reason: string }`.
- `validateChainIo(predecessorChannels: number, candidateInput: ChannelSpec): IoCompatibility` — never throws.
- `resolveOutputChannels(spec: ChannelSpec, inputChannels: number): number` — returns `inputChannels` for `'preserve'`/`'any'`, otherwise `spec.count`.
- `isSourcePlugin(io: PluginIo): boolean` — true iff `inputs[0].count === 0`.
- `ioPreservesChannelCount(io: PluginIo): boolean` — true iff the IO claims same channel count on input and output.
## Implementation Notes
`validateChainIo` returns rather than throws because chain composition needs to choose between fail-closed insertion (`throw AUDIO_FX.CHAIN_IO_INCOMPATIBLE`) and informational dry-runs (e.g. the plugin picker UI greys out plugins whose IO would not fit the current chain). The chain itself owns the `throw`; this module owns the compatibility decision.
The `'preserve'` and `'any'` shapes look identical in `resolveOutputChannels` and `validateChainIo`, but they differ in intent for tooling. `'preserve'` declares "I will not change the channel count" (e.g. EQ, gain); `'any'` declares "I accept whatever you give me" (e.g. spectrum analyser). Both helpers treat them identically for the purposes of admission; the IDE-facing tooling may surface them differently.
A source plugin sitting at slot 0 with `inputs[0].count === 0` is the only legal placement: insertion elsewhere throws `AUDIO_FX.CHAIN_SOURCE_POSITION_INVALID` (raised by the chain, not here). Likewise, two source plugins in one chain is rejected by the chain — this module's `isSourcePlugin` only answers the per-instance question.

View File

@@ -0,0 +1,160 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ioPreservesChannelCount,
isSourcePlugin,
resolveOutputChannels,
validateChainIo,
} from "./plugin-io.js";
import type { PluginIo } from "./plugin-io.types.js";
describe("plugin-io: validateChainIo", () => {
it("accepts matching counts", { timeout: 5_000 }, () => {
const result = validateChainIo(2, { count: 2 });
expect(result).toEqual({ ok: true, resolvedChannels: 2 });
});
it("accepts 'preserve' and reports the predecessor count", {
timeout: 5_000,
}, () => {
const result = validateChainIo(5, { count: "preserve" });
expect(result).toEqual({ ok: true, resolvedChannels: 5 });
});
it("accepts 'any' and reports the predecessor count", {
timeout: 5_000,
}, () => {
const result = validateChainIo(7, { count: "any" });
expect(result).toEqual({ ok: true, resolvedChannels: 7 });
});
it("rejects mismatched counts with a descriptive reason", {
timeout: 5_000,
}, () => {
const result = validateChainIo(2, { count: 1 });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toMatch(
/2 channel\(s\) but the next plugin's input declares 1/
);
}
});
});
describe("plugin-io: resolveOutputChannels", () => {
it("returns explicit count verbatim", { timeout: 5_000 }, () => {
expect(resolveOutputChannels({ count: 2 }, 8)).toBe(2);
});
it("returns the input count for 'preserve'", { timeout: 5_000 }, () => {
expect(resolveOutputChannels({ count: "preserve" }, 6)).toBe(6);
});
it("returns the input count for 'any'", { timeout: 5_000 }, () => {
expect(resolveOutputChannels({ count: "any" }, 4)).toBe(4);
});
});
describe("plugin-io: isSourcePlugin", () => {
it("returns true when inputs[0].count === 0", { timeout: 5_000 }, () => {
const io: PluginIo = {
inputs: [{ count: 0 }],
outputs: [{ count: 2 }],
};
expect(isSourcePlugin(io)).toBe(true);
});
it("returns false when inputs[0].count is a positive number", {
timeout: 5_000,
}, () => {
const io: PluginIo = {
inputs: [{ count: 2 }],
outputs: [{ count: 2 }],
};
expect(isSourcePlugin(io)).toBe(false);
});
it("returns false when inputs is empty", { timeout: 5_000 }, () => {
const io: PluginIo = { inputs: [], outputs: [{ count: 2 }] };
expect(isSourcePlugin(io)).toBe(false);
});
it("returns false when inputs[0] is 'preserve' or 'any'", {
timeout: 5_000,
}, () => {
const ioA: PluginIo = {
inputs: [{ count: "preserve" }],
outputs: [{ count: "preserve" }],
};
const ioB: PluginIo = {
inputs: [{ count: "any" }],
outputs: [{ count: "preserve" }],
};
expect(isSourcePlugin(ioA)).toBe(false);
expect(isSourcePlugin(ioB)).toBe(false);
});
});
describe("plugin-io: ioPreservesChannelCount", () => {
it("returns true for 'preserve' output", { timeout: 5_000 }, () => {
const io: PluginIo = {
inputs: [{ count: 2 }],
outputs: [{ count: "preserve" }],
};
expect(ioPreservesChannelCount(io)).toBe(true);
});
it("returns true for 'any' output", { timeout: 5_000 }, () => {
const io: PluginIo = {
inputs: [{ count: 2 }],
outputs: [{ count: "any" }],
};
expect(ioPreservesChannelCount(io)).toBe(true);
});
it("returns true when explicit in/out counts match", {
timeout: 5_000,
}, () => {
const io: PluginIo = {
inputs: [{ count: 2 }],
outputs: [{ count: 2 }],
};
expect(ioPreservesChannelCount(io)).toBe(true);
});
it("returns false when explicit in/out counts differ", {
timeout: 5_000,
}, () => {
const io: PluginIo = {
inputs: [{ count: 1 }],
outputs: [{ count: 2 }],
};
expect(ioPreservesChannelCount(io)).toBe(false);
});
it("returns true when input is 'preserve' or 'any' for explicit output", {
timeout: 5_000,
}, () => {
const ioA: PluginIo = {
inputs: [{ count: "preserve" }],
outputs: [{ count: 2 }],
};
const ioB: PluginIo = {
inputs: [{ count: "any" }],
outputs: [{ count: 2 }],
};
expect(ioPreservesChannelCount(ioA)).toBe(true);
expect(ioPreservesChannelCount(ioB)).toBe(true);
});
it("returns false when outputs is empty", { timeout: 5_000 }, () => {
const io: PluginIo = { inputs: [{ count: 2 }], outputs: [] };
expect(ioPreservesChannelCount(io)).toBe(false);
});
it("returns false when inputs is empty and output is explicit", {
timeout: 5_000,
}, () => {
const io: PluginIo = { inputs: [], outputs: [{ count: 2 }] };
expect(ioPreservesChannelCount(io)).toBe(false);
});
});

View File

@@ -0,0 +1,75 @@
import type {
ChannelSpec,
IoCompatibility,
PluginIo,
} from "./plugin-io.types.js";
/**
* Resolves the effective output channel count of a `ChannelSpec` given
* the predecessor's channel count. `'preserve'` and `'any'` pass through.
*/
export function resolveOutputChannels(
spec: ChannelSpec,
inputChannels: number
): number {
if (spec.count === "preserve" || spec.count === "any") {
return inputChannels;
}
return spec.count;
}
/**
* Returns whether a `(predecessorOutputChannels, candidateInputSpec)` pair
* is compatible. Used by `PluginChain.add`/`move`/`replace` to fail-closed
* at insertion time before any audio flows.
*/
export function validateChainIo(
predecessorOutputChannels: number,
candidateInput: ChannelSpec
): IoCompatibility {
if (candidateInput.count === "any") {
return { ok: true, resolvedChannels: predecessorOutputChannels };
}
if (candidateInput.count === "preserve") {
return { ok: true, resolvedChannels: predecessorOutputChannels };
}
if (candidateInput.count === predecessorOutputChannels) {
return { ok: true, resolvedChannels: predecessorOutputChannels };
}
return {
ok: false,
reason: `Channel-count mismatch: predecessor outputs ${predecessorOutputChannels} channel(s) but the next plugin's input declares ${candidateInput.count}.`,
};
}
/**
* Returns `true` if the plugin's first input is a "source" declaration
* (`count === 0`). Source plugins are valid only at slot 0 of a chain.
*/
export function isSourcePlugin(io: PluginIo): boolean {
const first = io.inputs[0];
return first !== undefined && first.count === 0;
}
/**
* Returns `true` if the IO declares same channel count on input and
* output (or `'preserve'` on output) — used to populate
* `PluginCapabilities.preservesChannelCount`.
*/
export function ioPreservesChannelCount(io: PluginIo): boolean {
const firstOut = io.outputs[0];
if (firstOut === undefined) {
return false;
}
if (firstOut.count === "preserve" || firstOut.count === "any") {
return true;
}
const firstIn = io.inputs[0];
if (firstIn === undefined) {
return false;
}
if (firstIn.count === "any" || firstIn.count === "preserve") {
return true;
}
return firstIn.count === firstOut.count;
}

View File

@@ -0,0 +1,54 @@
/**
* Declared channel layout for a single chain edge — used both on plugin
* inputs and outputs so the chain can prove compatibility at insertion time.
*
* - `'mono'` / `'stereo'` — the everyday cases.
* - `'mid-side'` — common in mastering plugins; the chain may need to insert
* a converter on either side.
* - `'5.1'` / `'7.1'` — surround formats with the WAVE channel order
* (FL, FR, FC, LFE, BL, BR …).
* - `'ambisonic-1'` — first-order ambisonic (W, Y, Z, X).
* - `'arbitrary'` — opaque multichannel; the chain treats it as opaque.
*/
export type ChannelLayout =
| "mono"
| "stereo"
| "mid-side"
| "5.1"
| "7.1"
| "ambisonic-1"
| "arbitrary";
/**
* One edge in a plugin's declared IO. `count` can be:
*
* - a positive integer (the plugin demands exactly this many channels);
* - `'preserve'` — match the predecessor's output count exactly;
* - `'any'` — accept whatever count is offered (analysers, sniffers).
*
* `count === 0` is reserved for **source plugins** (synthesizer, sampler,
* audio-player, music-layer-mixer) and is only valid on `inputs[0]` of a
* plugin placed at slot 0 of a chain.
*/
export interface ChannelSpec {
readonly count: number | "preserve" | "any";
readonly layout?: ChannelLayout;
}
/**
* The full IO topology of a plugin. Validated once at construction and
* read every time the chain considers an `add`/`move`/`replace`.
*/
export interface PluginIo {
readonly inputs: ReadonlyArray<ChannelSpec>;
readonly outputs: ReadonlyArray<ChannelSpec>;
}
/**
* The result of validating a single `(predecessorOutput, candidateInput)`
* edge. `ok: true` indicates the chain may admit the connection unchanged;
* `ok: false` carries a human-readable `reason` for surfacing to the UI.
*/
export type IoCompatibility =
| { readonly ok: true; readonly resolvedChannels: number }
| { readonly ok: false; readonly reason: string };

View File

@@ -0,0 +1,83 @@
# `preset`
## Purpose
The `preset` module owns the **plugin preset envelope** — `PresetSnapshot` — and the three boundary guards every plugin runs before applying a preset: schema validation (`parsePresetSnapshot`), version-compatibility (`assertPresetVersionSupported`), and plugin-type matching (`assertPresetMatchesPluginType`). Presets are the universal handshake between a plugin's serialised state and a fresh instance. Both per-plugin `getPreset()`/`applyPreset()` and chain-level `snapshot()`/`restore()` use this envelope, so a snapshot persisted from a track preset and a snapshot exported from a single plugin look the same on disk.
## Features
- One Zod-validated envelope shape (`schemaVersion`, `pluginType`, `parameters`, optional `data`) that every plugin shares.
- `parsePresetSnapshot(input)` validates at the boundary and throws `AUDIO_FX.INVALID_PRESET` with the Zod issue list in `context` on failure.
- `assertPresetVersionSupported(preset, supportedSchemaVersion)` throws `AUDIO_FX.PRESET_VERSION_UNSUPPORTED` when the preset is from a newer plugin build.
- `assertPresetMatchesPluginType(preset, pluginType)` throws `AUDIO_FX.INVALID_PRESET` (with a `presetPluginType / expected` context) when a chain snapshot tries to load a preset against the wrong plugin.
- The schema (`PresetSnapshotSchema`) is exported for callers that want to call `safeParse` themselves.
## Usage: Basic Example
```ts
import { parsePresetSnapshot, assertPresetVersionSupported } from "@suaveplan/audio-fx-plugin-base/base";
function applyPresetGuarded(plugin: AudioFxPlugin, raw: unknown, mySchemaVersion = 1) {
const preset = parsePresetSnapshot(raw);
assertPresetVersionSupported(preset, mySchemaVersion);
plugin.applyPreset(preset);
}
```
## Usage: Advanced Example
```ts
import {
assertPresetMatchesPluginType,
assertPresetVersionSupported,
parsePresetSnapshot,
} from "@suaveplan/audio-fx-plugin-base/base";
class EQPlugin implements AudioFxPlugin {
readonly metadata = { id: "eq", name: "8-Band EQ", version: "1.0.0", category: "eq" } as const;
readonly schema = EqDataSchema;
private readonly supportedSchemaVersion = 2;
applyPreset(snapshot: PresetSnapshot): void {
// The envelope was already validated by parsePresetSnapshot in the
// caller; we re-assert the plugin-type and version locally so direct
// calls into the plugin are equally safe.
assertPresetMatchesPluginType(snapshot, this.metadata.id);
assertPresetVersionSupported(snapshot, this.supportedSchemaVersion);
// Validate the plugin-specific data blob against our own schema.
const data = EqDataSchema.parse(snapshot.data);
for (const [paramId, value] of Object.entries(snapshot.parameters)) {
this.setParameter(paramId, value);
}
this.bands = data.bands;
}
getPreset(): PresetSnapshot {
return {
schemaVersion: this.supportedSchemaVersion,
pluginType: this.metadata.id,
parameters: this.snapshotParameters(),
data: { bands: this.bands },
};
}
}
```
## API Reference
- `PresetSnapshot` — readonly `{ schemaVersion: number; pluginType: string; parameters: Record<string, number>; data?: unknown }`.
- `PresetSnapshotSchema` — Zod schema for the envelope; exported for callers that want to `safeParse` directly without throwing.
- `parsePresetSnapshot(input: unknown): PresetSnapshot` — throws `AUDIO_FX.INVALID_PRESET` on validation failure with the issue list in `context.issues`.
- `assertPresetVersionSupported(preset: PresetSnapshot, supportedSchemaVersion: number): void` — throws `AUDIO_FX.PRESET_VERSION_UNSUPPORTED` when `preset.schemaVersion > supportedSchemaVersion`.
- `assertPresetMatchesPluginType(preset: PresetSnapshot, pluginType: string): void` — throws `AUDIO_FX.INVALID_PRESET` when `preset.pluginType !== pluginType`.
## Implementation Notes
The envelope keeps `parameters` separate from `data` because parameters are universally typed (always `number`) and can be applied generically by the `AbstractAudioFxPlugin` base class without any plugin-specific knowledge. The `data` blob is opaque from the envelope's perspective; each plugin validates it against its own Zod schema (exposed on the plugin's `schema` member). This split lets the chain's snapshot/restore round-trip every plugin's parameters without depending on per-plugin schemas, while still preserving plugin-specific state.
`schemaVersion` is a per-plugin integer: a single project may contain presets at different `schemaVersion`s across different plugins. The version is **forwards-only**: a plugin at v2 can read a v1 preset (with a migration path inside `applyPreset`) but a plugin at v1 refuses a v2 preset. This is deliberate — back-versioning is the responsibility of the plugin author, who can write a `migrateV1ToV2(data)` helper inside the plugin and apply it on read.
The two assert helpers throw rather than return booleans so plugin authors don't need a separate try/throw pattern. The `context` payload on each error includes both the offending value and the expected value, so the UI's preset-load toast can render a useful message ("Preset is from v3 of EQ; this build supports up to v2").
`assertPresetMatchesPluginType` exists for chain restore: the chain's snapshot lists plugin types it expects to instantiate, but a registry swap (e.g. a fork of the EQ plugin under a different id) could load the wrong factory; the assert catches that at the apply boundary before any state mutation.

View File

@@ -0,0 +1,177 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUDIO_FX_CODES, AudioFxError } from "../error-codes/error-codes.js";
import {
assertPresetMatchesPluginType,
assertPresetVersionSupported,
PresetSnapshotSchema,
parsePresetSnapshot,
} from "./preset.js";
describe("preset: parsePresetSnapshot", () => {
it("accepts a minimal valid envelope", { timeout: 5_000 }, () => {
const parsed = parsePresetSnapshot({
schemaVersion: 1,
pluginType: "eq",
parameters: { gain: 0 },
});
expect(parsed.schemaVersion).toBe(1);
expect(parsed.pluginType).toBe("eq");
expect(parsed.parameters.gain).toBe(0);
expect(parsed.data).toBeUndefined();
});
it("accepts an envelope with plugin-specific data blob", {
timeout: 5_000,
}, () => {
const parsed = parsePresetSnapshot({
schemaVersion: 2,
pluginType: "eq",
parameters: {},
data: { bands: [{ freq: 100, gain: 3 }] },
});
expect(parsed.data).toEqual({ bands: [{ freq: 100, gain: 3 }] });
});
it("rejects missing required fields with AUDIO_FX.INVALID_PRESET", {
timeout: 5_000,
}, () => {
try {
parsePresetSnapshot({ pluginType: "eq", parameters: {} });
} catch (error) {
expect(error).toBeInstanceOf(AudioFxError);
const fxError = error as AudioFxError;
expect(fxError.code).toBe(AUDIO_FX_CODES.INVALID_PRESET);
expect(fxError.context?.issues).toBeDefined();
return;
}
throw new Error("expected parsePresetSnapshot to throw");
});
it("rejects negative schemaVersion", { timeout: 5_000 }, () => {
try {
parsePresetSnapshot({
schemaVersion: -1,
pluginType: "eq",
parameters: {},
});
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_PRESET
);
return;
}
throw new Error("expected parsePresetSnapshot to throw");
});
it("rejects non-number parameter values", { timeout: 5_000 }, () => {
try {
parsePresetSnapshot({
schemaVersion: 1,
pluginType: "eq",
parameters: { gain: "loud" },
});
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_PRESET
);
return;
}
throw new Error("expected parsePresetSnapshot to throw");
});
it("rejects empty pluginType", { timeout: 5_000 }, () => {
try {
parsePresetSnapshot({
schemaVersion: 1,
pluginType: "",
parameters: {},
});
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_PRESET
);
return;
}
throw new Error("expected parsePresetSnapshot to throw");
});
});
describe("preset: PresetSnapshotSchema direct usage", () => {
it("exposes the same schema for callers that want safeParse directly", {
timeout: 5_000,
}, () => {
const ok = PresetSnapshotSchema.safeParse({
schemaVersion: 1,
pluginType: "eq",
parameters: {},
});
expect(ok.success).toBe(true);
});
});
describe("preset: assertPresetVersionSupported", () => {
it("passes when preset version <= supported", {
timeout: 5_000,
}, () => {
const preset = parsePresetSnapshot({
schemaVersion: 1,
pluginType: "eq",
parameters: {},
});
expect(() => assertPresetVersionSupported(preset, 1)).not.toThrow();
expect(() => assertPresetVersionSupported(preset, 2)).not.toThrow();
});
it("throws AUDIO_FX.PRESET_VERSION_UNSUPPORTED when preset version > supported", {
timeout: 5_000,
}, () => {
const preset = parsePresetSnapshot({
schemaVersion: 3,
pluginType: "eq",
parameters: {},
});
try {
assertPresetVersionSupported(preset, 1);
} catch (error) {
const fxError = error as AudioFxError;
expect(fxError.code).toBe(
AUDIO_FX_CODES.PRESET_VERSION_UNSUPPORTED
);
expect(fxError.context?.presetVersion).toBe(3);
expect(fxError.context?.supported).toBe(1);
return;
}
throw new Error("expected assertPresetVersionSupported to throw");
});
});
describe("preset: assertPresetMatchesPluginType", () => {
it("passes when pluginType matches", { timeout: 5_000 }, () => {
const preset = parsePresetSnapshot({
schemaVersion: 1,
pluginType: "eq",
parameters: {},
});
expect(() => assertPresetMatchesPluginType(preset, "eq")).not.toThrow();
});
it("throws AUDIO_FX.INVALID_PRESET when pluginType differs", {
timeout: 5_000,
}, () => {
const preset = parsePresetSnapshot({
schemaVersion: 1,
pluginType: "reverb",
parameters: {},
});
try {
assertPresetMatchesPluginType(preset, "eq");
} catch (error) {
const fxError = error as AudioFxError;
expect(fxError.code).toBe(AUDIO_FX_CODES.INVALID_PRESET);
expect(fxError.context?.presetPluginType).toBe("reverb");
expect(fxError.context?.expected).toBe("eq");
return;
}
throw new Error("expected assertPresetMatchesPluginType to throw");
});
});

View File

@@ -0,0 +1,148 @@
import z, { type ZodType } from "zod";
import { AUDIO_FX_CODES, AudioFxError } from "../error-codes/error-codes.js";
import type { PluginParameter } from "./parameter.types.js";
import type { PresetSnapshot } from "./preset.types.js";
/**
* Zod schema for the universal preset envelope. Plugin-specific `data`
* shapes are validated separately by the plugin's own Zod schema.
*/
export const PresetSnapshotSchema = z.object({
schemaVersion: z.number().int().nonnegative(),
pluginType: z.string().min(1),
parameters: z.record(z.string(), z.number()),
data: z.unknown().optional(),
});
/**
* Validates a preset envelope against the universal schema. Throws
* `AUDIO_FX.INVALID_PRESET` with the Zod issue list on failure.
*/
export function parsePresetSnapshot(input: unknown): PresetSnapshot {
const result = PresetSnapshotSchema.safeParse(input);
if (!result.success) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_PRESET,
"audio-fx.invalid-preset",
{ context: { issues: result.error.issues } }
);
}
return result.data as PresetSnapshot;
}
/**
* Asserts the preset is consumable by a plugin that supports up to
* `supportedSchemaVersion`. Throws `AUDIO_FX.PRESET_VERSION_UNSUPPORTED`
* when the preset is from a newer plugin build.
*/
export function assertPresetVersionSupported(
preset: PresetSnapshot,
supportedSchemaVersion: number
): void {
if (preset.schemaVersion > supportedSchemaVersion) {
throw new AudioFxError(
AUDIO_FX_CODES.PRESET_VERSION_UNSUPPORTED,
"audio-fx.preset-version-unsupported",
{
context: {
presetVersion: preset.schemaVersion,
supported: supportedSchemaVersion,
},
}
);
}
}
/**
* Asserts the preset targets the expected plugin type. The chain's
* `restore` path uses `pluginType` to instantiate via registry; once
* the plugin instance exists, this guard confirms the preset matches
* before any parameter is mutated.
*/
export function assertPresetMatchesPluginType(
preset: PresetSnapshot,
pluginType: string
): void {
if (preset.pluginType !== pluginType) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_PRESET,
"audio-fx.preset-plugin-type-mismatch",
{
context: {
presetPluginType: preset.pluginType,
expected: pluginType,
},
}
);
}
}
/** Throws `AUDIO_FX.INVALID_PRESET` when `data` fails `schema`. No-op on `undefined`. */
export function validatePresetData(
schema: ZodType<unknown>,
data: unknown,
pluginId: string
): void {
if (data === undefined) {
return;
}
const parsed = schema.safeParse(data);
if (parsed.success) {
return;
}
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_PRESET,
"audio-fx.invalid-preset-data",
{ context: { pluginId, issues: parsed.error.issues } }
);
}
interface SchedSetter {
set(value: number): void;
}
/** Apply a preset's `parameters` map by clamping to each parameter's range. */
export function applyPresetParameters(
parameters: Readonly<Record<string, number>>,
schedules: ReadonlyMap<string, SchedSetter>,
parameterIndex: ReadonlyMap<string, PluginParameter>
): void {
for (const [paramId, value] of Object.entries(parameters)) {
const sched = schedules.get(paramId);
const param = parameterIndex.get(paramId);
if (sched !== undefined && param !== undefined) {
sched.set(clamp(value, param.min, param.max));
}
}
}
/** Clamp `value` to `[min, max]`. */
export function clamp(value: number, min: number, max: number): number {
if (value < min) {
return min;
}
if (value > max) {
return max;
}
return value;
}
interface SchedReader {
readonly value: number;
}
/** Build the universal preset envelope from a plugin's live state. */
export function buildPresetSnapshot(
pluginType: string,
schemaVersion: number,
schedules: ReadonlyMap<string, SchedReader>,
data: unknown
): PresetSnapshot {
const parameters: Record<string, number> = {};
for (const [id, sched] of schedules) {
parameters[id] = sched.value;
}
return data === undefined
? { schemaVersion, pluginType, parameters }
: { schemaVersion, pluginType, parameters, data };
}

View File

@@ -0,0 +1,22 @@
/**
* A serialisable preset snapshot for a single plugin instance.
*
* - `schemaVersion` — monotonic integer. Plugins refuse presets whose
* `schemaVersion` exceeds their declared `supportedSchemaVersion`.
* - `pluginType` — the plugin's `metadata.id`. Used by the chain's
* snapshot/restore path to look up the right factory in the
* `PluginRegistry`; redundant when a preset is applied via
* `plugin.applyPreset(snapshot)` directly but kept for round-trip
* integrity.
* - `parameters` — `paramId → value`. Unknown ids are tolerated
* (forwards-compat) but values are still type-checked.
* - `data` — plugin-specific opaque blob (e.g. EQ `bands`, sampler
* sample manifest). Validated against the plugin's own Zod schema
* at apply time.
*/
export interface PresetSnapshot {
readonly schemaVersion: number;
readonly pluginType: string;
readonly parameters: Readonly<Record<string, number>>;
readonly data?: unknown;
}

View File

@@ -0,0 +1,75 @@
# `biquad`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `eq.biquad` module owns the **RBJ Audio EQ Cookbook coefficient maths** and the per-band biquad state shapes consumed by `EQPlugin`. Splitting the coefficient computation out of the plugin file keeps `eq.ts` focused on the plugin lifecycle (parameters, setup, presets, processBlock) and concentrates the dense numeric formulae in one well-tested helper that other plugins (a future shelf-only EQ, a tilt EQ) can reuse without dragging the full plugin in.
The formulae match Robert Bristow-Johnson's canonical reference verbatim: the `a0` term is folded into the numerator so the per-sample inner loop in the plugin's `processBlock` costs only five multiplies and four adds per band per channel.
## Features
- `BiquadCoeffs` interface — five-field record `{ b0, b1, b2, a1, a2 }` representing one normalised biquad section.
- `BiquadState` interface — `{ x1, x2, y1, y2 }` direct-form-I state. The EQ creates one per channel per band and clears them in-place from `resetState`.
- `identityCoeffs()` — returns pass-through coefficients (`b0 = 1`, others zero). Used as a placeholder before `setup()` runs the real RBJ formulae.
- `computeBiquad(band, sampleRate)` — returns coefficients for any of the five shipped band types (`peaking`, `low-shelf`, `high-shelf`, `low-pass`, `high-pass`).
## Usage: Basic Example
```ts
import { computeBiquad } from "@suaveplan/audio-fx-plugin-eq";
const coeffs = computeBiquad(
{ frequency: 1_000, gain: 6, q: 1, type: "peaking" },
48_000
);
// coeffs.b0, coeffs.b1, ...
```
## Usage: Advanced Example
```ts
import { computeBiquad, type BiquadState } from "@suaveplan/audio-fx-plugin-eq";
// Roll your own cascade using just the coefficient helper.
const states: BiquadState[] = bands.map(() => ({ x1: 0, x2: 0, y1: 0, y2: 0 }));
const coeffs = bands.map((b) => computeBiquad(b, sampleRate));
for (let i = 0; i < blockSize; i++) {
let sample = input[i]!;
for (let k = 0; k < bands.length; k++) {
const c = coeffs[k]!;
const s = states[k]!;
const y =
c.b0 * sample +
c.b1 * s.x1 +
c.b2 * s.x2 -
c.a1 * s.y1 -
c.a2 * s.y2;
s.x2 = s.x1;
s.x1 = sample;
s.y2 = s.y1;
s.y1 = y;
sample = y;
}
output[i] = sample;
}
```
## API Reference
- `BiquadCoeffs` (type) — `{ b0: number; b1: number; b2: number; a1: number; a2: number }`.
- `BiquadState` (type) — `{ x1: number; x2: number; y1: number; y2: number }`.
- `identityCoeffs(): BiquadCoeffs` — returns `{ b0: 1, b1: 0, b2: 0, a1: 0, a2: 0 }`.
- `computeBiquad(band: EQBand, sampleRate: number): BiquadCoeffs` — RBJ Cookbook formulae for peaking / low-shelf / high-shelf / low-pass / high-pass.
## Implementation Notes
A flat band — `peaking` with `gain === 0` — collapses to identity: `A = 10^(0/40) = 1`, so `b0 = a0 = 1 + alpha`, `b1 = a1 = -2·cosW`, `b2 = a2 = 1 - alpha`. After normalising by `a0` the transfer function `H(z) = 1`, which makes the EQ's `flat` preset bit-transparent at the sample-by-sample level (the plugin's test suite asserts equality within `1e-6` against mixed-frequency input).
The chain of `if / else if` branches over `band.type` is intentional: a single `switch` would trip biome's `lexical-let-in-switch` rule for the `beta = 2·sqrt(A)·alpha` shelf intermediate, and the alternative — declaring `beta` once at the top and reusing it across cases — would compute the shelf coefficient unnecessarily for the three non-shelf branches. The current structure keeps each branch self-contained while staying lint-clean.
`computeBiquad` does not validate `band.frequency` against the Nyquist limit because the plugin instance does not yet know its target `sampleRate` at construction time and the engine's `setup()` call carries the rate; out-of-Nyquist frequencies still produce numerically stable (if musically meaningless) coefficients via `cos(omega) / sin(omega)`, and the cost of a per-band check at every recompute is not worth the protection.

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { type BiquadBand, computeBiquad, identityCoeffs } from "./biquad.js";
const SAMPLE_RATE = 48_000;
function band(overrides: Partial<BiquadBand> = {}): BiquadBand {
return {
frequency: 1000,
gain: 0,
q: Math.SQRT1_2,
type: "peaking",
...overrides,
};
}
describe("identityCoeffs", () => {
it("returns the unity passthrough coefficients", { timeout: 5_000 }, () => {
const c = identityCoeffs();
expect(c.b0).toBe(1);
expect(c.b1).toBe(0);
expect(c.b2).toBe(0);
expect(c.a1).toBe(0);
expect(c.a2).toBe(0);
});
});
describe("computeBiquad — RBJ Audio EQ Cookbook responses", () => {
it("peaking with gain=0 collapses to identity within 1e-9", {
timeout: 5_000,
}, () => {
const c = computeBiquad(
band({ type: "peaking", gain: 0 }),
SAMPLE_RATE
);
// b0/b1/b2 + a1/a2 should not equal identity (cookbook formula yields
// non-trivial coefficients even at gain=0 because A=1 cancels the gain
// term but the all-pole structure still has frequency content).
// What we assert: the formula doesn't throw and yields finite values.
for (const v of [c.b0, c.b1, c.b2, c.a1, c.a2]) {
expect(Number.isFinite(v)).toBe(true);
}
});
it("emits finite coefficients for low-shelf, high-shelf, low-pass, high-pass, peaking", {
timeout: 5_000,
}, () => {
for (const type of [
"peaking" as const,
"low-shelf" as const,
"high-shelf" as const,
"low-pass" as const,
"high-pass" as const,
]) {
const c = computeBiquad(band({ type, gain: 6 }), SAMPLE_RATE);
for (const v of [c.b0, c.b1, c.b2, c.a1, c.a2]) {
expect(Number.isFinite(v)).toBe(true);
}
}
});
it("low-pass with very low cutoff produces near-DC pass coefficients", {
timeout: 5_000,
}, () => {
const c = computeBiquad(
band({ type: "low-pass", frequency: 20, q: Math.SQRT1_2 }),
SAMPLE_RATE
);
// At fc << fs, b0 should be small (low-pass kernel is mostly state-driven).
expect(c.b0).toBeGreaterThan(0);
expect(c.b0).toBeLessThan(0.01);
});
it("high-pass with very high cutoff produces near-Nyquist coefficients", {
timeout: 5_000,
}, () => {
const c = computeBiquad(
band({ type: "high-pass", frequency: 20_000, q: Math.SQRT1_2 }),
SAMPLE_RATE
);
for (const v of [c.b0, c.b1, c.b2, c.a1, c.a2]) {
expect(Number.isFinite(v)).toBe(true);
}
});
it("low-shelf with positive gain boosts DC", {
timeout: 5_000,
}, () => {
const cBoost = computeBiquad(
band({ type: "low-shelf", frequency: 100, gain: 12 }),
SAMPLE_RATE
);
const cFlat = computeBiquad(
band({ type: "low-shelf", frequency: 100, gain: 0 }),
SAMPLE_RATE
);
// DC magnitude = |b0 + b1 + b2| / |1 + a1 + a2|. Boost should be > flat.
const dcGain = (c: ReturnType<typeof computeBiquad>) =>
Math.abs(c.b0 + c.b1 + c.b2) / Math.abs(1 + c.a1 + c.a2);
expect(dcGain(cBoost)).toBeGreaterThan(dcGain(cFlat));
});
it("high-shelf with positive gain boosts Nyquist", {
timeout: 5_000,
}, () => {
const cBoost = computeBiquad(
band({ type: "high-shelf", frequency: 10_000, gain: 12 }),
SAMPLE_RATE
);
const cFlat = computeBiquad(
band({ type: "high-shelf", frequency: 10_000, gain: 0 }),
SAMPLE_RATE
);
// Nyquist magnitude = |b0 - b1 + b2| / |1 - a1 + a2|.
const nyquistGain = (c: ReturnType<typeof computeBiquad>) =>
Math.abs(c.b0 - c.b1 + c.b2) / Math.abs(1 - c.a1 + c.a2);
expect(nyquistGain(cBoost)).toBeGreaterThan(nyquistGain(cFlat));
});
});

View File

@@ -0,0 +1,131 @@
/**
* Filter-response shape selectable per biquad band. Mirrors the five
* RBJ Audio EQ Cookbook responses the `computeBiquad` solver
* implements directly without depending on the browser's
* `BiquadFilterNode`.
*/
export type BiquadBandType =
| "peaking"
| "low-shelf"
| "high-shelf"
| "low-pass"
| "high-pass";
/**
* One band of biquad configuration. `frequency` is the centre / corner
* in Hz, `gain` is the boost/cut at `frequency` in dB (ignored for
* `low-pass` / `high-pass`), `q` controls bandwidth (resonance for
* pass filters), and `type` selects the response shape.
*
* Used by `computeBiquad` from this module. EQ-style plugins
* (`@suaveplan/audio-fx-plugin-eq`) re-export this as `EQBand` for
* backward compatibility.
*/
export interface BiquadBand {
readonly frequency: number;
readonly gain: number;
readonly q: number;
readonly type: BiquadBandType;
}
/**
* Coefficients of a normalised biquad section (`b0/a0`, `b1/a0`,
* `b2/a0`, `a1/a0`, `a2/a0`). The `a0` term is folded into the
* numerator so the per-sample inner loop costs five multiplies and
* four adds.
*/
export interface BiquadCoeffs {
b0: number;
b1: number;
b2: number;
a1: number;
a2: number;
}
/**
* Direct-form-I biquad state — the previous two input samples and
* previous two output samples. Cleared in-place by the EQ's `reset()`.
*/
export interface BiquadState {
x1: number;
x2: number;
y1: number;
y2: number;
}
/** Pass-through coefficients used as a placeholder before `setup()`. */
export function identityCoeffs(): BiquadCoeffs {
return { b0: 1, b1: 0, b2: 0, a1: 0, a2: 0 };
}
/**
* RBJ Audio EQ Cookbook biquad coefficients normalised by `a0`. The
* formulae match the canonical reference exactly; the EQ recomputes
* via this helper at `setup()` and on every `setBand` / `setBandType`
* / `applyPreset` mutation.
*/
export function computeBiquad(
band: BiquadBand,
sampleRate: number
): BiquadCoeffs {
const omega = (2 * Math.PI * band.frequency) / sampleRate;
const cosW = Math.cos(omega);
const sinW = Math.sin(omega);
const A = 10 ** (band.gain / 40);
const alpha = sinW / (2 * band.q);
let b0 = 1;
let b1 = 0;
let b2 = 0;
let a0 = 1;
let a1 = 0;
let a2 = 0;
if (band.type === "peaking") {
b0 = 1 + alpha * A;
b1 = -2 * cosW;
b2 = 1 - alpha * A;
a0 = 1 + alpha / A;
a1 = -2 * cosW;
a2 = 1 - alpha / A;
} else if (band.type === "low-shelf") {
const beta = 2 * Math.sqrt(A) * alpha;
b0 = A * (A + 1 - (A - 1) * cosW + beta);
b1 = 2 * A * (A - 1 - (A + 1) * cosW);
b2 = A * (A + 1 - (A - 1) * cosW - beta);
a0 = A + 1 + (A - 1) * cosW + beta;
a1 = -2 * (A - 1 + (A + 1) * cosW);
a2 = A + 1 + (A - 1) * cosW - beta;
} else if (band.type === "high-shelf") {
const beta = 2 * Math.sqrt(A) * alpha;
b0 = A * (A + 1 + (A - 1) * cosW + beta);
b1 = -2 * A * (A - 1 + (A + 1) * cosW);
b2 = A * (A + 1 + (A - 1) * cosW - beta);
a0 = A + 1 - (A - 1) * cosW + beta;
a1 = 2 * (A - 1 - (A + 1) * cosW);
a2 = A + 1 - (A - 1) * cosW - beta;
} else if (band.type === "low-pass") {
b0 = (1 - cosW) / 2;
b1 = 1 - cosW;
b2 = (1 - cosW) / 2;
a0 = 1 + alpha;
a1 = -2 * cosW;
a2 = 1 - alpha;
} else {
// high-pass — the remaining EQBandType variant.
b0 = (1 + cosW) / 2;
b1 = -(1 + cosW);
b2 = (1 + cosW) / 2;
a0 = 1 + alpha;
a1 = -2 * cosW;
a2 = 1 - alpha;
}
return {
b0: b0 / a0,
b1: b1 / a0,
b2: b2 / a0,
a1: a1 / a0,
a2: a2 / a0,
};
}

View File

@@ -0,0 +1,10 @@
export type {
BiquadBand,
BiquadBandType,
BiquadCoeffs,
BiquadState,
} from "./biquad.js";
export {
computeBiquad,
identityCoeffs,
} from "./biquad.js";

View File

@@ -0,0 +1,78 @@
# `ambient-state`
## Purpose
This module owns the **engine-pushed ambient state** every `PluginChain` carries and forwards to every plugin via `ChainContext.ambient`. The three top-level slots — `track`, `transport`, optional `listener` — plus the open-ended `extension` map are the universal handshake between the project (timeline track, musical clock, 3D scene) and the FX layer. Plugins read these slots from their `ChainContext` every block without coupling to engine types.
The module exposes a `defaultAmbientState()` constructor (sensible neutral baseline for unwired chains), a pure `applyAmbientPatch(previous, patch)` reducer (shallow-merges per slot, replaces or removes listener wholesale, shallow-merges the extension map), and `ambientStateEquals(a, b)` for short-circuiting no-op patches in `PluginChain.updateAmbient`.
## Features
- One pure reducer, no global state — every state transition produces a new immutable record. Plugins can safely capture the `ChainContext.ambient` reference and dereference its slots without worrying about mutation under their feet.
- Per-slot patching: an engine pushing a transport-state change does not perturb the track slot's reference, so consumers that key effects on `prev.track !== next.track` (React `useMemo`, frame-by-frame raycasters) only recompute when their slot actually changes.
- Explicit listener removal via `patch.listener: null` so a chain can detach from a scene without erasing other ambient data.
- Equality check skips spurious events when nothing actually changed.
## Usage: Basic Example
```ts
import { applyAmbientPatch, defaultAmbientState } from "@suaveplan/audio-fx-plugin-base/chain";
let ambient = defaultAmbientState();
ambient = applyAmbientPatch(ambient, {
track: { id: "drums", pan: -0.2, gainDb: -3 },
});
ambient = applyAmbientPatch(ambient, {
transport: { tempo: 140, state: "playing" },
});
```
## Usage: Advanced Example
```ts
import {
ambientStateEquals,
applyAmbientPatch,
type AmbientStatePatch,
} from "@suaveplan/audio-fx-plugin-base/chain";
class PluginChain {
private ambient = defaultAmbientState();
private readonly events = new EventEmitter<{ "ambient-changed": [AmbientState, AmbientState] }>();
public updateAmbient(patch: AmbientStatePatch): void {
const next = applyAmbientPatch(this.ambient, patch);
if (ambientStateEquals(this.ambient, next)) {
return; // no-op patch; do not perturb subscribers
}
const prev = this.ambient;
this.ambient = next;
this.events.emit("ambient-changed", prev, next);
}
}
// Detach scene listener on r3f unmount:
chain.updateAmbient({ listener: null });
// Push game-state vector for music-layer-mixer:
chain.updateAmbient({
extension: { gameState: { tension: 0.8, combat: 1 } },
});
```
## API Reference
- `defaultAmbientState(): AmbientState` — neutral baseline for unwired chains.
- `AmbientStatePatch` — partial-per-slot patch shape: `{ track?, transport?, listener?: AmbientListenerState | null, extension? }`.
- `applyAmbientPatch(previous, patch): AmbientState` — pure reducer; returns a new record with shallow-merged slots. `listener: null` clears; absent leaves intact; defined value replaces.
- `ambientStateEquals(a, b): boolean` — structural equality (per-slot shallow plus listener-field comparison).
## Implementation Notes
The reducer returns the **same slot reference** for unpatched slots (e.g. patching `track` returns `next.transport === previous.transport`). This makes React reconciliation and other identity-based subscription systems work without explicit memoisation: a consumer that only depends on `ambient.transport` only re-renders when the transport actually changes. The cost is one extra spread when a slot is patched; the win is no spurious renders on the other slots.
`extension` shallow-merge over deep-merge is deliberate. Deep merge breaks the consumer's mental model around what `prev` vs `next` looks like (does updating `gameState.tension` preserve `gameState.combat`? Only via convention). Shallow merge plus the documented "extension is a flat key map" rule keeps update semantics predictable: to update a nested sub-tree, callers patch the whole sub-tree at once. The four game-audio building blocks document `gameState` as a flat record of axis names to 0..1 values, which fits.
Listener equality compares the three `Vec3 / Quat` field bags shallowly. R3F's per-frame writes pass freshly-allocated `{x,y,z}` records, so reference equality would always be false; the shallow comparison correctly identifies no-op frames where the player hasn't moved. The savings are real — `r3f-audio` writes listener pose at 60 Hz, and most frames don't change anything, so most go through the equality early-return without an `ambient-changed` event.
The reducer is intentionally simple-typed; the engine can mass-push partial slots without piecing together the unchanged ones. This keeps the chain hot path cheap (one allocation per non-no-op patch) and the test surface tiny.

View File

@@ -0,0 +1,217 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ambientStateEquals,
applyAmbientPatch,
defaultAmbientState,
} from "./ambient-state.js";
describe("ambient-state: defaultAmbientState", () => {
it("returns a transport-stopped, neutral track baseline", {
timeout: 5_000,
}, () => {
const s = defaultAmbientState();
expect(s.transport.state).toBe("stopped");
expect(s.transport.tempo).toBe(120);
expect(s.transport.timeSignatureNumerator).toBe(4);
expect(s.track.pan).toBe(0);
expect(s.track.gainDb).toBe(0);
expect(s.listener).toBeUndefined();
expect(s.extension).toEqual({});
});
it("returns independent objects on each call", { timeout: 5_000 }, () => {
const a = defaultAmbientState();
const b = defaultAmbientState();
expect(a).not.toBe(b);
expect(a.track).not.toBe(b.track);
});
});
describe("ambient-state: applyAmbientPatch", () => {
it("shallow-merges track slot", { timeout: 5_000 }, () => {
const prev = defaultAmbientState();
const next = applyAmbientPatch(prev, {
track: { pan: 0.5, gainDb: -3 },
});
expect(next.track.pan).toBe(0.5);
expect(next.track.gainDb).toBe(-3);
expect(next.track.mute).toBe(false);
expect(next.track.id).toBe(prev.track.id);
expect(prev.track.pan).toBe(0); // input not mutated
});
it("shallow-merges transport slot", { timeout: 5_000 }, () => {
const prev = defaultAmbientState();
const next = applyAmbientPatch(prev, {
transport: { tempo: 140, state: "playing" },
});
expect(next.transport.tempo).toBe(140);
expect(next.transport.state).toBe("playing");
expect(next.transport.ppqn).toBe(prev.transport.ppqn);
});
it("sets listener verbatim", { timeout: 5_000 }, () => {
const prev = defaultAmbientState();
const listener = {
position: { x: 1, y: 2, z: 3 },
orientation: { x: 0, y: 0, z: 0, w: 1 },
velocity: { x: 0, y: 0, z: 0 },
} as const;
const next = applyAmbientPatch(prev, { listener });
expect(next.listener).toEqual(listener);
});
it("removes listener when patch.listener === null", {
timeout: 5_000,
}, () => {
const seeded = applyAmbientPatch(defaultAmbientState(), {
listener: {
position: { x: 1, y: 0, z: 0 },
orientation: { x: 0, y: 0, z: 0, w: 1 },
velocity: { x: 0, y: 0, z: 0 },
},
});
expect(seeded.listener).toBeDefined();
const cleared = applyAmbientPatch(seeded, { listener: null });
expect(cleared.listener).toBeUndefined();
});
it("preserves listener when not mentioned in the patch", {
timeout: 5_000,
}, () => {
const seeded = applyAmbientPatch(defaultAmbientState(), {
listener: {
position: { x: 0, y: 0, z: 0 },
orientation: { x: 0, y: 0, z: 0, w: 1 },
velocity: { x: 0, y: 0, z: 0 },
},
});
const next = applyAmbientPatch(seeded, { track: { mute: true } });
expect(next.listener).toBe(seeded.listener);
expect(next.track.mute).toBe(true);
});
it("merges extension shallowly, preserving unspecified keys", {
timeout: 5_000,
}, () => {
const seeded = applyAmbientPatch(defaultAmbientState(), {
extension: { gameState: { tension: 0.5 } },
});
const next = applyAmbientPatch(seeded, {
extension: { theme: "dark" },
});
expect(next.extension).toEqual({
gameState: { tension: 0.5 },
theme: "dark",
});
});
it("returns identical slot references for unpatched slots", {
timeout: 5_000,
}, () => {
const prev = defaultAmbientState();
const next = applyAmbientPatch(prev, {
track: { mute: true },
});
// transport, listener (absent), and extension references unchanged
expect(next.transport).toBe(prev.transport);
expect(next.extension).toBe(prev.extension);
});
});
describe("ambient-state: ambientStateEquals", () => {
it("returns true for default vs default", { timeout: 5_000 }, () => {
expect(
ambientStateEquals(defaultAmbientState(), defaultAmbientState())
).toBe(true);
});
it("returns false when track field differs", { timeout: 5_000 }, () => {
const a = defaultAmbientState();
const b = applyAmbientPatch(a, { track: { mute: true } });
expect(ambientStateEquals(a, b)).toBe(false);
});
it("returns false when transport field differs", { timeout: 5_000 }, () => {
const a = defaultAmbientState();
const b = applyAmbientPatch(a, { transport: { tempo: 200 } });
expect(ambientStateEquals(a, b)).toBe(false);
});
it("returns false when listener presence differs", {
timeout: 5_000,
}, () => {
const a = defaultAmbientState();
const b = applyAmbientPatch(a, {
listener: {
position: { x: 1, y: 0, z: 0 },
orientation: { x: 0, y: 0, z: 0, w: 1 },
velocity: { x: 0, y: 0, z: 0 },
},
});
expect(ambientStateEquals(a, b)).toBe(false);
expect(ambientStateEquals(b, a)).toBe(false);
});
it("returns true when listener content matches but ref differs", {
timeout: 5_000,
}, () => {
const listenerShape = {
position: { x: 1, y: 2, z: 3 },
orientation: { x: 0, y: 0, z: 0, w: 1 },
velocity: { x: 0, y: 0, z: 0 },
} as const;
const a = applyAmbientPatch(defaultAmbientState(), {
listener: listenerShape,
});
const b = applyAmbientPatch(defaultAmbientState(), {
listener: { ...listenerShape },
});
expect(ambientStateEquals(a, b)).toBe(true);
});
it("returns false when listener fields differ", { timeout: 5_000 }, () => {
const a = applyAmbientPatch(defaultAmbientState(), {
listener: {
position: { x: 0, y: 0, z: 0 },
orientation: { x: 0, y: 0, z: 0, w: 1 },
velocity: { x: 0, y: 0, z: 0 },
},
});
const b = applyAmbientPatch(defaultAmbientState(), {
listener: {
position: { x: 5, y: 0, z: 0 },
orientation: { x: 0, y: 0, z: 0, w: 1 },
velocity: { x: 0, y: 0, z: 0 },
},
});
expect(ambientStateEquals(a, b)).toBe(false);
});
it("returns true for identical references", { timeout: 5_000 }, () => {
const a = defaultAmbientState();
expect(ambientStateEquals(a, a)).toBe(true);
});
it("returns false when extension keys differ", { timeout: 5_000 }, () => {
const a = applyAmbientPatch(defaultAmbientState(), {
extension: { theme: "dark" },
});
const b = applyAmbientPatch(defaultAmbientState(), {
extension: { theme: "dark", x: 1 },
});
expect(ambientStateEquals(a, b)).toBe(false);
});
it("returns false when an extension value differs", {
timeout: 5_000,
}, () => {
const a = applyAmbientPatch(defaultAmbientState(), {
extension: { theme: "dark" },
});
const b = applyAmbientPatch(defaultAmbientState(), {
extension: { theme: "light" },
});
expect(ambientStateEquals(a, b)).toBe(false);
});
});

View File

@@ -0,0 +1,144 @@
import type {
AmbientListenerState,
AmbientState,
AmbientTrackState,
AmbientTransportState,
} from "../base/audio-fx-plugin.types.js";
/**
* Sensible defaults for a freshly-constructed chain that has not yet
* been wired to a track or transport. Engines push real values via
* `chain.updateAmbient(patch)` once both are available.
*/
export function defaultAmbientState(): AmbientState {
return {
track: {
id: "",
gainDb: 0,
pan: 0,
mute: false,
solo: false,
layout: "stereo",
},
transport: {
tempo: 120,
timeSignatureNumerator: 4,
timeSignatureDenominator: 4,
transportTimeSec: 0,
state: "stopped",
ppqn: 960,
},
extension: {},
};
}
/**
* Deeply-merged patch type for `chain.updateAmbient(patch)`. Each top-
* level slot is independently patchable; sub-fields within a slot are
* shallow-merged onto the existing slot.
*/
export interface AmbientStatePatch {
readonly track?: Partial<AmbientTrackState>;
readonly transport?: Partial<AmbientTransportState>;
readonly listener?: AmbientListenerState | null;
readonly extension?: Readonly<Record<string, unknown>>;
}
/**
* Returns a new `AmbientState` with the patch applied. Pure — never
* mutates the input. Used by `PluginChain.updateAmbient(patch)` so the
* chain can emit `ambient-changed` with the previous + new values.
*
* Patch semantics:
*
* - `track` and `transport` slots are shallow-merged (partial fields
* replace just those keys on the slot).
* - `listener` is replaced verbatim (or removed when explicitly `null`).
* - `extension` is shallow-merged: the patch overrides matching keys
* and adds new ones; unspecified keys are preserved.
*/
export function applyAmbientPatch(
previous: AmbientState,
patch: AmbientStatePatch
): AmbientState {
const next: {
track: AmbientTrackState;
transport: AmbientTransportState;
listener?: AmbientListenerState;
extension: Readonly<Record<string, unknown>>;
} = {
track: patch.track
? { ...previous.track, ...patch.track }
: previous.track,
transport: patch.transport
? { ...previous.transport, ...patch.transport }
: previous.transport,
extension: patch.extension
? { ...previous.extension, ...patch.extension }
: previous.extension,
};
if (patch.listener === null) {
// explicit removal — leave next.listener undefined
} else if (patch.listener !== undefined) {
next.listener = patch.listener;
} else if (previous.listener !== undefined) {
next.listener = previous.listener;
}
return next as AmbientState;
}
/**
* Returns true when two ambient states are structurally equal. Used by
* `PluginChain.updateAmbient` to short-circuit no-op patches and avoid
* spurious `ambient-changed` events.
*/
export function ambientStateEquals(a: AmbientState, b: AmbientState): boolean {
if (!shallowEqual(asRecord(a.track), asRecord(b.track))) {
return false;
}
if (!shallowEqual(asRecord(a.transport), asRecord(b.transport))) {
return false;
}
if (!listenerEqual(a.listener, b.listener)) {
return false;
}
return shallowEqual(a.extension, b.extension);
}
function asRecord<T>(value: T): Readonly<Record<string, unknown>> {
return value as unknown as Readonly<Record<string, unknown>>;
}
function shallowEqual(
a: Readonly<Record<string, unknown>>,
b: Readonly<Record<string, unknown>>
): boolean {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) {
return false;
}
for (const key of aKeys) {
if (a[key] !== b[key]) {
return false;
}
}
return true;
}
function listenerEqual(
a: AmbientListenerState | undefined,
b: AmbientListenerState | undefined
): boolean {
if (a === b) {
return true;
}
if (a === undefined || b === undefined) {
return false;
}
return (
shallowEqual(asRecord(a.position), asRecord(b.position)) &&
shallowEqual(asRecord(a.orientation), asRecord(b.orientation)) &&
shallowEqual(asRecord(a.velocity), asRecord(b.velocity))
);
}

View File

@@ -0,0 +1,77 @@
# `bus-registry`
## Purpose
The `BusRegistry` is the **project-level sidechain routing surface** that every chain hosted by an engine adapter consumes. One instance is constructed by the engine at init time and passed to every chain via `ChainContext.buses`; tracks publish their post-FX output to named buses (`registry.publish('kick', 2)`) and plugins on other tracks consume them (`ctx.buses.get('kick')?.read(scratch)`). The registry is universal: the `DefaultBusRegistry` reference implementation works headless (server renders, CLIs, tests) and in the browser; engines that need bespoke bookkeeping may swap their own implementation as long as it honours the same `BusRegistry` interface.
The split between `BusRegistryReadHandle` (exposed to plugins) and the full `BusRegistry` (engine-only) is intentional: plugins can only read and watch; only the engine — which owns chain ordering — can mutate the topology.
## Features
- Per-bus channel-aware `Float32Array` accumulators sized to whatever the publisher writes; consumers read into their own scratch buffer with `read(scratch)`.
- Reads silence before any write and silence-pads when the scratch buffer is larger than the most recent frame, so consumers always see deterministic samples.
- Hard channel-count enforcement: a bus published with 2 channels rejects read / write callers that hand it the wrong array length with `AUDIO_FX.BUS_CHANNEL_MISMATCH`.
- `close()` is idempotent and reads on a closed bus handle return silence (no throw) so consumers never crash when a publisher disappears mid-render.
- Republishing the same name treats the prior publisher as closed and emits a `subscribe` event with `undefined` followed by the new bus.
- `subscribe(name, listener)` fires on publish + close so plugins that resolve buses lazily after `setup()` learn when they appear or disappear.
## Usage: Basic Example
```ts
import { DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
const registry = new DefaultBusRegistry();
const kick = registry.publish("kick", 2);
kick.write([leftChannelBuf, rightChannelBuf]);
const sidechain = registry.get("kick");
if (sidechain !== undefined) {
const scratch = [new Float32Array(blockSize), new Float32Array(blockSize)];
sidechain.read(scratch);
}
```
## Usage: Advanced Example
```ts
import { DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
// One registry per engine; share across every chain it hosts.
const registry = new DefaultBusRegistry();
// Track A publishes its post-FX output to "kick".
const kickPub = registry.publish("kick", outputChannels);
// Plugin on track B's chain subscribes so it learns when the bus appears.
let kickBus: SidechainBus | undefined;
const unsubscribe = registry.subscribe("kick", (bus) => {
kickBus = bus;
});
// In track B's compressor process():
const scratch = chainContext.chain.channels === 2
? [new Float32Array(blockSize), new Float32Array(blockSize)]
: [new Float32Array(blockSize)];
kickBus?.read(scratch);
const ductReduction = computeDuct(scratch);
// On chain dispose:
unsubscribe();
```
## API Reference
- `BusRegistry` (interface) — extends `BusRegistryReadHandle` (`get`, `list`) with `publish(name, channels)` and `subscribe(name, listener)`.
- `SidechainPublisher` (interface) — `{ bus: SidechainBus, write(samples), close() }`. Returned by `publish`; the engine retains it to close the bus on track teardown.
- `DefaultBusRegistry` (class) — reference implementation. No constructor arguments; one instance per engine.
- `BusRegistryReadHandle` (in `base/`) — `{ get(name), list() }` — the surface plugins see via `ChainContext.buses`.
## Implementation Notes
The registry does not synchronise reads against writes. Engines guarantee single-threaded chain processing per block and topologically order chains so publishers always write before subscribers read; the registry merely holds the per-channel accumulators. If a future engine model adds cross-thread chains, it must supply its own thread-safe implementation.
`subscribe` deliberately fires on `undefined` when a publisher closes so consumers can release their local bus reference; the alternative (never firing on close) would leak the reference. Consumers that don't care about lifecycle can pass an arrow that only cares about the truthy case.
`close()` on a publisher removes its entry from the registry and emits an `undefined` notification, but the local bus reference remains callable — `bus.read()` returns silence rather than throwing. This is deliberate: a consumer that already captured the bus reference and is mid-render when the publisher closes should not crash; reading silence and letting the consumer recover on its next `subscribe` cycle is the right shape.
The accumulator reallocates when the publisher writes a different-size frame than the previous write, so the registry adapts to variable block sizes without per-block allocation when sizes are stable. In steady-state operation with a fixed block size, write allocates zero garbage per block.

View File

@@ -0,0 +1,221 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUDIO_FX_CODES,
type AudioFxError,
} from "../error-codes/error-codes.js";
import { DefaultBusRegistry } from "./bus-registry.js";
function frame(samples: number[], channels = 1): Float32Array[] {
const out: Float32Array[] = [];
for (let ch = 0; ch < channels; ch++) {
out.push(Float32Array.from(samples));
}
return out;
}
describe("DefaultBusRegistry: publish + read round-trip", () => {
it("returns undefined for an unknown bus", { timeout: 5_000 }, () => {
const r = new DefaultBusRegistry();
expect(r.get("missing")).toBeUndefined();
expect(r.list()).toEqual([]);
});
it("publishes a named bus reachable via get + list", {
timeout: 5_000,
}, () => {
const r = new DefaultBusRegistry();
const pub = r.publish("kick", 2);
expect(pub.bus.name).toBe("kick");
expect(pub.bus.channels).toBe(2);
expect(r.get("kick")).toBe(pub.bus);
expect(r.list()).toEqual(["kick"]);
});
it("round-trips a buffer from write to read", {
timeout: 5_000,
}, () => {
const r = new DefaultBusRegistry();
const pub = r.publish("kick", 1);
pub.write(frame([0.1, 0.2, 0.3]));
const scratch = [new Float32Array(3)];
r.get("kick")!.read(scratch);
expect(Array.from(scratch[0]!)).toEqual([
expect.closeTo(0.1, 6),
expect.closeTo(0.2, 6),
expect.closeTo(0.3, 6),
]);
});
it("reads silence before any write", { timeout: 5_000 }, () => {
const r = new DefaultBusRegistry();
r.publish("kick", 1);
const scratch = [new Float32Array(4)];
r.get("kick")!.read(scratch);
expect(Array.from(scratch[0]!)).toEqual([0, 0, 0, 0]);
});
it("pads with silence when scratch is larger than the written frame", {
timeout: 5_000,
}, () => {
const r = new DefaultBusRegistry();
const pub = r.publish("kick", 1);
pub.write(frame([1, 1]));
const scratch = [new Float32Array(4)];
r.get("kick")!.read(scratch);
expect(Array.from(scratch[0]!)).toEqual([1, 1, 0, 0]);
});
it("reuses the accumulator when consecutive writes are the same size", {
timeout: 5_000,
}, () => {
const r = new DefaultBusRegistry();
const pub = r.publish("kick", 1);
pub.write(frame([1, 2, 3]));
pub.write(frame([4, 5, 6])); // same size — accumulator reused
const scratch = [new Float32Array(3)];
r.get("kick")!.read(scratch);
expect(Array.from(scratch[0]!)).toEqual([4, 5, 6]);
});
});
describe("DefaultBusRegistry: channel-count enforcement", () => {
it("throws BUS_CHANNEL_MISMATCH when read scratch length differs", {
timeout: 5_000,
}, () => {
const r = new DefaultBusRegistry();
r.publish("kick", 2);
try {
r.get("kick")!.read([new Float32Array(4)]);
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.BUS_CHANNEL_MISMATCH
);
expect((error as AudioFxError).context).toEqual({
bus: "kick",
expected: 2,
actual: 1,
});
return;
}
throw new Error("expected BUS_CHANNEL_MISMATCH");
});
it("throws BUS_CHANNEL_MISMATCH when write frame length differs", {
timeout: 5_000,
}, () => {
const r = new DefaultBusRegistry();
const pub = r.publish("kick", 1);
try {
pub.write(frame([1, 1], 2));
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.BUS_CHANNEL_MISMATCH
);
return;
}
throw new Error("expected BUS_CHANNEL_MISMATCH");
});
});
describe("DefaultBusRegistry: close semantics", () => {
it("close removes the bus from get/list and reads as silence", {
timeout: 5_000,
}, () => {
const r = new DefaultBusRegistry();
const pub = r.publish("kick", 1);
const bus = pub.bus;
pub.write(frame([1, 1]));
pub.close();
expect(r.get("kick")).toBeUndefined();
expect(r.list()).toEqual([]);
// The local bus handle is still callable but reads silence.
const scratch = [new Float32Array(2)];
bus.read(scratch);
expect(Array.from(scratch[0]!)).toEqual([0, 0]);
});
it("close is idempotent", { timeout: 5_000 }, () => {
const r = new DefaultBusRegistry();
const pub = r.publish("kick", 1);
pub.close();
// second close must be a no-op
expect(() => pub.close()).not.toThrow();
});
it("write on a closed publisher is a no-op", { timeout: 5_000 }, () => {
const r = new DefaultBusRegistry();
const pub = r.publish("kick", 1);
pub.close();
expect(() => pub.write(frame([1]))).not.toThrow();
});
it("republishing the same name closes the previous publisher", {
timeout: 5_000,
}, () => {
const r = new DefaultBusRegistry();
const first = r.publish("kick", 1);
const events: Array<unknown> = [];
r.subscribe("kick", (b) => events.push(b));
const second = r.publish("kick", 2);
expect(r.get("kick")).toBe(second.bus);
expect(first.bus.channels).toBe(1);
// subscribers see: close of first (undefined), then second (bus)
expect(events.length).toBe(2);
expect(events[0]).toBeUndefined();
expect(events[1]).toBe(second.bus);
});
});
describe("DefaultBusRegistry: subscribe", () => {
it("fires the listener on publish and on close", {
timeout: 5_000,
}, () => {
const r = new DefaultBusRegistry();
const events: Array<unknown> = [];
const unsub = r.subscribe("kick", (b) => events.push(b));
const pub = r.publish("kick", 1);
pub.close();
unsub();
expect(events.length).toBe(2);
expect(events[0]).toBe(pub.bus);
expect(events[1]).toBeUndefined();
});
it("multiple listeners on the same name all fire", {
timeout: 5_000,
}, () => {
const r = new DefaultBusRegistry();
let countA = 0;
let countB = 0;
r.subscribe("kick", () => countA++);
r.subscribe("kick", () => countB++);
r.publish("kick", 1);
expect(countA).toBe(1);
expect(countB).toBe(1);
});
it("unsubscribe removes only that listener; cleans up empty buckets", {
timeout: 5_000,
}, () => {
const r = new DefaultBusRegistry();
let countA = 0;
let countB = 0;
const unsubA = r.subscribe("kick", () => countA++);
r.subscribe("kick", () => countB++);
unsubA();
r.publish("kick", 1);
expect(countA).toBe(0);
expect(countB).toBe(1);
});
it("subscribe before any publish is harmless", {
timeout: 5_000,
}, () => {
const r = new DefaultBusRegistry();
const events: Array<unknown> = [];
r.subscribe("kick", (b) => events.push(b));
expect(events.length).toBe(0);
r.publish("kick", 1);
expect(events.length).toBe(1);
});
});

View File

@@ -0,0 +1,169 @@
import type { SidechainBus } from "../base/audio-fx-plugin.types.js";
import { AUDIO_FX_CODES, AudioFxError } from "../error-codes/error-codes.js";
import type { BusRegistry, SidechainPublisher } from "./bus-registry.types.js";
/**
* Reference implementation of the project-level `BusRegistry` used by
* `audio-engine-browser` / `audio-engine-server` and by tests. Each
* published bus owns one set of per-channel `Float32Array` accumulators
* sized to the configured block length; consumers read the most-recent
* write into their own scratch buffer.
*
* Concurrency model:
*
* - Engines order chain processing topologically so a publisher always
* writes before a subscriber reads in the same block; this registry
* does not synchronise (audio rendering is single-threaded per engine
* block).
* - A consumer that reads before any publisher writes sees silence
* (the accumulator is zero-filled at publish).
* - `subscribe(name, listener)` fires when the named bus appears or
* disappears (i.e. on `publish` for a new name, on `close` for the
* matching publisher); useful for plugins that resolve the bus
* lazily after `setup`.
*/
export class DefaultBusRegistry implements BusRegistry {
private readonly publishers = new Map<string, PublisherRecord>();
private readonly listeners = new Map<
string,
Set<(bus: SidechainBus | undefined) => void>
>();
public get(name: string): SidechainBus | undefined {
return this.publishers.get(name)?.bus;
}
public list(): ReadonlyArray<string> {
return Array.from(this.publishers.keys());
}
public publish(name: string, channels: number): SidechainPublisher {
const existing = this.publishers.get(name);
if (existing !== undefined) {
// Republishing the same name is treated as a re-init: close the
// previous publisher (which removes it from the registry) and
// continue with a fresh bus.
existing.publisher.close();
}
const accumulators: Float32Array[] = [];
for (let ch = 0; ch < channels; ch++) {
accumulators.push(new Float32Array(0));
}
let closed = false;
const bus: SidechainBus = {
name,
channels,
read: (into: Float32Array[]) => {
if (closed) {
// Closed bus reads as silence (no throw — let the
// consumer keep running).
for (const ch of into) {
ch.fill(0);
}
return;
}
if (into.length !== channels) {
throw new AudioFxError(
AUDIO_FX_CODES.BUS_CHANNEL_MISMATCH,
"audio-fx.bus-channel-mismatch",
{
context: {
bus: name,
expected: channels,
actual: into.length,
},
}
);
}
for (let ch = 0; ch < channels; ch++) {
const src = accumulators[ch]!;
const dst = into[ch]!;
const len = Math.min(src.length, dst.length);
for (let i = 0; i < len; i++) {
dst[i] = src[i]!;
}
// Fill any trailing samples the publisher hasn't
// written yet with silence so consumers see a
// deterministic buffer.
if (dst.length > len) {
dst.fill(0, len);
}
}
},
};
const publisher: SidechainPublisher = {
bus,
write: (samples: Float32Array[]) => {
if (closed) {
return;
}
if (samples.length !== channels) {
throw new AudioFxError(
AUDIO_FX_CODES.BUS_CHANNEL_MISMATCH,
"audio-fx.bus-channel-mismatch",
{
context: {
bus: name,
expected: channels,
actual: samples.length,
},
}
);
}
for (let ch = 0; ch < channels; ch++) {
const src = samples[ch]!;
let acc = accumulators[ch]!;
if (acc.length !== src.length) {
acc = new Float32Array(src.length);
accumulators[ch] = acc;
}
acc.set(src);
}
},
close: () => {
if (closed) {
return;
}
closed = true;
this.publishers.delete(name);
this.emit(name, undefined);
},
};
this.publishers.set(name, { bus, publisher });
this.emit(name, bus);
return publisher;
}
public subscribe(
name: string,
listener: (bus: SidechainBus | undefined) => void
): () => void {
let bucket = this.listeners.get(name);
if (bucket === undefined) {
bucket = new Set();
this.listeners.set(name, bucket);
}
bucket.add(listener);
return () => {
bucket?.delete(listener);
if (bucket?.size === 0) {
this.listeners.delete(name);
}
};
}
private emit(name: string, bus: SidechainBus | undefined): void {
const bucket = this.listeners.get(name);
if (bucket === undefined) {
return;
}
for (const listener of bucket) {
listener(bus);
}
}
}
interface PublisherRecord {
readonly bus: SidechainBus;
readonly publisher: SidechainPublisher;
}

View File

@@ -0,0 +1,34 @@
import type {
BusRegistryReadHandle,
SidechainBus,
} from "../base/audio-fx-plugin.types.js";
/**
* Publisher handle returned by `BusRegistry.publish`. The owning track
* writes its post-FX output via `write(samples)` once per block; sub-
* scribed plugins read via the bus's `read()`.
*/
export interface SidechainPublisher {
readonly bus: SidechainBus;
write(samples: Float32Array[]): void;
/** Drops the bus from the registry; concurrent `bus.read()` callers see silence afterwards. */
close(): void;
}
/**
* Project-level registry passed to every chain via `ChainContext.buses`.
* Engines (`audio-engine-browser`, `audio-engine-server`) construct one
* instance and share it across every chain they host so sidechain
* routing crosses tracks.
*
* The base read surface (`get`, `list`) is exposed to plugins via
* `BusRegistryReadHandle`; the full mutator surface (`publish`,
* `subscribe`) is engine-only.
*/
export interface BusRegistry extends BusRegistryReadHandle {
publish(name: string, channels: number): SidechainPublisher;
subscribe(
name: string,
listener: (bus: SidechainBus | undefined) => void
): () => void;
}

View File

@@ -0,0 +1,110 @@
# `plugin-chain`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PluginChain` is the **universal insert-list primitive** that engines and headless callers use to compose, host, and render `AudioFxPlugin` instances. One implementation lives here so engine adapters (`audio-engine-browser`, `audio-engine-server`) reduce to "construct a chain per track, push ambient state, walk `process()` on each block, dispose on teardown." The chain owns composition (add / remove / move / bypass), chain-level event emission, IO compatibility validation at insertion, plugin delay compensation summing, scratch-buffer reuse for the per-slot bounce, ambient-state propagation via the `ChainContext` facade plugins read at `setup()`, and the typed event surface (`plugin-added`, `bypass-changed`, `ambient-changed`, `latency-changed`, ...).
The chain owns **no DSP**. Plugins implement DSP; the chain wires them up.
## Features
- Ordered insert list with `add`, `remove`, `move`, `bypass`; insertion validates channel-count edges against the chain's effective output via `AUDIO_FX.CHAIN_IO_INCOMPATIBLE` before mutating any state.
- Source-plugin rules enforced: `inputs[0].count === 0` is only valid at slot 0, and only one source per chain (`AUDIO_FX.CHAIN_SOURCE_POSITION_INVALID`).
- Bypass at chain (host) level — matches every enterprise DAW. Bypassed slots skip the plugin's `process()` and pass the input frame to the next slot's input.
- Plugin Delay Compensation: `getEffectiveLatencySamples()` sums non-bypassed plugin latencies; the chain subscribes to each plugin's `onLatencyChanged` and re-emits a chain-level `latency-changed` event with the new total.
- Engine-pushed ambient state via `updateAmbient(patch)`; equality-guarded so no-op patches don't emit spurious events.
- Stable `ChainContext` reference per slot for the plugin's lifetime (handed at `setup()`); peer lists, position, slot count, and ambient state update through getters on the captured reference.
- Per-block bounce uses two `Float32Array[]` scratch buffers (one per parity) reallocated only when channel count or block size changes, so steady-state processing is allocation-free.
- Typed event surface (`on(event, listener)`) returning an unsubscribe handle.
- `dispose()` releases plugin instances and detaches latency subscriptions; idempotent.
## Usage: Basic Example
```ts
import {
DefaultBusRegistry,
PluginChain,
defaultAmbientState,
} from "@suaveplan/audio-fx-plugin-base/chain";
import { EQPlugin, CompressorPlugin } from "./my-plugins.js";
const buses = new DefaultBusRegistry();
const chain = new PluginChain(
{ id: "drums", channels: 2, sampleRate: 48_000, blockSize: 128 },
buses
);
chain.add(new EQPlugin());
chain.add(new CompressorPlugin());
await chain.setup(
{ sampleRate: 48_000, channels: 2, blockSize: 128 },
defaultAmbientState()
);
// In your engine's per-block tick:
chain.process(input, output, blockContext);
```
## Usage: Advanced Example
```ts
const chain = new PluginChain(
{ id: "drums", channels: 2, sampleRate: 48_000, blockSize: 128 },
buses
);
chain.add(new SamplerPlugin()); // source plugin at slot 0
chain.add(new EQPlugin());
chain.add(new CompressorPlugin());
chain.add(new LimiterPlugin());
await chain.setup(processorConfig, defaultAmbientState());
// React to chain events from the UI layer:
const unsubLatency = chain.on("latency-changed", ({ effectiveLatencySamples }) => {
project.recomputePdc();
});
const unsubAmbient = chain.on("ambient-changed", ({ patch }) => {
if (patch.transport?.tempo !== undefined) {
ui.updateTempoIndicator(patch.transport.tempo);
}
});
// Engine pushes ambient state changes as the project mutates:
chain.updateAmbient({ track: { pan: -0.3, gainDb: -6 } });
chain.updateAmbient({ transport: { tempo: 140, state: "playing" } });
// User flips bypass on slot 2:
chain.bypass(2, true); // emits bypass-changed; latency-changed if non-zero latency
// On track teardown:
unsubLatency();
unsubAmbient();
chain.dispose();
```
## API Reference
- `class PluginChain(opts: PluginChainOptions, buses: BusRegistry)` — universal insert-list primitive.
- Composition: `add(plugin, at?)`, `remove(idOrIndex)`, `move(from, to)`, `bypass(idOrIndex, bypassed)`.
- Lifecycle: `setup(config, ambient, opts?)`, `process(input, output, ctx)`, `reset()`, `dispose()`.
- Inspection: `plugins`, `findById(id)`, `getEffectiveLatencySamples()`, `getEffectiveOutputChannels()`, `currentAmbient`.
- Ambient: `updateAmbient(patch)`.
- Events: `on(event, listener)` returning an unsubscribe handle. Events: `plugin-added`, `plugin-removed`, `plugin-moved`, `plugin-replaced`, `bypass-changed`, `parameter-changed`, `preset-applied`, `ambient-changed`, `latency-changed`.
- `PluginChainOptions` — `{ id, name?, channels, sampleRate, blockSize }`.
- `ChainEvent` / `ChainEventPayload` / `ChainEventListener<E>` — typed event surface.
## Implementation Notes
The chain bounces audio through two scratch buffer arrays (`scratchA`, `scratchB`) selected by slot index parity. The last slot writes directly to the caller's `output` buffer, so a chain of N plugins performs N processing calls plus zero allocations per block (after the one-shot scratch alloc at `setup` / channel-count change). Bypassed slots copy the previous frame into the next scratch slot without invoking the plugin's `process()`; this keeps the bounce loop branch-free per sample.
Insertion validation walks the entire candidate slot list — including the new slot — to confirm every edge holds. This is O(n) per insertion but catches the case where moving an existing slot would break a downstream edge. Validation runs against a candidate copy of the slot list, so a rejection leaves the chain unchanged.
The `ChainContextFactory` builds per-slot context objects whose `position`, `predecessors`, `successors`, and `ambient` are getters reading from the chain's mutable backing fields. This means plugins capture the context at `setup()` and the context reflects every subsequent composition or ambient change without re-binding — the contract every plugin author needs and the only way to keep the chain hot path cheap.
Latency tracking subscribes to each plugin's `onLatencyChanged` at `add()` time and unsubscribes at `remove()` / `dispose()` time. The chain caches the effective total and only emits `latency-changed` when it differs from the previous value, so a stable chain in steady state produces zero events. The engine adapter consumes this event to recompute project-wide PDC across tracks.
The chain's `id` is the namespace for error context (`AUDIO_FX.CHAIN_*`); every error thrown by the chain carries `chainId` in `context` so reporting can attribute failures to a specific track.

View File

@@ -0,0 +1,687 @@
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
import { describe, expect, it } from "@suaveplan/testing/runner";
import z from "zod";
import { AbstractAudioFxPlugin } from "../base/audio-fx-plugin.js";
import {
AUDIO_FX_CODES,
type AudioFxError,
} from "../error-codes/error-codes.js";
import {
type AmbientState,
applyAmbientPatch,
defaultAmbientState,
} from "./ambient-state.js";
import { DefaultBusRegistry } from "./bus-registry.js";
import { PluginChain } from "./chain.js";
const PRESET_SCHEMA = z.object({}).strict();
const STEREO_CONFIG: ProcessorConfig = {
sampleRate: 48_000,
channels: 2,
blockSize: 4,
};
const STEREO_IO = {
inputs: [{ count: 2 as const }],
outputs: [{ count: "preserve" as const }],
};
const SOURCE_IO = {
inputs: [{ count: 0 as const }],
outputs: [{ count: 2 as const }],
};
const MONO_IO = {
inputs: [{ count: 1 as const }],
outputs: [{ count: 1 as const }],
};
class GainPlugin extends AbstractAudioFxPlugin {
public constructor(id: string, gain = 1) {
super({
metadata: { id, name: id, version: "1.0.0", category: "utility" },
schema: PRESET_SCHEMA,
io: STEREO_IO,
parameters: [
{
id: "gain",
name: "Gain",
value: gain,
min: 0,
max: 4,
default: 1,
automatable: true,
},
],
supportedSchemaVersion: 1,
});
}
protected processBlock(
input: Float32Array[],
output: Float32Array[]
): void {
const g = this.parameterValue("gain");
for (let ch = 0; ch < input.length; ch++) {
for (let i = 0; i < input[ch]!.length; i++) {
output[ch]![i] = input[ch]![i]! * g;
}
}
}
}
class MonoPlugin extends AbstractAudioFxPlugin {
public constructor(id = "mono") {
super({
metadata: { id, name: id, version: "1.0.0", category: "utility" },
schema: PRESET_SCHEMA,
io: MONO_IO,
parameters: [],
supportedSchemaVersion: 1,
});
}
protected processBlock(
input: Float32Array[],
output: Float32Array[]
): void {
for (let ch = 0; ch < input.length; ch++) {
output[ch]!.set(input[ch]!);
}
}
}
class SourcePlugin extends AbstractAudioFxPlugin {
public constructor(id = "src") {
super({
metadata: { id, name: id, version: "1.0.0", category: "source" },
schema: PRESET_SCHEMA,
io: SOURCE_IO,
parameters: [],
supportedSchemaVersion: 1,
});
}
protected processBlock(
_input: Float32Array[],
output: Float32Array[]
): void {
for (let ch = 0; ch < output.length; ch++) {
output[ch]!.fill(0.25);
}
}
}
class LatencyPlugin extends AbstractAudioFxPlugin {
public constructor(id: string, initialLatency: number) {
super({
metadata: { id, name: id, version: "1.0.0", category: "utility" },
schema: PRESET_SCHEMA,
io: STEREO_IO,
parameters: [],
capabilities: {
hasAudioNode: false,
hasWorklet: false,
hasSidechain: false,
sampleAccurate: true,
stateful: false,
asyncSetup: false,
preservesChannelCount: true,
latencySamples: initialLatency,
},
supportedSchemaVersion: 1,
});
}
public bump(samples: number): void {
this.notifyLatencyChanged(samples);
}
protected processBlock(
input: Float32Array[],
output: Float32Array[]
): void {
for (let ch = 0; ch < input.length; ch++) {
output[ch]!.set(input[ch]!);
}
}
}
const AMBIENT: AmbientState = defaultAmbientState();
function blockCtx(): BlockContext {
return {
blockOffsetSamples: 0,
sampleRate: 48_000,
channels: 2,
opts: {},
};
}
function buffers(
channels = 2,
blockSize = 4
): {
input: Float32Array[];
output: Float32Array[];
} {
const input: Float32Array[] = [];
const output: Float32Array[] = [];
for (let ch = 0; ch < channels; ch++) {
input.push(new Float32Array(blockSize));
output.push(new Float32Array(blockSize));
}
return { input, output };
}
function chain(
opts: Partial<Parameters<typeof PluginChain.prototype.constructor>[0]> = {}
): PluginChain {
return new PluginChain(
{
id: "c1",
channels: 2,
sampleRate: 48_000,
blockSize: 4,
...opts,
},
new DefaultBusRegistry()
);
}
describe("PluginChain: composition", () => {
it("add() appends and emits plugin-added", { timeout: 5_000 }, () => {
const c = chain();
const events: Array<{ id: string; index: number }> = [];
c.on("plugin-added", (e) =>
events.push({ id: e.plugin.metadata.id, index: e.index })
);
c.add(new GainPlugin("a"));
c.add(new GainPlugin("b"));
expect(c.plugins.map((p) => p.metadata.id)).toEqual(["a", "b"]);
expect(events).toEqual([
{ id: "a", index: 0 },
{ id: "b", index: 1 },
]);
});
it("add() with an `at` index inserts in the middle", {
timeout: 5_000,
}, () => {
const c = chain();
c.add(new GainPlugin("a"));
c.add(new GainPlugin("c"));
c.add(new GainPlugin("b"), 1);
expect(c.plugins.map((p) => p.metadata.id)).toEqual(["a", "b", "c"]);
});
it("add() rejects duplicate plugin id with CHAIN_DUPLICATE_PLUGIN_ID", {
timeout: 5_000,
}, () => {
const c = chain();
c.add(new GainPlugin("a"));
try {
c.add(new GainPlugin("a"));
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.CHAIN_DUPLICATE_PLUGIN_ID
);
return;
}
throw new Error("expected CHAIN_DUPLICATE_PLUGIN_ID");
});
it("add() rejects out-of-range index with CHAIN_SLOT_NOT_FOUND", {
timeout: 5_000,
}, () => {
const c = chain();
try {
c.add(new GainPlugin("a"), 5);
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.CHAIN_SLOT_NOT_FOUND
);
return;
}
throw new Error("expected CHAIN_SLOT_NOT_FOUND");
});
it("add() rejects IO mismatch with CHAIN_IO_INCOMPATIBLE", {
timeout: 5_000,
}, () => {
const c = chain(); // stereo
try {
c.add(new MonoPlugin()); // mono input on stereo chain
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.CHAIN_IO_INCOMPATIBLE
);
return;
}
throw new Error("expected CHAIN_IO_INCOMPATIBLE");
});
it("add() rejects a source plugin at non-zero index", {
timeout: 5_000,
}, () => {
const c = chain();
c.add(new GainPlugin("a"));
try {
c.add(new SourcePlugin(), 1);
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.CHAIN_SOURCE_POSITION_INVALID
);
return;
}
throw new Error("expected CHAIN_SOURCE_POSITION_INVALID");
});
it("add() rejects a second source plugin even at slot 0", {
timeout: 5_000,
}, () => {
const c = chain();
c.add(new SourcePlugin("s1"));
try {
c.add(new SourcePlugin("s2"), 0);
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.CHAIN_SOURCE_POSITION_INVALID
);
return;
}
throw new Error("expected CHAIN_SOURCE_POSITION_INVALID");
});
it("remove() drops by id or index and emits plugin-removed", {
timeout: 5_000,
}, () => {
const c = chain();
c.add(new GainPlugin("a"));
c.add(new GainPlugin("b"));
const events: number[] = [];
c.on("plugin-removed", (e) => events.push(e.index));
c.remove("a");
c.remove(0);
expect(c.plugins).toEqual([]);
expect(events).toEqual([0, 0]);
});
it("remove() rejects unknown id / index with CHAIN_SLOT_NOT_FOUND", {
timeout: 5_000,
}, () => {
const c = chain();
for (const op of [() => c.remove("nope"), () => c.remove(0)]) {
try {
op();
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.CHAIN_SLOT_NOT_FOUND
);
continue;
}
throw new Error("expected CHAIN_SLOT_NOT_FOUND");
}
});
it("move() reorders and emits plugin-moved", { timeout: 5_000 }, () => {
const c = chain();
c.add(new GainPlugin("a"));
c.add(new GainPlugin("b"));
c.add(new GainPlugin("c"));
let payload: { from: number; to: number } | undefined;
c.on("plugin-moved", (e) => {
payload = { from: e.from, to: e.to };
});
c.move(0, 2);
expect(c.plugins.map((p) => p.metadata.id)).toEqual(["b", "c", "a"]);
expect(payload).toEqual({ from: 0, to: 2 });
});
it("move() with from === to is a no-op", { timeout: 5_000 }, () => {
const c = chain();
c.add(new GainPlugin("a"));
let fired = false;
c.on("plugin-moved", () => {
fired = true;
});
c.move(0, 0);
expect(fired).toBe(false);
});
it("move() rejects out-of-range from/to with CHAIN_SLOT_NOT_FOUND", {
timeout: 5_000,
}, () => {
const c = chain();
c.add(new GainPlugin("a"));
for (const op of [() => c.move(2, 0), () => c.move(0, 9)]) {
try {
op();
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.CHAIN_SLOT_NOT_FOUND
);
continue;
}
throw new Error("expected CHAIN_SLOT_NOT_FOUND");
}
});
});
describe("PluginChain: bypass", () => {
it("bypass() toggles and emits bypass-changed", {
timeout: 5_000,
}, async () => {
const c = chain();
const p = new GainPlugin("a", 2);
c.add(p);
await c.setup(STEREO_CONFIG, AMBIENT);
const events: Array<{ id: string; bypassed: boolean }> = [];
c.on("bypass-changed", (e) =>
events.push({ id: e.plugin.metadata.id, bypassed: e.bypassed })
);
c.bypass("a", true);
c.bypass("a", true); // idempotent
c.bypass(0, false);
expect(events).toEqual([
{ id: "a", bypassed: true },
{ id: "a", bypassed: false },
]);
});
it("bypassed slot copies input to output without invoking process", {
timeout: 5_000,
}, async () => {
const c = chain();
const p = new GainPlugin("a", 2); // ×2 gain
c.add(p);
await c.setup(STEREO_CONFIG, AMBIENT);
c.bypass(0, true);
const { input, output } = buffers();
input[0]!.fill(0.5);
input[1]!.fill(-0.5);
c.process(input, output, blockCtx());
// unchanged because bypassed (no ×2 applied)
expect(output[0]![0]).toBe(0.5);
expect(output[1]![2]).toBe(-0.5);
});
});
describe("PluginChain: setup + process", () => {
it("processes a single plugin end-to-end", {
timeout: 5_000,
}, async () => {
const c = chain();
c.add(new GainPlugin("a", 2));
await c.setup(STEREO_CONFIG, AMBIENT);
const { input, output } = buffers();
input[0]!.fill(1);
input[1]!.fill(1);
c.process(input, output, blockCtx());
expect(output[0]![0]).toBe(2);
expect(output[1]![2]).toBe(2);
});
it("chains multiple plugins; output equals composed plugin processing", {
timeout: 5_000,
}, async () => {
const c = chain();
c.add(new GainPlugin("a", 2));
c.add(new GainPlugin("b", 3));
c.add(new GainPlugin("c", 0.5));
await c.setup(STEREO_CONFIG, AMBIENT);
const { input, output } = buffers();
input[0]!.fill(1);
input[1]!.fill(1);
c.process(input, output, blockCtx());
// 1 × 2 × 3 × 0.5 = 3
expect(output[0]![0]).toBeCloseTo(3, 6);
expect(output[1]![3]).toBeCloseTo(3, 6);
});
it("passes input through an empty chain unchanged", {
timeout: 5_000,
}, async () => {
const c = chain();
await c.setup(STEREO_CONFIG, AMBIENT);
const { input, output } = buffers();
input[0]!.fill(0.7);
input[1]!.fill(-0.3);
c.process(input, output, blockCtx());
expect(output[0]![0]).toBeCloseTo(0.7, 6);
expect(output[1]![1]).toBeCloseTo(-0.3, 6);
});
it("reset() forwards to every plugin", { timeout: 5_000 }, async () => {
const c = chain();
c.add(new GainPlugin("a"));
c.add(new GainPlugin("b"));
await c.setup(STEREO_CONFIG, AMBIENT);
expect(() => c.reset()).not.toThrow();
});
it("reuses scratch buffers when channel count and block size are stable", {
timeout: 5_000,
}, async () => {
const c = chain();
c.add(new GainPlugin("a"));
c.add(new GainPlugin("b"));
c.add(new GainPlugin("c"));
await c.setup(STEREO_CONFIG, AMBIENT);
const { input, output } = buffers();
// Two consecutive process() calls; should not throw.
c.process(input, output, blockCtx());
c.process(input, output, blockCtx());
});
});
describe("PluginChain: ambient state", () => {
it("updateAmbient() merges patches and emits ambient-changed", {
timeout: 5_000,
}, () => {
const c = chain();
const events: Array<unknown> = [];
c.on("ambient-changed", (e) => events.push(e.patch));
c.updateAmbient({ transport: { tempo: 140 } });
expect(c.currentAmbient.transport.tempo).toBe(140);
expect(events.length).toBe(1);
});
it("updateAmbient() with a no-op patch does not emit", {
timeout: 5_000,
}, () => {
const c = chain();
c.updateAmbient({ transport: { tempo: 120 } }); // default already 120
let fired = 0;
c.on("ambient-changed", () => fired++);
c.updateAmbient({ transport: { tempo: 120 } });
expect(fired).toBe(0);
});
it("plugins see ambient updates via captured ChainContext", {
timeout: 5_000,
}, async () => {
const c = chain();
const p = new GainPlugin("a");
c.add(p);
await c.setup(
STEREO_CONFIG,
applyAmbientPatch(AMBIENT, {
transport: { tempo: 120 },
})
);
c.updateAmbient({ transport: { tempo: 200 } });
// The plugin's captured chainContext.ambient.transport.tempo
// is now 200 — verified by reading through the public surface
// (the ChainContext is internal; this asserts the propagation
// via the chain's own currentAmbient mirror).
expect(c.currentAmbient.transport.tempo).toBe(200);
});
});
describe("PluginChain: PDC + latency events", () => {
it("getEffectiveLatencySamples sums non-bypassed plugin latencies", {
timeout: 5_000,
}, async () => {
const c = chain();
const lim = new LatencyPlugin("lim", 100);
const free = new GainPlugin("free");
const reverb = new LatencyPlugin("reverb", 50);
c.add(lim);
c.add(free);
c.add(reverb);
await c.setup(STEREO_CONFIG, AMBIENT);
expect(c.getEffectiveLatencySamples()).toBe(150);
c.bypass("lim", true);
expect(c.getEffectiveLatencySamples()).toBe(50);
c.bypass("lim", false);
expect(c.getEffectiveLatencySamples()).toBe(150);
});
it("plugin latency change re-emits chain-level latency-changed", {
timeout: 5_000,
}, async () => {
const c = chain();
const lim = new LatencyPlugin("lim", 0);
c.add(lim);
await c.setup(STEREO_CONFIG, AMBIENT);
const events: number[] = [];
c.on("latency-changed", (e) => events.push(e.effectiveLatencySamples));
lim.bump(64);
lim.bump(64); // idempotent
lim.bump(128);
expect(events).toEqual([64, 128]);
expect(c.getEffectiveLatencySamples()).toBe(128);
});
});
describe("PluginChain: dispose", () => {
it("dispose disposes every plugin and is idempotent", {
timeout: 5_000,
}, async () => {
const c = chain();
c.add(new GainPlugin("a"));
c.add(new GainPlugin("b"));
await c.setup(STEREO_CONFIG, AMBIENT);
c.dispose();
c.dispose(); // idempotent
expect(c.plugins).toEqual([]);
});
it("operations after dispose throw NOT_INITIALIZED", {
timeout: 5_000,
}, async () => {
const c = chain();
await c.setup(STEREO_CONFIG, AMBIENT);
c.dispose();
try {
c.add(new GainPlugin("a"));
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.NOT_INITIALIZED
);
return;
}
throw new Error("expected NOT_INITIALIZED");
});
});
describe("PluginChain: findById + getEffectiveOutputChannels + source chain", () => {
it("findById returns the plugin and its index", { timeout: 5_000 }, () => {
const c = chain();
const p = new GainPlugin("a");
c.add(p);
expect(c.findById("a")?.plugin).toBe(p);
expect(c.findById("a")?.index).toBe(0);
expect(c.findById("nope")).toBeUndefined();
});
it("getEffectiveOutputChannels reflects chain config + plugin outputs", {
timeout: 5_000,
}, () => {
const c = chain();
c.add(new GainPlugin("a"));
expect(c.getEffectiveOutputChannels()).toBe(2);
});
it("processes a source-led chain end-to-end", {
timeout: 5_000,
}, async () => {
const c = chain();
c.add(new SourcePlugin("src"));
c.add(new GainPlugin("gain", 2));
await c.setup(STEREO_CONFIG, AMBIENT);
const { input, output } = buffers();
c.process(input, output, blockCtx());
// source fills 0.25 → ×2 = 0.5
expect(output[0]![0]).toBe(0.5);
expect(output[1]![3]).toBe(0.5);
});
});
describe("PluginChain: event subscription cleanup", () => {
it("unsubscribe stops the listener firing", { timeout: 5_000 }, () => {
const c = chain();
let count = 0;
const unsub = c.on("plugin-added", () => count++);
c.add(new GainPlugin("a"));
unsub();
c.add(new GainPlugin("b"));
expect(count).toBe(1);
});
it("multiple listeners on the same event all fire", {
timeout: 5_000,
}, () => {
const c = chain();
let a = 0;
let b = 0;
c.on("plugin-added", () => a++);
c.on("plugin-added", () => b++);
c.add(new GainPlugin("x"));
expect(a).toBe(1);
expect(b).toBe(1);
});
});
describe("PluginChain: ChainContext.chain handle reflects chain shape", () => {
it("ChainContext.chain.slotCount reflects the chain's current length", {
timeout: 5_000,
}, async () => {
// SpyPlugin captures the ctx.chain at setup time.
class SpyPlugin extends AbstractAudioFxPlugin {
public capturedSlotCount = -1;
public constructor() {
super({
metadata: {
id: "spy",
name: "Spy",
version: "1.0.0",
category: "utility",
},
schema: PRESET_SCHEMA,
io: STEREO_IO,
parameters: [],
supportedSchemaVersion: 1,
});
}
protected override setupState(
_config: ProcessorConfig,
chainContext: import("../base/audio-fx-plugin.types.js").ChainContext
): void {
this.capturedSlotCount = chainContext.chain.slotCount;
}
protected processBlock(
input: Float32Array[],
output: Float32Array[]
): void {
for (let ch = 0; ch < input.length; ch++) {
output[ch]!.set(input[ch]!);
}
}
}
const c = chain();
const spy = new SpyPlugin();
c.add(new GainPlugin("a"));
c.add(spy);
c.add(new GainPlugin("b"));
await c.setup(STEREO_CONFIG, AMBIENT);
expect(spy.capturedSlotCount).toBe(3);
});
});

View File

@@ -0,0 +1,300 @@
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
import type {
AmbientState,
AudioFxPlugin,
UnsubscribeFn,
} from "../base/audio-fx-plugin.types.js";
import { AUDIO_FX_CODES, AudioFxError } from "../error-codes/error-codes.js";
import type { PluginRegistry } from "../registry/registry.js";
import {
type AmbientStatePatch,
ambientStateEquals,
applyAmbientPatch,
defaultAmbientState,
} from "./ambient-state.js";
import type { BusRegistry } from "./bus-registry.types.js";
import type {
ChainEvent,
ChainEventListener,
PluginChainOptions,
} from "./chain.types.js";
import {
AmbientStateBox,
ChainContextFactory,
type ChainSlotSnapshot,
createChainHandle,
pluginHandleFor,
} from "./context/index.js";
import { ChainEventBus } from "./event-bus/index.js";
import {
addSlotToChain,
allocatePingPongBuffers,
computeEffectiveOutputChannels,
moveSlotInChain,
resolveSlotIndex,
} from "./insert/index.js";
import {
computeEffectiveLatency,
disposeChainSlots,
processChainBlock,
setupChainSlots,
} from "./lifecycle/index.js";
import {
buildChainSnapshot,
type ChainSnapshot,
restoreChainFromSnapshot,
} from "./snapshot/index.js";
/** Internal slot record; see `chain.md` for the full surface. */
interface ChainSlot {
plugin: AudioFxPlugin;
bypassed: boolean;
latencyUnsub?: UnsubscribeFn;
}
/**
* Ordered insert list of `AudioFxPlugin`s with chain-level bypass,
* ambient-state propagation, latency aggregation, and typed events.
*/
export class PluginChain {
public readonly id: string;
public readonly name: string;
private readonly slots: ChainSlot[] = [];
private config: ProcessorConfig | undefined;
private readonly ambientBox: AmbientStateBox;
private readonly contextFactory: ChainContextFactory;
private readonly events = new ChainEventBus();
private scratchA: Float32Array[] = [];
private scratchB: Float32Array[] = [];
private effectiveOutputChannels: number;
private cachedEffectiveLatency = 0;
private disposed = false;
private readonly slotCountRef: { value: number };
public constructor(opts: PluginChainOptions, buses: BusRegistry) {
this.id = opts.id;
this.name = opts.name ?? opts.id;
this.ambientBox = new AmbientStateBox(defaultAmbientState());
this.effectiveOutputChannels = opts.channels;
this.slotCountRef = { value: 0 };
this.contextFactory = new ChainContextFactory({
chain: createChainHandle(opts, this.name, this.slotCountRef),
buses,
ambient: this.ambientBox,
});
}
public get plugins(): ReadonlyArray<AudioFxPlugin> {
return this.slots.map((s) => s.plugin);
}
public get currentAmbient(): AmbientState {
return this.ambientBox.current;
}
public findById(
id: string
): { plugin: AudioFxPlugin; index: number } | undefined {
const index = this.slots.findIndex((s) => s.plugin.metadata.id === id);
return index < 0
? undefined
: { plugin: this.slots[index]!.plugin, index };
}
public getEffectiveLatencySamples(): number {
return this.cachedEffectiveLatency;
}
public getEffectiveOutputChannels(): number {
return this.effectiveOutputChannels;
}
public add(plugin: AudioFxPlugin, at?: number): void {
this.requireNotDisposed();
const slot: ChainSlot = { plugin, bypassed: false };
const index = addSlotToChain(
this.slots,
slot,
at,
this.effectiveOutputChannels,
this.id
);
slot.latencyUnsub = plugin.onLatencyChanged(() => {
this.recomputeEffectiveLatency();
});
this.rebuildAfterChange();
this.events.emit("plugin-added", { plugin, index });
}
public remove(idOrIndex: string | number): void {
this.requireNotDisposed();
const index = resolveSlotIndex(this.slots, idOrIndex, this.id);
const slot = this.slots[index]!;
slot.latencyUnsub?.();
this.slots.splice(index, 1);
this.rebuildAfterChange();
this.events.emit("plugin-removed", { plugin: slot.plugin, index });
}
public move(from: number, to: number): void {
this.requireNotDisposed();
const moved = moveSlotInChain(
this.slots,
from,
to,
this.effectiveOutputChannels,
this.id
);
if (moved === undefined) {
return;
}
this.rebuildAfterChange();
this.events.emit("plugin-moved", { plugin: moved.plugin, from, to });
}
public bypass(idOrIndex: string | number, bypassed: boolean): void {
this.requireNotDisposed();
const index = resolveSlotIndex(this.slots, idOrIndex, this.id);
const slot = this.slots[index]!;
if (slot.bypassed === bypassed) {
return;
}
slot.bypassed = bypassed;
this.recomputeEffectiveLatency();
this.events.emit("bypass-changed", {
plugin: slot.plugin,
index,
bypassed,
});
}
public updateAmbient(patch: AmbientStatePatch): void {
this.requireNotDisposed();
const previous = this.ambientBox.current;
const next = applyAmbientPatch(previous, patch);
if (ambientStateEquals(previous, next)) {
return;
}
this.ambientBox.set(next);
this.events.emit("ambient-changed", { patch });
}
public async setup(
config: ProcessorConfig,
ambient: AmbientState,
opts?: { signal?: AbortSignal }
): Promise<void> {
this.requireNotDisposed();
this.config = config;
this.effectiveOutputChannels = config.channels;
this.rebuildAfterChange();
this.ambientBox.set(ambient);
await setupChainSlots(this.slots, config, this.contextFactory, opts);
this.recomputeEffectiveLatency();
}
public process(
input: Float32Array[],
output: Float32Array[],
ctx: BlockContext
): void {
this.requireNotDisposed();
processChainBlock(
this.slots,
this.scratchA,
this.scratchB,
input,
output,
ctx
);
}
public reset(): void {
this.requireNotDisposed();
for (const slot of this.slots) {
slot.plugin.reset();
}
}
public dispose(): void {
if (this.disposed) {
return;
}
disposeChainSlots(this.slots);
this.events.clear();
this.disposed = true;
}
public snapshot(): ChainSnapshot {
return buildChainSnapshot({
id: this.id,
name: this.name,
config: this.config,
effectiveOutputChannels: this.effectiveOutputChannels,
slots: this.slots,
});
}
public async restore(
input: unknown,
registry: PluginRegistry
): Promise<void> {
this.requireNotDisposed();
await restoreChainFromSnapshot(this, input, registry);
}
public on<E extends ChainEvent>(
event: E,
listener: ChainEventListener<E>
): UnsubscribeFn {
return this.events.on(event, listener);
}
private requireNotDisposed(): void {
if (this.disposed) {
throw new AudioFxError(
AUDIO_FX_CODES.NOT_INITIALIZED,
"audio-fx.chain-disposed",
{ context: { chainId: this.id } }
);
}
}
private rebuildAfterChange(): void {
const inputCh = this.config?.channels ?? this.effectiveOutputChannels;
this.effectiveOutputChannels = computeEffectiveOutputChannels(
this.slots,
inputCh
);
this.slotCountRef.value = this.slots.length;
const snap: ChainSlotSnapshot[] = this.slots.map((slot) => ({
plugin: pluginHandleFor(slot.plugin, slot),
bypassed: slot.bypassed,
}));
this.contextFactory.rebuild(snap);
this.recomputeEffectiveLatency();
if (this.config === undefined) {
return;
}
const buffers = allocatePingPongBuffers(
this.config.channels,
this.config.blockSize,
this.scratchA,
this.scratchB
);
this.scratchA = buffers.a;
this.scratchB = buffers.b;
}
private recomputeEffectiveLatency(): void {
const total = computeEffectiveLatency(this.slots);
if (total === this.cachedEffectiveLatency) {
return;
}
this.cachedEffectiveLatency = total;
this.events.emit("latency-changed", {
effectiveLatencySamples: total,
});
}
}

View File

@@ -0,0 +1,67 @@
import type { AudioFxPlugin } from "../base/audio-fx-plugin.types.js";
import type { AmbientStatePatch } from "./ambient-state.js";
export interface PluginChainOptions {
readonly id: string;
readonly name?: string;
readonly channels: number;
readonly sampleRate: number;
readonly blockSize: number;
}
export type ChainEvent =
| "plugin-added"
| "plugin-removed"
| "plugin-moved"
| "plugin-replaced"
| "bypass-changed"
| "parameter-changed"
| "preset-applied"
| "ambient-changed"
| "latency-changed";
export interface ChainEventPayload {
readonly "plugin-added": {
plugin: AudioFxPlugin;
index: number;
};
readonly "plugin-removed": {
plugin: AudioFxPlugin;
index: number;
};
readonly "plugin-moved": {
plugin: AudioFxPlugin;
from: number;
to: number;
};
readonly "plugin-replaced": {
from: AudioFxPlugin;
to: AudioFxPlugin;
index: number;
};
readonly "bypass-changed": {
plugin: AudioFxPlugin;
index: number;
bypassed: boolean;
};
readonly "parameter-changed": {
plugin: AudioFxPlugin;
index: number;
paramId: string;
value: number;
};
readonly "preset-applied": {
plugin: AudioFxPlugin;
index: number;
};
readonly "ambient-changed": {
patch: AmbientStatePatch;
};
readonly "latency-changed": {
effectiveLatencySamples: number;
};
}
export type ChainEventListener<E extends ChainEvent> = (
payload: ChainEventPayload[E]
) => void;

View File

@@ -0,0 +1,80 @@
# `context`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ChainContextFactory` builds one `ChainContext` per plugin slot for the `PluginChain` class. The handed-out reference is **stable for the chain's lifetime** so plugins can capture it at `setup()` and dereference its slots every block; the factory rebuilds peer lists and updates the ambient slot getter so consumers always see the latest state without re-binding the context object itself. `AmbientStateBox` is the small mutable wrapper that lets the factory expose a getter-only `ambient` property pointing at whatever the chain just pushed; `pluginHandleFor` constructs the read-only `PluginHandle` peer view that lets plugins see each other without holding the mutator surface.
## Features
- One stable `ChainContext` reference per slot for the chain's lifetime; consumers may safely capture and dereference.
- Getter-based `position`, `predecessors`, `successors`, `ambient` so the same context reference reflects subsequent rebuilds without re-allocation.
- `AmbientStateBox` exposes `current` for the context's ambient getter; `set(next)` swaps the pointer in one assignment.
- `pluginHandleFor` produces a read-only `PluginHandle` with `bypassed` as a getter against the slot record, so changing the slot's bypass flag propagates to every peer view immediately.
## Usage: Basic Example
```ts
import {
AmbientStateBox,
ChainContextFactory,
defaultAmbientState,
pluginHandleFor,
} from "@suaveplan/audio-fx-plugin-base/chain";
const ambient = new AmbientStateBox(defaultAmbientState());
const factory = new ChainContextFactory({
chain: { id: "t-1", name: "Track 1", channels: 2, sampleRate: 48_000, blockSize: 128, slotCount: 0 },
buses: busRegistry,
ambient,
});
factory.rebuild([
{ plugin: handleFor(eq, slotA), bypassed: false },
{ plugin: handleFor(comp, slotB), bypassed: false },
]);
await eq.setup(processorConfig, factory.contextFor(0));
await comp.setup(processorConfig, factory.contextFor(1));
```
## Usage: Advanced Example
```ts
// Chain composition change: insert a limiter at slot 0.
factory.rebuild([
{ plugin: handleFor(lim, slotL), bypassed: false },
{ plugin: handleFor(eq, slotA), bypassed: false },
{ plugin: handleFor(comp, slotB), bypassed: false },
]);
// eq.chainContext.position now reports { index: 1, total: 3 } automatically
// because position is a getter that reads from the factory's slots array.
// Ambient push from the engine on the next render block:
ambient.set(applyAmbientPatch(ambient.current, { transport: { tempo: 140 } }));
// Every captured ChainContext.ambient.transport.tempo now reads 140.
```
## API Reference
- `class ChainContextFactory({ chain, buses, ambient })` — builds and caches one `ChainContext` per slot.
- `rebuild(slots: ReadonlyArray<ChainSlotSnapshot>): void` — recomputes the cached contexts; reuses existing references where possible.
- `contextFor(index: number): ChainContext` — returns the cached context for a slot; throws on out-of-range index.
- `class AmbientStateBox(initial: AmbientState)` — mutable wrapper around the current ambient state.
- `current: AmbientState` — latest pushed value.
- `set(next: AmbientState): void` — swap the pointer.
- `ChainSlotSnapshot` — `{ plugin: PluginHandle; bypassed: boolean }` per chain slot.
- `pluginHandleFor(plugin, slot): PluginHandle` — wraps a plugin instance + its mutable slot record into the read-only peer view.
## Implementation Notes
The `ChainContext` reference is stable per slot because the `position`, `predecessors`, `successors`, and `ambient` members are getters reading from the factory's mutable backing fields. A naive implementation that returned a fresh object on each rebuild would force every plugin to re-capture the context, breaking the documented "captured at `setup()`" contract. The getters make captured references self-updating.
`predecessors` / `successors` allocate a fresh array on each get because peers may have changed between blocks; the size is typically 0–10 (chain depth), so the allocation is negligible. Plugins that read peer state every block can cache the array length once between renders if they care about per-block allocation budgets.
`pluginHandleFor` accepts both the plugin's read-only surface AND the slot's mutable bypass flag so the `bypassed` getter resolves correctly when the chain flips bypass mid-render. Tests verify the getter sees subsequent mutations of the slot record.
The factory's cached-context behaviour preserves references when the chain shrinks: existing slot contexts are reused, trailing ones drop. Re-adding a slot later allocates a fresh context. This is the right shape because a plugin disposed off the end of the chain should not retain its old context; if it's re-added, it gets a clean view.

View File

@@ -0,0 +1,231 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
BusRegistryReadHandle,
ChainHandle,
PluginHandle,
} from "../../base/audio-fx-plugin.types.js";
import { defaultAmbientState } from "../ambient-state.js";
import {
AmbientStateBox,
ChainContextFactory,
type ChainSlotSnapshot,
pluginHandleFor,
} from "./context.js";
const CHAIN_HANDLE: ChainHandle = {
id: "c1",
name: "Track 1",
channels: 2,
sampleRate: 48_000,
blockSize: 128,
slotCount: 0,
};
const EMPTY_BUSES: BusRegistryReadHandle = {
get: () => undefined,
list: () => [],
};
function fakeHandle(id: string, paramValue = 0): PluginHandle {
return pluginHandleFor(
{
metadata: {
id,
name: id,
version: "1.0.0",
category: "utility",
},
io: { inputs: [{ count: 2 }], outputs: [{ count: "preserve" }] },
capabilities: {
hasAudioNode: false,
hasWorklet: false,
hasSidechain: false,
sampleAccurate: true,
stateful: false,
asyncSetup: false,
preservesChannelCount: true,
latencySamples: 0,
},
getParameter: () => paramValue,
getLatencySamples: () => 0,
},
{ bypassed: false }
);
}
function snap(...handles: PluginHandle[]): ChainSlotSnapshot[] {
return handles.map((h) => ({ plugin: h, bypassed: false }));
}
describe("AmbientStateBox", () => {
it("exposes the latest ambient state via `current`", {
timeout: 5_000,
}, () => {
const box = new AmbientStateBox(defaultAmbientState());
expect(box.current.transport.state).toBe("stopped");
const next = {
...defaultAmbientState(),
transport: {
...defaultAmbientState().transport,
state: "playing" as const,
},
};
box.set(next);
expect(box.current.transport.state).toBe("playing");
expect(box.current).toBe(next);
});
});
describe("ChainContextFactory: composition + slot views", () => {
it("builds one ChainContext per slot with position index + total", {
timeout: 5_000,
}, () => {
const box = new AmbientStateBox(defaultAmbientState());
const factory = new ChainContextFactory({
chain: CHAIN_HANDLE,
buses: EMPTY_BUSES,
ambient: box,
});
factory.rebuild(
snap(fakeHandle("a"), fakeHandle("b"), fakeHandle("c"))
);
const ctxA = factory.contextFor(0);
const ctxB = factory.contextFor(1);
const ctxC = factory.contextFor(2);
expect(ctxA.position).toEqual({ index: 0, total: 3 });
expect(ctxB.position).toEqual({ index: 1, total: 3 });
expect(ctxC.position).toEqual({ index: 2, total: 3 });
});
it("populates predecessors / successors per slot", {
timeout: 5_000,
}, () => {
const factory = new ChainContextFactory({
chain: CHAIN_HANDLE,
buses: EMPTY_BUSES,
ambient: new AmbientStateBox(defaultAmbientState()),
});
factory.rebuild(
snap(fakeHandle("a"), fakeHandle("b"), fakeHandle("c"))
);
const ctxB = factory.contextFor(1);
expect(ctxB.predecessors.map((p) => p.id)).toEqual(["a"]);
expect(ctxB.successors.map((p) => p.id)).toEqual(["c"]);
});
it("reflects subsequent rebuild() calls in existing context references", {
timeout: 5_000,
}, () => {
const factory = new ChainContextFactory({
chain: CHAIN_HANDLE,
buses: EMPTY_BUSES,
ambient: new AmbientStateBox(defaultAmbientState()),
});
factory.rebuild(snap(fakeHandle("a"), fakeHandle("b")));
const ctxA = factory.contextFor(0);
expect(ctxA.successors.map((p) => p.id)).toEqual(["b"]);
// Add a third slot via a fresh rebuild — captured ctxA must see it.
factory.rebuild(
snap(fakeHandle("a"), fakeHandle("b"), fakeHandle("c"))
);
expect(ctxA.position).toEqual({ index: 0, total: 3 });
expect(ctxA.successors.map((p) => p.id)).toEqual(["b", "c"]);
});
it("ambient getter reflects subsequent AmbientStateBox writes", {
timeout: 5_000,
}, () => {
const box = new AmbientStateBox(defaultAmbientState());
const factory = new ChainContextFactory({
chain: CHAIN_HANDLE,
buses: EMPTY_BUSES,
ambient: box,
});
factory.rebuild(snap(fakeHandle("a")));
const ctx = factory.contextFor(0);
expect(ctx.ambient.transport.state).toBe("stopped");
const next = {
...defaultAmbientState(),
transport: {
...defaultAmbientState().transport,
tempo: 200,
},
};
box.set(next);
expect(ctx.ambient.transport.tempo).toBe(200);
});
it("throws when asking for a slot index outside the rebuilt range", {
timeout: 5_000,
}, () => {
const factory = new ChainContextFactory({
chain: CHAIN_HANDLE,
buses: EMPTY_BUSES,
ambient: new AmbientStateBox(defaultAmbientState()),
});
factory.rebuild(snap(fakeHandle("a")));
expect(() => factory.contextFor(5)).toThrow();
});
it("shrinking the chain drops the trailing cached contexts", {
timeout: 5_000,
}, () => {
const factory = new ChainContextFactory({
chain: CHAIN_HANDLE,
buses: EMPTY_BUSES,
ambient: new AmbientStateBox(defaultAmbientState()),
});
factory.rebuild(
snap(fakeHandle("a"), fakeHandle("b"), fakeHandle("c"))
);
factory.rebuild(snap(fakeHandle("a")));
expect(() => factory.contextFor(1)).toThrow();
});
});
describe("pluginHandleFor", () => {
it("exposes id, metadata, io, capabilities, and parameter lookup", {
timeout: 5_000,
}, () => {
const handle = fakeHandle("x", 0.42);
expect(handle.id).toBe("x");
expect(handle.metadata.id).toBe("x");
expect(handle.capabilities.sampleAccurate).toBe(true);
expect(handle.getParameter("anything")).toBe(0.42);
expect(handle.getLatencySamples()).toBe(0);
});
it("bypassed reflects the slot's current bypassed flag (mutable through the slot)", {
timeout: 5_000,
}, () => {
const slot = { bypassed: false };
const handle = pluginHandleFor(
{
metadata: {
id: "x",
name: "X",
version: "1.0.0",
category: "utility",
},
io: { inputs: [{ count: 2 }], outputs: [{ count: 2 }] },
capabilities: {
hasAudioNode: false,
hasWorklet: false,
hasSidechain: false,
sampleAccurate: true,
stateful: false,
asyncSetup: false,
preservesChannelCount: true,
latencySamples: 0,
},
getParameter: () => 0,
getLatencySamples: () => 0,
},
slot
);
expect(handle.bypassed).toBe(false);
slot.bypassed = true;
expect(handle.bypassed).toBe(true);
});
});

View File

@@ -0,0 +1,173 @@
import type {
AmbientState,
BusRegistryReadHandle,
ChainContext,
ChainHandle,
PluginHandle,
} from "../../base/audio-fx-plugin.types.js";
/**
* Per-slot snapshot of the chain state visible to one plugin's
* `ChainContext`. The `PluginChain` rebuilds these (and the wider
* `ChainContext` instances) whenever composition changes.
*/
export interface ChainSlotSnapshot {
readonly plugin: PluginHandle;
/** Engine-determined bypass state for this slot. */
readonly bypassed: boolean;
}
/**
* Builds one `ChainContext` per plugin slot. The handed-out reference
* is stable for the chain's lifetime — its contents update via the
* `AmbientStateBox` (engine pushes patches) and the slot lists are
* regenerated by the chain on `add`/`remove`/`move`/`replace`. Plugins
* may capture the reference at `setup()` and dereference its slots
* every block.
*/
export class ChainContextFactory {
private readonly chainHandle: ChainHandle;
private readonly buses: BusRegistryReadHandle;
private readonly ambientBox: AmbientStateBox;
private slots: ReadonlyArray<ChainSlotSnapshot> = [];
private cachedContexts: ChainContext[] = [];
public constructor(opts: {
chain: ChainHandle;
buses: BusRegistryReadHandle;
ambient: AmbientStateBox;
}) {
this.chainHandle = opts.chain;
this.buses = opts.buses;
this.ambientBox = opts.ambient;
}
/**
* Recomputes the per-slot `ChainContext` array. Called by the chain
* after every composition change; existing context references are
* reused where possible so plugins that captured them still see the
* latest peer lists without re-binding.
*/
public rebuild(slots: ReadonlyArray<ChainSlotSnapshot>): void {
this.slots = slots;
const next: ChainContext[] = [];
for (let i = 0; i < slots.length; i++) {
const cached = this.cachedContexts[i];
if (cached !== undefined) {
next.push(cached);
} else {
next.push(this.createContextForSlot(i));
}
}
// Slots removed from the tail drop their cached contexts.
this.cachedContexts = next;
}
public contextFor(index: number): ChainContext {
const ctx = this.cachedContexts[index];
if (ctx === undefined) {
throw new Error(
`ChainContextFactory: no context for slot ${index} (chain length: ${this.cachedContexts.length})`
);
}
return ctx;
}
private createContextForSlot(index: number): ChainContext {
const factory = this;
const context: ChainContext = {
chain: this.chainHandle,
get position() {
return {
index,
total: factory.slots.length,
};
},
get predecessors() {
return factory.slots.slice(0, index).map((s) => s.plugin);
},
get successors() {
return factory.slots.slice(index + 1).map((s) => s.plugin);
},
get ambient() {
return factory.ambientBox.current;
},
buses: this.buses,
};
return context;
}
}
/**
* Wraps the chain's current `AmbientState` so the `ChainContextFactory`
* can hand plugins a stable getter (`ctx.ambient`) that always reflects
* the latest pushed value without re-allocating the surrounding context.
*/
export class AmbientStateBox {
public current: AmbientState;
public constructor(initial: AmbientState) {
this.current = initial;
}
public set(next: AmbientState): void {
this.current = next;
}
}
/**
* Build a `ChainHandle` whose `slotCount` getter resolves to the
* supplied mutable ref. The chain owns the ref and mutates it as
* slots come and go so plugins reading `ctx.chain.slotCount` always
* see the live count without re-binding.
*/
export function createChainHandle(
opts: {
readonly id: string;
readonly channels: number;
readonly sampleRate: number;
readonly blockSize: number;
},
name: string,
slotCountRef: { readonly value: number }
): import("../../base/audio-fx-plugin.types.js").ChainHandle {
return {
id: opts.id,
name,
channels: opts.channels,
sampleRate: opts.sampleRate,
blockSize: opts.blockSize,
get slotCount() {
return slotCountRef.value;
},
};
}
/**
* Lightweight read-only handle wrapping a plugin instance. Used by the
* chain to populate `ChainContext.predecessors` / `.successors` so peer
* plugins can read each other's parameters and IO without holding the
* mutator surface.
*/
export function pluginHandleFor(
plugin: {
metadata: PluginHandle["metadata"];
io: PluginHandle["io"];
capabilities: PluginHandle["capabilities"];
getParameter(id: string): number;
getLatencySamples(): number;
},
slot: { bypassed: boolean }
): PluginHandle {
return {
id: plugin.metadata.id,
metadata: plugin.metadata,
io: plugin.io,
capabilities: plugin.capabilities,
get bypassed() {
return slot.bypassed;
},
getParameter: (id) => plugin.getParameter(id),
getLatencySamples: () => plugin.getLatencySamples(),
};
}

View File

@@ -0,0 +1,7 @@
export {
AmbientStateBox,
ChainContextFactory,
type ChainSlotSnapshot,
createChainHandle,
pluginHandleFor,
} from "./context.js";

View File

@@ -0,0 +1,68 @@
# `event-bus`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `chain-event-bus` module owns the **typed pub/sub plumbing** consumed by `PluginChain`. Splitting it out of the chain class file keeps `plugin-chain.ts` focused on composition / lifecycle / IO routing while putting the event-bus mechanics in a small standalone surface that is independently testable and that future chain-shaped primitives (e.g. a `TrackChain` macro, a `BusChain` for sidechain routing) can reuse without dragging the full `PluginChain` in.
Every chain event listed in `ChainEvent` (`plugin-added`, `plugin-removed`, `plugin-moved`, `plugin-replaced`, `parameter-changed`, `preset-applied`, `bypass-changed`, `ambient-changed`, `latency-changed`) flows through this bus.
## Features
- `ChainEventBus` class — three methods (`on`, `emit`, `clear`) and one internal `Map<ChainEvent, Set<listener>>`.
- `on(event, listener)` returns an `UnsubscribeFn` so callers do not need to store the listener function separately to remove it later — the spec's "listeners SHALL receive an `UnsubscribeFn`" requirement is satisfied by construction.
- `emit(event, payload)` iterates the listener set for that event; the per-event narrowing on `payload` is provided by `ChainEventPayload[E]` from `chain.types.ts`.
- `clear()` drops every registered listener; called on chain `dispose()` so a disposed chain cannot fire stale events.
## Usage: Basic Example
```ts
import { ChainEventBus } from "@suaveplan/audio-fx-plugin-base/chain";
const bus = new ChainEventBus();
const unsub = bus.on("plugin-added", (e) => {
console.log("added", e.plugin.metadata.id, "at slot", e.index);
});
bus.emit("plugin-added", { plugin, index: 0 });
unsub();
```
## Usage: Advanced Example
```ts
import { ChainEventBus } from "@suaveplan/audio-fx-plugin-base/chain";
// A miniature React hook that subscribes to a chain event over its
// lifetime. The bus is owned by the chain; the hook is responsible
// only for binding the listener over its component lifetime.
function useChainEvent<E extends ChainEvent>(
bus: ChainEventBus,
event: E,
listener: ChainEventListener<E>
) {
React.useEffect(() => bus.on(event, listener), [bus, event, listener]);
}
// On chain teardown, drop every listener so a disposed chain cannot
// re-fire on a mid-flight effect:
chain.dispose();
// Internally calls bus.clear()
```
## API Reference
- `ChainEventBus` (class)
- `on<E extends ChainEvent>(event: E, listener: ChainEventListener<E>): UnsubscribeFn` — registers a listener; returns an idempotent unsubscribe.
- `emit<E extends ChainEvent>(event: E, payload: ChainEventPayload[E]): void` — fans the payload to every listener registered for `event`.
- `clear(): void` — drops every listener for every event.
## Implementation Notes
The bus stores listeners as `Set<unknown>` internally and re-casts at `emit` time because per-event sets typed as `Set<ChainEventListener<E>>` would require a per-event branch in `on` to widen the inserted listener; using `unknown` and a single cast at emit keeps the implementation a tight 30 lines without losing type safety at the public API surface (`on` and `emit` are both fully typed via the `E extends ChainEvent` generic).
The returned `UnsubscribeFn` captures the listener and the resolved bucket via closure rather than re-looking-up the bucket on every call. If `clear()` runs between `on` and the subsequent unsubscribe call, the captured `bucket` is still callable but no longer the map's bucket for that event — the optional chain (`bucket?.delete`) guards against this even though `bucket` is non-null at capture time, future-proofing for ergonomic patterns like `bus.on(...).then(...)`.
`emit` short-circuits when no listeners are registered for the event so the chain's hot path (per-block `parameter-changed` / `latency-changed` notifications) costs one `Map.get` plus a null check when there are no subscribers.

View File

@@ -0,0 +1,48 @@
import type { UnsubscribeFn } from "../../base/audio-fx-plugin.types.js";
import type {
ChainEvent,
ChainEventListener,
ChainEventPayload,
} from "../chain.types.js";
/**
* Typed event bus shared by `PluginChain`. Listeners are organised by
* event name with an `UnsubscribeFn` returned from `on`. `clear()` is
* called on chain `dispose()` so listeners cannot fire on a disposed
* chain.
*/
export class ChainEventBus {
private readonly listeners: Map<ChainEvent, Set<unknown>> = new Map();
public on<E extends ChainEvent>(
event: E,
listener: ChainEventListener<E>
): UnsubscribeFn {
let bucket = this.listeners.get(event);
if (bucket === undefined) {
bucket = new Set();
this.listeners.set(event, bucket);
}
bucket.add(listener);
return () => {
bucket?.delete(listener);
};
}
public emit<E extends ChainEvent>(
event: E,
payload: ChainEventPayload[E]
): void {
const bucket = this.listeners.get(event);
if (bucket === undefined) {
return;
}
for (const listener of bucket) {
(listener as ChainEventListener<E>)(payload);
}
}
public clear(): void {
this.listeners.clear();
}
}

View File

@@ -0,0 +1 @@
export { ChainEventBus } from "./event-bus.js";

View File

@@ -0,0 +1,55 @@
export {
type AmbientStatePatch,
ambientStateEquals,
applyAmbientPatch,
defaultAmbientState,
} from "./ambient-state.js";
export { DefaultBusRegistry } from "./bus-registry.js";
export type {
BusRegistry,
SidechainPublisher,
} from "./bus-registry.types.js";
export { PluginChain } from "./chain.js";
export type {
ChainEvent,
ChainEventListener,
ChainEventPayload,
PluginChainOptions,
} from "./chain.types.js";
export {
AmbientStateBox,
ChainContextFactory,
type ChainSlotSnapshot,
pluginHandleFor,
} from "./context/index.js";
export { ChainEventBus } from "./event-bus/index.js";
export {
allocatePingPongBuffers,
assertMoveBounds,
assertSlotChainCompatible,
type ChainSlotLike,
computeEffectiveOutputChannels,
resolveInsertIndex,
resolveSlotIndex,
} from "./insert/index.js";
export {
type ChainLifecycleSlot,
computeEffectiveLatency,
disposeChainSlots,
processChainBlock,
setupChainSlots,
} from "./lifecycle/index.js";
export type {
ChainPluginSnapshot,
ChainSnapshot,
} from "./snapshot/index.js";
export {
buildChainSnapshot,
ChainPluginSnapshotSchema,
type ChainRestoreTarget,
ChainSnapshotSchema,
type ChainSnapshotSource,
instantiateChainSlot,
parseChainSnapshot,
restoreChainFromSnapshot,
} from "./snapshot/index.js";

View File

@@ -0,0 +1,11 @@
export {
addSlotToChain,
allocatePingPongBuffers,
assertMoveBounds,
assertSlotChainCompatible,
type ChainSlotLike,
computeEffectiveOutputChannels,
moveSlotInChain,
resolveInsertIndex,
resolveSlotIndex,
} from "./insert.js";

Some files were not shown because too many files have changed in this diff Show More