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,33 @@
# @suaveplan/audio-fx-plugin-reverb - reverb plugin for the @suaveplan/audio-fx-plugin-* family.
reverb 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
- [engines](./src/engines/engines.md) — The `reverb.engines` module owns the **per-sample DSP kernels** that `ReverbPlugin` calls every block: the Schroeder-Mo…
- [engines](./src/engines/engines.md) — The `reverb.engines` module owns the **per-sample DSP kernels** that `ReverbPlugin` calls every block: the Schroeder-Mo…
- [reverb](./src/reverb/reverb.md) — `ReverbPlugin` is the universal reverb effect: a Schroeder-style algorithmic engine (four parallel comb filters plus tw…
- [reverb](./src/reverb/reverb.md) — `ReverbPlugin` is the universal reverb effect: a Schroeder-style algorithmic engine (four parallel comb filters plus tw…
- [schemas](./src/schemas/schemas.md) — The `reverb.schemas` module owns the Zod boundary guards for `ReverbPlugin` — both the constructor options (`preDelay`,…
- [schemas](./src/schemas/schemas.md) — The `reverb.schemas` module owns the Zod boundary guards for `ReverbPlugin` — both the constructor options (`preDelay`,…
<!-- end:auto-docs -->
## Composition
```ts
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-reverb";
const plugin = new /* Plugin */();
const chain = new PluginChain({
buses: new DefaultBusRegistry(),
plugins: [plugin],
});
```
## Status
Replaces the `./reverb` subpath of the bundled `@suaveplan/audio-plugins-fx`.

View File

@@ -0,0 +1,47 @@
{
"name": "@suaveplan/audio-fx-plugin-reverb",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "reverb 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,97 @@
# `engines`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `reverb.engines` module owns the **per-sample DSP kernels** that `ReverbPlugin` calls every block: the Schroeder-Moorer algorithmic engine (comb-bank + all-pass diffusion), the direct-convolution engine (used when an IR is loaded), the pre-delay tap, and the per-channel state shape they all consume. Splitting these out of the plugin file keeps `reverb.ts` focused on the plugin lifecycle (parameters, setup, presets, loadImpulseResponse) and concentrates the inner-loop hot paths in one allocation-free helper that other reverb-shaped plugins (a future plate-only reverb, a Hadamard-matrix FDN) can reuse without dragging the full `ReverbPlugin` in.
The engines are pure functions over a mutable `ChannelState`: every per-sample call updates the state's circular buffer and indices in place, so the plugin's `processBlock` allocates nothing.
## Features
- `ChannelState`, `CombState`, `AllPassState` interfaces — the full per-channel state shape, sized once at `setup()`.
- `COMB_DELAYS_MS`, `ALLPASS_DELAYS_MS` — fixed Freeverb-style delay tables, scaled per session sample rate.
- `MAX_FEEDBACK`, `ALLPASS_FEEDBACK` — clamping and diffusion constants used by both engines.
- `createChannelState(sampleRate, preDelayMaxMs, irLength)` — fresh per-channel state allocator.
- `readPreDelay(state, sample, delaySamples)` — pre-delay tap; returns `sample` directly when `delaySamples === 0`.
- `algorithmicStep(state, input)` — Schroeder-Moorer per-sample step.
- `convolveStep(state, ir, input)` — direct time-domain convolution per-sample step.
- `updateCombFeedbacks(state, sampleRate, decaySec)` — recompute comb feedbacks from the current decay time (called once per block by the plugin).
- `resetChannelState(state)` — clear every buffer + index in place; no re-allocation.
## Usage: Basic Example
```ts
import {
createChannelState,
algorithmicStep,
updateCombFeedbacks,
} from "@suaveplan/audio-fx-plugin-reverb";
const state = createChannelState(48_000, 200, 0);
updateCombFeedbacks(state, 48_000, 2.5);
for (let i = 0; i < input.length; i++) {
output[i] = algorithmicStep(state, input[i]);
}
```
## Usage: Advanced Example
```ts
import {
createChannelState,
algorithmicStep,
convolveStep,
readPreDelay,
updateCombFeedbacks,
resetChannelState,
} from "@suaveplan/audio-fx-plugin-reverb";
// Build a custom dual-path reverb: pre-delay → convolver (if loaded)
// or algorithmic fallback, mixed wet/dry.
const states = Array.from({ length: channels }, () =>
createChannelState(sampleRate, 200, impulseResponse?.length ?? 0)
);
for (let ch = 0; ch < channels; ch++) {
const state = states[ch]!;
updateCombFeedbacks(state, sampleRate, decaySec);
for (let i = 0; i < blockSize; i++) {
const sample = input[ch]![i]!;
const wetIn = readPreDelay(state, sample, preDelaySamples);
const wetOut = impulseResponse
? convolveStep(state, impulseResponse, wetIn)
: algorithmicStep(state, wetIn);
output[ch]![i] = dry * sample + wet * wetOut;
}
}
// Transport stop: clear all state without reallocating.
for (const state of states) resetChannelState(state);
```
## API Reference
- `CombState`, `AllPassState`, `ChannelState` — state interfaces consumed by every engine function.
- `COMB_DELAYS_MS: readonly [29.7, 37.1, 41.1, 43.7]`, `ALLPASS_DELAYS_MS: readonly [5, 1.7]` — delay tables.
- `MAX_FEEDBACK = 0.997`, `ALLPASS_FEEDBACK = 0.5`.
- `createChannelState(sampleRate, preDelayMaxMs, irLength): ChannelState`.
- `readPreDelay(state, sample, delaySamples): number`.
- `algorithmicStep(state, input): number`.
- `convolveStep(state, ir, input): number`.
- `updateCombFeedbacks(state, sampleRate, decaySec): void`.
- `resetChannelState(state): void`.
## Implementation Notes
The algorithmic step parallels Freeverb's topology: four parallel comb filters with sample-rate-scaled delays, their outputs averaged, then fed through two series all-pass diffusers. The comb feedback formula `exp(-3 · ln10 · τ / T)` (where τ is the delay in seconds and T is the RT60) is mathematically equivalent to `10^(-3·τ/T)` and gives an analytically-correct RT60 in the linear-decay limit; the explicit `MAX_FEEDBACK = 0.997` clamp guarantees the engine cannot diverge regardless of the configured `decay` value.
`convolveStep` is direct time-domain convolution; it costs `O(blockSize · irLength)` per channel, which is appropriate for short room and plate IRs (up to a few thousand samples). Long cathedral IRs benefit from a partitioned FFT convolver — that is deliberately deferred until a real bottleneck demonstrates the need, since the algorithmic path covers long tails efficiently and the convolution path stays simple and obviously correct.
`readPreDelay` short-circuits when `delaySamples === 0` so the bit-transparent dry path stays tight at zero pre-delay. The buffer is sized to the maximum allowed pre-delay (200 ms × sample rate) at setup and never reallocated; the current pre-delay length is read per-block from the live parameter so automation lanes stay continuous.
`resetChannelState` mutates buffers in place (`fill(0)`) rather than reallocating so the plugin's `reset()` honours the spec's "buffers are the same instances as before reset" guarantee.

View File

@@ -0,0 +1,171 @@
/**
* Per-channel comb filter state for the algorithmic reverb. The
* feedback coefficient is recomputed per block from the current
* `decay` parameter.
*/
export interface CombState {
buffer: Float32Array;
index: number;
feedback: number;
}
/** Per-channel all-pass filter state. */
export interface AllPassState {
buffer: Float32Array;
index: number;
}
/** All state needed to render one channel through the reverb. */
export interface ChannelState {
combs: CombState[];
allpasses: AllPassState[];
preDelay: Float32Array;
preDelayIndex: number;
/** Delay line used when convolving against a loaded IR. */
convDelay: Float32Array;
convIndex: number;
}
/** Comb filter delay times (ms) used at 44.1 kHz baseline; scaled per sample rate. */
export const COMB_DELAYS_MS = [29.7, 37.1, 41.1, 43.7] as const;
/** All-pass diffuser delay times (ms). */
export const ALLPASS_DELAYS_MS = [5.0, 1.7] as const;
/**
* Hard clamp on comb feedback so the algorithmic engine cannot
* diverge regardless of the configured `decay` value.
*/
export const MAX_FEEDBACK = 0.997;
/** Fixed all-pass diffusion coefficient — the Freeverb reference value. */
export const ALLPASS_FEEDBACK = 0.5;
/**
* Read one sample through the channel's pre-delay buffer. When
* `delaySamples === 0` the buffer is skipped entirely so the
* algorithmic engine sees the input as-is.
*/
export function readPreDelay(
state: ChannelState,
sample: number,
delaySamples: number
): number {
if (delaySamples === 0) {
return sample;
}
const size = state.preDelay.length;
const writeIdx = state.preDelayIndex;
const readIdx = (writeIdx + size - delaySamples) % size;
state.preDelay[writeIdx] = sample;
state.preDelayIndex = (writeIdx + 1) % size;
return state.preDelay[readIdx]!;
}
/**
* Run one sample through the comb-bank + all-pass diffusion of the
* algorithmic reverb engine.
*/
export function algorithmicStep(state: ChannelState, input: number): number {
let sum = 0;
for (let k = 0; k < state.combs.length; k++) {
const comb = state.combs[k]!;
const out = comb.buffer[comb.index]!;
comb.buffer[comb.index] = input + out * comb.feedback;
comb.index = (comb.index + 1) % comb.buffer.length;
sum += out;
}
let signal = sum / state.combs.length;
for (let k = 0; k < state.allpasses.length; k++) {
const ap = state.allpasses[k]!;
const buffered = ap.buffer[ap.index]!;
const out = -signal + buffered;
ap.buffer[ap.index] = signal + buffered * ALLPASS_FEEDBACK;
ap.index = (ap.index + 1) % ap.buffer.length;
signal = out;
}
return signal;
}
/**
* Run one sample through the direct-convolution engine. Used in
* preference to `algorithmicStep` when an impulse response is loaded.
*/
export function convolveStep(
state: ChannelState,
ir: Float32Array,
input: number
): number {
const size = state.convDelay.length;
state.convDelay[state.convIndex] = input;
let sum = 0;
for (let k = 0; k < ir.length; k++) {
const idx = (state.convIndex + size - k) % size;
sum += ir[k]! * state.convDelay[idx]!;
}
state.convIndex = (state.convIndex + 1) % size;
return sum;
}
/** Allocate a fresh per-channel state sized to the configured rate / IR length. */
export function createChannelState(
sampleRate: number,
preDelayMaxMs: number,
irLength: number
): ChannelState {
const combs: CombState[] = COMB_DELAYS_MS.map((ms) => ({
buffer: new Float32Array(
Math.max(1, Math.round((ms / 1000) * sampleRate))
),
index: 0,
feedback: 0,
}));
const allpasses: AllPassState[] = ALLPASS_DELAYS_MS.map((ms) => ({
buffer: new Float32Array(
Math.max(1, Math.round((ms / 1000) * sampleRate))
),
index: 0,
}));
return {
combs,
allpasses,
preDelay: new Float32Array(
Math.max(1, Math.ceil((preDelayMaxMs / 1000) * sampleRate))
),
preDelayIndex: 0,
convDelay: new Float32Array(irLength),
convIndex: 0,
};
}
/** Re-derive each comb's feedback coefficient from the current decay time. */
export function updateCombFeedbacks(
state: ChannelState,
sampleRate: number,
decaySec: number
): void {
for (let k = 0; k < state.combs.length; k++) {
const comb = state.combs[k]!;
const delaySec = comb.buffer.length / sampleRate;
comb.feedback = Math.min(
MAX_FEEDBACK,
Math.exp((-3 * Math.LN10 * delaySec) / decaySec)
);
}
}
/** Clear every buffer + index in the channel's state without re-allocation. */
export function resetChannelState(state: ChannelState): void {
for (const c of state.combs) {
c.buffer.fill(0);
c.index = 0;
}
for (const a of state.allpasses) {
a.buffer.fill(0);
a.index = 0;
}
state.preDelay.fill(0);
state.preDelayIndex = 0;
state.convDelay.fill(0);
state.convIndex = 0;
}

View File

@@ -0,0 +1,17 @@
export type {
AllPassState,
ChannelState,
CombState,
} from "./engines.js";
export {
ALLPASS_DELAYS_MS,
ALLPASS_FEEDBACK,
algorithmicStep,
COMB_DELAYS_MS,
convolveStep,
createChannelState,
MAX_FEEDBACK,
readPreDelay,
resetChannelState,
updateCombFeedbacks,
} from "./engines.js";

View File

@@ -0,0 +1,27 @@
export {
ALLPASS_DELAYS_MS,
ALLPASS_FEEDBACK,
type AllPassState,
algorithmicStep,
type ChannelState,
COMB_DELAYS_MS,
type CombState,
convolveStep,
createChannelState,
MAX_FEEDBACK,
readPreDelay,
resetChannelState,
updateCombFeedbacks,
} from "./engines/index.js";
export { REVERB_PRESETS, ReverbPlugin } from "./reverb/index.js";
export type {
ReverbLoadOptions,
ReverbPluginOptions,
ReverbPresetData,
ReverbPresetName,
} from "./reverb.types.js";
export {
REVERB_PRESET_NAMES,
ReverbPluginOptionsSchema,
ReverbPresetDataSchema,
} from "./schemas/index.js";

View File

@@ -0,0 +1,29 @@
/** Construction options for `ReverbPlugin`. */
export interface ReverbPluginOptions {
readonly initialPreDelayMs?: number;
readonly initialDecaySec?: number;
readonly initialWet?: number;
readonly initialDry?: number;
}
/** Plugin-specific preset payload. */
export interface ReverbPresetData {
/**
* Snapshot of the loaded impulse response, if any, encoded as the
* raw Float32 samples. Kept opaque (`number[]`) for serialisation
* so a preset can travel through JSON without binary handling.
*/
readonly impulseResponse?: ReadonlyArray<number>;
}
/** Names of the shipped factory presets. */
export type ReverbPresetName = "room" | "hall" | "plate" | "cathedral";
/**
* `loadImpulseResponse` accepts an `AbortSignal` so long-running
* fetches can be cancelled by the host. A pre-aborted signal
* short-circuits before any fetch is issued.
*/
export interface ReverbLoadOptions {
readonly signal?: AbortSignal;
}

View File

@@ -0,0 +1,4 @@
export {
REVERB_PRESETS,
ReverbPlugin,
} from "./reverb.js";

View File

@@ -0,0 +1,83 @@
# `reverb`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ReverbPlugin` is the universal reverb effect: a Schroeder-style algorithmic engine (four parallel comb filters plus two series all-pass filters) that doubles as a direct-convolution renderer when an impulse response is loaded. The same plugin handles short room ambience, large hall colour, plate-style metallic shimmer, and full cathedral tails — preset by name or sculpted by adjusting `preDelay` and `decay`. Pre-delay is applied to the wet path only so the dry signal stays sample-aligned with the source, and `wet === 0` collapses to bit-transparency.
The plugin satisfies the spec's "impulse-response convolver with algorithmic fallback" requirement: once `loadImpulseResponse(url)` succeeds, every block runs direct convolution; before any IR is loaded (or after `applyPreset` clears one), the algorithmic engine renders the tail.
## Features
- Four automatable parameters — `preDelay` (ms), `decay` (s, mapped to per-comb feedback via `0.001^(τ/T)`), `wet` (0..1), `dry` (0..1).
- Allocation-free per-block processing once `setup()` has allocated comb / all-pass / pre-delay / convolution delay lines.
- Bounded feedback (clamped to `0.997`) so the algorithmic path cannot diverge regardless of `decay` value.
- `wet === 0` early-return path: output equals `dry * input` exactly, bit-transparent when `dry === 1`.
- `loadImpulseResponse(url, { signal })` — fetches the IR, switches the reverb into convolution mode. A pre-aborted `AbortSignal` short-circuits with `AUDIO_FX.ABORTED` before any fetch is issued.
- Four shipped factory presets — `room`, `hall`, `plate`, `cathedral` — applied via `applyPresetByName`.
- Round-trips the loaded IR through the universal preset envelope as a plain `number[]` so a saved session captures the cathedral.
## Usage: Basic Example
```ts
import { ReverbPlugin } from "@suaveplan/audio-fx-plugin-reverb";
const verb = new ReverbPlugin({
initialWet: 0.25,
initialDry: 0.75,
});
verb.applyPresetByName("hall");
chain.add(verb);
```
## Usage: Advanced Example
```ts
import { ReverbPlugin } from "@suaveplan/audio-fx-plugin-reverb";
const verb = new ReverbPlugin({ initialWet: 0.6, initialDry: 0.4 });
await verb.setup({ sampleRate: 48_000, channels: 2, blockSize: 512 }, ctx);
// Load a cathedral IR from a CDN, but let the user cancel the load
// if they switch presets before it finishes.
const ac = new AbortController();
cancelButton.addEventListener("click", () => ac.abort());
try {
await verb.loadImpulseResponse("https://cdn.example/impulses/cathedral.f32", {
signal: ac.signal,
});
console.log("Cathedral loaded:", verb.getImpulseResponse()?.length, "samples");
} catch (error) {
if ((error as AudioFxError).code === "AUDIO_FX.ABORTED") {
// User cancelled — algorithmic fallback continues to render.
} else {
throw error;
}
}
// Persist with the session: the IR rides along inside the preset envelope.
const snapshot = verb.getPreset();
saveSession(JSON.stringify(snapshot));
```
## API Reference
- `ReverbPlugin` (class extends `AbstractAudioFxPlugin`)
- `new ReverbPlugin(options?: ReverbPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
- `loadImpulseResponse(url: string, opts?: { signal?: AbortSignal }): Promise<void>` — throws `AUDIO_FX.ABORTED` on pre-aborted signal.
- `applyPresetByName(name: ReverbPresetName): void` — applies `room` / `hall` / `plate` / `cathedral`.
- `getImpulseResponse(): Float32Array | undefined` — current IR or undefined when algorithmic.
- Inherited parameter accessors for `preDelay`, `decay`, `wet`, `dry`.
- `REVERB_PRESETS: Readonly<Record<ReverbPresetName, { preDelayMs, decaySec }>>` — exported preset table.
## Implementation Notes
The Schroeder-Moorer comb/all-pass topology was chosen because every state buffer is short (≈30–45 ms × `sampleRate`), feedback is closed-form derivable from RT60 (`feedback = 10^(-3 · τ / T)`, clamped to `0.997`), and the implementation has zero per-block allocation after `setup`. Comb buffers and all-pass buffers are per-channel: stereo material gets four combs and two all-pass filters per channel, with the same delay times — engine-level decorrelation (different delays per channel) is a future enhancement, not a current blocker.
Pre-delay is implemented as a single circular buffer per channel sized to the maximum allowed (`200 ms` at the configured sample rate). The current pre-delay length is read per-block from the `preDelay` parameter so automation lanes stay live; the buffer itself is never reallocated. When `preDelayMs === 0` the read short-circuits (input passes straight through to the wet engine) so the bit-transparent path remains tight.
The convolution path is direct (time-domain) — appropriate for short room and plate IRs (≲ a few thousand samples). Long cathedral IRs would benefit from a partitioned FFT convolver; that is deferred until a real bottleneck demonstrates the need, since the algorithmic path covers long tails efficiently. The convolution delay line is reallocated whenever an IR is loaded or applied via preset, so per-block cost stays linear in the IR length without per-block allocation.

View File

@@ -0,0 +1,341 @@
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 { ReverbPlugin } from "./reverb.js";
const SAMPLE_RATE = 48_000;
function blockCtx(): BlockContext {
return {
blockOffsetSamples: 0,
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 fillNoise(buf: Float32Array): void {
let seed = 0x1234_5678;
for (let i = 0; i < buf.length; i++) {
seed = (seed * 1_103_515_245 + 12_345) & 0x7fff_ffff;
buf[i] = (seed / 0x7fff_ffff) * 2 - 1;
}
}
function newConfig(blockSize: number, channels = 1): ProcessorConfig {
return { sampleRate: SAMPLE_RATE, channels, blockSize };
}
function newCtx(channels = 1): ChainContext {
const chain: ChainHandle = {
id: "rv",
name: "rv",
channels,
sampleRate: SAMPLE_RATE,
blockSize: 256,
slotCount: 1,
};
return {
chain,
position: { index: 0, total: 1 },
predecessors: [],
successors: [],
ambient: defaultAmbientState(),
buses: { get: () => undefined, list: () => [] },
};
}
function irDataUrl(samples: number[]): string {
const arr = Float32Array.from(samples);
const b64 = Buffer.from(arr.buffer).toString("base64");
return `data:application/octet-stream;base64,${b64}`;
}
describe("ReverbPlugin: contract surface", () => {
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
const r = new ReverbPlugin();
expect(r.metadata.id).toBe("reverb");
expect(r.metadata.category).toBe("reverb");
expect(r.parameters.map((p) => p.id)).toEqual([
"preDelay",
"decay",
"wet",
"dry",
]);
expect(r.capabilities.asyncSetup).toBe(true);
});
it("rejects malformed options with INVALID_OPTIONS", {
timeout: 5_000,
}, () => {
try {
new ReverbPlugin({ initialWet: 2 });
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
});
describe("ReverbPlugin: wet=0 is bit-transparent to the dry signal", () => {
it("output equals input when wet=0 and dry=1", {
timeout: 5_000,
}, async () => {
const r = new ReverbPlugin({ initialWet: 0, initialDry: 1 });
await r.setup(newConfig(256), newCtx());
const { input, output } = buffers(256);
fillNoise(input[0]!);
r.process(input, output, blockCtx());
for (let i = 0; i < 256; i++) {
expect(output[0]![i]!).toBe(input[0]![i]!);
}
});
});
describe("ReverbPlugin: algorithmic fallback runs without an IR", () => {
it("processes an impulse and produces a non-trivial tail", {
timeout: 5_000,
}, async () => {
const r = new ReverbPlugin({
initialWet: 0.7,
initialDry: 0,
initialPreDelayMs: 5,
initialDecaySec: 1.5,
});
await r.setup(newConfig(2048), newCtx());
const { input, output } = buffers(2048);
input[0]![0] = 1;
r.process(input, output, blockCtx());
let energy = 0;
for (let i = 100; i < 2048; i++) {
energy += Math.abs(output[0]![i]!);
}
expect(energy).toBeGreaterThan(0);
// Algorithmic reverb is bounded — no divergence
for (let i = 0; i < 2048; i++) {
expect(Number.isFinite(output[0]![i]!)).toBe(true);
expect(Math.abs(output[0]![i]!)).toBeLessThan(10);
}
});
});
describe("ReverbPlugin: loadImpulseResponse abort handling", () => {
it("pre-aborted signal throws AUDIO_FX.ABORTED", {
timeout: 5_000,
}, async () => {
const r = new ReverbPlugin();
const ac = new AbortController();
ac.abort();
try {
await r.loadImpulseResponse("data:application/octet-stream,", {
signal: ac.signal,
});
} catch (error) {
expect((error as AudioFxError).code).toBe(AUDIO_FX_CODES.ABORTED);
expect((error as AudioFxError).context).toEqual({
url: "data:application/octet-stream,",
});
return;
}
throw new Error("expected AUDIO_FX.ABORTED");
});
it("successful load switches to convolution mode", {
timeout: 5_000,
}, async () => {
const r = new ReverbPlugin({
initialWet: 1,
initialDry: 0,
initialPreDelayMs: 0,
});
await r.setup(newConfig(256), newCtx());
await r.loadImpulseResponse(irDataUrl([1, 0.5, 0.25, 0.125]));
expect(r.getImpulseResponse()).toBeInstanceOf(Float32Array);
const { input, output } = buffers(256);
input[0]![0] = 1;
r.process(input, output, blockCtx());
// First four samples should approximate the IR (wet=1, dry=0).
expect(output[0]![0]!).toBeCloseTo(1, 5);
expect(output[0]![1]!).toBeCloseTo(0.5, 5);
expect(output[0]![2]!).toBeCloseTo(0.25, 5);
expect(output[0]![3]!).toBeCloseTo(0.125, 5);
});
it("load before setup defers convolution state until setup", {
timeout: 5_000,
}, async () => {
const r = new ReverbPlugin({
initialWet: 1,
initialDry: 0,
initialPreDelayMs: 0,
});
await r.loadImpulseResponse(irDataUrl([1, 0.5]));
await r.setup(newConfig(64), newCtx());
const { input, output } = buffers(64);
input[0]![0] = 1;
r.process(input, output, blockCtx());
expect(output[0]![0]!).toBeCloseTo(1, 5);
expect(output[0]![1]!).toBeCloseTo(0.5, 5);
});
it("load with a non-aborted signal succeeds", {
timeout: 5_000,
}, async () => {
const r = new ReverbPlugin();
const ac = new AbortController();
await r.loadImpulseResponse(irDataUrl([0.5]), { signal: ac.signal });
expect(r.getImpulseResponse()!.length).toBe(1);
});
});
describe("ReverbPlugin: presets", () => {
it("applyPresetByName updates preDelay and decay", {
timeout: 5_000,
}, () => {
const r = new ReverbPlugin();
r.applyPresetByName("cathedral");
expect(r.getParameter("preDelay")).toBe(80);
expect(r.getParameter("decay")).toBe(6);
r.applyPresetByName("room");
expect(r.getParameter("preDelay")).toBe(10);
});
});
describe("ReverbPlugin: preset round-trip", () => {
it("captures and restores an impulse response", {
timeout: 5_000,
}, async () => {
const r = new ReverbPlugin();
await r.loadImpulseResponse(irDataUrl([0.5, 0.25, 0.125]));
const snap = r.getPreset();
const r2 = new ReverbPlugin();
r2.applyPreset(snap);
const ir = r2.getImpulseResponse()!;
expect(ir.length).toBe(3);
expect(ir[0]!).toBeCloseTo(0.5, 5);
});
it("preset without impulseResponse clears the IR", {
timeout: 5_000,
}, async () => {
const r = new ReverbPlugin();
await r.loadImpulseResponse(irDataUrl([1, 0]));
const snap = r.getPreset();
const r2 = new ReverbPlugin();
r2.applyPreset(snap);
// Apply a preset with the IR cleared.
r2.applyPreset({
schemaVersion: 1,
pluginType: "reverb",
parameters: {},
data: {},
});
expect(r2.getImpulseResponse()).toBeUndefined();
});
it("getPreset without a loaded IR returns undefined impulseResponse", {
timeout: 5_000,
}, () => {
const r = new ReverbPlugin();
const snap = r.getPreset();
expect(
(snap.data as { impulseResponse?: unknown }).impulseResponse
).toBeUndefined();
});
it("applyPresetData restores IR even before setup", {
timeout: 5_000,
}, () => {
const r = new ReverbPlugin();
r.applyPreset({
schemaVersion: 1,
pluginType: "reverb",
parameters: {},
data: { impulseResponse: [1, 0.5] },
});
expect(r.getImpulseResponse()!.length).toBe(2);
});
it("applyPresetData allocates convolution state after setup", {
timeout: 5_000,
}, async () => {
const r = new ReverbPlugin({
initialWet: 1,
initialDry: 0,
initialPreDelayMs: 0,
});
await r.setup(newConfig(64), newCtx());
r.applyPreset({
schemaVersion: 1,
pluginType: "reverb",
parameters: {},
data: { impulseResponse: [1, 0.5] },
});
const { input, output } = buffers(64);
input[0]![0] = 1;
r.process(input, output, blockCtx());
expect(output[0]![0]!).toBeCloseTo(1, 5);
expect(output[0]![1]!).toBeCloseTo(0.5, 5);
});
});
describe("ReverbPlugin: reset clears all state", () => {
it("reset zeros comb / allpass / preDelay / conv buffers", {
timeout: 5_000,
}, async () => {
const r = new ReverbPlugin({ initialWet: 0.5, initialDry: 0.5 });
await r.setup(newConfig(256), newCtx());
const { input, output } = buffers(256);
input[0]![0] = 1;
r.process(input, output, blockCtx());
r.reset();
// After reset, a silence-fed process produces silence.
const silence = new Float32Array(256);
const silenceOut = [new Float32Array(256)];
r.process([silence], silenceOut, blockCtx());
for (let i = 0; i < 256; i++) {
expect(Math.abs(silenceOut[0]![i]!)).toBeLessThan(1e-9);
}
});
});
describe("ReverbPlugin: preDelay=0 path", () => {
it("processes without applying any pre-delay", {
timeout: 5_000,
}, async () => {
const r = new ReverbPlugin({
initialWet: 0.5,
initialDry: 0.5,
initialPreDelayMs: 0,
});
await r.setup(newConfig(128), newCtx());
const { input, output } = buffers(128);
input[0]![0] = 1;
r.process(input, output, blockCtx());
expect(Number.isFinite(output[0]![0]!)).toBe(true);
});
});

View File

@@ -0,0 +1,264 @@
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 {
algorithmicStep,
type ChannelState,
convolveStep,
createChannelState,
readPreDelay,
resetChannelState,
updateCombFeedbacks,
} from "../engines/engines.js";
import type {
ReverbLoadOptions,
ReverbPluginOptions,
ReverbPresetData,
ReverbPresetName,
} from "../reverb.types.js";
import {
ReverbPluginOptionsSchema,
ReverbPresetDataSchema,
} from "../schemas/schemas.js";
const PRE_DELAY_MAX_MS = 200;
/** Named factory presets shipped with the plugin. */
export const REVERB_PRESETS: Readonly<
Record<
ReverbPresetName,
{ readonly preDelayMs: number; readonly decaySec: number }
>
> = {
room: { preDelayMs: 10, decaySec: 0.6 },
hall: { preDelayMs: 30, decaySec: 2.5 },
plate: { preDelayMs: 5, decaySec: 1.5 },
cathedral: { preDelayMs: 80, decaySec: 6.0 },
};
/**
* Reverb with two render paths: direct convolution against a loaded
* impulse response (preferred when available) and a Schroeder-style
* algorithmic reverb (four comb filters + two all-pass per channel)
* used as the fallback. Pre-delay applies to the wet path only; the
* dry path passes the input through verbatim so `wet === 0` collapses
* to bit-transparency.
*/
export class ReverbPlugin extends AbstractAudioFxPlugin {
private states: ChannelState[] = [];
private impulseResponse: Float32Array | undefined;
public constructor(options: ReverbPluginOptions = {}) {
const parsed = ReverbPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.reverb-invalid-options",
{ context: { issues: parsed.error.issues } }
);
}
const opts = parsed.data;
super({
metadata: {
id: "reverb",
name: "Reverb",
version: "1.0.0",
category: "reverb",
},
schema: ReverbPresetDataSchema,
io: {
inputs: [{ count: "preserve" }],
outputs: [{ count: "preserve" }],
},
parameters: [
{
id: "preDelay",
name: "Pre-Delay",
value: opts.initialPreDelayMs ?? 20,
min: 0,
max: 200,
default: 20,
unit: "ms",
automatable: true,
},
{
id: "decay",
name: "Decay",
value: opts.initialDecaySec ?? 2,
min: 0.1,
max: 10,
default: 2,
automatable: true,
},
{
id: "wet",
name: "Wet",
value: opts.initialWet ?? 0.3,
min: 0,
max: 1,
default: 0.3,
automatable: true,
},
{
id: "dry",
name: "Dry",
value: opts.initialDry ?? 0.7,
min: 0,
max: 1,
default: 0.7,
automatable: true,
},
],
capabilities: {
hasAudioNode: false,
hasWorklet: false,
hasSidechain: false,
sampleAccurate: true,
stateful: true,
asyncSetup: true,
preservesChannelCount: true,
latencySamples: 0,
},
supportedSchemaVersion: 1,
});
}
/**
* Fetch an impulse response from `url` and switch the reverb into
* convolution mode. A pre-aborted `signal` short-circuits with
* `AUDIO_FX.ABORTED` before any fetch is issued.
*/
public async loadImpulseResponse(
url: string,
opts: ReverbLoadOptions = {}
): Promise<void> {
if (opts.signal?.aborted) {
throw new AudioFxError(
AUDIO_FX_CODES.ABORTED,
"audio-fx.reverb-load-pre-aborted",
{ context: { url } }
);
}
const fetchInit: RequestInit = {};
if (opts.signal !== undefined) {
fetchInit.signal = opts.signal;
}
const response = await fetch(url, fetchInit);
const buffer = await response.arrayBuffer();
this.impulseResponse = new Float32Array(buffer.slice(0));
if (this.config !== undefined) {
this.allocateConvolutionState();
}
}
/** Apply a factory preset by name (preDelay + decay only). */
public applyPresetByName(name: ReverbPresetName): void {
const preset = REVERB_PRESETS[name];
this.setParameter("preDelay", preset.preDelayMs);
this.setParameter("decay", preset.decaySec);
}
/** Read-only view of the currently loaded impulse response. */
public getImpulseResponse(): Float32Array | undefined {
return this.impulseResponse;
}
protected override setupState(
config: ProcessorConfig,
_chain: ChainContext
): void {
this.states = [];
for (let c = 0; c < config.channels; c++) {
this.states.push(
createChannelState(
config.sampleRate,
PRE_DELAY_MAX_MS,
this.impulseResponse?.length ?? 0
)
);
}
}
private allocateConvolutionState(): void {
const length = this.impulseResponse!.length;
for (const state of this.states) {
state.convDelay = new Float32Array(length);
state.convIndex = 0;
}
}
protected override resetState(): void {
for (const state of this.states) {
resetChannelState(state);
}
}
protected processBlock(
input: Float32Array[],
output: Float32Array[],
_ctx: BlockContext
): void {
const dry = this.parameterValue("dry");
const wet = this.parameterValue("wet");
const preDelayMs = this.parameterValue("preDelay");
const decaySec = this.parameterValue("decay");
const sr = this.config!.sampleRate;
const blockSize = input[0]!.length;
const preDelaySamples = Math.min(
this.states[0]!.preDelay.length - 1,
Math.max(0, Math.round((preDelayMs / 1000) * sr))
);
if (wet <= 0) {
for (let ch = 0; ch < input.length; ch++) {
const inCh = input[ch]!;
const outCh = output[ch]!;
for (let i = 0; i < blockSize; i++) {
outCh[i] = dry * inCh[i]!;
}
}
return;
}
for (const state of this.states) {
updateCombFeedbacks(state, sr, decaySec);
}
const ir = this.impulseResponse;
for (let ch = 0; ch < input.length; ch++) {
const inCh = input[ch]!;
const outCh = output[ch]!;
const state = this.states[ch]!;
for (let i = 0; i < blockSize; i++) {
const x = inCh[i]!;
const wetIn = readPreDelay(state, x, preDelaySamples);
const wetOut =
ir !== undefined && ir.length > 0
? convolveStep(state, ir, wetIn)
: algorithmicStep(state, wetIn);
outCh[i] = dry * x + wet * wetOut;
}
}
}
protected override getPresetData(): ReverbPresetData {
const ir = this.impulseResponse;
return ir !== undefined ? { impulseResponse: Array.from(ir) } : {};
}
protected override applyPresetData(data: unknown): void {
const { impulseResponse } = data as ReverbPresetData;
if (impulseResponse !== undefined) {
this.impulseResponse = Float32Array.from(impulseResponse);
if (this.config !== undefined) {
this.allocateConvolutionState();
}
} else {
this.impulseResponse = undefined;
}
}
}

View File

@@ -0,0 +1,5 @@
export {
REVERB_PRESET_NAMES,
ReverbPluginOptionsSchema,
ReverbPresetDataSchema,
} from "./schemas.js";

View File

@@ -0,0 +1,70 @@
# `schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `reverb.schemas` module owns the Zod boundary guards for `ReverbPlugin` — both the constructor options (`preDelay`, `decay`, `wet`, `dry` initial values) and the plugin-specific preset payload (`impulseResponse`). Per the package's "Zod boundary validation" requirement, every public options object on every plugin SHALL be validated against a sibling `*.schemas.ts` before the plugin touches any state, and `ReverbPlugin` is no exception: malformed options surface as `AUDIO_FX.INVALID_OPTIONS` and malformed preset data as `AUDIO_FX.INVALID_PRESET`, both carrying the Zod issue list in `context.issues`.
Keeping the schemas in a sibling file (rather than inline in `reverb.ts`) is the package-wide convention: consumers can import the schemas directly and run their own `safeParse` against UI input, surfacing field-level errors before the plugin instance exists.
## Features
- `ReverbPluginOptionsSchema` — every initial value is optional but, when present, must be finite and inside the parameter declaration's `[min, max]` range (`preDelay ∈ [0, 200] ms`, `decay ∈ [0.1, 10] s`, `wet ∈ [0, 1]`, `dry ∈ [0, 1]`).
- `ReverbPresetDataSchema` — the plugin-specific preset payload. Just one optional field, `impulseResponse: number[]`, validated as an array of finite numbers so a hostile payload cannot smuggle `NaN`s into the convolution delay line.
- `REVERB_PRESET_NAMES` — the canonical four preset names exported as a `readonly` tuple so consumers can type-narrow against it and iterate UI menus deterministically.
## Usage: Basic Example
```ts
import { ReverbPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-reverb";
const result = ReverbPluginOptionsSchema.safeParse(formValues);
if (!result.success) {
return showFieldErrors(result.error.issues);
}
const reverb = new ReverbPlugin(result.data);
```
## Usage: Advanced Example
```ts
import {
REVERB_PRESET_NAMES,
ReverbPluginOptionsSchema,
ReverbPresetDataSchema,
} from "@suaveplan/audio-fx-plugin-reverb";
// Render a UI preset picker driven by the schema-exported tuple.
const select = document.createElement("select");
for (const name of REVERB_PRESET_NAMES) {
select.append(new Option(name, name));
}
// Validate an untrusted preset payload (e.g. from cloud sync) before
// handing it to the plugin's applyPreset. The plugin would catch the
// same issue, but the host wants to render a friendlier error first.
const result = ReverbPresetDataSchema.safeParse(cloudPayload.data);
if (!result.success) {
return reject("Preset is corrupt; not loading.");
}
if (result.data.impulseResponse && result.data.impulseResponse.length > 65_536) {
return reject("Impulse response too long for the current build.");
}
```
## API Reference
- `ReverbPluginOptionsSchema` — `z.ZodObject` matching `ReverbPluginOptions`. Every field is optional and ranged to match the parameter declarations in `reverb.ts`.
- `ReverbPresetDataSchema` — `z.ZodObject` matching `ReverbPresetData`. `impulseResponse` is optional; when present, must be an array of finite numbers.
- `REVERB_PRESET_NAMES: readonly ["room", "hall", "plate", "cathedral"]`.
## Implementation Notes
`ReverbPluginOptionsSchema` uses `.finite()` on every numeric field so `NaN` and `±Infinity` are rejected at the boundary; the comb feedback formula `exp(-3 · LN10 · τ / T)` would otherwise propagate non-finite values into every comb's accumulator and contaminate every subsequent block. Time bounds (`min(0.1)` for decay, `max(200)` for pre-delay) match the parameter declarations in `reverb.ts` exactly so consumers can take either the schema or the parameter declaration as authoritative without having to cross-reference.
`ReverbPresetDataSchema.impulseResponse` is stored as `number[]` rather than a `Float32Array` because the universal `PresetSnapshot` envelope round-trips through `JSON.stringify`; `Float32Array` would lose its element-type during serialisation, so the schema accepts the JSON-native shape and the plugin converts back to `Float32Array` on load. Every element is `.finite()`-checked so a malicious payload cannot inject `NaN` into the convolution kernel.
The preset names tuple is exported as a `readonly` const assertion so consumers writing exhaustive switch statements get compile-time exhaustiveness checking — adding a new preset name to the plugin requires updating this tuple, which in turn surfaces every consumer that needs to handle the new case.

View File

@@ -0,0 +1,19 @@
import z from "zod";
export const ReverbPluginOptionsSchema = z.object({
initialPreDelayMs: z.number().finite().min(0).max(200).optional(),
initialDecaySec: z.number().finite().min(0.1).max(10).optional(),
initialWet: z.number().finite().min(0).max(1).optional(),
initialDry: z.number().finite().min(0).max(1).optional(),
});
export const ReverbPresetDataSchema = z.object({
impulseResponse: z.array(z.number().finite()).optional(),
});
export const REVERB_PRESET_NAMES = [
"room",
"hall",
"plate",
"cathedral",
] as const;

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-reverb",
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 });