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:
38
plugins/document-navigator/docs/media/audio-fx/README.md
Normal file
38
plugins/document-navigator/docs/media/audio-fx/README.md
Normal 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-*` deps… |
|
||||
| [`@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 -->
|
||||
1
plugins/document-navigator/docs/media/audio-fx/_intro.md
Normal file
1
plugins/document-navigator/docs/media/audio-fx/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Real-time audio effects and DSP plugins.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,92 @@
|
||||
# `insert`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `chain-insert` module owns the **structural validation + slot-index resolution + scratch-buffer allocation** helpers consumed by `PluginChain`. Splitting these out of the chain class file keeps `plugin-chain.ts` focused on the orchestration layer (constructor, lifecycle, composition methods, snapshot/restore) while putting the dense `if/throw` validation logic in a small standalone surface that future chain-shaped primitives (a multi-track macro chain, a sidechain bus chain) can reuse without inheriting from `PluginChain`.
|
||||
|
||||
Every public composition method on the chain (`add`, `remove`, `move`, `bypass`) funnels its boundary checks through these helpers, so the chain's own methods stay readable: each one is now a single helper call plus the actual state mutation.
|
||||
|
||||
## Features
|
||||
|
||||
- `ChainSlotLike` — minimal slot-shape interface (`{ plugin: AudioFxPlugin }`) the helpers operate on; the chain's full slot record (with bypass + latency-unsub) is structurally compatible.
|
||||
- `resolveInsertIndex(slots, plugin, at, chainId)` — duplicate-id check, range check, source-plugin position rule. Returns the resolved index or throws `AUDIO_FX.CHAIN_DUPLICATE_PLUGIN_ID` / `AUDIO_FX.CHAIN_SLOT_NOT_FOUND` / `AUDIO_FX.CHAIN_SOURCE_POSITION_INVALID`.
|
||||
- `assertSlotChainCompatible(candidate, inputChannels, chainId)` — walks the candidate slot list validating every input/output channel edge. Throws `AUDIO_FX.CHAIN_IO_INCOMPATIBLE` with `slot`/`reason` context on mismatch.
|
||||
- `computeEffectiveOutputChannels(slots, inputChannels)` — derives the chain's effective output channel count from the current slot list.
|
||||
- `resolveSlotIndex(slots, idOrIndex, chainId)` — string-or-number resolver; throws `AUDIO_FX.CHAIN_SLOT_NOT_FOUND` on miss.
|
||||
- `assertMoveBounds(length, from, to, chainId)` — both endpoints of a `move(from, to)` must be in range.
|
||||
- `allocatePingPongBuffers(channels, blockSize, prevA, prevB)` — returns the previous buffers unchanged when shape matches; otherwise a fresh pair sized to `channels × blockSize`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
resolveInsertIndex,
|
||||
assertSlotChainCompatible,
|
||||
} from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
|
||||
const index = resolveInsertIndex(slots, plugin, atOpt, chainId);
|
||||
const candidate = [...slots];
|
||||
candidate.splice(index, 0, { plugin });
|
||||
assertSlotChainCompatible(candidate, inputChannels, chainId);
|
||||
slots.splice(index, 0, candidate[index]!);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertSlotChainCompatible,
|
||||
computeEffectiveOutputChannels,
|
||||
allocatePingPongBuffers,
|
||||
resolveSlotIndex,
|
||||
} from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
|
||||
class MacroChain {
|
||||
private readonly slots: Array<{ plugin: AudioFxPlugin }> = [];
|
||||
private inputChannels: number;
|
||||
private scratchA: Float32Array[] = [];
|
||||
private scratchB: Float32Array[] = [];
|
||||
|
||||
insertAfter(targetId: string, plugin: AudioFxPlugin): void {
|
||||
const after = resolveSlotIndex(this.slots, targetId, this.id);
|
||||
const candidate = [...this.slots];
|
||||
candidate.splice(after + 1, 0, { plugin });
|
||||
assertSlotChainCompatible(candidate, this.inputChannels, this.id);
|
||||
this.slots.splice(after + 1, 0, candidate[after + 1]!);
|
||||
const effective = computeEffectiveOutputChannels(
|
||||
this.slots,
|
||||
this.inputChannels,
|
||||
);
|
||||
const buffers = allocatePingPongBuffers(
|
||||
effective,
|
||||
this.blockSize,
|
||||
this.scratchA,
|
||||
this.scratchB,
|
||||
);
|
||||
this.scratchA = buffers.a;
|
||||
this.scratchB = buffers.b;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ChainSlotLike` — `{ readonly plugin: AudioFxPlugin }`.
|
||||
- `resolveInsertIndex(slots, plugin, at, chainId): number` — throws on validation failure.
|
||||
- `assertSlotChainCompatible(candidate, inputChannels, chainId): void`.
|
||||
- `computeEffectiveOutputChannels(slots, inputChannels): number`.
|
||||
- `resolveSlotIndex(slots, idOrIndex, chainId): number`.
|
||||
- `assertMoveBounds(slotsLength, from, to, chainId): void`.
|
||||
- `allocatePingPongBuffers(channels, blockSize, prevA, prevB): { a, b }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every helper accepts `chainId` as a string parameter rather than reading it from a class field — these are pure functions over plain data so they can be called from any chain-shaped object without inheriting from a specific class. The `chainId` propagates to the `AudioFxError.context.chainId` payload so consumers grepping for which chain produced a given error can pattern-match against it directly.
|
||||
|
||||
`allocatePingPongBuffers` short-circuits when the requested shape matches the previous shape, returning the previous buffers unchanged. This means the chain's `rebuildAfterChange` can call it on every composition mutation without thrashing the heap as long as `channels` and `blockSize` stay stable (the common case — only changing the chain config changes shape). When the shape does change (e.g. swapping a `preserve` plugin for one that outputs a different channel count), the helper allocates a fresh pair and the caller assigns them; the previous buffers become garbage and are collected normally.
|
||||
|
||||
`resolveInsertIndex` returns the index rather than mutating the slots array because the chain runs the IO-edge validation step (`assertSlotChainCompatible`) on a `candidate` slot list before committing — splitting the "where would this go?" decision from the "would this be legal?" check keeps both steps cheap and testable in isolation.
|
||||
@@ -0,0 +1,79 @@
|
||||
# `lifecycle`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `lifecycle` module owns the **per-block / per-setup helpers** that `PluginChain` calls during `setup`, `process`, `dispose`, and the latency aggregation cycle. Splitting these out of the chain class file keeps `chain.ts` focused on composition + ambient state + event surface while concentrating the inner-loop block routing, the parallel-setup fan-out, the dispose teardown, and the latency-sum maths in a small standalone surface that future chain-shaped primitives (a `MacroChain` over multiple stems, a `BusChain` for sidechain routing) can reuse without inheriting from `PluginChain`.
|
||||
|
||||
Every helper operates on a minimal `ChainLifecycleSlot` shape (`{ plugin, bypassed, latencyUnsub? }`) so the chain's internal `ChainSlot` record is structurally compatible without exporting its full type.
|
||||
|
||||
## Features
|
||||
|
||||
- `ChainLifecycleSlot` — minimal slot shape (`plugin`, `bypassed`, optional `latencyUnsub`) every helper accepts.
|
||||
- `processChainBlock(slots, scratchA, scratchB, input, output, ctx)` — walks the chain forward, threading per-slot ping-pong scratch buffers. Empty chain copies input → output verbatim; bypassed slots copy through without invoking `plugin.process`; the last slot writes directly to `output`.
|
||||
- `setupChainSlots(slots, config, contextFactory, opts?)` — fans `plugin.setup` across every slot in parallel via `Promise.all`, propagating any abort signal.
|
||||
- `disposeChainSlots(slots)` — unsubscribes every slot's latency listener, disposes the plugin, then clears the slot list in place.
|
||||
- `computeEffectiveLatency(slots)` — sums `getLatencySamples()` across every non-bypassed slot.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
processChainBlock,
|
||||
computeEffectiveLatency,
|
||||
} from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
|
||||
processChainBlock(slots, scratchA, scratchB, input, output, ctx);
|
||||
const total = computeEffectiveLatency(slots);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
setupChainSlots,
|
||||
processChainBlock,
|
||||
disposeChainSlots,
|
||||
computeEffectiveLatency,
|
||||
} from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
|
||||
class MacroChain {
|
||||
private readonly slots: ChainLifecycleSlot[] = [];
|
||||
private readonly scratchA: Float32Array[] = [];
|
||||
private readonly scratchB: Float32Array[] = [];
|
||||
|
||||
async setup(config: ProcessorConfig): Promise<void> {
|
||||
await setupChainSlots(this.slots, config, this.contextFactory);
|
||||
this.latencyTotal = computeEffectiveLatency(this.slots);
|
||||
}
|
||||
|
||||
process(input: Float32Array[], output: Float32Array[], ctx: BlockContext): void {
|
||||
processChainBlock(this.slots, this.scratchA, this.scratchB, input, output, ctx);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
disposeChainSlots(this.slots);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ChainLifecycleSlot` — `{ plugin: AudioFxPlugin; bypassed: boolean; latencyUnsub?: UnsubscribeFn }`.
|
||||
- `processChainBlock(slots, scratchA, scratchB, input, output, ctx): void`.
|
||||
- `setupChainSlots(slots, config, contextFactory, opts?): Promise<void>`.
|
||||
- `disposeChainSlots(slots): void` — mutates the slot list in place.
|
||||
- `computeEffectiveLatency(slots): number` — non-bypassed sum.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`processChainBlock` uses an even/odd-index decision to pick between `scratchA` and `scratchB`, so consecutive non-bypassed plugins always read from one scratch and write to the other. The last slot writes directly into the caller's `output` buffer to avoid an extra copy on the way out. When the chain is empty, the helper short-circuits with `output[ch].set(input[ch])` per channel — a tight loop that satisfies the spec's "empty chain is a pass-through" guarantee.
|
||||
|
||||
`setupChainSlots` fans `plugin.setup` calls in parallel via `Promise.all`. Plugins that throw or reject propagate naturally; if one plugin's setup throws (e.g. `AUDIO_FX.ABORTED` from a pre-aborted signal), the others may still complete, but the aggregate promise rejects with the first failure. Callers should treat a failed setup as a non-retriable state and dispose the chain.
|
||||
|
||||
`disposeChainSlots` mutates the slot array in place (`slots.length = 0`) rather than reassigning it so external references to the slot list (e.g. the context factory's snapshot) see the cleared state immediately.
|
||||
|
||||
`computeEffectiveLatency` does not cache; the chain calls it whenever a slot's latency changes (via `onLatencyChanged`) or composition changes (`add`/`remove`/`move`/`bypass`). Caching lives one level up in `PluginChain.cachedEffectiveLatency` so the helper stays a pure function over current slot state.
|
||||
@@ -0,0 +1,94 @@
|
||||
# `snapshot`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `chain-snapshot` module owns the **`PluginChain` serialisation envelope** and the boundary helpers that round-trip a chain through plain JSON. A snapshot is the unit of persistence for "save your track", "duplicate this insert chain", "export this preset to another project", and "restore from cloud sync". The envelope nests the universal `PresetSnapshot` per plugin slot so a chain snapshot is, at its core, a Zod-validated list of presets wrapped in chain metadata — the same envelope a single plugin emits, just composed.
|
||||
|
||||
The module is the connective tissue between `PluginChain` (which knows its slots) and `PluginRegistry` (which knows how to build plugins from a `pluginType` string). Together with `base/preset.ts`, this is the only path that turns serialised bytes into live `AudioFxPlugin` instances, so every load goes through Zod validation before any state is mutated.
|
||||
|
||||
## Features
|
||||
|
||||
- `ChainSnapshotSchema` — Zod schema for the envelope. Validates `id`, `name`, `channels`, `sampleRate`, `blockSize`, and the per-slot `plugins` array. Nests `PresetSnapshotSchema` so plugin presets are validated alongside the slot envelope.
|
||||
- `parseChainSnapshot(input)` — throws `AUDIO_FX.INVALID_PRESET` with the Zod issue list on malformed input. Reuses the same code as preset validation because a chain snapshot is a structural superset of a preset.
|
||||
- `instantiateChainSlot(entry, registry)` — restore step for one slot. Calls `registry.create(pluginType)` (which throws `AUDIO_FX.CHAIN_SLOT_NOT_FOUND` on a missing factory), then `applyPreset(entry.preset)` on the new instance, and returns `{ plugin, bypassed }` to the caller.
|
||||
- `PluginChain.snapshot(): ChainSnapshot` — captures slot order, each slot's bypass state, and each plugin's `getPreset()` envelope. The chain's `id`/`name` and configured `channels`/`sampleRate`/`blockSize` (or construction-time defaults if `setup` has not run) are stored so a snapshot is self-describing.
|
||||
- `PluginChain.restore(input, registry): Promise<void>` — Zod-validates the envelope, drains any pre-existing slots so the method is idempotent on a re-used chain, then walks `plugins[]` adding each instantiated plugin and applying its bypass state. The result reproduces order, parameters, bypass, and `getEffectiveLatencySamples()` (the last because plugin latency comes from `capabilities.latencySamples` at construction and the registry's factories rebuild identical capabilities).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { PluginRegistry } from "@suaveplan/audio-fx-plugin-base/registry";
|
||||
|
||||
const source = new PluginChain({ id: "vox-bus", channels: 2, sampleRate: 48_000, blockSize: 512 }, new DefaultBusRegistry());
|
||||
source.add(eqPlugin);
|
||||
source.add(compPlugin);
|
||||
source.bypass(0, true);
|
||||
|
||||
const json = JSON.stringify(source.snapshot());
|
||||
|
||||
// elsewhere, on a fresh process:
|
||||
const registry = new PluginRegistry();
|
||||
registry.register("eq", () => new EQPlugin());
|
||||
registry.register("compressor", () => new CompressorPlugin());
|
||||
|
||||
const target = new PluginChain({ id: "vox-bus", channels: 2, sampleRate: 48_000, blockSize: 512 }, new DefaultBusRegistry());
|
||||
await target.restore(JSON.parse(json), registry);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { PluginRegistry } from "@suaveplan/audio-fx-plugin-base/registry";
|
||||
import { parseChainSnapshot, ChainSnapshotSchema } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
|
||||
// 1. Validate the envelope shape early, surface field-level issues to the UI.
|
||||
const result = ChainSnapshotSchema.safeParse(payload);
|
||||
if (!result.success) {
|
||||
return showFieldErrors(result.error.issues);
|
||||
}
|
||||
|
||||
// 2. Reject restores referencing plugins not in the host's allowlist.
|
||||
const allowedTypes = new Set(registry.list());
|
||||
const missing = result.data.plugins
|
||||
.map((p) => p.pluginType)
|
||||
.filter((t) => !allowedTypes.has(t));
|
||||
if (missing.length > 0) {
|
||||
return showMissingPlugins(missing);
|
||||
}
|
||||
|
||||
// 3. Now restore. parseChainSnapshot will run the same Zod check, and
|
||||
// each instantiateChainSlot call propagates AUDIO_FX.CHAIN_SLOT_NOT_FOUND
|
||||
// if registration changes underfoot.
|
||||
const chain = new PluginChain(
|
||||
{ id: result.data.id, channels: result.data.channels, sampleRate: result.data.sampleRate, blockSize: result.data.blockSize },
|
||||
new DefaultBusRegistry(),
|
||||
);
|
||||
await chain.restore(payload, registry);
|
||||
await chain.setup({ sampleRate: result.data.sampleRate, channels: result.data.channels, blockSize: result.data.blockSize }, defaultAmbientState());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ChainPluginSnapshot` (type) — `{ pluginType: string; instanceId: string; bypassed: boolean; preset: PresetSnapshot }`.
|
||||
- `ChainSnapshot` (type) — `{ id: string; name: string; channels: number; sampleRate: number; blockSize: number; plugins: ReadonlyArray<ChainPluginSnapshot> }`.
|
||||
- `ChainSnapshotSchema`, `ChainPluginSnapshotSchema` — exported Zod schemas for callers that want to `safeParse` directly.
|
||||
- `parseChainSnapshot(input: unknown): ChainSnapshot` — throws `AUDIO_FX.INVALID_PRESET` with Zod issues in `context.issues` on failure.
|
||||
- `instantiateChainSlot(entry, registry): { plugin: AudioFxPlugin; bypassed: boolean }` — restore step for one slot; throws `AUDIO_FX.CHAIN_SLOT_NOT_FOUND` (via `registry.create`) when the registry has no factory.
|
||||
- `PluginChain.snapshot(): ChainSnapshot` — instance method on the chain.
|
||||
- `PluginChain.restore(input: unknown, registry: PluginRegistry): Promise<void>` — instance method; async to leave room for future async resource preload during restore.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`instanceId` currently equals `metadata.id` because `PluginChain` enforces unique `metadata.id` per chain via `AUDIO_FX.CHAIN_DUPLICATE_PLUGIN_ID`. The field is preserved as a distinct envelope key so a future chain that drops the uniqueness rule (e.g. multiple instances of the same plugin type on different bus stems) can store a per-instance key without an envelope migration. Restore intentionally does not assert `instanceId === metadata.id` — the registry's factory determines the live plugin's id, and forcing them to match would couple snapshot authors to factory implementations.
|
||||
|
||||
`restore()` drains existing slots before rebuilding so the chain object can be re-used across multiple restores (e.g. an "undo history" UI that replays older snapshots into the same chain). The drain uses `remove(0)` rather than `dispose()` so listeners attached to the chain via `on()` survive — restoring an older state should not silently remove the user's event wiring.
|
||||
|
||||
`AUDIO_FX.INVALID_PRESET` is reused for envelope failures rather than introducing a dedicated `INVALID_CHAIN_SNAPSHOT` code because the chain snapshot is structurally a list of presets with envelope metadata; consumers that pattern-match on the code see one unified "your saved state is malformed" path. The `context.issues` Zod payload is identical in shape, so UI error rendering is shared.
|
||||
|
||||
`restore()` is `async` to leave room for future factories that need to await resource preloading (e.g. an offline-first sampler that hydrates from IndexedDB). Today every step is synchronous; the trailing `await Promise.resolve()` makes the `async` annotation honest without changing call-site semantics. Engine adapters are expected to call `chain.setup(...)` after restore — restore reproduces the structure; setup allocates the live DSP.
|
||||
@@ -0,0 +1,74 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `error-codes` module declares the complete error code surface for `@suaveplan/audio-fx-plugin-base` via `defineCodes("AUDIO_FX", […])` from `@suaveplan/error`. The output strings carry the dotted namespace form (`AUDIO_FX.NOT_INITIALIZED`, `AUDIO_FX.CHAIN_IO_INCOMPATIBLE`, …) mandated by the error-code convention so consumers can match against stable values without re-deriving names.
|
||||
|
||||
It also exports an `AudioFxError` subclass of `SuaveplanError<AudioFxErrorCode>` with a `category = "audio-fx"` discriminator so downstream code can branch on the family without parsing the code string. Every error thrown anywhere in the package — base contract, chain primitives, registry, plugins — uses this class.
|
||||
|
||||
## Features
|
||||
|
||||
- Single dotted-namespace code registry covering every code thrown in this package.
|
||||
- `AUDIO_FX_ERROR_METADATA` frozen object with `description` and `severity` per code, suitable for centralised reporting or i18n message routing.
|
||||
- `AudioFxError` class with `category` discriminator; preserves `cause` chain and `context` payload from the base.
|
||||
- Idempotent `registerAudioFxErrorCodes()` for symmetry with sibling packages — metadata is already direct-exported, so the call is a no-op the second time.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AUDIO_FX_CODES, AudioFxError } from "@suaveplan/audio-fx-plugin-base/error-codes";
|
||||
|
||||
if (channels !== config.channels) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.CHANNEL_MISMATCH,
|
||||
"audio-fx.channel-mismatch",
|
||||
{ context: { expected: config.channels, actual: channels } }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIO_FX_CODES,
|
||||
AUDIO_FX_ERROR_METADATA,
|
||||
AudioFxError,
|
||||
} from "@suaveplan/audio-fx-plugin-base/error-codes";
|
||||
|
||||
function reportFxFailure(err: unknown): void {
|
||||
if (err instanceof AudioFxError) {
|
||||
const meta = AUDIO_FX_ERROR_METADATA[err.code];
|
||||
// central reporting path: route warnings to log, errors to sentry, etc.
|
||||
if (meta.severity === "warning") {
|
||||
console.warn(err.code, meta.description, err.context);
|
||||
} else {
|
||||
sentry.captureException(err, {
|
||||
tags: { code: err.code, category: err.category },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AUDIO_FX_CODES` — readonly record produced by `defineCodes` mapping each reason key (e.g. `NOT_INITIALIZED`) to its dotted code string (`"AUDIO_FX.NOT_INITIALIZED"`).
|
||||
- `AudioFxErrorCode` — discriminated string union of every dotted code value.
|
||||
- `AUDIO_FX_ERROR_METADATA` — `Readonly<Record<AudioFxErrorCode, { description, severity }>>` covering every code in the registry. Frozen at module load.
|
||||
- `AudioFxError(code, messageKey, options?)` — class extending `SuaveplanError<AudioFxErrorCode>`. Carries a readonly `category = "audio-fx"` discriminator plus everything from the base (`code`, `messageKey`, `cause`, `context`, `toJSON`).
|
||||
- `registerAudioFxErrorCodes(): void` — idempotent registration hook; safe to call from a module load side effect.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The error class deliberately declares `category` as a readonly instance member rather than a static one, so the constructor body has a real assignment and `noUselessConstructor` does not flag the widening of `SuaveplanError`'s protected constructor to public. The class does not introduce any other state; instances remain serialisable via the base `toJSON`.
|
||||
|
||||
Codes that name an external boundary (Zod validation, AbortSignal, bus topology) carry `severity: "warning"` only where the failure is recoverable by the caller; everything that indicates a contract violation (`NOT_INITIALIZED`, `CHANNEL_MISMATCH`, `CHAIN_IO_INCOMPATIBLE`) carries `severity: "error"`. The chain primitives and engine adapters can read `severity` to decide whether to surface a non-blocking toast or block the operation.
|
||||
|
||||
The seventeen codes are intentionally exhaustive: every `throw new AudioFxError(...)` in the package must reference one of them. New error conditions require adding a code to the registry plus its metadata, and the tests assert that the metadata covers every code.
|
||||
@@ -0,0 +1,82 @@
|
||||
# `registry`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `PluginRegistry` is the **session-restore lookup table** that maps a serialised `pluginType` string (a plugin's `metadata.id`) to a fresh-instance factory. `PluginChain.restore(snapshot, registry)` calls into it once per slot in the snapshot to rehydrate the original chain composition. The registry is intentionally external to the chain so consumers control which plugins are available for restore: omit a factory and any snapshot referencing that `pluginType` will be rejected with `AUDIO_FX.CHAIN_SLOT_NOT_FOUND`, which is the right shape for sandboxed projects, plugin-blacklist policies, and CI fixtures that need to fail loud on missing dependencies.
|
||||
|
||||
The registry deliberately holds factories, not singletons. Audio plugins own per-instance DSP state (delay lines, filter histories, voice pools); two chains restoring the same `pluginType` MUST receive independent plugin instances so disposing one chain never affects another.
|
||||
|
||||
## Features
|
||||
|
||||
- `register(pluginType, factory)` stores a synchronous factory. Re-registering the same `pluginType` overwrites the previous factory — useful for tests that inject doubles without rebuilding a global registry.
|
||||
- `has(pluginType)` cheap presence check that does not invoke the factory.
|
||||
- `create(pluginType)` invokes the factory and returns a fresh `AudioFxPlugin`. Throws `AUDIO_FX.CHAIN_SLOT_NOT_FOUND` with `context.pluginType` when no factory is registered; the chain restore loop relies on this exact error code so callers can pattern-match on the missing-type case.
|
||||
- `list()` returns registered `pluginType`s in **insertion order** so UI pickers that render a "create plugin" list have a deterministic sequence. This matters for snapshot diff stability when consumers iterate the registry to seed defaults.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PluginRegistry } from "@suaveplan/audio-fx-plugin-base/registry";
|
||||
import { EQPlugin } from "@suaveplan/audio-fx-plugin-eq";
|
||||
import { CompressorPlugin } from "@suaveplan/audio-fx-plugin-compressor";
|
||||
|
||||
const registry = new PluginRegistry();
|
||||
registry.register("eq", () => new EQPlugin());
|
||||
registry.register("compressor", () => new CompressorPlugin());
|
||||
|
||||
const eq = registry.create("eq"); // fresh instance
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PluginRegistry } from "@suaveplan/audio-fx-plugin-base/registry";
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
|
||||
// Sandboxed project: only ship a curated subset of plugins to the
|
||||
// host's restore registry so user-submitted snapshots can only
|
||||
// instantiate vetted plugins.
|
||||
const sandboxRegistry = new PluginRegistry();
|
||||
for (const id of allowlist) {
|
||||
sandboxRegistry.register(id, plugins[id]);
|
||||
}
|
||||
|
||||
// Untrusted snapshot incoming. Restore will reject any pluginType
|
||||
// not in the sandbox allowlist with AUDIO_FX.CHAIN_SLOT_NOT_FOUND
|
||||
// before any plugin is instantiated.
|
||||
const chain = new PluginChain(
|
||||
{ id: snapshot.id, channels: snapshot.channels, sampleRate: snapshot.sampleRate, blockSize: snapshot.blockSize },
|
||||
new DefaultBusRegistry(),
|
||||
);
|
||||
|
||||
try {
|
||||
await chain.restore(snapshot, sandboxRegistry);
|
||||
} catch (error) {
|
||||
if ((error as AudioFxError).code === "AUDIO_FX.CHAIN_SLOT_NOT_FOUND") {
|
||||
reportToUser(`Snapshot references plugin "${error.context.pluginType}" which is not allowed on this server.`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PluginRegistry` (class)
|
||||
- `register(pluginType: string, factory: PluginFactory): void` — stores a factory; overwrites existing entries.
|
||||
- `has(pluginType: string): boolean` — presence check.
|
||||
- `create(pluginType: string): AudioFxPlugin` — invokes the factory; throws `AUDIO_FX.CHAIN_SLOT_NOT_FOUND` (`context.pluginType`) when no factory is registered.
|
||||
- `list(): ReadonlyArray<string>` — registered `pluginType`s in insertion order.
|
||||
- `PluginFactory` (type) — `() => AudioFxPlugin`. Synchronous. Must return a fresh instance per call.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry stores plain references in a `Map<string, PluginFactory>`; iteration order is insertion order per the ECMA-262 spec, which `list()` relies on. The implementation deliberately avoids any caching: a registry shared across many chains would otherwise hand out shared instances that get disposed by the first chain to tear down. Letting the consumer decide caching is the right shape — most callers want fresh instances; the rare caller that wants pooling can implement pooling inside their factory.
|
||||
|
||||
The error code on missing factory is `CHAIN_SLOT_NOT_FOUND` rather than a bespoke `PLUGIN_TYPE_NOT_REGISTERED` because the restore code path is the primary consumer and treating "no slot" and "no plugin type to fill the slot" identically keeps the error vocabulary small. The `context.pluginType` payload disambiguates the two cases for callers that need to act on them differently.
|
||||
|
||||
Factories are synchronous because plugin construction is synchronous in this package — async resource loading (impulse responses, sample files) happens in `setup()`, not the constructor. If a future plugin needed async construction, the right shape is a registered factory that performs the sync construction and an `await plugin.setup(...)` afterwards; the registry contract does not need to change.
|
||||
|
||||
Re-registration overwriting is intentional for tests and for plugin hot-swap scenarios. Consumers who want strict registration semantics can wrap the registry and reject duplicates at the caller boundary.
|
||||
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-chorus - chorus plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
chorus 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
|
||||
|
||||
- [chorus](./src/chorus/chorus.md) — `ChorusPlugin` is the universal modulated-delay chorus. Each channel reads from its own delay line at an LFO-modulated…
|
||||
- [chorus](./src/chorus/chorus.md) — `ChorusPlugin` is the universal modulated-delay chorus. Each channel reads from its own delay line at an LFO-modulated…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `ChorusPlugin`. These run on the public boun…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `ChorusPlugin`. These run on the public boun…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-chorus";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./chorus` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,50 @@
|
||||
# `chorus.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `ChorusPlugin`'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's runtime code.
|
||||
|
||||
## Features
|
||||
|
||||
- `ChorusPluginOptions` — all fields optional so the default constructor (`new ChorusPlugin()`) is valid.
|
||||
- `readonly` properties — emphasises that the options object is consumed once at construction and never mutated.
|
||||
- Numeric ranges are encoded in the Zod schema (single source of truth) rather than in TypeScript types.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { ChorusPluginOptions } from "@suaveplan/audio-fx-plugin-chorus";
|
||||
|
||||
const opts: ChorusPluginOptions = { initialRateHz: 0.5 };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { ChorusPluginOptions } from "@suaveplan/audio-fx-plugin-chorus";
|
||||
|
||||
const presets: Record<string, ChorusPluginOptions> = {
|
||||
subtle: { initialDepthMs: 3, initialWet: 0.2, initialRateHz: 0.4 },
|
||||
lush: { initialDepthMs: 14, initialWet: 0.6, initialRateHz: 1.2 },
|
||||
seasick: { initialDepthMs: 18, initialWet: 0.8, initialRateHz: 2.5 },
|
||||
};
|
||||
|
||||
function load(name: keyof typeof presets): ChorusPluginOptions {
|
||||
return presets[name]!;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ChorusPluginOptions`
|
||||
- `readonly initialRateHz?: number`
|
||||
- `readonly initialDepthMs?: number`
|
||||
- `readonly initialFeedback?: number`
|
||||
- `readonly initialWet?: number`
|
||||
- `readonly initialBaseDelayMs?: number`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The options interface mirrors the parameter declarations exactly so a future "save these options" round-trip can be implemented by just copying field-for-field from the plugin's parameter snapshot. There is no separate "options vs. parameters" split here — every option corresponds to one parameter, named `initial<X>` to make it clear that the value is consumed once at construction and then handed to the universal parameter system.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,70 @@
|
||||
# `chorus`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ChorusPlugin` is the universal modulated-delay chorus. Each channel reads from its own delay line at an LFO-modulated tap point, centred on `baseDelay`. The LFO is phase-offset per channel so a stereo signal smears in two different directions, producing the characteristic chorus width without relying on platform-specific spatial APIs. The plugin extends `AbstractAudioFxPlugin`, so it runs identically across browser, server, offline, and worklet renderers.
|
||||
|
||||
Feedback is clamped at the parameter boundary (`0..0.95`) so the output cannot diverge for bounded input, and `wet === 0` is bit-transparent to the dry signal (early-out copy) — both contracts are exercised in the test suite.
|
||||
|
||||
## Features
|
||||
|
||||
- Five parameters: `rate` (Hz, 0.05..10), `depth` (ms, 0..20), `feedback` (0..0.95), `wet` (0..1), `baseDelay` (ms, 5..30).
|
||||
- Per-channel delay buffer sized for the spec's max delay (60 ms) plus a fractional-read safety margin.
|
||||
- Sine LFO with channel-indexed phase offset (`2π / channels`) so two-channel input gets the classic L/R width.
|
||||
- Linear-interpolated fractional read so the modulation is smooth at low rates and high depths.
|
||||
- `wet === 0` early-out: returns the dry path sample-for-sample with no allocation and no buffer writes — saves CPU when the plugin is fully bypassed by automation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ChorusPlugin } from "@suaveplan/audio-fx-plugin-chorus";
|
||||
|
||||
const chorus = new ChorusPlugin({
|
||||
initialRateHz: 0.5,
|
||||
initialDepthMs: 5,
|
||||
initialWet: 0.4,
|
||||
});
|
||||
chain.add(chorus);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ChorusPlugin } from "@suaveplan/audio-fx-plugin-chorus";
|
||||
|
||||
// "Lush" chorus: faster rate, deeper modulation, moderate feedback.
|
||||
// Useful as the front of a pad-synth chain to thicken the sound.
|
||||
const lush = new ChorusPlugin({
|
||||
initialRateHz: 1.2,
|
||||
initialDepthMs: 14,
|
||||
initialFeedback: 0.35,
|
||||
initialWet: 0.6,
|
||||
initialBaseDelayMs: 18,
|
||||
});
|
||||
await lush.setup(
|
||||
{ sampleRate: 48_000, channels: 2, blockSize: 512 },
|
||||
ctx,
|
||||
);
|
||||
|
||||
// Crossfade to dry on a long note-off via parameter automation.
|
||||
lush.setParameterAt("wet", 0.6, 0);
|
||||
lush.setParameterAt("wet", 0, 0.8);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ChorusPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new ChorusPlugin(options?: ChorusPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- Inherited parameter accessors for `rate`, `depth`, `feedback`, `wet`, `baseDelay`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The delay line is a circular `Float32Array` per channel sized for `MAX_DELAY_MS = 60 ms` plus a 4-sample safety margin (the fractional read indexes `i0` and `i0 + 1`). Sixty milliseconds is well over `baseDelay.max + depth.max = 30 + 20 = 50`, so a typical real-time use case never wraps inside a single LFO period; the modulus is there for correctness when the buffer is much smaller than the modulation window (e.g., very low sample rates).
|
||||
|
||||
The LFO is a plain `Math.sin(phase)` evaluated per sample. Phase advances by `phaseInc = 2π · rate / sr` per sample and the running phase is kept in `this.lfoPhase % 2π` so a long-running session never accumulates floating-point error past about `1e-15` per cycle. Phase-offset per channel is `2π / channels` — for stereo that's `π`, which gives the classic "left goes high while right goes low" L/R smear; for mono it collapses to a single voice with no decorrelation, which is the expected behaviour.
|
||||
|
||||
Feedback is clamped at the parameter boundary (`max: 0.95`) rather than at runtime so the bounded-output property is provable by static inspection of the parameter declaration. A higher feedback would push the system close to self-oscillation; the test "max feedback over a long sine never produces non-finite samples" exercises the boundary and verifies the result stays finite and within a comfortable amplitude budget. The `wet === 0` early-out short-circuits the entire DSP loop with a `Float32Array.set` copy per channel — both fast and bit-exact, satisfying the spec's "wet=0 is bit-transparent" contract without relying on coincidence (`0 * lfo + dry === dry` would only hold for IEEE-clean values, not subnormals).
|
||||
@@ -0,0 +1,57 @@
|
||||
# `chorus/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options and preset payload of `ChorusPlugin`. 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, editors, RPC validation) without importing the audio implementation.
|
||||
|
||||
## Features
|
||||
|
||||
- `ChorusPluginOptionsSchema` — validates all five optional construction fields with their documented ranges (`rate` 0.05..10 Hz, `depth` 0..20 ms, `feedback` 0..0.95, `wet` 0..1, `baseDelay` 5..30 ms).
|
||||
- `ChorusPresetDataSchema` — empty strict object: the chorus has no plugin-specific preset payload, so any extra keys are rejected by `.strict()`.
|
||||
- Both schemas use `z.number().finite()` so `NaN` and `Infinity` cannot sneak through.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ChorusPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-chorus";
|
||||
|
||||
const parsed = ChorusPluginOptionsSchema.parse({ initialWet: 0.4 });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ChorusPluginOptionsSchema,
|
||||
ChorusPresetDataSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-chorus";
|
||||
|
||||
// Build a form from the schema's shape keys.
|
||||
const fields = ChorusPluginOptionsSchema.keyof().options;
|
||||
for (const field of fields) {
|
||||
form.addRow(field);
|
||||
}
|
||||
|
||||
// Reject foreign preset payloads at the IPC boundary before they
|
||||
// reach the plugin.
|
||||
function validateChorusPreset(blob: unknown): boolean {
|
||||
return ChorusPresetDataSchema.safeParse(blob).success;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ChorusPluginOptionsSchema: z.ZodObject<{ initialRateHz?, initialDepthMs?, initialFeedback?, initialWet?, initialBaseDelayMs? }>`
|
||||
- `ChorusPresetDataSchema: z.ZodObject<{}, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The maximum feedback is `0.95`, not `1.0`. Allowing feedback at unity would create a marginally stable system whose output diverges for any non-zero input; clamping at 0.95 leaves enough headroom that even a noise-floor leak stays bounded over arbitrarily long renders. The test "max feedback over a long sine never produces non-finite samples" exercises this boundary.
|
||||
|
||||
`ChorusPresetDataSchema` is `.strict()` for the same reason as the other plugins: unknown fields almost always indicate a payload from a sibling plugin or a forwards-incompatible schema. Silent rejection at the boundary is much less harmful than partial application, which can leave the plugin in a half-restored state.
|
||||
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-compressor - compressor plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
compressor 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
|
||||
|
||||
- [compressor](./src/compressor/compressor.md) — `CompressorPlugin` is the universal soft-knee dynamics compressor for the project — the same plugin handles vocal level…
|
||||
- [compressor](./src/compressor/compressor.md) — `CompressorPlugin` is the universal soft-knee dynamics compressor for the project — the same plugin handles vocal level…
|
||||
- [schemas](./src/schemas/schemas.md) — The `compressor.schemas` module owns the Zod boundary guards for `CompressorPlugin` — both the constructor options and…
|
||||
- [schemas](./src/schemas/schemas.md) — The `compressor.schemas` module owns the Zod boundary guards for `CompressorPlugin` — both the constructor options and…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-compressor";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./compressor` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,85 @@
|
||||
# `compressor`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`CompressorPlugin` is the universal soft-knee dynamics compressor for the project — the same plugin handles vocal levelling, drum-bus glue, mastering compression, and sidechain-ducked bass under a kick. It implements the `AudioFxPlugin` contract by extending `AbstractAudioFxPlugin`, so the same compressor runs identically inside the Web Audio engine, the server renderer, and the worklet wrapper.
|
||||
|
||||
The compressor exposes its full control surface (`threshold`, `knee`, `ratio`, `attack`, `release`) as automatable `PluginParameter`s, plus non-automatable mode flags (`midSide`, `sidechainEnabled`) and a read-only `gainReductionDb` telemetry parameter that meters can poll. A peak envelope follower with separate attack/release smoothing coefficients tracks the loudest channel across the input (or the sidechain bus when wired), and a Reiss & Stables soft-knee curve maps envelope-above-threshold to gain reduction in dB.
|
||||
|
||||
## Features
|
||||
|
||||
- Five automatable continuous parameters — `threshold`, `knee`, `ratio`, `attack`, `release` — plus two mode flags and a read-only meter.
|
||||
- Soft-knee transfer curve (Reiss & Stables formulation): below `threshold - knee/2` no reduction, above `threshold + knee/2` full ratio reduction, smooth quadratic interpolation through the knee.
|
||||
- Linked-channel envelope follower (one shared envelope) so stereo material stays phase-coherent under compression.
|
||||
- Optional sidechain detection — call `setSidechain(bus)` to route the envelope detector from an external `SidechainBus` (e.g. a kick stem) when `sidechainEnabled` is true.
|
||||
- Mid/side mode on stereo input: encode L/R → M/S, apply the linked gain to both, decode back. Mono input ignores the mode flag.
|
||||
- Allocation-free per-block processing: attack/release smoothing coefficients recomputed once per block from the current parameter values.
|
||||
- `gainReductionDb` parameter republished every block; consumers can either poll `getGainReductionDb()` or read the parameter directly.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CompressorPlugin } from "@suaveplan/audio-fx-plugin-compressor";
|
||||
|
||||
const comp = new CompressorPlugin({
|
||||
initialThresholdDb: -18,
|
||||
initialRatio: 4,
|
||||
initialAttackMs: 5,
|
||||
initialReleaseMs: 80,
|
||||
});
|
||||
chain.add(comp);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CompressorPlugin } from "@suaveplan/audio-fx-plugin-compressor";
|
||||
import { DefaultBusRegistry } from "@suaveplan/audio-plugins-fx/chain";
|
||||
|
||||
// Sidechain bass under kick:
|
||||
const buses = new DefaultBusRegistry();
|
||||
const kickPub = buses.publish("kick", 1);
|
||||
|
||||
const bass = new CompressorPlugin({
|
||||
initialThresholdDb: -20,
|
||||
initialRatio: 6,
|
||||
initialAttackMs: 2,
|
||||
initialReleaseMs: 150,
|
||||
initialSidechainEnabled: true,
|
||||
});
|
||||
await bass.setup({ sampleRate: 48_000, channels: 1, blockSize: 256 }, ctx);
|
||||
bass.setSidechain(buses.get("kick"));
|
||||
|
||||
// In the kick track's processBlock, publish its output:
|
||||
kickPub.write([kickSamples]);
|
||||
|
||||
// Meter the gain reduction every UI frame:
|
||||
requestAnimationFrame(function tick() {
|
||||
meter.write(bass.getGainReductionDb());
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CompressorPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new CompressorPlugin(options?: CompressorPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- `setSidechain(bus: SidechainBus | undefined): void` — attach / detach an external detection source.
|
||||
- `getGainReductionDb(): number` — current frame's peak gain reduction in dB (positive = attenuation).
|
||||
- Inherited parameter accessors for `threshold`, `knee`, `ratio`, `attack`, `release`, `midSide`, `sidechainEnabled`, `gainReductionDb`.
|
||||
- `CompressorPluginOptions` — initial parameter values and mode flags.
|
||||
- `CompressorPresetData` — `{ midSide, sidechainEnabled }`; the five continuous parameters round-trip via the universal `parameters` map.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The envelope follower uses one-pole smoothing with separate `attack` and `release` time constants converted to per-sample coefficients via `alpha = exp(-1 / (timeSec * sampleRate))`. The peak detector takes `max(|L|, |R|, …)` across detector channels per sample so a single transient on any channel pulls the envelope up immediately. Envelope state is one number (linked) so all output channels share the same gain reduction — this keeps the stereo image stable under compression.
|
||||
|
||||
The soft-knee formula uses the closed-form Reiss & Stables expression: inside the knee region, `gr = (1 − 1/ratio) · (envDb − threshold + knee/2)² / (2·knee)`. At `knee = 0` the formula degenerates to a hard-knee response by skipping the knee branch entirely (the condition `knee > 0 && 2·above > -knee && 2·above < knee` is false when `knee === 0`).
|
||||
|
||||
Sidechain routing: `setSidechain(bus)` allocates per-channel scratch buffers sized to `bus.channels` (not the plugin's channels) so a mono sidechain can drive a stereo program. During `processBlock`, when `sidechainEnabled` and a bus is wired, the detector reads into the scratch and uses those samples as the envelope source; otherwise it falls back to the program input. Calling `setSidechain(undefined)` detaches the bus and frees the scratch.
|
||||
|
||||
The `gainReductionDb` parameter is non-automatable (its value is plugin output, not user input); the plugin writes it via the same `setParameter` path used by external callers, which clamps to `[0, 60]`.
|
||||
@@ -0,0 +1,61 @@
|
||||
# `schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `compressor.schemas` module owns the Zod boundary guards for `CompressorPlugin` — both the constructor options and the plugin-specific preset payload. 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 these schemas are how `CompressorPlugin` keeps that promise. A malformed `initialRatio`, an out-of-range `initialAttackMs`, or a missing field on `applyPreset` data all surface as `AUDIO_FX.INVALID_OPTIONS` / `AUDIO_FX.INVALID_PRESET` with the Zod issue list in `context.issues`.
|
||||
|
||||
Keeping the schemas in a sibling file (rather than inline in `compressor.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
|
||||
|
||||
- `CompressorPluginOptionsSchema` — every constructor field is optional but, when present, must be finite and inside the per-parameter `[min, max]` range that the plugin's `parameters` declare (`threshold ∈ [−60, 0]`, `knee ∈ [0, 24]`, `ratio ∈ [1, 20]`, `attack ∈ [0.1, 100] ms`, `release ∈ [1, 1000] ms`).
|
||||
- `CompressorPresetDataSchema` — the plugin-specific preset payload: just `midSide` and `sidechainEnabled` booleans. The five continuous parameters round-trip through the universal `parameters` map on the `PresetSnapshot` envelope, so they do not need to appear here.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CompressorPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-compressor";
|
||||
|
||||
const result = CompressorPluginOptionsSchema.safeParse(formValues);
|
||||
if (!result.success) {
|
||||
return showFieldErrors(result.error.issues);
|
||||
}
|
||||
const compressor = new CompressorPlugin(result.data);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CompressorPluginOptionsSchema,
|
||||
CompressorPresetDataSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-compressor";
|
||||
|
||||
// Server-side validation of an untrusted preset payload before it
|
||||
// hits the plugin's applyPreset (which would throw with the same
|
||||
// code, but the host wants to surface a friendlier error first).
|
||||
const envelope = parseChainSnapshot(payload); // base envelope check
|
||||
for (const entry of envelope.plugins.filter((p) => p.pluginType === "compressor")) {
|
||||
const data = CompressorPresetDataSchema.parse(entry.preset.data);
|
||||
if (data.sidechainEnabled && !allowSidechain) {
|
||||
return reject("Sidechain compression is disabled for this tier.");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CompressorPluginOptionsSchema` — `z.ZodObject` matching `CompressorPluginOptions`. Every field is optional and ranged.
|
||||
- `CompressorPresetDataSchema` — `z.ZodObject` matching `CompressorPresetData`. Both fields required.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schemas use `.finite()` so `NaN` and `±Infinity` are rejected at the boundary; the envelope follower's `exp(-1 / (τ · sr))` calculation would otherwise propagate non-finite alphas into the smoothing state and contaminate every subsequent block. Time constants use `.min(0.1)` for attack and `.min(1)` for release — values smaller than this break the one-pole smoothing assumption (alpha approaches 1 from below) and produce numerical artefacts in single-precision float.
|
||||
|
||||
Ratio is `.min(1)` (rather than `.positive()`) because a ratio below 1 would expand rather than compress, which the plugin's curve does not model. A future expander plugin would live as a separate plugin with its own schema. The maxima (`.max(20)` for ratio, `.max(24)` for knee, etc.) are the values the plugin's parameter declarations advertise — keeping the schema and the declaration in sync means consumers can safely take the parameter's `min` / `max` as authoritative without re-deriving them from the schema.
|
||||
|
||||
The preset data deliberately omits the continuous parameters because they live in `PresetSnapshot.parameters` (the universal map). Duplicating them here would risk drift if a future refactor changes one source but not the other; keeping the data slim makes the preset round-trip obviously correct.
|
||||
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-delay - delay plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
delay 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
|
||||
|
||||
- [delay](./src/delay/delay.md) — `DelayPlugin` is the universal stereo delay effect. Per-channel circular delay lines render decaying echoes; bounded fe…
|
||||
- [delay](./src/delay/delay.md) — `DelayPlugin` is the universal stereo delay effect. Per-channel circular delay lines render decaying echoes; bounded fe…
|
||||
- [schemas](./src/schemas/schemas.md) — The `delay.schemas` module owns the Zod boundary guards for `DelayPlugin` — construction options and the plugin-specifi…
|
||||
- [schemas](./src/schemas/schemas.md) — The `delay.schemas` module owns the Zod boundary guards for `DelayPlugin` — construction options and the plugin-specifi…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-delay";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./delay` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,72 @@
|
||||
# `delay`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`DelayPlugin` is the universal stereo delay effect. Per-channel circular delay lines render decaying echoes; bounded feedback (clamped at `0.95`) prevents the recursion from diverging regardless of automation; an optional ping-pong mode cross-feeds the two channels for that classic alternating-side bounce; and a transport-BPM sync mode derives the delay length from the live `tempo` on the chain's ambient state so a 1/4-note delay always stays tempo-locked.
|
||||
|
||||
The plugin extends `AbstractAudioFxPlugin`, so it runs identically inside the Web Audio engine, the server renderer, and the worklet wrapper. Delay state is per-channel and cleared in place by `reset()`. With `wet === 0` the plugin short-circuits to `dry * input`, satisfying the spec's "fully-dry mix is transparent" guarantee.
|
||||
|
||||
## Features
|
||||
|
||||
- Up-to-2 s per-channel delay line, sized at `setup` and never re-allocated.
|
||||
- Seven parameters: `delayMs`, `feedback` (0–0.95), `wet`, `dry`, `pingPong`, `bpmSync`, `syncBeats`.
|
||||
- Ping-pong mode (stereo only): the L delay reads R's feedback and vice versa for an alternating-side bounce; mono input silently falls back to regular delay.
|
||||
- BPM sync: when `bpmSync >= 0.5`, delay length = `60_000 · syncBeats / tempo` ms, with `tempo` read from `ChainContext.ambient.transport`.
|
||||
- `wet === 0` early-return path: output equals `dry * input` exactly, bit-transparent when `dry === 1`.
|
||||
- Bounded feedback so the IIR recursion cannot blow up.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DelayPlugin } from "@suaveplan/audio-fx-plugin-delay";
|
||||
|
||||
const delay = new DelayPlugin({
|
||||
initialDelayMs: 250,
|
||||
initialFeedback: 0.4,
|
||||
initialWet: 0.3,
|
||||
initialDry: 0.7,
|
||||
});
|
||||
chain.add(delay);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DelayPlugin } from "@suaveplan/audio-fx-plugin-delay";
|
||||
|
||||
// Tempo-locked 1/4-note ping-pong delay for stereo guitar.
|
||||
const delay = new DelayPlugin({
|
||||
initialBpmSync: true,
|
||||
initialSyncBeats: 1,
|
||||
initialFeedback: 0.6,
|
||||
initialPingPong: true,
|
||||
initialWet: 0.5,
|
||||
initialDry: 0.7,
|
||||
});
|
||||
await delay.setup({ sampleRate: 48_000, channels: 2, blockSize: 512 }, ctx);
|
||||
|
||||
// Sweep the delay length from 1/4 → 1/8 over four bars via automation.
|
||||
delay.setParameterCurve("syncBeats", { type: "linear", value: 0.5, duration: 8 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DelayPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new DelayPlugin(options?: DelayPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- Inherited parameter accessors for `delayMs`, `feedback`, `wet`, `dry`, `pingPong`, `bpmSync`, `syncBeats`.
|
||||
- `DelayPluginOptions` — initial parameter values and mode flags.
|
||||
- `DelayPresetData` — `{ pingPong, bpmSync }`; the continuous parameters round-trip via the universal `parameters` map.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The non-ping-pong path is the standard "comb" structure: `line[w] = input + feedback · line[r]; out = dry · input + wet · line[r]`. The read index `r` is `(w + lineSize − delaySamples) % lineSize`, computed once per sample because `delaySamples` may move with automation. The line size is fixed at the maximum allowed (`2 s · sampleRate`) so changing `delayMs` between blocks costs nothing.
|
||||
|
||||
Ping-pong cross-feeds the feedbacks: `lineL[w] = inL + feedback · lineR[r]` and symmetrically for R. The dry path stays in-channel. This is the classic Eventide-style ping-pong topology; an impulse on L produces a bounce L → R → L → R with each bounce attenuated by `feedback`.
|
||||
|
||||
BPM sync uses `60_000 / tempo · syncBeats` to convert beats to milliseconds. The plugin reads `tempo` from `ChainContext.ambient.transport.tempo` each block, so a tempo automation lane updates the delay length continuously without per-tempo recompute on the plugin side.
|
||||
|
||||
Feedback is clamped to `0.95` by the parameter declaration. Higher values are mathematically unstable for an IIR delay; the clamp keeps the plugin safe under any automation lane the user can write.
|
||||
@@ -0,0 +1,64 @@
|
||||
# `schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `delay.schemas` module owns the Zod boundary guards for `DelayPlugin` — construction options and the plugin-specific preset payload. Every public input runs through these schemas before the plugin touches any state; failures surface as `AUDIO_FX.INVALID_OPTIONS` / `AUDIO_FX.INVALID_PRESET` with the Zod issue list in `context.issues`.
|
||||
|
||||
Keeping the schemas in a sibling folder follows the package-wide convention: hosts can import the schemas directly, run their own `safeParse` against UI input, and surface field-level errors before constructing a `DelayPlugin`.
|
||||
|
||||
## Features
|
||||
|
||||
- `DelayPluginOptionsSchema` — every field optional, ranged to match the plugin's parameter declarations: `delayMs ∈ [1, 2000]`, `feedback ∈ [0, 0.95]`, `wet`/`dry ∈ [0, 1]`, `syncBeats ∈ [1/16, 8]`.
|
||||
- `DelayPresetDataSchema` — required `pingPong` and `bpmSync` booleans. Continuous parameters (delayMs, feedback, wet, dry, syncBeats) ride the universal `parameters` map in the `PresetSnapshot` envelope.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DelayPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-delay";
|
||||
|
||||
const parsed = DelayPluginOptionsSchema.safeParse(formValues);
|
||||
if (!parsed.success) {
|
||||
return showFieldErrors(parsed.error.issues);
|
||||
}
|
||||
const delay = new DelayPlugin(parsed.data);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
DelayPluginOptionsSchema,
|
||||
DelayPresetDataSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-delay";
|
||||
|
||||
// Server-side validation of an untrusted preset payload from cloud
|
||||
// sync before applyPreset. The plugin would catch the same issue,
|
||||
// but the host wants a friendlier error before a plugin instance is
|
||||
// constructed.
|
||||
const envelope = parseChainSnapshot(payload);
|
||||
for (const entry of envelope.plugins.filter((p) => p.pluginType === "delay")) {
|
||||
const result = DelayPresetDataSchema.safeParse(entry.preset.data);
|
||||
if (!result.success) {
|
||||
return reject("Delay preset is corrupt.");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DelayPluginOptionsSchema` — `z.ZodObject` matching `DelayPluginOptions`. All fields optional and ranged.
|
||||
- `DelayPresetDataSchema` — `z.ZodObject` matching `DelayPresetData`. Both fields required.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Numeric fields use `.finite()` so `NaN` and `±Infinity` are rejected at the boundary; the per-block delay-line writes would otherwise propagate non-finite values into every subsequent block, and `feedback` close to `1` already lives at the edge of numerical stability.
|
||||
|
||||
`feedback` is `.max(0.95)` rather than the maximum the IIR can theoretically sustain (`1.0 − ε`) because at `0.95` the signal still has substantial perceived decay over many seconds; allowing values closer to `1` invites runaway under any automation lane that drifts.
|
||||
|
||||
`delayMs` is `.max(2000)` to match the line-size allocation in `delay.ts`; values beyond that would be silently clamped at the play-side anyway, so rejecting them at the boundary surfaces the limit clearly.
|
||||
|
||||
`syncBeats` ranges from `0.0625` (1/16 note) to `8` (two whole notes) so a tempo-locked delay covers everything from a tight slap-back to a long ambient wash.
|
||||
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-distortion - distortion plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
distortion 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
|
||||
|
||||
- [distortion](./src/distortion/distortion.md) — `DistortionPlugin` is the universal three-mode distortion: `waveshaper` (smooth `tanh` saturation), `bitcrusher` (unifo…
|
||||
- [distortion](./src/distortion/distortion.md) — `DistortionPlugin` is the universal three-mode distortion: `waveshaper` (smooth `tanh` saturation), `bitcrusher` (unifo…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `DistortionPlugin`. These run at the public…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `DistortionPlugin`. These run at the public…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-distortion";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./distortion` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,69 @@
|
||||
# `distortion.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `DistortionPlugin`'s construction options, preset payload, and mode enum. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`.
|
||||
|
||||
The `DistortionMode` union is the canonical naming of the three transfer functions; saved presets travel by name (not index) so a future parameter reordering does not break stored data.
|
||||
|
||||
## Features
|
||||
|
||||
- `DistortionMode` — textual enum (`"waveshaper" | "bitcrusher" | "overdrive"`) used in options and presets.
|
||||
- `DistortionPluginOptions` — all-optional construction options; the default constructor (`new DistortionPlugin()`) is valid.
|
||||
- `DistortionPresetData` — preset payload carrying only the non-numeric `mode`; all numeric parameters travel through the universal parameter snapshot.
|
||||
- `readonly` fields prevent accidental mutation of saved options.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
DistortionMode,
|
||||
DistortionPluginOptions,
|
||||
} from "@suaveplan/audio-fx-plugin-distortion";
|
||||
|
||||
const opts: DistortionPluginOptions = { initialMode: "bitcrusher" };
|
||||
const mode: DistortionMode = opts.initialMode ?? "waveshaper";
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
DistortionMode,
|
||||
DistortionPluginOptions,
|
||||
} from "@suaveplan/audio-fx-plugin-distortion";
|
||||
|
||||
const modes: readonly DistortionMode[] = [
|
||||
"waveshaper",
|
||||
"bitcrusher",
|
||||
"overdrive",
|
||||
];
|
||||
|
||||
function nextMode(current: DistortionMode): DistortionMode {
|
||||
const i = modes.indexOf(current);
|
||||
return modes[(i + 1) % modes.length]!;
|
||||
}
|
||||
|
||||
const opts: DistortionPluginOptions = {
|
||||
initialMode: nextMode("waveshaper"),
|
||||
initialDriveDb: 18,
|
||||
initialOutputGainDb: -3,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DistortionMode = "waveshaper" | "bitcrusher" | "overdrive"`
|
||||
- `DistortionPluginOptions`
|
||||
- `readonly initialMode?: DistortionMode`
|
||||
- `readonly initialDriveDb?: number`
|
||||
- `readonly initialBitDepth?: number`
|
||||
- `readonly initialOutputGainDb?: number`
|
||||
- `DistortionPresetData`
|
||||
- `readonly mode: DistortionMode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `mode` parameter in the plugin is a numeric enum (0..2) for compatibility with the universal `Parameter` shape, which carries `number` values. The plugin maps that index to/from the textual `DistortionMode` enum at the boundaries (`getPresetData` / `applyPresetData`). This keeps the runtime fast (no string comparisons in the inner loop) while making saved presets human-readable and forward-compatible.
|
||||
|
||||
`DistortionPresetData` deliberately includes only `mode`. Every numeric parameter (`drive`, `bits`, `outputGain`) is already part of the universal parameter snapshot that `AbstractAudioFxPlugin` builds, so re-encoding them here would double-store data and risk drift. The base class's preset round-trip honours both the universal parameters and the plugin-specific `presetData` field, applied in that order so plugin-specific decoding always wins for fields it cares about.
|
||||
@@ -0,0 +1,76 @@
|
||||
# `distortion`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`DistortionPlugin` is the universal three-mode distortion: `waveshaper` (smooth `tanh` saturation), `bitcrusher` (uniform quantisation to a chosen bit depth), and `overdrive` (cubic soft-clip). All three are stateless transfer functions applied per sample without allocation, so the plugin runs equally well on the audio thread, in a worklet, in an offline renderer, or under server-side rendering.
|
||||
|
||||
Every mode is bounded by construction: `tanh` is bounded in `[-1, 1]` for any input; the bitcrusher pre-clamps to `[-1, 1]` before quantisation; the cubic soft-clip clamps to `[-2/3, 2/3]`. That property is the contract enforced by the spec test "waveshaper output stays finite and bounded for an over-unity input".
|
||||
|
||||
## Features
|
||||
|
||||
- Four parameters: `mode` (enum 0..2), `drive` (dB, 0..40), `bits` (bitcrusher depth 1..16), `outputGain` (dB, -24..24).
|
||||
- Drive pre-gain raises the input level *into* the transfer function — useful for waveshaper/overdrive to push the signal further into the non-linear region.
|
||||
- `outputGain` compensates for level changes caused by shaping so the plugin can drop into an existing chain without raising perceived loudness.
|
||||
- Mode switching is parameter-driven, so a single instance can crossfade between modes at runtime (snap by default; for click-free morphing, use the parameter automation API on `mode`).
|
||||
- Preset payload carries the textual `mode` enum so saved presets are robust to future parameter-index changes.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DistortionPlugin } from "@suaveplan/audio-fx-plugin-distortion";
|
||||
|
||||
const dist = new DistortionPlugin({
|
||||
initialMode: "waveshaper",
|
||||
initialDriveDb: 12,
|
||||
initialOutputGainDb: -3,
|
||||
});
|
||||
chain.add(dist);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DistortionPlugin } from "@suaveplan/audio-fx-plugin-distortion";
|
||||
|
||||
// Lo-fi bitcrusher pad: 6-bit quantisation with mild gain compensation.
|
||||
const lofi = new DistortionPlugin({
|
||||
initialMode: "bitcrusher",
|
||||
initialBitDepth: 6,
|
||||
initialDriveDb: 0,
|
||||
initialOutputGainDb: 4,
|
||||
});
|
||||
await lofi.setup(
|
||||
{ sampleRate: 48_000, channels: 2, blockSize: 512 },
|
||||
ctx
|
||||
);
|
||||
|
||||
// Automate bit depth from 8 down to 3 over four seconds for a tape-eject
|
||||
// effect. `bits` is automatable so the same plugin handles both static
|
||||
// and time-varying use.
|
||||
const startTime = 0;
|
||||
const endTime = 4;
|
||||
lofi.setParameterAt("bits", 8, startTime);
|
||||
lofi.setParameterAt("bits", 3, endTime);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DistortionPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new DistortionPlugin(options?: DistortionPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- Inherited parameter accessors for `mode`, `drive`, `bits`, `outputGain`.
|
||||
- Types: `DistortionMode = "waveshaper" | "bitcrusher" | "overdrive"`.
|
||||
- Schemas: `DistortionPluginOptionsSchema`, `DistortionPresetDataSchema`, `DistortionModeSchema`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The waveshaper transfer is plain `Math.tanh(drive · x)`. We chose `tanh` over `atan` or piecewise-polynomial shapers because it has a clean analytic bound (`|tanh(y)| < 1`), is monotonic everywhere, and is what most reference distortion plugins use, so the perceptual character matches what producers expect. Per-sample `Math.tanh` is fast enough on V8 / JavaScriptCore not to warrant a lookup table for the typical block sizes used in real-time audio.
|
||||
|
||||
The bitcrusher pre-clamps to `[-1, 1]` before quantisation because uniform rounding on an unbounded input would produce arbitrarily large output (e.g. `round(5 * 4) / 4 = 5`). The spec demands "the output takes only the expected quantised set of values" — that test would fail on an unclamped input. The number of distinct output values is `2^bits + 1` (`2^(bits-1)` per side plus zero) — for `bits = 3`, that's 9 levels.
|
||||
|
||||
The overdrive cubic `x - x³/3` is the standard "Reaper-style" soft-clip. Inside `[-1, 1]` it is monotonic and approximately linear near zero; outside `[-1, 1]` it would diverge, so we clamp to the saturation value `±2/3` (which equals `f(±1)`). This keeps the transfer function continuous at the clamp boundaries — no audible discontinuity even on huge transients.
|
||||
|
||||
`reset()` and `setupState()` are no-ops by design: distortion is a memory-less transfer function, so there is no internal buffer that would need clearing. Tests still exercise `reset()` to lock in the contract that it does not throw, which protects against accidentally adding state in a future revision without updating the docs.
|
||||
@@ -0,0 +1,66 @@
|
||||
# `distortion/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options and preset payload of `DistortionPlugin`. These run at the public boundary so any malformed object — wrong mode name, out-of-range drive, non-integer bit depth, NaN gain — is rejected with `AUDIO_FX.INVALID_OPTIONS` or `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
|
||||
|
||||
- `DistortionModeSchema` — `z.enum(["waveshaper", "bitcrusher", "overdrive"])` — used in both options and preset payloads.
|
||||
- `DistortionPluginOptionsSchema` — validates all four optional construction fields with their documented ranges (`drive` 0..40 dB, `bits` 1..16 integer, `outputGain` ±24 dB).
|
||||
- `DistortionPresetDataSchema` — `.strict()` object carrying only `mode`; extra fields are rejected.
|
||||
- `bits` is `z.number().int()` so float bit-depths (which would round unpredictably) cannot sneak through.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DistortionPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-distortion";
|
||||
|
||||
const parsed = DistortionPluginOptionsSchema.parse({
|
||||
initialMode: "waveshaper",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
DistortionModeSchema,
|
||||
DistortionPresetDataSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-distortion";
|
||||
|
||||
// Validate a preset blob coming off the wire before applying it.
|
||||
function safeApplyPreset(plugin: DistortionPlugin, payload: unknown) {
|
||||
const parsed = DistortionPresetDataSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
log.warn("dropped bad distortion preset", parsed.error.issues);
|
||||
return;
|
||||
}
|
||||
plugin.applyPreset({
|
||||
version: 1,
|
||||
parameters: {},
|
||||
presetData: parsed.data,
|
||||
});
|
||||
}
|
||||
|
||||
const modes = DistortionModeSchema.options;
|
||||
console.log(modes); // ["waveshaper", "bitcrusher", "overdrive"]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DistortionModeSchema: z.ZodEnum<["waveshaper", "bitcrusher", "overdrive"]>`
|
||||
- `DistortionPluginOptionsSchema: z.ZodObject<{ initialMode?, initialDriveDb?, initialBitDepth?, initialOutputGainDb? }>`
|
||||
- `DistortionPresetDataSchema: z.ZodObject<{ mode: DistortionMode }, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`DistortionModeSchema` is exported as a named schema (not inlined) so the same value is reused by `DistortionPluginOptionsSchema.initialMode` and `DistortionPresetDataSchema.mode`. That guarantees both validation paths agree byte-for-byte on the legal mode set — adding a fourth mode in a future revision is a one-line change in `schemas.ts`.
|
||||
|
||||
`DistortionPresetDataSchema` uses `.strict()` for the same reason as the other plugins: unknown fields almost always indicate a payload from a sibling plugin or a forwards-incompatible schema, and silent rejection at the boundary is much less harmful than partial application. The combined "mode-must-be-known, payload-must-be-exact" stance keeps the preset surface stable across plugin versions.
|
||||
@@ -0,0 +1,46 @@
|
||||
# @suaveplan/audio-fx-plugin-eq - Parametric / shelving / high-pass / low-pass equaliser plugin for the @suaveplan/audio-fx-plugin-* family. Multi-band biquad implementation, sample-accurate parameter automation, preset round-tripping, universal.
|
||||
|
||||
Parametric / shelving / high-pass / low-pass equaliser for the `@suaveplan/audio-fx-plugin-*` family. Multi-band biquad implementation, sample-accurate parameter automation, Zod-validated preset round-tripping, universal (browser / server / offline / worklet identical).
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [eq](./src/eq/eq.md) — `EQPlugin` is the 8-band parametric equaliser that anchors every mix bus, vocal chain, and master strip in the project.…
|
||||
- [eq](./src/eq/eq.md) — `EQPlugin` is the 8-band parametric equaliser that anchors every mix bus, vocal chain, and master strip in the project.…
|
||||
- [schemas](./src/schemas/schemas.md) — The `eq.schemas` module owns every Zod schema that guards `EQPlugin`'s boundaries: construction options, single-band re…
|
||||
- [schemas](./src/schemas/schemas.md) — The `eq.schemas` module owns every Zod schema that guards `EQPlugin`'s boundaries: construction options, single-band re…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { createStorageUriResolver } from "@suaveplan/storage-uri";
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { EQPlugin, EQ_PRESETS } from "@suaveplan/audio-fx-plugin-eq";
|
||||
|
||||
const eq = new EQPlugin();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [eq],
|
||||
});
|
||||
|
||||
eq.applyPreset(EQ_PRESETS.vocalAir);
|
||||
```
|
||||
|
||||
## Surface
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `EQPlugin` | class | Concrete `AudioFxPlugin` implementation; extends `AbstractAudioFxPlugin` |
|
||||
| `EQ_PRESETS` | const | Built-in factory presets |
|
||||
| `EQBand`, `EQBandType`, `EQPluginOptions`, `EQPresetData`, `EQPresetName` | type | Public type surface |
|
||||
| `EQBandSchema`, `EQBandTypeSchema`, `EQPluginOptionsSchema`, `EQPresetDataSchema`, `EQ_BAND_COUNT` | const / schema | Zod boundaries |
|
||||
| `computeBiquad`, `BiquadCoeffs`, `BiquadState`, `identityCoeffs` | utility | Biquad math helpers (used internally; re-exported for DSP-curious consumers) |
|
||||
|
||||
## Composition
|
||||
|
||||
This package depends on `@suaveplan/audio-fx-plugin-base` for the `AudioFxPlugin` contract and the chain primitives. It declares no transitive `@suaveplan/audio-fx-plugin-*` deps — the dependency arrow points only at the foundation.
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./eq` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,82 @@
|
||||
# `eq`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`EQPlugin` is the 8-band parametric equaliser that anchors every mix bus, vocal chain, and master strip in the project. It implements the universal `AudioFxPlugin` contract by extending `AbstractAudioFxPlugin`, so the same plugin runs identically inside the Web Audio engine, the server renderer, the offline render, and the worklet wrapper. Every band is a self-contained RBJ Audio EQ Cookbook biquad computed in pure JavaScript — no `BiquadFilterNode`, no Web Audio context, no platform-specific code on the processing path.
|
||||
|
||||
The plugin declares no automatable parameters because EQ tunables (`frequency`, `gain`, `q`, `type`) are band records, not scalar parameter automation lanes. Band state lives in the universal `PresetSnapshot.data` payload validated by `EQPresetDataSchema`, so `getPreset()` / `applyPreset()` round-trip the full plugin state without losing precision.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight independent bands, each selectable between `peaking`, `low-shelf`, `high-shelf`, `low-pass`, and `high-pass` shapes.
|
||||
- RBJ Audio EQ Cookbook biquad coefficients computed at `setup()` time and recomputed lazily when `setBand` / `setBandType` / `applyPreset` mutate a band; no per-block recompute.
|
||||
- Per-channel direct-form biquad state cleared in-place by `reset()`; processing is allocation-free.
|
||||
- Numerically stable across the audible range at any session sample rate — a flat (all-peaking-at-0dB) configuration is bit-transparent at the transfer function level and reproduces the input within float tolerance.
|
||||
- Four factory presets (`flat`, `vocal`, `bass`, `podcast`) exported as `EQ_PRESETS` and applied by name via `applyPresetByName`.
|
||||
- Zod boundary validation on construction, `setBand`, `setBandType`, and `applyPreset`; every rejection throws an `AudioFxError` with the offending field surfaced in `context`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { EQPlugin } from "@suaveplan/audio-fx-plugin-eq";
|
||||
|
||||
const eq = new EQPlugin();
|
||||
eq.applyPresetByName("vocal");
|
||||
eq.setBand(4, 2_500, 4, 1); // band 4 → 2.5 kHz, +4 dB, Q=1
|
||||
// Hand off to the chain / engine:
|
||||
chain.add(eq);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { EQPlugin, EQ_PRESETS } from "@suaveplan/audio-fx-plugin-eq";
|
||||
|
||||
// Custom 8-band layout for a kick-drum bus
|
||||
const eq = new EQPlugin({
|
||||
initialBands: [
|
||||
{ frequency: 40, gain: 3, q: 0.8, type: "low-shelf" },
|
||||
{ frequency: 60, gain: 5, q: 1.5, type: "peaking" },
|
||||
{ frequency: 120, gain: -3, q: 1.5, type: "peaking" },
|
||||
{ frequency: 400, gain: -6, q: 2, type: "peaking" },
|
||||
{ frequency: 1_000, gain: 2, q: 1, type: "peaking" },
|
||||
{ frequency: 3_500, gain: 4, q: 1.2, type: "peaking" },
|
||||
{ frequency: 6_000, gain: 2, q: 1, type: "peaking" },
|
||||
{ frequency: 12_000, gain: 0, q: 0.7, type: "high-shelf" },
|
||||
],
|
||||
});
|
||||
|
||||
// Snapshot for session save and rehydrate later
|
||||
const snap = eq.getPreset();
|
||||
localStorage.setItem("kick-eq", JSON.stringify(snap));
|
||||
|
||||
// Later, in a fresh process:
|
||||
const restored = new EQPlugin();
|
||||
restored.applyPreset(JSON.parse(localStorage.getItem("kick-eq")!));
|
||||
expect(restored.getBands()).toEqual(EQ_PRESETS.flat.bands); // false — restored matches kick layout
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EQPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new EQPlugin(options?: EQPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed `initialBands`.
|
||||
- `getBands(): ReadonlyArray<EQBand>` — read-only snapshot of the live band configuration.
|
||||
- `setBand(index, frequency, gain, q): void` — throws `AUDIO_FX.INVALID_OPTIONS` on out-of-range `index` or invalid field values.
|
||||
- `setBandType(index, type): void` — switches one band to a different response shape.
|
||||
- `applyPresetByName(name: EQPresetName): void` — applies one of the four shipped factory presets.
|
||||
- Inherited: `setup`, `process`, `reset`, `dispose`, `getPreset`, `applyPreset`, `onLatencyChanged`.
|
||||
- `EQ_PRESETS: Readonly<Record<EQPresetName, EQPresetData>>` — exported factory presets.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The biquad coefficient formulas are the canonical RBJ Audio EQ Cookbook variants, normalised by `a0` so the per-sample inner loop costs five multiplies and four adds plus four state updates per band. A flat configuration — every band `peaking` with `gain === 0` — collapses to identity (`b0/a0 = 1`, `b1/a0 = a1/a0`, `b2/a0 = a2/a0`); the test suite verifies this by feeding a mixed-frequency input and asserting sample-for-sample equality within `1e-6`.
|
||||
|
||||
Band state is stored as `BiquadState` objects (`x1, x2, y1, y2`) per channel per band; `reset()` mutates them in place rather than reallocating so the spec's "buffers are the same instances as before reset" guarantee holds without bookkeeping. `setup()` reallocates state only when channel count changes (it always rebuilds in this implementation, which is acceptable because plugin lifecycle setups are rare relative to block processing).
|
||||
|
||||
The plugin deliberately exports no `createAudioNode` — the universal path is the canonical path. Engine adapters that want a `BiquadFilterNode`-backed graph in the browser can wrap eight `BiquadFilterNode`s in series; this plugin's role is the universal source of truth that runs identically on every backend.
|
||||
|
||||
`applyPresetData` validates the incoming `data` against `EQPresetDataSchema` before mutating any band so a malformed snapshot leaves the plugin in its previous state.
|
||||
@@ -0,0 +1,71 @@
|
||||
# `schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `eq.schemas` module owns every Zod schema that guards `EQPlugin`'s boundaries: construction options, single-band records, and preset data payloads. Per the spec's "Zod boundary validation" requirement, every public options object on every plugin SHALL be validated against a sibling `*.schemas.ts` before any state is mutated, and `EQPlugin` is no exception — `setBand`, `setBandType`, constructor options, and `applyPreset` all funnel their input through these schemas and throw `AUDIO_FX.INVALID_OPTIONS` or `AUDIO_FX.INVALID_PRESET` with the Zod issue list in `context.issues` on failure.
|
||||
|
||||
Keeping the schemas in a sibling file (rather than inline in `eq.ts`) is the project-wide convention: external consumers can import the schema, run their own `safeParse` against UI input, and surface field-level errors before the plugin sees the data.
|
||||
|
||||
## Features
|
||||
|
||||
- `EQ_BAND_COUNT` — the fixed 8-band count enforced by both `EQPluginOptionsSchema` and `EQPresetDataSchema`; preset payloads with anything other than exactly eight bands are rejected at the boundary so the plugin's per-block inner loop can hoist the count as a literal.
|
||||
- `EQBandTypeSchema` — discriminated enum over the five shipped response shapes.
|
||||
- `EQBandSchema` — full per-band record with `frequency > 0`, `gain` finite, `q > 0`, and `type` from the band-type enum.
|
||||
- `EQPluginOptionsSchema` — constructor options; the `initialBands` array is optional but, when present, must have exactly `EQ_BAND_COUNT` entries.
|
||||
- `EQPresetDataSchema` — plugin-specific preset payload; same length constraint on `bands`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { EQBandSchema, EQPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-eq";
|
||||
|
||||
const result = EQBandSchema.safeParse(userInput);
|
||||
if (!result.success) {
|
||||
return showFieldErrors(result.error.issues);
|
||||
}
|
||||
const band = result.data;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
EQ_BAND_COUNT,
|
||||
EQBandSchema,
|
||||
EQPluginOptionsSchema,
|
||||
EQPresetDataSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-eq";
|
||||
|
||||
// 1. Bulk-validate a UI form's eight rows before constructing the plugin.
|
||||
const rows = readUIForm();
|
||||
if (rows.length !== EQ_BAND_COUNT) {
|
||||
return showError(`EQ requires exactly ${EQ_BAND_COUNT} bands; got ${rows.length}.`);
|
||||
}
|
||||
const validated = EQPluginOptionsSchema.parse({ initialBands: rows });
|
||||
|
||||
// 2. Re-validate a deserialised preset against EQPresetDataSchema before
|
||||
// handing it to the plugin's applyPreset. This duplicates the plugin's
|
||||
// own check, but lets the host surface user-friendly errors *before*
|
||||
// a plugin instance exists.
|
||||
const preset = EQPresetDataSchema.parse(JSON.parse(savedJson));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EQ_BAND_COUNT: number` — exported constant; always `8`.
|
||||
- `EQBandTypeSchema` — `z.ZodEnum<['peaking', 'low-shelf', 'high-shelf', 'low-pass', 'high-pass']>`.
|
||||
- `EQBandSchema` — `z.ZodObject` matching `EQBand`. Rejects non-positive `frequency` / `q`, non-finite values, and unknown `type` strings.
|
||||
- `EQPluginOptionsSchema` — `z.ZodObject` matching `EQPluginOptions`. `initialBands` is optional; when present, length must equal `EQ_BAND_COUNT`.
|
||||
- `EQPresetDataSchema` — `z.ZodObject` matching `EQPresetData`. `bands` length must equal `EQ_BAND_COUNT`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schemas use `.finite()` on numeric fields so `NaN` and `±Infinity` are rejected at the boundary; the biquad coefficient formulas would propagate non-finite values into IIR state otherwise and contaminate every subsequent block. Q is `.positive()` rather than `.nonnegative()` because the cookbook formulas divide by `Q` in the `alpha` calculation; allowing `Q === 0` would produce `Infinity` coefficients.
|
||||
|
||||
`EQBandSchema` does not validate `frequency` against the Nyquist limit because the plugin instance does not yet know its target `sampleRate` at construction time — the engine's `setup()` call carries the rate. Out-of-Nyquist frequencies still produce numerically stable (if musically meaningless) coefficients, and the cost of a per-band check at every coefficient recompute is not worth the protection.
|
||||
|
||||
The schemas are exported from the package's `./eq` subpath so consumers can reuse them in form validation, server-side payload guards, and integration tests without pulling in the full plugin runtime.
|
||||
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-filter - filter plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
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
|
||||
|
||||
- [filter](./src/filter/filter.md) — `FilterPlugin` is the universal multi-mode low-pass filter. It implements three independent kernels — a Cookbook biquad…
|
||||
- [filter](./src/filter/filter.md) — `FilterPlugin` is the universal multi-mode low-pass filter. It implements three independent kernels — a Cookbook biquad…
|
||||
- [schemas](./src/schemas/schemas.md) — The `filter.schemas` module owns the Zod boundary guards for `FilterPlugin` — construction options and the plugin-speci…
|
||||
- [schemas](./src/schemas/schemas.md) — The `filter.schemas` module owns the Zod boundary guards for `FilterPlugin` — construction options and the plugin-speci…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-filter";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./filter` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,80 @@
|
||||
# `filter`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`FilterPlugin` is the universal multi-mode low-pass filter. It implements three independent kernels — a Cookbook biquad, a 1-pole IIR, and a Chamberlin state-variable filter — selectable per session via the `mode` parameter so consumers can trade off CPU cost against frequency-response fidelity without changing plugin instances. The biquad shares its coefficient maths with the EQ, the IIR is the cheapest broad-stroke option, and the SVF gives high-Q resonance peaks for synth-style sweeps.
|
||||
|
||||
The plugin extends `AbstractAudioFxPlugin`, so it runs identically inside the Web Audio engine, the server renderer, and the worklet wrapper. Filter state is per-channel and cleared in place by `reset()`. Coefficients are recomputed once per block from the live `cutoff` / `q` parameters so automation lanes stay continuous.
|
||||
|
||||
## Features
|
||||
|
||||
- Three filter modes — `biquad`, `iir`, `state-variable` — selectable via the `mode` parameter or the `setMode(mode)` convenience.
|
||||
- Two automatable continuous parameters: `cutoff` (Hz, 20–20 000) and `q` (0.1–20). The `q` cap keeps the SVF below self-oscillation so the per-spec stability guarantee holds.
|
||||
- Per-block coefficient recompute: cutoff and Q are read at block boundaries so automation lanes stay live without per-sample maths.
|
||||
- Numerically stable at high resonance — the SVF's `f = 2·sin(min(π·0.45, π·cutoff/sr))` cap keeps the integrator step inside the stable region across any sample rate.
|
||||
- Per-channel state for all three kernels allocated once at `setup`; `reset()` clears the buffers in place without re-allocating.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { FilterPlugin } from "@suaveplan/audio-fx-plugin-filter";
|
||||
|
||||
const filter = new FilterPlugin({
|
||||
initialMode: "biquad",
|
||||
initialCutoffHz: 1_200,
|
||||
initialQ: 0.707,
|
||||
});
|
||||
chain.add(filter);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { FilterPlugin } from "@suaveplan/audio-fx-plugin-filter";
|
||||
|
||||
// Sweepable resonant SVF for a synth lead.
|
||||
const filter = new FilterPlugin({
|
||||
initialMode: "state-variable",
|
||||
initialCutoffHz: 200,
|
||||
initialQ: 12,
|
||||
});
|
||||
await filter.setup({ sampleRate: 48_000, channels: 2, blockSize: 512 }, ctx);
|
||||
|
||||
// Automate the cutoff with a linear sweep — sample-accurate via the
|
||||
// AbstractAudioFxPlugin parameter-schedule machinery.
|
||||
filter.setParameterCurve("cutoff", {
|
||||
type: "linear",
|
||||
value: 8_000,
|
||||
duration: 2,
|
||||
});
|
||||
|
||||
// Round-trip via preset envelope; mode rides plugin-specific data,
|
||||
// cutoff / Q ride the universal parameters map.
|
||||
const snap = filter.getPreset();
|
||||
const fresh = new FilterPlugin();
|
||||
fresh.applyPreset(snap);
|
||||
expect(fresh.getMode()).toBe("state-variable");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FilterPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new FilterPlugin(options?: FilterPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- `getMode(): FilterMode` — current mode.
|
||||
- `setMode(mode: FilterMode): void` — switch the kernel; current state is preserved across modes so a live sweep can change mode mid-stream.
|
||||
- Inherited parameter accessors for `mode`, `cutoff`, `q`.
|
||||
- `FilterMode` — `"biquad" | "iir" | "state-variable"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The biquad kernel reuses `computeBiquad` from `@suaveplan/audio-fx-plugin-base/biquad` with the `low-pass` band type so the maths stays single-sourced — fixing a bug in the cookbook coefficients fixes both plugins. The biquad inner loop is the same five-multiply-four-add direct-form-I structure the EQ uses, recomputed per block when cutoff / Q change.
|
||||
|
||||
The 1-pole IIR uses `alpha = exp(-2·π·cutoff/sr)` with the recurrence `y[n] = (1 − α)·x[n] + α·y[n−1]`. This is the cheapest of the three kernels (one mul, one add per sample per channel) and is appropriate for broad-stroke shaping where a 6 dB/octave slope is acceptable.
|
||||
|
||||
The Chamberlin SVF uses the classic two-state recursion (`low += f·band`, `high = x − low − damping·band`, `band += f·high`) with `damping = 1/Q` clamped via `q.min = 0.1`. The integrator step `f = 2·sin(π·cutoff/sr)` is approximated; at cutoff approaching Nyquist the approximation diverges, so it's hard-capped at `2·sin(π·0.45)`. Combined with the Q upper bound, this keeps the filter strictly below self-oscillation — the spec's stability scenario asserts no non-finite output and bounded magnitude under long noise input at maximum Q.
|
||||
|
||||
Mode switching does not reset per-mode state, so a consumer that switches biquad → SVF and back hears continuous filter behaviour rather than a click. Consumers who want a clean state on mode change should call `reset()` after `setMode()`.
|
||||
@@ -0,0 +1,63 @@
|
||||
# `schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `filter.schemas` module owns the Zod boundary guards for `FilterPlugin` — construction options and the plugin-specific preset payload. Every public input runs through these schemas before the plugin touches any state; failures surface as `AUDIO_FX.INVALID_OPTIONS` / `AUDIO_FX.INVALID_PRESET` with the Zod issue list in `context.issues`.
|
||||
|
||||
Keeping the schemas in a sibling folder follows the package-wide convention: hosts can import the schemas directly, run their own `safeParse` against UI input, and surface field-level errors before constructing a `FilterPlugin`.
|
||||
|
||||
## Features
|
||||
|
||||
- `FilterModeSchema` — discriminated enum over the three shipped modes (`biquad`, `iir`, `state-variable`).
|
||||
- `FilterPluginOptionsSchema` — every field optional; `initialCutoffHz` must be finite positive, `initialQ` must be finite and inside `[0.1, 20]` (matching the parameter declaration in `filter.ts`).
|
||||
- `FilterPresetDataSchema` — required `mode` field carrying the current filter mode through the preset envelope (the cutoff / Q values ride the universal `parameters` map).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { FilterPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-filter";
|
||||
|
||||
const parsed = FilterPluginOptionsSchema.safeParse(formValues);
|
||||
if (!parsed.success) {
|
||||
return showFieldErrors(parsed.error.issues);
|
||||
}
|
||||
const filter = new FilterPlugin(parsed.data);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
FilterModeSchema,
|
||||
FilterPresetDataSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-filter";
|
||||
|
||||
// Validate a deserialised preset payload before applyPreset. The
|
||||
// plugin would catch the same issue but the host wants a friendlier
|
||||
// error before a plugin instance is constructed.
|
||||
const result = FilterPresetDataSchema.safeParse(payload.data);
|
||||
if (!result.success) {
|
||||
return reject("Filter preset is corrupt; not loading.");
|
||||
}
|
||||
if (!FilterModeSchema.options.includes(result.data.mode)) {
|
||||
return reject(`Unknown filter mode "${result.data.mode}".`);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FilterModeSchema` — `z.ZodEnum<['biquad', 'iir', 'state-variable']>`.
|
||||
- `FilterPluginOptionsSchema` — `z.ZodObject` matching `FilterPluginOptions`. All fields optional.
|
||||
- `FilterPresetDataSchema` — `z.ZodObject` matching `FilterPresetData`. `mode` is required.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`FilterPluginOptionsSchema` uses `.finite()` on every numeric field so `NaN` and `±Infinity` are rejected at the boundary; the per-block coefficient maths (cookbook biquad, SVF, 1-pole IIR) would otherwise propagate non-finite values into the filter state and contaminate every subsequent block.
|
||||
|
||||
`initialQ` is clamped to `[0.1, 20]` to match the plugin's parameter declaration. The lower bound prevents division-by-near-zero in the SVF damping coefficient (`damping = 1 / Q`), and the upper bound keeps the SVF below self-oscillation — important because the spec's stability scenario fires "maximum resonance below self-oscillation" against this filter and treats divergence as a test failure.
|
||||
|
||||
The preset envelope carries only `mode` because cutoff / Q are automatable parameters and round-trip through the universal `parameters` map on the `PresetSnapshot`. Duplicating them here would risk drift if a future refactor changes one source but not the other; keeping the data slim makes the preset round-trip obviously correct.
|
||||
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-flanger - flanger plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
flanger 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
|
||||
|
||||
- [flanger](./src/flanger/flanger.md) — `FlangerPlugin` is the universal comb-filter flanger. A short LFO-modulated delay line (sub-10 ms) is summed with the d…
|
||||
- [flanger](./src/flanger/flanger.md) — `FlangerPlugin` is the universal comb-filter flanger. A short LFO-modulated delay line (sub-10 ms) is summed with the d…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `FlangerPlugin`. These run on the public bou…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `FlangerPlugin`. These run on the public bou…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-flanger";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./flanger` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,50 @@
|
||||
# `flanger.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `FlangerPlugin`'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 or storybook controls without pulling in the plugin's runtime code.
|
||||
|
||||
## Features
|
||||
|
||||
- `FlangerPluginOptions` — all fields optional so the default constructor (`new FlangerPlugin()`) is valid.
|
||||
- `readonly` properties — emphasises that the options object is consumed once at construction and never mutated.
|
||||
- Numeric ranges live in the Zod schema (single source of truth) rather than in TypeScript types.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { FlangerPluginOptions } from "@suaveplan/audio-fx-plugin-flanger";
|
||||
|
||||
const opts: FlangerPluginOptions = { initialDepthMs: 2 };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { FlangerPluginOptions } from "@suaveplan/audio-fx-plugin-flanger";
|
||||
|
||||
const presets: Record<string, FlangerPluginOptions> = {
|
||||
subtle: { initialDepthMs: 0.5, initialFeedback: 0.3, initialWet: 0.3 },
|
||||
classic: { initialDepthMs: 2, initialFeedback: 0.5, initialWet: 0.5 },
|
||||
jet: { initialDepthMs: 3, initialFeedback: 0.9, initialWet: 0.7 },
|
||||
};
|
||||
|
||||
function load(name: keyof typeof presets): FlangerPluginOptions {
|
||||
return presets[name]!;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FlangerPluginOptions`
|
||||
- `readonly initialRateHz?: number`
|
||||
- `readonly initialDepthMs?: number`
|
||||
- `readonly initialFeedback?: number`
|
||||
- `readonly initialWet?: number`
|
||||
- `readonly initialBaseDelayMs?: number`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interface deliberately mirrors `ChorusPluginOptions` field-for-field except that `depth` is shorter-range (`0..5 ms` versus the chorus's `0..20 ms`) and `baseDelay` is much shorter (`0.1..10 ms` versus `5..30 ms`). This consistency lets a UI render both plugins from a shared component if it wants to, while the schema enforces the perceptually-distinct parameter ranges that define each effect.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,69 @@
|
||||
# `flanger`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`FlangerPlugin` is the universal comb-filter flanger. A short LFO-modulated delay line (sub-10 ms) is summed with the dry signal; the close tap spacing produces evenly-spaced comb peaks and notches in the frequency response. Feedback emphasises the comb peaks, producing the "metallic" or "jet-engine" timbre that defines the flange sound.
|
||||
|
||||
The plugin extends `AbstractAudioFxPlugin` so it runs identically across browser, server, offline, and worklet renderers. State is a per-channel circular `Float32Array` sized for the plugin's maximum delay; `reset()` zeros all buffers in place without reallocation.
|
||||
|
||||
## Features
|
||||
|
||||
- Five parameters: `rate` (Hz, 0.05..10), `depth` (ms, 0..5), `feedback` (0..0.95), `wet` (0..1), `baseDelay` (ms, 0.1..10).
|
||||
- Sine LFO drives the delay tap around `baseDelay` with peak-to-peak excursion of `2 · depth`.
|
||||
- Linear-interpolated fractional read keeps the modulation smooth at low rates and high depths.
|
||||
- Feedback clamped at the parameter boundary (max 0.95) so the comb feedback loop stays bounded for any bounded input.
|
||||
- `wet === 0` early-out: returns the dry path sample-for-sample with no allocation and no buffer writes — keeps the spec contract "fully-dry mix is transparent" verifiable by static inspection.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { FlangerPlugin } from "@suaveplan/audio-fx-plugin-flanger";
|
||||
|
||||
const flanger = new FlangerPlugin({
|
||||
initialRateHz: 0.4,
|
||||
initialDepthMs: 2,
|
||||
initialFeedback: 0.6,
|
||||
initialWet: 0.5,
|
||||
});
|
||||
chain.add(flanger);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { FlangerPlugin } from "@suaveplan/audio-fx-plugin-flanger";
|
||||
|
||||
// "Through-zero" style deep flange: slow rate, very short base delay,
|
||||
// near-max feedback for the classic resonant jet sweep.
|
||||
const jet = new FlangerPlugin({
|
||||
initialRateHz: 0.2,
|
||||
initialDepthMs: 3,
|
||||
initialFeedback: 0.9,
|
||||
initialWet: 0.7,
|
||||
initialBaseDelayMs: 0.5,
|
||||
});
|
||||
await jet.setup(
|
||||
{ sampleRate: 48_000, channels: 2, blockSize: 512 },
|
||||
ctx
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FlangerPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new FlangerPlugin(options?: FlangerPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- Inherited parameter accessors for `rate`, `depth`, `feedback`, `wet`, `baseDelay`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The delay line is a circular `Float32Array` per channel sized for `MAX_DELAY_MS = 20 ms` plus a 4-sample safety margin (the fractional read indexes `i0` and `i0 + 1`). Twenty milliseconds is well over `baseDelay.max + 2·depth.max = 10 + 10 = 20`, with the extra margin so the wrap path is never hit on the hottest combination of parameters.
|
||||
|
||||
The LFO is mapped to the delay tap via `delay = base + (sin + 1)/2 · depth`, not `delay = base + sin · depth`. Mapping to the half-positive range `[0, depth]` means the flange always *adds* delay to the base — never *subtracts* — so the line never reads near `writeIndex - 0` (which would wrap aliased noise into the output). It also matches the standard convention used by most reference flanger plugins so the perceptual sweep direction is what producers expect.
|
||||
|
||||
Feedback at the comb-filter resonance is intentionally allowed to climb to 0.95 because the flanger character lives at the resonance edge. The test "max feedback over a long sine never produces non-finite samples" exercises this boundary against a one-second sine at `feedback = 0.95` and verifies every output sample is finite and amplitude-bounded. Allowing `1.0` would create a marginally stable system whose output diverges for any non-zero input; clamping at 0.95 leaves enough headroom that even a noise-floor leak stays bounded over arbitrarily long renders.
|
||||
|
||||
The `wet === 0` early-out short-circuits the entire DSP loop with a `Float32Array.set` copy per channel — both fast and bit-exact. This satisfies the spec's "wet=0 is bit-transparent" contract without relying on coincidence (`0 * read + dry === dry` would only hold for IEEE-clean values, not subnormals).
|
||||
@@ -0,0 +1,56 @@
|
||||
# `flanger/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options and preset payload of `FlangerPlugin`. 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
|
||||
|
||||
- `FlangerPluginOptionsSchema` — validates all five optional construction fields with their documented ranges (`rate` 0.05..10 Hz, `depth` 0..5 ms, `feedback` 0..0.95, `wet` 0..1, `baseDelay` 0.1..10 ms).
|
||||
- `FlangerPresetDataSchema` — empty strict object: the flanger 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 { FlangerPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-flanger";
|
||||
|
||||
const parsed = FlangerPluginOptionsSchema.parse({ initialDepthMs: 1.5 });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
FlangerPluginOptionsSchema,
|
||||
FlangerPresetDataSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-flanger";
|
||||
|
||||
// Pull keys + ranges from the schema for a generated form.
|
||||
const fields = FlangerPluginOptionsSchema.keyof().options;
|
||||
for (const field of fields) {
|
||||
form.addRow(field);
|
||||
}
|
||||
|
||||
// Strict preset validation at the IPC boundary.
|
||||
function isFlangerPreset(blob: unknown): boolean {
|
||||
return FlangerPresetDataSchema.safeParse(blob).success;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FlangerPluginOptionsSchema: z.ZodObject<{ initialRateHz?, initialDepthMs?, initialFeedback?, initialWet?, initialBaseDelayMs? }>`
|
||||
- `FlangerPresetDataSchema: z.ZodObject<{}, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The maximum feedback is `0.95`, matching the chorus, phaser, and delay plugins so all four feedback-bearing effects share one mental model. The flanger lives close to that boundary by design — the resonant comb peaks are the effect's character — but the same hard cap keeps the loop strictly under unity gain so the output cannot diverge.
|
||||
|
||||
`FlangerPresetDataSchema` is `.strict()` so unknown fields are rejected, matching the sibling-plugin policy. The flanger has no plugin-specific preset payload today: every parameter (`rate`, `depth`, `feedback`, `wet`, `baseDelay`) travels through the universal parameter snapshot that `AbstractAudioFxPlugin` builds, and the empty `presetData` field marks that the plugin opts out of plugin-specific state.
|
||||
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-game-audio - game-audio plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
game-audio 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
|
||||
|
||||
- [game-audio](./src/game-audio/game-audio.md) — `GameAudioPlugin` is the universal composite plugin for game audio. It maintains three registries — spatial sources, mu…
|
||||
- [game-audio](./src/game-audio/game-audio.md) — `GameAudioPlugin` is the universal composite plugin for game audio. It maintains three registries — spatial sources, mu…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, vector types, voice-chat configuration, game-state enum, and pres…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, vector types, voice-chat configuration, game-state enum, and pres…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-game-audio";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./game-audio` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,83 @@
|
||||
# `game-audio.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `GameAudioPlugin`'s vector math types, registry entries, configuration, and preset payload. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`.
|
||||
|
||||
The interfaces here form the structural contract that the browser-engine fast path (`createAudioNode`) will read in a future revision — keeping them stable here is essential for that integration.
|
||||
|
||||
## Features
|
||||
|
||||
- `Vector3` — `{ x, y, z }` finite-number 3-vector.
|
||||
- `ListenerOrientation` — `{ forward: Vector3, up: Vector3 }`.
|
||||
- `SpatialAudioSource` — `{ id, position, velocity, occlusion }`.
|
||||
- `VoiceChatParticipant` — `{ id, position }`.
|
||||
- `ProximityVoiceChatConfig` — `{ minDistance, maxDistance, rolloffFactor }`.
|
||||
- `MusicLayer` — `{ name, buffer, currentGain, targetGain }`.
|
||||
- `GameState` — textual enum `"exploration" | "combat" | "victory" | "menu"`.
|
||||
- `GameVoiceEffect` — `{ id, pitchShift, distortion }`.
|
||||
- `GameAudioPluginOptions` — optional listener + voice-chat options.
|
||||
- `GameAudioPresetData` — full preset payload.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
GameAudioPluginOptions,
|
||||
Vector3,
|
||||
} from "@suaveplan/audio-plugins-fx/game";
|
||||
|
||||
const position: Vector3 = { x: 1, y: 0, z: 0 };
|
||||
const opts: GameAudioPluginOptions = { listenerPosition: position };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
GameState,
|
||||
MusicLayer,
|
||||
ProximityVoiceChatConfig,
|
||||
Vector3,
|
||||
} from "@suaveplan/audio-plugins-fx/game";
|
||||
|
||||
function magnitude(v: Vector3): number {
|
||||
return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
|
||||
}
|
||||
|
||||
const states: ReadonlyArray<GameState> = [
|
||||
"exploration",
|
||||
"combat",
|
||||
"victory",
|
||||
"menu",
|
||||
];
|
||||
|
||||
function describeLayer(layer: MusicLayer): string {
|
||||
return `${layer.name} @ ${(layer.currentGain * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
const tight: ProximityVoiceChatConfig = {
|
||||
minDistance: 1,
|
||||
maxDistance: 20,
|
||||
rolloffFactor: 2,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `Vector3` — `{ readonly x, y, z: number }`
|
||||
- `ListenerOrientation` — `{ readonly forward, up: Vector3 }`
|
||||
- `SpatialAudioSource` — `{ readonly id, position, velocity, occlusion }`
|
||||
- `VoiceChatParticipant` — `{ readonly id, position }`
|
||||
- `ProximityVoiceChatConfig` — `{ readonly minDistance, maxDistance, rolloffFactor }`
|
||||
- `MusicLayer` — `{ readonly name, buffer, currentGain, targetGain }`
|
||||
- `GameState = "exploration" | "combat" | "victory" | "menu"`
|
||||
- `GameVoiceEffect` — `{ readonly id, pitchShift, distortion }`
|
||||
- `GameAudioPluginOptions` — `{ readonly listenerPosition?, listenerOrientation?, voiceChat? }`
|
||||
- `GameAudioPresetData` — `{ listenerPosition, listenerOrientation, voiceChat: … | null, gameState }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`GameAudioPresetData.voiceChat` is `ProximityVoiceChatConfig | null` (not `?:`) because a disabled voice-chat state is a legitimate value that the preset must round-trip. Using `?` would let the field be omitted, which is ambiguous between "no voice chat configured" and "voice chat at the default". The explicit `null` makes the disabled state unambiguous.
|
||||
|
||||
`SpatialAudioSource` and `MusicLayer` are returned as read-only views from the plugin's accessors; the live state inside the plugin is mutable (it has to be, for per-block gain updates), but consumers see snapshots that they cannot mutate to corrupt the registry. The cost is one shallow object copy per accessor call — negligible compared to the per-block work the plugin does.
|
||||
@@ -0,0 +1,83 @@
|
||||
# `game-audio`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GameAudioPlugin` is the universal composite plugin for game audio. It maintains three registries — spatial sources, music layers, and proximity voice-chat participants — and exposes a transport-level API for managing each. The plugin is a source (`io.inputs[0].count === 0`, `io.outputs[0].count === 1`); it owns its audio output rather than colouring upstream audio.
|
||||
|
||||
The plugin extends `AbstractAudioFxPlugin` and runs identically across browser / server / worklet renderers. The audio path is intentionally a silent placeholder — the listener data, source registry, voice attenuation, and music-layer crossfade scheduling are pure bookkeeping that the browser-engine fast path consumes via `createAudioNode()` in a follow-up. The universal block path produces silence by design; consumers route the registry state into their own mixing graph.
|
||||
|
||||
## Features
|
||||
|
||||
- **Spatial sources**: `createSpatialSource`, `updateSpatialSourcePosition`, `applyDopplerEffect`, `applyOcclusion(0..1)`, `cullDistantSources(maxDistance)`, `getActiveSpatialSourceCount`, `getSpatialSource(id)`.
|
||||
- **Proximity voice chat**: `enableProximityVoiceChat(config)`, `disableProximityVoiceChat`, `addVoiceChatParticipant`, `removeVoiceChatParticipant`, `updateVoiceChatParticipantPosition`, `calculateVoiceAttenuation`, `optimizeVoiceChatBandwidth`, `getVoiceChatOptimizationLevel`.
|
||||
- **Music layers**: `addMusicLayer`, `removeMusicLayer`, `getMusicLayers`, `crossfadeMusicLayers(from, to, durationMs)`, `isCrossfading`.
|
||||
- **Voice effects**: `applyGameEffect`, `removeGameEffect`, `getCurrentEffect`, `blendToEffect(effect, durationMs)`, `isBlending`.
|
||||
- **Listener**: `setListenerPosition`, `setListenerOrientation`, `getListenerPosition`, `getListenerOrientation`.
|
||||
- **Game state**: `setGameState("exploration" | "combat" | "victory" | "menu")`, `getGameState`.
|
||||
|
||||
Voice attenuation uses the same inverse-distance model as `SpatialAudioPlugin`: `gain = minDistance / (minDistance + rolloffFactor · (clamp(d, minDistance, maxDistance) − minDistance))`. Distance grows ⇒ gain decreases monotonically, exactly as the spec requires.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { GameAudioPlugin } from "@suaveplan/audio-plugins-fx/game";
|
||||
|
||||
const game = new GameAudioPlugin({
|
||||
listenerPosition: { x: 0, y: 0, z: 0 },
|
||||
voiceChat: { minDistance: 1, maxDistance: 50, rolloffFactor: 1 },
|
||||
});
|
||||
chain.add(game);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { GameAudioPlugin } from "@suaveplan/audio-plugins-fx/game";
|
||||
|
||||
const game = new GameAudioPlugin({ voiceChat: { minDistance: 1, maxDistance: 100, rolloffFactor: 2 } });
|
||||
await game.setup(
|
||||
{ sampleRate: 48_000, channels: 1, blockSize: 512 },
|
||||
ctx
|
||||
);
|
||||
|
||||
// Register a footstep emitter and a voice-chat peer.
|
||||
game.createSpatialSource("footstep", { x: 5, y: 0, z: 0 });
|
||||
game.addVoiceChatParticipant("teammate", { x: 10, y: 0, z: 0 });
|
||||
|
||||
// Adaptive music: crossfade from exploration to combat over 1s.
|
||||
game.addMusicLayer("exploration", explorationBuffer);
|
||||
game.addMusicLayer("combat", combatBuffer);
|
||||
game.crossfadeMusicLayers("exploration", "combat", 1_000);
|
||||
game.setGameState("combat");
|
||||
|
||||
// Cull distant sources every frame to keep the voice-chat budget tight.
|
||||
game.optimizeVoiceChatBandwidth();
|
||||
game.cullDistantSources(80);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GameAudioPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- Construction and lifecycle: `new GameAudioPlugin(options?)`, `setup`, `reset`.
|
||||
- Listener: `setListenerPosition`, `setListenerOrientation`, `getListenerPosition`, `getListenerOrientation`.
|
||||
- Spatial sources: see Features above.
|
||||
- Voice chat: see Features above.
|
||||
- Music layers: see Features above.
|
||||
- Voice effects: see Features above.
|
||||
- Game state: `setGameState`, `getGameState`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The audio path is silent by design. Implementing the full mix of spatial sources + music layers + voice chat into a single mono output stream would require this plugin to embed three sibling-plugin engines (`SpatialAudioPlugin`, `MixerPlugin`, plus a voice-chat router) — which would either duplicate code or pull every plugin into this one's import graph. Instead, the plugin owns the *registry* of game-audio state; the browser engine reads that state via `createAudioNode()` and renders it into a Web Audio graph in a future revision. The universal block path produces silence but advances the internal sample clock so `isCrossfading` / `isBlending` work correctly under any renderer.
|
||||
|
||||
Crossfade scheduling stores the start sample (`processedSamples` at the time of the call) plus the duration in samples. `isCrossfading` is `elapsed < durationSamples`. The check is exact at the sample level — the test "isCrossfading true immediately; false after enough samples processed" exercises that by processing exactly the configured number of samples.
|
||||
|
||||
`cullDistantSources` walks the spatial-source map and deletes entries whose distance to the listener exceeds the threshold. The cost is `O(numSources)` per call; for the typical game-audio case (under 100 sources) this is below 1 ms even at 60 fps cull frequency. A spatial-hash variant would amortise this to `O(log n)` but adds bookkeeping that the MVP does not need.
|
||||
|
||||
`optimizeVoiceChatBandwidth` returns a coarse 0..3 tier rather than per-participant codec parameters because the actual codec selection lives in the transport layer (WebRTC / similar). The plugin's job is to *advise*; the transport applies the advice. Currently the heuristic is just a participant-count threshold (>8 ⇒ level 3, otherwise level 1) — a future revision can layer on signal-strength and ping data.
|
||||
|
||||
The plugin maintains an internal `processedSamples` counter incremented by `output.length` each `processBlock`. This is the same clock crossfade and effect-blend countdowns reference — using `BlockContext.blockOffsetSamples` directly would not work because the chain may slice or align blocks differently, and the consumer expects clock advancement per actual rendered sample. The counter resets on `setup`/`reset`.
|
||||
@@ -0,0 +1,72 @@
|
||||
# `game/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options, vector types, voice-chat configuration, game-state enum, and preset payload of `GameAudioPlugin`. 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
|
||||
|
||||
- `Vector3Schema` — `{ x, y, z: finite }`. Reused by every position/velocity/orientation field in the package.
|
||||
- `ListenerOrientationSchema` — `{ forward, up: Vector3 }`.
|
||||
- `ProximityVoiceChatConfigSchema` — `{ minDistance, maxDistance, rolloffFactor }` with documented ranges.
|
||||
- `GameStateSchema` — `z.enum(["exploration", "combat", "victory", "menu"])`.
|
||||
- `GameAudioPluginOptionsSchema` — optional listener position, orientation, and voice-chat config.
|
||||
- `GameAudioPresetDataSchema` — `.strict()` object with all four persistent state fields (voice-chat nullable for the disabled state).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { GameAudioPluginOptionsSchema } from "@suaveplan/audio-plugins-fx/game";
|
||||
|
||||
const parsed = GameAudioPluginOptionsSchema.parse({
|
||||
listenerPosition: { x: 0, y: 0, z: 0 },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
GameAudioPresetDataSchema,
|
||||
ProximityVoiceChatConfigSchema,
|
||||
Vector3Schema,
|
||||
} from "@suaveplan/audio-plugins-fx/game";
|
||||
|
||||
function isPosition(blob: unknown): boolean {
|
||||
return Vector3Schema.safeParse(blob).success;
|
||||
}
|
||||
|
||||
function isVoiceConfig(blob: unknown): boolean {
|
||||
return ProximityVoiceChatConfigSchema.safeParse(blob).success;
|
||||
}
|
||||
|
||||
function safeApplyPreset(plugin: GameAudioPlugin, payload: unknown) {
|
||||
const parsed = GameAudioPresetDataSchema.safeParse(payload);
|
||||
if (!parsed.success) return false;
|
||||
plugin.applyPreset({ version: 1, parameters: {}, presetData: parsed.data });
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `Vector3Schema: z.ZodObject<{ x, y, z }>`
|
||||
- `ListenerOrientationSchema: z.ZodObject<{ forward, up }>`
|
||||
- `ProximityVoiceChatConfigSchema: z.ZodObject<{ minDistance, maxDistance, rolloffFactor }>`
|
||||
- `GameStateSchema: z.ZodEnum<["exploration", "combat", "victory", "menu"]>`
|
||||
- `GameAudioPluginOptionsSchema: z.ZodObject<{ listenerPosition?, listenerOrientation?, voiceChat? }>`
|
||||
- `GameAudioPresetDataSchema: z.ZodObject<{ listenerPosition, listenerOrientation, voiceChat, gameState }, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Vector3Schema` is exported as a named schema and reused throughout the file. That guarantees every position/velocity/orientation field in the package agrees on the legal vector shape — a finite 3-tuple — and downstream code that wants to validate a vector independently (e.g., a network message carrying position updates) can pull the same schema.
|
||||
|
||||
`GameAudioPresetDataSchema` uses `.strict()` so unknown fields are rejected. `voiceChat` is `.nullable()` rather than `.optional()` because a disabled voice-chat state is a meaningful value the preset must round-trip; making it optional would lose the distinction between "absent" and "explicitly disabled".
|
||||
|
||||
The numeric ranges on `ProximityVoiceChatConfigSchema` match the `SpatialAudioPlugin` bounds (`minDistance` 0.01..1000, `maxDistance` 0.01..100 000, `rolloffFactor` 0..10) so a developer migrating from one plugin to the other can copy values directly without re-scaling.
|
||||
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-gate - gate plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
gate 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
|
||||
|
||||
- [gate](./src/gate/gate.md) — `GatePlugin` is the universal noise gate. When the input envelope falls below `threshold`, the gate closes after `hold`…
|
||||
- [gate](./src/gate/gate.md) — `GatePlugin` is the universal noise gate. When the input envelope falls below `threshold`, the gate closes after `hold`…
|
||||
- [schemas](./src/schemas/schemas.md) — The `gate.schemas` module owns the Zod boundary guards for `GatePlugin` — construction options and the plugin-specific…
|
||||
- [schemas](./src/schemas/schemas.md) — The `gate.schemas` module owns the Zod boundary guards for `GatePlugin` — construction options and the plugin-specific…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-gate";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./gate` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,74 @@
|
||||
# `gate`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GatePlugin` is the universal noise gate. When the input envelope falls below `threshold`, the gate closes after `hold` ms via the `release` envelope; when the envelope rises back above `threshold`, the gate opens via `attack`. The envelope is linked across input channels so a transient on any channel opens the gate for the whole frame, keeping the stereo image phase-coherent during gated passages.
|
||||
|
||||
The plugin extends `AbstractAudioFxPlugin`, so it runs identically across browser / server / worklet renderers. Gate state is one number (`currentGain`) plus a hold-sample counter; `reset()` zeros both in place.
|
||||
|
||||
## Features
|
||||
|
||||
- Four parameters: `threshold` (dB, -80..0), `attack` (ms, 0.1..100), `hold` (ms, 0..500), `release` (ms, 1..1000).
|
||||
- Linked envelope detection: `max(|in_c|)` across all input channels per sample so a stereo transient on either channel opens the gate.
|
||||
- One-pole attack/release smoothing with `coef = 1 − exp(-1/(τ · sr))` per direction.
|
||||
- Hold counter resets every time the envelope crosses the threshold so a sustained-but-fluctuating signal stays gated open.
|
||||
- `getCurrentGain()` accessor exposes the live gate gain for metering / VCAs that mirror the gate.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { GatePlugin } from "@suaveplan/audio-fx-plugin-gate";
|
||||
|
||||
const gate = new GatePlugin({
|
||||
initialThresholdDb: -40,
|
||||
initialAttackMs: 1,
|
||||
initialHoldMs: 20,
|
||||
initialReleaseMs: 100,
|
||||
});
|
||||
chain.add(gate);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { GatePlugin } from "@suaveplan/audio-fx-plugin-gate";
|
||||
|
||||
// Tight drum-bus gate: fast attack/release, short hold, aggressive
|
||||
// threshold. Useful for cleaning up cymbal bleed under snare hits.
|
||||
const gate = new GatePlugin({
|
||||
initialThresholdDb: -25,
|
||||
initialAttackMs: 0.5,
|
||||
initialHoldMs: 30,
|
||||
initialReleaseMs: 60,
|
||||
});
|
||||
await gate.setup({ sampleRate: 48_000, channels: 2, blockSize: 512 }, ctx);
|
||||
|
||||
// Drive a UI meter from the live gate gain.
|
||||
requestAnimationFrame(function tick() {
|
||||
meter.write(gate.getCurrentGain());
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GatePlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new GatePlugin(options?: GatePluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- `getCurrentGain(): number` — current gate gain (0..1).
|
||||
- Inherited parameter accessors for `threshold`, `attack`, `hold`, `release`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The envelope detector is per-sample peak across channels: `level = max(|in_c[i]|)`. This is the cheapest detector that still responds to fast transients; an RMS detector would smooth out kick-drum attacks, which is rarely what a noise gate wants.
|
||||
|
||||
The hold counter resets every time `level >= thresholdLin`, then decrements while the envelope is below threshold. While `hold > 0` the target gain stays at `1`; once `hold === 0` the target drops to `0` and the release ramp engages. This implements the spec's "after the hold time" semantics literally.
|
||||
|
||||
`attackCoef` and `releaseCoef` are one-pole filter coefficients (`1 − exp(-1/(τ · sr))`). At each sample, `gain += coef · (target − gain)`, asymptoting toward `target` with the relevant time constant. With `release = 100 ms` at 48 kHz the gain falls by ~63% in the first 100 ms, ~95% in 300 ms, effectively closed within ~500 ms.
|
||||
|
||||
The threshold is converted to linear via `10^(thresholdDb / 20)` once per block; per-sample the gate compares `level` (already linear) to that constant.
|
||||
|
||||
The plugin has no `setSidechain` member — gates are usually program-side detection; if a future build wants an external key input, `setSidechain` can be added behind a `hasSidechain: true` capability flag without API breakage.
|
||||
@@ -0,0 +1,61 @@
|
||||
# `schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `gate.schemas` module owns the Zod boundary guards for `GatePlugin` — construction options and the plugin-specific preset payload. Every public input runs through these schemas before the plugin touches any state; failures surface as `AUDIO_FX.INVALID_OPTIONS` / `AUDIO_FX.INVALID_PRESET` with the Zod issue list in `context.issues`.
|
||||
|
||||
Keeping the schemas in a sibling folder follows the package-wide convention: hosts can import the schemas directly, run their own `safeParse` against UI input, and surface field-level errors before constructing a `GatePlugin`.
|
||||
|
||||
## Features
|
||||
|
||||
- `GatePluginOptionsSchema` — every field optional, ranged to match the parameter declarations in `gate.ts`: `threshold ∈ [-80, 0] dB`, `attack ∈ [0.1, 100] ms`, `hold ∈ [0, 500] ms`, `release ∈ [1, 1000] ms`.
|
||||
- `GatePresetDataSchema` — `z.object({}).strict()`. The gate has no plugin-specific state beyond its automatable parameters; the universal `parameters` map carries everything that round-trips.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { GatePluginOptionsSchema } from "@suaveplan/audio-fx-plugin-gate";
|
||||
|
||||
const parsed = GatePluginOptionsSchema.safeParse(formValues);
|
||||
if (!parsed.success) {
|
||||
return showFieldErrors(parsed.error.issues);
|
||||
}
|
||||
const gate = new GatePlugin(parsed.data);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
GatePluginOptionsSchema,
|
||||
GatePresetDataSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-gate";
|
||||
|
||||
// Validate an untrusted preset payload from a shared template
|
||||
// library before applyPreset. The plugin would catch the same issue,
|
||||
// but the host wants a friendlier error before a plugin instance is
|
||||
// constructed.
|
||||
const result = GatePresetDataSchema.safeParse(payload.data);
|
||||
if (!result.success) {
|
||||
return reject("Gate preset is corrupt.");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GatePluginOptionsSchema` — `z.ZodObject` matching `GatePluginOptions`. All fields optional and ranged.
|
||||
- `GatePresetDataSchema` — strict empty object; rejects any unknown keys.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`GatePluginOptionsSchema` uses `.finite()` on every numeric field so `NaN` and `±Infinity` are rejected at the boundary; the one-pole envelope follower's `exp(-1 / (τ · sr))` calculation would otherwise propagate non-finite coefficients into the smoothing state and contaminate every subsequent block.
|
||||
|
||||
`attack.min(0.1)` and `release.min(1)` match the parameter declarations; values smaller than this break the one-pole smoothing assumption (the coefficient approaches `1` from below) and produce numerical artefacts in single-precision float. `hold.min(0)` allows a zero-hold gate (closes immediately when the envelope drops), which is useful for hyper-tight drum gates.
|
||||
|
||||
`threshold.min(-80)` covers the practical noise floor of consumer audio; values below `-80 dB` are effectively "gate never closes" and don't need their own UI affordance. The upper bound at `0 dB` is the maximum useful threshold — at `0 dB` the gate is essentially always closed since real-world signals rarely exceed `0 dBFS`.
|
||||
|
||||
The strict empty `GatePresetDataSchema` rejects any unknown keys so a stale preset format with phantom fields fails loud rather than silently dropping data.
|
||||
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-glados - glados plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
glados 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
|
||||
|
||||
- [glados](./src/glados/glados.md) — `GLaDOSPlugin` is the universal voice-effect insert. It runs the input signal through a fixed six-stage chain — granula…
|
||||
- [glados](./src/glados/glados.md) — `GLaDOSPlugin` is the universal voice-effect insert. It runs the input signal through a fixed six-stage chain — granula…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, stage configuration, preset enum, and preset payload of `GLaDOSPl…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, stage configuration, preset enum, and preset payload of `GLaDOSPl…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-glados";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./glados` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,78 @@
|
||||
# `glados.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `GLaDOSPlugin`'s preset enum, stage configuration, construction options, and preset payload. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`.
|
||||
|
||||
`GLaDOSPreset` is the canonical preset-name enum; saved presets travel by name (not numeric index) so a future revision adding new presets does not break stored data.
|
||||
|
||||
## Features
|
||||
|
||||
- `GLaDOSPreset` — textual enum covering the six built-in presets.
|
||||
- `GLaDOSConfig` — six numeric stage values, all `readonly`.
|
||||
- `GLaDOSPluginOptions` — `preset?` selects a starting preset; `config?` supplies a `Partial<GLaDOSConfig>` overlay applied on top.
|
||||
- `GLaDOSPresetData` — preset payload carrying both `preset` (intent) and the full `config` (effective state) so restore is deterministic.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
GLaDOSConfig,
|
||||
GLaDOSPluginOptions,
|
||||
GLaDOSPreset,
|
||||
} from "@suaveplan/audio-fx-plugin-glados";
|
||||
|
||||
const opts: GLaDOSPluginOptions = { preset: "wheatley" };
|
||||
const preset: GLaDOSPreset = opts.preset ?? "glados-mainline";
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
GLaDOSConfig,
|
||||
GLaDOSPluginOptions,
|
||||
GLaDOSPreset,
|
||||
} from "@suaveplan/audio-fx-plugin-glados";
|
||||
|
||||
function blend(a: GLaDOSConfig, b: GLaDOSConfig, t: number): GLaDOSConfig {
|
||||
return {
|
||||
pitchShift: a.pitchShift + (b.pitchShift - a.pitchShift) * t,
|
||||
formantShift: a.formantShift + (b.formantShift - a.formantShift) * t,
|
||||
roboticIntensity:
|
||||
a.roboticIntensity + (b.roboticIntensity - a.roboticIntensity) * t,
|
||||
distortionAmount:
|
||||
a.distortionAmount + (b.distortionAmount - a.distortionAmount) * t,
|
||||
lowPassCutoff: a.lowPassCutoff + (b.lowPassCutoff - a.lowPassCutoff) * t,
|
||||
reverbAmount: a.reverbAmount + (b.reverbAmount - a.reverbAmount) * t,
|
||||
};
|
||||
}
|
||||
|
||||
const opts: GLaDOSPluginOptions = {
|
||||
preset: "robotic-generic",
|
||||
config: { roboticIntensity: 0.9, reverbAmount: 0.3 },
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GLaDOSPreset` = `"glados-mainline" | "glados-test-chamber" | "glados-cake-deception" | "wheatley" | "cave-johnson" | "robotic-generic"`
|
||||
- `GLaDOSConfig`
|
||||
- `readonly pitchShift: number` (-12..12 semitones)
|
||||
- `readonly formantShift: number` (-12..12 semitones)
|
||||
- `readonly roboticIntensity: number` (0..1)
|
||||
- `readonly distortionAmount: number` (0..1)
|
||||
- `readonly lowPassCutoff: number` (20..20 000 Hz)
|
||||
- `readonly reverbAmount: number` (0..1)
|
||||
- `GLaDOSPluginOptions`
|
||||
- `readonly preset?: GLaDOSPreset`
|
||||
- `readonly config?: Partial<GLaDOSConfig>`
|
||||
- `GLaDOSPresetData`
|
||||
- `readonly preset: GLaDOSPreset`
|
||||
- `readonly config: GLaDOSConfig`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`GLaDOSPresetData` encodes both the preset name *and* the live config because the user can apply a preset and then override individual stages — restoring the preset name alone would lose those overrides. Storing both keeps restore deterministic. The pattern matches the audio-analyzer plugin's `AudioAnalyzerPresetData` which encodes both the preset name and the toggle state for the same reason.
|
||||
|
||||
`GLaDOSPluginOptions.config` is a `Partial<GLaDOSConfig>` overlay applied on top of the selected preset (or the default config when no preset is given). This lets callers express things like "start with the test-chamber preset but with extra reverb" in a single options object without two function calls.
|
||||
@@ -0,0 +1,76 @@
|
||||
# `glados`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GLaDOSPlugin` is the universal voice-effect insert. It runs the input signal through a fixed six-stage chain — granular pitch shift → formant tilt → robotic-intensity tremolo → soft-clip distortion → low-pass filter → short feedback reverb — and exposes a typed setter for each stage plus six named presets. The plugin is an insert (`io.inputs[0].count === io.outputs[0].count === "preserve"`), so it accepts any input channel count and emits the same shape.
|
||||
|
||||
The plugin extends `AbstractAudioFxPlugin`, runs identically across browser / server / worklet renderers, and validates every setter at the boundary: out-of-range values throw `AUDIO_FX.INVALID_OPTIONS` with the offending value attached. Six built-in presets cover the GLaDOS family (mainline, test chamber, cake deception) plus Wheatley, Cave Johnson, and a generic robotic voice.
|
||||
|
||||
## Features
|
||||
|
||||
- Six chained stages, each with a typed setter: `setPitchShift`, `setFormantShift`, `setRoboticIntensity`, `setDistortionAmount`, `setLowPassCutoff`, `setReverbAmount`.
|
||||
- Six built-in presets (`glados-mainline`, `glados-test-chamber`, `glados-cake-deception`, `wheatley`, `cave-johnson`, `robotic-generic`) selected via the construction `preset` option or `applyGladosPreset(name)`.
|
||||
- `getGLaDOSPresets()` exposes the read-only preset table so editors can render preset dropdowns and preview the values.
|
||||
- Per-stage range validation: `pitchShift`/`formantShift` ∈ [-12, 12] semitones, `roboticIntensity`/`distortionAmount`/`reverbAmount` ∈ [0, 1], `lowPassCutoff` ∈ [20, 20 000] Hz.
|
||||
- `applyGladosPreset` is named distinctly from `AbstractAudioFxPlugin.applyPreset(snapshot)` so the two methods do not collide.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { GLaDOSPlugin } from "@suaveplan/audio-fx-plugin-glados";
|
||||
|
||||
const glados = new GLaDOSPlugin({ preset: "glados-mainline" });
|
||||
chain.add(glados);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { GLaDOSPlugin, getGLaDOSPresets } from "@suaveplan/audio-fx-plugin-glados";
|
||||
|
||||
const presetNames = Object.keys(getGLaDOSPresets()) as ReadonlyArray<
|
||||
keyof ReturnType<typeof getGLaDOSPresets>
|
||||
>;
|
||||
const voice = new GLaDOSPlugin({ preset: "wheatley" });
|
||||
await voice.setup(
|
||||
{ sampleRate: 48_000, channels: 2, blockSize: 512 },
|
||||
ctx
|
||||
);
|
||||
|
||||
// Customise the wheatley preset with extra reverb.
|
||||
voice.setReverbAmount(0.4);
|
||||
voice.setLowPassCutoff(5_500);
|
||||
|
||||
// Preview every preset on the same line of dialogue.
|
||||
for (const name of presetNames) {
|
||||
voice.applyGladosPreset(name);
|
||||
await player.playDemo();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GLaDOSPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new GLaDOSPlugin(options?: GLaDOSPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- `applyGladosPreset(name: GLaDOSPreset): void`, `getCurrentPreset(): GLaDOSPreset`, `getGladosConfig(): GLaDOSConfig`.
|
||||
- `setPitchShift(-12..12)`, `setFormantShift(-12..12)`, `setRoboticIntensity(0..1)`, `setDistortionAmount(0..1)`, `setLowPassCutoff(20..20000)`, `setReverbAmount(0..1)` — each throws `AUDIO_FX.INVALID_OPTIONS` on out-of-range.
|
||||
- `getGLaDOSPresets()` — read-only view of the six built-in presets.
|
||||
- Types: `GLaDOSPreset` literal union, `GLaDOSConfig`, `GLaDOSPluginOptions`, `GLaDOSPresetData`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The pitch-shift stage is a per-channel granular ring buffer with linear-interpolated fractional read — the same shape used by `PitchShifterPlugin` but with a smaller window because vocal content does not need the larger windows that drum or instrumental material benefits from. For pitch shifts within the GLaDOS-typical range (-5 to +5 semitones) this produces an audibly clean result. Going further out is supported but the perceptual artefacts increase.
|
||||
|
||||
The formant-tilt stage is a simple gain shift (`10^(formantShift / 8)`) rather than a true formant analyzer. A real formant-shift implementation needs spectral analysis + cepstrum manipulation, which is much heavier and would not measurably improve the GLaDOS character. The simple gain matches what most game-audio implementations use for "robotic voice" effects.
|
||||
|
||||
The robotic-intensity stage is a 30 Hz tremolo modulating amplitude — fast enough to read as "robotic clicking" rather than "vibrato". The depth is mapped linearly from the `roboticIntensity` knob so the user-facing range `[0, 1]` translates to `0` (no tremolo) through `1` (full chopping). The 30 Hz rate is faster than typical tremolo because that's what makes the voice sound mechanical rather than emotional.
|
||||
|
||||
The distortion stage is a `tanh` soft-clip with parallel dry/wet routing — same shape as the `DistortionPlugin`'s waveshaper mode but with a fixed drive that scales with the user's `distortionAmount` knob.
|
||||
|
||||
The low-pass is a one-pole filter (the same shape used by `MicrophoneSimulationPlugin`), and the reverb is a short feedback delay (`REVERB_DELAY_MS = 30 ms` per channel). Both are intentionally simpler than their dedicated sibling-plugin counterparts because the GLaDOS character does not need surgical control — the user wants "more or less" of each colour, not a fully parametric filter and convolution.
|
||||
|
||||
Preset application replaces the *entire* config in one shot via `{ ...PRESETS[preset] }`. Individual setter overrides applied after `applyGladosPreset` will stick; the next preset application replaces them. The preset payload encodes both the preset name and the live config so `applyPreset(snap)` restores any per-stage overrides made after the preset was originally applied.
|
||||
@@ -0,0 +1,60 @@
|
||||
# `glados/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options, stage configuration, preset enum, and preset payload of `GLaDOSPlugin`. 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
|
||||
|
||||
- `GLaDOSPresetSchema` — `z.enum([...six preset names])` reused in options + preset payload.
|
||||
- `GLaDOSConfigSchema` — validates the six stage values with their documented ranges.
|
||||
- `GLaDOSPluginOptionsSchema` — `preset?: enum, config?: Partial<GLaDOSConfig>` (via `.partial()`).
|
||||
- `GLaDOSPresetDataSchema` — `.strict()` object carrying the preset name + full config.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { GLaDOSConfigSchema } from "@suaveplan/audio-fx-plugin-glados";
|
||||
|
||||
const parsed = GLaDOSConfigSchema.partial().parse({
|
||||
pitchShift: -3,
|
||||
roboticIntensity: 0.6,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
GLaDOSPresetDataSchema,
|
||||
GLaDOSPresetSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-glados";
|
||||
|
||||
// Render a dropdown of presets from the schema's options.
|
||||
const names = GLaDOSPresetSchema.options;
|
||||
for (const name of names) ui.addOption(name);
|
||||
|
||||
// Strict preset validation at the IPC boundary.
|
||||
function isGladosPreset(blob: unknown): boolean {
|
||||
return GLaDOSPresetDataSchema.safeParse(blob).success;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GLaDOSPresetSchema: z.ZodEnum<[...six]>`
|
||||
- `GLaDOSConfigSchema: z.ZodObject<{ pitchShift, formantShift, roboticIntensity, distortionAmount, lowPassCutoff, reverbAmount }>`
|
||||
- `GLaDOSPluginOptionsSchema: z.ZodObject<{ preset?, config? }>`
|
||||
- `GLaDOSPresetDataSchema: z.ZodObject<{ preset, config }, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`GLaDOSPresetSchema` is exported as a named schema (not inlined) so the same value is reused by `GLaDOSPluginOptionsSchema.preset` and `GLaDOSPresetDataSchema.preset`. That guarantees both validation paths agree byte-for-byte on the legal preset set — adding a seventh preset is a one-line change in `schemas.ts`.
|
||||
|
||||
`GLaDOSPresetDataSchema` is `.strict()` so unknown fields are rejected — matching the sibling-plugin policy. The preset payload carries both the preset name and the live config; storing only the name would lose per-stage overrides made after the preset was originally applied. The schema validates the full config so a forwards-incompatible payload is caught at the boundary.
|
||||
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-limiter - limiter plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
limiter 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
|
||||
|
||||
- [limiter](./src/limiter/limiter.md) — `LimiterPlugin` is the universal brick-wall limiter. Its core contract is unconditional: for any bounded input, no outp…
|
||||
- [limiter](./src/limiter/limiter.md) — `LimiterPlugin` is the universal brick-wall limiter. Its core contract is unconditional: for any bounded input, no outp…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options of `LimiterPlugin` and the (currently empty) preset payload contra…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options of `LimiterPlugin` and the (currently empty) preset payload contra…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-limiter";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./limiter` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,51 @@
|
||||
# `limiter.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `LimiterPlugin`'s construction options. Co-located with the implementation so the public type surface (the `LimiterPluginOptions` interface) and its Zod validator stay in lockstep. Consumers can import the type for typed form state, dependency-injection wiring, or storybook controls without pulling in the plugin's runtime code.
|
||||
|
||||
This module declares no runtime values — it is the canonical "shape" contract the constructor accepts, mirrored by `LimiterPluginOptionsSchema` in `schemas/schemas.ts`.
|
||||
|
||||
## Features
|
||||
|
||||
- `LimiterPluginOptions` — all fields optional so the default constructor (`new LimiterPlugin()`) is valid.
|
||||
- `readonly` properties — emphasises that the options object is consumed once at construction and never mutated.
|
||||
- Numeric ranges are documented here via the Zod schema (not encoded in TS types) so a single source of truth governs validation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { LimiterPluginOptions } from "@suaveplan/audio-fx-plugin-limiter";
|
||||
|
||||
const opts: LimiterPluginOptions = { initialCeilingDb: -1 };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { LimiterPluginOptions } from "@suaveplan/audio-fx-plugin-limiter";
|
||||
|
||||
// Use the options type as a state shape for a UI form.
|
||||
interface LimiterFormState extends Required<LimiterPluginOptions> {
|
||||
readonly dirty: boolean;
|
||||
}
|
||||
|
||||
function saveLimiterOptions(state: LimiterFormState): LimiterPluginOptions {
|
||||
return {
|
||||
initialCeilingDb: state.initialCeilingDb,
|
||||
initialReleaseMs: state.initialReleaseMs,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `LimiterPluginOptions`
|
||||
- `readonly initialCeilingDb?: number` — initial brick-wall ceiling in dB.
|
||||
- `readonly initialReleaseMs?: number` — initial release time constant in ms.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The options interface deliberately omits a `gainReductionDb` field. That parameter is a telemetry output, not a configurable input — it is always derived from the per-block worst-case attenuation and would not be meaningful as a construction-time value. Constraining the options surface to inputs prevents callers from creating plugins in an impossible "pre-set" telemetry state.
|
||||
|
||||
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, including in long-lived editor stores.
|
||||
@@ -0,0 +1,70 @@
|
||||
# `limiter`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`LimiterPlugin` is the universal brick-wall limiter. Its core contract is unconditional: for any bounded input, no output sample's absolute value exceeds the linear conversion of the `ceiling` parameter. The plugin pairs an instantaneous (zero-lookahead) attack with a one-pole exponential release, so transients are clamped on the sample they occur — no lookahead buffer, no group delay, no latency to report.
|
||||
|
||||
The plugin extends `AbstractAudioFxPlugin`, so it runs identically across browser / server / worklet renderers. State is a single linked gain value plus a frame-peak reduction metric for telemetry. `reset()` snaps the gain back to unity in place.
|
||||
|
||||
## Features
|
||||
|
||||
- Three parameters: `ceiling` (dB, -60..0), `release` (ms, 1..1000), and `gainReductionDb` (telemetry).
|
||||
- Linked-stereo brick-wall: gain is shared across all input channels per sample, so a transient on one channel attenuates every channel identically — the stereo image stays phase-coherent.
|
||||
- Zero-lookahead attack: when `|peak| > ceilingLin` at sample `i`, the gain at sample `i` is set to `ceilingLin / |peak|` *before* writing the output, so the ceiling is a hard contract verifiable by audit.
|
||||
- One-pole release: `gain += releaseCoef · (1 − gain)` per sample with `releaseCoef = 1 − exp(-1/(τ · sr))`.
|
||||
- `gainReductionDb` is updated once per block with the worst-case in-block reduction so meters get a useful peak reading without per-sample state.
|
||||
- `getGainReductionDb()` accessor mirrors the parameter for low-friction metering.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { LimiterPlugin } from "@suaveplan/audio-fx-plugin-limiter";
|
||||
|
||||
const limiter = new LimiterPlugin({
|
||||
initialCeilingDb: -1,
|
||||
initialReleaseMs: 50,
|
||||
});
|
||||
chain.add(limiter);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { LimiterPlugin } from "@suaveplan/audio-fx-plugin-limiter";
|
||||
|
||||
// Master-bus limiter feeding a UI meter from the live gain reduction.
|
||||
const limiter = new LimiterPlugin({
|
||||
initialCeilingDb: -0.3,
|
||||
initialReleaseMs: 80,
|
||||
});
|
||||
await limiter.setup(
|
||||
{ sampleRate: 48_000, channels: 2, blockSize: 512 },
|
||||
ctx
|
||||
);
|
||||
|
||||
setInterval(() => {
|
||||
const gr = limiter.getGainReductionDb();
|
||||
if (gr > 6) {
|
||||
console.warn(`Master limiter pulling ${gr.toFixed(2)} dB`);
|
||||
}
|
||||
}, 100);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `LimiterPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new LimiterPlugin(options?: LimiterPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- `getGainReductionDb(): number` — last block's peak gain reduction (positive number, in dB).
|
||||
- Inherited parameter accessors for `ceiling`, `release`, `gainReductionDb`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The limiter is intentionally a true brick-wall, not a "soft" or "transparent" limiter. We do not interpolate around the threshold or apply a knee. The reason: many use sites layer this plugin downstream of an output-stage compressor that already provides knee shaping, and rely on the limiter for guaranteed bound semantics. Adding a knee would weaken the contract that the test in `output peak never exceeds the configured ceiling` enforces.
|
||||
|
||||
There is no lookahead buffer. That keeps the plugin latency-free (`latencySamples: 0`, inherited default) and makes the bound testable by static inspection of the inner loop: at sample `i`, if `peak > ceilingLin` then `gain := ceilingLin / peak` *before* `output[ch][i] = input[ch][i] * gain`. The multiplication therefore can produce at most `|input[ch][i]| * (ceilingLin / |peak|) ≤ ceilingLin`. The downside is that an unusually fast transient (single-sample spike at a very low source sample rate) can produce visible distortion on its leading edge — for those cases a future `LookaheadLimiterPlugin` can sit on a separate subpath without breaking this contract.
|
||||
|
||||
`gainReductionDb` is reported as a per-block peak rather than a running average. Meters typically expect peak; if a future surface wants RMS, it can compute that from the gain-reduction stream by subscribing to a parameter automation channel. The release coefficient is recomputed every block from the current `release` parameter so automation works without introducing zipper noise — the one-pole step is small per sample even when the parameter changes mid-block.
|
||||
@@ -0,0 +1,48 @@
|
||||
# `limiter/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options of `LimiterPlugin` and the (currently empty) preset payload contract. 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, editors, or RPC validation) without importing the audio implementation.
|
||||
|
||||
## Features
|
||||
|
||||
- `LimiterPluginOptionsSchema` — validates `initialCeilingDb` ∈ [-60, 0] and `initialReleaseMs` ∈ [1, 1000].
|
||||
- `LimiterPresetDataSchema` — empty strict object: the limiter has no plugin-specific preset payload, so any extra keys are rejected by `.strict()`.
|
||||
- Both schemas are `z.object` / `z.number().finite()` based so `NaN` and `Infinity` cannot sneak through.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { LimiterPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-limiter";
|
||||
|
||||
const parsed = LimiterPluginOptionsSchema.parse({ initialCeilingDb: -3 });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { LimiterPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-limiter";
|
||||
|
||||
// Drive an editor form from the schema's introspected shape.
|
||||
const fields = LimiterPluginOptionsSchema.keyof().options;
|
||||
for (const field of fields) {
|
||||
form.addRow(field);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `LimiterPluginOptionsSchema: z.ZodObject<{ initialCeilingDb?, initialReleaseMs? }>`
|
||||
- `LimiterPresetDataSchema: z.ZodObject<{}, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The constructor in `limiter.ts` calls `LimiterPluginOptionsSchema.safeParse(options)` and throws `AudioFxError(INVALID_OPTIONS, ...)` on failure with the Zod `issues` array attached as `context`. This keeps the surface uniform with the other plugins (eq/compressor/reverb/filter/delay/gate) and makes log triage easy: a single grep for `audio-fx.limiter-invalid-options` finds every malformed-options site.
|
||||
|
||||
`LimiterPresetDataSchema` is `.strict()` on purpose. The limiter has no preset-specific payload today, but `applyPreset` from `AbstractAudioFxPlugin` will still call the schema's `parse`. Using `.strict()` rejects accidentally-mixed payloads from sibling plugins (e.g., a `CompressorPresetData` blob applied to a `LimiterPlugin`), surfacing the mistake at the boundary rather than silently no-op'ing parameter restoration.
|
||||
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-microphone - microphone plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
microphone 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
|
||||
|
||||
- [microphone](./src/microphone/microphone.md) — `MicrophoneSimulationPlugin` is the universal microphone-simulation source plugin. It synthesises a microphone-coloured…
|
||||
- [microphone](./src/microphone/microphone.md) — `MicrophoneSimulationPlugin` is the universal microphone-simulation source plugin. It synthesises a microphone-coloured…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, the eight control groups, the full configuration tree, and the pr…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, the eight control groups, the full configuration tree, and the pr…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-microphone";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./microphone` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,70 @@
|
||||
# `microphone.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `MicrophoneSimulationPlugin`'s control groups, configuration tree, preset payload, and UI section descriptor. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`. The control-group split (FrequencyControls, ResonanceControls, …) mirrors the main-branch `microphone.types.ts` so a UI built against either codebase can render the other.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight `*Controls` interfaces, one per microphone signal stage.
|
||||
- `MicrophoneConfig` aggregates them under named sections; consumers can pass `Partial<MicrophoneConfig>` to merge with current state.
|
||||
- `MicrophoneSimulationPluginOptions` is a thin shell carrying the partial config.
|
||||
- `MicrophonePresetData` carries the full live config so a preset restores every section.
|
||||
- `MicrophoneUIControl` / `MicrophoneUISection` describe the UI without prescribing a renderer.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
MicrophoneConfig,
|
||||
MicrophoneSimulationPluginOptions,
|
||||
} from "@suaveplan/audio-fx-plugin-microphone";
|
||||
|
||||
const opts: MicrophoneSimulationPluginOptions = {
|
||||
config: { noise: { level: 0.1, color: "pink" } },
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
MicrophoneConfig,
|
||||
MicrophoneUISection,
|
||||
} from "@suaveplan/audio-fx-plugin-microphone";
|
||||
|
||||
function indexControlsById(
|
||||
sections: ReadonlyArray<MicrophoneUISection>
|
||||
): Record<string, MicrophoneUISection["controls"][number]> {
|
||||
const out: Record<string, MicrophoneUISection["controls"][number]> = {};
|
||||
for (const section of sections) {
|
||||
for (const control of section.controls) {
|
||||
out[control.id] = control;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function presetFrom(config: MicrophoneConfig): MicrophoneConfig {
|
||||
return { ...config };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FrequencyControls`, `ResonanceControls`, `DistortionControls`, `NoiseControls`, `DynamicsControls`, `GainControls`, `CharacterControls`, `WowAndFlutterControls` — one `readonly`-fielded interface per signal stage.
|
||||
- `MicrophoneConfig` — the eight control groups composed into one object.
|
||||
- `MicrophoneSimulationPluginOptions` — `{ config?: Partial<MicrophoneConfig> }`.
|
||||
- `MicrophonePresetData` — `{ config: MicrophoneConfig }`.
|
||||
- `MicrophoneUIControl` — `{ id, label, min, max, step, unit, group }`.
|
||||
- `MicrophoneUISection` — `{ id, label, controls: ReadonlyArray<MicrophoneUIControl> }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The eight `*Controls` interfaces are kept distinct (not collapsed into one flat record) because:
|
||||
|
||||
1. UI code can iterate the sections rather than the flat field list, producing a grouped form for free.
|
||||
2. `Partial<MicrophoneConfig>` provides per-section partials; consumers can pass `setMicrophoneConfig({ noise: { level: 0.7 } })` without restating other fields.
|
||||
3. The Zod schema for each section is reusable in RPC contracts that target one section at a time.
|
||||
|
||||
`MicrophoneUIControl.group` is typed as `keyof MicrophoneConfig` (a string literal union over the eight section names) rather than a freeform string so the type system enforces consistency: adding a section requires updating the interface name, the schema, and the UI descriptor in lockstep, all caught at compile time.
|
||||
@@ -0,0 +1,89 @@
|
||||
# `microphone`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`MicrophoneSimulationPlugin` is the universal microphone-simulation source plugin. It synthesises a microphone-coloured signal from a deterministic noise generator (selectable white or pink) passed through a one-pole low-pass filter, an input/output gain stage, optional `tanh`-soft-clip distortion, a "warmth + presence" tonal stage, and wow/flutter amplitude modulation. The plugin is a source (`io.inputs[0].count === 0`, `io.outputs[0].count === 1`).
|
||||
|
||||
The configuration is grouped into eight control families (frequency, resonance, distortion, noise, dynamics, gain, character, wowFlutter); each family is defined as a structured interface so editors can render the UI by iterating `getUISections()`. The plugin extends `AbstractAudioFxPlugin` and runs identically across browser / server / worklet renderers.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight control groups (`MicrophoneConfig`): `frequency`, `resonance`, `distortion`, `noise`, `dynamics`, `gain`, `character`, `wowFlutter`. Each is a typed `*Controls` interface with a Zod schema and a UI section descriptor.
|
||||
- One automatable parameter: `outputGain` (0..4) for top-of-chain level.
|
||||
- `getUISections()` returns a `ReadonlyArray<MicrophoneUISection>` with stable `id`s, min/max/step/unit/group metadata, so editors can render the full surface without hard-coding any of it.
|
||||
- Deterministic noise generator (xorshift32 RNG); `reset()` re-seeds so two reset-then-process runs produce byte-identical output — useful for regression testing.
|
||||
- `createMicrophonePlugin(options)` factory mirrors the main-branch surface for migration parity.
|
||||
- Pink-noise mode uses Paul Kellet's three-pole approximation, the same one the main-branch package ships, so the spectral colour matches.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MicrophoneSimulationPlugin } from "@suaveplan/audio-fx-plugin-microphone";
|
||||
|
||||
const mic = new MicrophoneSimulationPlugin();
|
||||
chain.add(mic);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MicrophoneSimulationPlugin,
|
||||
createMicrophonePlugin,
|
||||
} from "@suaveplan/audio-fx-plugin-microphone";
|
||||
|
||||
const lofi = createMicrophonePlugin({
|
||||
config: {
|
||||
frequency: { cutoffHz: 3_500, tiltDb: 3 },
|
||||
noise: { level: 0.2, color: "pink" },
|
||||
distortion: { drive: 0.6, mix: 0.4 },
|
||||
wowFlutter: {
|
||||
wowRateHz: 1.2,
|
||||
wowDepth: 0.3,
|
||||
flutterRateHz: 10,
|
||||
flutterDepth: 0.15,
|
||||
},
|
||||
},
|
||||
});
|
||||
await lofi.setup(
|
||||
{ sampleRate: 48_000, channels: 1, blockSize: 512 },
|
||||
ctx
|
||||
);
|
||||
|
||||
// Drive a UI form from the section descriptor.
|
||||
for (const section of lofi.getUISections()) {
|
||||
form.addSection(section.id, section.label);
|
||||
for (const control of section.controls) {
|
||||
form.addControl(control);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MicrophoneSimulationPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new MicrophoneSimulationPlugin(options?: MicrophoneSimulationPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- `getMicrophoneConfig(): MicrophoneConfig`
|
||||
- `setMicrophoneConfig(partial: Partial<MicrophoneConfig>): void` — merge-updates the live config; missing keys stay at their current values.
|
||||
- `getUISections(): ReadonlyArray<MicrophoneUISection>`
|
||||
- Inherited accessor for `outputGain`.
|
||||
- `createMicrophonePlugin(options?)` — factory equivalent.
|
||||
- Types: `FrequencyControls`, `ResonanceControls`, `DistortionControls`, `NoiseControls`, `DynamicsControls`, `GainControls`, `CharacterControls`, `WowAndFlutterControls`, `MicrophoneConfig`, `MicrophoneUIControl`, `MicrophoneUISection`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The noise generator is xorshift32 seeded with a fixed constant (`0x12345678`). Determinism matters for testing: the "reset re-seeds" test asserts that two runs after `reset()` produce byte-identical output. A `Math.random()`-based generator would fail that test on every invocation because the RNG state would advance between runs. The xorshift32 output is mapped to `[-1, 1]` via the standard `(u32 / 0xffffffff) * 2 - 1` formula.
|
||||
|
||||
Pink-noise mode uses Paul Kellet's three-pole approximation rather than a true 1/f filter because the latter requires a much higher-order filter to be accurate and would add per-sample CPU cost out of proportion with the perceptual improvement. The three-pole shape is the same as the main-branch implementation, so a side-by-side spectral comparison passes within a couple of dB.
|
||||
|
||||
The filter is a one-pole low-pass (`y = y_prev + α · (x - y_prev)`, with `α = ω / (ω + 1)`), not a biquad. The single-pole shape is what consumer microphones colour the signal with — biquad-class shaping is the territory of the `FilterPlugin` and `EQPlugin` siblings, which a caller can chain downstream. Cutoff is clamped to `[20, 0.45·sr]` so the bilinear transform stays stable across the full range.
|
||||
|
||||
Wow and flutter use two independent sine LFOs modulating the output gain. The wow rate is around 0.6 Hz by default (slow tape drift); flutter is around 8 Hz (fast tape wobble). Both depths are scaled by small constants (0.1 and 0.05 respectively) so the user-facing range `[0, 1]` translates to ±10% and ±5% amplitude variation — perceptually-sensible defaults rather than mathematically-extreme ones.
|
||||
|
||||
`MicrophoneConfig` is consumed as a tree of `Partial<*Controls>` so callers can update only the field they care about. `setMicrophoneConfig` merges with the current state per-section, so a `setMicrophoneConfig({ noise: { level: 0.7 } })` call does **not** require respecifying `color` — the previous value is preserved.
|
||||
|
||||
The plugin declares only one parameter (`outputGain`); every other control lives on the `MicrophoneConfig` tree because they are configuration, not real-time automation. If a future revision wants to automate a specific control (e.g., automate `cutoffHz`), promoting it to a parameter is a one-line change that does not break the section descriptor (which would still list it under `frequency`).
|
||||
@@ -0,0 +1,68 @@
|
||||
# `microphone/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options, the eight control groups, the full configuration tree, and the preset payload of `MicrophoneSimulationPlugin`. 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
|
||||
|
||||
- Eight per-section schemas: `FrequencyControlsSchema`, `ResonanceControlsSchema`, `DistortionControlsSchema`, `NoiseControlsSchema`, `DynamicsControlsSchema`, `GainControlsSchema`, `CharacterControlsSchema`, `WowAndFlutterControlsSchema`. Each enforces its documented range with `z.number().finite()` to block `NaN`/`Infinity`.
|
||||
- `MicrophoneConfigSchema` composes the eight sections into one object.
|
||||
- `MicrophoneSimulationPluginOptionsSchema` wraps `MicrophoneConfigSchema.partial()` so callers can pass any subset of fields.
|
||||
- `MicrophonePresetDataSchema` is `.strict()` and demands the full `MicrophoneConfig` payload.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MicrophoneConfigSchema } from "@suaveplan/audio-fx-plugin-microphone";
|
||||
|
||||
const parsed = MicrophoneConfigSchema.partial().parse({
|
||||
noise: { level: 0.05, color: "white" },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MicrophoneConfigSchema,
|
||||
NoiseControlsSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-microphone";
|
||||
|
||||
// Validate a section in isolation (useful for an RPC that updates only one
|
||||
// part of the mic state).
|
||||
function setNoise(plugin: MicrophoneSimulationPlugin, raw: unknown) {
|
||||
const noise = NoiseControlsSchema.parse(raw);
|
||||
plugin.setMicrophoneConfig({ noise });
|
||||
}
|
||||
|
||||
const partial = MicrophoneConfigSchema.partial().parse({
|
||||
frequency: { cutoffHz: 4_000, tiltDb: 0 },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FrequencyControlsSchema: z.ZodObject<{ cutoffHz, tiltDb }>`
|
||||
- `ResonanceControlsSchema: z.ZodObject<{ q }>`
|
||||
- `DistortionControlsSchema: z.ZodObject<{ drive, mix }>`
|
||||
- `NoiseControlsSchema: z.ZodObject<{ level, color }>`
|
||||
- `DynamicsControlsSchema: z.ZodObject<{ thresholdDb, ratio }>`
|
||||
- `GainControlsSchema: z.ZodObject<{ inputDb, outputDb }>`
|
||||
- `CharacterControlsSchema: z.ZodObject<{ warmth, presence }>`
|
||||
- `WowAndFlutterControlsSchema: z.ZodObject<{ wowRateHz, wowDepth, flutterRateHz, flutterDepth }>`
|
||||
- `MicrophoneConfigSchema: z.ZodObject<…>`
|
||||
- `MicrophoneSimulationPluginOptionsSchema: z.ZodObject<{ config? }>`
|
||||
- `MicrophonePresetDataSchema: z.ZodObject<{ config }, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The per-section schemas are exported as named values rather than inlined into `MicrophoneConfigSchema` so they can be reused by callers that want to validate a single section at a time — for example, an RPC endpoint that updates only the noise floor. The combined `MicrophoneConfigSchema` is built by composing the section schemas, so adding a field to a section is a single-place change.
|
||||
|
||||
`MicrophonePresetDataSchema` demands the full `MicrophoneConfig` (not a partial). Presets restore the entire microphone state in one shot; allowing a partial would leave the restored plugin in an indeterminate state where some sections came from the preset and others from whatever happened to be loaded. The `.strict()` modifier additionally rejects unknown fields so a forwards-incompatible preset payload is caught at the boundary rather than silently dropping unknown keys.
|
||||
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-mixer - mixer plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
mixer 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
|
||||
|
||||
- [mixer](./src/mixer/mixer.md) — `MixerPlugin` is the universal multi-channel summing mixer. Each input channel is one channel strip with its own gain,…
|
||||
- [mixer](./src/mixer/mixer.md) — `MixerPlugin` is the universal multi-channel summing mixer. Each input channel is one channel strip with its own gain,…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, channel strips, and preset payload of `MixerPlugin`. These run on…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, channel strips, and preset payload of `MixerPlugin`. These run on…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-mixer";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./mixer` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,85 @@
|
||||
# `mixer.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `MixerPlugin`'s channel strips, construction options, preset payload, and VU metering snapshot. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`.
|
||||
|
||||
## Features
|
||||
|
||||
- `MixerChannel` — a live channel strip: id, gain (0..4), pan (-1..1), sends record.
|
||||
- `MixerChannelOptions` — construction-time half-twin of `MixerChannel` with optional initial values.
|
||||
- `MixerPluginOptions` — top-level construction options with `channels: ReadonlyArray<MixerChannelOptions>` and optional `initialMasterGain`.
|
||||
- `MixerPresetData` — preset payload carrying the full live channel strips.
|
||||
- `MixerVuSnapshot` — `{ channels: Record<id, rms>; master: rms }` per-block metering view.
|
||||
- All record types are `Readonly` so the consumer cannot mutate the snapshot returned by `getVuLevels()` or `getChannels()`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
MixerPluginOptions,
|
||||
} from "@suaveplan/audio-fx-plugin-mixer";
|
||||
|
||||
const opts: MixerPluginOptions = {
|
||||
channels: [
|
||||
{ id: "drums", initialGain: 0.8 },
|
||||
{ id: "vocal", initialGain: 0.9, initialPan: 0.1 },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
MixerChannel,
|
||||
MixerPluginOptions,
|
||||
MixerVuSnapshot,
|
||||
} from "@suaveplan/audio-fx-plugin-mixer";
|
||||
|
||||
function loudest(snapshot: MixerVuSnapshot): string | null {
|
||||
let best: { id: string; rms: number } | null = null;
|
||||
for (const [id, rms] of Object.entries(snapshot.channels)) {
|
||||
if (best === null || rms > best.rms) best = { id, rms };
|
||||
}
|
||||
return best?.id ?? null;
|
||||
}
|
||||
|
||||
function asPreset(channels: ReadonlyArray<MixerChannel>): MixerPluginOptions {
|
||||
return {
|
||||
channels: channels.map((c) => ({
|
||||
id: c.id,
|
||||
initialGain: c.gain,
|
||||
initialPan: c.pan,
|
||||
initialSends: c.sends,
|
||||
})),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MixerChannel`
|
||||
- `readonly id: string`
|
||||
- `readonly gain: number` (0..4)
|
||||
- `readonly pan: number` (-1..1)
|
||||
- `readonly sends: Readonly<Record<string, number>>`
|
||||
- `MixerChannelOptions`
|
||||
- `readonly id: string`
|
||||
- `readonly initialGain?: number`
|
||||
- `readonly initialPan?: number`
|
||||
- `readonly initialSends?: Readonly<Record<string, number>>`
|
||||
- `MixerPluginOptions`
|
||||
- `readonly channels: ReadonlyArray<MixerChannelOptions>`
|
||||
- `readonly initialMasterGain?: number`
|
||||
- `MixerPresetData`
|
||||
- `readonly channels: ReadonlyArray<MixerChannel>`
|
||||
- `MixerVuSnapshot`
|
||||
- `readonly channels: Readonly<Record<string, number>>`
|
||||
- `readonly master: number`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`MixerChannelOptions` mirrors `MixerChannel` with `initial*` prefixes on the mutable fields. The pattern matches the rest of this package — every plugin uses `initialX` for construction-time hydration of a runtime-mutable value. Keeping the two types distinct lets the constructor's Zod schema validate the boundary input without forcing callers to specify defaults for every field.
|
||||
|
||||
`MixerVuSnapshot.channels` is a `Record<string, number>` rather than an array because consumers typically look up by id. Returning an array would force a lookup-by-position contract that breaks as soon as the user reorders channels. The downside is that `Record` enumeration order is not guaranteed across runtimes; UI code that wants stable ordering should iterate over `mixer.getChannels()` and index into the snapshot map by id.
|
||||
@@ -0,0 +1,84 @@
|
||||
# `mixer`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`MixerPlugin` is the universal multi-channel summing mixer. Each input channel is one channel strip with its own gain, pan, and named sends; the panned outputs are summed into a stereo master bus. The output sum is internally unclipped — a caller wanting hard-cap behaviour chains a `LimiterPlugin` downstream. Per-channel and master VU levels are exposed via `getVuLevels()` so a UI can drive meters from a single source of truth.
|
||||
|
||||
The plugin extends `AbstractAudioFxPlugin` and runs identically across browser, server, offline, and worklet renderers. Channels live as plain configuration on the plugin (not as parameters) and survive preset round-trip via `MixerPresetData`.
|
||||
|
||||
## Features
|
||||
|
||||
- `MixerChannel[]` configured at construction with per-channel `id`, `initialGain`, `initialPan`, and `initialSends`. Up to 64 channels per instance.
|
||||
- One automatable parameter: `masterGain` (0..4, default 1).
|
||||
- `setChannelGain(id, gain)`, `setChannelPan(id, pan)`, `setSend(channelId, busId, amount)` — mutate channel strips at runtime. Unknown ids throw `AUDIO_FX.CHAIN_SLOT_NOT_FOUND`.
|
||||
- Equal-power per-channel panning: `angle = (pan + 1) · π / 4`, `leftGain = cos(angle) · gain`, `rightGain = sin(angle) · gain`. Centre pan splits equally to `±√½`.
|
||||
- Sum into a fixed-count stereo output (`io.outputs[0].count === 2`) regardless of input channel count.
|
||||
- `getVuLevels()` returns per-channel RMS and master RMS over the most recent block.
|
||||
- Sends are stored on the channel strip and persisted in presets. The universal block-path does not route sends through buses — the browser engine wires them up via `createAudioNode()` in a future revision.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MixerPlugin } from "@suaveplan/audio-fx-plugin-mixer";
|
||||
|
||||
const mixer = new MixerPlugin({
|
||||
channels: [
|
||||
{ id: "drums", initialGain: 0.8, initialPan: 0 },
|
||||
{ id: "bass", initialGain: 0.7, initialPan: -0.1 },
|
||||
{ id: "lead", initialGain: 0.6, initialPan: 0.2 },
|
||||
],
|
||||
});
|
||||
chain.add(mixer);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MixerPlugin } from "@suaveplan/audio-fx-plugin-mixer";
|
||||
|
||||
const mixer = new MixerPlugin({
|
||||
channels: [
|
||||
{ id: "vocal" },
|
||||
{ id: "guitar" },
|
||||
{ id: "drums" },
|
||||
],
|
||||
initialMasterGain: 0.9,
|
||||
});
|
||||
await mixer.setup(
|
||||
{ sampleRate: 48_000, channels: 3, blockSize: 512 },
|
||||
ctx
|
||||
);
|
||||
|
||||
// Route vocal to a reverb send and a delay send.
|
||||
mixer.setSend("vocal", "reverb", 0.4);
|
||||
mixer.setSend("vocal", "delay", 0.2);
|
||||
|
||||
// Live VU metering at ~30 fps.
|
||||
setInterval(() => {
|
||||
const vu = mixer.getVuLevels();
|
||||
meter.draw(vu);
|
||||
}, 33);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MixerPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new MixerPlugin(options: MixerPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options or duplicate channel ids.
|
||||
- `getChannels(): ReadonlyArray<MixerChannel>` — current channel strips.
|
||||
- `setChannelGain(id, gain)`, `setChannelPan(id, pan)`, `setSend(channelId, busId, amount)` — mutate channel strips.
|
||||
- `getVuLevels(): MixerVuSnapshot` — per-channel RMS + master RMS for the most recent block.
|
||||
- Inherited accessor for `masterGain`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The mixer declares `io.outputs[0].count === 2` (fixed stereo) rather than `"preserve"` because the spec calls for "a master bus" — a single output target — and downstream plugins should be able to assume a stereo bus regardless of how many input channels feed in. `io.inputs[0].count === "any"` accepts whatever the predecessor produces, with channels beyond the configured strip count silently ignored.
|
||||
|
||||
Per-channel sends are stored on the strip but **not routed through the `BusRegistry` in this universal implementation**. A future browser-engine build will read the `sends` map in `createAudioNode()` and wire each strip's output through the named auxiliary buses. The spec test only checks "sums channels with applied gain and pan", which the universal path satisfies; full send routing requires the browser-engine integration that lives outside this package.
|
||||
|
||||
The mixer's per-block VU update is RMS, not peak. RMS is what mixing engineers expect when they read a "VU" label — peak meters are typically labelled "PPM" in the same UI. Computing RMS adds one accumulator + one `Math.sqrt` per channel and one `Math.sqrt` for the master at the end of the block, which is negligible compared to the per-sample multiply-accumulate work. If a future surface needs peak metering, it can subscribe to the per-block telemetry stream and reduce locally.
|
||||
|
||||
The internal sum is unclipped on purpose. The spec's "sum its channels into the output block without clipping internally" requirement is verified by a test that pushes the per-channel gain to `1.5` with two channels active, producing an output of `3.0` — well past `±1` — and asserting that value is what comes out. This keeps the mixer composable: a caller can chain a `LimiterPlugin` downstream for hard-cap behaviour, or leave the clipping policy to the host renderer's master.
|
||||
@@ -0,0 +1,65 @@
|
||||
# `mixer/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options, channel strips, and preset payload of `MixerPlugin`. 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
|
||||
|
||||
- `MixerSendsSchema` — `z.record(string.min(1), number.finite().min(0).max(2))`. Send amounts are bounded above at `2` to allow modest gain at the bus, but not unbounded amplification.
|
||||
- `MixerChannelSchema` — full live strip schema: id, gain (0..4), pan (-1..1), sends.
|
||||
- `MixerChannelOptionsSchema` — boundary schema with `initialX` half-twins of the live fields.
|
||||
- `MixerPluginOptionsSchema` — `channels` array constrained to 1..64 plus optional `initialMasterGain`.
|
||||
- `MixerPresetDataSchema` — `.strict()` object carrying the full live channel strips.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MixerPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-mixer";
|
||||
|
||||
const parsed = MixerPluginOptionsSchema.parse({
|
||||
channels: [{ id: "drums" }, { id: "vocal" }],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MixerChannelSchema,
|
||||
MixerPresetDataSchema,
|
||||
MixerSendsSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-mixer";
|
||||
|
||||
// Validate an external send-map blob before forwarding to the plugin.
|
||||
function isSendMap(blob: unknown): boolean {
|
||||
return MixerSendsSchema.safeParse(blob).success;
|
||||
}
|
||||
|
||||
// Strict preset validation at the IPC boundary.
|
||||
function isMixerPreset(blob: unknown): boolean {
|
||||
return MixerPresetDataSchema.safeParse(blob).success;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MixerSendsSchema: z.ZodRecord<string, number>`
|
||||
- `MixerChannelSchema: z.ZodObject<{ id, gain, pan, sends }>`
|
||||
- `MixerChannelOptionsSchema: z.ZodObject<{ id, initialGain?, initialPan?, initialSends? }>`
|
||||
- `MixerPluginOptionsSchema: z.ZodObject<{ channels, initialMasterGain? }>`
|
||||
- `MixerPresetDataSchema: z.ZodObject<{ channels }, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The channel-count cap of 64 reflects two practical constraints: a typical mixing console UI struggles to render more than 32 strips legibly, and a per-channel loop over 64 entries per sample is already at the upper end of what a real-time block-processing budget can absorb on a typical CPU. Going past 64 strips is usually a sign that the caller should be using a submix architecture instead.
|
||||
|
||||
`MixerPresetDataSchema` uses `.strict()` so unknown fields are rejected — matching the sibling-plugin policy. The schema validates the full live `MixerChannel` shape (not just the construction-time half) because presets restore the runtime state directly, bypassing the `initialX → gain` mapping the constructor does.
|
||||
|
||||
The send-amount upper bound of `2` lets a bus send slightly boost the signal (useful for parallel-compression busses where the dry sum is too quiet) but blocks pathological values that would silently drown the master. The lower bound of `0` allows a "mute send" without removing the entry from the sends map, so a UI animating a fader to zero can resume from the same state.
|
||||
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-phaser - phaser plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
phaser 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
|
||||
|
||||
- [phaser](./src/phaser/phaser.md) — `PhaserPlugin` is the universal all-pass phaser. A cascade of first-order all-pass sections (configurable from 2 to 12)…
|
||||
- [phaser](./src/phaser/phaser.md) — `PhaserPlugin` is the universal all-pass phaser. A cascade of first-order all-pass sections (configurable from 2 to 12)…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `PhaserPlugin`. These run on the public boun…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `PhaserPlugin`. These run on the public boun…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-phaser";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./phaser` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,51 @@
|
||||
# `phaser.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `PhaserPlugin`'s construction options. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`. The interface gives consumers typed access to construction options for forms, dependency injection, and storybook controls without pulling in the plugin runtime.
|
||||
|
||||
## Features
|
||||
|
||||
- `PhaserPluginOptions` — all fields optional so the default constructor (`new PhaserPlugin()`) is valid.
|
||||
- `readonly` properties emphasise that the options object is consumed once at construction.
|
||||
- Numeric ranges are documented in the Zod schema, not duplicated in TypeScript types.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { PhaserPluginOptions } from "@suaveplan/audio-fx-plugin-phaser";
|
||||
|
||||
const opts: PhaserPluginOptions = { initialDepth: 0.6 };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { PhaserPluginOptions } from "@suaveplan/audio-fx-plugin-phaser";
|
||||
|
||||
const presets: Record<string, PhaserPluginOptions> = {
|
||||
subtle: { initialStages: 4, initialDepth: 0.4, initialWet: 0.3 },
|
||||
classic: { initialStages: 6, initialDepth: 0.7, initialWet: 0.5 },
|
||||
jet: { initialStages: 10, initialDepth: 0.95, initialWet: 0.8 },
|
||||
};
|
||||
|
||||
function load(name: keyof typeof presets): PhaserPluginOptions {
|
||||
return presets[name]!;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PhaserPluginOptions`
|
||||
- `readonly initialRateHz?: number`
|
||||
- `readonly initialDepth?: number`
|
||||
- `readonly initialStages?: number`
|
||||
- `readonly initialFeedback?: number`
|
||||
- `readonly initialWet?: number`
|
||||
- `readonly initialCenterHz?: number`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`initialStages` is typed as `number` rather than as a literal union (`2 | 3 | ... | 12`) because the parameter system stores all parameter values as `number` and accepting the wider type avoids a no-op cast at every call site. The Zod schema enforces integer-ness and range at the boundary; the plugin rounds the live value each block, which yields the same effective behaviour as a literal union without polluting the public type surface.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,70 @@
|
||||
# `phaser`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`PhaserPlugin` is the universal all-pass phaser. A cascade of first-order all-pass sections (configurable from 2 to 12) has its break frequencies modulated together by a shared LFO. Mixing the cascade's output with the dry signal creates the moving notches that define the phaser sound. Feedback from the last stage back into the first stage strengthens the resonant peaks at the notch edges.
|
||||
|
||||
The plugin extends `AbstractAudioFxPlugin`, so it runs identically across browser / server / worklet renderers. State is per-channel arrays sized for the maximum stage count; `reset()` zeros them in place without reallocation.
|
||||
|
||||
## Features
|
||||
|
||||
- Six parameters: `rate` (Hz, 0.05..10), `depth` (0..1), `stages` (int, 2..12), `feedback` (0..0.95), `wet` (0..1), `center` (Hz, 100..5000).
|
||||
- LFO-modulated break frequency: `break = center · 2^(lfo · depth · 2)` so depth controls the modulation span in octaves around `center`.
|
||||
- `wet === 0` early-out: copies the dry path bit-exact, satisfying the spec's "fully-dry mix is transparent" contract.
|
||||
- Feedback clamped at 0.95 at the parameter boundary so the system stays bounded for any bounded input — proven by the test "max feedback over a long sine never produces non-finite samples".
|
||||
- Stages capped at 12 so a single instance can run from a thin "subtle two-stage phaser" all the way up to a deep "Bass Synth 12-stage" without re-allocating delay state.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PhaserPlugin } from "@suaveplan/audio-fx-plugin-phaser";
|
||||
|
||||
const phaser = new PhaserPlugin({
|
||||
initialRateHz: 0.5,
|
||||
initialDepth: 0.7,
|
||||
initialWet: 0.5,
|
||||
});
|
||||
chain.add(phaser);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PhaserPlugin } from "@suaveplan/audio-fx-plugin-phaser";
|
||||
|
||||
// Deep "jet flange" phaser: 10 stages, heavy feedback, slow modulation,
|
||||
// near-full wet. Designed for synth pads where you want the slow sweep
|
||||
// to dominate the timbre rather than sit behind it.
|
||||
const jet = new PhaserPlugin({
|
||||
initialRateHz: 0.25,
|
||||
initialDepth: 0.95,
|
||||
initialStages: 10,
|
||||
initialFeedback: 0.85,
|
||||
initialWet: 0.8,
|
||||
initialCenterHz: 600,
|
||||
});
|
||||
await jet.setup(
|
||||
{ sampleRate: 48_000, channels: 2, blockSize: 512 },
|
||||
ctx
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PhaserPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new PhaserPlugin(options?: PhaserPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- Inherited parameter accessors for `rate`, `depth`, `stages`, `feedback`, `wet`, `center`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The first-order all-pass section is implemented as `y = a·x + xPrev − a·yPrev`, where the bilinear coefficient `a = (tan(πf/sr) − 1) / (tan(πf/sr) + 1)`. This form has unity magnitude response (all-pass) and phase response that swings 180° around `f`. Cascading N sections produces N evenly-spaced notches in the mixed dry+wet output; the LFO sweeps these notches across the spectrum to create the phasing motion.
|
||||
|
||||
`stages` is declared non-automatable because changing the count mid-block would either leave stale state in the unused stages or require gain compensation. The parameter accepts integer values 2..12; the inner loop iterates the rounded value each block, so users can still change stages between blocks without artefacts beyond the first-block transient.
|
||||
|
||||
The break-frequency clamp `[20, 0.45·sr]` is set well below Nyquist so the bilinear transform stays numerically stable even at the high-depth extreme. The `0.45·sr` upper bound matches the policy used in the state-variable filter plugin and was chosen so the all-pass coefficient stays in `(-1, 1)` across the parameter range; pushing closer to Nyquist would risk the coefficient approaching ±1 and amplifying float-precision noise.
|
||||
|
||||
Feedback runs through a per-channel `feedbackState` updated after each sample's cascade output. Clamping `feedback ≤ 0.95` keeps the loop strictly under unity gain so the output cannot diverge. The test "max feedback over a long sine never produces non-finite samples" exercises this boundary against a one-second sine at `feedback = 0.95` and verifies every output sample is finite and amplitude-bounded.
|
||||
@@ -0,0 +1,57 @@
|
||||
# `phaser/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options and preset payload of `PhaserPlugin`. 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
|
||||
|
||||
- `PhaserPluginOptionsSchema` — validates all six optional construction fields with their documented ranges.
|
||||
- `initialStages` is `z.number().int().min(2).max(12)` so non-integer stage counts cannot sneak through.
|
||||
- `PhaserPresetDataSchema` — empty strict object; the phaser has no plugin-specific preset payload, so any extra keys are rejected.
|
||||
- All numeric fields use `z.number().finite()` so `NaN` and `Infinity` cannot sneak through.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PhaserPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-phaser";
|
||||
|
||||
const parsed = PhaserPluginOptionsSchema.parse({ initialDepth: 0.6 });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
PhaserPluginOptionsSchema,
|
||||
PhaserPresetDataSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-phaser";
|
||||
|
||||
// Driver for an editor form: pull the keys + ranges from the schema.
|
||||
const optionFields = PhaserPluginOptionsSchema.keyof().options;
|
||||
for (const field of optionFields) {
|
||||
form.addRow(field);
|
||||
}
|
||||
|
||||
// Reject foreign preset payloads at the IPC boundary.
|
||||
function isPhaserPreset(blob: unknown): boolean {
|
||||
return PhaserPresetDataSchema.safeParse(blob).success;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PhaserPluginOptionsSchema: z.ZodObject<{ initialRateHz?, initialDepth?, initialStages?, initialFeedback?, initialWet?, initialCenterHz? }>`
|
||||
- `PhaserPresetDataSchema: z.ZodObject<{}, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`PhaserPresetDataSchema` is `.strict()` so unknown fields are rejected, matching the sibling-plugin policy. The phaser has no plugin-specific preset payload today: every parameter (`rate`, `depth`, `stages`, `feedback`, `wet`, `center`) travels through the universal parameter snapshot that `AbstractAudioFxPlugin` builds, and the empty `presetData` field is a marker that the plugin opts out of plugin-specific state.
|
||||
|
||||
The maximum feedback is `0.95`, not `1.0`. Allowing feedback at unity would create a marginally stable system whose output diverges for any non-zero input; clamping at 0.95 leaves enough headroom that even a noise-floor leak stays bounded over arbitrarily long renders. This boundary is the same as for the chorus, delay, and flanger plugins so all four feedback-bearing effects share one mental model.
|
||||
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-pitch-shifter - pitch-shifter plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
pitch-shifter 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
|
||||
|
||||
- [pitch-shifter](./src/pitch-shifter/pitch-shifter.md) — `PitchShifterPlugin` is the universal granular pitch shifter. It writes incoming audio into a per-channel circular ring…
|
||||
- [pitch-shifter](./src/pitch-shifter/pitch-shifter.md) — `PitchShifterPlugin` is the universal granular pitch shifter. It writes incoming audio into a per-channel circular ring…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `PitchShifterPlugin`. These run on the publi…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `PitchShifterPlugin`. These run on the publi…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-pitch-shifter";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./pitch-shifter` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,49 @@
|
||||
# `pitch-shifter.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `PitchShifterPlugin`'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
|
||||
|
||||
- `PitchShifterPluginOptions` — both fields optional so the default constructor (`new PitchShifterPlugin()`) is valid.
|
||||
- `readonly` properties — emphasises that the options object is consumed once at construction.
|
||||
- `windowSize` accepts one of `512 | 1024 | 2048 | 4096`; the validation lives in the Zod schema rather than in the TypeScript type to avoid forcing a literal-union cast at every call site.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { PitchShifterPluginOptions } from "@suaveplan/audio-fx-plugin-pitch-shifter";
|
||||
|
||||
const opts: PitchShifterPluginOptions = { initialSemitones: 5 };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { PitchShifterPluginOptions } from "@suaveplan/audio-fx-plugin-pitch-shifter";
|
||||
|
||||
const presets: Record<string, PitchShifterPluginOptions> = {
|
||||
fast: { windowSize: 512 },
|
||||
balanced: { windowSize: 2048 },
|
||||
fidelity: { windowSize: 4096 },
|
||||
};
|
||||
|
||||
function pickWindow(latencyBudgetSamples: number): PitchShifterPluginOptions {
|
||||
if (latencyBudgetSamples >= 2048) return presets.fidelity!;
|
||||
if (latencyBudgetSamples >= 1024) return presets.balanced!;
|
||||
return presets.fast!;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PitchShifterPluginOptions`
|
||||
- `readonly initialSemitones?: number` — initial pitch offset in semitones (-24..24).
|
||||
- `readonly windowSize?: number` — analysis/synthesis window, one of 512/1024/2048/4096 (default 2048).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The window size is constrained to a small set of powers of two. The implementation does not technically require a power of two (the ring buffer indexes by modulo, not bitmask), but constraining the choice makes the plugin behaviourally portable: a future FFT-based revision (phase vocoder) would benefit from power-of-two window sizes for efficient FFT routines, and locking the choice here means a swap is non-breaking. The four sizes cover the spectrum from "low latency, low fidelity" (512 = ~10 ms at 48 kHz) to "high fidelity, high latency" (4096 = ~85 ms).
|
||||
|
||||
`initialSemitones` is bounded at ±24 (two octaves). Beyond this, the granular algorithm's wrap rate exceeds the perceptual fusion frequency and the output starts to sound like granular synthesis rather than a clean shift. Callers wanting extreme transposition should chain multiple instances or wait for the phase-vocoder variant.
|
||||
@@ -0,0 +1,66 @@
|
||||
# `pitch-shifter`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`PitchShifterPlugin` is the universal granular pitch shifter. It writes incoming audio into a per-channel circular ring buffer and reads from two overlapping pointers advancing at the pitch ratio `2^(semitones / 12)`. The two read streams are Hann-window crossfaded into a single continuous output of the same length as the input — duration is preserved while pitch is shifted. The plugin maintains its overlap-add state across blocks; `reset()` clears the ring and re-seeds the read pointers in place.
|
||||
|
||||
A browser-native fast path is available via `getWorkletProcessor()`, which returns a self-contained `AudioWorkletProcessor` source string for off-thread rendering. The worklet path is functionally equivalent to the universal block path so a chain that uses one renders identically to a chain that uses the other.
|
||||
|
||||
## Features
|
||||
|
||||
- One automatable parameter: `semitones` (-24..24, default 0).
|
||||
- Configurable window size at construction (`512`, `1024`, `2048`, `4096`; default `2048`).
|
||||
- Stateful: per-channel ring buffer, two fractional read pointers, write head. Survives block boundaries; `reset()` clears all of it.
|
||||
- `latencySamples = windowSize / 2` declared in `capabilities`. Chain consumers can read this to align downstream processing.
|
||||
- `getWorkletProcessor()` returns a self-contained string containing a `registerProcessor("suaveplan-pitch-shifter", ...)` call and no Node-only `require` / ESM `import` — the spec's "worklet processor source is a self-contained string" contract.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PitchShifterPlugin } from "@suaveplan/audio-fx-plugin-pitch-shifter";
|
||||
|
||||
const shifter = new PitchShifterPlugin({ initialSemitones: 7 });
|
||||
chain.add(shifter);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PitchShifterPlugin } from "@suaveplan/audio-fx-plugin-pitch-shifter";
|
||||
|
||||
// Browser fast path: mount the worklet processor and forward the
|
||||
// `semitones` automation channel.
|
||||
const shifter = new PitchShifterPlugin({
|
||||
initialSemitones: 0,
|
||||
windowSize: 4096,
|
||||
});
|
||||
const source = shifter.getWorkletProcessor();
|
||||
await context.audioWorklet.addModule(URL.createObjectURL(new Blob([source])));
|
||||
const node = new AudioWorkletNode(context, "suaveplan-pitch-shifter", {
|
||||
processorOptions: { windowSize: 4096 },
|
||||
});
|
||||
node.parameters.get("semitones")!.setValueAtTime(-5, 0);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PitchShifterPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new PitchShifterPlugin(options?: PitchShifterPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options (semitones outside ±24, non-power-of-two window size).
|
||||
- `getWorkletProcessor(): string` — returns the self-contained AudioWorkletProcessor source string.
|
||||
- Inherited parameter accessor for `semitones`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The algorithm is a simple two-grain SOLA (synchronous overlap-add) variant: each output sample mixes two read-pointer reads weighted by a Hann window of the pointer's distance to the write head. Read pointer 1 starts half a window behind the write head; read pointer 2 starts a full window behind. As the pointers advance at the pitch ratio while the write head advances at the input rate, the lag between write and read changes — when the lag escapes `[0, windowSize]`, the pointer wraps by `±windowSize` so it stays in the analysis band. The Hann weighting ensures the wrap discontinuity lands at the trough of the crossfade, where its perceptual impact is minimal.
|
||||
|
||||
This is not a true phase vocoder. A phase vocoder analyses the input in the frequency domain via FFT, computes per-bin instantaneous frequencies, and resynthesises with adjusted phase advances — that produces higher-fidelity shifts for polyphonic / wideband material but requires a much larger code surface (FFT + magnitude/phase analysis + synthesis loop). The two-grain SOLA picks the cheaper algorithm for the MVP; a future revision can add a `PhaseVocoderPitchShifter` on a sibling subpath without breaking this contract. The spec's scenarios — "zero-semitone shift preserves pitch and duration within phase-vocoder reconstruction tolerance" and "octave-up shift doubles the fundamental" — are both within reach for SOLA on steady tones, with tolerances loose enough to absorb the wrap artefacts.
|
||||
|
||||
The ring buffer is sized at `windowSize × channels` and indexed by `channelOffset + (pos mod windowSize)`. Flattening avoids one indirection per inner-loop sample and keeps the ring contiguous in memory for cache locality. The two read pointers carry fractional positions so the linear-interp read produces smooth output even at non-integer pitch ratios.
|
||||
|
||||
`getWorkletProcessor()` returns a template string that includes the full processor class. We do not minify or precompile it; the worklet engine parses it on `addModule`. The string is constructed without any module-system primitives (`require`, `import`) so it can be served as a `Blob` URL without an extra build step — verified by the test "getWorkletProcessor returns self-contained processor source".
|
||||
|
||||
Latency is declared as `windowSize / 2`. The Hann weight is zero at the write head and at one window behind, peaking at half a window — so the first meaningful output sample appears after the read pointer reaches the centre of the buffer, which by construction happens after `windowSize / 2` input samples. Consumers can read `capabilities.latencySamples` and compensate (e.g., by delaying parallel dry signal in a wet/dry mixer downstream).
|
||||
@@ -0,0 +1,57 @@
|
||||
# `pitch-shifter/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options and preset payload of `PitchShifterPlugin`. 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
|
||||
|
||||
- `PitchShifterPluginOptionsSchema` — validates `initialSemitones ∈ [-24, 24]` and `windowSize ∈ {512, 1024, 2048, 4096}`.
|
||||
- `PitchShifterPresetDataSchema` — empty strict object; the plugin has no plugin-specific preset payload, so any extra keys are rejected.
|
||||
- `windowSize` uses `z.number().int().refine(...)` rather than `z.enum([512, 1024, 2048, 4096])` because Zod's enum validator only accepts string keys; the refinement gives clear error messages on the numeric option.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PitchShifterPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-pitch-shifter";
|
||||
|
||||
const parsed = PitchShifterPluginOptionsSchema.parse({
|
||||
initialSemitones: 7,
|
||||
windowSize: 2048,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
PitchShifterPluginOptionsSchema,
|
||||
PitchShifterPresetDataSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-pitch-shifter";
|
||||
|
||||
// Driver for an editor form: pull the keys from the schema.
|
||||
const fields = PitchShifterPluginOptionsSchema.keyof().options;
|
||||
for (const field of fields) form.addRow(field);
|
||||
|
||||
// Strict preset validation at the IPC boundary.
|
||||
function isPitchShifterPreset(blob: unknown): boolean {
|
||||
return PitchShifterPresetDataSchema.safeParse(blob).success;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PitchShifterPluginOptionsSchema: z.ZodObject<{ initialSemitones?, windowSize? }>`
|
||||
- `PitchShifterPresetDataSchema: z.ZodObject<{}, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `windowSize` constraint is enforced by `z.refine` rather than `z.enum` because Zod's `z.enum` requires string keys. Using `z.literal(512).or(z.literal(1024))…` would work too but produces awkward type unions; the refinement keeps the inferred type as `number` while still rejecting non-power-of-two values at the boundary. The error message names the four legal values so a UI driving this schema can produce a useful dropdown.
|
||||
|
||||
`PitchShifterPresetDataSchema` is `.strict()` so unknown fields are rejected, matching the sibling-plugin policy. The plugin has no plugin-specific preset payload today: the `semitones` parameter travels through the universal parameter snapshot that `AbstractAudioFxPlugin` builds, and the empty `presetData` field marks that the plugin opts out of plugin-specific state.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-sampler - sampler plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
sampler plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
Member of the `@suaveplan/audio-fx-plugin-*` family. Universal (browser / server / offline / worklet identical). Depends on `@suaveplan/audio-fx-plugin-base` for the `AudioFxPlugin` contract; declares no other `@suaveplan/audio-fx-plugin-*` deps.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [sampler](./src/sampler/sampler.md) — `SamplerPlugin` is the universal polyphonic sampler. `loadSample(note, url)` fetches a raw `Float32Array` PCM buffer fr…
|
||||
- [sampler](./src/sampler/sampler.md) — `SamplerPlugin` is the universal polyphonic sampler. `loadSample(note, url)` fetches a raw `Float32Array` PCM buffer fr…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, load options, and preset payload of `SamplerPlugin`. These run on…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options, load options, and preset payload of `SamplerPlugin`. These run on…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-sampler";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./sampler` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,67 @@
|
||||
# `sampler.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `SamplerPlugin`'s construction options, load options, loaded-sample introspection, and preset payload. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`.
|
||||
|
||||
## Features
|
||||
|
||||
- `SamplerLoadOptions` — accepts an `AbortSignal` to cancel an in-flight `loadSample` call.
|
||||
- `SamplerPluginOptions` — optional `initialMaxVoices` (voice pool size) and `initialReleaseMs` (default release-envelope time).
|
||||
- `SamplerLoadedSample` — diagnostic snapshot of a loaded base sample: `{ note, length }`.
|
||||
- `SamplerPresetData = Record<string, never>` — sampler has no plugin-specific preset payload.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SamplerLoadedSample,
|
||||
SamplerPluginOptions,
|
||||
} from "@suaveplan/audio-fx-plugin-sampler";
|
||||
|
||||
const opts: SamplerPluginOptions = { initialMaxVoices: 8 };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SamplerLoadOptions,
|
||||
SamplerLoadedSample,
|
||||
SamplerPluginOptions,
|
||||
} from "@suaveplan/audio-fx-plugin-sampler";
|
||||
|
||||
async function loadAll(
|
||||
sampler: SamplerPlugin,
|
||||
samples: Array<{ note: number; url: string }>,
|
||||
signal: AbortSignal
|
||||
) {
|
||||
const opts: SamplerLoadOptions = { signal };
|
||||
await Promise.all(samples.map((s) => sampler.loadSample(s.note, s.url, opts)));
|
||||
const loaded: ReadonlyArray<SamplerLoadedSample> = sampler.getLoadedSamples();
|
||||
return loaded.length;
|
||||
}
|
||||
|
||||
const presetState: SamplerPluginOptions = {
|
||||
initialMaxVoices: 24,
|
||||
initialReleaseMs: 60,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SamplerLoadOptions`
|
||||
- `readonly signal?: AbortSignal`
|
||||
- `SamplerPluginOptions`
|
||||
- `readonly initialMaxVoices?: number` (1..128, default 32)
|
||||
- `readonly initialReleaseMs?: number` (0..5000, default 30)
|
||||
- `SamplerLoadedSample`
|
||||
- `readonly note: number`
|
||||
- `readonly length: number`
|
||||
- `SamplerPresetData = Record<string, never>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SamplerPresetData` is typed as `Record<string, never>` rather than `{}` because the empty-record form is structurally distinct in TypeScript's exact-type checking: `{}` matches any non-null value, whereas `Record<string, never>` only matches an object literal with no enumerable properties. This means callers cannot accidentally pass a foreign preset payload (e.g., a `MixerPresetData`) and have it silently round-trip through the sampler.
|
||||
|
||||
The voice-pool size is capped at 128 in the schema. Beyond that, the linear voice-stealing scan starts to add noticeable per-attack overhead — and few real-world sampler instances need more than 64 simultaneous voices. A future "high-density" variant could swap the linear scan for a heap if a use case emerges.
|
||||
@@ -0,0 +1,90 @@
|
||||
# `sampler`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SamplerPlugin` is the universal polyphonic sampler. `loadSample(note, url)` fetches a raw `Float32Array` PCM buffer from a URL and registers it under a MIDI note key; `triggerAttack(note, velocity?)` finds the nearest loaded base sample and plays it back at the pitch ratio `2^((note − baseNote) / 12)` using linear interpolation. Concurrent voices are summed into the mono output channel, with deterministic voice stealing when the configured pool size is exceeded.
|
||||
|
||||
The plugin is a source (`io.inputs[0].count === 0`), so it slots into chains at position 0 — the chain's IO validator rejects placing it elsewhere. The processing path is allocation-free; only the voice pool array is mutated per block.
|
||||
|
||||
## Features
|
||||
|
||||
- Source plugin (`io.inputs[0].count === 0`), mono output (`io.outputs[0].count === 1`).
|
||||
- One automatable parameter: `gain` (0..4, default 1) for master-out level.
|
||||
- `loadSample(note, url, { signal? })` — fetches `url` as a raw Float32Array buffer; `signal` aborts pre-fetch (`AUDIO_FX.ABORTED`) or mid-fetch.
|
||||
- `triggerAttack(note, velocity?)` — throws `AUDIO_FX.SAMPLE_NOT_LOADED` if no samples are registered; throws `AUDIO_FX.INVALID_NOTE` for `note ∉ [0, 127]` integer.
|
||||
- `triggerRelease(note)` — switches the voice into release mode; one-pole exponential decay ends the voice cleanly.
|
||||
- Voice pool (default 32, configurable via `initialMaxVoices` up to 128). Stealing is deterministic: the lowest-sequence voice is replaced.
|
||||
- `getLoadedSamples()`, `getActiveVoiceCount()` accessors for diagnostics and meters.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SamplerPlugin } from "@suaveplan/audio-fx-plugin-sampler";
|
||||
|
||||
const sampler = new SamplerPlugin();
|
||||
await sampler.loadSample(60, "/sounds/piano-c4.raw");
|
||||
chain.add(sampler);
|
||||
sampler.triggerAttack(60); // play at native pitch
|
||||
sampler.triggerAttack(72); // play one octave up
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SamplerPlugin } from "@suaveplan/audio-fx-plugin-sampler";
|
||||
|
||||
const sampler = new SamplerPlugin({
|
||||
initialMaxVoices: 16,
|
||||
initialReleaseMs: 40,
|
||||
});
|
||||
await sampler.setup(
|
||||
{ sampleRate: 48_000, channels: 1, blockSize: 512 },
|
||||
ctx
|
||||
);
|
||||
|
||||
// Load a piano sample bank, key-mapped across the keyboard.
|
||||
const ac = new AbortController();
|
||||
try {
|
||||
await Promise.all([
|
||||
sampler.loadSample(48, "/sounds/piano-c3.raw", { signal: ac.signal }),
|
||||
sampler.loadSample(60, "/sounds/piano-c4.raw", { signal: ac.signal }),
|
||||
sampler.loadSample(72, "/sounds/piano-c5.raw", { signal: ac.signal }),
|
||||
]);
|
||||
} catch (error) {
|
||||
if (error instanceof AudioFxError && error.code === "AUDIO_FX.ABORTED") {
|
||||
console.warn("sample bank load aborted");
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// triggerAttack(65) picks the nearest base (60), pitch-shifts +5 semitones.
|
||||
sampler.triggerAttack(65, 0.8);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SamplerPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new SamplerPlugin(options?: SamplerPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- `loadSample(note, url, { signal? }): Promise<void>` — fetches and registers a base sample.
|
||||
- `triggerAttack(note, velocity?: number): void`
|
||||
- `triggerRelease(note: number): void`
|
||||
- `getLoadedSamples(): ReadonlyArray<SamplerLoadedSample>`
|
||||
- `getActiveVoiceCount(): number`
|
||||
- Inherited accessor for `gain`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The pitch ratio is `2^((note − baseNote) / 12)` — the standard equal-temperament pitch-shifting formula. We use linear interpolation between adjacent samples in the source buffer, which is the cheapest interpolator that avoids audible quantisation artefacts. For high-fidelity pitch shifts (e.g., spanning more than an octave from the base), callers should load multiple base samples spaced an octave apart so the nearest-neighbour lookup keeps the interpolation distance short.
|
||||
|
||||
Voice stealing uses a monotonic `sequence` counter on each voice. When the pool fills, we scan for the lowest sequence and replace it. The cost is O(maxVoices) per attack — for the default pool size of 32, this is 32 comparisons, well under the per-attack budget of a real-time engine. Faster algorithms (heap, ring buffer) would lower the constant factor but add bookkeeping complexity; the linear scan is the right MVP.
|
||||
|
||||
Release uses a one-pole exponential decay: `releaseGain *= 1 − releaseCoef` per sample with `releaseCoef = 1 − exp(-1/releaseSamples)`. When the gain falls below `1e-4`, the voice is removed from the pool. This is the same envelope-decay form used by the gate plugin and gives a perceptually-correct "soft release" rather than a click-prone hard cut.
|
||||
|
||||
`loadSample` mirrors the reverb's `loadImpulseResponse` pattern: pre-aborted signal throws `AUDIO_FX.ABORTED` before any fetch; signal aborted mid-fetch surfaces as the same error, leaving the existing sample map untouched. The signal field is built conditionally in `fetchInit` to satisfy `exactOptionalPropertyTypes` — `RequestInit.signal` is `AbortSignal | null`, and assigning `undefined` would be a type error.
|
||||
|
||||
The plugin output is mono (`io.outputs[0].count === 1`) so callers can chain a `MixerPlugin` or `StereoWidenerPlugin` downstream to place the sampler in stereo space. Spatial positioning is the consumer's responsibility — the sampler intentionally does not pan internally so a single instance can drive multiple voices that are panned differently downstream.
|
||||
@@ -0,0 +1,54 @@
|
||||
# `sampler/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options, load options, and preset payload of `SamplerPlugin`. These run on the public boundary so any malformed object is rejected with `AUDIO_FX.INVALID_OPTIONS` / `AUDIO_FX.INVALID_PRESET` before it can corrupt runtime state.
|
||||
|
||||
Separating schemas from the plugin module lets `index.ts` re-export them so callers can introspect the option surface (for forms, RPC validation, or storybook controls) without importing the audio implementation.
|
||||
|
||||
## Features
|
||||
|
||||
- `SamplerPluginOptionsSchema` — `initialMaxVoices` integer 1..128, `initialReleaseMs` 0..5000.
|
||||
- `SamplerLoadOptionsSchema` — `.strict()` with `signal: AbortSignal | undefined`.
|
||||
- `SamplerPresetDataSchema` — empty strict object; the sampler has no plugin-specific preset payload.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SamplerPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-sampler";
|
||||
|
||||
const parsed = SamplerPluginOptionsSchema.parse({ initialMaxVoices: 16 });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SamplerLoadOptionsSchema,
|
||||
SamplerPluginOptionsSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-sampler";
|
||||
|
||||
// Validate load options from an RPC boundary before forwarding.
|
||||
function safeLoadOptions(blob: unknown) {
|
||||
return SamplerLoadOptionsSchema.parse(blob);
|
||||
}
|
||||
|
||||
// Driver for an editor form: pull the keys from the schema.
|
||||
const fields = SamplerPluginOptionsSchema.keyof().options;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SamplerPluginOptionsSchema: z.ZodObject<{ initialMaxVoices?, initialReleaseMs? }>`
|
||||
- `SamplerLoadOptionsSchema: z.ZodObject<{ signal? }, "strict">`
|
||||
- `SamplerPresetDataSchema: z.ZodObject<{}, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SamplerLoadOptionsSchema` uses `z.instanceof(AbortSignal)` for the signal field. `instanceof` is the only Zod check that can verify a value is a real `AbortSignal` rather than a structurally-similar object — a fake `{ aborted: false, addEventListener: () => {} }` would pass a duck-typed check but break the actual abort wiring. The constructor-identity check is reliable in any runtime that exposes `AbortSignal` as a global (browser, Node 18+, Bun).
|
||||
|
||||
The schemas are duplicated in concept between the OpenSpec and the enterprise main-branch spec; this implementation reflects the OpenSpec (subset of the enterprise spec). Future revisions extending the sampler to support multi-velocity layers or round-robin sample selection would extend these schemas with optional fields and update `SamplerPresetDataSchema` accordingly.
|
||||
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-spatial - spatial plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
spatial 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
|
||||
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `SpatialAudioPlugin`. These run on the publi…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options and preset payload of `SpatialAudioPlugin`. These run on the publi…
|
||||
- [spatial](./src/spatial/spatial.md) — `SpatialAudioPlugin` is the universal positional spatial plugin. It computes a per-block distance attenuation from the…
|
||||
- [spatial](./src/spatial/spatial.md) — `SpatialAudioPlugin` is the universal positional spatial plugin. It computes a per-block distance attenuation from the…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-spatial";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./spatial` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,66 @@
|
||||
# `spatial/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options and preset payload of `SpatialAudioPlugin`. 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
|
||||
|
||||
- `Vec3Schema` — validates `{ x, y, z }` all `finite()` so `NaN` / `Infinity` cannot sneak through any vector field.
|
||||
- `SpatialAudioPluginOptionsSchema` — validates all five optional construction fields with their documented ranges (`refDistance` 0.01..1 000, `maxDistance` 0.01..100 000, `rolloffFactor` 0..10) plus the two `Vec3` slots.
|
||||
- `SpatialPresetDataSchema` — `.strict()` object carrying only `orientation: Vec3`; extra fields are rejected.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SpatialAudioPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-spatial";
|
||||
|
||||
const parsed = SpatialAudioPluginOptionsSchema.parse({
|
||||
initialPosition: { x: 5, y: 0, z: 0 },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SpatialAudioPluginOptionsSchema,
|
||||
SpatialPresetDataSchema,
|
||||
Vec3Schema,
|
||||
} from "@suaveplan/audio-fx-plugin-spatial";
|
||||
|
||||
// Reuse the shared Vec3 validator on a network message before
|
||||
// forwarding it to the plugin.
|
||||
function parseSpatialMessage(raw: unknown) {
|
||||
const message = z
|
||||
.object({
|
||||
position: Vec3Schema,
|
||||
orientation: Vec3Schema,
|
||||
})
|
||||
.parse(raw);
|
||||
return message;
|
||||
}
|
||||
|
||||
// Strict preset validation at the IPC boundary.
|
||||
function isSpatialPreset(blob: unknown): boolean {
|
||||
return SpatialPresetDataSchema.safeParse(blob).success;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `Vec3Schema: z.ZodObject<{ x: number; y: number; z: number }>`
|
||||
- `SpatialAudioPluginOptionsSchema: z.ZodObject<{ initialPosition?, initialOrientation?, initialRefDistance?, initialMaxDistance?, initialRolloffFactor? }>`
|
||||
- `SpatialPresetDataSchema: z.ZodObject<{ orientation: Vec3 }, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Vec3Schema` is exported as a named schema (not inlined) so the same value is reused by both `SpatialAudioPluginOptionsSchema` (for `initialPosition` and `initialOrientation`) and `SpatialPresetDataSchema` (for `orientation`). That guarantees all three vector fields agree byte-for-byte on what a legal `Vec3` looks like — and downstream code that wants to validate a `Vec3` independently (e.g., a network message carrying position updates) can pull the same schema.
|
||||
|
||||
`SpatialPresetDataSchema` uses `.strict()` so unknown fields are rejected, matching the sibling-plugin policy. Position and the rolloff knobs are universal parameters and travel through the parameter snapshot built by `AbstractAudioFxPlugin`. Storing them again in the preset payload would double-store the same data and risk drift; encoding only the non-parameter `orientation` makes the preset contract minimal and unambiguous.
|
||||
@@ -0,0 +1,74 @@
|
||||
# `spatial.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `SpatialAudioPlugin`'s construction options, preset payload, and the shared `Vec3` 3-vector. Co-located with the implementation so the public type surface stays aligned with the Zod validator in `schemas/schemas.ts`.
|
||||
|
||||
`Vec3` is exported here (rather than from a shared package) so the spatial plugin's public API does not force callers to import an unrelated math library — a `{ x, y, z }` interface is small enough to live with its consumer.
|
||||
|
||||
## Features
|
||||
|
||||
- `Vec3` — a finite 3-vector with `x`, `y`, `z` number members.
|
||||
- `SpatialAudioPluginOptions` — all fields optional so the default constructor (`new SpatialAudioPlugin()`) is valid.
|
||||
- `SpatialPresetData` — preset payload carrying the non-parameter `orientation` vector so it survives round-trip.
|
||||
- `readonly` fields prevent accidental mutation of saved options.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SpatialAudioPluginOptions,
|
||||
Vec3,
|
||||
} from "@suaveplan/audio-fx-plugin-spatial";
|
||||
|
||||
const opts: SpatialAudioPluginOptions = {
|
||||
initialPosition: { x: 5, y: 0, z: 0 },
|
||||
initialRefDistance: 2,
|
||||
};
|
||||
|
||||
const forward: Vec3 = { x: 1, y: 0, z: 0 };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SpatialAudioPluginOptions,
|
||||
Vec3,
|
||||
} from "@suaveplan/audio-fx-plugin-spatial";
|
||||
|
||||
function lerp(a: Vec3, b: Vec3, t: number): Vec3 {
|
||||
return {
|
||||
x: a.x + (b.x - a.x) * t,
|
||||
y: a.y + (b.y - a.y) * t,
|
||||
z: a.z + (b.z - a.z) * t,
|
||||
};
|
||||
}
|
||||
|
||||
const startPos: Vec3 = { x: 0, y: 0, z: 0 };
|
||||
const endPos: Vec3 = { x: 30, y: 0, z: 0 };
|
||||
const halfway: SpatialAudioPluginOptions = {
|
||||
initialPosition: lerp(startPos, endPos, 0.5),
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `Vec3`
|
||||
- `readonly x: number`
|
||||
- `readonly y: number`
|
||||
- `readonly z: number`
|
||||
- `SpatialAudioPluginOptions`
|
||||
- `readonly initialPosition?: Vec3`
|
||||
- `readonly initialOrientation?: Vec3`
|
||||
- `readonly initialRefDistance?: number`
|
||||
- `readonly initialMaxDistance?: number`
|
||||
- `readonly initialRolloffFactor?: number`
|
||||
- `SpatialPresetData`
|
||||
- `readonly orientation: Vec3`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Vec3` is intentionally a plain interface (not a class). Callers can construct one with an object literal at any seam, and TypeScript's structural typing means values produced by a math library can satisfy the shape without explicit conversion. The Zod schema in `schemas.ts` validates that all three fields are finite numbers, blocking `NaN` and `Infinity` at the construction boundary.
|
||||
|
||||
`SpatialPresetData` carries only `orientation`. Position and the rolloff knobs are universal parameters and travel through the parameter snapshot built by `AbstractAudioFxPlugin`. Storing them again in the preset payload would double-store the same data and risk drift; storing only the non-parameter `orientation` makes the preset contract minimal and unambiguous.
|
||||
@@ -0,0 +1,75 @@
|
||||
# `spatial`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SpatialAudioPlugin` is the universal positional spatial plugin. It computes a per-block distance attenuation from the listener-relative `position` vector using the Web Audio "inverse-distance" rolloff model, then applies equal-power stereo panning based on the source's position along the X axis. The model matches the standard `PannerNode` semantics so a browser-side build using `createAudioNode()` (mounted by `@suaveplan/audio-engine-browser`) renders identically to the universal block path.
|
||||
|
||||
The plugin extends `AbstractAudioFxPlugin` and is stateless on the processing path. `reset()` is a no-op by design.
|
||||
|
||||
## Features
|
||||
|
||||
- Six parameters: `positionX/Y/Z`, `refDistance`, `maxDistance`, `rolloffFactor` — all automatable, mirroring the Web Audio `PannerNode` surface.
|
||||
- `orientation: Vec3` carried via `setOrientation` / `getOrientation` and survives preset round-trip via `SpatialPresetData`. Not used by the universal distance/pan path, but available so a future HRTF renderer can read it without API breakage.
|
||||
- Inverse-distance rolloff: `gain = refDistance / (refDistance + rolloffFactor · (clamp(d, refDistance, maxDistance) − refDistance))`. At `d ≤ refDistance`, gain = 1 (unity); for `d > refDistance`, gain decreases monotonically as `d` increases — the spec's "distance attenuation decreases monotonically per the rolloff model" contract.
|
||||
- Equal-power stereo panning: `panNorm = x / d`, `angle = (panNorm + 1) · π / 4`, `outL = in · cos(angle) · distanceGain`, `outR = in · sin(angle) · distanceGain`. Mono input gets distance gain only; surround channels (third+) get distance gain only.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SpatialAudioPlugin } from "@suaveplan/audio-fx-plugin-spatial";
|
||||
|
||||
const spatial = new SpatialAudioPlugin({
|
||||
initialPosition: { x: 0, y: 0, z: 0 },
|
||||
initialRefDistance: 1,
|
||||
initialMaxDistance: 100,
|
||||
initialRolloffFactor: 1,
|
||||
});
|
||||
chain.add(spatial);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SpatialAudioPlugin } from "@suaveplan/audio-fx-plugin-spatial";
|
||||
|
||||
// Game footstep emitter: position is updated every frame from the
|
||||
// listener-relative player transform.
|
||||
const footstep = new SpatialAudioPlugin({
|
||||
initialRefDistance: 1,
|
||||
initialMaxDistance: 50,
|
||||
initialRolloffFactor: 2,
|
||||
});
|
||||
await footstep.setup(
|
||||
{ sampleRate: 48_000, channels: 2, blockSize: 512 },
|
||||
ctx
|
||||
);
|
||||
|
||||
function onFrame(playerToSource: { x: number; y: number; z: number }) {
|
||||
footstep.setParameter("positionX", playerToSource.x);
|
||||
footstep.setParameter("positionY", playerToSource.y);
|
||||
footstep.setParameter("positionZ", playerToSource.z);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SpatialAudioPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new SpatialAudioPlugin(options?: SpatialAudioPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- `setOrientation(orientation: Vec3): void` — replace the stored orientation vector.
|
||||
- `getOrientation(): Vec3` — read the current orientation vector.
|
||||
- Inherited parameter accessors for `positionX`, `positionY`, `positionZ`, `refDistance`, `maxDistance`, `rolloffFactor`.
|
||||
- Types: `Vec3 = { x: number; y: number; z: number }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The distance gain uses the Web Audio inverse-distance model rather than the linear or exponential alternatives because it has the most predictable monotonic-decrease property: every `d > refDistance` strictly lowers the gain, with no inflection points or asymptote at `maxDistance`. The clamp `clamp(d, refDistance, maxDistance)` handles the boundary cases — `d < refDistance` collapses to unity gain (no boost for sources past the listener's head), and `d > maxDistance` freezes the attenuation at the maximum-distance value (a typical game-audio "kill zone" that prevents distant sources from being inaudible-but-still-rendered).
|
||||
|
||||
Panning is equal-power (`cos`/`sin`) rather than linear (`1-panNorm`/`panNorm`) because equal-power preserves perceived loudness as a source moves across the stereo field. The mapping `angle = (panNorm + 1) · π / 4` puts `panNorm = -1` (hard left) at `angle = 0` (gain = `cos 0 = 1`, sin 0 = 0`) and`panNorm = 1` (hard right) at `angle = π/2` (gain = `cos π/2 = 0`, sin π/2 = 1). At`panNorm = 0` (centre), both channels get `√½` so the summed energy matches a mono source unchanged.
|
||||
|
||||
`orientation` is deliberately *not* a parameter. Parameters in the universal scheme are scalar-valued and automatable; a 3-vector orientation would need three separate parameters that are typically updated together. Exposing `setOrientation` / `getOrientation` keeps the API ergonomic for game-audio callers while preserving the vector across preset round-trip via `SpatialPresetData`. A future revision could add automatable `orientationX/Y/Z` parameters if a real use case emerges.
|
||||
|
||||
Mono input is distance-attenuated only — no pan is applied because there is no L/R to mix between. This matches typical 3D-audio engine behaviour: if a sound is recorded mono, it gets routed through the spatial pipeline but emerges mono unless the engine explicitly upmixes. The `io.outputs[0].count === "preserve"` declaration carries that intent into the chain composition rules.
|
||||
@@ -0,0 +1,119 @@
|
||||
# @suaveplan/audio-fx-plugin-spessasynth - SF2/SF3 SoundFont synthesizer plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
SF2/SF3 SoundFont synthesizer plugin for the `@suaveplan/audio-fx-plugin-*` family. Wraps the MIT-licensed [SpessaSynth](https://github.com/spessasus/SpessaSynth) engine as a standard `AudioFxPlugin`, so it composes inside any `PluginChain`, accepts MIDI from a `MidiPortLike` consumer, and exposes its 16-channel parameter surface through the same automation / snapshot / preset machinery every other plugin uses.
|
||||
|
||||
`spessasynth_lib` is declared as an `optionalDependencies` entry and pulled in only via lazy `import()` inside the loader, so consumers who never call `init()` pay zero bundle cost.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `SPESSASYNTH.*` error-code namespace used by every other module in `@suaveplan/audio-fx-plugin-spess…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `SPESSASYNTH.*` error-code namespace used by every other module in `@suaveplan/audio-fx-plugin-spess…
|
||||
- [loader](./src/loader/soundfont-loader.md) — Lazy entry point for the SF2/SF3/DLS SoundFont pipeline. The loader is the **only** place in the package where `spessas…
|
||||
- [soundfont-loader](./src/loader/soundfont-loader.md) — Lazy entry point for the SF2/SF3/DLS SoundFont pipeline. The loader is the **only** place in the package where `spessas…
|
||||
- [midi-router](./src/midi-router/midi-router.md) — Routes `MidiMessage` events from a `MidiPortLike` (or a direct `handle()` call) into the upstream `SpessaSynthProcessor…
|
||||
- [midi-router](./src/midi-router/midi-router.md) — Routes `MidiMessage` events from a `MidiPortLike` (or a direct `handle()` call) into the upstream `SpessaSynthProcessor…
|
||||
- [parameters](./src/parameters/parameters.md) — Declares the parameter surface the `SpessaSynthPlugin` exposes to the host chain: 16 GM channels × 5 controls (`volume`…
|
||||
- [parameters](./src/parameters/parameters.md) — Declares the parameter surface the `SpessaSynthPlugin` exposes to the host chain: 16 GM channels × 5 controls (`volume`…
|
||||
- [plugin](./src/plugin/spessasynth-plugin.md) — `SpessaSynthPlugin` is the `AudioFxPlugin` implementation that wraps the SpessaSynth SF2/SF3 SoundFont engine. It is a…
|
||||
- [spessasynth-plugin](./src/plugin/spessasynth-plugin.md) — `SpessaSynthPlugin` is the `AudioFxPlugin` implementation that wraps the SpessaSynth SF2/SF3 SoundFont engine. It is a…
|
||||
- [register](./src/register/register.md) — Provides the one-call helper `registerSpessaSynthPlugin(registry)` that attaches the SpessaSynth plugin factory to a `P…
|
||||
- [register](./src/register/register.md) — Provides the one-call helper `registerSpessaSynthPlugin(registry)` that attaches the SpessaSynth plugin factory to a `P…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — OpenTelemetry instrument bridge for the SpessaSynth plugin. Exposes three instruments under the meter `@suaveplan/audio…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — OpenTelemetry instrument bridge for the SpessaSynth plugin. Exposes three instruments under the meter `@suaveplan/audio…
|
||||
- [voice-manager](./src/voice-manager/voice-manager.md) — Owns the polyphony cap and the voice-stealing policy layered on top of the upstream `SpessaSynthProcessor`. The process…
|
||||
- [voice-manager](./src/voice-manager/voice-manager.md) — Owns the polyphony cap and the voice-stealing policy layered on top of the upstream `SpessaSynthProcessor`. The process…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Quickstart
|
||||
|
||||
```ts
|
||||
import { SpessaSynthPlugin } from "@suaveplan/audio-fx-plugin-spessasynth";
|
||||
|
||||
const plugin = new SpessaSynthPlugin();
|
||||
await plugin.init({
|
||||
soundFontUrl: "/assets/gm.sf2",
|
||||
sampleRate: 48_000,
|
||||
});
|
||||
await plugin.setup(
|
||||
{ sampleRate: 48_000, channels: 2, blockSize: 128 },
|
||||
chainContext
|
||||
);
|
||||
|
||||
plugin.sendMidi({ type: "noteOn", channel: 0, note: 60, velocity: 100 });
|
||||
|
||||
const out: Float32Array[] = [
|
||||
new Float32Array(128),
|
||||
new Float32Array(128),
|
||||
];
|
||||
plugin.process([], out, {
|
||||
blockOffsetSamples: 0,
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
opts: {},
|
||||
});
|
||||
```
|
||||
|
||||
## SF2 loading
|
||||
|
||||
```ts
|
||||
// From a URL (browser):
|
||||
await plugin.init({ soundFontUrl: "/assets/gm.sf2" });
|
||||
|
||||
// From an in-memory blob (offline render / tests):
|
||||
await plugin.init({ soundFontBytes: sf2Bytes });
|
||||
|
||||
// With an AbortController:
|
||||
await plugin.init({ soundFontUrl, signal: ac.signal });
|
||||
```
|
||||
|
||||
## Control-surface integration
|
||||
|
||||
```ts
|
||||
import {
|
||||
SpessaSynthPlugin,
|
||||
channelParameterId,
|
||||
MASTER_GAIN_ID,
|
||||
} from "@suaveplan/audio-fx-plugin-spessasynth";
|
||||
|
||||
// A control-surface knob driving channel-0 volume in real time:
|
||||
controlSurface.onKnobMove((value) => {
|
||||
plugin.setParameter(channelParameterId(0, "volume"), value);
|
||||
});
|
||||
|
||||
// Automate the master gain over half a second:
|
||||
plugin.setParameterCurve(MASTER_GAIN_ID, {
|
||||
type: "linear",
|
||||
value: 0,
|
||||
duration: 0.5,
|
||||
});
|
||||
```
|
||||
|
||||
## Registry
|
||||
|
||||
```ts
|
||||
import { PluginRegistry } from "@suaveplan/audio-fx-plugin-base/registry";
|
||||
import { registerSpessaSynthPlugin } from "@suaveplan/audio-fx-plugin-spessasynth/register";
|
||||
|
||||
const registry = new PluginRegistry();
|
||||
registerSpessaSynthPlugin(registry);
|
||||
|
||||
// Now `chain.restore(snapshot, registry)` can rehydrate any snapshot
|
||||
// containing a SpessaSynth slot.
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- `error-codes` — `SPESSASYNTH.*` codes via `defineCodes`.
|
||||
- `loader` — lazy `import("spessasynth_lib")`, SF2/SF3/DLS magic sniff, parse.
|
||||
- `midi-router` — channel-voice + program-change + NRPN/RPN reassembly.
|
||||
- `parameters` — 16-channel × 5-control + master gain (81 parameters).
|
||||
- `voice-manager` — polyphony cap + voice-stealing.
|
||||
- `plugin` — `SpessaSynthPlugin` (the `AudioFxPlugin` implementation).
|
||||
- `telemetry` — OTel counter + histogram + observable gauge.
|
||||
- `register` — registry helper for snapshot-restore.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user