6.7 KiB
plugin-chain
(no submodules)
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 viaAUDIO_FX.CHAIN_IO_INCOMPATIBLEbefore mutating any state. - Source-plugin rules enforced:
inputs[0].count === 0is 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'sonLatencyChangedand re-emits a chain-levellatency-changedevent with the new total. - Engine-pushed ambient state via
updateAmbient(patch); equality-guarded so no-op patches don't emit spurious events. - Stable
ChainContextreference per slot for the plugin's lifetime (handed atsetup()); 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
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
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.
- Composition:
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.