# `plugin-io` ## Purpose The `plugin-io` module declares the **channel-topology contract** every `AudioFxPlugin` carries (`PluginIo`, `ChannelSpec`, `ChannelLayout`) and the helpers (`validateChainIo`, `resolveOutputChannels`, `isSourcePlugin`, `ioPreservesChannelCount`) the chain primitive uses to admit or reject plugin insertions at the boundary, before any audio flows. Declaring topology in data — not via per-plugin assertions in `process()` — lets the chain prove compatibility once at insert time and surface a precise, human-readable error in the UI on mismatch. ## Features - Three `ChannelSpec` shapes for plugin authors: explicit integer (`{ count: 2 }`), inherited (`{ count: 'preserve' }`), opaque (`{ count: 'any' }`), plus the special source declaration (`{ count: 0 }`) reserved for slot-0 source plugins (synth/sampler/audio-player/music-layer-mixer). - Layout hints (`ChannelLayout`: mono/stereo/mid-side/5.1/7.1/ambisonic-1/arbitrary) for plugins that need to express semantics, not just channel count. - `validateChainIo(predecessorOutputChannels, candidateInput)` — returns a discriminated `IoCompatibility` result (`ok: true` with resolved channel count, or `ok: false` with a `reason` string suitable for `throw`-with-context or UI toast). - `resolveOutputChannels(spec, inputChannels)` — used after admission to compute the effective channel count flowing out of a slot. - `isSourcePlugin(io)` — true iff `inputs[0].count === 0`; chain uses this to enforce the at-most-one-source-at-slot-0 rule. - `ioPreservesChannelCount(io)` — feeds `PluginCapabilities.preservesChannelCount` so engines can choose render paths. ## Usage: Basic Example ```ts import { validateChainIo } from "@suaveplan/audio-fx-plugin-base/base"; const next = { count: 2 } as const; // stereo plugin const compat = validateChainIo(predecessorOutputChannels, next); if (!compat.ok) { throw new AudioFxError( AUDIO_FX_CODES.CHAIN_IO_INCOMPATIBLE, "audio-fx.chain-io-incompatible", { context: { reason: compat.reason } } ); } const effectiveOut = compat.resolvedChannels; // 2 ``` ## Usage: Advanced Example ```ts import { ioPreservesChannelCount, isSourcePlugin, resolveOutputChannels, } from "@suaveplan/audio-fx-plugin-base/base"; function declareCapabilities(io: PluginIo, latencySamples = 0): PluginCapabilities { return { hasAudioNode: false, hasWorklet: false, hasSidechain: false, sampleAccurate: true, stateful: false, asyncSetup: false, preservesChannelCount: ioPreservesChannelCount(io), latencySamples, }; } // Walking a chain to compute per-slot effective output channel count: let channels = chainInputChannels; for (const slot of chain) { if (isSourcePlugin(slot.plugin.io)) { channels = resolveOutputChannels(slot.plugin.io.outputs[0]!, 0); continue; } channels = resolveOutputChannels(slot.plugin.io.outputs[0]!, channels); } ``` ## API Reference - `ChannelLayout` — string union of supported layouts (mono / stereo / mid-side / 5.1 / 7.1 / ambisonic-1 / arbitrary). - `ChannelSpec` — `{ count: number | 'preserve' | 'any'; layout?: ChannelLayout }`. - `PluginIo` — `{ inputs: ReadonlyArray; outputs: ReadonlyArray }`. - `IoCompatibility` — discriminated union: `{ ok: true; resolvedChannels: number } | { ok: false; reason: string }`. - `validateChainIo(predecessorChannels: number, candidateInput: ChannelSpec): IoCompatibility` — never throws. - `resolveOutputChannels(spec: ChannelSpec, inputChannels: number): number` — returns `inputChannels` for `'preserve'`/`'any'`, otherwise `spec.count`. - `isSourcePlugin(io: PluginIo): boolean` — true iff `inputs[0].count === 0`. - `ioPreservesChannelCount(io: PluginIo): boolean` — true iff the IO claims same channel count on input and output. ## Implementation Notes `validateChainIo` returns rather than throws because chain composition needs to choose between fail-closed insertion (`throw AUDIO_FX.CHAIN_IO_INCOMPATIBLE`) and informational dry-runs (e.g. the plugin picker UI greys out plugins whose IO would not fit the current chain). The chain itself owns the `throw`; this module owns the compatibility decision. The `'preserve'` and `'any'` shapes look identical in `resolveOutputChannels` and `validateChainIo`, but they differ in intent for tooling. `'preserve'` declares "I will not change the channel count" (e.g. EQ, gain); `'any'` declares "I accept whatever you give me" (e.g. spectrum analyser). Both helpers treat them identically for the purposes of admission; the IDE-facing tooling may surface them differently. A source plugin sitting at slot 0 with `inputs[0].count === 0` is the only legal placement: insertion elsewhere throws `AUDIO_FX.CHAIN_SOURCE_POSITION_INVALID` (raised by the chain, not here). Likewise, two source plugins in one chain is rejected by the chain — this module's `isSourcePlugin` only answers the per-instance question.