# `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 via `AUDIO_FX.CHAIN_IO_INCOMPATIBLE` before mutating any state. - Source-plugin rules enforced: `inputs[0].count === 0` is 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's `onLatencyChanged` and re-emits a chain-level `latency-changed` event with the new total. - Engine-pushed ambient state via `updateAmbient(patch)`; equality-guarded so no-op patches don't emit spurious events. - Stable `ChainContext` reference per slot for the plugin's lifetime (handed at `setup()`); 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 ```ts 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 ```ts 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`. - `PluginChainOptions` — `{ id, name?, channels, sampleRate, blockSize }`. - `ChainEvent` / `ChainEventPayload` / `ChainEventListener` — 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.