# `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 { // 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` plus: `metadata`, `schema`, `parameters`, `parameterIndex`, `io`, `capabilities`, `setup(config, chain, opts?): Promise`, `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.