5.0 KiB
bus-registry
Purpose
The BusRegistry is the project-level sidechain routing surface that every chain hosted by an engine adapter consumes. One instance is constructed by the engine at init time and passed to every chain via ChainContext.buses; tracks publish their post-FX output to named buses (registry.publish('kick', 2)) and plugins on other tracks consume them (ctx.buses.get('kick')?.read(scratch)). The registry is universal: the DefaultBusRegistry reference implementation works headless (server renders, CLIs, tests) and in the browser; engines that need bespoke bookkeeping may swap their own implementation as long as it honours the same BusRegistry interface.
The split between BusRegistryReadHandle (exposed to plugins) and the full BusRegistry (engine-only) is intentional: plugins can only read and watch; only the engine — which owns chain ordering — can mutate the topology.
Features
- Per-bus channel-aware
Float32Arrayaccumulators sized to whatever the publisher writes; consumers read into their own scratch buffer withread(scratch). - Reads silence before any write and silence-pads when the scratch buffer is larger than the most recent frame, so consumers always see deterministic samples.
- Hard channel-count enforcement: a bus published with 2 channels rejects read / write callers that hand it the wrong array length with
AUDIO_FX.BUS_CHANNEL_MISMATCH. close()is idempotent and reads on a closed bus handle return silence (no throw) so consumers never crash when a publisher disappears mid-render.- Republishing the same name treats the prior publisher as closed and emits a
subscribeevent withundefinedfollowed by the new bus. subscribe(name, listener)fires on publish + close so plugins that resolve buses lazily aftersetup()learn when they appear or disappear.
Usage: Basic Example
import { DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
const registry = new DefaultBusRegistry();
const kick = registry.publish("kick", 2);
kick.write([leftChannelBuf, rightChannelBuf]);
const sidechain = registry.get("kick");
if (sidechain !== undefined) {
const scratch = [new Float32Array(blockSize), new Float32Array(blockSize)];
sidechain.read(scratch);
}
Usage: Advanced Example
import { DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
// One registry per engine; share across every chain it hosts.
const registry = new DefaultBusRegistry();
// Track A publishes its post-FX output to "kick".
const kickPub = registry.publish("kick", outputChannels);
// Plugin on track B's chain subscribes so it learns when the bus appears.
let kickBus: SidechainBus | undefined;
const unsubscribe = registry.subscribe("kick", (bus) => {
kickBus = bus;
});
// In track B's compressor process():
const scratch = chainContext.chain.channels === 2
? [new Float32Array(blockSize), new Float32Array(blockSize)]
: [new Float32Array(blockSize)];
kickBus?.read(scratch);
const ductReduction = computeDuct(scratch);
// On chain dispose:
unsubscribe();
API Reference
BusRegistry(interface) — extendsBusRegistryReadHandle(get,list) withpublish(name, channels)andsubscribe(name, listener).SidechainPublisher(interface) —{ bus: SidechainBus, write(samples), close() }. Returned bypublish; the engine retains it to close the bus on track teardown.DefaultBusRegistry(class) — reference implementation. No constructor arguments; one instance per engine.BusRegistryReadHandle(inbase/) —{ get(name), list() }— the surface plugins see viaChainContext.buses.
Implementation Notes
The registry does not synchronise reads against writes. Engines guarantee single-threaded chain processing per block and topologically order chains so publishers always write before subscribers read; the registry merely holds the per-channel accumulators. If a future engine model adds cross-thread chains, it must supply its own thread-safe implementation.
subscribe deliberately fires on undefined when a publisher closes so consumers can release their local bus reference; the alternative (never firing on close) would leak the reference. Consumers that don't care about lifecycle can pass an arrow that only cares about the truthy case.
close() on a publisher removes its entry from the registry and emits an undefined notification, but the local bus reference remains callable — bus.read() returns silence rather than throwing. This is deliberate: a consumer that already captured the bus reference and is mid-render when the publisher closes should not crash; reading silence and letting the consumer recover on its next subscribe cycle is the right shape.
The accumulator reallocates when the publisher writes a different-size frame than the previous write, so the registry adapts to variable block sizes without per-block allocation when sizes are stable. In steady-state operation with a fixed block size, write allocates zero garbage per block.