8.9 KiB
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
AudioFxPlugininterface every plugin implements; engines and chains program against this shape exclusively. AbstractAudioFxPluginreference 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 atsetup().- Engine-pushed
ChainContextreference captured atsetup()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
ParameterScheduleper parameter;processBlockcallsthis.parameterValue(id)to read. - Wall-clock seconds threaded through
BlockContext.opts.nowSecso synthetic clocks in tests produce deterministic ramps. - Atomic preset apply: parameter values and plugin-specific
dataare validated up front, then mutations are applied only after both pass.
Usage: Basic Example
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
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. ExtendsOmit<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, optionalcreateAudioNode? / getWorkletProcessor? / setSidechain?.AbstractAudioFxPlugin— reference base class. Subclasses passAbstractAudioFxPluginOptions(metadata, schema, io, parameters, optional capabilities, supportedSchemaVersion) tosuper()and overrideprocessBlock(required) plussetupState,resetState,disposeState,getPresetData,applyPresetData(optional).PluginMetadata,PluginHandle,AmbientState,AmbientTrackState,AmbientTransportState,AmbientListenerState,SidechainBus,BusRegistryReadHandle,ChainHandle,ChainContext,UnsubscribeFn— supporting types.parameterValue(id)(protected) — sample-accurate read forprocessBlock.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.