first commit
This commit is contained in:
110
docs/media/audio-fx/audio-fx-plugin-base/src/chain/chain.md
Normal file
110
docs/media/audio-fx/audio-fx-plugin-base/src/chain/chain.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# `plugin-chain`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## 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<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.
|
||||
Reference in New Issue
Block a user