first commit
This commit is contained in:
83
docs/media/audio-fx/audio-fx-plugin-base/README.md
Normal file
83
docs/media/audio-fx/audio-fx-plugin-base/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# @suaveplan/audio-fx-plugin-base - Foundation for the @suaveplan/audio-fx-plugin-* family. Exports the AudioFxPlugin contract, PluginChain primitive, ChainContext, AmbientState, BusRegistry, PluginRegistry, ChainSnapshot, ChainEventBus, ParameterSchedule, PresetSnapshot, and the shared AUDIO_FX.* error-code registry. Runs identically in browser, server, offline, and worklet contexts.
|
||||
|
||||
Foundation for the `@suaveplan/audio-fx-plugin-*` family. Universal (browser / server / offline / worklet identical). Zero `@suaveplan/audio-fx-plugin-*` deps — concrete plugins depend on this package, never the other way around.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [plugin-internals](./src/base/plugin-internals/plugin-internals.md) — The `plugin-internals` module owns the **boundary guards + parameter validation helpers** consumed by `AbstractAudioFxP…
|
||||
- [plugin-internals](./src/base/plugin-internals/plugin-internals.md) — The `plugin-internals` module owns the **boundary guards + parameter validation helpers** consumed by `AbstractAudioFxP…
|
||||
- [audio-fx-plugin](./src/base/audio-fx-plugin.md) — This module defines the **universal `AudioFxPlugin` contract** every plugin in the audio family implements, plu…
|
||||
- [capabilities](./src/base/capabilities.md) — `PluginCapabilities` is the **explicit feature-flag declaration** every `AudioFxPlugin` carries. Engines route audio th…
|
||||
- [parameter-schedule](./src/base/parameter-schedule.md) — `ParameterSchedule` is the **per-parameter automation evaluator** every plugin uses. One instance per `(plugin, paramet…
|
||||
- [plugin-io](./src/base/plugin-io.md) — The `plugin-io` module declares the **channel-topology contract** every `AudioFxPlugin` carries (`PluginIo`, `ChannelSp…
|
||||
- [preset](./src/base/preset.md) — The `preset` module owns the **plugin preset envelope** — `PresetSnapshot` — and the three boundary guards every plugin…
|
||||
- [biquad](./src/biquad/biquad.md) — The `eq.biquad` module owns the **RBJ Audio EQ Cookbook coefficient maths** and the per-band biquad state shapes consum…
|
||||
- [biquad](./src/biquad/biquad.md) — The `eq.biquad` module owns the **RBJ Audio EQ Cookbook coefficient maths** and the per-band biquad state shapes consum…
|
||||
- [chain](./src/chain/chain.md) — `PluginChain` is the **universal insert-list primitive** that engines and headless callers use to compose, host, and re…
|
||||
- [context](./src/chain/context/context.md) — `ChainContextFactory` builds one `ChainContext` per plugin slot for the `PluginChain` class. The handed-out reference i…
|
||||
- [context](./src/chain/context/context.md) — `ChainContextFactory` builds one `ChainContext` per plugin slot for the `PluginChain` class. The handed-out reference i…
|
||||
- [event-bus](./src/chain/event-bus/event-bus.md) — The `chain-event-bus` module owns the **typed pub/sub plumbing** consumed by `PluginChain`. Splitting it out of the cha…
|
||||
- [event-bus](./src/chain/event-bus/event-bus.md) — The `chain-event-bus` module owns the **typed pub/sub plumbing** consumed by `PluginChain`. Splitting it out of the cha…
|
||||
- [insert](./src/chain/insert/insert.md) — The `chain-insert` module owns the **structural validation + slot-index resolution + scratch-buffer allocation** helper…
|
||||
- [insert](./src/chain/insert/insert.md) — The `chain-insert` module owns the **structural validation + slot-index resolution + scratch-buffer allocation** helper…
|
||||
- [lifecycle](./src/chain/lifecycle/lifecycle.md) — The `lifecycle` module owns the **per-block / per-setup helpers** that `PluginChain` calls during `setup`, `process`, `…
|
||||
- [lifecycle](./src/chain/lifecycle/lifecycle.md) — The `lifecycle` module owns the **per-block / per-setup helpers** that `PluginChain` calls during `setup`, `process`, `…
|
||||
- [snapshot](./src/chain/snapshot/snapshot.md) — The `chain-snapshot` module owns the **`PluginChain` serialisation envelope** and the boundary helpers that round-trip…
|
||||
- [snapshot](./src/chain/snapshot/snapshot.md) — The `chain-snapshot` module owns the **`PluginChain` serialisation envelope** and the boundary helpers that round-trip…
|
||||
- [ambient-state](./src/chain/ambient-state.md) — This module owns the **engine-pushed ambient state** every `PluginChain` carries and forwards to every plugin via `Chai…
|
||||
- [bus-registry](./src/chain/bus-registry.md) — The `BusRegistry` is the **project-level sidechain routing surface** that every chain hosted by an engine adapter consu…
|
||||
- [chain](./src/chain/chain.md) — `PluginChain` is the **universal insert-list primitive** that engines and headless callers use to compose, host, and re…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module declares the complete error code surface for `@suaveplan/audio-fx-plugin-base` via `defineCode…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module declares the complete error code surface for `@suaveplan/audio-fx-plugin-base` via `defineCode…
|
||||
- [registry](./src/registry/registry.md) — The `PluginRegistry` is the **session-restore lookup table** that maps a serialised `pluginType` string (a plugin's `me…
|
||||
- [registry](./src/registry/registry.md) — The `PluginRegistry` is the **session-restore lookup table** that maps a serialised `pluginType` string (a plugin's `me…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## What's in here
|
||||
|
||||
- **`./base`** — the `AudioFxPlugin` contract, `AbstractAudioFxPlugin` helper, `PluginCapabilities` / `PluginIo` / `PluginMetadata` / `PluginParameter` types, `ParameterSchedule`, `PresetSnapshot` Zod schema, and channel-layout utilities.
|
||||
- **`./chain`** — `PluginChain` primitive, `ChainContext`, `AmbientState` propagation, `DefaultBusRegistry`, `ChainSnapshot` (parse + serialise), `ChainEventBus`, insertion / lifecycle helpers.
|
||||
- **`./registry`** — `PluginRegistry` for factory-driven plugin instantiation.
|
||||
- **`./error-codes`** — the shared `AUDIO_FX.*` error-code registry via `defineCodes`, `AudioFxError` carrier, `AUDIO_FX_ERROR_METADATA` table, and `registerAudioFxErrorCodes()` for early-registration callers.
|
||||
|
||||
## Composition
|
||||
|
||||
Every concrete `@suaveplan/audio-fx-plugin-<name>` package depends on this one and imports the contract:
|
||||
|
||||
```ts
|
||||
import { AbstractAudioFxPlugin } from "@suaveplan/audio-fx-plugin-base/base";
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { PluginRegistry } from "@suaveplan/audio-fx-plugin-base/registry";
|
||||
import { AUDIO_FX_CODES, AudioFxError } from "@suaveplan/audio-fx-plugin-base/error-codes";
|
||||
```
|
||||
|
||||
Consumers compose a chain from per-plugin packages:
|
||||
|
||||
```ts
|
||||
import { EqPlugin } from "@suaveplan/audio-fx-plugin-eq";
|
||||
import { CompressorPlugin } from "@suaveplan/audio-fx-plugin-compressor";
|
||||
import { ReverbPlugin } from "@suaveplan/audio-fx-plugin-reverb";
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [new EqPlugin(), new CompressorPlugin(), new ReverbPlugin()],
|
||||
});
|
||||
```
|
||||
|
||||
The chain accepts any `AudioFxPlugin` without per-plugin glue — the contract is the single point of agreement.
|
||||
|
||||
## Subpath exports
|
||||
|
||||
| Subpath | Surface |
|
||||
|---|---|
|
||||
| `.` | Re-exports every public symbol from the four feature folders below |
|
||||
| `./base` | `AudioFxPlugin`, `AbstractAudioFxPlugin`, capabilities, IO, parameter, preset |
|
||||
| `./chain` | `PluginChain`, `ChainContext`, `AmbientState`, `BusRegistry`, `ChainSnapshot`, event-bus, insert, lifecycle |
|
||||
| `./registry` | `PluginRegistry`, `PluginFactory` |
|
||||
| `./error-codes` | `AUDIO_FX_CODES`, `AudioFxError`, `AUDIO_FX_ERROR_METADATA`, `registerAudioFxErrorCodes` |
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the foundation surface previously bundled inside `@suaveplan/audio-plugins-fx`.
|
||||
@@ -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.
|
||||
83
docs/media/audio-fx/audio-fx-plugin-base/src/base/preset.md
Normal file
83
docs/media/audio-fx/audio-fx-plugin-base/src/base/preset.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# `preset`
|
||||
|
||||
## Purpose
|
||||
|
||||
The `preset` module owns the **plugin preset envelope** — `PresetSnapshot` — and the three boundary guards every plugin runs before applying a preset: schema validation (`parsePresetSnapshot`), version-compatibility (`assertPresetVersionSupported`), and plugin-type matching (`assertPresetMatchesPluginType`). Presets are the universal handshake between a plugin's serialised state and a fresh instance. Both per-plugin `getPreset()`/`applyPreset()` and chain-level `snapshot()`/`restore()` use this envelope, so a snapshot persisted from a track preset and a snapshot exported from a single plugin look the same on disk.
|
||||
|
||||
## Features
|
||||
|
||||
- One Zod-validated envelope shape (`schemaVersion`, `pluginType`, `parameters`, optional `data`) that every plugin shares.
|
||||
- `parsePresetSnapshot(input)` validates at the boundary and throws `AUDIO_FX.INVALID_PRESET` with the Zod issue list in `context` on failure.
|
||||
- `assertPresetVersionSupported(preset, supportedSchemaVersion)` throws `AUDIO_FX.PRESET_VERSION_UNSUPPORTED` when the preset is from a newer plugin build.
|
||||
- `assertPresetMatchesPluginType(preset, pluginType)` throws `AUDIO_FX.INVALID_PRESET` (with a `presetPluginType / expected` context) when a chain snapshot tries to load a preset against the wrong plugin.
|
||||
- The schema (`PresetSnapshotSchema`) is exported for callers that want to call `safeParse` themselves.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parsePresetSnapshot, assertPresetVersionSupported } from "@suaveplan/audio-fx-plugin-base/base";
|
||||
|
||||
function applyPresetGuarded(plugin: AudioFxPlugin, raw: unknown, mySchemaVersion = 1) {
|
||||
const preset = parsePresetSnapshot(raw);
|
||||
assertPresetVersionSupported(preset, mySchemaVersion);
|
||||
plugin.applyPreset(preset);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertPresetMatchesPluginType,
|
||||
assertPresetVersionSupported,
|
||||
parsePresetSnapshot,
|
||||
} from "@suaveplan/audio-fx-plugin-base/base";
|
||||
|
||||
class EQPlugin implements AudioFxPlugin {
|
||||
readonly metadata = { id: "eq", name: "8-Band EQ", version: "1.0.0", category: "eq" } as const;
|
||||
readonly schema = EqDataSchema;
|
||||
private readonly supportedSchemaVersion = 2;
|
||||
|
||||
applyPreset(snapshot: PresetSnapshot): void {
|
||||
// The envelope was already validated by parsePresetSnapshot in the
|
||||
// caller; we re-assert the plugin-type and version locally so direct
|
||||
// calls into the plugin are equally safe.
|
||||
assertPresetMatchesPluginType(snapshot, this.metadata.id);
|
||||
assertPresetVersionSupported(snapshot, this.supportedSchemaVersion);
|
||||
|
||||
// Validate the plugin-specific data blob against our own schema.
|
||||
const data = EqDataSchema.parse(snapshot.data);
|
||||
for (const [paramId, value] of Object.entries(snapshot.parameters)) {
|
||||
this.setParameter(paramId, value);
|
||||
}
|
||||
this.bands = data.bands;
|
||||
}
|
||||
|
||||
getPreset(): PresetSnapshot {
|
||||
return {
|
||||
schemaVersion: this.supportedSchemaVersion,
|
||||
pluginType: this.metadata.id,
|
||||
parameters: this.snapshotParameters(),
|
||||
data: { bands: this.bands },
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PresetSnapshot` — readonly `{ schemaVersion: number; pluginType: string; parameters: Record<string, number>; data?: unknown }`.
|
||||
- `PresetSnapshotSchema` — Zod schema for the envelope; exported for callers that want to `safeParse` directly without throwing.
|
||||
- `parsePresetSnapshot(input: unknown): PresetSnapshot` — throws `AUDIO_FX.INVALID_PRESET` on validation failure with the issue list in `context.issues`.
|
||||
- `assertPresetVersionSupported(preset: PresetSnapshot, supportedSchemaVersion: number): void` — throws `AUDIO_FX.PRESET_VERSION_UNSUPPORTED` when `preset.schemaVersion > supportedSchemaVersion`.
|
||||
- `assertPresetMatchesPluginType(preset: PresetSnapshot, pluginType: string): void` — throws `AUDIO_FX.INVALID_PRESET` when `preset.pluginType !== pluginType`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The envelope keeps `parameters` separate from `data` because parameters are universally typed (always `number`) and can be applied generically by the `AbstractAudioFxPlugin` base class without any plugin-specific knowledge. The `data` blob is opaque from the envelope's perspective; each plugin validates it against its own Zod schema (exposed on the plugin's `schema` member). This split lets the chain's snapshot/restore round-trip every plugin's parameters without depending on per-plugin schemas, while still preserving plugin-specific state.
|
||||
|
||||
`schemaVersion` is a per-plugin integer: a single project may contain presets at different `schemaVersion`s across different plugins. The version is **forwards-only**: a plugin at v2 can read a v1 preset (with a migration path inside `applyPreset`) but a plugin at v1 refuses a v2 preset. This is deliberate — back-versioning is the responsibility of the plugin author, who can write a `migrateV1ToV2(data)` helper inside the plugin and apply it on read.
|
||||
|
||||
The two assert helpers throw rather than return booleans so plugin authors don't need a separate try/throw pattern. The `context` payload on each error includes both the offending value and the expected value, so the UI's preset-load toast can render a useful message ("Preset is from v3 of EQ; this build supports up to v2").
|
||||
|
||||
`assertPresetMatchesPluginType` exists for chain restore: the chain's snapshot lists plugin types it expects to instantiate, but a registry swap (e.g. a fork of the EQ plugin under a different id) could load the wrong factory; the assert catches that at the apply boundary before any state mutation.
|
||||
@@ -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.
|
||||
110
docs/media/audio-fx/audio-fx-plugin-base/src/chain/chain.md
Normal file
110
docs/media/audio-fx/audio-fx-plugin-base/src/chain/chain.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# `plugin-chain`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`PluginChain` is the **universal insert-list primitive** that engines and headless callers use to compose, host, and render `AudioFxPlugin` instances. One implementation lives here so engine adapters (`audio-engine-browser`, `audio-engine-server`) reduce to "construct a chain per track, push ambient state, walk `process()` on each block, dispose on teardown." The chain owns composition (add / remove / move / bypass), chain-level event emission, IO compatibility validation at insertion, plugin delay compensation summing, scratch-buffer reuse for the per-slot bounce, ambient-state propagation via the `ChainContext` facade plugins read at `setup()`, and the typed event surface (`plugin-added`, `bypass-changed`, `ambient-changed`, `latency-changed`, ...).
|
||||
|
||||
The chain owns **no DSP**. Plugins implement DSP; the chain wires them up.
|
||||
|
||||
## Features
|
||||
|
||||
- Ordered insert list with `add`, `remove`, `move`, `bypass`; insertion validates channel-count edges against the chain's effective output via `AUDIO_FX.CHAIN_IO_INCOMPATIBLE` before mutating any state.
|
||||
- Source-plugin rules enforced: `inputs[0].count === 0` is only valid at slot 0, and only one source per chain (`AUDIO_FX.CHAIN_SOURCE_POSITION_INVALID`).
|
||||
- Bypass at chain (host) level — matches every enterprise DAW. Bypassed slots skip the plugin's `process()` and pass the input frame to the next slot's input.
|
||||
- Plugin Delay Compensation: `getEffectiveLatencySamples()` sums non-bypassed plugin latencies; the chain subscribes to each plugin's `onLatencyChanged` and re-emits a chain-level `latency-changed` event with the new total.
|
||||
- Engine-pushed ambient state via `updateAmbient(patch)`; equality-guarded so no-op patches don't emit spurious events.
|
||||
- Stable `ChainContext` reference per slot for the plugin's lifetime (handed at `setup()`); peer lists, position, slot count, and ambient state update through getters on the captured reference.
|
||||
- Per-block bounce uses two `Float32Array[]` scratch buffers (one per parity) reallocated only when channel count or block size changes, so steady-state processing is allocation-free.
|
||||
- Typed event surface (`on(event, listener)`) returning an unsubscribe handle.
|
||||
- `dispose()` releases plugin instances and detaches latency subscriptions; idempotent.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
DefaultBusRegistry,
|
||||
PluginChain,
|
||||
defaultAmbientState,
|
||||
} from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { EQPlugin, CompressorPlugin } from "./my-plugins.js";
|
||||
|
||||
const buses = new DefaultBusRegistry();
|
||||
const chain = new PluginChain(
|
||||
{ id: "drums", channels: 2, sampleRate: 48_000, blockSize: 128 },
|
||||
buses
|
||||
);
|
||||
chain.add(new EQPlugin());
|
||||
chain.add(new CompressorPlugin());
|
||||
|
||||
await chain.setup(
|
||||
{ sampleRate: 48_000, channels: 2, blockSize: 128 },
|
||||
defaultAmbientState()
|
||||
);
|
||||
|
||||
// In your engine's per-block tick:
|
||||
chain.process(input, output, blockContext);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
const chain = new PluginChain(
|
||||
{ id: "drums", channels: 2, sampleRate: 48_000, blockSize: 128 },
|
||||
buses
|
||||
);
|
||||
chain.add(new SamplerPlugin()); // source plugin at slot 0
|
||||
chain.add(new EQPlugin());
|
||||
chain.add(new CompressorPlugin());
|
||||
chain.add(new LimiterPlugin());
|
||||
|
||||
await chain.setup(processorConfig, defaultAmbientState());
|
||||
|
||||
// React to chain events from the UI layer:
|
||||
const unsubLatency = chain.on("latency-changed", ({ effectiveLatencySamples }) => {
|
||||
project.recomputePdc();
|
||||
});
|
||||
const unsubAmbient = chain.on("ambient-changed", ({ patch }) => {
|
||||
if (patch.transport?.tempo !== undefined) {
|
||||
ui.updateTempoIndicator(patch.transport.tempo);
|
||||
}
|
||||
});
|
||||
|
||||
// Engine pushes ambient state changes as the project mutates:
|
||||
chain.updateAmbient({ track: { pan: -0.3, gainDb: -6 } });
|
||||
chain.updateAmbient({ transport: { tempo: 140, state: "playing" } });
|
||||
|
||||
// User flips bypass on slot 2:
|
||||
chain.bypass(2, true); // emits bypass-changed; latency-changed if non-zero latency
|
||||
|
||||
// On track teardown:
|
||||
unsubLatency();
|
||||
unsubAmbient();
|
||||
chain.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class PluginChain(opts: PluginChainOptions, buses: BusRegistry)` — universal insert-list primitive.
|
||||
- Composition: `add(plugin, at?)`, `remove(idOrIndex)`, `move(from, to)`, `bypass(idOrIndex, bypassed)`.
|
||||
- Lifecycle: `setup(config, ambient, opts?)`, `process(input, output, ctx)`, `reset()`, `dispose()`.
|
||||
- Inspection: `plugins`, `findById(id)`, `getEffectiveLatencySamples()`, `getEffectiveOutputChannels()`, `currentAmbient`.
|
||||
- Ambient: `updateAmbient(patch)`.
|
||||
- Events: `on(event, listener)` returning an unsubscribe handle. Events: `plugin-added`, `plugin-removed`, `plugin-moved`, `plugin-replaced`, `bypass-changed`, `parameter-changed`, `preset-applied`, `ambient-changed`, `latency-changed`.
|
||||
- `PluginChainOptions` — `{ id, name?, channels, sampleRate, blockSize }`.
|
||||
- `ChainEvent` / `ChainEventPayload` / `ChainEventListener<E>` — typed event surface.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The chain bounces audio through two scratch buffer arrays (`scratchA`, `scratchB`) selected by slot index parity. The last slot writes directly to the caller's `output` buffer, so a chain of N plugins performs N processing calls plus zero allocations per block (after the one-shot scratch alloc at `setup` / channel-count change). Bypassed slots copy the previous frame into the next scratch slot without invoking the plugin's `process()`; this keeps the bounce loop branch-free per sample.
|
||||
|
||||
Insertion validation walks the entire candidate slot list — including the new slot — to confirm every edge holds. This is O(n) per insertion but catches the case where moving an existing slot would break a downstream edge. Validation runs against a candidate copy of the slot list, so a rejection leaves the chain unchanged.
|
||||
|
||||
The `ChainContextFactory` builds per-slot context objects whose `position`, `predecessors`, `successors`, and `ambient` are getters reading from the chain's mutable backing fields. This means plugins capture the context at `setup()` and the context reflects every subsequent composition or ambient change without re-binding — the contract every plugin author needs and the only way to keep the chain hot path cheap.
|
||||
|
||||
Latency tracking subscribes to each plugin's `onLatencyChanged` at `add()` time and unsubscribes at `remove()` / `dispose()` time. The chain caches the effective total and only emits `latency-changed` when it differs from the previous value, so a stable chain in steady state produces zero events. The engine adapter consumes this event to recompute project-wide PDC across tracks.
|
||||
|
||||
The chain's `id` is the namespace for error context (`AUDIO_FX.CHAIN_*`); every error thrown by the chain carries `chainId` in `context` so reporting can attribute failures to a specific track.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user