first commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user