first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View 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.