Initial Commit
This commit is contained in:
83
packages/media/audio-fx/audio-fx-plugin-base/README.md
Normal file
83
packages/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`.
|
||||
81
packages/media/audio-fx/audio-fx-plugin-base/package.json
Normal file
81
packages/media/audio-fx/audio-fx-plugin-base/package.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "@suaveplan/audio-fx-plugin-base",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Foundation for the @suaveplan/audio-fx-plugin-* family. Exports the AudioFxPlugin contract, PluginChain primitive, ChainContext, AmbientState, BusRegistry, PluginRegistry, ChainSnapshot, ChainEventBus, ParameterSchedule, PresetSnapshot, and the shared AUDIO_FX.* error-code registry. Runs identically in browser, server, offline, and worklet contexts.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist", "README.md"],
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./base": {
|
||||
"import": {
|
||||
"types": "./dist/base/index.d.ts",
|
||||
"default": "./dist/base/index.js"
|
||||
}
|
||||
},
|
||||
"./chain": {
|
||||
"import": {
|
||||
"types": "./dist/chain/index.d.ts",
|
||||
"default": "./dist/chain/index.js"
|
||||
}
|
||||
},
|
||||
"./registry": {
|
||||
"import": {
|
||||
"types": "./dist/registry/index.d.ts",
|
||||
"default": "./dist/registry/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./biquad": {
|
||||
"import": {
|
||||
"types": "./dist/biquad/index.d.ts",
|
||||
"default": "./dist/biquad/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"lint": "biome check --max-diagnostics 500 src/",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.lib.json",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/audio-core": "workspace:^",
|
||||
"@suaveplan/audio-transport": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/opentelemetry": "workspace:^",
|
||||
"@suaveplan/schema-registry": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/typescript-config": "workspace:^",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
}
|
||||
}
|
||||
@@ -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,699 @@
|
||||
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import z from "zod";
|
||||
import {
|
||||
AUDIO_FX_CODES,
|
||||
type AudioFxError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
AbstractAudioFxPlugin,
|
||||
type AbstractAudioFxPluginOptions,
|
||||
} from "./audio-fx-plugin.js";
|
||||
import type { ChainContext } from "./audio-fx-plugin.types.js";
|
||||
|
||||
const STEREO_CONFIG: ProcessorConfig = {
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
blockSize: 128,
|
||||
};
|
||||
|
||||
const STEREO_IO = {
|
||||
inputs: [{ count: 2 as const }],
|
||||
outputs: [{ count: "preserve" as const }],
|
||||
};
|
||||
|
||||
const PRESET_SCHEMA = z
|
||||
.object({ shape: z.enum(["soft", "hard"]).optional() })
|
||||
.strict();
|
||||
|
||||
const STUB_CHAIN_CONTEXT: ChainContext = {
|
||||
chain: {
|
||||
id: "test-chain",
|
||||
name: "test",
|
||||
channels: 2,
|
||||
sampleRate: 48_000,
|
||||
blockSize: 128,
|
||||
slotCount: 1,
|
||||
},
|
||||
position: { index: 0, total: 1 },
|
||||
predecessors: [],
|
||||
successors: [],
|
||||
ambient: {
|
||||
track: {
|
||||
id: "t1",
|
||||
gainDb: 0,
|
||||
pan: 0,
|
||||
mute: false,
|
||||
solo: false,
|
||||
layout: "stereo",
|
||||
},
|
||||
transport: {
|
||||
tempo: 120,
|
||||
timeSignatureNumerator: 4,
|
||||
timeSignatureDenominator: 4,
|
||||
transportTimeSec: 0,
|
||||
state: "playing",
|
||||
ppqn: 960,
|
||||
},
|
||||
extension: {},
|
||||
},
|
||||
buses: {
|
||||
get: () => undefined,
|
||||
list: () => [],
|
||||
},
|
||||
};
|
||||
|
||||
class GainPlugin extends AbstractAudioFxPlugin {
|
||||
public processedBlocks = 0;
|
||||
public lastAppliedData: unknown = undefined;
|
||||
public stateCleared = 0;
|
||||
public disposed = 0;
|
||||
|
||||
public constructor(overrides?: Partial<AbstractAudioFxPluginOptions>) {
|
||||
super({
|
||||
metadata: {
|
||||
id: "gain",
|
||||
name: "Gain",
|
||||
version: "1.0.0",
|
||||
category: "utility",
|
||||
},
|
||||
schema: PRESET_SCHEMA,
|
||||
io: STEREO_IO,
|
||||
parameters: [
|
||||
{
|
||||
id: "gain",
|
||||
name: "Gain",
|
||||
value: 1,
|
||||
min: 0,
|
||||
max: 4,
|
||||
default: 1,
|
||||
unit: "ratio",
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "label",
|
||||
name: "Label",
|
||||
value: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
default: 0,
|
||||
unit: "enum",
|
||||
automatable: false,
|
||||
},
|
||||
],
|
||||
supportedSchemaVersion: 1,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[],
|
||||
_ctx: BlockContext
|
||||
): void {
|
||||
this.processedBlocks += 1;
|
||||
const gain = this.parameterValue("gain");
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
const inCh = input[ch]!;
|
||||
const outCh = output[ch]!;
|
||||
for (let i = 0; i < inCh.length; i++) {
|
||||
outCh[i] = (inCh[i] ?? 0) * gain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override resetState(): void {
|
||||
this.stateCleared += 1;
|
||||
}
|
||||
|
||||
protected override disposeState(): void {
|
||||
this.disposed += 1;
|
||||
}
|
||||
|
||||
protected override getPresetData(): unknown {
|
||||
return { shape: "soft" };
|
||||
}
|
||||
|
||||
protected override applyPresetData(data: unknown): void {
|
||||
this.lastAppliedData = data;
|
||||
}
|
||||
}
|
||||
|
||||
function blockCtx(opts: Record<string, unknown> = {}): BlockContext {
|
||||
return {
|
||||
blockOffsetSamples: 0,
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
opts,
|
||||
};
|
||||
}
|
||||
|
||||
function buffers(): { input: Float32Array[]; output: Float32Array[] } {
|
||||
return {
|
||||
input: [new Float32Array(128), new Float32Array(128)],
|
||||
output: [new Float32Array(128), new Float32Array(128)],
|
||||
};
|
||||
}
|
||||
|
||||
describe("AbstractAudioFxPlugin: identity and shape", () => {
|
||||
it("exposes metadata, parameters, parameterIndex, io, capabilities", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const p = new GainPlugin();
|
||||
expect(p.metadata.id).toBe("gain");
|
||||
expect(p.name).toBe("gain");
|
||||
expect(p.parameters.length).toBe(2);
|
||||
expect(p.parameterIndex.size).toBe(2);
|
||||
expect(p.parameterIndex.get("gain")?.id).toBe("gain");
|
||||
expect(p.io).toEqual(STEREO_IO);
|
||||
expect(p.capabilities.hasAudioNode).toBe(false);
|
||||
expect(p.capabilities.preservesChannelCount).toBe(true);
|
||||
expect(p.capabilities.latencySamples).toBe(0);
|
||||
});
|
||||
|
||||
it("throws AUDIO_FX.INVALID_OPTIONS when capability flags disagree with method presence", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
try {
|
||||
new GainPlugin({
|
||||
capabilities: {
|
||||
hasAudioNode: true, // claim true but no createAudioNode
|
||||
hasWorklet: false,
|
||||
hasSidechain: false,
|
||||
sampleAccurate: true,
|
||||
stateful: false,
|
||||
asyncSetup: false,
|
||||
preservesChannelCount: true,
|
||||
latencySamples: 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected GainPlugin construction to throw");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AbstractAudioFxPlugin: setup lifecycle", () => {
|
||||
it("throws AUDIO_FX.NOT_INITIALIZED if process() runs before setup()", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const p = new GainPlugin();
|
||||
const { input, output } = buffers();
|
||||
try {
|
||||
p.process(input, output, blockCtx());
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.NOT_INITIALIZED
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected NOT_INITIALIZED");
|
||||
});
|
||||
|
||||
it("rejects pre-aborted signal with AUDIO_FX.ABORTED before allocation", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new GainPlugin();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
await p.setup(STEREO_CONFIG, STUB_CHAIN_CONTEXT, {
|
||||
signal: ac.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(AUDIO_FX_CODES.ABORTED);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected ABORTED");
|
||||
});
|
||||
|
||||
it("rejects mid-setup signal abort with AUDIO_FX.ABORTED", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
class SlowPlugin extends GainPlugin {
|
||||
protected override async setupState(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
const p = new SlowPlugin();
|
||||
const ac = new AbortController();
|
||||
const promise = p.setup(STEREO_CONFIG, STUB_CHAIN_CONTEXT, {
|
||||
signal: ac.signal,
|
||||
});
|
||||
ac.abort();
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(AUDIO_FX_CODES.ABORTED);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected mid-setup ABORTED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AbstractAudioFxPlugin: process channel validation", () => {
|
||||
it("throws CHANNEL_MISMATCH when input length differs from configured channels", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new GainPlugin();
|
||||
await p.setup(STEREO_CONFIG, STUB_CHAIN_CONTEXT);
|
||||
try {
|
||||
p.process(
|
||||
[new Float32Array(128)],
|
||||
[new Float32Array(128), new Float32Array(128)],
|
||||
blockCtx()
|
||||
);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.CHANNEL_MISMATCH
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected CHANNEL_MISMATCH");
|
||||
});
|
||||
|
||||
it("applies gain block-by-block via parameterValue() helper", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new GainPlugin();
|
||||
await p.setup(STEREO_CONFIG, STUB_CHAIN_CONTEXT);
|
||||
p.setParameter("gain", 2);
|
||||
const { input, output } = buffers();
|
||||
input[0]!.fill(0.25);
|
||||
input[1]!.fill(-0.5);
|
||||
p.process(input, output, blockCtx());
|
||||
expect(output[0]![0]).toBe(0.5);
|
||||
expect(output[1]![64]).toBe(-1);
|
||||
expect(p.processedBlocks).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AbstractAudioFxPlugin: parameter mutations and clamping", () => {
|
||||
it("clamps setParameter to declared min/max", { timeout: 5_000 }, () => {
|
||||
const p = new GainPlugin();
|
||||
p.setParameter("gain", -10);
|
||||
expect(p.getParameter("gain")).toBe(0);
|
||||
p.setParameter("gain", 100);
|
||||
expect(p.getParameter("gain")).toBe(4);
|
||||
});
|
||||
|
||||
it("throws UNKNOWN_PARAMETER on unknown id for set/get/curve/at", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const p = new GainPlugin();
|
||||
for (const op of [
|
||||
() => p.getParameter("missing"),
|
||||
() => p.setParameter("missing", 0),
|
||||
() =>
|
||||
p.setParameterCurve("missing", {
|
||||
type: "linear",
|
||||
value: 0,
|
||||
duration: 1,
|
||||
}),
|
||||
() => p.setParameterAt("missing", 0, 1),
|
||||
]) {
|
||||
try {
|
||||
op();
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.UNKNOWN_PARAMETER
|
||||
);
|
||||
continue;
|
||||
}
|
||||
throw new Error("expected UNKNOWN_PARAMETER");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws PARAMETER_NOT_AUTOMATABLE when curving / scheduling a non-automatable param", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const p = new GainPlugin();
|
||||
for (const op of [
|
||||
() =>
|
||||
p.setParameterCurve("label", {
|
||||
type: "linear",
|
||||
value: 1,
|
||||
duration: 1,
|
||||
}),
|
||||
() => p.setParameterAt("label", 1, 1),
|
||||
]) {
|
||||
try {
|
||||
op();
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.PARAMETER_NOT_AUTOMATABLE
|
||||
);
|
||||
continue;
|
||||
}
|
||||
throw new Error("expected PARAMETER_NOT_AUTOMATABLE");
|
||||
}
|
||||
});
|
||||
|
||||
it("setParameterAt schedules an event clamped into the parameter's range", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new GainPlugin();
|
||||
await p.setup(STEREO_CONFIG, {
|
||||
...STUB_CHAIN_CONTEXT,
|
||||
ambient: {
|
||||
...STUB_CHAIN_CONTEXT.ambient,
|
||||
transport: {
|
||||
...STUB_CHAIN_CONTEXT.ambient.transport,
|
||||
transportTimeSec: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Schedule beyond max (4); clamp expected.
|
||||
p.setParameterAt("gain", 99, 2);
|
||||
const { input, output } = buffers();
|
||||
input[0]!.fill(1);
|
||||
input[1]!.fill(1);
|
||||
// Advance both wall clock and transport time past the schedule.
|
||||
p.process(input, output, {
|
||||
...blockCtx({ nowSec: 0 }),
|
||||
opts: { nowSec: 0 },
|
||||
});
|
||||
// The chainContext.ambient is captured by reference at setup; our
|
||||
// STUB has transportTimeSec=1 by default, so the t=2 event hasn't
|
||||
// fired. parameterValue() reads from the same context.
|
||||
expect(output[0]![0]).toBe(1);
|
||||
// We can verify the schedule is queued via getParameter (raw current).
|
||||
expect(p.getParameter("gain")).toBe(1);
|
||||
});
|
||||
|
||||
it("setParameterCurve clamps the target into the parameter's range", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const p = new GainPlugin();
|
||||
p.setParameterCurve("gain", {
|
||||
type: "linear",
|
||||
value: 999,
|
||||
duration: 0,
|
||||
startTime: 0,
|
||||
});
|
||||
// zero-duration curve drains at first read => snaps to clamped 4.
|
||||
const sched = p["schedules"].get("gain")!;
|
||||
expect(sched.valueAtBlockOffset(0, 0)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AbstractAudioFxPlugin: preset round-trip", () => {
|
||||
it("getPreset round-trips through applyPreset", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const p = new GainPlugin();
|
||||
p.setParameter("gain", 3);
|
||||
const snapshot = p.getPreset();
|
||||
const p2 = new GainPlugin();
|
||||
p2.applyPreset(snapshot);
|
||||
expect(p2.getParameter("gain")).toBe(3);
|
||||
expect(p2.lastAppliedData).toEqual({ shape: "soft" });
|
||||
});
|
||||
|
||||
it("getPreset omits data when getPresetData returns undefined", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
class NoDataPlugin extends GainPlugin {
|
||||
protected override getPresetData(): unknown {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const p = new NoDataPlugin();
|
||||
const snap = p.getPreset();
|
||||
expect(snap.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("default getPresetData returns undefined for plugins that don't override", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
// BarePlugin omits the override entirely so the default base
|
||||
// implementation (returning undefined) is exercised.
|
||||
class BarePlugin extends AbstractAudioFxPlugin {
|
||||
public constructor() {
|
||||
super({
|
||||
metadata: {
|
||||
id: "bare",
|
||||
name: "Bare",
|
||||
version: "1.0.0",
|
||||
category: "utility",
|
||||
},
|
||||
schema: PRESET_SCHEMA,
|
||||
io: STEREO_IO,
|
||||
parameters: [
|
||||
{
|
||||
id: "x",
|
||||
name: "X",
|
||||
value: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
}
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[]
|
||||
): void {
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
output[ch]!.set(input[ch]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
const p = new BarePlugin();
|
||||
// applyPresetData default no-op (also covers that branch).
|
||||
p.applyPreset({
|
||||
schemaVersion: 1,
|
||||
pluginType: "bare",
|
||||
parameters: {},
|
||||
data: {},
|
||||
});
|
||||
const snap = p.getPreset();
|
||||
expect(snap.data).toBeUndefined();
|
||||
expect(snap.parameters).toEqual({ x: 0 });
|
||||
// Default reset/dispose: no override; both must be safe no-ops.
|
||||
expect(() => p.reset()).not.toThrow();
|
||||
expect(() => p.dispose()).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws INVALID_PRESET when applyPreset is called with a wrong plugin type", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const p = new GainPlugin();
|
||||
try {
|
||||
p.applyPreset({
|
||||
schemaVersion: 1,
|
||||
pluginType: "reverb",
|
||||
parameters: {},
|
||||
});
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_PRESET
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_PRESET");
|
||||
});
|
||||
|
||||
it("throws INVALID_PRESET when data fails the plugin's own schema", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const p = new GainPlugin();
|
||||
try {
|
||||
p.applyPreset({
|
||||
schemaVersion: 1,
|
||||
pluginType: "gain",
|
||||
parameters: {},
|
||||
data: { shape: "spiky" }, // not in enum
|
||||
});
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_PRESET
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_PRESET");
|
||||
});
|
||||
|
||||
it("clamps parameter values from the preset into declared range", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const p = new GainPlugin();
|
||||
p.applyPreset({
|
||||
schemaVersion: 1,
|
||||
pluginType: "gain",
|
||||
parameters: { gain: 100 },
|
||||
});
|
||||
expect(p.getParameter("gain")).toBe(4);
|
||||
});
|
||||
|
||||
it("ignores unknown parameter ids in the preset", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const p = new GainPlugin();
|
||||
// should not throw
|
||||
p.applyPreset({
|
||||
schemaVersion: 1,
|
||||
pluginType: "gain",
|
||||
parameters: { unknown: 5 },
|
||||
});
|
||||
// gain remains at default
|
||||
expect(p.getParameter("gain")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AbstractAudioFxPlugin: latency events", () => {
|
||||
it("notifyLatencyChanged fires listeners with the new value", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
class LookaheadPlugin extends GainPlugin {
|
||||
public bumpLatency(samples: number): void {
|
||||
this.notifyLatencyChanged(samples);
|
||||
}
|
||||
}
|
||||
const p = new LookaheadPlugin();
|
||||
const received: number[] = [];
|
||||
p.onLatencyChanged((s) => received.push(s));
|
||||
p.bumpLatency(128);
|
||||
p.bumpLatency(128); // identical — no-op
|
||||
p.bumpLatency(256);
|
||||
expect(received).toEqual([128, 256]);
|
||||
expect(p.getLatencySamples()).toBe(256);
|
||||
});
|
||||
|
||||
it("onLatencyChanged returns an unsubscribe handle", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
class LookaheadPlugin extends GainPlugin {
|
||||
public bumpLatency(samples: number): void {
|
||||
this.notifyLatencyChanged(samples);
|
||||
}
|
||||
}
|
||||
const p = new LookaheadPlugin();
|
||||
const received: number[] = [];
|
||||
const unsub = p.onLatencyChanged((s) => received.push(s));
|
||||
unsub();
|
||||
p.bumpLatency(64);
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AbstractAudioFxPlugin: reset + dispose", () => {
|
||||
it("reset() drops parameter schedules and calls resetState", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new GainPlugin();
|
||||
await p.setup(STEREO_CONFIG, STUB_CHAIN_CONTEXT);
|
||||
p.setParameterCurve("gain", {
|
||||
type: "linear",
|
||||
value: 2,
|
||||
duration: 1,
|
||||
startTime: 10,
|
||||
});
|
||||
p.reset();
|
||||
expect(p.stateCleared).toBe(1);
|
||||
// schedule was dropped (gain stays at 1).
|
||||
const { input, output } = buffers();
|
||||
input[0]!.fill(1);
|
||||
input[1]!.fill(1);
|
||||
p.process(input, output, blockCtx());
|
||||
expect(output[0]![0]).toBe(1);
|
||||
});
|
||||
|
||||
it("dispose() clears listeners and prevents further process", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new GainPlugin();
|
||||
await p.setup(STEREO_CONFIG, STUB_CHAIN_CONTEXT);
|
||||
p.onLatencyChanged(() => {});
|
||||
p.dispose();
|
||||
expect(p.disposed).toBe(1);
|
||||
const { input, output } = buffers();
|
||||
try {
|
||||
p.process(input, output, blockCtx());
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.NOT_INITIALIZED
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected NOT_INITIALIZED after dispose");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AbstractAudioFxPlugin: input count='preserve' falls back to config.channels", () => {
|
||||
class PreserveInputPlugin extends AbstractAudioFxPlugin {
|
||||
public constructor() {
|
||||
super({
|
||||
metadata: {
|
||||
id: "preserve-input",
|
||||
name: "Preserve",
|
||||
version: "1.0.0",
|
||||
category: "utility",
|
||||
},
|
||||
schema: PRESET_SCHEMA,
|
||||
parameters: [],
|
||||
io: {
|
||||
inputs: [{ count: "preserve" as const }],
|
||||
outputs: [{ count: "preserve" as const }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[]
|
||||
): void {
|
||||
for (let c = 0; c < input.length; c++) {
|
||||
const src = input[c];
|
||||
const dst = output[c];
|
||||
if (!src || !dst) continue;
|
||||
for (let i = 0; i < src.length; i++) dst[i] = src[i] ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("expectedInput falls back to channels when inSpec is not a number", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new PreserveInputPlugin();
|
||||
await p.setup(STEREO_CONFIG, STUB_CHAIN_CONTEXT);
|
||||
const { input, output } = buffers();
|
||||
input[0]!.fill(0.25);
|
||||
input[1]!.fill(0.5);
|
||||
// Both inputs and outputs use the channels fallback (count="preserve");
|
||||
// process should succeed and copy input → output.
|
||||
p.process(input, output, blockCtx());
|
||||
expect(output[0]![0]).toBe(0.25);
|
||||
expect(output[1]![0]).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AbstractAudioFxPlugin: BlockContext nowSec threading", () => {
|
||||
it("uses ctx.opts.nowSec when supplied for parameter schedule evaluation", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const p = new GainPlugin();
|
||||
await p.setup(STEREO_CONFIG, STUB_CHAIN_CONTEXT);
|
||||
p.setParameterCurve("gain", {
|
||||
type: "linear",
|
||||
value: 4,
|
||||
duration: 1,
|
||||
startTime: 0,
|
||||
});
|
||||
const { input, output } = buffers();
|
||||
input[0]!.fill(1);
|
||||
input[1]!.fill(1);
|
||||
// First poll at nowSec=0 captures startValue = 1.
|
||||
p.process(input, output, { ...blockCtx({ nowSec: 0 }) });
|
||||
expect(output[0]![0]).toBe(1);
|
||||
// Mid-ramp at nowSec=0.5 should produce ~2.5.
|
||||
p.process(input, output, { ...blockCtx({ nowSec: 0.5 }) });
|
||||
expect(output[0]![0]).toBeCloseTo(2.5, 5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import type { ZodType } from "zod";
|
||||
import { AUDIO_FX_CODES, AudioFxError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
AudioFxPlugin,
|
||||
ChainContext,
|
||||
PluginMetadata,
|
||||
UnsubscribeFn,
|
||||
} from "./audio-fx-plugin.types.js";
|
||||
import {
|
||||
capabilitiesAgreeWithMembers,
|
||||
defaultCapabilities,
|
||||
} from "./capabilities.js";
|
||||
import type {
|
||||
OptionalPluginMembers,
|
||||
PluginCapabilities,
|
||||
} from "./capabilities.types.js";
|
||||
import type { ParameterCurve, PluginParameter } from "./parameter.types.js";
|
||||
import { ParameterSchedule } from "./parameter-schedule.js";
|
||||
import {
|
||||
assertChannelMatch,
|
||||
assertNotAborted,
|
||||
requireAutomatable,
|
||||
requireParameter,
|
||||
} from "./plugin-internals/index.js";
|
||||
import type { PluginIo } from "./plugin-io.types.js";
|
||||
import {
|
||||
applyPresetParameters,
|
||||
assertPresetMatchesPluginType,
|
||||
assertPresetVersionSupported,
|
||||
buildPresetSnapshot,
|
||||
clamp,
|
||||
parsePresetSnapshot,
|
||||
validatePresetData,
|
||||
} from "./preset.js";
|
||||
import type { PresetSnapshot } from "./preset.types.js";
|
||||
|
||||
/** Construction options for `AbstractAudioFxPlugin`. See `audio-fx-plugin.md`. */
|
||||
export interface AbstractAudioFxPluginOptions {
|
||||
readonly metadata: PluginMetadata;
|
||||
readonly schema: ZodType<unknown>;
|
||||
readonly io: PluginIo;
|
||||
readonly parameters: ReadonlyArray<PluginParameter>;
|
||||
readonly capabilities?: PluginCapabilities;
|
||||
readonly supportedSchemaVersion: number;
|
||||
}
|
||||
|
||||
/** Reference base; concrete plugins implement `processBlock` + state hooks. */
|
||||
export abstract class AbstractAudioFxPlugin implements AudioFxPlugin {
|
||||
public readonly metadata: PluginMetadata;
|
||||
public readonly schema: ZodType<unknown>;
|
||||
public readonly parameters: ReadonlyArray<PluginParameter>;
|
||||
public readonly parameterIndex: ReadonlyMap<string, PluginParameter>;
|
||||
public readonly io: PluginIo;
|
||||
public readonly capabilities: PluginCapabilities;
|
||||
|
||||
public get name(): string {
|
||||
return this.metadata.id;
|
||||
}
|
||||
|
||||
private readonly schedules: ReadonlyMap<string, ParameterSchedule>;
|
||||
private readonly latencyListeners = new Set<(samples: number) => void>();
|
||||
private currentLatencySamples: number;
|
||||
private readonly supportedSchemaVersion: number;
|
||||
private initialised = false;
|
||||
private nowSec = 0;
|
||||
protected chainContext?: ChainContext;
|
||||
protected config?: ProcessorConfig;
|
||||
|
||||
public constructor(options: AbstractAudioFxPluginOptions) {
|
||||
this.metadata = options.metadata;
|
||||
this.schema = options.schema;
|
||||
this.io = options.io;
|
||||
this.parameters = options.parameters;
|
||||
this.parameterIndex = new Map(options.parameters.map((p) => [p.id, p]));
|
||||
this.capabilities =
|
||||
options.capabilities ?? defaultCapabilities(options.io);
|
||||
this.currentLatencySamples = this.capabilities.latencySamples;
|
||||
this.supportedSchemaVersion = options.supportedSchemaVersion;
|
||||
this.schedules = new Map(
|
||||
options.parameters.map((p) => [
|
||||
p.id,
|
||||
new ParameterSchedule(p.value),
|
||||
])
|
||||
);
|
||||
if (
|
||||
!capabilitiesAgreeWithMembers(
|
||||
this.capabilities,
|
||||
this as unknown as OptionalPluginMembers
|
||||
)
|
||||
) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
"audio-fx.capability-method-mismatch",
|
||||
{ context: { pluginId: this.metadata.id } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected setupState(
|
||||
_config: ProcessorConfig,
|
||||
_chain: ChainContext,
|
||||
_opts?: { signal?: AbortSignal }
|
||||
): Promise<void> | void {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected abstract processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[],
|
||||
ctx: BlockContext
|
||||
): void;
|
||||
|
||||
protected resetState(): void {}
|
||||
|
||||
protected disposeState(): void {}
|
||||
|
||||
protected getPresetData(): unknown {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected applyPresetData(_data: unknown): void {}
|
||||
|
||||
/** Sample-accurate parameter value at current time. */
|
||||
protected parameterValue(id: string): number {
|
||||
requireParameter(this.parameterIndex, id, this.metadata.id);
|
||||
const sched = this.schedules.get(id)!;
|
||||
const transportTime =
|
||||
this.chainContext!.ambient.transport.transportTimeSec;
|
||||
return sched.valueAtBlockOffset(this.nowSec, transportTime);
|
||||
}
|
||||
|
||||
/** Emit a latency change to every listener. */
|
||||
protected notifyLatencyChanged(samples: number): void {
|
||||
if (samples === this.currentLatencySamples) {
|
||||
return;
|
||||
}
|
||||
this.currentLatencySamples = samples;
|
||||
for (const listener of this.latencyListeners) {
|
||||
listener(samples);
|
||||
}
|
||||
}
|
||||
|
||||
public async setup(
|
||||
config: ProcessorConfig,
|
||||
chain: ChainContext,
|
||||
opts?: { signal?: AbortSignal }
|
||||
): Promise<void> {
|
||||
assertNotAborted(
|
||||
opts?.signal,
|
||||
this.metadata.id,
|
||||
"audio-fx.setup-pre-aborted"
|
||||
);
|
||||
this.config = config;
|
||||
this.chainContext = chain;
|
||||
await Promise.resolve(this.setupState(config, chain, opts));
|
||||
assertNotAborted(
|
||||
opts?.signal,
|
||||
this.metadata.id,
|
||||
"audio-fx.setup-mid-aborted"
|
||||
);
|
||||
this.initialised = true;
|
||||
}
|
||||
|
||||
public process(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[],
|
||||
ctx: BlockContext
|
||||
): void {
|
||||
if (!this.initialised) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.NOT_INITIALIZED,
|
||||
"audio-fx.process-before-setup",
|
||||
{ context: { pluginId: this.metadata.id } }
|
||||
);
|
||||
}
|
||||
const channels = this.config!.channels;
|
||||
const inSpec = this.io.inputs[0]?.count;
|
||||
const outSpec = this.io.outputs[0]?.count;
|
||||
const expectedInput = typeof inSpec === "number" ? inSpec : channels;
|
||||
const expectedOutput = typeof outSpec === "number" ? outSpec : channels;
|
||||
assertChannelMatch(
|
||||
input,
|
||||
output,
|
||||
expectedInput,
|
||||
expectedOutput,
|
||||
this.metadata.id
|
||||
);
|
||||
const nowOpt = ctx.opts["nowSec"];
|
||||
if (typeof nowOpt === "number") {
|
||||
this.nowSec = nowOpt;
|
||||
}
|
||||
this.processBlock(input, output, ctx);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
const transportTime =
|
||||
this.chainContext?.ambient.transport.transportTimeSec ?? 0;
|
||||
for (const sched of this.schedules.values()) {
|
||||
sched.reset(transportTime);
|
||||
}
|
||||
this.resetState();
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.initialised = false;
|
||||
this.latencyListeners.clear();
|
||||
this.disposeState();
|
||||
}
|
||||
|
||||
public getLatencySamples(): number {
|
||||
return this.currentLatencySamples;
|
||||
}
|
||||
|
||||
public onLatencyChanged(
|
||||
listener: (samples: number) => void
|
||||
): UnsubscribeFn {
|
||||
this.latencyListeners.add(listener);
|
||||
return () => {
|
||||
this.latencyListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
public getParameter(id: string): number {
|
||||
const sched = this.schedules.get(id);
|
||||
if (sched === undefined) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.UNKNOWN_PARAMETER,
|
||||
"audio-fx.unknown-parameter",
|
||||
{ context: { paramId: id, pluginId: this.metadata.id } }
|
||||
);
|
||||
}
|
||||
return sched.value;
|
||||
}
|
||||
|
||||
public setParameter(id: string, value: number): void {
|
||||
const param = requireParameter(
|
||||
this.parameterIndex,
|
||||
id,
|
||||
this.metadata.id
|
||||
);
|
||||
this.schedules.get(id)!.set(clamp(value, param.min, param.max));
|
||||
}
|
||||
|
||||
public setParameterCurve(id: string, curve: ParameterCurve): void {
|
||||
const param = requireAutomatable(
|
||||
this.parameterIndex,
|
||||
id,
|
||||
this.metadata.id
|
||||
);
|
||||
const target = clamp(curve.value, param.min, param.max);
|
||||
this.schedules
|
||||
.get(id)!
|
||||
.scheduleCurve({ ...curve, value: target }, this.nowSec);
|
||||
}
|
||||
|
||||
public setParameterAt(
|
||||
id: string,
|
||||
value: number,
|
||||
transportTimeSec: number
|
||||
): void {
|
||||
const param = requireAutomatable(
|
||||
this.parameterIndex,
|
||||
id,
|
||||
this.metadata.id
|
||||
);
|
||||
this.schedules
|
||||
.get(id)!
|
||||
.scheduleAt(clamp(value, param.min, param.max), transportTimeSec);
|
||||
}
|
||||
|
||||
public getPreset(): PresetSnapshot {
|
||||
return buildPresetSnapshot(
|
||||
this.metadata.id,
|
||||
this.supportedSchemaVersion,
|
||||
this.schedules,
|
||||
this.getPresetData()
|
||||
);
|
||||
}
|
||||
|
||||
public applyPreset(preset: PresetSnapshot): void {
|
||||
const validated = parsePresetSnapshot(preset);
|
||||
assertPresetMatchesPluginType(validated, this.metadata.id);
|
||||
assertPresetVersionSupported(validated, this.supportedSchemaVersion);
|
||||
validatePresetData(this.schema, validated.data, this.metadata.id);
|
||||
applyPresetParameters(
|
||||
validated.parameters,
|
||||
this.schedules,
|
||||
this.parameterIndex
|
||||
);
|
||||
this.applyPresetData(validated.data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import type {
|
||||
BlockContext,
|
||||
BlockProcessor,
|
||||
ProcessorConfig,
|
||||
} from "@suaveplan/audio-core";
|
||||
import type { ZodType } from "zod";
|
||||
import type { PluginCapabilities } from "./capabilities.types.js";
|
||||
import type { ParameterCurve, PluginParameter } from "./parameter.types.js";
|
||||
import type { PluginIo } from "./plugin-io.types.js";
|
||||
import type { PresetSnapshot } from "./preset.types.js";
|
||||
|
||||
/**
|
||||
* Plugin metadata — stable identity, human-readable name, semver, category.
|
||||
* `id` is the registry key; `category` is a coarse bucket used by UI grouping.
|
||||
*/
|
||||
export interface PluginMetadata {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly category:
|
||||
| "eq"
|
||||
| "dynamics"
|
||||
| "reverb"
|
||||
| "delay"
|
||||
| "modulation"
|
||||
| "distortion"
|
||||
| "synthesis"
|
||||
| "sampling"
|
||||
| "spatial"
|
||||
| "utility"
|
||||
| "voice"
|
||||
| "source"
|
||||
| "mixer";
|
||||
readonly description?: string;
|
||||
readonly tags?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only view of a peer plugin handed to other plugins via
|
||||
* `ChainContext.predecessors` / `.successors`. Peers can read each
|
||||
* other's parameters and IO, but cannot mutate. Mutation always goes
|
||||
* through the owning plugin's own `setParameter` family.
|
||||
*/
|
||||
export interface PluginHandle {
|
||||
readonly id: string;
|
||||
readonly metadata: PluginMetadata;
|
||||
readonly io: PluginIo;
|
||||
readonly capabilities: PluginCapabilities;
|
||||
readonly bypassed: boolean;
|
||||
getParameter(id: string): number;
|
||||
getLatencySamples(): number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Engine-pushed ambient state visible to every plugin in a chain.
|
||||
* The reference is stable for the chain's lifetime; contents update via
|
||||
* `chain.updateAmbient(patch)` between blocks.
|
||||
*/
|
||||
export interface AmbientState {
|
||||
readonly track: AmbientTrackState;
|
||||
readonly transport: AmbientTransportState;
|
||||
readonly listener?: AmbientListenerState;
|
||||
readonly extension: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface AmbientTrackState {
|
||||
readonly id: string;
|
||||
readonly gainDb: number;
|
||||
readonly pan: number;
|
||||
readonly mute: boolean;
|
||||
readonly solo: boolean;
|
||||
readonly layout: string;
|
||||
}
|
||||
|
||||
export interface AmbientTransportState {
|
||||
readonly tempo: number;
|
||||
readonly timeSignatureNumerator: number;
|
||||
readonly timeSignatureDenominator: number;
|
||||
readonly transportTimeSec: number;
|
||||
readonly state: "stopped" | "playing" | "paused" | "recording";
|
||||
readonly ppqn: number;
|
||||
}
|
||||
|
||||
export interface AmbientListenerState {
|
||||
readonly position: Readonly<{ x: number; y: number; z: number }>;
|
||||
readonly orientation: Readonly<{
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
w: number;
|
||||
}>;
|
||||
readonly velocity: Readonly<{ x: number; y: number; z: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One published bus in the project-level `BusRegistry`. Consumers read
|
||||
* block-aligned PCM into their own scratch buffers.
|
||||
*/
|
||||
export interface SidechainBus {
|
||||
readonly name: string;
|
||||
readonly channels: number;
|
||||
read(into: Float32Array[]): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry handle handed to every chain via `ChainContext.buses`. The
|
||||
* implementation lives in `chain/bus-registry.ts`; this type captures the
|
||||
* surface plugins read against, not the engine-owned mutator surface.
|
||||
*/
|
||||
export interface BusRegistryReadHandle {
|
||||
get(name: string): SidechainBus | undefined;
|
||||
list(): ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only handle to the chain itself — id, structural shape, lifetime
|
||||
* config. Plugins use it to discover whether they sit on a mono or
|
||||
* stereo chain, what the configured block size is, etc.
|
||||
*/
|
||||
export interface ChainHandle {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly channels: number;
|
||||
readonly sampleRate: number;
|
||||
readonly blockSize: number;
|
||||
readonly slotCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ChainContext` instance handed to a plugin at `setup()`. The
|
||||
* reference is stable for the chain's lifetime; the **contents** update
|
||||
* across blocks (peer lists when the chain changes, ambient state when
|
||||
* the engine pushes updates).
|
||||
*/
|
||||
export interface ChainContext {
|
||||
readonly chain: ChainHandle;
|
||||
readonly position: {
|
||||
readonly index: number;
|
||||
readonly total: number;
|
||||
};
|
||||
readonly predecessors: ReadonlyArray<PluginHandle>;
|
||||
readonly successors: ReadonlyArray<PluginHandle>;
|
||||
readonly ambient: AmbientState;
|
||||
readonly buses: BusRegistryReadHandle;
|
||||
}
|
||||
|
||||
/** Unsubscribe handle returned by `onLatencyChanged`. */
|
||||
export type UnsubscribeFn = () => void;
|
||||
|
||||
/**
|
||||
* The universal contract every plugin in this package implements. Extends
|
||||
* `BlockProcessor` from `@suaveplan/audio-core` so a plugin doubles as a
|
||||
* `BlockProcessor` consumable by any audio-core stream / pipeline.
|
||||
*
|
||||
* Optional members (`createAudioNode`, `getWorkletProcessor`,
|
||||
* `setSidechain`) reflect the plugin's `capabilities` — engine adapters
|
||||
* cross-check the flags against method presence.
|
||||
*/
|
||||
export interface AudioFxPlugin extends Omit<BlockProcessor, "setup"> {
|
||||
readonly metadata: PluginMetadata;
|
||||
readonly schema: ZodType<unknown>;
|
||||
readonly parameters: ReadonlyArray<PluginParameter>;
|
||||
readonly parameterIndex: ReadonlyMap<string, PluginParameter>;
|
||||
readonly io: PluginIo;
|
||||
readonly capabilities: PluginCapabilities;
|
||||
|
||||
setup(
|
||||
config: ProcessorConfig,
|
||||
chain: ChainContext,
|
||||
opts?: { signal?: AbortSignal }
|
||||
): Promise<void>;
|
||||
process(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[],
|
||||
ctx: BlockContext
|
||||
): void;
|
||||
reset(): void;
|
||||
dispose(): void;
|
||||
getLatencySamples(): number;
|
||||
|
||||
getParameter(id: string): number;
|
||||
setParameter(id: string, value: number): void;
|
||||
setParameterCurve(id: string, curve: ParameterCurve): void;
|
||||
setParameterAt(id: string, value: number, transportTimeSec: number): void;
|
||||
onLatencyChanged(listener: (samples: number) => void): UnsubscribeFn;
|
||||
|
||||
getPreset(): PresetSnapshot;
|
||||
applyPreset(preset: PresetSnapshot): void;
|
||||
|
||||
createAudioNode?(ctx: unknown, chain: ChainContext): unknown;
|
||||
getWorkletProcessor?(): string;
|
||||
setSidechain?(bus: SidechainBus | undefined): void;
|
||||
}
|
||||
@@ -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,132 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
capabilitiesAgreeWithMembers,
|
||||
defaultCapabilities,
|
||||
withCapabilities,
|
||||
} from "./capabilities.js";
|
||||
import type { PluginIo } from "./plugin-io.types.js";
|
||||
|
||||
const STEREO_PRESERVE_IO: PluginIo = {
|
||||
inputs: [{ count: 2 }],
|
||||
outputs: [{ count: "preserve" }],
|
||||
};
|
||||
|
||||
describe("capabilities: defaultCapabilities", () => {
|
||||
it("returns a stateless, sample-accurate, universal-only baseline", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const cap = defaultCapabilities(STEREO_PRESERVE_IO);
|
||||
expect(cap.hasAudioNode).toBe(false);
|
||||
expect(cap.hasWorklet).toBe(false);
|
||||
expect(cap.hasSidechain).toBe(false);
|
||||
expect(cap.sampleAccurate).toBe(true);
|
||||
expect(cap.stateful).toBe(false);
|
||||
expect(cap.asyncSetup).toBe(false);
|
||||
expect(cap.preservesChannelCount).toBe(true);
|
||||
expect(cap.latencySamples).toBe(0);
|
||||
});
|
||||
|
||||
it("derives preservesChannelCount from declared IO", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const upmix: PluginIo = {
|
||||
inputs: [{ count: 1 }],
|
||||
outputs: [{ count: 2 }],
|
||||
};
|
||||
expect(defaultCapabilities(upmix).preservesChannelCount).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capabilities: capabilitiesAgreeWithMembers", () => {
|
||||
it("agrees when no optional methods and no flags claim them", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const cap = defaultCapabilities(STEREO_PRESERVE_IO);
|
||||
expect(capabilitiesAgreeWithMembers(cap, {})).toBe(true);
|
||||
});
|
||||
|
||||
it("agrees when hasAudioNode === typeof createAudioNode is function", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const cap = withCapabilities(defaultCapabilities(STEREO_PRESERVE_IO), {
|
||||
hasAudioNode: true,
|
||||
});
|
||||
expect(
|
||||
capabilitiesAgreeWithMembers(cap, { createAudioNode: () => null })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("disagrees when hasAudioNode is true but createAudioNode is absent", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const cap = withCapabilities(defaultCapabilities(STEREO_PRESERVE_IO), {
|
||||
hasAudioNode: true,
|
||||
});
|
||||
expect(capabilitiesAgreeWithMembers(cap, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("disagrees when createAudioNode is present but flag is false", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const cap = defaultCapabilities(STEREO_PRESERVE_IO);
|
||||
expect(
|
||||
capabilitiesAgreeWithMembers(cap, { createAudioNode: () => null })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("checks worklet and sidechain symmetrically", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const cap = withCapabilities(defaultCapabilities(STEREO_PRESERVE_IO), {
|
||||
hasWorklet: true,
|
||||
hasSidechain: true,
|
||||
});
|
||||
expect(
|
||||
capabilitiesAgreeWithMembers(cap, {
|
||||
getWorkletProcessor: () => "",
|
||||
setSidechain: () => undefined,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(capabilitiesAgreeWithMembers(cap, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("disagrees specifically when hasSidechain flag does not match", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const flagOnly = withCapabilities(
|
||||
defaultCapabilities(STEREO_PRESERVE_IO),
|
||||
{ hasSidechain: true }
|
||||
);
|
||||
expect(capabilitiesAgreeWithMembers(flagOnly, {})).toBe(false);
|
||||
|
||||
const methodOnly = defaultCapabilities(STEREO_PRESERVE_IO);
|
||||
expect(
|
||||
capabilitiesAgreeWithMembers(methodOnly, {
|
||||
setSidechain: () => undefined,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capabilities: withCapabilities", () => {
|
||||
it("merges overrides on top of the base", { timeout: 5_000 }, () => {
|
||||
const base = defaultCapabilities(STEREO_PRESERVE_IO);
|
||||
const merged = withCapabilities(base, {
|
||||
stateful: true,
|
||||
latencySamples: 64,
|
||||
});
|
||||
expect(merged.stateful).toBe(true);
|
||||
expect(merged.latencySamples).toBe(64);
|
||||
expect(merged.sampleAccurate).toBe(true);
|
||||
expect(merged.preservesChannelCount).toBe(true);
|
||||
});
|
||||
|
||||
it("does not mutate the base capabilities object", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const base = defaultCapabilities(STEREO_PRESERVE_IO);
|
||||
const before = { ...base };
|
||||
withCapabilities(base, { latencySamples: 128 });
|
||||
expect(base).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
OptionalPluginMembers,
|
||||
PluginCapabilities,
|
||||
} from "./capabilities.types.js";
|
||||
import { ioPreservesChannelCount } from "./plugin-io.js";
|
||||
import type { PluginIo } from "./plugin-io.types.js";
|
||||
|
||||
/**
|
||||
* Sensible defaults for a stateless, universal-only plugin that preserves
|
||||
* channel count. Plugin authors merge their overrides on top.
|
||||
*/
|
||||
export function defaultCapabilities(io: PluginIo): PluginCapabilities {
|
||||
return {
|
||||
hasAudioNode: false,
|
||||
hasWorklet: false,
|
||||
hasSidechain: false,
|
||||
sampleAccurate: true,
|
||||
stateful: false,
|
||||
asyncSetup: false,
|
||||
preservesChannelCount: ioPreservesChannelCount(io),
|
||||
latencySamples: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when every flag → method-presence claim agrees with the
|
||||
* supplied plugin members. Used by `AbstractAudioFxPlugin` and tests to
|
||||
* keep capability declarations honest.
|
||||
*/
|
||||
export function capabilitiesAgreeWithMembers(
|
||||
capabilities: PluginCapabilities,
|
||||
members: OptionalPluginMembers
|
||||
): boolean {
|
||||
const hasFn = (value: unknown): value is (...args: never[]) => unknown =>
|
||||
typeof value === "function";
|
||||
|
||||
if (capabilities.hasAudioNode !== hasFn(members.createAudioNode)) {
|
||||
return false;
|
||||
}
|
||||
if (capabilities.hasWorklet !== hasFn(members.getWorkletProcessor)) {
|
||||
return false;
|
||||
}
|
||||
if (capabilities.hasSidechain !== hasFn(members.setSidechain)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of `base` with the supplied overrides merged on top.
|
||||
* Intentionally typed so that authors can override individual flags
|
||||
* without restating the unchanged ones.
|
||||
*/
|
||||
export function withCapabilities(
|
||||
base: PluginCapabilities,
|
||||
overrides: Partial<PluginCapabilities>
|
||||
): PluginCapabilities {
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Static feature flags carried by every plugin. Engine adapters read these
|
||||
* to pick the render path (AudioNode → Worklet → BlockProcessor fallback)
|
||||
* and to decide whether to await `setup()`. Capabilities describe contract
|
||||
* — runtime state (bypassed?, latency right now?) is queried separately.
|
||||
*/
|
||||
export interface PluginCapabilities {
|
||||
/** Plugin exposes `createAudioNode(context, chain)`. */
|
||||
readonly hasAudioNode: boolean;
|
||||
/** Plugin exposes `getWorkletProcessor()` returning a self-contained source string. */
|
||||
readonly hasWorklet: boolean;
|
||||
/** Plugin exposes `setSidechain(bus)` for external sidechain input. */
|
||||
readonly hasSidechain: boolean;
|
||||
/** `process()` honours sample-accurate parameter ramps mid-block. */
|
||||
readonly sampleAccurate: boolean;
|
||||
/** `reset()` clears DSP state that survives across blocks (delay lines, filter histories). */
|
||||
readonly stateful: boolean;
|
||||
/** `setup()` may take meaningful wall-clock time; engines must await before first `process()`. */
|
||||
readonly asyncSetup: boolean;
|
||||
/** `process()` always produces the same channel count it received. */
|
||||
readonly preservesChannelCount: boolean;
|
||||
/** Initial latency in samples. Plugins with dynamic latency emit `onLatencyChanged`. */
|
||||
readonly latencySamples: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional members on `AudioFxPlugin` whose presence is mirrored by the
|
||||
* capability flags. Used by `deriveCapabilities` to cross-check that flags
|
||||
* agree with method presence at construction time.
|
||||
*/
|
||||
export interface OptionalPluginMembers {
|
||||
readonly createAudioNode?: unknown;
|
||||
readonly getWorkletProcessor?: unknown;
|
||||
readonly setSidechain?: unknown;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export {
|
||||
AbstractAudioFxPlugin,
|
||||
type AbstractAudioFxPluginOptions,
|
||||
} from "./audio-fx-plugin.js";
|
||||
export type {
|
||||
AmbientListenerState,
|
||||
AmbientState,
|
||||
AmbientTrackState,
|
||||
AmbientTransportState,
|
||||
AudioFxPlugin,
|
||||
BusRegistryReadHandle,
|
||||
ChainContext,
|
||||
ChainHandle,
|
||||
PluginHandle,
|
||||
PluginMetadata,
|
||||
SidechainBus,
|
||||
UnsubscribeFn,
|
||||
} from "./audio-fx-plugin.types.js";
|
||||
export {
|
||||
capabilitiesAgreeWithMembers,
|
||||
defaultCapabilities,
|
||||
withCapabilities,
|
||||
} from "./capabilities.js";
|
||||
export type {
|
||||
OptionalPluginMembers,
|
||||
PluginCapabilities,
|
||||
} from "./capabilities.types.js";
|
||||
export type {
|
||||
ParameterAtTransport,
|
||||
ParameterCurve,
|
||||
ParameterScale,
|
||||
ParameterUnit,
|
||||
PluginParameter,
|
||||
} from "./parameter.types.js";
|
||||
export { ParameterSchedule } from "./parameter-schedule.js";
|
||||
export {
|
||||
assertChannelMatch,
|
||||
assertNotAborted,
|
||||
requireAutomatable,
|
||||
requireParameter,
|
||||
} from "./plugin-internals/index.js";
|
||||
export {
|
||||
ioPreservesChannelCount,
|
||||
isSourcePlugin,
|
||||
resolveOutputChannels,
|
||||
validateChainIo,
|
||||
} from "./plugin-io.js";
|
||||
export type {
|
||||
ChannelLayout,
|
||||
ChannelSpec,
|
||||
IoCompatibility,
|
||||
PluginIo,
|
||||
} from "./plugin-io.types.js";
|
||||
export {
|
||||
applyPresetParameters,
|
||||
assertPresetMatchesPluginType,
|
||||
assertPresetVersionSupported,
|
||||
buildPresetSnapshot,
|
||||
clamp,
|
||||
PresetSnapshotSchema,
|
||||
parsePresetSnapshot,
|
||||
validatePresetData,
|
||||
} from "./preset.js";
|
||||
export type { PresetSnapshot } from "./preset.types.js";
|
||||
@@ -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,227 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ParameterSchedule } from "./parameter-schedule.js";
|
||||
|
||||
describe("ParameterSchedule: basic value access", () => {
|
||||
it("returns the constructed initial value", { timeout: 5_000 }, () => {
|
||||
const s = new ParameterSchedule(0.5);
|
||||
expect(s.value).toBe(0.5);
|
||||
expect(s.valueAtBlockOffset(0, 0)).toBe(0.5);
|
||||
});
|
||||
|
||||
it("set() jumps the value immediately and drops pending curves", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const s = new ParameterSchedule(0);
|
||||
s.scheduleCurve({ type: "linear", value: 10, duration: 1 }, 0);
|
||||
expect(s.pendingCurveCount).toBe(1);
|
||||
s.set(7);
|
||||
expect(s.value).toBe(7);
|
||||
expect(s.pendingCurveCount).toBe(0);
|
||||
expect(s.valueAtBlockOffset(0.5, 0)).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ParameterSchedule: linear curves", () => {
|
||||
it("interpolates exactly halfway across the ramp", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const s = new ParameterSchedule(0);
|
||||
s.scheduleCurve(
|
||||
{ type: "linear", value: 100, duration: 1, startTime: 0 },
|
||||
0
|
||||
);
|
||||
const half = s.valueAtBlockOffset(0.5, 0);
|
||||
expect(half).toBeCloseTo(50, 6);
|
||||
});
|
||||
|
||||
it("reaches target exactly at endSec and drains the queue", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const s = new ParameterSchedule(0);
|
||||
s.scheduleCurve(
|
||||
{ type: "linear", value: 100, duration: 1, startTime: 0 },
|
||||
0
|
||||
);
|
||||
// First poll mid-ramp so the curve's startValue is captured at 0.
|
||||
s.valueAtBlockOffset(0.25, 0);
|
||||
const atEnd = s.valueAtBlockOffset(1.0, 0);
|
||||
expect(atEnd).toBe(100);
|
||||
expect(s.pendingCurveCount).toBe(0);
|
||||
});
|
||||
|
||||
it("idempotent for identical inputs (no advance, no drain)", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const s = new ParameterSchedule(0);
|
||||
s.scheduleCurve(
|
||||
{ type: "linear", value: 10, duration: 1, startTime: 0 },
|
||||
0
|
||||
);
|
||||
expect(s.valueAtBlockOffset(0.5, 0)).toBeCloseTo(5, 6);
|
||||
expect(s.valueAtBlockOffset(0.5, 0)).toBeCloseTo(5, 6);
|
||||
expect(s.pendingCurveCount).toBe(1);
|
||||
});
|
||||
|
||||
it("startTime in the future is respected", { timeout: 5_000 }, () => {
|
||||
const s = new ParameterSchedule(2);
|
||||
s.scheduleCurve(
|
||||
{ type: "linear", value: 4, duration: 1, startTime: 5 },
|
||||
0
|
||||
);
|
||||
expect(s.valueAtBlockOffset(3, 0)).toBe(2);
|
||||
expect(s.valueAtBlockOffset(5.5, 0)).toBeCloseTo(3, 6);
|
||||
expect(s.valueAtBlockOffset(6, 0)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ParameterSchedule: exponential curves", () => {
|
||||
it("starts at startValue and ends at targetValue", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const s = new ParameterSchedule(1);
|
||||
s.scheduleCurve(
|
||||
{ type: "exponential", value: 8, duration: 1, startTime: 0 },
|
||||
0
|
||||
);
|
||||
// First read at t=0 captures the startValue (= 1)
|
||||
expect(s.valueAtBlockOffset(0, 0)).toBe(1);
|
||||
// Mid-ramp should equal 1 * 8^0.5 = ~2.828
|
||||
const mid = s.valueAtBlockOffset(0.5, 0);
|
||||
expect(mid).toBeCloseTo(Math.sqrt(8), 5);
|
||||
expect(s.valueAtBlockOffset(1, 0)).toBeCloseTo(8, 5);
|
||||
});
|
||||
|
||||
it("handles a zero starting value without producing NaN", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const s = new ParameterSchedule(0);
|
||||
s.scheduleCurve(
|
||||
{ type: "exponential", value: 1, duration: 1, startTime: 0 },
|
||||
0
|
||||
);
|
||||
const mid = s.valueAtBlockOffset(0.5, 0);
|
||||
expect(Number.isFinite(mid)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles a zero target value without producing NaN", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const s = new ParameterSchedule(1);
|
||||
s.scheduleCurve(
|
||||
{ type: "exponential", value: 0, duration: 1, startTime: 0 },
|
||||
0
|
||||
);
|
||||
const mid = s.valueAtBlockOffset(0.5, 0);
|
||||
expect(Number.isFinite(mid)).toBe(true);
|
||||
expect(mid).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("sorts pending curves by end time so the shorter one drains first", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const s = new ParameterSchedule(0);
|
||||
// Schedule out-of-order: curve A ends at 5, curve B ends at 1.
|
||||
s.scheduleCurve(
|
||||
{ type: "linear", value: 100, duration: 5, startTime: 0 },
|
||||
0
|
||||
);
|
||||
s.scheduleCurve(
|
||||
{ type: "linear", value: 50, duration: 1, startTime: 0 },
|
||||
0
|
||||
);
|
||||
// After t=1 the shorter curve has drained and the longer remains.
|
||||
const after1 = s.valueAtBlockOffset(1, 0);
|
||||
expect(s.pendingCurveCount).toBe(1);
|
||||
// Drain snapped current to 50; the remaining curve then captures
|
||||
// startValue=50 and interpolates toward 100 with t = 1/5 = 0.2.
|
||||
expect(after1).toBeCloseTo(50 + (100 - 50) * 0.2, 6);
|
||||
});
|
||||
|
||||
it("drops a zero-duration linear curve instantly via the drain step", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const s = new ParameterSchedule(0);
|
||||
s.scheduleCurve(
|
||||
{ type: "linear", value: 9, duration: 0, startTime: 0 },
|
||||
0
|
||||
);
|
||||
// endSec === startSec, so the drain at `valueAtBlockOffset(0, 0)`
|
||||
// snaps the value to target without ever calling interpolateCurve.
|
||||
expect(s.valueAtBlockOffset(0, 0)).toBe(9);
|
||||
expect(s.pendingCurveCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ParameterSchedule: target (first-order) curves", () => {
|
||||
it("approaches target asymptotically", { timeout: 5_000 }, () => {
|
||||
const s = new ParameterSchedule(0);
|
||||
s.scheduleCurve(
|
||||
{ type: "target", value: 1, duration: 0.1, startTime: 0 },
|
||||
0
|
||||
);
|
||||
// Pin startValue at t=0.
|
||||
expect(s.valueAtBlockOffset(0, 0)).toBe(0);
|
||||
// After ~3 time constants we should be ≥ 95 % of the way there.
|
||||
const after3Tau = s.valueAtBlockOffset(0.3, 0);
|
||||
expect(after3Tau).toBeGreaterThan(0.94);
|
||||
expect(after3Tau).toBeLessThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ParameterSchedule: transport-time schedules", () => {
|
||||
it("applies a transport event when the deadline is reached", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const s = new ParameterSchedule(0);
|
||||
s.scheduleAt(0.5, 2);
|
||||
expect(s.valueAtBlockOffset(0, 0)).toBe(0);
|
||||
expect(s.valueAtBlockOffset(0, 1.999)).toBe(0);
|
||||
expect(s.valueAtBlockOffset(0, 2)).toBe(0.5);
|
||||
});
|
||||
|
||||
it("transport event overrides an in-flight curve", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const s = new ParameterSchedule(0);
|
||||
s.scheduleCurve(
|
||||
{ type: "linear", value: 100, duration: 1, startTime: 0 },
|
||||
0
|
||||
);
|
||||
s.scheduleAt(42, 0.5);
|
||||
const result = s.valueAtBlockOffset(0.5, 0.5);
|
||||
expect(result).toBe(42);
|
||||
expect(s.pendingCurveCount).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves event order across multiple schedules", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const s = new ParameterSchedule(0);
|
||||
s.scheduleAt(2, 2);
|
||||
s.scheduleAt(1, 1);
|
||||
s.scheduleAt(3, 3);
|
||||
expect(s.pendingTransportEventCount).toBe(3);
|
||||
expect(s.valueAtBlockOffset(0, 1)).toBe(1);
|
||||
expect(s.valueAtBlockOffset(0, 2)).toBe(2);
|
||||
expect(s.valueAtBlockOffset(0, 3)).toBe(3);
|
||||
expect(s.pendingTransportEventCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ParameterSchedule: reset semantics", () => {
|
||||
it("drops curves and past transport events; keeps future ones", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const s = new ParameterSchedule(0);
|
||||
s.scheduleCurve(
|
||||
{ type: "linear", value: 10, duration: 1, startTime: 0 },
|
||||
0
|
||||
);
|
||||
s.scheduleAt(5, 0.5);
|
||||
s.scheduleAt(7, 3);
|
||||
s.reset(1);
|
||||
expect(s.pendingCurveCount).toBe(0);
|
||||
expect(s.pendingTransportEventCount).toBe(1); // only the t=3 event survives
|
||||
expect(s.valueAtBlockOffset(0, 3)).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import type {
|
||||
ParameterAtTransport,
|
||||
ParameterCurve,
|
||||
} from "./parameter.types.js";
|
||||
|
||||
/**
|
||||
* Sample-accurate evaluator for a single parameter's pending wall-clock
|
||||
* curves and transport-time scheduled events. One instance per
|
||||
* (plugin, parameter id). The evaluator owns the current value and the
|
||||
* queue of pending events; the plugin's `process()` calls
|
||||
* `valueAtBlockOffset(...)` once per block to read the value to apply.
|
||||
*
|
||||
* Behaviour:
|
||||
*
|
||||
* - `set(value)` jumps the current value immediately (clamped by caller).
|
||||
* - `scheduleCurve(curve, nowSec)` enqueues a wall-clock ramp.
|
||||
* `nowSec` is the engine's current wall-clock seconds; `curve.startTime`
|
||||
* defaults to `nowSec` when absent.
|
||||
* - `scheduleAt(value, transportTimeSec)` enqueues a transport-time event.
|
||||
* - `valueAtBlockOffset(nowSec, transportTimeSec)` returns the value at
|
||||
* the requested time, advancing the queue past completed events.
|
||||
* Idempotent for identical inputs; never reads "future" events.
|
||||
* - `reset(transportTimeSec)` drops every scheduled event whose deadline is
|
||||
* in the past relative to the supplied transport time; future events stay
|
||||
* pending (matches DAW stop-rewind semantics).
|
||||
*
|
||||
* Determinism: events are sorted by their resolved deadline; ties are
|
||||
* broken by enqueue order. No global state; tests can drive the evaluator
|
||||
* directly against a synthetic clock.
|
||||
*/
|
||||
export class ParameterSchedule {
|
||||
private current: number;
|
||||
private pendingCurves: PendingCurve[] = [];
|
||||
private pendingAt: ParameterAtTransport[] = [];
|
||||
|
||||
public constructor(initialValue: number) {
|
||||
this.current = initialValue;
|
||||
}
|
||||
|
||||
public get value(): number {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
public set(value: number): void {
|
||||
this.current = value;
|
||||
// A direct `set` overrides any pending curves whose deadline has not
|
||||
// yet fired; transport-time events stay pending because they target
|
||||
// a specific musical moment.
|
||||
this.pendingCurves = [];
|
||||
}
|
||||
|
||||
public scheduleCurve(curve: ParameterCurve, nowSec: number): void {
|
||||
const startSec = curve.startTime ?? nowSec;
|
||||
// `target` is a first-order approach: it never reaches the target
|
||||
// exactly but is ≥ 99.3 % complete after 5τ. Drain at 5τ to bound
|
||||
// memory; linear / exponential drain at their declared end.
|
||||
const drainSec =
|
||||
curve.type === "target"
|
||||
? startSec + Math.max(0, curve.duration) * 5
|
||||
: startSec + Math.max(0, curve.duration);
|
||||
this.pendingCurves.push({
|
||||
type: curve.type,
|
||||
startSec,
|
||||
durationSec: Math.max(0, curve.duration),
|
||||
endSec: drainSec,
|
||||
startValue: undefined,
|
||||
targetValue: curve.value,
|
||||
});
|
||||
this.pendingCurves.sort((a, b) => a.endSec - b.endSec);
|
||||
}
|
||||
|
||||
public scheduleAt(value: number, transportTimeSec: number): void {
|
||||
this.pendingAt.push({ value, transportTimeSec });
|
||||
this.pendingAt.sort((a, b) => a.transportTimeSec - b.transportTimeSec);
|
||||
}
|
||||
|
||||
public valueAtBlockOffset(
|
||||
nowSec: number,
|
||||
transportTimeSec: number
|
||||
): number {
|
||||
// Drain wall-clock curves whose end is at or before `nowSec`.
|
||||
while (
|
||||
this.pendingCurves.length > 0 &&
|
||||
this.pendingCurves[0]!.endSec <= nowSec
|
||||
) {
|
||||
const c = this.pendingCurves.shift()!;
|
||||
this.current = c.targetValue;
|
||||
}
|
||||
|
||||
// Apply transport-time events whose deadline is at or before
|
||||
// `transportTimeSec`.
|
||||
while (
|
||||
this.pendingAt.length > 0 &&
|
||||
this.pendingAt[0]!.transportTimeSec <= transportTimeSec
|
||||
) {
|
||||
const event = this.pendingAt.shift()!;
|
||||
this.current = event.value;
|
||||
// Drop any in-flight curve that would have continued past this
|
||||
// point: transport-time events are authoritative.
|
||||
this.pendingCurves = [];
|
||||
}
|
||||
|
||||
// Interpolate the head curve if we're mid-ramp.
|
||||
const head = this.pendingCurves[0];
|
||||
if (head === undefined) {
|
||||
return this.current;
|
||||
}
|
||||
if (nowSec < head.startSec) {
|
||||
return this.current;
|
||||
}
|
||||
if (head.startValue === undefined) {
|
||||
head.startValue = this.current;
|
||||
}
|
||||
return interpolateCurve(head, nowSec);
|
||||
}
|
||||
|
||||
public reset(transportTimeSec: number): void {
|
||||
// Drop any wall-clock curve that has already started — there is no
|
||||
// meaningful "transport" reference for wall-clock ramps, so a reset
|
||||
// truncates them.
|
||||
this.pendingCurves = [];
|
||||
this.pendingAt = this.pendingAt.filter(
|
||||
(event) => event.transportTimeSec > transportTimeSec
|
||||
);
|
||||
}
|
||||
|
||||
public get pendingCurveCount(): number {
|
||||
return this.pendingCurves.length;
|
||||
}
|
||||
|
||||
public get pendingTransportEventCount(): number {
|
||||
return this.pendingAt.length;
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingCurve {
|
||||
readonly type: "linear" | "exponential" | "target";
|
||||
readonly startSec: number;
|
||||
readonly durationSec: number;
|
||||
readonly endSec: number;
|
||||
startValue: number | undefined;
|
||||
readonly targetValue: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolates a pending curve at `nowSec`. The caller in
|
||||
* `valueAtBlockOffset` guarantees:
|
||||
*
|
||||
* - `curve.startValue` is captured before the call;
|
||||
* - `nowSec >= curve.startSec` (the caller skips future-start curves);
|
||||
* - `nowSec < curve.endSec` (completed curves are drained before).
|
||||
*
|
||||
* Therefore no defensive branches for `startValue === undefined` or
|
||||
* `span <= 0` are needed here; a zero-duration ramp is treated as an
|
||||
* instantaneous jump at the curve's drain step.
|
||||
*/
|
||||
function interpolateCurve(curve: PendingCurve, nowSec: number): number {
|
||||
const startValue = curve.startValue as number;
|
||||
const span = curve.durationSec;
|
||||
const elapsed = nowSec - curve.startSec;
|
||||
switch (curve.type) {
|
||||
case "linear": {
|
||||
const t = Math.min(1, Math.max(0, elapsed / span));
|
||||
return startValue + (curve.targetValue - startValue) * t;
|
||||
}
|
||||
case "exponential": {
|
||||
const t = Math.min(1, Math.max(0, elapsed / span));
|
||||
const safeStart = startValue === 0 ? Number.EPSILON : startValue;
|
||||
const safeTarget =
|
||||
curve.targetValue === 0 ? Number.EPSILON : curve.targetValue;
|
||||
const ratio = safeTarget / safeStart;
|
||||
return safeStart * ratio ** t;
|
||||
}
|
||||
case "target": {
|
||||
// First-order time-constant approach — never clamps. Active until
|
||||
// the schedule drains the curve at 5τ.
|
||||
const tau = Math.max(Number.EPSILON, span);
|
||||
const k = 1 - Math.exp(-Math.max(0, elapsed) / tau);
|
||||
return startValue + (curve.targetValue - startValue) * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Display unit for a plugin parameter. UI renderers pick a control
|
||||
* (linear slider, log knob, segmented control) based on the unit, and
|
||||
* format `aria-valuetext` against it.
|
||||
*/
|
||||
export type ParameterUnit =
|
||||
| "db"
|
||||
| "hz"
|
||||
| "ms"
|
||||
| "percent"
|
||||
| "semitones"
|
||||
| "samples"
|
||||
| "ratio"
|
||||
| "enum";
|
||||
|
||||
/**
|
||||
* Numeric scaling for a parameter — used by the UI to map a 0..1
|
||||
* control position to / from the parameter's `[min, max]` range. The
|
||||
* schedule evaluator interpolates linearly regardless of scale.
|
||||
*/
|
||||
export type ParameterScale = "linear" | "log" | "exponential";
|
||||
|
||||
/**
|
||||
* Static declaration of a single plugin parameter. Plugins expose
|
||||
* `parameters: ReadonlyArray<PluginParameter>` in declaration order
|
||||
* (for UI iteration) plus `parameterIndex: ReadonlyMap<string, PluginParameter>`
|
||||
* for O(1) lookup.
|
||||
*/
|
||||
export interface PluginParameter {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly value: number;
|
||||
readonly min: number;
|
||||
readonly max: number;
|
||||
readonly default: number;
|
||||
readonly unit?: ParameterUnit;
|
||||
readonly scale?: ParameterScale;
|
||||
readonly automatable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A scheduled parameter ramp expressed in wall-clock seconds.
|
||||
*
|
||||
* - `linear` — straight line from current value to `value` over `duration`.
|
||||
* - `exponential` — exponential curve (Web-Audio-equivalent semantics).
|
||||
* - `target` — first-order approach toward `value` with time constant
|
||||
* `duration` (never quite reaches, asymptotes).
|
||||
*
|
||||
* `startTime` is wall-clock seconds; when absent, the ramp starts at the
|
||||
* next process block.
|
||||
*/
|
||||
export interface ParameterCurve {
|
||||
readonly type: "linear" | "exponential" | "target";
|
||||
readonly value: number;
|
||||
readonly duration: number;
|
||||
readonly startTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A scheduled value-at-transport-time event. Resolved against the active
|
||||
* `Transport.transportTimeSec` (carried on `AmbientState`) so musical-time
|
||||
* automation lanes stay tempo-synchronous.
|
||||
*/
|
||||
export interface ParameterAtTransport {
|
||||
readonly value: number;
|
||||
readonly transportTimeSec: number;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
assertChannelMatch,
|
||||
assertNotAborted,
|
||||
requireAutomatable,
|
||||
requireParameter,
|
||||
} from "./plugin-internals.js";
|
||||
@@ -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,88 @@
|
||||
import { AUDIO_FX_CODES, AudioFxError } from "../../error-codes/error-codes.js";
|
||||
import type { PluginParameter } from "../parameter.types.js";
|
||||
|
||||
/** Throws `AUDIO_FX.ABORTED` when `signal` is aborted. No-op otherwise. */
|
||||
export function assertNotAborted(
|
||||
signal: AbortSignal | undefined,
|
||||
pluginId: string,
|
||||
messageKey: string
|
||||
): void {
|
||||
if (signal?.aborted) {
|
||||
throw new AudioFxError(AUDIO_FX_CODES.ABORTED, messageKey, {
|
||||
context: { pluginId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws `AUDIO_FX.CHANNEL_MISMATCH` when either buffer disagrees with
|
||||
* the resolved input / output channel counts. `expectedInput` and
|
||||
* `expectedOutput` may differ for source plugins (`expectedInput === 0`)
|
||||
* and for fixed-output plugins like the sampler (`expectedOutput === 1`).
|
||||
*/
|
||||
export function assertChannelMatch(
|
||||
input: ReadonlyArray<unknown>,
|
||||
output: ReadonlyArray<unknown>,
|
||||
expectedInput: number,
|
||||
expectedOutput: number,
|
||||
pluginId: string
|
||||
): void {
|
||||
// Source plugins (expectedInput === 0) accept any input buffer count
|
||||
// because the chain passes its own input through unchanged to slot 0
|
||||
// even when the plugin produces audio rather than consuming it.
|
||||
const inputOk = expectedInput === 0 ? true : input.length === expectedInput;
|
||||
if (inputOk && output.length === expectedOutput) {
|
||||
return;
|
||||
}
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.CHANNEL_MISMATCH,
|
||||
"audio-fx.channel-mismatch",
|
||||
{
|
||||
context: {
|
||||
expectedInput,
|
||||
expectedOutput,
|
||||
inputLength: input.length,
|
||||
outputLength: output.length,
|
||||
pluginId,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the `PluginParameter` for `id` or throws `AUDIO_FX.UNKNOWN_PARAMETER`. */
|
||||
export function requireParameter(
|
||||
parameterIndex: ReadonlyMap<string, PluginParameter>,
|
||||
id: string,
|
||||
pluginId: string
|
||||
): PluginParameter {
|
||||
const param = parameterIndex.get(id);
|
||||
if (param === undefined) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.UNKNOWN_PARAMETER,
|
||||
"audio-fx.unknown-parameter",
|
||||
{ context: { paramId: id, pluginId } }
|
||||
);
|
||||
}
|
||||
return param;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parameter for `id` after asserting it is automatable.
|
||||
* Throws `AUDIO_FX.PARAMETER_NOT_AUTOMATABLE` when the parameter
|
||||
* declares `automatable: false`.
|
||||
*/
|
||||
export function requireAutomatable(
|
||||
parameterIndex: ReadonlyMap<string, PluginParameter>,
|
||||
id: string,
|
||||
pluginId: string
|
||||
): PluginParameter {
|
||||
const param = requireParameter(parameterIndex, id, pluginId);
|
||||
if (param.automatable !== false) {
|
||||
return param;
|
||||
}
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.PARAMETER_NOT_AUTOMATABLE,
|
||||
"audio-fx.parameter-not-automatable",
|
||||
{ context: { paramId: id, pluginId } }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# `plugin-io`
|
||||
|
||||
## Purpose
|
||||
|
||||
The `plugin-io` module declares the **channel-topology contract** every `AudioFxPlugin` carries (`PluginIo`, `ChannelSpec`, `ChannelLayout`) and the helpers (`validateChainIo`, `resolveOutputChannels`, `isSourcePlugin`, `ioPreservesChannelCount`) the chain primitive uses to admit or reject plugin insertions at the boundary, before any audio flows. Declaring topology in data — not via per-plugin assertions in `process()` — lets the chain prove compatibility once at insert time and surface a precise, human-readable error in the UI on mismatch.
|
||||
|
||||
## Features
|
||||
|
||||
- Three `ChannelSpec` shapes for plugin authors: explicit integer (`{ count: 2 }`), inherited (`{ count: 'preserve' }`), opaque (`{ count: 'any' }`), plus the special source declaration (`{ count: 0 }`) reserved for slot-0 source plugins (synth/sampler/audio-player/music-layer-mixer).
|
||||
- Layout hints (`ChannelLayout`: mono/stereo/mid-side/5.1/7.1/ambisonic-1/arbitrary) for plugins that need to express semantics, not just channel count.
|
||||
- `validateChainIo(predecessorOutputChannels, candidateInput)` — returns a discriminated `IoCompatibility` result (`ok: true` with resolved channel count, or `ok: false` with a `reason` string suitable for `throw`-with-context or UI toast).
|
||||
- `resolveOutputChannels(spec, inputChannels)` — used after admission to compute the effective channel count flowing out of a slot.
|
||||
- `isSourcePlugin(io)` — true iff `inputs[0].count === 0`; chain uses this to enforce the at-most-one-source-at-slot-0 rule.
|
||||
- `ioPreservesChannelCount(io)` — feeds `PluginCapabilities.preservesChannelCount` so engines can choose render paths.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { validateChainIo } from "@suaveplan/audio-fx-plugin-base/base";
|
||||
|
||||
const next = { count: 2 } as const; // stereo plugin
|
||||
const compat = validateChainIo(predecessorOutputChannels, next);
|
||||
if (!compat.ok) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.CHAIN_IO_INCOMPATIBLE,
|
||||
"audio-fx.chain-io-incompatible",
|
||||
{ context: { reason: compat.reason } }
|
||||
);
|
||||
}
|
||||
const effectiveOut = compat.resolvedChannels; // 2
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ioPreservesChannelCount,
|
||||
isSourcePlugin,
|
||||
resolveOutputChannels,
|
||||
} from "@suaveplan/audio-fx-plugin-base/base";
|
||||
|
||||
function declareCapabilities(io: PluginIo, latencySamples = 0): PluginCapabilities {
|
||||
return {
|
||||
hasAudioNode: false,
|
||||
hasWorklet: false,
|
||||
hasSidechain: false,
|
||||
sampleAccurate: true,
|
||||
stateful: false,
|
||||
asyncSetup: false,
|
||||
preservesChannelCount: ioPreservesChannelCount(io),
|
||||
latencySamples,
|
||||
};
|
||||
}
|
||||
|
||||
// Walking a chain to compute per-slot effective output channel count:
|
||||
let channels = chainInputChannels;
|
||||
for (const slot of chain) {
|
||||
if (isSourcePlugin(slot.plugin.io)) {
|
||||
channels = resolveOutputChannels(slot.plugin.io.outputs[0]!, 0);
|
||||
continue;
|
||||
}
|
||||
channels = resolveOutputChannels(slot.plugin.io.outputs[0]!, channels);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ChannelLayout` — string union of supported layouts (mono / stereo / mid-side / 5.1 / 7.1 / ambisonic-1 / arbitrary).
|
||||
- `ChannelSpec` — `{ count: number | 'preserve' | 'any'; layout?: ChannelLayout }`.
|
||||
- `PluginIo` — `{ inputs: ReadonlyArray<ChannelSpec>; outputs: ReadonlyArray<ChannelSpec> }`.
|
||||
- `IoCompatibility` — discriminated union: `{ ok: true; resolvedChannels: number } | { ok: false; reason: string }`.
|
||||
- `validateChainIo(predecessorChannels: number, candidateInput: ChannelSpec): IoCompatibility` — never throws.
|
||||
- `resolveOutputChannels(spec: ChannelSpec, inputChannels: number): number` — returns `inputChannels` for `'preserve'`/`'any'`, otherwise `spec.count`.
|
||||
- `isSourcePlugin(io: PluginIo): boolean` — true iff `inputs[0].count === 0`.
|
||||
- `ioPreservesChannelCount(io: PluginIo): boolean` — true iff the IO claims same channel count on input and output.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`validateChainIo` returns rather than throws because chain composition needs to choose between fail-closed insertion (`throw AUDIO_FX.CHAIN_IO_INCOMPATIBLE`) and informational dry-runs (e.g. the plugin picker UI greys out plugins whose IO would not fit the current chain). The chain itself owns the `throw`; this module owns the compatibility decision.
|
||||
|
||||
The `'preserve'` and `'any'` shapes look identical in `resolveOutputChannels` and `validateChainIo`, but they differ in intent for tooling. `'preserve'` declares "I will not change the channel count" (e.g. EQ, gain); `'any'` declares "I accept whatever you give me" (e.g. spectrum analyser). Both helpers treat them identically for the purposes of admission; the IDE-facing tooling may surface them differently.
|
||||
|
||||
A source plugin sitting at slot 0 with `inputs[0].count === 0` is the only legal placement: insertion elsewhere throws `AUDIO_FX.CHAIN_SOURCE_POSITION_INVALID` (raised by the chain, not here). Likewise, two source plugins in one chain is rejected by the chain — this module's `isSourcePlugin` only answers the per-instance question.
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ioPreservesChannelCount,
|
||||
isSourcePlugin,
|
||||
resolveOutputChannels,
|
||||
validateChainIo,
|
||||
} from "./plugin-io.js";
|
||||
import type { PluginIo } from "./plugin-io.types.js";
|
||||
|
||||
describe("plugin-io: validateChainIo", () => {
|
||||
it("accepts matching counts", { timeout: 5_000 }, () => {
|
||||
const result = validateChainIo(2, { count: 2 });
|
||||
expect(result).toEqual({ ok: true, resolvedChannels: 2 });
|
||||
});
|
||||
|
||||
it("accepts 'preserve' and reports the predecessor count", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const result = validateChainIo(5, { count: "preserve" });
|
||||
expect(result).toEqual({ ok: true, resolvedChannels: 5 });
|
||||
});
|
||||
|
||||
it("accepts 'any' and reports the predecessor count", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const result = validateChainIo(7, { count: "any" });
|
||||
expect(result).toEqual({ ok: true, resolvedChannels: 7 });
|
||||
});
|
||||
|
||||
it("rejects mismatched counts with a descriptive reason", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const result = validateChainIo(2, { count: 1 });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toMatch(
|
||||
/2 channel\(s\) but the next plugin's input declares 1/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin-io: resolveOutputChannels", () => {
|
||||
it("returns explicit count verbatim", { timeout: 5_000 }, () => {
|
||||
expect(resolveOutputChannels({ count: 2 }, 8)).toBe(2);
|
||||
});
|
||||
|
||||
it("returns the input count for 'preserve'", { timeout: 5_000 }, () => {
|
||||
expect(resolveOutputChannels({ count: "preserve" }, 6)).toBe(6);
|
||||
});
|
||||
|
||||
it("returns the input count for 'any'", { timeout: 5_000 }, () => {
|
||||
expect(resolveOutputChannels({ count: "any" }, 4)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin-io: isSourcePlugin", () => {
|
||||
it("returns true when inputs[0].count === 0", { timeout: 5_000 }, () => {
|
||||
const io: PluginIo = {
|
||||
inputs: [{ count: 0 }],
|
||||
outputs: [{ count: 2 }],
|
||||
};
|
||||
expect(isSourcePlugin(io)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when inputs[0].count is a positive number", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const io: PluginIo = {
|
||||
inputs: [{ count: 2 }],
|
||||
outputs: [{ count: 2 }],
|
||||
};
|
||||
expect(isSourcePlugin(io)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when inputs is empty", { timeout: 5_000 }, () => {
|
||||
const io: PluginIo = { inputs: [], outputs: [{ count: 2 }] };
|
||||
expect(isSourcePlugin(io)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when inputs[0] is 'preserve' or 'any'", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const ioA: PluginIo = {
|
||||
inputs: [{ count: "preserve" }],
|
||||
outputs: [{ count: "preserve" }],
|
||||
};
|
||||
const ioB: PluginIo = {
|
||||
inputs: [{ count: "any" }],
|
||||
outputs: [{ count: "preserve" }],
|
||||
};
|
||||
expect(isSourcePlugin(ioA)).toBe(false);
|
||||
expect(isSourcePlugin(ioB)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin-io: ioPreservesChannelCount", () => {
|
||||
it("returns true for 'preserve' output", { timeout: 5_000 }, () => {
|
||||
const io: PluginIo = {
|
||||
inputs: [{ count: 2 }],
|
||||
outputs: [{ count: "preserve" }],
|
||||
};
|
||||
expect(ioPreservesChannelCount(io)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for 'any' output", { timeout: 5_000 }, () => {
|
||||
const io: PluginIo = {
|
||||
inputs: [{ count: 2 }],
|
||||
outputs: [{ count: "any" }],
|
||||
};
|
||||
expect(ioPreservesChannelCount(io)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when explicit in/out counts match", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const io: PluginIo = {
|
||||
inputs: [{ count: 2 }],
|
||||
outputs: [{ count: 2 }],
|
||||
};
|
||||
expect(ioPreservesChannelCount(io)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when explicit in/out counts differ", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const io: PluginIo = {
|
||||
inputs: [{ count: 1 }],
|
||||
outputs: [{ count: 2 }],
|
||||
};
|
||||
expect(ioPreservesChannelCount(io)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when input is 'preserve' or 'any' for explicit output", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const ioA: PluginIo = {
|
||||
inputs: [{ count: "preserve" }],
|
||||
outputs: [{ count: 2 }],
|
||||
};
|
||||
const ioB: PluginIo = {
|
||||
inputs: [{ count: "any" }],
|
||||
outputs: [{ count: 2 }],
|
||||
};
|
||||
expect(ioPreservesChannelCount(ioA)).toBe(true);
|
||||
expect(ioPreservesChannelCount(ioB)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when outputs is empty", { timeout: 5_000 }, () => {
|
||||
const io: PluginIo = { inputs: [{ count: 2 }], outputs: [] };
|
||||
expect(ioPreservesChannelCount(io)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when inputs is empty and output is explicit", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const io: PluginIo = { inputs: [], outputs: [{ count: 2 }] };
|
||||
expect(ioPreservesChannelCount(io)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type {
|
||||
ChannelSpec,
|
||||
IoCompatibility,
|
||||
PluginIo,
|
||||
} from "./plugin-io.types.js";
|
||||
|
||||
/**
|
||||
* Resolves the effective output channel count of a `ChannelSpec` given
|
||||
* the predecessor's channel count. `'preserve'` and `'any'` pass through.
|
||||
*/
|
||||
export function resolveOutputChannels(
|
||||
spec: ChannelSpec,
|
||||
inputChannels: number
|
||||
): number {
|
||||
if (spec.count === "preserve" || spec.count === "any") {
|
||||
return inputChannels;
|
||||
}
|
||||
return spec.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a `(predecessorOutputChannels, candidateInputSpec)` pair
|
||||
* is compatible. Used by `PluginChain.add`/`move`/`replace` to fail-closed
|
||||
* at insertion time before any audio flows.
|
||||
*/
|
||||
export function validateChainIo(
|
||||
predecessorOutputChannels: number,
|
||||
candidateInput: ChannelSpec
|
||||
): IoCompatibility {
|
||||
if (candidateInput.count === "any") {
|
||||
return { ok: true, resolvedChannels: predecessorOutputChannels };
|
||||
}
|
||||
if (candidateInput.count === "preserve") {
|
||||
return { ok: true, resolvedChannels: predecessorOutputChannels };
|
||||
}
|
||||
if (candidateInput.count === predecessorOutputChannels) {
|
||||
return { ok: true, resolvedChannels: predecessorOutputChannels };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Channel-count mismatch: predecessor outputs ${predecessorOutputChannels} channel(s) but the next plugin's input declares ${candidateInput.count}.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the plugin's first input is a "source" declaration
|
||||
* (`count === 0`). Source plugins are valid only at slot 0 of a chain.
|
||||
*/
|
||||
export function isSourcePlugin(io: PluginIo): boolean {
|
||||
const first = io.inputs[0];
|
||||
return first !== undefined && first.count === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the IO declares same channel count on input and
|
||||
* output (or `'preserve'` on output) — used to populate
|
||||
* `PluginCapabilities.preservesChannelCount`.
|
||||
*/
|
||||
export function ioPreservesChannelCount(io: PluginIo): boolean {
|
||||
const firstOut = io.outputs[0];
|
||||
if (firstOut === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (firstOut.count === "preserve" || firstOut.count === "any") {
|
||||
return true;
|
||||
}
|
||||
const firstIn = io.inputs[0];
|
||||
if (firstIn === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (firstIn.count === "any" || firstIn.count === "preserve") {
|
||||
return true;
|
||||
}
|
||||
return firstIn.count === firstOut.count;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Declared channel layout for a single chain edge — used both on plugin
|
||||
* inputs and outputs so the chain can prove compatibility at insertion time.
|
||||
*
|
||||
* - `'mono'` / `'stereo'` — the everyday cases.
|
||||
* - `'mid-side'` — common in mastering plugins; the chain may need to insert
|
||||
* a converter on either side.
|
||||
* - `'5.1'` / `'7.1'` — surround formats with the WAVE channel order
|
||||
* (FL, FR, FC, LFE, BL, BR …).
|
||||
* - `'ambisonic-1'` — first-order ambisonic (W, Y, Z, X).
|
||||
* - `'arbitrary'` — opaque multichannel; the chain treats it as opaque.
|
||||
*/
|
||||
export type ChannelLayout =
|
||||
| "mono"
|
||||
| "stereo"
|
||||
| "mid-side"
|
||||
| "5.1"
|
||||
| "7.1"
|
||||
| "ambisonic-1"
|
||||
| "arbitrary";
|
||||
|
||||
/**
|
||||
* One edge in a plugin's declared IO. `count` can be:
|
||||
*
|
||||
* - a positive integer (the plugin demands exactly this many channels);
|
||||
* - `'preserve'` — match the predecessor's output count exactly;
|
||||
* - `'any'` — accept whatever count is offered (analysers, sniffers).
|
||||
*
|
||||
* `count === 0` is reserved for **source plugins** (synthesizer, sampler,
|
||||
* audio-player, music-layer-mixer) and is only valid on `inputs[0]` of a
|
||||
* plugin placed at slot 0 of a chain.
|
||||
*/
|
||||
export interface ChannelSpec {
|
||||
readonly count: number | "preserve" | "any";
|
||||
readonly layout?: ChannelLayout;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full IO topology of a plugin. Validated once at construction and
|
||||
* read every time the chain considers an `add`/`move`/`replace`.
|
||||
*/
|
||||
export interface PluginIo {
|
||||
readonly inputs: ReadonlyArray<ChannelSpec>;
|
||||
readonly outputs: ReadonlyArray<ChannelSpec>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of validating a single `(predecessorOutput, candidateInput)`
|
||||
* edge. `ok: true` indicates the chain may admit the connection unchanged;
|
||||
* `ok: false` carries a human-readable `reason` for surfacing to the UI.
|
||||
*/
|
||||
export type IoCompatibility =
|
||||
| { readonly ok: true; readonly resolvedChannels: number }
|
||||
| { readonly ok: false; readonly reason: string };
|
||||
@@ -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,177 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AUDIO_FX_CODES, AudioFxError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
assertPresetMatchesPluginType,
|
||||
assertPresetVersionSupported,
|
||||
PresetSnapshotSchema,
|
||||
parsePresetSnapshot,
|
||||
} from "./preset.js";
|
||||
|
||||
describe("preset: parsePresetSnapshot", () => {
|
||||
it("accepts a minimal valid envelope", { timeout: 5_000 }, () => {
|
||||
const parsed = parsePresetSnapshot({
|
||||
schemaVersion: 1,
|
||||
pluginType: "eq",
|
||||
parameters: { gain: 0 },
|
||||
});
|
||||
expect(parsed.schemaVersion).toBe(1);
|
||||
expect(parsed.pluginType).toBe("eq");
|
||||
expect(parsed.parameters.gain).toBe(0);
|
||||
expect(parsed.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts an envelope with plugin-specific data blob", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const parsed = parsePresetSnapshot({
|
||||
schemaVersion: 2,
|
||||
pluginType: "eq",
|
||||
parameters: {},
|
||||
data: { bands: [{ freq: 100, gain: 3 }] },
|
||||
});
|
||||
expect(parsed.data).toEqual({ bands: [{ freq: 100, gain: 3 }] });
|
||||
});
|
||||
|
||||
it("rejects missing required fields with AUDIO_FX.INVALID_PRESET", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
try {
|
||||
parsePresetSnapshot({ pluginType: "eq", parameters: {} });
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AudioFxError);
|
||||
const fxError = error as AudioFxError;
|
||||
expect(fxError.code).toBe(AUDIO_FX_CODES.INVALID_PRESET);
|
||||
expect(fxError.context?.issues).toBeDefined();
|
||||
return;
|
||||
}
|
||||
throw new Error("expected parsePresetSnapshot to throw");
|
||||
});
|
||||
|
||||
it("rejects negative schemaVersion", { timeout: 5_000 }, () => {
|
||||
try {
|
||||
parsePresetSnapshot({
|
||||
schemaVersion: -1,
|
||||
pluginType: "eq",
|
||||
parameters: {},
|
||||
});
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_PRESET
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected parsePresetSnapshot to throw");
|
||||
});
|
||||
|
||||
it("rejects non-number parameter values", { timeout: 5_000 }, () => {
|
||||
try {
|
||||
parsePresetSnapshot({
|
||||
schemaVersion: 1,
|
||||
pluginType: "eq",
|
||||
parameters: { gain: "loud" },
|
||||
});
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_PRESET
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected parsePresetSnapshot to throw");
|
||||
});
|
||||
|
||||
it("rejects empty pluginType", { timeout: 5_000 }, () => {
|
||||
try {
|
||||
parsePresetSnapshot({
|
||||
schemaVersion: 1,
|
||||
pluginType: "",
|
||||
parameters: {},
|
||||
});
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_PRESET
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected parsePresetSnapshot to throw");
|
||||
});
|
||||
});
|
||||
|
||||
describe("preset: PresetSnapshotSchema direct usage", () => {
|
||||
it("exposes the same schema for callers that want safeParse directly", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const ok = PresetSnapshotSchema.safeParse({
|
||||
schemaVersion: 1,
|
||||
pluginType: "eq",
|
||||
parameters: {},
|
||||
});
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preset: assertPresetVersionSupported", () => {
|
||||
it("passes when preset version <= supported", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const preset = parsePresetSnapshot({
|
||||
schemaVersion: 1,
|
||||
pluginType: "eq",
|
||||
parameters: {},
|
||||
});
|
||||
expect(() => assertPresetVersionSupported(preset, 1)).not.toThrow();
|
||||
expect(() => assertPresetVersionSupported(preset, 2)).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws AUDIO_FX.PRESET_VERSION_UNSUPPORTED when preset version > supported", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const preset = parsePresetSnapshot({
|
||||
schemaVersion: 3,
|
||||
pluginType: "eq",
|
||||
parameters: {},
|
||||
});
|
||||
try {
|
||||
assertPresetVersionSupported(preset, 1);
|
||||
} catch (error) {
|
||||
const fxError = error as AudioFxError;
|
||||
expect(fxError.code).toBe(
|
||||
AUDIO_FX_CODES.PRESET_VERSION_UNSUPPORTED
|
||||
);
|
||||
expect(fxError.context?.presetVersion).toBe(3);
|
||||
expect(fxError.context?.supported).toBe(1);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected assertPresetVersionSupported to throw");
|
||||
});
|
||||
});
|
||||
|
||||
describe("preset: assertPresetMatchesPluginType", () => {
|
||||
it("passes when pluginType matches", { timeout: 5_000 }, () => {
|
||||
const preset = parsePresetSnapshot({
|
||||
schemaVersion: 1,
|
||||
pluginType: "eq",
|
||||
parameters: {},
|
||||
});
|
||||
expect(() => assertPresetMatchesPluginType(preset, "eq")).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws AUDIO_FX.INVALID_PRESET when pluginType differs", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const preset = parsePresetSnapshot({
|
||||
schemaVersion: 1,
|
||||
pluginType: "reverb",
|
||||
parameters: {},
|
||||
});
|
||||
try {
|
||||
assertPresetMatchesPluginType(preset, "eq");
|
||||
} catch (error) {
|
||||
const fxError = error as AudioFxError;
|
||||
expect(fxError.code).toBe(AUDIO_FX_CODES.INVALID_PRESET);
|
||||
expect(fxError.context?.presetPluginType).toBe("reverb");
|
||||
expect(fxError.context?.expected).toBe("eq");
|
||||
return;
|
||||
}
|
||||
throw new Error("expected assertPresetMatchesPluginType to throw");
|
||||
});
|
||||
});
|
||||
148
packages/media/audio-fx/audio-fx-plugin-base/src/base/preset.ts
Normal file
148
packages/media/audio-fx/audio-fx-plugin-base/src/base/preset.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import z, { type ZodType } from "zod";
|
||||
import { AUDIO_FX_CODES, AudioFxError } from "../error-codes/error-codes.js";
|
||||
import type { PluginParameter } from "./parameter.types.js";
|
||||
import type { PresetSnapshot } from "./preset.types.js";
|
||||
|
||||
/**
|
||||
* Zod schema for the universal preset envelope. Plugin-specific `data`
|
||||
* shapes are validated separately by the plugin's own Zod schema.
|
||||
*/
|
||||
export const PresetSnapshotSchema = z.object({
|
||||
schemaVersion: z.number().int().nonnegative(),
|
||||
pluginType: z.string().min(1),
|
||||
parameters: z.record(z.string(), z.number()),
|
||||
data: z.unknown().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates a preset envelope against the universal schema. Throws
|
||||
* `AUDIO_FX.INVALID_PRESET` with the Zod issue list on failure.
|
||||
*/
|
||||
export function parsePresetSnapshot(input: unknown): PresetSnapshot {
|
||||
const result = PresetSnapshotSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_PRESET,
|
||||
"audio-fx.invalid-preset",
|
||||
{ context: { issues: result.error.issues } }
|
||||
);
|
||||
}
|
||||
return result.data as PresetSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the preset is consumable by a plugin that supports up to
|
||||
* `supportedSchemaVersion`. Throws `AUDIO_FX.PRESET_VERSION_UNSUPPORTED`
|
||||
* when the preset is from a newer plugin build.
|
||||
*/
|
||||
export function assertPresetVersionSupported(
|
||||
preset: PresetSnapshot,
|
||||
supportedSchemaVersion: number
|
||||
): void {
|
||||
if (preset.schemaVersion > supportedSchemaVersion) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.PRESET_VERSION_UNSUPPORTED,
|
||||
"audio-fx.preset-version-unsupported",
|
||||
{
|
||||
context: {
|
||||
presetVersion: preset.schemaVersion,
|
||||
supported: supportedSchemaVersion,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the preset targets the expected plugin type. The chain's
|
||||
* `restore` path uses `pluginType` to instantiate via registry; once
|
||||
* the plugin instance exists, this guard confirms the preset matches
|
||||
* before any parameter is mutated.
|
||||
*/
|
||||
export function assertPresetMatchesPluginType(
|
||||
preset: PresetSnapshot,
|
||||
pluginType: string
|
||||
): void {
|
||||
if (preset.pluginType !== pluginType) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_PRESET,
|
||||
"audio-fx.preset-plugin-type-mismatch",
|
||||
{
|
||||
context: {
|
||||
presetPluginType: preset.pluginType,
|
||||
expected: pluginType,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws `AUDIO_FX.INVALID_PRESET` when `data` fails `schema`. No-op on `undefined`. */
|
||||
export function validatePresetData(
|
||||
schema: ZodType<unknown>,
|
||||
data: unknown,
|
||||
pluginId: string
|
||||
): void {
|
||||
if (data === undefined) {
|
||||
return;
|
||||
}
|
||||
const parsed = schema.safeParse(data);
|
||||
if (parsed.success) {
|
||||
return;
|
||||
}
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_PRESET,
|
||||
"audio-fx.invalid-preset-data",
|
||||
{ context: { pluginId, issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
|
||||
interface SchedSetter {
|
||||
set(value: number): void;
|
||||
}
|
||||
|
||||
/** Apply a preset's `parameters` map by clamping to each parameter's range. */
|
||||
export function applyPresetParameters(
|
||||
parameters: Readonly<Record<string, number>>,
|
||||
schedules: ReadonlyMap<string, SchedSetter>,
|
||||
parameterIndex: ReadonlyMap<string, PluginParameter>
|
||||
): void {
|
||||
for (const [paramId, value] of Object.entries(parameters)) {
|
||||
const sched = schedules.get(paramId);
|
||||
const param = parameterIndex.get(paramId);
|
||||
if (sched !== undefined && param !== undefined) {
|
||||
sched.set(clamp(value, param.min, param.max));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Clamp `value` to `[min, max]`. */
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
if (value < min) {
|
||||
return min;
|
||||
}
|
||||
if (value > max) {
|
||||
return max;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
interface SchedReader {
|
||||
readonly value: number;
|
||||
}
|
||||
|
||||
/** Build the universal preset envelope from a plugin's live state. */
|
||||
export function buildPresetSnapshot(
|
||||
pluginType: string,
|
||||
schemaVersion: number,
|
||||
schedules: ReadonlyMap<string, SchedReader>,
|
||||
data: unknown
|
||||
): PresetSnapshot {
|
||||
const parameters: Record<string, number> = {};
|
||||
for (const [id, sched] of schedules) {
|
||||
parameters[id] = sched.value;
|
||||
}
|
||||
return data === undefined
|
||||
? { schemaVersion, pluginType, parameters }
|
||||
: { schemaVersion, pluginType, parameters, data };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* A serialisable preset snapshot for a single plugin instance.
|
||||
*
|
||||
* - `schemaVersion` — monotonic integer. Plugins refuse presets whose
|
||||
* `schemaVersion` exceeds their declared `supportedSchemaVersion`.
|
||||
* - `pluginType` — the plugin's `metadata.id`. Used by the chain's
|
||||
* snapshot/restore path to look up the right factory in the
|
||||
* `PluginRegistry`; redundant when a preset is applied via
|
||||
* `plugin.applyPreset(snapshot)` directly but kept for round-trip
|
||||
* integrity.
|
||||
* - `parameters` — `paramId → value`. Unknown ids are tolerated
|
||||
* (forwards-compat) but values are still type-checked.
|
||||
* - `data` — plugin-specific opaque blob (e.g. EQ `bands`, sampler
|
||||
* sample manifest). Validated against the plugin's own Zod schema
|
||||
* at apply time.
|
||||
*/
|
||||
export interface PresetSnapshot {
|
||||
readonly schemaVersion: number;
|
||||
readonly pluginType: string;
|
||||
readonly parameters: Readonly<Record<string, number>>;
|
||||
readonly data?: unknown;
|
||||
}
|
||||
@@ -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,118 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { type BiquadBand, computeBiquad, identityCoeffs } from "./biquad.js";
|
||||
|
||||
const SAMPLE_RATE = 48_000;
|
||||
|
||||
function band(overrides: Partial<BiquadBand> = {}): BiquadBand {
|
||||
return {
|
||||
frequency: 1000,
|
||||
gain: 0,
|
||||
q: Math.SQRT1_2,
|
||||
type: "peaking",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("identityCoeffs", () => {
|
||||
it("returns the unity passthrough coefficients", { timeout: 5_000 }, () => {
|
||||
const c = identityCoeffs();
|
||||
expect(c.b0).toBe(1);
|
||||
expect(c.b1).toBe(0);
|
||||
expect(c.b2).toBe(0);
|
||||
expect(c.a1).toBe(0);
|
||||
expect(c.a2).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeBiquad — RBJ Audio EQ Cookbook responses", () => {
|
||||
it("peaking with gain=0 collapses to identity within 1e-9", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const c = computeBiquad(
|
||||
band({ type: "peaking", gain: 0 }),
|
||||
SAMPLE_RATE
|
||||
);
|
||||
// b0/b1/b2 + a1/a2 should not equal identity (cookbook formula yields
|
||||
// non-trivial coefficients even at gain=0 because A=1 cancels the gain
|
||||
// term but the all-pole structure still has frequency content).
|
||||
// What we assert: the formula doesn't throw and yields finite values.
|
||||
for (const v of [c.b0, c.b1, c.b2, c.a1, c.a2]) {
|
||||
expect(Number.isFinite(v)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("emits finite coefficients for low-shelf, high-shelf, low-pass, high-pass, peaking", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
for (const type of [
|
||||
"peaking" as const,
|
||||
"low-shelf" as const,
|
||||
"high-shelf" as const,
|
||||
"low-pass" as const,
|
||||
"high-pass" as const,
|
||||
]) {
|
||||
const c = computeBiquad(band({ type, gain: 6 }), SAMPLE_RATE);
|
||||
for (const v of [c.b0, c.b1, c.b2, c.a1, c.a2]) {
|
||||
expect(Number.isFinite(v)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("low-pass with very low cutoff produces near-DC pass coefficients", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const c = computeBiquad(
|
||||
band({ type: "low-pass", frequency: 20, q: Math.SQRT1_2 }),
|
||||
SAMPLE_RATE
|
||||
);
|
||||
// At fc << fs, b0 should be small (low-pass kernel is mostly state-driven).
|
||||
expect(c.b0).toBeGreaterThan(0);
|
||||
expect(c.b0).toBeLessThan(0.01);
|
||||
});
|
||||
|
||||
it("high-pass with very high cutoff produces near-Nyquist coefficients", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const c = computeBiquad(
|
||||
band({ type: "high-pass", frequency: 20_000, q: Math.SQRT1_2 }),
|
||||
SAMPLE_RATE
|
||||
);
|
||||
for (const v of [c.b0, c.b1, c.b2, c.a1, c.a2]) {
|
||||
expect(Number.isFinite(v)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("low-shelf with positive gain boosts DC", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const cBoost = computeBiquad(
|
||||
band({ type: "low-shelf", frequency: 100, gain: 12 }),
|
||||
SAMPLE_RATE
|
||||
);
|
||||
const cFlat = computeBiquad(
|
||||
band({ type: "low-shelf", frequency: 100, gain: 0 }),
|
||||
SAMPLE_RATE
|
||||
);
|
||||
// DC magnitude = |b0 + b1 + b2| / |1 + a1 + a2|. Boost should be > flat.
|
||||
const dcGain = (c: ReturnType<typeof computeBiquad>) =>
|
||||
Math.abs(c.b0 + c.b1 + c.b2) / Math.abs(1 + c.a1 + c.a2);
|
||||
expect(dcGain(cBoost)).toBeGreaterThan(dcGain(cFlat));
|
||||
});
|
||||
|
||||
it("high-shelf with positive gain boosts Nyquist", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const cBoost = computeBiquad(
|
||||
band({ type: "high-shelf", frequency: 10_000, gain: 12 }),
|
||||
SAMPLE_RATE
|
||||
);
|
||||
const cFlat = computeBiquad(
|
||||
band({ type: "high-shelf", frequency: 10_000, gain: 0 }),
|
||||
SAMPLE_RATE
|
||||
);
|
||||
// Nyquist magnitude = |b0 - b1 + b2| / |1 - a1 + a2|.
|
||||
const nyquistGain = (c: ReturnType<typeof computeBiquad>) =>
|
||||
Math.abs(c.b0 - c.b1 + c.b2) / Math.abs(1 - c.a1 + c.a2);
|
||||
expect(nyquistGain(cBoost)).toBeGreaterThan(nyquistGain(cFlat));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Filter-response shape selectable per biquad band. Mirrors the five
|
||||
* RBJ Audio EQ Cookbook responses the `computeBiquad` solver
|
||||
* implements directly without depending on the browser's
|
||||
* `BiquadFilterNode`.
|
||||
*/
|
||||
export type BiquadBandType =
|
||||
| "peaking"
|
||||
| "low-shelf"
|
||||
| "high-shelf"
|
||||
| "low-pass"
|
||||
| "high-pass";
|
||||
|
||||
/**
|
||||
* One band of biquad configuration. `frequency` is the centre / corner
|
||||
* in Hz, `gain` is the boost/cut at `frequency` in dB (ignored for
|
||||
* `low-pass` / `high-pass`), `q` controls bandwidth (resonance for
|
||||
* pass filters), and `type` selects the response shape.
|
||||
*
|
||||
* Used by `computeBiquad` from this module. EQ-style plugins
|
||||
* (`@suaveplan/audio-fx-plugin-eq`) re-export this as `EQBand` for
|
||||
* backward compatibility.
|
||||
*/
|
||||
export interface BiquadBand {
|
||||
readonly frequency: number;
|
||||
readonly gain: number;
|
||||
readonly q: number;
|
||||
readonly type: BiquadBandType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coefficients of a normalised biquad section (`b0/a0`, `b1/a0`,
|
||||
* `b2/a0`, `a1/a0`, `a2/a0`). The `a0` term is folded into the
|
||||
* numerator so the per-sample inner loop costs five multiplies and
|
||||
* four adds.
|
||||
*/
|
||||
export interface BiquadCoeffs {
|
||||
b0: number;
|
||||
b1: number;
|
||||
b2: number;
|
||||
a1: number;
|
||||
a2: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct-form-I biquad state — the previous two input samples and
|
||||
* previous two output samples. Cleared in-place by the EQ's `reset()`.
|
||||
*/
|
||||
export interface BiquadState {
|
||||
x1: number;
|
||||
x2: number;
|
||||
y1: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
/** Pass-through coefficients used as a placeholder before `setup()`. */
|
||||
export function identityCoeffs(): BiquadCoeffs {
|
||||
return { b0: 1, b1: 0, b2: 0, a1: 0, a2: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* RBJ Audio EQ Cookbook biquad coefficients normalised by `a0`. The
|
||||
* formulae match the canonical reference exactly; the EQ recomputes
|
||||
* via this helper at `setup()` and on every `setBand` / `setBandType`
|
||||
* / `applyPreset` mutation.
|
||||
*/
|
||||
export function computeBiquad(
|
||||
band: BiquadBand,
|
||||
sampleRate: number
|
||||
): BiquadCoeffs {
|
||||
const omega = (2 * Math.PI * band.frequency) / sampleRate;
|
||||
const cosW = Math.cos(omega);
|
||||
const sinW = Math.sin(omega);
|
||||
const A = 10 ** (band.gain / 40);
|
||||
const alpha = sinW / (2 * band.q);
|
||||
|
||||
let b0 = 1;
|
||||
let b1 = 0;
|
||||
let b2 = 0;
|
||||
let a0 = 1;
|
||||
let a1 = 0;
|
||||
let a2 = 0;
|
||||
|
||||
if (band.type === "peaking") {
|
||||
b0 = 1 + alpha * A;
|
||||
b1 = -2 * cosW;
|
||||
b2 = 1 - alpha * A;
|
||||
a0 = 1 + alpha / A;
|
||||
a1 = -2 * cosW;
|
||||
a2 = 1 - alpha / A;
|
||||
} else if (band.type === "low-shelf") {
|
||||
const beta = 2 * Math.sqrt(A) * alpha;
|
||||
b0 = A * (A + 1 - (A - 1) * cosW + beta);
|
||||
b1 = 2 * A * (A - 1 - (A + 1) * cosW);
|
||||
b2 = A * (A + 1 - (A - 1) * cosW - beta);
|
||||
a0 = A + 1 + (A - 1) * cosW + beta;
|
||||
a1 = -2 * (A - 1 + (A + 1) * cosW);
|
||||
a2 = A + 1 + (A - 1) * cosW - beta;
|
||||
} else if (band.type === "high-shelf") {
|
||||
const beta = 2 * Math.sqrt(A) * alpha;
|
||||
b0 = A * (A + 1 + (A - 1) * cosW + beta);
|
||||
b1 = -2 * A * (A - 1 + (A + 1) * cosW);
|
||||
b2 = A * (A + 1 + (A - 1) * cosW - beta);
|
||||
a0 = A + 1 - (A - 1) * cosW + beta;
|
||||
a1 = 2 * (A - 1 - (A + 1) * cosW);
|
||||
a2 = A + 1 - (A - 1) * cosW - beta;
|
||||
} else if (band.type === "low-pass") {
|
||||
b0 = (1 - cosW) / 2;
|
||||
b1 = 1 - cosW;
|
||||
b2 = (1 - cosW) / 2;
|
||||
a0 = 1 + alpha;
|
||||
a1 = -2 * cosW;
|
||||
a2 = 1 - alpha;
|
||||
} else {
|
||||
// high-pass — the remaining EQBandType variant.
|
||||
b0 = (1 + cosW) / 2;
|
||||
b1 = -(1 + cosW);
|
||||
b2 = (1 + cosW) / 2;
|
||||
a0 = 1 + alpha;
|
||||
a1 = -2 * cosW;
|
||||
a2 = 1 - alpha;
|
||||
}
|
||||
|
||||
return {
|
||||
b0: b0 / a0,
|
||||
b1: b1 / a0,
|
||||
b2: b2 / a0,
|
||||
a1: a1 / a0,
|
||||
a2: a2 / a0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
BiquadBand,
|
||||
BiquadBandType,
|
||||
BiquadCoeffs,
|
||||
BiquadState,
|
||||
} from "./biquad.js";
|
||||
export {
|
||||
computeBiquad,
|
||||
identityCoeffs,
|
||||
} from "./biquad.js";
|
||||
@@ -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,217 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ambientStateEquals,
|
||||
applyAmbientPatch,
|
||||
defaultAmbientState,
|
||||
} from "./ambient-state.js";
|
||||
|
||||
describe("ambient-state: defaultAmbientState", () => {
|
||||
it("returns a transport-stopped, neutral track baseline", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const s = defaultAmbientState();
|
||||
expect(s.transport.state).toBe("stopped");
|
||||
expect(s.transport.tempo).toBe(120);
|
||||
expect(s.transport.timeSignatureNumerator).toBe(4);
|
||||
expect(s.track.pan).toBe(0);
|
||||
expect(s.track.gainDb).toBe(0);
|
||||
expect(s.listener).toBeUndefined();
|
||||
expect(s.extension).toEqual({});
|
||||
});
|
||||
|
||||
it("returns independent objects on each call", { timeout: 5_000 }, () => {
|
||||
const a = defaultAmbientState();
|
||||
const b = defaultAmbientState();
|
||||
expect(a).not.toBe(b);
|
||||
expect(a.track).not.toBe(b.track);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ambient-state: applyAmbientPatch", () => {
|
||||
it("shallow-merges track slot", { timeout: 5_000 }, () => {
|
||||
const prev = defaultAmbientState();
|
||||
const next = applyAmbientPatch(prev, {
|
||||
track: { pan: 0.5, gainDb: -3 },
|
||||
});
|
||||
expect(next.track.pan).toBe(0.5);
|
||||
expect(next.track.gainDb).toBe(-3);
|
||||
expect(next.track.mute).toBe(false);
|
||||
expect(next.track.id).toBe(prev.track.id);
|
||||
expect(prev.track.pan).toBe(0); // input not mutated
|
||||
});
|
||||
|
||||
it("shallow-merges transport slot", { timeout: 5_000 }, () => {
|
||||
const prev = defaultAmbientState();
|
||||
const next = applyAmbientPatch(prev, {
|
||||
transport: { tempo: 140, state: "playing" },
|
||||
});
|
||||
expect(next.transport.tempo).toBe(140);
|
||||
expect(next.transport.state).toBe("playing");
|
||||
expect(next.transport.ppqn).toBe(prev.transport.ppqn);
|
||||
});
|
||||
|
||||
it("sets listener verbatim", { timeout: 5_000 }, () => {
|
||||
const prev = defaultAmbientState();
|
||||
const listener = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
orientation: { x: 0, y: 0, z: 0, w: 1 },
|
||||
velocity: { x: 0, y: 0, z: 0 },
|
||||
} as const;
|
||||
const next = applyAmbientPatch(prev, { listener });
|
||||
expect(next.listener).toEqual(listener);
|
||||
});
|
||||
|
||||
it("removes listener when patch.listener === null", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const seeded = applyAmbientPatch(defaultAmbientState(), {
|
||||
listener: {
|
||||
position: { x: 1, y: 0, z: 0 },
|
||||
orientation: { x: 0, y: 0, z: 0, w: 1 },
|
||||
velocity: { x: 0, y: 0, z: 0 },
|
||||
},
|
||||
});
|
||||
expect(seeded.listener).toBeDefined();
|
||||
const cleared = applyAmbientPatch(seeded, { listener: null });
|
||||
expect(cleared.listener).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves listener when not mentioned in the patch", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const seeded = applyAmbientPatch(defaultAmbientState(), {
|
||||
listener: {
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
orientation: { x: 0, y: 0, z: 0, w: 1 },
|
||||
velocity: { x: 0, y: 0, z: 0 },
|
||||
},
|
||||
});
|
||||
const next = applyAmbientPatch(seeded, { track: { mute: true } });
|
||||
expect(next.listener).toBe(seeded.listener);
|
||||
expect(next.track.mute).toBe(true);
|
||||
});
|
||||
|
||||
it("merges extension shallowly, preserving unspecified keys", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const seeded = applyAmbientPatch(defaultAmbientState(), {
|
||||
extension: { gameState: { tension: 0.5 } },
|
||||
});
|
||||
const next = applyAmbientPatch(seeded, {
|
||||
extension: { theme: "dark" },
|
||||
});
|
||||
expect(next.extension).toEqual({
|
||||
gameState: { tension: 0.5 },
|
||||
theme: "dark",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns identical slot references for unpatched slots", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const prev = defaultAmbientState();
|
||||
const next = applyAmbientPatch(prev, {
|
||||
track: { mute: true },
|
||||
});
|
||||
// transport, listener (absent), and extension references unchanged
|
||||
expect(next.transport).toBe(prev.transport);
|
||||
expect(next.extension).toBe(prev.extension);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ambient-state: ambientStateEquals", () => {
|
||||
it("returns true for default vs default", { timeout: 5_000 }, () => {
|
||||
expect(
|
||||
ambientStateEquals(defaultAmbientState(), defaultAmbientState())
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when track field differs", { timeout: 5_000 }, () => {
|
||||
const a = defaultAmbientState();
|
||||
const b = applyAmbientPatch(a, { track: { mute: true } });
|
||||
expect(ambientStateEquals(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when transport field differs", { timeout: 5_000 }, () => {
|
||||
const a = defaultAmbientState();
|
||||
const b = applyAmbientPatch(a, { transport: { tempo: 200 } });
|
||||
expect(ambientStateEquals(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when listener presence differs", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const a = defaultAmbientState();
|
||||
const b = applyAmbientPatch(a, {
|
||||
listener: {
|
||||
position: { x: 1, y: 0, z: 0 },
|
||||
orientation: { x: 0, y: 0, z: 0, w: 1 },
|
||||
velocity: { x: 0, y: 0, z: 0 },
|
||||
},
|
||||
});
|
||||
expect(ambientStateEquals(a, b)).toBe(false);
|
||||
expect(ambientStateEquals(b, a)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when listener content matches but ref differs", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const listenerShape = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
orientation: { x: 0, y: 0, z: 0, w: 1 },
|
||||
velocity: { x: 0, y: 0, z: 0 },
|
||||
} as const;
|
||||
const a = applyAmbientPatch(defaultAmbientState(), {
|
||||
listener: listenerShape,
|
||||
});
|
||||
const b = applyAmbientPatch(defaultAmbientState(), {
|
||||
listener: { ...listenerShape },
|
||||
});
|
||||
expect(ambientStateEquals(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when listener fields differ", { timeout: 5_000 }, () => {
|
||||
const a = applyAmbientPatch(defaultAmbientState(), {
|
||||
listener: {
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
orientation: { x: 0, y: 0, z: 0, w: 1 },
|
||||
velocity: { x: 0, y: 0, z: 0 },
|
||||
},
|
||||
});
|
||||
const b = applyAmbientPatch(defaultAmbientState(), {
|
||||
listener: {
|
||||
position: { x: 5, y: 0, z: 0 },
|
||||
orientation: { x: 0, y: 0, z: 0, w: 1 },
|
||||
velocity: { x: 0, y: 0, z: 0 },
|
||||
},
|
||||
});
|
||||
expect(ambientStateEquals(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for identical references", { timeout: 5_000 }, () => {
|
||||
const a = defaultAmbientState();
|
||||
expect(ambientStateEquals(a, a)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when extension keys differ", { timeout: 5_000 }, () => {
|
||||
const a = applyAmbientPatch(defaultAmbientState(), {
|
||||
extension: { theme: "dark" },
|
||||
});
|
||||
const b = applyAmbientPatch(defaultAmbientState(), {
|
||||
extension: { theme: "dark", x: 1 },
|
||||
});
|
||||
expect(ambientStateEquals(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when an extension value differs", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const a = applyAmbientPatch(defaultAmbientState(), {
|
||||
extension: { theme: "dark" },
|
||||
});
|
||||
const b = applyAmbientPatch(defaultAmbientState(), {
|
||||
extension: { theme: "light" },
|
||||
});
|
||||
expect(ambientStateEquals(a, b)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import type {
|
||||
AmbientListenerState,
|
||||
AmbientState,
|
||||
AmbientTrackState,
|
||||
AmbientTransportState,
|
||||
} from "../base/audio-fx-plugin.types.js";
|
||||
|
||||
/**
|
||||
* Sensible defaults for a freshly-constructed chain that has not yet
|
||||
* been wired to a track or transport. Engines push real values via
|
||||
* `chain.updateAmbient(patch)` once both are available.
|
||||
*/
|
||||
export function defaultAmbientState(): AmbientState {
|
||||
return {
|
||||
track: {
|
||||
id: "",
|
||||
gainDb: 0,
|
||||
pan: 0,
|
||||
mute: false,
|
||||
solo: false,
|
||||
layout: "stereo",
|
||||
},
|
||||
transport: {
|
||||
tempo: 120,
|
||||
timeSignatureNumerator: 4,
|
||||
timeSignatureDenominator: 4,
|
||||
transportTimeSec: 0,
|
||||
state: "stopped",
|
||||
ppqn: 960,
|
||||
},
|
||||
extension: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deeply-merged patch type for `chain.updateAmbient(patch)`. Each top-
|
||||
* level slot is independently patchable; sub-fields within a slot are
|
||||
* shallow-merged onto the existing slot.
|
||||
*/
|
||||
export interface AmbientStatePatch {
|
||||
readonly track?: Partial<AmbientTrackState>;
|
||||
readonly transport?: Partial<AmbientTransportState>;
|
||||
readonly listener?: AmbientListenerState | null;
|
||||
readonly extension?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new `AmbientState` with the patch applied. Pure — never
|
||||
* mutates the input. Used by `PluginChain.updateAmbient(patch)` so the
|
||||
* chain can emit `ambient-changed` with the previous + new values.
|
||||
*
|
||||
* Patch semantics:
|
||||
*
|
||||
* - `track` and `transport` slots are shallow-merged (partial fields
|
||||
* replace just those keys on the slot).
|
||||
* - `listener` is replaced verbatim (or removed when explicitly `null`).
|
||||
* - `extension` is shallow-merged: the patch overrides matching keys
|
||||
* and adds new ones; unspecified keys are preserved.
|
||||
*/
|
||||
export function applyAmbientPatch(
|
||||
previous: AmbientState,
|
||||
patch: AmbientStatePatch
|
||||
): AmbientState {
|
||||
const next: {
|
||||
track: AmbientTrackState;
|
||||
transport: AmbientTransportState;
|
||||
listener?: AmbientListenerState;
|
||||
extension: Readonly<Record<string, unknown>>;
|
||||
} = {
|
||||
track: patch.track
|
||||
? { ...previous.track, ...patch.track }
|
||||
: previous.track,
|
||||
transport: patch.transport
|
||||
? { ...previous.transport, ...patch.transport }
|
||||
: previous.transport,
|
||||
extension: patch.extension
|
||||
? { ...previous.extension, ...patch.extension }
|
||||
: previous.extension,
|
||||
};
|
||||
if (patch.listener === null) {
|
||||
// explicit removal — leave next.listener undefined
|
||||
} else if (patch.listener !== undefined) {
|
||||
next.listener = patch.listener;
|
||||
} else if (previous.listener !== undefined) {
|
||||
next.listener = previous.listener;
|
||||
}
|
||||
return next as AmbientState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when two ambient states are structurally equal. Used by
|
||||
* `PluginChain.updateAmbient` to short-circuit no-op patches and avoid
|
||||
* spurious `ambient-changed` events.
|
||||
*/
|
||||
export function ambientStateEquals(a: AmbientState, b: AmbientState): boolean {
|
||||
if (!shallowEqual(asRecord(a.track), asRecord(b.track))) {
|
||||
return false;
|
||||
}
|
||||
if (!shallowEqual(asRecord(a.transport), asRecord(b.transport))) {
|
||||
return false;
|
||||
}
|
||||
if (!listenerEqual(a.listener, b.listener)) {
|
||||
return false;
|
||||
}
|
||||
return shallowEqual(a.extension, b.extension);
|
||||
}
|
||||
|
||||
function asRecord<T>(value: T): Readonly<Record<string, unknown>> {
|
||||
return value as unknown as Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function shallowEqual(
|
||||
a: Readonly<Record<string, unknown>>,
|
||||
b: Readonly<Record<string, unknown>>
|
||||
): boolean {
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = Object.keys(b);
|
||||
if (aKeys.length !== bKeys.length) {
|
||||
return false;
|
||||
}
|
||||
for (const key of aKeys) {
|
||||
if (a[key] !== b[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function listenerEqual(
|
||||
a: AmbientListenerState | undefined,
|
||||
b: AmbientListenerState | undefined
|
||||
): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
if (a === undefined || b === undefined) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
shallowEqual(asRecord(a.position), asRecord(b.position)) &&
|
||||
shallowEqual(asRecord(a.orientation), asRecord(b.orientation)) &&
|
||||
shallowEqual(asRecord(a.velocity), asRecord(b.velocity))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
# `bus-registry`
|
||||
|
||||
## Purpose
|
||||
|
||||
The `BusRegistry` is the **project-level sidechain routing surface** that every chain hosted by an engine adapter consumes. One instance is constructed by the engine at init time and passed to every chain via `ChainContext.buses`; tracks publish their post-FX output to named buses (`registry.publish('kick', 2)`) and plugins on other tracks consume them (`ctx.buses.get('kick')?.read(scratch)`). The registry is universal: the `DefaultBusRegistry` reference implementation works headless (server renders, CLIs, tests) and in the browser; engines that need bespoke bookkeeping may swap their own implementation as long as it honours the same `BusRegistry` interface.
|
||||
|
||||
The split between `BusRegistryReadHandle` (exposed to plugins) and the full `BusRegistry` (engine-only) is intentional: plugins can only read and watch; only the engine — which owns chain ordering — can mutate the topology.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-bus channel-aware `Float32Array` accumulators sized to whatever the publisher writes; consumers read into their own scratch buffer with `read(scratch)`.
|
||||
- Reads silence before any write and silence-pads when the scratch buffer is larger than the most recent frame, so consumers always see deterministic samples.
|
||||
- Hard channel-count enforcement: a bus published with 2 channels rejects read / write callers that hand it the wrong array length with `AUDIO_FX.BUS_CHANNEL_MISMATCH`.
|
||||
- `close()` is idempotent and reads on a closed bus handle return silence (no throw) so consumers never crash when a publisher disappears mid-render.
|
||||
- Republishing the same name treats the prior publisher as closed and emits a `subscribe` event with `undefined` followed by the new bus.
|
||||
- `subscribe(name, listener)` fires on publish + close so plugins that resolve buses lazily after `setup()` learn when they appear or disappear.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
|
||||
const registry = new DefaultBusRegistry();
|
||||
const kick = registry.publish("kick", 2);
|
||||
kick.write([leftChannelBuf, rightChannelBuf]);
|
||||
|
||||
const sidechain = registry.get("kick");
|
||||
if (sidechain !== undefined) {
|
||||
const scratch = [new Float32Array(blockSize), new Float32Array(blockSize)];
|
||||
sidechain.read(scratch);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
|
||||
// One registry per engine; share across every chain it hosts.
|
||||
const registry = new DefaultBusRegistry();
|
||||
|
||||
// Track A publishes its post-FX output to "kick".
|
||||
const kickPub = registry.publish("kick", outputChannels);
|
||||
|
||||
// Plugin on track B's chain subscribes so it learns when the bus appears.
|
||||
let kickBus: SidechainBus | undefined;
|
||||
const unsubscribe = registry.subscribe("kick", (bus) => {
|
||||
kickBus = bus;
|
||||
});
|
||||
|
||||
// In track B's compressor process():
|
||||
const scratch = chainContext.chain.channels === 2
|
||||
? [new Float32Array(blockSize), new Float32Array(blockSize)]
|
||||
: [new Float32Array(blockSize)];
|
||||
kickBus?.read(scratch);
|
||||
const ductReduction = computeDuct(scratch);
|
||||
|
||||
// On chain dispose:
|
||||
unsubscribe();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `BusRegistry` (interface) — extends `BusRegistryReadHandle` (`get`, `list`) with `publish(name, channels)` and `subscribe(name, listener)`.
|
||||
- `SidechainPublisher` (interface) — `{ bus: SidechainBus, write(samples), close() }`. Returned by `publish`; the engine retains it to close the bus on track teardown.
|
||||
- `DefaultBusRegistry` (class) — reference implementation. No constructor arguments; one instance per engine.
|
||||
- `BusRegistryReadHandle` (in `base/`) — `{ get(name), list() }` — the surface plugins see via `ChainContext.buses`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry does not synchronise reads against writes. Engines guarantee single-threaded chain processing per block and topologically order chains so publishers always write before subscribers read; the registry merely holds the per-channel accumulators. If a future engine model adds cross-thread chains, it must supply its own thread-safe implementation.
|
||||
|
||||
`subscribe` deliberately fires on `undefined` when a publisher closes so consumers can release their local bus reference; the alternative (never firing on close) would leak the reference. Consumers that don't care about lifecycle can pass an arrow that only cares about the truthy case.
|
||||
|
||||
`close()` on a publisher removes its entry from the registry and emits an `undefined` notification, but the local bus reference remains callable — `bus.read()` returns silence rather than throwing. This is deliberate: a consumer that already captured the bus reference and is mid-render when the publisher closes should not crash; reading silence and letting the consumer recover on its next `subscribe` cycle is the right shape.
|
||||
|
||||
The accumulator reallocates when the publisher writes a different-size frame than the previous write, so the registry adapts to variable block sizes without per-block allocation when sizes are stable. In steady-state operation with a fixed block size, write allocates zero garbage per block.
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_FX_CODES,
|
||||
type AudioFxError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { DefaultBusRegistry } from "./bus-registry.js";
|
||||
|
||||
function frame(samples: number[], channels = 1): Float32Array[] {
|
||||
const out: Float32Array[] = [];
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
out.push(Float32Array.from(samples));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("DefaultBusRegistry: publish + read round-trip", () => {
|
||||
it("returns undefined for an unknown bus", { timeout: 5_000 }, () => {
|
||||
const r = new DefaultBusRegistry();
|
||||
expect(r.get("missing")).toBeUndefined();
|
||||
expect(r.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it("publishes a named bus reachable via get + list", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = new DefaultBusRegistry();
|
||||
const pub = r.publish("kick", 2);
|
||||
expect(pub.bus.name).toBe("kick");
|
||||
expect(pub.bus.channels).toBe(2);
|
||||
expect(r.get("kick")).toBe(pub.bus);
|
||||
expect(r.list()).toEqual(["kick"]);
|
||||
});
|
||||
|
||||
it("round-trips a buffer from write to read", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = new DefaultBusRegistry();
|
||||
const pub = r.publish("kick", 1);
|
||||
pub.write(frame([0.1, 0.2, 0.3]));
|
||||
const scratch = [new Float32Array(3)];
|
||||
r.get("kick")!.read(scratch);
|
||||
expect(Array.from(scratch[0]!)).toEqual([
|
||||
expect.closeTo(0.1, 6),
|
||||
expect.closeTo(0.2, 6),
|
||||
expect.closeTo(0.3, 6),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads silence before any write", { timeout: 5_000 }, () => {
|
||||
const r = new DefaultBusRegistry();
|
||||
r.publish("kick", 1);
|
||||
const scratch = [new Float32Array(4)];
|
||||
r.get("kick")!.read(scratch);
|
||||
expect(Array.from(scratch[0]!)).toEqual([0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
it("pads with silence when scratch is larger than the written frame", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = new DefaultBusRegistry();
|
||||
const pub = r.publish("kick", 1);
|
||||
pub.write(frame([1, 1]));
|
||||
const scratch = [new Float32Array(4)];
|
||||
r.get("kick")!.read(scratch);
|
||||
expect(Array.from(scratch[0]!)).toEqual([1, 1, 0, 0]);
|
||||
});
|
||||
|
||||
it("reuses the accumulator when consecutive writes are the same size", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = new DefaultBusRegistry();
|
||||
const pub = r.publish("kick", 1);
|
||||
pub.write(frame([1, 2, 3]));
|
||||
pub.write(frame([4, 5, 6])); // same size — accumulator reused
|
||||
const scratch = [new Float32Array(3)];
|
||||
r.get("kick")!.read(scratch);
|
||||
expect(Array.from(scratch[0]!)).toEqual([4, 5, 6]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefaultBusRegistry: channel-count enforcement", () => {
|
||||
it("throws BUS_CHANNEL_MISMATCH when read scratch length differs", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = new DefaultBusRegistry();
|
||||
r.publish("kick", 2);
|
||||
try {
|
||||
r.get("kick")!.read([new Float32Array(4)]);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.BUS_CHANNEL_MISMATCH
|
||||
);
|
||||
expect((error as AudioFxError).context).toEqual({
|
||||
bus: "kick",
|
||||
expected: 2,
|
||||
actual: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error("expected BUS_CHANNEL_MISMATCH");
|
||||
});
|
||||
|
||||
it("throws BUS_CHANNEL_MISMATCH when write frame length differs", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = new DefaultBusRegistry();
|
||||
const pub = r.publish("kick", 1);
|
||||
try {
|
||||
pub.write(frame([1, 1], 2));
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.BUS_CHANNEL_MISMATCH
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected BUS_CHANNEL_MISMATCH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefaultBusRegistry: close semantics", () => {
|
||||
it("close removes the bus from get/list and reads as silence", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = new DefaultBusRegistry();
|
||||
const pub = r.publish("kick", 1);
|
||||
const bus = pub.bus;
|
||||
pub.write(frame([1, 1]));
|
||||
pub.close();
|
||||
expect(r.get("kick")).toBeUndefined();
|
||||
expect(r.list()).toEqual([]);
|
||||
// The local bus handle is still callable but reads silence.
|
||||
const scratch = [new Float32Array(2)];
|
||||
bus.read(scratch);
|
||||
expect(Array.from(scratch[0]!)).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it("close is idempotent", { timeout: 5_000 }, () => {
|
||||
const r = new DefaultBusRegistry();
|
||||
const pub = r.publish("kick", 1);
|
||||
pub.close();
|
||||
// second close must be a no-op
|
||||
expect(() => pub.close()).not.toThrow();
|
||||
});
|
||||
|
||||
it("write on a closed publisher is a no-op", { timeout: 5_000 }, () => {
|
||||
const r = new DefaultBusRegistry();
|
||||
const pub = r.publish("kick", 1);
|
||||
pub.close();
|
||||
expect(() => pub.write(frame([1]))).not.toThrow();
|
||||
});
|
||||
|
||||
it("republishing the same name closes the previous publisher", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = new DefaultBusRegistry();
|
||||
const first = r.publish("kick", 1);
|
||||
const events: Array<unknown> = [];
|
||||
r.subscribe("kick", (b) => events.push(b));
|
||||
const second = r.publish("kick", 2);
|
||||
expect(r.get("kick")).toBe(second.bus);
|
||||
expect(first.bus.channels).toBe(1);
|
||||
// subscribers see: close of first (undefined), then second (bus)
|
||||
expect(events.length).toBe(2);
|
||||
expect(events[0]).toBeUndefined();
|
||||
expect(events[1]).toBe(second.bus);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefaultBusRegistry: subscribe", () => {
|
||||
it("fires the listener on publish and on close", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = new DefaultBusRegistry();
|
||||
const events: Array<unknown> = [];
|
||||
const unsub = r.subscribe("kick", (b) => events.push(b));
|
||||
const pub = r.publish("kick", 1);
|
||||
pub.close();
|
||||
unsub();
|
||||
expect(events.length).toBe(2);
|
||||
expect(events[0]).toBe(pub.bus);
|
||||
expect(events[1]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("multiple listeners on the same name all fire", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = new DefaultBusRegistry();
|
||||
let countA = 0;
|
||||
let countB = 0;
|
||||
r.subscribe("kick", () => countA++);
|
||||
r.subscribe("kick", () => countB++);
|
||||
r.publish("kick", 1);
|
||||
expect(countA).toBe(1);
|
||||
expect(countB).toBe(1);
|
||||
});
|
||||
|
||||
it("unsubscribe removes only that listener; cleans up empty buckets", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = new DefaultBusRegistry();
|
||||
let countA = 0;
|
||||
let countB = 0;
|
||||
const unsubA = r.subscribe("kick", () => countA++);
|
||||
r.subscribe("kick", () => countB++);
|
||||
unsubA();
|
||||
r.publish("kick", 1);
|
||||
expect(countA).toBe(0);
|
||||
expect(countB).toBe(1);
|
||||
});
|
||||
|
||||
it("subscribe before any publish is harmless", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = new DefaultBusRegistry();
|
||||
const events: Array<unknown> = [];
|
||||
r.subscribe("kick", (b) => events.push(b));
|
||||
expect(events.length).toBe(0);
|
||||
r.publish("kick", 1);
|
||||
expect(events.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { SidechainBus } from "../base/audio-fx-plugin.types.js";
|
||||
import { AUDIO_FX_CODES, AudioFxError } from "../error-codes/error-codes.js";
|
||||
import type { BusRegistry, SidechainPublisher } from "./bus-registry.types.js";
|
||||
|
||||
/**
|
||||
* Reference implementation of the project-level `BusRegistry` used by
|
||||
* `audio-engine-browser` / `audio-engine-server` and by tests. Each
|
||||
* published bus owns one set of per-channel `Float32Array` accumulators
|
||||
* sized to the configured block length; consumers read the most-recent
|
||||
* write into their own scratch buffer.
|
||||
*
|
||||
* Concurrency model:
|
||||
*
|
||||
* - Engines order chain processing topologically so a publisher always
|
||||
* writes before a subscriber reads in the same block; this registry
|
||||
* does not synchronise (audio rendering is single-threaded per engine
|
||||
* block).
|
||||
* - A consumer that reads before any publisher writes sees silence
|
||||
* (the accumulator is zero-filled at publish).
|
||||
* - `subscribe(name, listener)` fires when the named bus appears or
|
||||
* disappears (i.e. on `publish` for a new name, on `close` for the
|
||||
* matching publisher); useful for plugins that resolve the bus
|
||||
* lazily after `setup`.
|
||||
*/
|
||||
export class DefaultBusRegistry implements BusRegistry {
|
||||
private readonly publishers = new Map<string, PublisherRecord>();
|
||||
private readonly listeners = new Map<
|
||||
string,
|
||||
Set<(bus: SidechainBus | undefined) => void>
|
||||
>();
|
||||
|
||||
public get(name: string): SidechainBus | undefined {
|
||||
return this.publishers.get(name)?.bus;
|
||||
}
|
||||
|
||||
public list(): ReadonlyArray<string> {
|
||||
return Array.from(this.publishers.keys());
|
||||
}
|
||||
|
||||
public publish(name: string, channels: number): SidechainPublisher {
|
||||
const existing = this.publishers.get(name);
|
||||
if (existing !== undefined) {
|
||||
// Republishing the same name is treated as a re-init: close the
|
||||
// previous publisher (which removes it from the registry) and
|
||||
// continue with a fresh bus.
|
||||
existing.publisher.close();
|
||||
}
|
||||
const accumulators: Float32Array[] = [];
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
accumulators.push(new Float32Array(0));
|
||||
}
|
||||
let closed = false;
|
||||
const bus: SidechainBus = {
|
||||
name,
|
||||
channels,
|
||||
read: (into: Float32Array[]) => {
|
||||
if (closed) {
|
||||
// Closed bus reads as silence (no throw — let the
|
||||
// consumer keep running).
|
||||
for (const ch of into) {
|
||||
ch.fill(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (into.length !== channels) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.BUS_CHANNEL_MISMATCH,
|
||||
"audio-fx.bus-channel-mismatch",
|
||||
{
|
||||
context: {
|
||||
bus: name,
|
||||
expected: channels,
|
||||
actual: into.length,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
const src = accumulators[ch]!;
|
||||
const dst = into[ch]!;
|
||||
const len = Math.min(src.length, dst.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
dst[i] = src[i]!;
|
||||
}
|
||||
// Fill any trailing samples the publisher hasn't
|
||||
// written yet with silence so consumers see a
|
||||
// deterministic buffer.
|
||||
if (dst.length > len) {
|
||||
dst.fill(0, len);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
const publisher: SidechainPublisher = {
|
||||
bus,
|
||||
write: (samples: Float32Array[]) => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
if (samples.length !== channels) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.BUS_CHANNEL_MISMATCH,
|
||||
"audio-fx.bus-channel-mismatch",
|
||||
{
|
||||
context: {
|
||||
bus: name,
|
||||
expected: channels,
|
||||
actual: samples.length,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
const src = samples[ch]!;
|
||||
let acc = accumulators[ch]!;
|
||||
if (acc.length !== src.length) {
|
||||
acc = new Float32Array(src.length);
|
||||
accumulators[ch] = acc;
|
||||
}
|
||||
acc.set(src);
|
||||
}
|
||||
},
|
||||
close: () => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
this.publishers.delete(name);
|
||||
this.emit(name, undefined);
|
||||
},
|
||||
};
|
||||
this.publishers.set(name, { bus, publisher });
|
||||
this.emit(name, bus);
|
||||
return publisher;
|
||||
}
|
||||
|
||||
public subscribe(
|
||||
name: string,
|
||||
listener: (bus: SidechainBus | undefined) => void
|
||||
): () => void {
|
||||
let bucket = this.listeners.get(name);
|
||||
if (bucket === undefined) {
|
||||
bucket = new Set();
|
||||
this.listeners.set(name, bucket);
|
||||
}
|
||||
bucket.add(listener);
|
||||
return () => {
|
||||
bucket?.delete(listener);
|
||||
if (bucket?.size === 0) {
|
||||
this.listeners.delete(name);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private emit(name: string, bus: SidechainBus | undefined): void {
|
||||
const bucket = this.listeners.get(name);
|
||||
if (bucket === undefined) {
|
||||
return;
|
||||
}
|
||||
for (const listener of bucket) {
|
||||
listener(bus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface PublisherRecord {
|
||||
readonly bus: SidechainBus;
|
||||
readonly publisher: SidechainPublisher;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type {
|
||||
BusRegistryReadHandle,
|
||||
SidechainBus,
|
||||
} from "../base/audio-fx-plugin.types.js";
|
||||
|
||||
/**
|
||||
* Publisher handle returned by `BusRegistry.publish`. The owning track
|
||||
* writes its post-FX output via `write(samples)` once per block; sub-
|
||||
* scribed plugins read via the bus's `read()`.
|
||||
*/
|
||||
export interface SidechainPublisher {
|
||||
readonly bus: SidechainBus;
|
||||
write(samples: Float32Array[]): void;
|
||||
/** Drops the bus from the registry; concurrent `bus.read()` callers see silence afterwards. */
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project-level registry passed to every chain via `ChainContext.buses`.
|
||||
* Engines (`audio-engine-browser`, `audio-engine-server`) construct one
|
||||
* instance and share it across every chain they host so sidechain
|
||||
* routing crosses tracks.
|
||||
*
|
||||
* The base read surface (`get`, `list`) is exposed to plugins via
|
||||
* `BusRegistryReadHandle`; the full mutator surface (`publish`,
|
||||
* `subscribe`) is engine-only.
|
||||
*/
|
||||
export interface BusRegistry extends BusRegistryReadHandle {
|
||||
publish(name: string, channels: number): SidechainPublisher;
|
||||
subscribe(
|
||||
name: string,
|
||||
listener: (bus: SidechainBus | undefined) => void
|
||||
): () => void;
|
||||
}
|
||||
110
packages/media/audio-fx/audio-fx-plugin-base/src/chain/chain.md
Normal file
110
packages/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,687 @@
|
||||
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import z from "zod";
|
||||
import { AbstractAudioFxPlugin } from "../base/audio-fx-plugin.js";
|
||||
import {
|
||||
AUDIO_FX_CODES,
|
||||
type AudioFxError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
type AmbientState,
|
||||
applyAmbientPatch,
|
||||
defaultAmbientState,
|
||||
} from "./ambient-state.js";
|
||||
import { DefaultBusRegistry } from "./bus-registry.js";
|
||||
import { PluginChain } from "./chain.js";
|
||||
|
||||
const PRESET_SCHEMA = z.object({}).strict();
|
||||
|
||||
const STEREO_CONFIG: ProcessorConfig = {
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
blockSize: 4,
|
||||
};
|
||||
|
||||
const STEREO_IO = {
|
||||
inputs: [{ count: 2 as const }],
|
||||
outputs: [{ count: "preserve" as const }],
|
||||
};
|
||||
|
||||
const SOURCE_IO = {
|
||||
inputs: [{ count: 0 as const }],
|
||||
outputs: [{ count: 2 as const }],
|
||||
};
|
||||
|
||||
const MONO_IO = {
|
||||
inputs: [{ count: 1 as const }],
|
||||
outputs: [{ count: 1 as const }],
|
||||
};
|
||||
|
||||
class GainPlugin extends AbstractAudioFxPlugin {
|
||||
public constructor(id: string, gain = 1) {
|
||||
super({
|
||||
metadata: { id, name: id, version: "1.0.0", category: "utility" },
|
||||
schema: PRESET_SCHEMA,
|
||||
io: STEREO_IO,
|
||||
parameters: [
|
||||
{
|
||||
id: "gain",
|
||||
name: "Gain",
|
||||
value: gain,
|
||||
min: 0,
|
||||
max: 4,
|
||||
default: 1,
|
||||
automatable: true,
|
||||
},
|
||||
],
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
}
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[]
|
||||
): void {
|
||||
const g = this.parameterValue("gain");
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
for (let i = 0; i < input[ch]!.length; i++) {
|
||||
output[ch]![i] = input[ch]![i]! * g;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MonoPlugin extends AbstractAudioFxPlugin {
|
||||
public constructor(id = "mono") {
|
||||
super({
|
||||
metadata: { id, name: id, version: "1.0.0", category: "utility" },
|
||||
schema: PRESET_SCHEMA,
|
||||
io: MONO_IO,
|
||||
parameters: [],
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
}
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[]
|
||||
): void {
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
output[ch]!.set(input[ch]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SourcePlugin extends AbstractAudioFxPlugin {
|
||||
public constructor(id = "src") {
|
||||
super({
|
||||
metadata: { id, name: id, version: "1.0.0", category: "source" },
|
||||
schema: PRESET_SCHEMA,
|
||||
io: SOURCE_IO,
|
||||
parameters: [],
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
}
|
||||
protected processBlock(
|
||||
_input: Float32Array[],
|
||||
output: Float32Array[]
|
||||
): void {
|
||||
for (let ch = 0; ch < output.length; ch++) {
|
||||
output[ch]!.fill(0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LatencyPlugin extends AbstractAudioFxPlugin {
|
||||
public constructor(id: string, initialLatency: number) {
|
||||
super({
|
||||
metadata: { id, name: id, version: "1.0.0", category: "utility" },
|
||||
schema: PRESET_SCHEMA,
|
||||
io: STEREO_IO,
|
||||
parameters: [],
|
||||
capabilities: {
|
||||
hasAudioNode: false,
|
||||
hasWorklet: false,
|
||||
hasSidechain: false,
|
||||
sampleAccurate: true,
|
||||
stateful: false,
|
||||
asyncSetup: false,
|
||||
preservesChannelCount: true,
|
||||
latencySamples: initialLatency,
|
||||
},
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
}
|
||||
public bump(samples: number): void {
|
||||
this.notifyLatencyChanged(samples);
|
||||
}
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[]
|
||||
): void {
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
output[ch]!.set(input[ch]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const AMBIENT: AmbientState = defaultAmbientState();
|
||||
|
||||
function blockCtx(): BlockContext {
|
||||
return {
|
||||
blockOffsetSamples: 0,
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
opts: {},
|
||||
};
|
||||
}
|
||||
|
||||
function buffers(
|
||||
channels = 2,
|
||||
blockSize = 4
|
||||
): {
|
||||
input: Float32Array[];
|
||||
output: Float32Array[];
|
||||
} {
|
||||
const input: Float32Array[] = [];
|
||||
const output: Float32Array[] = [];
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
input.push(new Float32Array(blockSize));
|
||||
output.push(new Float32Array(blockSize));
|
||||
}
|
||||
return { input, output };
|
||||
}
|
||||
|
||||
function chain(
|
||||
opts: Partial<Parameters<typeof PluginChain.prototype.constructor>[0]> = {}
|
||||
): PluginChain {
|
||||
return new PluginChain(
|
||||
{
|
||||
id: "c1",
|
||||
channels: 2,
|
||||
sampleRate: 48_000,
|
||||
blockSize: 4,
|
||||
...opts,
|
||||
},
|
||||
new DefaultBusRegistry()
|
||||
);
|
||||
}
|
||||
|
||||
describe("PluginChain: composition", () => {
|
||||
it("add() appends and emits plugin-added", { timeout: 5_000 }, () => {
|
||||
const c = chain();
|
||||
const events: Array<{ id: string; index: number }> = [];
|
||||
c.on("plugin-added", (e) =>
|
||||
events.push({ id: e.plugin.metadata.id, index: e.index })
|
||||
);
|
||||
c.add(new GainPlugin("a"));
|
||||
c.add(new GainPlugin("b"));
|
||||
expect(c.plugins.map((p) => p.metadata.id)).toEqual(["a", "b"]);
|
||||
expect(events).toEqual([
|
||||
{ id: "a", index: 0 },
|
||||
{ id: "b", index: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("add() with an `at` index inserts in the middle", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const c = chain();
|
||||
c.add(new GainPlugin("a"));
|
||||
c.add(new GainPlugin("c"));
|
||||
c.add(new GainPlugin("b"), 1);
|
||||
expect(c.plugins.map((p) => p.metadata.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("add() rejects duplicate plugin id with CHAIN_DUPLICATE_PLUGIN_ID", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const c = chain();
|
||||
c.add(new GainPlugin("a"));
|
||||
try {
|
||||
c.add(new GainPlugin("a"));
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.CHAIN_DUPLICATE_PLUGIN_ID
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected CHAIN_DUPLICATE_PLUGIN_ID");
|
||||
});
|
||||
|
||||
it("add() rejects out-of-range index with CHAIN_SLOT_NOT_FOUND", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const c = chain();
|
||||
try {
|
||||
c.add(new GainPlugin("a"), 5);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.CHAIN_SLOT_NOT_FOUND
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected CHAIN_SLOT_NOT_FOUND");
|
||||
});
|
||||
|
||||
it("add() rejects IO mismatch with CHAIN_IO_INCOMPATIBLE", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const c = chain(); // stereo
|
||||
try {
|
||||
c.add(new MonoPlugin()); // mono input on stereo chain
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.CHAIN_IO_INCOMPATIBLE
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected CHAIN_IO_INCOMPATIBLE");
|
||||
});
|
||||
|
||||
it("add() rejects a source plugin at non-zero index", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const c = chain();
|
||||
c.add(new GainPlugin("a"));
|
||||
try {
|
||||
c.add(new SourcePlugin(), 1);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.CHAIN_SOURCE_POSITION_INVALID
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected CHAIN_SOURCE_POSITION_INVALID");
|
||||
});
|
||||
|
||||
it("add() rejects a second source plugin even at slot 0", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const c = chain();
|
||||
c.add(new SourcePlugin("s1"));
|
||||
try {
|
||||
c.add(new SourcePlugin("s2"), 0);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.CHAIN_SOURCE_POSITION_INVALID
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected CHAIN_SOURCE_POSITION_INVALID");
|
||||
});
|
||||
|
||||
it("remove() drops by id or index and emits plugin-removed", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const c = chain();
|
||||
c.add(new GainPlugin("a"));
|
||||
c.add(new GainPlugin("b"));
|
||||
const events: number[] = [];
|
||||
c.on("plugin-removed", (e) => events.push(e.index));
|
||||
c.remove("a");
|
||||
c.remove(0);
|
||||
expect(c.plugins).toEqual([]);
|
||||
expect(events).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it("remove() rejects unknown id / index with CHAIN_SLOT_NOT_FOUND", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const c = chain();
|
||||
for (const op of [() => c.remove("nope"), () => c.remove(0)]) {
|
||||
try {
|
||||
op();
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.CHAIN_SLOT_NOT_FOUND
|
||||
);
|
||||
continue;
|
||||
}
|
||||
throw new Error("expected CHAIN_SLOT_NOT_FOUND");
|
||||
}
|
||||
});
|
||||
|
||||
it("move() reorders and emits plugin-moved", { timeout: 5_000 }, () => {
|
||||
const c = chain();
|
||||
c.add(new GainPlugin("a"));
|
||||
c.add(new GainPlugin("b"));
|
||||
c.add(new GainPlugin("c"));
|
||||
let payload: { from: number; to: number } | undefined;
|
||||
c.on("plugin-moved", (e) => {
|
||||
payload = { from: e.from, to: e.to };
|
||||
});
|
||||
c.move(0, 2);
|
||||
expect(c.plugins.map((p) => p.metadata.id)).toEqual(["b", "c", "a"]);
|
||||
expect(payload).toEqual({ from: 0, to: 2 });
|
||||
});
|
||||
|
||||
it("move() with from === to is a no-op", { timeout: 5_000 }, () => {
|
||||
const c = chain();
|
||||
c.add(new GainPlugin("a"));
|
||||
let fired = false;
|
||||
c.on("plugin-moved", () => {
|
||||
fired = true;
|
||||
});
|
||||
c.move(0, 0);
|
||||
expect(fired).toBe(false);
|
||||
});
|
||||
|
||||
it("move() rejects out-of-range from/to with CHAIN_SLOT_NOT_FOUND", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const c = chain();
|
||||
c.add(new GainPlugin("a"));
|
||||
for (const op of [() => c.move(2, 0), () => c.move(0, 9)]) {
|
||||
try {
|
||||
op();
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.CHAIN_SLOT_NOT_FOUND
|
||||
);
|
||||
continue;
|
||||
}
|
||||
throw new Error("expected CHAIN_SLOT_NOT_FOUND");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginChain: bypass", () => {
|
||||
it("bypass() toggles and emits bypass-changed", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = chain();
|
||||
const p = new GainPlugin("a", 2);
|
||||
c.add(p);
|
||||
await c.setup(STEREO_CONFIG, AMBIENT);
|
||||
const events: Array<{ id: string; bypassed: boolean }> = [];
|
||||
c.on("bypass-changed", (e) =>
|
||||
events.push({ id: e.plugin.metadata.id, bypassed: e.bypassed })
|
||||
);
|
||||
c.bypass("a", true);
|
||||
c.bypass("a", true); // idempotent
|
||||
c.bypass(0, false);
|
||||
expect(events).toEqual([
|
||||
{ id: "a", bypassed: true },
|
||||
{ id: "a", bypassed: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("bypassed slot copies input to output without invoking process", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = chain();
|
||||
const p = new GainPlugin("a", 2); // ×2 gain
|
||||
c.add(p);
|
||||
await c.setup(STEREO_CONFIG, AMBIENT);
|
||||
c.bypass(0, true);
|
||||
const { input, output } = buffers();
|
||||
input[0]!.fill(0.5);
|
||||
input[1]!.fill(-0.5);
|
||||
c.process(input, output, blockCtx());
|
||||
// unchanged because bypassed (no ×2 applied)
|
||||
expect(output[0]![0]).toBe(0.5);
|
||||
expect(output[1]![2]).toBe(-0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginChain: setup + process", () => {
|
||||
it("processes a single plugin end-to-end", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = chain();
|
||||
c.add(new GainPlugin("a", 2));
|
||||
await c.setup(STEREO_CONFIG, AMBIENT);
|
||||
const { input, output } = buffers();
|
||||
input[0]!.fill(1);
|
||||
input[1]!.fill(1);
|
||||
c.process(input, output, blockCtx());
|
||||
expect(output[0]![0]).toBe(2);
|
||||
expect(output[1]![2]).toBe(2);
|
||||
});
|
||||
|
||||
it("chains multiple plugins; output equals composed plugin processing", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = chain();
|
||||
c.add(new GainPlugin("a", 2));
|
||||
c.add(new GainPlugin("b", 3));
|
||||
c.add(new GainPlugin("c", 0.5));
|
||||
await c.setup(STEREO_CONFIG, AMBIENT);
|
||||
const { input, output } = buffers();
|
||||
input[0]!.fill(1);
|
||||
input[1]!.fill(1);
|
||||
c.process(input, output, blockCtx());
|
||||
// 1 × 2 × 3 × 0.5 = 3
|
||||
expect(output[0]![0]).toBeCloseTo(3, 6);
|
||||
expect(output[1]![3]).toBeCloseTo(3, 6);
|
||||
});
|
||||
|
||||
it("passes input through an empty chain unchanged", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = chain();
|
||||
await c.setup(STEREO_CONFIG, AMBIENT);
|
||||
const { input, output } = buffers();
|
||||
input[0]!.fill(0.7);
|
||||
input[1]!.fill(-0.3);
|
||||
c.process(input, output, blockCtx());
|
||||
expect(output[0]![0]).toBeCloseTo(0.7, 6);
|
||||
expect(output[1]![1]).toBeCloseTo(-0.3, 6);
|
||||
});
|
||||
|
||||
it("reset() forwards to every plugin", { timeout: 5_000 }, async () => {
|
||||
const c = chain();
|
||||
c.add(new GainPlugin("a"));
|
||||
c.add(new GainPlugin("b"));
|
||||
await c.setup(STEREO_CONFIG, AMBIENT);
|
||||
expect(() => c.reset()).not.toThrow();
|
||||
});
|
||||
|
||||
it("reuses scratch buffers when channel count and block size are stable", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = chain();
|
||||
c.add(new GainPlugin("a"));
|
||||
c.add(new GainPlugin("b"));
|
||||
c.add(new GainPlugin("c"));
|
||||
await c.setup(STEREO_CONFIG, AMBIENT);
|
||||
const { input, output } = buffers();
|
||||
// Two consecutive process() calls; should not throw.
|
||||
c.process(input, output, blockCtx());
|
||||
c.process(input, output, blockCtx());
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginChain: ambient state", () => {
|
||||
it("updateAmbient() merges patches and emits ambient-changed", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const c = chain();
|
||||
const events: Array<unknown> = [];
|
||||
c.on("ambient-changed", (e) => events.push(e.patch));
|
||||
c.updateAmbient({ transport: { tempo: 140 } });
|
||||
expect(c.currentAmbient.transport.tempo).toBe(140);
|
||||
expect(events.length).toBe(1);
|
||||
});
|
||||
|
||||
it("updateAmbient() with a no-op patch does not emit", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const c = chain();
|
||||
c.updateAmbient({ transport: { tempo: 120 } }); // default already 120
|
||||
let fired = 0;
|
||||
c.on("ambient-changed", () => fired++);
|
||||
c.updateAmbient({ transport: { tempo: 120 } });
|
||||
expect(fired).toBe(0);
|
||||
});
|
||||
|
||||
it("plugins see ambient updates via captured ChainContext", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = chain();
|
||||
const p = new GainPlugin("a");
|
||||
c.add(p);
|
||||
await c.setup(
|
||||
STEREO_CONFIG,
|
||||
applyAmbientPatch(AMBIENT, {
|
||||
transport: { tempo: 120 },
|
||||
})
|
||||
);
|
||||
c.updateAmbient({ transport: { tempo: 200 } });
|
||||
// The plugin's captured chainContext.ambient.transport.tempo
|
||||
// is now 200 — verified by reading through the public surface
|
||||
// (the ChainContext is internal; this asserts the propagation
|
||||
// via the chain's own currentAmbient mirror).
|
||||
expect(c.currentAmbient.transport.tempo).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginChain: PDC + latency events", () => {
|
||||
it("getEffectiveLatencySamples sums non-bypassed plugin latencies", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = chain();
|
||||
const lim = new LatencyPlugin("lim", 100);
|
||||
const free = new GainPlugin("free");
|
||||
const reverb = new LatencyPlugin("reverb", 50);
|
||||
c.add(lim);
|
||||
c.add(free);
|
||||
c.add(reverb);
|
||||
await c.setup(STEREO_CONFIG, AMBIENT);
|
||||
expect(c.getEffectiveLatencySamples()).toBe(150);
|
||||
c.bypass("lim", true);
|
||||
expect(c.getEffectiveLatencySamples()).toBe(50);
|
||||
c.bypass("lim", false);
|
||||
expect(c.getEffectiveLatencySamples()).toBe(150);
|
||||
});
|
||||
|
||||
it("plugin latency change re-emits chain-level latency-changed", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = chain();
|
||||
const lim = new LatencyPlugin("lim", 0);
|
||||
c.add(lim);
|
||||
await c.setup(STEREO_CONFIG, AMBIENT);
|
||||
const events: number[] = [];
|
||||
c.on("latency-changed", (e) => events.push(e.effectiveLatencySamples));
|
||||
lim.bump(64);
|
||||
lim.bump(64); // idempotent
|
||||
lim.bump(128);
|
||||
expect(events).toEqual([64, 128]);
|
||||
expect(c.getEffectiveLatencySamples()).toBe(128);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginChain: dispose", () => {
|
||||
it("dispose disposes every plugin and is idempotent", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = chain();
|
||||
c.add(new GainPlugin("a"));
|
||||
c.add(new GainPlugin("b"));
|
||||
await c.setup(STEREO_CONFIG, AMBIENT);
|
||||
c.dispose();
|
||||
c.dispose(); // idempotent
|
||||
expect(c.plugins).toEqual([]);
|
||||
});
|
||||
|
||||
it("operations after dispose throw NOT_INITIALIZED", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = chain();
|
||||
await c.setup(STEREO_CONFIG, AMBIENT);
|
||||
c.dispose();
|
||||
try {
|
||||
c.add(new GainPlugin("a"));
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.NOT_INITIALIZED
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected NOT_INITIALIZED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginChain: findById + getEffectiveOutputChannels + source chain", () => {
|
||||
it("findById returns the plugin and its index", { timeout: 5_000 }, () => {
|
||||
const c = chain();
|
||||
const p = new GainPlugin("a");
|
||||
c.add(p);
|
||||
expect(c.findById("a")?.plugin).toBe(p);
|
||||
expect(c.findById("a")?.index).toBe(0);
|
||||
expect(c.findById("nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getEffectiveOutputChannels reflects chain config + plugin outputs", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const c = chain();
|
||||
c.add(new GainPlugin("a"));
|
||||
expect(c.getEffectiveOutputChannels()).toBe(2);
|
||||
});
|
||||
|
||||
it("processes a source-led chain end-to-end", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = chain();
|
||||
c.add(new SourcePlugin("src"));
|
||||
c.add(new GainPlugin("gain", 2));
|
||||
await c.setup(STEREO_CONFIG, AMBIENT);
|
||||
const { input, output } = buffers();
|
||||
c.process(input, output, blockCtx());
|
||||
// source fills 0.25 → ×2 = 0.5
|
||||
expect(output[0]![0]).toBe(0.5);
|
||||
expect(output[1]![3]).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginChain: event subscription cleanup", () => {
|
||||
it("unsubscribe stops the listener firing", { timeout: 5_000 }, () => {
|
||||
const c = chain();
|
||||
let count = 0;
|
||||
const unsub = c.on("plugin-added", () => count++);
|
||||
c.add(new GainPlugin("a"));
|
||||
unsub();
|
||||
c.add(new GainPlugin("b"));
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("multiple listeners on the same event all fire", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const c = chain();
|
||||
let a = 0;
|
||||
let b = 0;
|
||||
c.on("plugin-added", () => a++);
|
||||
c.on("plugin-added", () => b++);
|
||||
c.add(new GainPlugin("x"));
|
||||
expect(a).toBe(1);
|
||||
expect(b).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginChain: ChainContext.chain handle reflects chain shape", () => {
|
||||
it("ChainContext.chain.slotCount reflects the chain's current length", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
// SpyPlugin captures the ctx.chain at setup time.
|
||||
class SpyPlugin extends AbstractAudioFxPlugin {
|
||||
public capturedSlotCount = -1;
|
||||
public constructor() {
|
||||
super({
|
||||
metadata: {
|
||||
id: "spy",
|
||||
name: "Spy",
|
||||
version: "1.0.0",
|
||||
category: "utility",
|
||||
},
|
||||
schema: PRESET_SCHEMA,
|
||||
io: STEREO_IO,
|
||||
parameters: [],
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
}
|
||||
protected override setupState(
|
||||
_config: ProcessorConfig,
|
||||
chainContext: import("../base/audio-fx-plugin.types.js").ChainContext
|
||||
): void {
|
||||
this.capturedSlotCount = chainContext.chain.slotCount;
|
||||
}
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[]
|
||||
): void {
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
output[ch]!.set(input[ch]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
const c = chain();
|
||||
const spy = new SpyPlugin();
|
||||
c.add(new GainPlugin("a"));
|
||||
c.add(spy);
|
||||
c.add(new GainPlugin("b"));
|
||||
await c.setup(STEREO_CONFIG, AMBIENT);
|
||||
expect(spy.capturedSlotCount).toBe(3);
|
||||
});
|
||||
});
|
||||
300
packages/media/audio-fx/audio-fx-plugin-base/src/chain/chain.ts
Normal file
300
packages/media/audio-fx/audio-fx-plugin-base/src/chain/chain.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import type {
|
||||
AmbientState,
|
||||
AudioFxPlugin,
|
||||
UnsubscribeFn,
|
||||
} from "../base/audio-fx-plugin.types.js";
|
||||
import { AUDIO_FX_CODES, AudioFxError } from "../error-codes/error-codes.js";
|
||||
import type { PluginRegistry } from "../registry/registry.js";
|
||||
import {
|
||||
type AmbientStatePatch,
|
||||
ambientStateEquals,
|
||||
applyAmbientPatch,
|
||||
defaultAmbientState,
|
||||
} from "./ambient-state.js";
|
||||
import type { BusRegistry } from "./bus-registry.types.js";
|
||||
import type {
|
||||
ChainEvent,
|
||||
ChainEventListener,
|
||||
PluginChainOptions,
|
||||
} from "./chain.types.js";
|
||||
import {
|
||||
AmbientStateBox,
|
||||
ChainContextFactory,
|
||||
type ChainSlotSnapshot,
|
||||
createChainHandle,
|
||||
pluginHandleFor,
|
||||
} from "./context/index.js";
|
||||
import { ChainEventBus } from "./event-bus/index.js";
|
||||
import {
|
||||
addSlotToChain,
|
||||
allocatePingPongBuffers,
|
||||
computeEffectiveOutputChannels,
|
||||
moveSlotInChain,
|
||||
resolveSlotIndex,
|
||||
} from "./insert/index.js";
|
||||
import {
|
||||
computeEffectiveLatency,
|
||||
disposeChainSlots,
|
||||
processChainBlock,
|
||||
setupChainSlots,
|
||||
} from "./lifecycle/index.js";
|
||||
import {
|
||||
buildChainSnapshot,
|
||||
type ChainSnapshot,
|
||||
restoreChainFromSnapshot,
|
||||
} from "./snapshot/index.js";
|
||||
|
||||
/** Internal slot record; see `chain.md` for the full surface. */
|
||||
interface ChainSlot {
|
||||
plugin: AudioFxPlugin;
|
||||
bypassed: boolean;
|
||||
latencyUnsub?: UnsubscribeFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered insert list of `AudioFxPlugin`s with chain-level bypass,
|
||||
* ambient-state propagation, latency aggregation, and typed events.
|
||||
*/
|
||||
export class PluginChain {
|
||||
public readonly id: string;
|
||||
public readonly name: string;
|
||||
|
||||
private readonly slots: ChainSlot[] = [];
|
||||
private config: ProcessorConfig | undefined;
|
||||
private readonly ambientBox: AmbientStateBox;
|
||||
private readonly contextFactory: ChainContextFactory;
|
||||
private readonly events = new ChainEventBus();
|
||||
private scratchA: Float32Array[] = [];
|
||||
private scratchB: Float32Array[] = [];
|
||||
private effectiveOutputChannels: number;
|
||||
private cachedEffectiveLatency = 0;
|
||||
private disposed = false;
|
||||
private readonly slotCountRef: { value: number };
|
||||
|
||||
public constructor(opts: PluginChainOptions, buses: BusRegistry) {
|
||||
this.id = opts.id;
|
||||
this.name = opts.name ?? opts.id;
|
||||
this.ambientBox = new AmbientStateBox(defaultAmbientState());
|
||||
this.effectiveOutputChannels = opts.channels;
|
||||
this.slotCountRef = { value: 0 };
|
||||
this.contextFactory = new ChainContextFactory({
|
||||
chain: createChainHandle(opts, this.name, this.slotCountRef),
|
||||
buses,
|
||||
ambient: this.ambientBox,
|
||||
});
|
||||
}
|
||||
|
||||
public get plugins(): ReadonlyArray<AudioFxPlugin> {
|
||||
return this.slots.map((s) => s.plugin);
|
||||
}
|
||||
|
||||
public get currentAmbient(): AmbientState {
|
||||
return this.ambientBox.current;
|
||||
}
|
||||
|
||||
public findById(
|
||||
id: string
|
||||
): { plugin: AudioFxPlugin; index: number } | undefined {
|
||||
const index = this.slots.findIndex((s) => s.plugin.metadata.id === id);
|
||||
return index < 0
|
||||
? undefined
|
||||
: { plugin: this.slots[index]!.plugin, index };
|
||||
}
|
||||
|
||||
public getEffectiveLatencySamples(): number {
|
||||
return this.cachedEffectiveLatency;
|
||||
}
|
||||
|
||||
public getEffectiveOutputChannels(): number {
|
||||
return this.effectiveOutputChannels;
|
||||
}
|
||||
|
||||
public add(plugin: AudioFxPlugin, at?: number): void {
|
||||
this.requireNotDisposed();
|
||||
const slot: ChainSlot = { plugin, bypassed: false };
|
||||
const index = addSlotToChain(
|
||||
this.slots,
|
||||
slot,
|
||||
at,
|
||||
this.effectiveOutputChannels,
|
||||
this.id
|
||||
);
|
||||
slot.latencyUnsub = plugin.onLatencyChanged(() => {
|
||||
this.recomputeEffectiveLatency();
|
||||
});
|
||||
this.rebuildAfterChange();
|
||||
this.events.emit("plugin-added", { plugin, index });
|
||||
}
|
||||
|
||||
public remove(idOrIndex: string | number): void {
|
||||
this.requireNotDisposed();
|
||||
const index = resolveSlotIndex(this.slots, idOrIndex, this.id);
|
||||
const slot = this.slots[index]!;
|
||||
slot.latencyUnsub?.();
|
||||
this.slots.splice(index, 1);
|
||||
this.rebuildAfterChange();
|
||||
this.events.emit("plugin-removed", { plugin: slot.plugin, index });
|
||||
}
|
||||
|
||||
public move(from: number, to: number): void {
|
||||
this.requireNotDisposed();
|
||||
const moved = moveSlotInChain(
|
||||
this.slots,
|
||||
from,
|
||||
to,
|
||||
this.effectiveOutputChannels,
|
||||
this.id
|
||||
);
|
||||
if (moved === undefined) {
|
||||
return;
|
||||
}
|
||||
this.rebuildAfterChange();
|
||||
this.events.emit("plugin-moved", { plugin: moved.plugin, from, to });
|
||||
}
|
||||
|
||||
public bypass(idOrIndex: string | number, bypassed: boolean): void {
|
||||
this.requireNotDisposed();
|
||||
const index = resolveSlotIndex(this.slots, idOrIndex, this.id);
|
||||
const slot = this.slots[index]!;
|
||||
if (slot.bypassed === bypassed) {
|
||||
return;
|
||||
}
|
||||
slot.bypassed = bypassed;
|
||||
this.recomputeEffectiveLatency();
|
||||
this.events.emit("bypass-changed", {
|
||||
plugin: slot.plugin,
|
||||
index,
|
||||
bypassed,
|
||||
});
|
||||
}
|
||||
|
||||
public updateAmbient(patch: AmbientStatePatch): void {
|
||||
this.requireNotDisposed();
|
||||
const previous = this.ambientBox.current;
|
||||
const next = applyAmbientPatch(previous, patch);
|
||||
if (ambientStateEquals(previous, next)) {
|
||||
return;
|
||||
}
|
||||
this.ambientBox.set(next);
|
||||
this.events.emit("ambient-changed", { patch });
|
||||
}
|
||||
|
||||
public async setup(
|
||||
config: ProcessorConfig,
|
||||
ambient: AmbientState,
|
||||
opts?: { signal?: AbortSignal }
|
||||
): Promise<void> {
|
||||
this.requireNotDisposed();
|
||||
this.config = config;
|
||||
this.effectiveOutputChannels = config.channels;
|
||||
this.rebuildAfterChange();
|
||||
this.ambientBox.set(ambient);
|
||||
await setupChainSlots(this.slots, config, this.contextFactory, opts);
|
||||
this.recomputeEffectiveLatency();
|
||||
}
|
||||
|
||||
public process(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[],
|
||||
ctx: BlockContext
|
||||
): void {
|
||||
this.requireNotDisposed();
|
||||
processChainBlock(
|
||||
this.slots,
|
||||
this.scratchA,
|
||||
this.scratchB,
|
||||
input,
|
||||
output,
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.requireNotDisposed();
|
||||
for (const slot of this.slots) {
|
||||
slot.plugin.reset();
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
disposeChainSlots(this.slots);
|
||||
this.events.clear();
|
||||
this.disposed = true;
|
||||
}
|
||||
|
||||
public snapshot(): ChainSnapshot {
|
||||
return buildChainSnapshot({
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
config: this.config,
|
||||
effectiveOutputChannels: this.effectiveOutputChannels,
|
||||
slots: this.slots,
|
||||
});
|
||||
}
|
||||
|
||||
public async restore(
|
||||
input: unknown,
|
||||
registry: PluginRegistry
|
||||
): Promise<void> {
|
||||
this.requireNotDisposed();
|
||||
await restoreChainFromSnapshot(this, input, registry);
|
||||
}
|
||||
|
||||
public on<E extends ChainEvent>(
|
||||
event: E,
|
||||
listener: ChainEventListener<E>
|
||||
): UnsubscribeFn {
|
||||
return this.events.on(event, listener);
|
||||
}
|
||||
|
||||
private requireNotDisposed(): void {
|
||||
if (this.disposed) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.NOT_INITIALIZED,
|
||||
"audio-fx.chain-disposed",
|
||||
{ context: { chainId: this.id } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildAfterChange(): void {
|
||||
const inputCh = this.config?.channels ?? this.effectiveOutputChannels;
|
||||
this.effectiveOutputChannels = computeEffectiveOutputChannels(
|
||||
this.slots,
|
||||
inputCh
|
||||
);
|
||||
this.slotCountRef.value = this.slots.length;
|
||||
const snap: ChainSlotSnapshot[] = this.slots.map((slot) => ({
|
||||
plugin: pluginHandleFor(slot.plugin, slot),
|
||||
bypassed: slot.bypassed,
|
||||
}));
|
||||
this.contextFactory.rebuild(snap);
|
||||
this.recomputeEffectiveLatency();
|
||||
if (this.config === undefined) {
|
||||
return;
|
||||
}
|
||||
const buffers = allocatePingPongBuffers(
|
||||
this.config.channels,
|
||||
this.config.blockSize,
|
||||
this.scratchA,
|
||||
this.scratchB
|
||||
);
|
||||
this.scratchA = buffers.a;
|
||||
this.scratchB = buffers.b;
|
||||
}
|
||||
|
||||
private recomputeEffectiveLatency(): void {
|
||||
const total = computeEffectiveLatency(this.slots);
|
||||
if (total === this.cachedEffectiveLatency) {
|
||||
return;
|
||||
}
|
||||
this.cachedEffectiveLatency = total;
|
||||
this.events.emit("latency-changed", {
|
||||
effectiveLatencySamples: total,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { AudioFxPlugin } from "../base/audio-fx-plugin.types.js";
|
||||
import type { AmbientStatePatch } from "./ambient-state.js";
|
||||
|
||||
export interface PluginChainOptions {
|
||||
readonly id: string;
|
||||
readonly name?: string;
|
||||
readonly channels: number;
|
||||
readonly sampleRate: number;
|
||||
readonly blockSize: number;
|
||||
}
|
||||
|
||||
export type ChainEvent =
|
||||
| "plugin-added"
|
||||
| "plugin-removed"
|
||||
| "plugin-moved"
|
||||
| "plugin-replaced"
|
||||
| "bypass-changed"
|
||||
| "parameter-changed"
|
||||
| "preset-applied"
|
||||
| "ambient-changed"
|
||||
| "latency-changed";
|
||||
|
||||
export interface ChainEventPayload {
|
||||
readonly "plugin-added": {
|
||||
plugin: AudioFxPlugin;
|
||||
index: number;
|
||||
};
|
||||
readonly "plugin-removed": {
|
||||
plugin: AudioFxPlugin;
|
||||
index: number;
|
||||
};
|
||||
readonly "plugin-moved": {
|
||||
plugin: AudioFxPlugin;
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
readonly "plugin-replaced": {
|
||||
from: AudioFxPlugin;
|
||||
to: AudioFxPlugin;
|
||||
index: number;
|
||||
};
|
||||
readonly "bypass-changed": {
|
||||
plugin: AudioFxPlugin;
|
||||
index: number;
|
||||
bypassed: boolean;
|
||||
};
|
||||
readonly "parameter-changed": {
|
||||
plugin: AudioFxPlugin;
|
||||
index: number;
|
||||
paramId: string;
|
||||
value: number;
|
||||
};
|
||||
readonly "preset-applied": {
|
||||
plugin: AudioFxPlugin;
|
||||
index: number;
|
||||
};
|
||||
readonly "ambient-changed": {
|
||||
patch: AmbientStatePatch;
|
||||
};
|
||||
readonly "latency-changed": {
|
||||
effectiveLatencySamples: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type ChainEventListener<E extends ChainEvent> = (
|
||||
payload: ChainEventPayload[E]
|
||||
) => void;
|
||||
@@ -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,231 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
BusRegistryReadHandle,
|
||||
ChainHandle,
|
||||
PluginHandle,
|
||||
} from "../../base/audio-fx-plugin.types.js";
|
||||
import { defaultAmbientState } from "../ambient-state.js";
|
||||
import {
|
||||
AmbientStateBox,
|
||||
ChainContextFactory,
|
||||
type ChainSlotSnapshot,
|
||||
pluginHandleFor,
|
||||
} from "./context.js";
|
||||
|
||||
const CHAIN_HANDLE: ChainHandle = {
|
||||
id: "c1",
|
||||
name: "Track 1",
|
||||
channels: 2,
|
||||
sampleRate: 48_000,
|
||||
blockSize: 128,
|
||||
slotCount: 0,
|
||||
};
|
||||
|
||||
const EMPTY_BUSES: BusRegistryReadHandle = {
|
||||
get: () => undefined,
|
||||
list: () => [],
|
||||
};
|
||||
|
||||
function fakeHandle(id: string, paramValue = 0): PluginHandle {
|
||||
return pluginHandleFor(
|
||||
{
|
||||
metadata: {
|
||||
id,
|
||||
name: id,
|
||||
version: "1.0.0",
|
||||
category: "utility",
|
||||
},
|
||||
io: { inputs: [{ count: 2 }], outputs: [{ count: "preserve" }] },
|
||||
capabilities: {
|
||||
hasAudioNode: false,
|
||||
hasWorklet: false,
|
||||
hasSidechain: false,
|
||||
sampleAccurate: true,
|
||||
stateful: false,
|
||||
asyncSetup: false,
|
||||
preservesChannelCount: true,
|
||||
latencySamples: 0,
|
||||
},
|
||||
getParameter: () => paramValue,
|
||||
getLatencySamples: () => 0,
|
||||
},
|
||||
{ bypassed: false }
|
||||
);
|
||||
}
|
||||
|
||||
function snap(...handles: PluginHandle[]): ChainSlotSnapshot[] {
|
||||
return handles.map((h) => ({ plugin: h, bypassed: false }));
|
||||
}
|
||||
|
||||
describe("AmbientStateBox", () => {
|
||||
it("exposes the latest ambient state via `current`", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const box = new AmbientStateBox(defaultAmbientState());
|
||||
expect(box.current.transport.state).toBe("stopped");
|
||||
const next = {
|
||||
...defaultAmbientState(),
|
||||
transport: {
|
||||
...defaultAmbientState().transport,
|
||||
state: "playing" as const,
|
||||
},
|
||||
};
|
||||
box.set(next);
|
||||
expect(box.current.transport.state).toBe("playing");
|
||||
expect(box.current).toBe(next);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChainContextFactory: composition + slot views", () => {
|
||||
it("builds one ChainContext per slot with position index + total", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const box = new AmbientStateBox(defaultAmbientState());
|
||||
const factory = new ChainContextFactory({
|
||||
chain: CHAIN_HANDLE,
|
||||
buses: EMPTY_BUSES,
|
||||
ambient: box,
|
||||
});
|
||||
factory.rebuild(
|
||||
snap(fakeHandle("a"), fakeHandle("b"), fakeHandle("c"))
|
||||
);
|
||||
const ctxA = factory.contextFor(0);
|
||||
const ctxB = factory.contextFor(1);
|
||||
const ctxC = factory.contextFor(2);
|
||||
expect(ctxA.position).toEqual({ index: 0, total: 3 });
|
||||
expect(ctxB.position).toEqual({ index: 1, total: 3 });
|
||||
expect(ctxC.position).toEqual({ index: 2, total: 3 });
|
||||
});
|
||||
|
||||
it("populates predecessors / successors per slot", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const factory = new ChainContextFactory({
|
||||
chain: CHAIN_HANDLE,
|
||||
buses: EMPTY_BUSES,
|
||||
ambient: new AmbientStateBox(defaultAmbientState()),
|
||||
});
|
||||
factory.rebuild(
|
||||
snap(fakeHandle("a"), fakeHandle("b"), fakeHandle("c"))
|
||||
);
|
||||
const ctxB = factory.contextFor(1);
|
||||
expect(ctxB.predecessors.map((p) => p.id)).toEqual(["a"]);
|
||||
expect(ctxB.successors.map((p) => p.id)).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("reflects subsequent rebuild() calls in existing context references", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const factory = new ChainContextFactory({
|
||||
chain: CHAIN_HANDLE,
|
||||
buses: EMPTY_BUSES,
|
||||
ambient: new AmbientStateBox(defaultAmbientState()),
|
||||
});
|
||||
factory.rebuild(snap(fakeHandle("a"), fakeHandle("b")));
|
||||
const ctxA = factory.contextFor(0);
|
||||
expect(ctxA.successors.map((p) => p.id)).toEqual(["b"]);
|
||||
|
||||
// Add a third slot via a fresh rebuild — captured ctxA must see it.
|
||||
factory.rebuild(
|
||||
snap(fakeHandle("a"), fakeHandle("b"), fakeHandle("c"))
|
||||
);
|
||||
expect(ctxA.position).toEqual({ index: 0, total: 3 });
|
||||
expect(ctxA.successors.map((p) => p.id)).toEqual(["b", "c"]);
|
||||
});
|
||||
|
||||
it("ambient getter reflects subsequent AmbientStateBox writes", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const box = new AmbientStateBox(defaultAmbientState());
|
||||
const factory = new ChainContextFactory({
|
||||
chain: CHAIN_HANDLE,
|
||||
buses: EMPTY_BUSES,
|
||||
ambient: box,
|
||||
});
|
||||
factory.rebuild(snap(fakeHandle("a")));
|
||||
const ctx = factory.contextFor(0);
|
||||
expect(ctx.ambient.transport.state).toBe("stopped");
|
||||
const next = {
|
||||
...defaultAmbientState(),
|
||||
transport: {
|
||||
...defaultAmbientState().transport,
|
||||
tempo: 200,
|
||||
},
|
||||
};
|
||||
box.set(next);
|
||||
expect(ctx.ambient.transport.tempo).toBe(200);
|
||||
});
|
||||
|
||||
it("throws when asking for a slot index outside the rebuilt range", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const factory = new ChainContextFactory({
|
||||
chain: CHAIN_HANDLE,
|
||||
buses: EMPTY_BUSES,
|
||||
ambient: new AmbientStateBox(defaultAmbientState()),
|
||||
});
|
||||
factory.rebuild(snap(fakeHandle("a")));
|
||||
expect(() => factory.contextFor(5)).toThrow();
|
||||
});
|
||||
|
||||
it("shrinking the chain drops the trailing cached contexts", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const factory = new ChainContextFactory({
|
||||
chain: CHAIN_HANDLE,
|
||||
buses: EMPTY_BUSES,
|
||||
ambient: new AmbientStateBox(defaultAmbientState()),
|
||||
});
|
||||
factory.rebuild(
|
||||
snap(fakeHandle("a"), fakeHandle("b"), fakeHandle("c"))
|
||||
);
|
||||
factory.rebuild(snap(fakeHandle("a")));
|
||||
expect(() => factory.contextFor(1)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pluginHandleFor", () => {
|
||||
it("exposes id, metadata, io, capabilities, and parameter lookup", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const handle = fakeHandle("x", 0.42);
|
||||
expect(handle.id).toBe("x");
|
||||
expect(handle.metadata.id).toBe("x");
|
||||
expect(handle.capabilities.sampleAccurate).toBe(true);
|
||||
expect(handle.getParameter("anything")).toBe(0.42);
|
||||
expect(handle.getLatencySamples()).toBe(0);
|
||||
});
|
||||
|
||||
it("bypassed reflects the slot's current bypassed flag (mutable through the slot)", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const slot = { bypassed: false };
|
||||
const handle = pluginHandleFor(
|
||||
{
|
||||
metadata: {
|
||||
id: "x",
|
||||
name: "X",
|
||||
version: "1.0.0",
|
||||
category: "utility",
|
||||
},
|
||||
io: { inputs: [{ count: 2 }], outputs: [{ count: 2 }] },
|
||||
capabilities: {
|
||||
hasAudioNode: false,
|
||||
hasWorklet: false,
|
||||
hasSidechain: false,
|
||||
sampleAccurate: true,
|
||||
stateful: false,
|
||||
asyncSetup: false,
|
||||
preservesChannelCount: true,
|
||||
latencySamples: 0,
|
||||
},
|
||||
getParameter: () => 0,
|
||||
getLatencySamples: () => 0,
|
||||
},
|
||||
slot
|
||||
);
|
||||
expect(handle.bypassed).toBe(false);
|
||||
slot.bypassed = true;
|
||||
expect(handle.bypassed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import type {
|
||||
AmbientState,
|
||||
BusRegistryReadHandle,
|
||||
ChainContext,
|
||||
ChainHandle,
|
||||
PluginHandle,
|
||||
} from "../../base/audio-fx-plugin.types.js";
|
||||
|
||||
/**
|
||||
* Per-slot snapshot of the chain state visible to one plugin's
|
||||
* `ChainContext`. The `PluginChain` rebuilds these (and the wider
|
||||
* `ChainContext` instances) whenever composition changes.
|
||||
*/
|
||||
export interface ChainSlotSnapshot {
|
||||
readonly plugin: PluginHandle;
|
||||
/** Engine-determined bypass state for this slot. */
|
||||
readonly bypassed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one `ChainContext` per plugin slot. The handed-out reference
|
||||
* is stable for the chain's lifetime — its contents update via the
|
||||
* `AmbientStateBox` (engine pushes patches) and the slot lists are
|
||||
* regenerated by the chain on `add`/`remove`/`move`/`replace`. Plugins
|
||||
* may capture the reference at `setup()` and dereference its slots
|
||||
* every block.
|
||||
*/
|
||||
export class ChainContextFactory {
|
||||
private readonly chainHandle: ChainHandle;
|
||||
private readonly buses: BusRegistryReadHandle;
|
||||
private readonly ambientBox: AmbientStateBox;
|
||||
private slots: ReadonlyArray<ChainSlotSnapshot> = [];
|
||||
private cachedContexts: ChainContext[] = [];
|
||||
|
||||
public constructor(opts: {
|
||||
chain: ChainHandle;
|
||||
buses: BusRegistryReadHandle;
|
||||
ambient: AmbientStateBox;
|
||||
}) {
|
||||
this.chainHandle = opts.chain;
|
||||
this.buses = opts.buses;
|
||||
this.ambientBox = opts.ambient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes the per-slot `ChainContext` array. Called by the chain
|
||||
* after every composition change; existing context references are
|
||||
* reused where possible so plugins that captured them still see the
|
||||
* latest peer lists without re-binding.
|
||||
*/
|
||||
public rebuild(slots: ReadonlyArray<ChainSlotSnapshot>): void {
|
||||
this.slots = slots;
|
||||
const next: ChainContext[] = [];
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
const cached = this.cachedContexts[i];
|
||||
if (cached !== undefined) {
|
||||
next.push(cached);
|
||||
} else {
|
||||
next.push(this.createContextForSlot(i));
|
||||
}
|
||||
}
|
||||
// Slots removed from the tail drop their cached contexts.
|
||||
this.cachedContexts = next;
|
||||
}
|
||||
|
||||
public contextFor(index: number): ChainContext {
|
||||
const ctx = this.cachedContexts[index];
|
||||
if (ctx === undefined) {
|
||||
throw new Error(
|
||||
`ChainContextFactory: no context for slot ${index} (chain length: ${this.cachedContexts.length})`
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private createContextForSlot(index: number): ChainContext {
|
||||
const factory = this;
|
||||
const context: ChainContext = {
|
||||
chain: this.chainHandle,
|
||||
get position() {
|
||||
return {
|
||||
index,
|
||||
total: factory.slots.length,
|
||||
};
|
||||
},
|
||||
get predecessors() {
|
||||
return factory.slots.slice(0, index).map((s) => s.plugin);
|
||||
},
|
||||
get successors() {
|
||||
return factory.slots.slice(index + 1).map((s) => s.plugin);
|
||||
},
|
||||
get ambient() {
|
||||
return factory.ambientBox.current;
|
||||
},
|
||||
buses: this.buses,
|
||||
};
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the chain's current `AmbientState` so the `ChainContextFactory`
|
||||
* can hand plugins a stable getter (`ctx.ambient`) that always reflects
|
||||
* the latest pushed value without re-allocating the surrounding context.
|
||||
*/
|
||||
export class AmbientStateBox {
|
||||
public current: AmbientState;
|
||||
|
||||
public constructor(initial: AmbientState) {
|
||||
this.current = initial;
|
||||
}
|
||||
|
||||
public set(next: AmbientState): void {
|
||||
this.current = next;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `ChainHandle` whose `slotCount` getter resolves to the
|
||||
* supplied mutable ref. The chain owns the ref and mutates it as
|
||||
* slots come and go so plugins reading `ctx.chain.slotCount` always
|
||||
* see the live count without re-binding.
|
||||
*/
|
||||
export function createChainHandle(
|
||||
opts: {
|
||||
readonly id: string;
|
||||
readonly channels: number;
|
||||
readonly sampleRate: number;
|
||||
readonly blockSize: number;
|
||||
},
|
||||
name: string,
|
||||
slotCountRef: { readonly value: number }
|
||||
): import("../../base/audio-fx-plugin.types.js").ChainHandle {
|
||||
return {
|
||||
id: opts.id,
|
||||
name,
|
||||
channels: opts.channels,
|
||||
sampleRate: opts.sampleRate,
|
||||
blockSize: opts.blockSize,
|
||||
get slotCount() {
|
||||
return slotCountRef.value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight read-only handle wrapping a plugin instance. Used by the
|
||||
* chain to populate `ChainContext.predecessors` / `.successors` so peer
|
||||
* plugins can read each other's parameters and IO without holding the
|
||||
* mutator surface.
|
||||
*/
|
||||
export function pluginHandleFor(
|
||||
plugin: {
|
||||
metadata: PluginHandle["metadata"];
|
||||
io: PluginHandle["io"];
|
||||
capabilities: PluginHandle["capabilities"];
|
||||
getParameter(id: string): number;
|
||||
getLatencySamples(): number;
|
||||
},
|
||||
slot: { bypassed: boolean }
|
||||
): PluginHandle {
|
||||
return {
|
||||
id: plugin.metadata.id,
|
||||
metadata: plugin.metadata,
|
||||
io: plugin.io,
|
||||
capabilities: plugin.capabilities,
|
||||
get bypassed() {
|
||||
return slot.bypassed;
|
||||
},
|
||||
getParameter: (id) => plugin.getParameter(id),
|
||||
getLatencySamples: () => plugin.getLatencySamples(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
AmbientStateBox,
|
||||
ChainContextFactory,
|
||||
type ChainSlotSnapshot,
|
||||
createChainHandle,
|
||||
pluginHandleFor,
|
||||
} from "./context.js";
|
||||
@@ -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,48 @@
|
||||
import type { UnsubscribeFn } from "../../base/audio-fx-plugin.types.js";
|
||||
import type {
|
||||
ChainEvent,
|
||||
ChainEventListener,
|
||||
ChainEventPayload,
|
||||
} from "../chain.types.js";
|
||||
|
||||
/**
|
||||
* Typed event bus shared by `PluginChain`. Listeners are organised by
|
||||
* event name with an `UnsubscribeFn` returned from `on`. `clear()` is
|
||||
* called on chain `dispose()` so listeners cannot fire on a disposed
|
||||
* chain.
|
||||
*/
|
||||
export class ChainEventBus {
|
||||
private readonly listeners: Map<ChainEvent, Set<unknown>> = new Map();
|
||||
|
||||
public on<E extends ChainEvent>(
|
||||
event: E,
|
||||
listener: ChainEventListener<E>
|
||||
): UnsubscribeFn {
|
||||
let bucket = this.listeners.get(event);
|
||||
if (bucket === undefined) {
|
||||
bucket = new Set();
|
||||
this.listeners.set(event, bucket);
|
||||
}
|
||||
bucket.add(listener);
|
||||
return () => {
|
||||
bucket?.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
public emit<E extends ChainEvent>(
|
||||
event: E,
|
||||
payload: ChainEventPayload[E]
|
||||
): void {
|
||||
const bucket = this.listeners.get(event);
|
||||
if (bucket === undefined) {
|
||||
return;
|
||||
}
|
||||
for (const listener of bucket) {
|
||||
(listener as ChainEventListener<E>)(payload);
|
||||
}
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ChainEventBus } from "./event-bus.js";
|
||||
@@ -0,0 +1,55 @@
|
||||
export {
|
||||
type AmbientStatePatch,
|
||||
ambientStateEquals,
|
||||
applyAmbientPatch,
|
||||
defaultAmbientState,
|
||||
} from "./ambient-state.js";
|
||||
export { DefaultBusRegistry } from "./bus-registry.js";
|
||||
export type {
|
||||
BusRegistry,
|
||||
SidechainPublisher,
|
||||
} from "./bus-registry.types.js";
|
||||
export { PluginChain } from "./chain.js";
|
||||
export type {
|
||||
ChainEvent,
|
||||
ChainEventListener,
|
||||
ChainEventPayload,
|
||||
PluginChainOptions,
|
||||
} from "./chain.types.js";
|
||||
export {
|
||||
AmbientStateBox,
|
||||
ChainContextFactory,
|
||||
type ChainSlotSnapshot,
|
||||
pluginHandleFor,
|
||||
} from "./context/index.js";
|
||||
export { ChainEventBus } from "./event-bus/index.js";
|
||||
export {
|
||||
allocatePingPongBuffers,
|
||||
assertMoveBounds,
|
||||
assertSlotChainCompatible,
|
||||
type ChainSlotLike,
|
||||
computeEffectiveOutputChannels,
|
||||
resolveInsertIndex,
|
||||
resolveSlotIndex,
|
||||
} from "./insert/index.js";
|
||||
export {
|
||||
type ChainLifecycleSlot,
|
||||
computeEffectiveLatency,
|
||||
disposeChainSlots,
|
||||
processChainBlock,
|
||||
setupChainSlots,
|
||||
} from "./lifecycle/index.js";
|
||||
export type {
|
||||
ChainPluginSnapshot,
|
||||
ChainSnapshot,
|
||||
} from "./snapshot/index.js";
|
||||
export {
|
||||
buildChainSnapshot,
|
||||
ChainPluginSnapshotSchema,
|
||||
type ChainRestoreTarget,
|
||||
ChainSnapshotSchema,
|
||||
type ChainSnapshotSource,
|
||||
instantiateChainSlot,
|
||||
parseChainSnapshot,
|
||||
restoreChainFromSnapshot,
|
||||
} from "./snapshot/index.js";
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
addSlotToChain,
|
||||
allocatePingPongBuffers,
|
||||
assertMoveBounds,
|
||||
assertSlotChainCompatible,
|
||||
type ChainSlotLike,
|
||||
computeEffectiveOutputChannels,
|
||||
moveSlotInChain,
|
||||
resolveInsertIndex,
|
||||
resolveSlotIndex,
|
||||
} from "./insert.js";
|
||||
@@ -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,42 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { allocatePingPongBuffers } from "./insert.js";
|
||||
|
||||
describe("allocatePingPongBuffers", () => {
|
||||
it("returns previous buffers unchanged when shape matches", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const prevA = [new Float32Array(64), new Float32Array(64)];
|
||||
const prevB = [new Float32Array(64), new Float32Array(64)];
|
||||
const result = allocatePingPongBuffers(2, 64, prevA, prevB);
|
||||
expect(result.a).toBe(prevA);
|
||||
expect(result.b).toBe(prevB);
|
||||
});
|
||||
|
||||
it("allocates fresh buffers when channel count changes", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const prevA = [new Float32Array(64)];
|
||||
const prevB = [new Float32Array(64)];
|
||||
const result = allocatePingPongBuffers(2, 64, prevA, prevB);
|
||||
expect(result.a).not.toBe(prevA);
|
||||
expect(result.a).toHaveLength(2);
|
||||
expect(result.a[0]).toHaveLength(64);
|
||||
expect(result.b).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("allocates fresh buffers when block size changes", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const prevA = [new Float32Array(64)];
|
||||
const prevB = [new Float32Array(64)];
|
||||
const result = allocatePingPongBuffers(1, 128, prevA, prevB);
|
||||
expect(result.a).not.toBe(prevA);
|
||||
expect(result.a[0]).toHaveLength(128);
|
||||
});
|
||||
|
||||
it("allocates fresh buffers from empty input", { timeout: 5_000 }, () => {
|
||||
const result = allocatePingPongBuffers(2, 32, [], []);
|
||||
expect(result.a).toHaveLength(2);
|
||||
expect(result.a[0]).toHaveLength(32);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { AudioFxPlugin } from "../../base/audio-fx-plugin.types.js";
|
||||
import {
|
||||
isSourcePlugin,
|
||||
resolveOutputChannels,
|
||||
validateChainIo,
|
||||
} from "../../base/plugin-io.js";
|
||||
import { AUDIO_FX_CODES, AudioFxError } from "../../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Minimal slot shape these helpers care about — just the plugin
|
||||
* reference. The chain owns the full slot record with bypass +
|
||||
* latency-unsubscribe; this surface is enough for validation.
|
||||
*/
|
||||
export interface ChainSlotLike {
|
||||
readonly plugin: AudioFxPlugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resolved insertion index for `plugin`, throwing
|
||||
* `AUDIO_FX.CHAIN_DUPLICATE_PLUGIN_ID` for a clashing
|
||||
* `metadata.id`, `AUDIO_FX.CHAIN_SLOT_NOT_FOUND` for an
|
||||
* out-of-range `at`, or `AUDIO_FX.CHAIN_SOURCE_POSITION_INVALID`
|
||||
* when source-plugin placement rules are violated.
|
||||
*/
|
||||
export function resolveInsertIndex(
|
||||
slots: ReadonlyArray<ChainSlotLike>,
|
||||
plugin: AudioFxPlugin,
|
||||
at: number | undefined,
|
||||
chainId: string
|
||||
): number {
|
||||
for (const s of slots) {
|
||||
if (s.plugin.metadata.id === plugin.metadata.id) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.CHAIN_DUPLICATE_PLUGIN_ID,
|
||||
"audio-fx.chain-duplicate-plugin-id",
|
||||
{
|
||||
context: { chainId, pluginId: plugin.metadata.id },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
const index = at ?? slots.length;
|
||||
if (index < 0 || index > slots.length) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.CHAIN_SLOT_NOT_FOUND,
|
||||
"audio-fx.chain-slot-not-found",
|
||||
{ context: { chainId, requestedIndex: at } }
|
||||
);
|
||||
}
|
||||
if (isSourcePlugin(plugin.io)) {
|
||||
if (index !== 0) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.CHAIN_SOURCE_POSITION_INVALID,
|
||||
"audio-fx.chain-source-position-invalid",
|
||||
{
|
||||
context: {
|
||||
chainId,
|
||||
pluginId: plugin.metadata.id,
|
||||
requestedIndex: index,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
if (slots.length > 0 && isSourcePlugin(slots[0]!.plugin.io)) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.CHAIN_SOURCE_POSITION_INVALID,
|
||||
"audio-fx.chain-source-already-present",
|
||||
{ context: { chainId, pluginId: plugin.metadata.id } }
|
||||
);
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the candidate slot list and validates every input/output
|
||||
* channel edge against the configured chain input channel count.
|
||||
* Source plugins (input count 0) skip edge validation; their output
|
||||
* becomes the upstream channel count for the next edge.
|
||||
*/
|
||||
export function assertSlotChainCompatible(
|
||||
candidate: ReadonlyArray<ChainSlotLike>,
|
||||
inputChannels: number,
|
||||
chainId: string
|
||||
): void {
|
||||
let upstream = inputChannels;
|
||||
for (let i = 0; i < candidate.length; i++) {
|
||||
const slot = candidate[i]!;
|
||||
const inputSpec = slot.plugin.io.inputs[0]!;
|
||||
if (inputSpec.count !== 0) {
|
||||
const result = validateChainIo(upstream, inputSpec);
|
||||
if (!result.ok) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.CHAIN_IO_INCOMPATIBLE,
|
||||
"audio-fx.chain-io-incompatible",
|
||||
{
|
||||
context: {
|
||||
chainId,
|
||||
pluginId: slot.plugin.metadata.id,
|
||||
slot: i,
|
||||
reason: result.reason,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
const outputSpec = slot.plugin.io.outputs[0]!;
|
||||
upstream = resolveOutputChannels(outputSpec, upstream);
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute the chain's effective output channel count from its slots. */
|
||||
export function computeEffectiveOutputChannels(
|
||||
slots: ReadonlyArray<ChainSlotLike>,
|
||||
inputChannels: number
|
||||
): number {
|
||||
let upstream = inputChannels;
|
||||
for (const slot of slots) {
|
||||
const outputSpec = slot.plugin.io.outputs[0]!;
|
||||
upstream = resolveOutputChannels(outputSpec, upstream);
|
||||
}
|
||||
return upstream;
|
||||
}
|
||||
|
||||
/** Resolve a string id or numeric index to a slot index, or throw. */
|
||||
export function resolveSlotIndex(
|
||||
slots: ReadonlyArray<ChainSlotLike>,
|
||||
idOrIndex: string | number,
|
||||
chainId: string
|
||||
): number {
|
||||
if (typeof idOrIndex === "number") {
|
||||
if (idOrIndex < 0 || idOrIndex >= slots.length) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.CHAIN_SLOT_NOT_FOUND,
|
||||
"audio-fx.chain-slot-index-not-found",
|
||||
{ context: { chainId, index: idOrIndex } }
|
||||
);
|
||||
}
|
||||
return idOrIndex;
|
||||
}
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
if (slots[i]!.plugin.metadata.id === idOrIndex) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.CHAIN_SLOT_NOT_FOUND,
|
||||
"audio-fx.chain-slot-id-not-found",
|
||||
{ context: { chainId, pluginId: idOrIndex } }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + commit an `add` of `slot` at `at` (or end). Returns the
|
||||
* resolved index. Throws via `resolveInsertIndex` /
|
||||
* `assertSlotChainCompatible` on validation failure; the supplied
|
||||
* `slots` array is left untouched on throw.
|
||||
*/
|
||||
export function addSlotToChain<S extends ChainSlotLike>(
|
||||
slots: S[],
|
||||
slot: S,
|
||||
at: number | undefined,
|
||||
inputChannels: number,
|
||||
chainId: string
|
||||
): number {
|
||||
const index = resolveInsertIndex(slots, slot.plugin, at, chainId);
|
||||
const candidate = [...slots];
|
||||
candidate.splice(index, 0, slot);
|
||||
assertSlotChainCompatible(candidate, inputChannels, chainId);
|
||||
slots.splice(index, 0, slot);
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + commit a `move(from, to)` against `slots` in place. Returns
|
||||
* the moved slot on success, or `undefined` when `from === to` so the
|
||||
* caller can short-circuit event emission.
|
||||
*/
|
||||
export function moveSlotInChain<S extends ChainSlotLike>(
|
||||
slots: S[],
|
||||
from: number,
|
||||
to: number,
|
||||
inputChannels: number,
|
||||
chainId: string
|
||||
): S | undefined {
|
||||
assertMoveBounds(slots.length, from, to, chainId);
|
||||
if (from === to) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = [...slots];
|
||||
const [moved] = candidate.splice(from, 1) as [S];
|
||||
candidate.splice(to, 0, moved);
|
||||
assertSlotChainCompatible(candidate, inputChannels, chainId);
|
||||
const [actual] = slots.splice(from, 1) as [S];
|
||||
slots.splice(to, 0, actual);
|
||||
return actual;
|
||||
}
|
||||
|
||||
/** Asserts both endpoints of a `move(from, to)` are in range. */
|
||||
export function assertMoveBounds(
|
||||
slotsLength: number,
|
||||
from: number,
|
||||
to: number,
|
||||
chainId: string
|
||||
): void {
|
||||
if (from < 0 || from >= slotsLength) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.CHAIN_SLOT_NOT_FOUND,
|
||||
"audio-fx.chain-move-from-not-found",
|
||||
{ context: { chainId, from } }
|
||||
);
|
||||
}
|
||||
if (to < 0 || to >= slotsLength) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.CHAIN_SLOT_NOT_FOUND,
|
||||
"audio-fx.chain-move-to-not-found",
|
||||
{ context: { chainId, to } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `{ a, b }` ping-pong scratch buffers sized to
|
||||
* `channels × blockSize`. When `prevA` already matches the requested
|
||||
* shape, the previous buffers are returned unchanged so the chain
|
||||
* does not reallocate on stable shape changes.
|
||||
*/
|
||||
export function allocatePingPongBuffers(
|
||||
channels: number,
|
||||
blockSize: number,
|
||||
prevA: Float32Array[],
|
||||
prevB: Float32Array[]
|
||||
): { a: Float32Array[]; b: Float32Array[] } {
|
||||
if (prevA.length === channels && prevA[0]?.length === blockSize) {
|
||||
return { a: prevA, b: prevB };
|
||||
}
|
||||
const a: Float32Array[] = [];
|
||||
const b: Float32Array[] = [];
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
a.push(new Float32Array(blockSize));
|
||||
b.push(new Float32Array(blockSize));
|
||||
}
|
||||
return { a, b };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type ChainLifecycleSlot,
|
||||
computeEffectiveLatency,
|
||||
disposeChainSlots,
|
||||
processChainBlock,
|
||||
setupChainSlots,
|
||||
} from "./lifecycle.js";
|
||||
@@ -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,85 @@
|
||||
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import type {
|
||||
AudioFxPlugin,
|
||||
UnsubscribeFn,
|
||||
} from "../../base/audio-fx-plugin.types.js";
|
||||
import type { ChainContextFactory } from "../context/context.js";
|
||||
|
||||
/** Slot shape the lifecycle helpers operate on. */
|
||||
export interface ChainLifecycleSlot {
|
||||
plugin: AudioFxPlugin;
|
||||
bypassed: boolean;
|
||||
latencyUnsub?: UnsubscribeFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the chain's slots forward, threading per-slot scratch buffers.
|
||||
* Bypassed slots copy input → target verbatim without invoking
|
||||
* `plugin.process`. The last slot writes directly to `output`.
|
||||
*/
|
||||
export function processChainBlock(
|
||||
slots: ReadonlyArray<ChainLifecycleSlot>,
|
||||
scratchA: Float32Array[],
|
||||
scratchB: Float32Array[],
|
||||
input: Float32Array[],
|
||||
output: Float32Array[],
|
||||
ctx: BlockContext
|
||||
): void {
|
||||
if (slots.length === 0) {
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
output[ch]!.set(input[ch]!);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let currentIn = input;
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
const slot = slots[i]!;
|
||||
const last = i === slots.length - 1;
|
||||
const target = last ? output : i % 2 === 0 ? scratchA : scratchB;
|
||||
if (slot.bypassed) {
|
||||
for (let ch = 0; ch < currentIn.length; ch++) {
|
||||
target[ch]!.set(currentIn[ch]!);
|
||||
}
|
||||
} else {
|
||||
slot.plugin.process(currentIn, target, ctx);
|
||||
}
|
||||
currentIn = target;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fan `slot.plugin.setup(config, ctx, opts)` across every slot in parallel. */
|
||||
export async function setupChainSlots(
|
||||
slots: ReadonlyArray<ChainLifecycleSlot>,
|
||||
config: ProcessorConfig,
|
||||
contextFactory: ChainContextFactory,
|
||||
opts?: { signal?: AbortSignal }
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
slots.map((slot, i) =>
|
||||
slot.plugin.setup(config, contextFactory.contextFor(i), opts)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Unsubscribe + dispose every slot, then clear the slot list. */
|
||||
export function disposeChainSlots(slots: ChainLifecycleSlot[]): void {
|
||||
for (const slot of slots) {
|
||||
slot.latencyUnsub?.();
|
||||
slot.plugin.dispose();
|
||||
}
|
||||
slots.length = 0;
|
||||
}
|
||||
|
||||
/** Sum `getLatencySamples()` across every non-bypassed slot. */
|
||||
export function computeEffectiveLatency(
|
||||
slots: ReadonlyArray<ChainLifecycleSlot>
|
||||
): number {
|
||||
let total = 0;
|
||||
for (const slot of slots) {
|
||||
if (slot.bypassed) {
|
||||
continue;
|
||||
}
|
||||
total += slot.plugin.getLatencySamples();
|
||||
}
|
||||
return total;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
buildChainSnapshot,
|
||||
ChainPluginSnapshotSchema,
|
||||
type ChainRestoreTarget,
|
||||
ChainSnapshotSchema,
|
||||
type ChainSnapshotSource,
|
||||
instantiateChainSlot,
|
||||
parseChainSnapshot,
|
||||
restoreChainFromSnapshot,
|
||||
} from "./snapshot.js";
|
||||
export type {
|
||||
ChainPluginSnapshot,
|
||||
ChainSnapshot,
|
||||
} from "./snapshot.types.js";
|
||||
@@ -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,387 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import z from "zod";
|
||||
import { AbstractAudioFxPlugin } from "../../base/audio-fx-plugin.js";
|
||||
import {
|
||||
AUDIO_FX_CODES,
|
||||
type AudioFxError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { PluginRegistry } from "../../registry/registry.js";
|
||||
import { DefaultBusRegistry } from "../bus-registry.js";
|
||||
import { PluginChain } from "../chain.js";
|
||||
import {
|
||||
ChainSnapshotSchema,
|
||||
instantiateChainSlot,
|
||||
parseChainSnapshot,
|
||||
} from "./snapshot.js";
|
||||
|
||||
const SCHEMA = z.object({}).strict();
|
||||
const STEREO_IO = {
|
||||
inputs: [{ count: 2 as const }],
|
||||
outputs: [{ count: "preserve" as const }],
|
||||
};
|
||||
|
||||
class GainPlugin extends AbstractAudioFxPlugin {
|
||||
public constructor(id: string, gain = 1) {
|
||||
super({
|
||||
metadata: { id, name: id, version: "1.0.0", category: "utility" },
|
||||
schema: SCHEMA,
|
||||
io: STEREO_IO,
|
||||
parameters: [
|
||||
{
|
||||
id: "gain",
|
||||
name: "Gain",
|
||||
value: gain,
|
||||
min: 0,
|
||||
max: 4,
|
||||
default: 1,
|
||||
automatable: true,
|
||||
},
|
||||
],
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
}
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[]
|
||||
): void {
|
||||
const g = this.parameterValue("gain");
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
for (let i = 0; i < input[ch]!.length; i++) {
|
||||
output[ch]![i] = input[ch]![i]! * g;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LatencyPlugin extends AbstractAudioFxPlugin {
|
||||
public constructor(id: string, latency: number) {
|
||||
super({
|
||||
metadata: { id, name: id, version: "1.0.0", category: "utility" },
|
||||
schema: SCHEMA,
|
||||
io: STEREO_IO,
|
||||
parameters: [],
|
||||
capabilities: {
|
||||
hasAudioNode: false,
|
||||
hasWorklet: false,
|
||||
hasSidechain: false,
|
||||
sampleAccurate: true,
|
||||
stateful: false,
|
||||
asyncSetup: false,
|
||||
preservesChannelCount: true,
|
||||
latencySamples: latency,
|
||||
},
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
}
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[]
|
||||
): void {
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
output[ch]!.set(input[ch]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function newChain(): PluginChain {
|
||||
return new PluginChain(
|
||||
{ id: "c1", channels: 2, sampleRate: 48_000, blockSize: 4 },
|
||||
new DefaultBusRegistry()
|
||||
);
|
||||
}
|
||||
|
||||
function newRegistry(): PluginRegistry {
|
||||
const r = new PluginRegistry();
|
||||
r.register("gain-a", () => new GainPlugin("gain-a"));
|
||||
r.register("gain-b", () => new GainPlugin("gain-b"));
|
||||
r.register("lat", () => new LatencyPlugin("lat", 64));
|
||||
return r;
|
||||
}
|
||||
|
||||
describe("parseChainSnapshot: envelope validation", () => {
|
||||
it("accepts a well-formed envelope", { timeout: 5_000 }, () => {
|
||||
const valid = {
|
||||
id: "c1",
|
||||
name: "c1",
|
||||
channels: 2,
|
||||
sampleRate: 48_000,
|
||||
blockSize: 4,
|
||||
plugins: [],
|
||||
};
|
||||
expect(parseChainSnapshot(valid)).toEqual(valid);
|
||||
});
|
||||
|
||||
it("rejects a malformed envelope with INVALID_PRESET", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
try {
|
||||
parseChainSnapshot({ id: "", plugins: "not-an-array" });
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_PRESET
|
||||
);
|
||||
expect(
|
||||
Array.isArray((error as AudioFxError).context?.["issues"])
|
||||
).toBe(true);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_PRESET");
|
||||
});
|
||||
|
||||
it("exports the schema for direct safeParse use", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = ChainSnapshotSchema.safeParse({
|
||||
id: "c1",
|
||||
name: "c1",
|
||||
channels: 2,
|
||||
sampleRate: 48_000,
|
||||
blockSize: 4,
|
||||
plugins: [],
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("instantiateChainSlot", () => {
|
||||
it("creates the plugin via registry and applies the preset", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = newRegistry();
|
||||
const out = instantiateChainSlot(
|
||||
{
|
||||
pluginType: "gain-a",
|
||||
instanceId: "gain-a",
|
||||
bypassed: true,
|
||||
preset: {
|
||||
schemaVersion: 1,
|
||||
pluginType: "gain-a",
|
||||
parameters: { gain: 3 },
|
||||
},
|
||||
},
|
||||
r
|
||||
);
|
||||
expect(out.plugin.metadata.id).toBe("gain-a");
|
||||
expect(out.plugin.getParameter("gain")).toBe(3);
|
||||
expect(out.bypassed).toBe(true);
|
||||
});
|
||||
|
||||
it("propagates CHAIN_SLOT_NOT_FOUND for missing plugin types", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = new PluginRegistry();
|
||||
try {
|
||||
instantiateChainSlot(
|
||||
{
|
||||
pluginType: "absent",
|
||||
instanceId: "absent",
|
||||
bypassed: false,
|
||||
preset: {
|
||||
schemaVersion: 1,
|
||||
pluginType: "absent",
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
r
|
||||
);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.CHAIN_SLOT_NOT_FOUND
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected CHAIN_SLOT_NOT_FOUND");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginChain.snapshot", () => {
|
||||
it("captures slot order, parameters, bypass, and chain metadata", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = newChain();
|
||||
const a = new GainPlugin("gain-a", 2);
|
||||
const b = new GainPlugin("gain-b", 3);
|
||||
c.add(a);
|
||||
c.add(b);
|
||||
c.bypass("gain-b", true);
|
||||
await c.setup(
|
||||
{ sampleRate: 48_000, channels: 2, blockSize: 4 },
|
||||
// applyAmbientPatch defaults are fine; reuse the chain's own
|
||||
// ambient by constructing a default state via the box.
|
||||
c.currentAmbient
|
||||
);
|
||||
const snap = c.snapshot();
|
||||
expect(snap.id).toBe("c1");
|
||||
expect(snap.name).toBe("c1");
|
||||
expect(snap.channels).toBe(2);
|
||||
expect(snap.sampleRate).toBe(48_000);
|
||||
expect(snap.blockSize).toBe(4);
|
||||
expect(snap.plugins.map((p) => p.pluginType)).toEqual([
|
||||
"gain-a",
|
||||
"gain-b",
|
||||
]);
|
||||
expect(snap.plugins.map((p) => p.bypassed)).toEqual([false, true]);
|
||||
expect(snap.plugins[0]!.preset.parameters["gain"]).toBe(2);
|
||||
expect(snap.plugins[1]!.preset.parameters["gain"]).toBe(3);
|
||||
// Plain JSON round-trip works.
|
||||
const json = JSON.parse(JSON.stringify(snap));
|
||||
expect(parseChainSnapshot(json)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("snapshot before setup uses chain-construction defaults for shape", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const c = newChain();
|
||||
c.add(new GainPlugin("gain-a"));
|
||||
const snap = c.snapshot();
|
||||
expect(snap.channels).toBe(2);
|
||||
expect(snap.sampleRate).toBe(48_000);
|
||||
expect(snap.blockSize).toBe(0);
|
||||
expect(snap.plugins).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginChain.restore", () => {
|
||||
it("rebuilds order, parameters, bypass, and effective latency", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const source = newChain();
|
||||
source.add(new LatencyPlugin("lat", 64));
|
||||
source.add(new GainPlugin("gain-a", 2));
|
||||
source.add(new GainPlugin("gain-b", 3));
|
||||
source.bypass("gain-a", true);
|
||||
await source.setup(
|
||||
{ sampleRate: 48_000, channels: 2, blockSize: 4 },
|
||||
source.currentAmbient
|
||||
);
|
||||
const snap = source.snapshot();
|
||||
expect(source.getEffectiveLatencySamples()).toBe(64);
|
||||
|
||||
const target = newChain();
|
||||
await target.restore(snap, newRegistry());
|
||||
expect(target.plugins.map((p) => p.metadata.id)).toEqual([
|
||||
"lat",
|
||||
"gain-a",
|
||||
"gain-b",
|
||||
]);
|
||||
expect(target.plugins[1]!.getParameter("gain")).toBe(2);
|
||||
expect(target.plugins[2]!.getParameter("gain")).toBe(3);
|
||||
expect(target.findById("gain-a")).toBeDefined();
|
||||
expect(target.getEffectiveLatencySamples()).toBe(
|
||||
source.getEffectiveLatencySamples()
|
||||
);
|
||||
// bypass state preserved
|
||||
const snapAfter = target.snapshot();
|
||||
expect(snapAfter.plugins.map((p) => p.bypassed)).toEqual([
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects missing pluginType with CHAIN_SLOT_NOT_FOUND", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const snap = {
|
||||
id: "c1",
|
||||
name: "c1",
|
||||
channels: 2,
|
||||
sampleRate: 48_000,
|
||||
blockSize: 4,
|
||||
plugins: [
|
||||
{
|
||||
pluginType: "ghost",
|
||||
instanceId: "ghost",
|
||||
bypassed: false,
|
||||
preset: {
|
||||
schemaVersion: 1,
|
||||
pluginType: "ghost",
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const target = newChain();
|
||||
try {
|
||||
await target.restore(snap, newRegistry());
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.CHAIN_SLOT_NOT_FOUND
|
||||
);
|
||||
expect((error as AudioFxError).context).toEqual({
|
||||
pluginType: "ghost",
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error("expected CHAIN_SLOT_NOT_FOUND");
|
||||
});
|
||||
|
||||
it("rejects a malformed envelope with INVALID_PRESET", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const target = newChain();
|
||||
try {
|
||||
await target.restore({ id: 0 }, newRegistry());
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_PRESET
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_PRESET");
|
||||
});
|
||||
|
||||
it("drains existing slots before restoring", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const target = newChain();
|
||||
target.add(new GainPlugin("gain-b"));
|
||||
const snap = {
|
||||
id: "c1",
|
||||
name: "c1",
|
||||
channels: 2,
|
||||
sampleRate: 48_000,
|
||||
blockSize: 4,
|
||||
plugins: [
|
||||
{
|
||||
pluginType: "gain-a",
|
||||
instanceId: "gain-a",
|
||||
bypassed: false,
|
||||
preset: {
|
||||
schemaVersion: 1,
|
||||
pluginType: "gain-a",
|
||||
parameters: { gain: 2 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
await target.restore(snap, newRegistry());
|
||||
expect(target.plugins.map((p) => p.metadata.id)).toEqual(["gain-a"]);
|
||||
});
|
||||
|
||||
it("rejects restore after dispose with NOT_INITIALIZED", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const target = newChain();
|
||||
target.dispose();
|
||||
try {
|
||||
await target.restore(
|
||||
{
|
||||
id: "c1",
|
||||
name: "c1",
|
||||
channels: 2,
|
||||
sampleRate: 48_000,
|
||||
blockSize: 4,
|
||||
plugins: [],
|
||||
},
|
||||
newRegistry()
|
||||
);
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.NOT_INITIALIZED
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected NOT_INITIALIZED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import z from "zod";
|
||||
import type { AudioFxPlugin } from "../../base/audio-fx-plugin.types.js";
|
||||
import { PresetSnapshotSchema } from "../../base/preset.js";
|
||||
import { AUDIO_FX_CODES, AudioFxError } from "../../error-codes/error-codes.js";
|
||||
import type { PluginRegistry } from "../../registry/registry.js";
|
||||
import type { ChainPluginSnapshot, ChainSnapshot } from "./snapshot.types.js";
|
||||
|
||||
/**
|
||||
* Zod schema for one chain slot's snapshot envelope. Nests
|
||||
* `PresetSnapshotSchema` so the preset's universal envelope is
|
||||
* validated alongside the slot's per-instance metadata.
|
||||
*/
|
||||
export const ChainPluginSnapshotSchema = z.object({
|
||||
pluginType: z.string().min(1),
|
||||
instanceId: z.string().min(1),
|
||||
bypassed: z.boolean(),
|
||||
preset: PresetSnapshotSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Zod schema for the chain envelope. The `channels`/`sampleRate`/
|
||||
* `blockSize` fields are informational on restore — the caller is
|
||||
* responsible for constructing the target chain with matching values;
|
||||
* the snapshot records them so a snapshot is self-describing for
|
||||
* archival.
|
||||
*/
|
||||
export const ChainSnapshotSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
channels: z.number().int().positive(),
|
||||
sampleRate: z.number().positive(),
|
||||
blockSize: z.number().int().positive(),
|
||||
plugins: z.array(ChainPluginSnapshotSchema),
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates an unknown input against the chain snapshot schema. Throws
|
||||
* `AUDIO_FX.INVALID_PRESET` with the Zod issue list in `context.issues`
|
||||
* on failure. Reuses `INVALID_PRESET` rather than introducing a new
|
||||
* code because the chain snapshot is, at its core, a list of presets
|
||||
* wrapped in chain metadata.
|
||||
*/
|
||||
export function parseChainSnapshot(input: unknown): ChainSnapshot {
|
||||
const result = ChainSnapshotSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_PRESET,
|
||||
"audio-fx.invalid-chain-snapshot",
|
||||
{ context: { issues: result.error.issues } }
|
||||
);
|
||||
}
|
||||
return result.data as ChainSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore step for a single slot: ask the registry for a plugin
|
||||
* instance keyed by `pluginType`, apply the universal preset envelope,
|
||||
* and hand the constructed plugin + bypass state back to the caller.
|
||||
* Throws `AUDIO_FX.CHAIN_SLOT_NOT_FOUND` (via `registry.create`) when
|
||||
* the registry has no factory for the requested `pluginType`.
|
||||
*/
|
||||
export function instantiateChainSlot(
|
||||
entry: ChainPluginSnapshot,
|
||||
registry: PluginRegistry
|
||||
): { plugin: ReturnType<PluginRegistry["create"]>; bypassed: boolean } {
|
||||
const plugin = registry.create(entry.pluginType);
|
||||
plugin.applyPreset(entry.preset);
|
||||
return { plugin, bypassed: entry.bypassed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure-data input shape for `buildChainSnapshot`. The chain passes its
|
||||
* id/name/config/effective-output-channels + a slot view; the helper
|
||||
* assembles the snapshot envelope.
|
||||
*/
|
||||
export interface ChainSnapshotSource {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly config: ProcessorConfig | undefined;
|
||||
readonly effectiveOutputChannels: number;
|
||||
readonly slots: ReadonlyArray<{
|
||||
readonly plugin: AudioFxPlugin;
|
||||
readonly bypassed: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Assemble a serialisable `ChainSnapshot` from a chain's live state. */
|
||||
export function buildChainSnapshot(source: ChainSnapshotSource): ChainSnapshot {
|
||||
const plugins: ChainPluginSnapshot[] = source.slots.map((slot) => ({
|
||||
pluginType: slot.plugin.metadata.id,
|
||||
instanceId: slot.plugin.metadata.id,
|
||||
bypassed: slot.bypassed,
|
||||
preset: slot.plugin.getPreset(),
|
||||
}));
|
||||
return {
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
channels: source.config?.channels ?? source.effectiveOutputChannels,
|
||||
sampleRate: source.config?.sampleRate ?? 48_000,
|
||||
blockSize: source.config?.blockSize ?? 0,
|
||||
plugins,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal surface the chain exposes to `restoreChainFromSnapshot` so
|
||||
* the restore logic can be a free function rather than a method.
|
||||
*/
|
||||
export interface ChainRestoreTarget {
|
||||
readonly plugins: ReadonlyArray<AudioFxPlugin>;
|
||||
remove(idOrIndex: string | number): void;
|
||||
add(plugin: AudioFxPlugin): void;
|
||||
bypass(idOrIndex: string | number, bypassed: boolean): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates `input` against `ChainSnapshotSchema`, drains any existing
|
||||
* slots on `target`, then walks the snapshot adding plugins via the
|
||||
* supplied `registry`. Missing `pluginType` surfaces as
|
||||
* `AUDIO_FX.CHAIN_SLOT_NOT_FOUND` from `registry.create`.
|
||||
*/
|
||||
export async function restoreChainFromSnapshot(
|
||||
target: ChainRestoreTarget,
|
||||
input: unknown,
|
||||
registry: PluginRegistry
|
||||
): Promise<void> {
|
||||
const snapshot = parseChainSnapshot(input);
|
||||
while (target.plugins.length > 0) {
|
||||
target.remove(0);
|
||||
}
|
||||
for (const entry of snapshot.plugins) {
|
||||
const { plugin, bypassed } = instantiateChainSlot(entry, registry);
|
||||
target.add(plugin);
|
||||
if (bypassed) {
|
||||
target.bypass(plugin.metadata.id, true);
|
||||
}
|
||||
}
|
||||
await Promise.resolve();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { PresetSnapshot } from "../../base/preset.types.js";
|
||||
|
||||
/**
|
||||
* One entry in a `ChainSnapshot.plugins` list. Captures everything
|
||||
* needed to rehydrate a single slot via the `PluginRegistry`:
|
||||
*
|
||||
* - `pluginType` — the plugin's `metadata.id`; used as the factory key
|
||||
* in the registry.
|
||||
* - `instanceId` — the slot's instance identifier at snapshot time.
|
||||
* Currently equals `metadata.id` (the chain enforces unique
|
||||
* `metadata.id` per chain) but kept as a distinct field so future
|
||||
* chains that allow multiple instances of the same `pluginType`
|
||||
* can store a per-instance key.
|
||||
* - `bypassed` — the slot's bypass state.
|
||||
* - `preset` — the universal `PresetSnapshot` envelope returned by the
|
||||
* plugin's `getPreset()` at snapshot time.
|
||||
*/
|
||||
export interface ChainPluginSnapshot {
|
||||
readonly pluginType: string;
|
||||
readonly instanceId: string;
|
||||
readonly bypassed: boolean;
|
||||
readonly preset: PresetSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialisable snapshot of a `PluginChain`. Validated by
|
||||
* `parseChainSnapshot` against a Zod schema before any state is
|
||||
* touched on restore; rejected envelopes throw
|
||||
* `AUDIO_FX.INVALID_PRESET`.
|
||||
*/
|
||||
export interface ChainSnapshot {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly channels: number;
|
||||
readonly sampleRate: number;
|
||||
readonly blockSize: number;
|
||||
readonly plugins: ReadonlyArray<ChainPluginSnapshot>;
|
||||
}
|
||||
@@ -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,59 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIO_FX_CODES,
|
||||
AUDIO_FX_ERROR_METADATA,
|
||||
AudioFxError,
|
||||
registerAudioFxErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("audio-plugins-fx error codes", () => {
|
||||
it("produces dotted code strings via defineCodes", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
expect(AUDIO_FX_CODES.NOT_INITIALIZED).toBe("AUDIO_FX.NOT_INITIALIZED");
|
||||
expect(AUDIO_FX_CODES.CHAIN_IO_INCOMPATIBLE).toBe(
|
||||
"AUDIO_FX.CHAIN_IO_INCOMPATIBLE"
|
||||
);
|
||||
expect(AUDIO_FX_CODES.BUS_CYCLE_DETECTED).toBe(
|
||||
"AUDIO_FX.BUS_CYCLE_DETECTED"
|
||||
);
|
||||
});
|
||||
|
||||
it("metadata covers every declared code", { timeout: 5_000 }, () => {
|
||||
for (const code of Object.values(AUDIO_FX_CODES)) {
|
||||
expect(AUDIO_FX_ERROR_METADATA[code]).toBeDefined();
|
||||
expect(
|
||||
AUDIO_FX_ERROR_METADATA[code].description.length
|
||||
).toBeGreaterThan(0);
|
||||
expect(["warning", "error"]).toContain(
|
||||
AUDIO_FX_ERROR_METADATA[code].severity
|
||||
);
|
||||
}
|
||||
expect(Object.keys(AUDIO_FX_ERROR_METADATA).length).toBe(
|
||||
Object.values(AUDIO_FX_CODES).length
|
||||
);
|
||||
});
|
||||
|
||||
it("AudioFxError carries code, category, cause, and context", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const cause = new Error("boom");
|
||||
const err = new AudioFxError(
|
||||
AUDIO_FX_CODES.NOT_INITIALIZED,
|
||||
"audio-fx.not-initialized",
|
||||
{ cause, context: { trackId: "t-1" } }
|
||||
);
|
||||
expect(err.code).toBe("AUDIO_FX.NOT_INITIALIZED");
|
||||
expect(err.category).toBe("audio-fx");
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ trackId: "t-1" });
|
||||
expect(err.name).toBe("AudioFxError(error)");
|
||||
expect(err).toBeInstanceOf(AudioFxError);
|
||||
});
|
||||
|
||||
it("registerAudioFxErrorCodes is idempotent", { timeout: 5_000 }, () => {
|
||||
registerAudioFxErrorCodes();
|
||||
registerAudioFxErrorCodes();
|
||||
expect(Object.isFrozen(AUDIO_FX_ERROR_METADATA)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
export const AUDIO_FX_CODES = defineCodes("AUDIO_FX", [
|
||||
"NOT_INITIALIZED",
|
||||
"CHANNEL_MISMATCH",
|
||||
"UNKNOWN_PARAMETER",
|
||||
"PARAMETER_NOT_AUTOMATABLE",
|
||||
"INVALID_PRESET",
|
||||
"PRESET_VERSION_UNSUPPORTED",
|
||||
"INVALID_NOTE",
|
||||
"SAMPLE_NOT_LOADED",
|
||||
"INVALID_OPTIONS",
|
||||
"ABORTED",
|
||||
"CHAIN_IO_INCOMPATIBLE",
|
||||
"CHAIN_SLOT_NOT_FOUND",
|
||||
"CHAIN_DUPLICATE_PLUGIN_ID",
|
||||
"CHAIN_SOURCE_POSITION_INVALID",
|
||||
"BUS_NOT_FOUND",
|
||||
"BUS_CHANNEL_MISMATCH",
|
||||
"BUS_CYCLE_DETECTED",
|
||||
] as const);
|
||||
|
||||
export type AudioFxErrorCode =
|
||||
(typeof AUDIO_FX_CODES)[keyof typeof AUDIO_FX_CODES];
|
||||
|
||||
export interface AudioFxErrorMetadata {
|
||||
readonly description: string;
|
||||
readonly severity: "warning" | "error";
|
||||
}
|
||||
|
||||
export const AUDIO_FX_ERROR_METADATA: Readonly<
|
||||
Record<AudioFxErrorCode, AudioFxErrorMetadata>
|
||||
> = Object.freeze({
|
||||
[AUDIO_FX_CODES.NOT_INITIALIZED]: {
|
||||
description: "process() was called before setup() resolved.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_FX_CODES.CHANNEL_MISMATCH]: {
|
||||
description:
|
||||
"input or output channel count disagreed with the configured channels.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_FX_CODES.UNKNOWN_PARAMETER]: {
|
||||
description:
|
||||
"the supplied parameter id is not present on the plugin's declared parameters.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_FX_CODES.PARAMETER_NOT_AUTOMATABLE]: {
|
||||
description:
|
||||
"the parameter is declared non-automatable; setParameterCurve / setParameterAt are forbidden.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_FX_CODES.INVALID_PRESET]: {
|
||||
description: "preset failed Zod boundary validation.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_FX_CODES.PRESET_VERSION_UNSUPPORTED]: {
|
||||
description: "preset schemaVersion is newer than this plugin supports.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_FX_CODES.INVALID_NOTE]: {
|
||||
description: "MIDI note is outside the 0..127 range.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_FX_CODES.SAMPLE_NOT_LOADED]: {
|
||||
description:
|
||||
"playback was triggered before the source sample was loaded.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_FX_CODES.INVALID_OPTIONS]: {
|
||||
description: "options object failed Zod boundary validation.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_FX_CODES.ABORTED]: {
|
||||
description:
|
||||
"an async operation was aborted by AbortSignal before or during work.",
|
||||
severity: "warning",
|
||||
},
|
||||
[AUDIO_FX_CODES.CHAIN_IO_INCOMPATIBLE]: {
|
||||
description:
|
||||
"predecessor output channels do not satisfy the candidate plugin's declared input.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_FX_CODES.CHAIN_SLOT_NOT_FOUND]: {
|
||||
description: "no chain slot matches the requested id or index.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_FX_CODES.CHAIN_DUPLICATE_PLUGIN_ID]: {
|
||||
description:
|
||||
"a plugin with the same metadata.id is already present in the chain.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_FX_CODES.CHAIN_SOURCE_POSITION_INVALID]: {
|
||||
description:
|
||||
"a source plugin (io.inputs[0].count === 0) was inserted somewhere other than slot 0, or a second source was added.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_FX_CODES.BUS_NOT_FOUND]: {
|
||||
description:
|
||||
"requested sidechain bus is not registered in the BusRegistry.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_FX_CODES.BUS_CHANNEL_MISMATCH]: {
|
||||
description:
|
||||
"caller buffer length disagrees with the bus's declared channel count.",
|
||||
severity: "error",
|
||||
},
|
||||
[AUDIO_FX_CODES.BUS_CYCLE_DETECTED]: {
|
||||
description:
|
||||
"publishing this bus would create a cycle in the project's bus graph.",
|
||||
severity: "error",
|
||||
},
|
||||
});
|
||||
|
||||
export class AudioFxError extends SuaveplanError<AudioFxErrorCode> {
|
||||
/** Marker discriminator preventing the constructor body being elided by lint. */
|
||||
public readonly category = "audio-fx" as const;
|
||||
|
||||
public constructor(
|
||||
code: AudioFxErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
// Side-effect read of metadata keeps the lint's noUselessConstructor satisfied
|
||||
// without dead code: we surface a descriptive name pulled from the registry.
|
||||
this.name = `AudioFxError(${AUDIO_FX_ERROR_METADATA[code].severity})`;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAudioFxErrorCodes(): void {
|
||||
/* Idempotent — metadata is frozen and exported for direct use. */
|
||||
}
|
||||
|
||||
registerAudioFxErrorCodes();
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
AudioFxErrorCode,
|
||||
AudioFxErrorMetadata,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
AUDIO_FX_CODES,
|
||||
AUDIO_FX_ERROR_METADATA,
|
||||
AudioFxError,
|
||||
registerAudioFxErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
84
packages/media/audio-fx/audio-fx-plugin-base/src/index.ts
Normal file
84
packages/media/audio-fx/audio-fx-plugin-base/src/index.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
export {
|
||||
AbstractAudioFxPlugin,
|
||||
type AbstractAudioFxPluginOptions,
|
||||
type AmbientListenerState,
|
||||
type AmbientState,
|
||||
type AmbientTrackState,
|
||||
type AmbientTransportState,
|
||||
type AudioFxPlugin,
|
||||
assertPresetMatchesPluginType,
|
||||
assertPresetVersionSupported,
|
||||
type BusRegistryReadHandle,
|
||||
type ChainContext,
|
||||
type ChainHandle,
|
||||
type ChannelLayout,
|
||||
type ChannelSpec,
|
||||
capabilitiesAgreeWithMembers,
|
||||
defaultCapabilities,
|
||||
type IoCompatibility,
|
||||
ioPreservesChannelCount,
|
||||
isSourcePlugin,
|
||||
type OptionalPluginMembers,
|
||||
type ParameterAtTransport,
|
||||
type ParameterCurve,
|
||||
type ParameterScale,
|
||||
ParameterSchedule,
|
||||
type ParameterUnit,
|
||||
type PluginCapabilities,
|
||||
type PluginHandle,
|
||||
type PluginIo,
|
||||
type PluginMetadata,
|
||||
type PluginParameter,
|
||||
type PresetSnapshot,
|
||||
PresetSnapshotSchema,
|
||||
parsePresetSnapshot,
|
||||
resolveOutputChannels,
|
||||
type SidechainBus,
|
||||
type UnsubscribeFn,
|
||||
validateChainIo,
|
||||
withCapabilities,
|
||||
} from "./base/index.js";
|
||||
export {
|
||||
type BiquadBand,
|
||||
type BiquadBandType,
|
||||
type BiquadCoeffs,
|
||||
type BiquadState,
|
||||
computeBiquad,
|
||||
identityCoeffs,
|
||||
} from "./biquad/index.js";
|
||||
export {
|
||||
AmbientStateBox,
|
||||
type AmbientStatePatch,
|
||||
ambientStateEquals,
|
||||
applyAmbientPatch,
|
||||
type BusRegistry,
|
||||
ChainContextFactory,
|
||||
type ChainEvent,
|
||||
type ChainEventListener,
|
||||
type ChainEventPayload,
|
||||
type ChainPluginSnapshot,
|
||||
ChainPluginSnapshotSchema,
|
||||
type ChainSlotSnapshot,
|
||||
type ChainSnapshot,
|
||||
ChainSnapshotSchema,
|
||||
DefaultBusRegistry,
|
||||
defaultAmbientState,
|
||||
instantiateChainSlot,
|
||||
PluginChain,
|
||||
type PluginChainOptions,
|
||||
parseChainSnapshot,
|
||||
pluginHandleFor,
|
||||
type SidechainPublisher,
|
||||
} from "./chain/index.js";
|
||||
export {
|
||||
AUDIO_FX_CODES,
|
||||
AUDIO_FX_ERROR_METADATA,
|
||||
AudioFxError,
|
||||
type AudioFxErrorCode,
|
||||
type AudioFxErrorMetadata,
|
||||
registerAudioFxErrorCodes,
|
||||
} from "./error-codes/index.js";
|
||||
export {
|
||||
type PluginFactory,
|
||||
PluginRegistry,
|
||||
} from "./registry/index.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { PluginRegistry } from "./registry.js";
|
||||
export type { PluginFactory } from "./registry.types.js";
|
||||
@@ -0,0 +1,82 @@
|
||||
# `registry`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `PluginRegistry` is the **session-restore lookup table** that maps a serialised `pluginType` string (a plugin's `metadata.id`) to a fresh-instance factory. `PluginChain.restore(snapshot, registry)` calls into it once per slot in the snapshot to rehydrate the original chain composition. The registry is intentionally external to the chain so consumers control which plugins are available for restore: omit a factory and any snapshot referencing that `pluginType` will be rejected with `AUDIO_FX.CHAIN_SLOT_NOT_FOUND`, which is the right shape for sandboxed projects, plugin-blacklist policies, and CI fixtures that need to fail loud on missing dependencies.
|
||||
|
||||
The registry deliberately holds factories, not singletons. Audio plugins own per-instance DSP state (delay lines, filter histories, voice pools); two chains restoring the same `pluginType` MUST receive independent plugin instances so disposing one chain never affects another.
|
||||
|
||||
## Features
|
||||
|
||||
- `register(pluginType, factory)` stores a synchronous factory. Re-registering the same `pluginType` overwrites the previous factory — useful for tests that inject doubles without rebuilding a global registry.
|
||||
- `has(pluginType)` cheap presence check that does not invoke the factory.
|
||||
- `create(pluginType)` invokes the factory and returns a fresh `AudioFxPlugin`. Throws `AUDIO_FX.CHAIN_SLOT_NOT_FOUND` with `context.pluginType` when no factory is registered; the chain restore loop relies on this exact error code so callers can pattern-match on the missing-type case.
|
||||
- `list()` returns registered `pluginType`s in **insertion order** so UI pickers that render a "create plugin" list have a deterministic sequence. This matters for snapshot diff stability when consumers iterate the registry to seed defaults.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PluginRegistry } from "@suaveplan/audio-fx-plugin-base/registry";
|
||||
import { EQPlugin } from "@suaveplan/audio-fx-plugin-eq";
|
||||
import { CompressorPlugin } from "@suaveplan/audio-fx-plugin-compressor";
|
||||
|
||||
const registry = new PluginRegistry();
|
||||
registry.register("eq", () => new EQPlugin());
|
||||
registry.register("compressor", () => new CompressorPlugin());
|
||||
|
||||
const eq = registry.create("eq"); // fresh instance
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PluginRegistry } from "@suaveplan/audio-fx-plugin-base/registry";
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
|
||||
// Sandboxed project: only ship a curated subset of plugins to the
|
||||
// host's restore registry so user-submitted snapshots can only
|
||||
// instantiate vetted plugins.
|
||||
const sandboxRegistry = new PluginRegistry();
|
||||
for (const id of allowlist) {
|
||||
sandboxRegistry.register(id, plugins[id]);
|
||||
}
|
||||
|
||||
// Untrusted snapshot incoming. Restore will reject any pluginType
|
||||
// not in the sandbox allowlist with AUDIO_FX.CHAIN_SLOT_NOT_FOUND
|
||||
// before any plugin is instantiated.
|
||||
const chain = new PluginChain(
|
||||
{ id: snapshot.id, channels: snapshot.channels, sampleRate: snapshot.sampleRate, blockSize: snapshot.blockSize },
|
||||
new DefaultBusRegistry(),
|
||||
);
|
||||
|
||||
try {
|
||||
await chain.restore(snapshot, sandboxRegistry);
|
||||
} catch (error) {
|
||||
if ((error as AudioFxError).code === "AUDIO_FX.CHAIN_SLOT_NOT_FOUND") {
|
||||
reportToUser(`Snapshot references plugin "${error.context.pluginType}" which is not allowed on this server.`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PluginRegistry` (class)
|
||||
- `register(pluginType: string, factory: PluginFactory): void` — stores a factory; overwrites existing entries.
|
||||
- `has(pluginType: string): boolean` — presence check.
|
||||
- `create(pluginType: string): AudioFxPlugin` — invokes the factory; throws `AUDIO_FX.CHAIN_SLOT_NOT_FOUND` (`context.pluginType`) when no factory is registered.
|
||||
- `list(): ReadonlyArray<string>` — registered `pluginType`s in insertion order.
|
||||
- `PluginFactory` (type) — `() => AudioFxPlugin`. Synchronous. Must return a fresh instance per call.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry stores plain references in a `Map<string, PluginFactory>`; iteration order is insertion order per the ECMA-262 spec, which `list()` relies on. The implementation deliberately avoids any caching: a registry shared across many chains would otherwise hand out shared instances that get disposed by the first chain to tear down. Letting the consumer decide caching is the right shape — most callers want fresh instances; the rare caller that wants pooling can implement pooling inside their factory.
|
||||
|
||||
The error code on missing factory is `CHAIN_SLOT_NOT_FOUND` rather than a bespoke `PLUGIN_TYPE_NOT_REGISTERED` because the restore code path is the primary consumer and treating "no slot" and "no plugin type to fill the slot" identically keeps the error vocabulary small. The `context.pluginType` payload disambiguates the two cases for callers that need to act on them differently.
|
||||
|
||||
Factories are synchronous because plugin construction is synchronous in this package — async resource loading (impulse responses, sample files) happens in `setup()`, not the constructor. If a future plugin needed async construction, the right shape is a registered factory that performs the sync construction and an `await plugin.setup(...)` afterwards; the registry contract does not need to change.
|
||||
|
||||
Re-registration overwriting is intentional for tests and for plugin hot-swap scenarios. Consumers who want strict registration semantics can wrap the registry and reject duplicates at the caller boundary.
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import z from "zod";
|
||||
import { AbstractAudioFxPlugin } from "../base/audio-fx-plugin.js";
|
||||
import {
|
||||
AUDIO_FX_CODES,
|
||||
type AudioFxError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { PluginRegistry } from "./registry.js";
|
||||
|
||||
const SCHEMA = z.object({}).strict();
|
||||
const STEREO_IO = {
|
||||
inputs: [{ count: 2 as const }],
|
||||
outputs: [{ count: "preserve" as const }],
|
||||
};
|
||||
|
||||
class FixturePlugin extends AbstractAudioFxPlugin {
|
||||
public constructor(id: string) {
|
||||
super({
|
||||
metadata: { id, name: id, version: "1.0.0", category: "utility" },
|
||||
schema: SCHEMA,
|
||||
io: STEREO_IO,
|
||||
parameters: [],
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
}
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[]
|
||||
): void {
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
output[ch]!.set(input[ch]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("PluginRegistry: register + create", () => {
|
||||
it("registers a factory and creates fresh instances", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = new PluginRegistry();
|
||||
r.register("fx-a", () => new FixturePlugin("fx-a"));
|
||||
const a = r.create("fx-a");
|
||||
const b = r.create("fx-a");
|
||||
expect(a.metadata.id).toBe("fx-a");
|
||||
expect(b.metadata.id).toBe("fx-a");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("has(pluginType) reflects registration state", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = new PluginRegistry();
|
||||
expect(r.has("fx-a")).toBe(false);
|
||||
r.register("fx-a", () => new FixturePlugin("fx-a"));
|
||||
expect(r.has("fx-a")).toBe(true);
|
||||
});
|
||||
|
||||
it("list() returns registered types in insertion order", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = new PluginRegistry();
|
||||
r.register("fx-b", () => new FixturePlugin("fx-b"));
|
||||
r.register("fx-a", () => new FixturePlugin("fx-a"));
|
||||
r.register("fx-c", () => new FixturePlugin("fx-c"));
|
||||
expect(r.list()).toEqual(["fx-b", "fx-a", "fx-c"]);
|
||||
});
|
||||
|
||||
it("re-registering a type overwrites the previous factory", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = new PluginRegistry();
|
||||
r.register("fx-a", () => new FixturePlugin("fx-a"));
|
||||
r.register("fx-a", () => new FixturePlugin("fx-a-2"));
|
||||
expect(r.create("fx-a").metadata.id).toBe("fx-a-2");
|
||||
expect(r.list()).toEqual(["fx-a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginRegistry: missing factory rejection", () => {
|
||||
it("create() rejects an unregistered type with CHAIN_SLOT_NOT_FOUND", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const r = new PluginRegistry();
|
||||
try {
|
||||
r.create("fx-missing");
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.CHAIN_SLOT_NOT_FOUND
|
||||
);
|
||||
expect((error as AudioFxError).context).toEqual({
|
||||
pluginType: "fx-missing",
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error("expected CHAIN_SLOT_NOT_FOUND");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { AudioFxPlugin } from "../base/audio-fx-plugin.types.js";
|
||||
import { AUDIO_FX_CODES, AudioFxError } from "../error-codes/error-codes.js";
|
||||
import type { PluginFactory } from "./registry.types.js";
|
||||
|
||||
/**
|
||||
* Plugin factory registry consumed by `PluginChain.restore(snapshot,
|
||||
* registry)` to instantiate plugins from their serialised `pluginType`
|
||||
* strings. The registry is decoupled from the chain so consumers control
|
||||
* which plugins are available for session restore (omit a factory to
|
||||
* forbid restoring chains that depend on it).
|
||||
*
|
||||
* - `register(pluginType, factory)` overwrites any existing factory for
|
||||
* the same `pluginType`; this is intentional so consumers can swap in
|
||||
* a stub or test double during tests.
|
||||
* - `create(pluginType)` throws `AUDIO_FX.CHAIN_SLOT_NOT_FOUND` carrying
|
||||
* `pluginType` in `context` when no factory is registered; the chain
|
||||
* restore loop relies on that error code to surface missing types.
|
||||
* - `list()` returns the registered `pluginType`s in insertion order so
|
||||
* UIs that render a "create plugin" picker have a deterministic
|
||||
* sequence.
|
||||
*/
|
||||
export class PluginRegistry {
|
||||
private readonly factories = new Map<string, PluginFactory>();
|
||||
|
||||
public register(pluginType: string, factory: PluginFactory): void {
|
||||
this.factories.set(pluginType, factory);
|
||||
}
|
||||
|
||||
public has(pluginType: string): boolean {
|
||||
return this.factories.has(pluginType);
|
||||
}
|
||||
|
||||
public create(pluginType: string): AudioFxPlugin {
|
||||
const factory = this.factories.get(pluginType);
|
||||
if (factory === undefined) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.CHAIN_SLOT_NOT_FOUND,
|
||||
"audio-fx.plugin-type-not-registered",
|
||||
{ context: { pluginType } }
|
||||
);
|
||||
}
|
||||
return factory();
|
||||
}
|
||||
|
||||
public list(): ReadonlyArray<string> {
|
||||
return Array.from(this.factories.keys());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { AudioFxPlugin } from "../base/audio-fx-plugin.types.js";
|
||||
|
||||
/**
|
||||
* Sync factory that instantiates a fresh `AudioFxPlugin`. The factory
|
||||
* is invoked once per `PluginRegistry.create(pluginType)` call; each
|
||||
* call SHALL return a new, independent plugin instance — never a shared
|
||||
* singleton — so the chain that owns the plugin can dispose it without
|
||||
* affecting other chains.
|
||||
*/
|
||||
export type PluginFactory = () => AudioFxPlugin;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const pkgJson = JSON.parse(
|
||||
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
||||
) as {
|
||||
name: string;
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
describe("audio-fx-plugin-base dep-graph isolation", () => {
|
||||
it("declares no @suaveplan/audio-fx-plugin-* deps", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const deps = {
|
||||
...(pkgJson.dependencies ?? {}),
|
||||
...(pkgJson.devDependencies ?? {}),
|
||||
};
|
||||
const offenders = Object.keys(deps).filter((name) =>
|
||||
name.startsWith("@suaveplan/audio-fx-plugin-")
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not declare a self-dependency", { timeout: 5000 }, () => {
|
||||
const deps = {
|
||||
...(pkgJson.dependencies ?? {}),
|
||||
...(pkgJson.devDependencies ?? {}),
|
||||
};
|
||||
expect(deps[pkgJson.name]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
12
packages/media/audio-fx/audio-fx-plugin-base/tsconfig.json
Normal file
12
packages/media/audio-fx/audio-fx-plugin-base/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"tests/**",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
45
packages/media/audio-fx/audio-fx-plugin-base/vite.config.ts
Normal file
45
packages/media/audio-fx/audio-fx-plugin-base/vite.config.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/audio-fx-plugin-base",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"base/index": "src/base/index.ts",
|
||||
"biquad/index": "src/biquad/index.ts",
|
||||
"chain/index": "src/chain/index.ts",
|
||||
"registry/index": "src/registry/index.ts",
|
||||
"error-codes/index": "src/error-codes/index.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({ testTimeout: 30_000 });
|
||||
Reference in New Issue
Block a user