Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user