first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -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.