Files
2026-05-27 23:29:53 +01:00

668 lines
56 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: audio-plugins-fx (audio-plugins-fx)
**Package**: `@suaveplan/audio-plugins-fx`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Universal-tier purity with guarded browser surface
The `@suaveplan/audio-plugins-fx` package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AudioBuffer`, `AudioWorkletNode`) and zero Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`) on the `BlockProcessor` processing path. The only browser-touching surface SHALL be the optional `createAudioNode(context)` method and the `getWorkletProcessor()` source-string accessor; both SHALL be optional members guarded so that a plugin used purely as a `BlockProcessor` never reaches a platform global. The package's `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/schema-registry`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `tslib`, and `zod`.
#### Scenario: Built dist references no platform globals on the processing path
- **WHEN** the built `dist/` is grep'd excluding the `createAudioNode` / worklet-source modules
- **THEN** it MUST NOT reference `window`, `document`, `navigator`, `AudioContext`, `node:fs`, `node:path`, `process`, or `Buffer`
#### Scenario: Tests pass under both runtimes
- **WHEN** the suite runs under `bun test` (server) and under vitest with a `happy-dom` shim (browser)
- **THEN** both runs pass with identical results and every plugin's `BlockProcessor` path runs without a browser global present
**Verified by:** `tests/purity.test.ts::"dist processing path references no browser or Node globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace deps and zod"`
---
### Requirement: AudioFxPlugin base interface
The package SHALL export an `AudioFxPlugin` interface that extends `BlockProcessor` from `@suaveplan/audio-core` and adds:
- a readonly `metadata: PluginMetadata`,
- a readonly `schema: ZodType<unknown>` — the single source of truth for the plugin's options and preset shape,
- a readonly `parameters: ReadonlyArray<PluginParameter>` (declaration order, for UI iteration) AND a readonly `parameterIndex: ReadonlyMap<string, PluginParameter>` (O(1) lookup),
- a readonly `io: PluginIo` declaring its channel topology (see "Channel topology" requirement),
- a readonly `capabilities: PluginCapabilities` declaring its feature flags (see "Capabilities" requirement),
- parameter accessors `getParameter(id)`, `setParameter(id, value)`, `setParameterCurve(id, curve)`, and `setParameterAt(id, value, transportTimeSec)`,
- a `getLatencySamples(): number` accessor and `onLatencyChanged(listener): UnsubscribeFn` event,
- preset methods `getPreset()` and `applyPreset(preset)`,
- the optional browser-native fast paths `createAudioNode?(ctx, chain)` and `getWorkletProcessor?()`,
- an optional `setSidechain?(bus: SidechainBus | undefined)` consumer hook.
Every concrete plugin in this package SHALL implement `AudioFxPlugin`, and therefore SHALL implement the full `BlockProcessor` contract. A reference `AbstractAudioFxPlugin` base class SHALL provide parameter storage, curve / transport-time schedule evaluation, latency-event emission, capabilities defaults, and metadata wiring so concrete plugins implement only DSP.
#### Scenario: Concrete plugin satisfies the full contract
- **WHEN** any exported plugin instance is checked
- **THEN** it exposes the full `AudioFxPlugin` surface (`metadata`, `schema`, `parameters`, `parameterIndex`, `io`, `capabilities`, the four parameter accessors, `getLatencySamples`, `onLatencyChanged`, `getPreset`, `applyPreset`) AND the full `BlockProcessor` surface (`name`, `setup`, `process`, `reset`, `dispose`)
#### Scenario: parameterIndex agrees with parameters
- **WHEN** any plugin instance is checked
- **THEN** for every entry `p` in `parameters`, `parameterIndex.get(p.id) === p`, AND `parameterIndex.size === parameters.length`
#### Scenario: Browser members are optional and absent by default
- **WHEN** a plugin that ships no browser path is constructed
- **THEN** `createAudioNode` and `getWorkletProcessor` are `undefined`, `capabilities.hasAudioNode === false`, `capabilities.hasWorklet === false`, and the plugin still processes blocks correctly
**Verified by:** `src/base/audio-fx-plugin.test.ts::"every exported plugin implements the full AudioFxPlugin contract"` AND `src/base/audio-fx-plugin.test.ts::"parameterIndex agrees with parameters for every plugin"` AND `src/base/audio-fx-plugin.test.ts::"browser members and capability flags are absent on universal-only plugins"`
---
### Requirement: BlockProcessor processing contract for every plugin
Every plugin SHALL implement `process(input, output, ctx)` operating block-by-block on `Float32Array[]` without allocating per-block, SHALL implement `setup(config, chain, opts?): Promise<void>` to allocate state from `ProcessorConfig` (`sampleRate`, `channels`, `blockSize`) and capture the `ChainContext` reference and honour an optional `AbortSignal` from `opts.signal`, SHALL implement `reset()` to clear all internal DSP state (delay lines, filter histories, envelopes) without re-allocating, and SHALL implement `dispose()` to release retained buffers. `setup` SHALL be idempotent: calling it twice without `dispose()` between SHALL reuse allocated buffers and re-bind the `ChainContext`. Calling `process` before `setup` SHALL throw `AudioError` with code `AUDIO_FX.NOT_INITIALIZED`. An `input`/`output` channel-count mismatch against the configured `channels` SHALL throw `AudioError` with code `AUDIO_FX.CHANNEL_MISMATCH`. A `setup` call whose `opts.signal` is already aborted SHALL reject with `AudioError` code `AUDIO_FX.ABORTED` before any allocation; a signal aborted mid-setup SHALL abort any in-flight async loads (impulse responses, sample files, worklet modules) and reject with the same code.
#### Scenario: process before setup is rejected
- **WHEN** a caller invokes `process` on a freshly constructed plugin without calling `setup` first
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.NOT_INITIALIZED"`
#### Scenario: reset clears state without re-allocation
- **WHEN** a caller feeds a signal through a stateful plugin, calls `reset()`, then feeds silence
- **THEN** the output is silence (no residual tail) and the plugin's buffers are the same instances as before `reset()`
**Verified by:** `src/base/audio-fx-plugin.test.ts::"process throws AUDIO_FX.NOT_INITIALIZED before setup"` AND `src/base/audio-fx-plugin.test.ts::"reset clears DSP state without re-allocating buffers"`
---
### Requirement: Parameter model and automation curves
Each plugin SHALL declare its `parameters` as `PluginParameter` records (`id`, `name`, `value`, `min`, `max`, `default`, optional `unit` ∈ `'db' | 'hz' | 'ms' | 'percent' | 'semitones' | 'samples' | 'ratio' | 'enum'`, optional `scale` ∈ `'linear' | 'log' | 'exponential'`, optional `automatable`). `setParameter(id, value)` SHALL clamp the value to `[min, max]` and take effect on the next `process` block. `setParameterCurve(id, curve)` SHALL accept a `ParameterCurve` (`type` ∈ `'linear' | 'exponential' | 'target'`, `value`, `duration` seconds, optional `startTime` wall-time seconds) and ramp the parameter sample-accurately across subsequent blocks using the block context's sample offset. `setParameterAt(id, value, transportTimeSec)` SHALL schedule the parameter to reach `value` at musical-time `transportTimeSec` (resolved against `ChainContext.ambient.transport.transportTimeSec`); past events SHALL be dropped on transport `reset()`/seek-rewind. `getParameter(id)` SHALL return the current value or `undefined` for an unknown id. Setting, curving, or scheduling an unknown parameter id SHALL throw `AudioError` with code `AUDIO_FX.UNKNOWN_PARAMETER`. Setting a curve or transport schedule on a parameter whose `automatable` is `false` SHALL throw `AudioError` with code `AUDIO_FX.PARAMETER_NOT_AUTOMATABLE`. `setParameterCurve` and `setParameterAt` for the same parameter SHALL share a single schedule queue; later schedules override earlier overlapping ones.
#### Scenario: setParameter clamps to the declared range
- **WHEN** a caller calls `setParameter` with a value above the parameter's `max`
- **THEN** `getParameter` returns exactly `max` and the next processed block reflects the clamped value
#### Scenario: linear curve ramps sample-accurately across blocks
- **WHEN** a caller sets a `linear` curve from the current value to a target over a known duration
- **THEN** the parameter's per-sample value at each block boundary matches the analytic ramp within floating-point tolerance
#### Scenario: unknown parameter id is rejected
- **WHEN** a caller calls `setParameter` with an id not present in `parameters`
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.UNKNOWN_PARAMETER"`
#### Scenario: setParameterAt lands on the configured transport beat
- **WHEN** a caller calls `setParameterAt(id, target, transportTimeSec)` while the chain's ambient `transportTimeSec` is in the past relative to `transportTimeSec`, then processes blocks until the transport advances past the scheduled time
- **THEN** the parameter reaches `target` by the block whose start sample maps to `transportTimeSec` within sample-accurate tolerance
#### Scenario: past schedule is dropped on transport rewind
- **WHEN** a caller schedules a future event with `setParameterAt`, then the chain receives an `updateAmbient` that resets `transportTimeSec` to before the schedule's time, then `reset()` is called
- **THEN** the schedule remains pending; if instead the schedule's time is now in the past after the rewind, `reset()` drops it
**Verified by:** `src/base/audio-fx-plugin.test.ts::"setParameter clamps to the declared min/max range"` AND `src/base/audio-fx-plugin.test.ts::"linear parameter curve ramps sample-accurately across blocks"` AND `src/base/audio-fx-plugin.test.ts::"setParameter throws AUDIO_FX.UNKNOWN_PARAMETER for an unknown id"` AND `src/base/audio-fx-plugin.test.ts::"setParameterAt lands on the configured transport time"` AND `src/base/audio-fx-plugin.test.ts::"reset drops past transport schedules and keeps future ones"`
---
### Requirement: Versioned Zod-validated preset system
Every plugin SHALL register a versioned preset schema with `@suaveplan/schema-registry` under a stable key (e.g. `audio-fx:eq-preset`). `getPreset()` SHALL return a plain serializable object that validates against the registered schema. `applyPreset(preset)` SHALL validate the input against the registered schema before mutating any parameter; a validation failure SHALL throw `AudioError` with code `AUDIO_FX.INVALID_PRESET` carrying the Zod issue list in `context`. A preset whose `schemaVersion` is newer than the plugin supports SHALL throw `AudioError` with code `AUDIO_FX.PRESET_VERSION_UNSUPPORTED`. Preset packs (e.g. cathedral reverb impulse descriptors) SHALL be lazy-loaded subpath exports under `./presets/*`.
#### Scenario: getPreset round-trips through applyPreset
- **WHEN** a caller captures `p = plugin.getPreset()`, mutates a parameter, then calls `plugin.applyPreset(p)`
- **THEN** every parameter is restored to its captured value and `getPreset()` deep-equals `p`
#### Scenario: malformed preset is rejected at the boundary
- **WHEN** a caller calls `applyPreset` with an object missing a required field
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.INVALID_PRESET"` with the Zod issues in `context` and no parameter is mutated
#### Scenario: future preset version is rejected
- **WHEN** a caller calls `applyPreset` with a `schemaVersion` greater than the plugin supports
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.PRESET_VERSION_UNSUPPORTED"`
**Verified by:** `src/base/preset-system.test.ts::"getPreset round-trips through applyPreset"` AND `src/base/preset-system.test.ts::"applyPreset rejects malformed presets with AUDIO_FX.INVALID_PRESET"` AND `src/base/preset-system.test.ts::"applyPreset rejects newer schemaVersion with AUDIO_FX.PRESET_VERSION_UNSUPPORTED"`
---
### Requirement: EQ and Filter plugins
The `./eq` subpath SHALL export `EQPlugin`, an 8-band parametric equalizer whose `bands` are `EQBand` records (`frequency`, `gain`, `q`, `type`) supporting peaking, low-shelf, high-shelf, low-pass, and high-pass band types, with `setBand(index, freq, gain, q)` and the presets `flat`, `vocal`, `bass`, and `podcast`. The `./filter` subpath SHALL export `FilterPlugin` supporting biquad, IIR, and state-variable filter modes. Both plugins SHALL process via `BlockProcessor` using stable per-channel filter state, SHALL be numerically stable across the audible range at the configured sample rate, and SHALL each optionally expose `createAudioNode()` returning a `BiquadFilterNode`-backed graph in the browser.
#### Scenario: EQ flat preset is transparent
- **WHEN** `EQPlugin` is set to the `flat` preset and processes a full-band test signal
- **THEN** the output equals the input within filter-precision tolerance
#### Scenario: EQ band boost raises the targeted frequency
- **WHEN** `setBand` applies a peaking boost at a known frequency and a sine at that frequency is processed
- **THEN** the output RMS at that frequency is increased by approximately the configured gain
#### Scenario: state-variable filter mode is stable
- **WHEN** `FilterPlugin` runs in state-variable mode over a long noise signal at maximum resonance below self-oscillation
- **THEN** no output sample is non-finite and the signal does not diverge
**Verified by:** `src/plugins/eq/eq-plugin.test.ts::"flat preset is transparent"` AND `src/plugins/eq/eq-plugin.test.ts::"band boost raises RMS at the targeted frequency"` AND `src/plugins/filter/filter-plugin.test.ts::"state-variable mode stays finite and bounded at high resonance"`
---
### Requirement: Dynamics plugins — Compressor, Limiter, Gate
The `./compressor` subpath SHALL export `CompressorPlugin` with `threshold`, `knee`, `ratio`, `attack`, and `release` parameters, a soft-knee transfer curve, an optional sidechain source, and a mid/side mode. The `./limiter` subpath SHALL export `LimiterPlugin`, a brick-wall limiter that guarantees the output peak never exceeds its ceiling. The `./gate` subpath SHALL export `GatePlugin`, a noise gate with threshold, attack, hold, and release. All three SHALL implement `BlockProcessor`, SHALL compute gain reduction from an envelope follower without per-block allocation, and SHALL expose gain-reduction telemetry through their parameter surface.
#### Scenario: compressor attenuates signal above threshold
- **WHEN** `CompressorPlugin` with a 4:1 ratio processes a signal sustained above its threshold
- **THEN** the steady-state output level above threshold is reduced according to the ratio within tolerance
#### Scenario: limiter output never exceeds the ceiling
- **WHEN** `LimiterPlugin` with a ceiling processes a signal whose peaks exceed that ceiling
- **THEN** no output sample's absolute value exceeds the ceiling
#### Scenario: gate silences signal below threshold
- **WHEN** `GatePlugin` processes a signal that falls below its threshold for longer than the hold time
- **THEN** the corresponding output region is attenuated to (or toward) silence per the release envelope
**Verified by:** `src/plugins/compressor/compressor-plugin.test.ts::"attenuates steady-state signal above threshold per ratio"` AND `src/plugins/limiter/limiter-plugin.test.ts::"output peak never exceeds the configured ceiling"` AND `src/plugins/gate/gate-plugin.test.ts::"attenuates signal that falls below threshold past the hold time"`
---
### Requirement: Time-based plugins — Reverb, Delay, Chorus, Phaser, Flanger
The `./reverb` subpath SHALL export `ReverbPlugin` with an impulse-response convolver, an algorithmic fallback, `decay`/`preDelay`/`wet`/`dry` parameters, a `loadImpulseResponse(url)` method, and the presets `room`, `hall`, `plate`, `cathedral`. The `./delay` subpath SHALL export `DelayPlugin` with `delayTime`/`feedback`/`wet` parameters, ping-pong mode, and transport-BPM sync. The `./chorus`, `./phaser`, and `./flanger` subpaths SHALL export `ChorusPlugin` (modulated-delay chorus), `PhaserPlugin` (all-pass phaser), and `FlangerPlugin` (comb-filter flanger). All SHALL implement `BlockProcessor` using internally owned delay lines, SHALL bound feedback so the output cannot diverge, and SHALL keep `wet === 0` bit-transparent to the dry path.
#### Scenario: delay produces a feedback echo at the configured time
- **WHEN** `DelayPlugin` processes an impulse with a known `delayTime` and non-zero `feedback`
- **THEN** the output contains decaying repeats spaced by `delayTime` and the signal does not diverge
#### Scenario: fully-dry mix is transparent
- **WHEN** any time-based plugin is set to `wet = 0` and processes a signal
- **THEN** the output equals the input sample-for-sample
#### Scenario: reverb algorithmic fallback runs without an impulse response
- **WHEN** `ReverbPlugin` processes a block before `loadImpulseResponse` has been called
- **THEN** it renders the algorithmic reverb tail without throwing
**Verified by:** `src/plugins/delay/delay-plugin.test.ts::"produces decaying echoes spaced by delayTime"` AND `src/plugins/reverb/reverb-plugin.test.ts::"wet=0 is bit-transparent to the dry signal"` AND `src/plugins/reverb/reverb-plugin.test.ts::"algorithmic fallback renders without a loaded impulse response"`
---
### Requirement: Modulation plugins — Tremolo, AutoFilter
The `./tremolo` subpath SHALL export `TremoloPlugin`, an amplitude-modulation effect with rate, depth, and waveform parameters. The `./auto-filter` subpath SHALL export `AutoFilterPlugin`, an LFO-swept filter with rate, depth, base-frequency, and resonance parameters. Both SHALL implement `BlockProcessor` with a phase-continuous LFO that advances by exactly `blockSize` samples per block using the `BlockContext` sample offset, so modulation is seamless across block boundaries and unaffected by block size.
#### Scenario: tremolo modulation is phase-continuous across blocks
- **WHEN** `TremoloPlugin` processes a constant signal split into two block sizes covering the same span
- **THEN** the concatenated outputs are sample-identical regardless of block size
#### Scenario: zero depth is transparent
- **WHEN** `TremoloPlugin` or `AutoFilterPlugin` is set to `depth = 0` and processes a signal
- **THEN** the output equals the input (tremolo) or is unmodulated by the LFO (auto-filter)
**Verified by:** `src/plugins/tremolo/tremolo-plugin.test.ts::"LFO is phase-continuous and block-size independent"` AND `src/plugins/auto-filter/auto-filter-plugin.test.ts::"zero depth leaves the signal unmodulated"`
---
### Requirement: Distortion plugin
The `./distortion` subpath SHALL export `DistortionPlugin` supporting waveshaper, bitcrusher, and overdrive modes selected by parameter. It SHALL implement `BlockProcessor`, SHALL apply its transfer function per sample without allocation, SHALL keep the output bounded for any bounded input, and SHALL provide an output-gain parameter to compensate for level changes introduced by shaping.
#### Scenario: waveshaper clips a hot signal without producing non-finite samples
- **WHEN** `DistortionPlugin` in waveshaper mode processes an over-unity signal
- **THEN** every output sample is finite and bounded within the transfer function's range
#### Scenario: bitcrusher reduces effective resolution
- **WHEN** `DistortionPlugin` in bitcrusher mode processes a ramp with a low bit-depth parameter
- **THEN** the output takes only the expected quantised set of values
**Verified by:** `src/plugins/distortion/distortion-plugin.test.ts::"waveshaper output stays finite and bounded for an over-unity input"` AND `src/plugins/distortion/distortion-plugin.test.ts::"bitcrusher quantises the signal to the configured bit depth"`
---
### Requirement: Pitch shifter plugin
The `./pitch-shifter` subpath SHALL export `PitchShifterPlugin`, a phase-locked vocoder pitch shifter exposing a `semitones` parameter that shifts pitch without changing duration. Pitch *shifting* lives in this package as a plugin — it is not a core `audio-core` op and is distinct from the pitch *detection* in `@suaveplan/audio-analysis-pitch`. The plugin SHALL implement `BlockProcessor` for universal use, SHALL maintain its FFT/overlap-add state across blocks via `reset()`-clearable buffers, and SHALL expose `getWorkletProcessor()` returning AudioWorklet processor source for browser-native rendering.
#### Scenario: zero-semitone shift is transparent within vocoder tolerance
- **WHEN** `PitchShifterPlugin` with `semitones = 0` processes a signal
- **THEN** the output preserves the input's pitch and duration within phase-vocoder reconstruction tolerance
#### Scenario: octave-up shift doubles the fundamental
- **WHEN** `PitchShifterPlugin` with `semitones = 12` processes a sine of known frequency
- **THEN** the dominant output frequency is approximately double the input frequency and the output length equals the input length
**Verified by:** `src/plugins/pitch-shifter/pitch-shifter-plugin.test.ts::"zero-semitone shift preserves pitch and duration"` AND `src/plugins/pitch-shifter/pitch-shifter-plugin.test.ts::"twelve-semitone shift doubles the fundamental without changing duration"`
---
### Requirement: Synthesizer plugin
The `./synthesizer` subpath SHALL export `SynthesizerPlugin`, a polyphonic synthesizer with `triggerAttack(note, velocity?)`, `triggerRelease(note)`, and `triggerAttackRelease(note, duration)`, an `oscillator` (sine/square/sawtooth/triangle), an ADSR `envelope`, a `filter`, a `polyphony` voice count, and portamento. It SHALL implement `BlockProcessor`, rendering active voices into the output block from a fixed-size voice pool with deterministic voice stealing when `polyphony` is exceeded. Triggering with a MIDI note outside `0..127` SHALL throw `AudioError` with code `AUDIO_FX.INVALID_NOTE`.
#### Scenario: triggerAttack produces a tone at the note's frequency
- **WHEN** a caller calls `triggerAttack` for a known MIDI note and processes blocks
- **THEN** the output contains energy at that note's fundamental frequency and the ADSR attack stage is audible
#### Scenario: voice stealing is deterministic past polyphony
- **WHEN** more notes than `polyphony` are triggered
- **THEN** the oldest voice is reused and the active voice count never exceeds `polyphony`
#### Scenario: out-of-range note is rejected
- **WHEN** a caller calls `triggerAttack(128)`
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.INVALID_NOTE"`
**Verified by:** `src/plugins/synthesizer/synthesizer-plugin.test.ts::"triggerAttack renders a tone at the note frequency"` AND `src/plugins/synthesizer/synthesizer-plugin.test.ts::"voice stealing keeps active voices within polyphony"` AND `src/plugins/synthesizer/synthesizer-plugin.test.ts::"triggerAttack rejects out-of-range notes with AUDIO_FX.INVALID_NOTE"`
---
### Requirement: Sampler plugin
The `./sampler` subpath SHALL export `SamplerPlugin` with `loadSample(note, url)`, `triggerAttack(note, velocity?)`, and `triggerRelease(note)`. It SHALL implement `BlockProcessor`, playing loaded samples pitched per the triggered note from a base sample, mixing concurrent voices into the output block. Triggering a note with no loaded sample SHALL throw `AudioError` with code `AUDIO_FX.SAMPLE_NOT_LOADED`. Sample loading SHALL accept an `AbortSignal` and reject with `AUDIO_FX.ABORTED` when the signal is aborted.
#### Scenario: triggered note plays the loaded sample
- **WHEN** a caller `loadSample` for a note then `triggerAttack` that note and processes blocks
- **THEN** the output contains the loaded sample's signal mixed into the block
#### Scenario: triggering an unloaded note is rejected
- **WHEN** a caller calls `triggerAttack` for a note with no loaded sample
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.SAMPLE_NOT_LOADED"`
**Verified by:** `src/plugins/sampler/sampler-plugin.test.ts::"plays the loaded sample for a triggered note"` AND `src/plugins/sampler/sampler-plugin.test.ts::"triggerAttack rejects an unloaded note with AUDIO_FX.SAMPLE_NOT_LOADED"`
---
### Requirement: SpatialAudio and Mixer plugins
The `./spatial` subpath SHALL export `SpatialAudioPlugin` with `position`, `orientation`, `refDistance`, `maxDistance`, and `rolloffFactor`, applying amplitude panning plus distance attenuation universally and an HRTF panner via `createAudioNode()` in the browser. The `./mixer` subpath SHALL export `MixerPlugin` with `channels` (`MixerChannel[]`), `setChannelGain`, `setChannelPan`, and `setSend(channel, bus, amount)` for channel strips, sends/returns, a master bus, and VU metering. The `./widener` subpath SHALL export `StereoWidenerPlugin` for mid/side width control. All SHALL implement `BlockProcessor`; `MixerPlugin` SHALL sum its channels into the output block without clipping internally.
#### Scenario: spatial distance attenuation follows the rolloff model
- **WHEN** `SpatialAudioPlugin` processes a signal at increasing distances within `[refDistance, maxDistance]`
- **THEN** the output level decreases monotonically per the configured `rolloffFactor`
#### Scenario: mixer sums channels with applied gain and pan
- **WHEN** `MixerPlugin` processes two channels with distinct gains and pans
- **THEN** the output block equals the gain- and pan-weighted sum of the input channels
**Verified by:** `src/plugins/spatial/spatial-plugin.test.ts::"distance attenuation decreases monotonically per the rolloff model"` AND `src/plugins/mixer/mixer-plugin.test.ts::"sums channels with applied gain and pan"`
---
### Requirement: Optional browser AudioNode rendering path
A plugin MAY implement `createAudioNode(context: BaseAudioContext): AudioNode` to return a Web Audio node graph equivalent to its `BlockProcessor` behaviour, and MAY implement `getWorkletProcessor(): string` returning AudioWorklet processor source. When present, `createAudioNode` SHALL NOT be invoked on the universal processing path and SHALL be the sole entry point that touches `BaseAudioContext`. A plugin SHALL function fully through `BlockProcessor` alone when these members are absent, so `@suaveplan/audio-engine-browser` can map `createAudioNode()` into its graph when available and fall back to the worklet-wrapped `BlockProcessor` otherwise.
#### Scenario: createAudioNode is never reached on the universal path
- **WHEN** a plugin that implements `createAudioNode` is exercised purely via `setup` + `process`
- **THEN** `createAudioNode` is not invoked and no `BaseAudioContext` reference is constructed
#### Scenario: worklet processor source is a self-contained string
- **WHEN** a plugin that implements `getWorkletProcessor` returns its source
- **THEN** the returned value is a non-empty string containing a `registerProcessor` call and no import of a Node-only module
**Verified by:** `src/base/audio-fx-plugin.test.ts::"createAudioNode is not invoked on the BlockProcessor path"` AND `src/plugins/pitch-shifter/pitch-shifter-plugin.test.ts::"getWorkletProcessor returns self-contained processor source"`
---
### Requirement: Zod boundary validation
Every public options object — `PluginMetadata`, `PluginParameter`, `ParameterCurve`, `EQBand`, each plugin's constructor options, and every plugin preset shape — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and every public entry point SHALL validate its argument against the schema before use. A validation failure SHALL throw `AudioError` with code `AUDIO_FX.INVALID_OPTIONS` carrying the Zod issue list in `context`.
#### Scenario: invalid plugin options are rejected at the boundary
- **WHEN** a plugin is constructed with an out-of-range or mistyped option
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.INVALID_OPTIONS"` with the Zod issues in `context`
#### Scenario: malformed parameter curve is rejected
- **WHEN** a caller calls `setParameterCurve` with a curve whose `type` is not one of `linear`/`exponential`/`target`
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.INVALID_OPTIONS"`
**Verified by:** `src/base/audio-fx-plugin.test.ts::"plugin constructor rejects invalid options with AUDIO_FX.INVALID_OPTIONS"` AND `src/base/audio-fx-plugin.test.ts::"setParameterCurve rejects a malformed curve"`
---
### Requirement: Structured error codes
The package SHALL declare its codes via `defineCodes("AUDIO_FX", [...])` from `@suaveplan/error`, producing the dotted code strings `AUDIO_FX.NOT_INITIALIZED`, `AUDIO_FX.CHANNEL_MISMATCH`, `AUDIO_FX.UNKNOWN_PARAMETER`, `AUDIO_FX.PARAMETER_NOT_AUTOMATABLE`, `AUDIO_FX.INVALID_PRESET`, `AUDIO_FX.PRESET_VERSION_UNSUPPORTED`, `AUDIO_FX.INVALID_NOTE`, `AUDIO_FX.SAMPLE_NOT_LOADED`, `AUDIO_FX.INVALID_OPTIONS`, `AUDIO_FX.ABORTED`, `AUDIO_FX.CHAIN_IO_INCOMPATIBLE`, `AUDIO_FX.CHAIN_SLOT_NOT_FOUND`, `AUDIO_FX.CHAIN_DUPLICATE_PLUGIN_ID`, `AUDIO_FX.CHAIN_SOURCE_POSITION_INVALID`, `AUDIO_FX.BUS_NOT_FOUND`, `AUDIO_FX.BUS_CHANNEL_MISMATCH`, and `AUDIO_FX.BUS_CYCLE_DETECTED`. The package SHALL export `AudioFxErrorCode`, `AUDIO_FX_ERROR_METADATA`, `registerAudioFxErrorCodes()`, and an `AudioError` class extending `SuaveplanError`. Every error thrown by the package SHALL be an `AudioError` and carry one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests.
#### Scenario: every registered code is thrown by a call site
- **WHEN** the test suite runs
- **THEN** each code in `AUDIO_FX_ERROR_METADATA` is asserted thrown by at least one test
**Verified by:** `src/error-codes.test.ts::"every AUDIO_FX code is reachable from a call site"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL instrument each plugin's `process` path via `@suaveplan/opentelemetry`: a span `audio_fx.process` tagged with the plugin id, a counter `audio_fx_blocks_total{plugin}`, and a histogram `audio_fx_process_duration_ms{plugin}` for per-plugin latency, plus a counter `audio_fx_preset_applied_total{plugin}`. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
#### Scenario: instrumentation is inert without an exporter
- **WHEN** a plugin is imported and used with no OTel exporter registered
- **THEN** no error is thrown and no global state is mutated at import time
#### Scenario: process records a latency histogram when an exporter is registered
- **WHEN** a plugin processes a block against a real in-memory `MeterProvider`
- **THEN** `audio_fx_blocks_total{plugin}` increments and `audio_fx_process_duration_ms{plugin}` records one observation
**Verified by:** `tests/purity.test.ts::"importing a plugin mutates no global state"` AND `src/base/otel.test.ts::"process records a span and latency histogram when an exporter is registered"`
---
### Requirement: AbortSignal propagation on async plugin operations
Asynchronous plugin operations that load external resources — `ReverbPlugin.loadImpulseResponse(url)` and `SamplerPlugin.loadSample(note, url)` — SHALL accept an `AbortSignal` via their options. An already-aborted signal SHALL cause the method to throw `AudioError` with code `AUDIO_FX.ABORTED` before any fetch work; a signal aborted mid-load SHALL terminate the load with the same error and leave the plugin's loaded state unchanged.
#### Scenario: pre-aborted signal short-circuits the load
- **WHEN** `loadImpulseResponse` or `loadSample` is called with an already-aborted signal
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.ABORTED"` before any fetch is issued
#### Scenario: mid-load abort leaves loaded state unchanged
- **WHEN** a signal is aborted while a sample load is in flight
- **THEN** the load rejects with `AUDIO_FX.ABORTED` and the plugin's previously loaded samples are intact
**Verified by:** `src/plugins/reverb/reverb-plugin.test.ts::"loadImpulseResponse throws AUDIO_FX.ABORTED for a pre-aborted signal"` AND `src/plugins/sampler/sampler-plugin.test.ts::"mid-load abort leaves previously loaded samples intact"`
---
### Requirement: Subpath exports per plugin
`package.json` SHALL declare one `exports` entry per plugin (`./eq`, `./reverb`, `./delay`, `./compressor`, `./pitch-shifter`, `./synthesizer`, `./sampler`, `./mixer`, `./spatial`, `./filter`, `./distortion`, `./chorus`, `./tremolo`, `./auto-filter`, `./gate`, `./limiter`, `./widener`, `./phaser`, `./flanger`) plus the `./base` entry, the lazy `./presets/*` packs, and the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false` so importing one plugin does not pull in any other.
#### Scenario: each plugin is independently importable and tree-shakeable
- **WHEN** a consumer imports `@suaveplan/audio-plugins-fx/eq`
- **THEN** the import resolves to the EQ plugin module without pulling in `./synthesizer` or any other plugin
**Verified by:** `tests/exports.test.ts::"every plugin has a subpath export entry"` AND `tests/bundle-size/eq.size.test.ts::"importing ./eq does not bundle other plugins"`
---
### Requirement: Channel topology — PluginIo contract
Every plugin SHALL declare a readonly `io: PluginIo = { inputs: ChannelSpec[], outputs: ChannelSpec[] }`. Each `ChannelSpec` has `{ count: number | 'preserve' | 'any', layout?: ChannelLayout }` where `ChannelLayout ∈ 'mono' | 'stereo' | 'mid-side' | '5.1' | '7.1' | 'ambisonic-1' | 'arbitrary'`. `'preserve'` declares "same channel count as input"; `'any'` declares "accepts whatever channel count is offered". The package SHALL export a `validateChainIo(predecessorOutputs, nextInputs): { ok: true } | { ok: false; reason: string }` helper used by `PluginChain` at insertion time. A plugin whose first input has `count === 0` is a **source** plugin (e.g. `audio-player`, `synthesizer`, `sampler`); a chain SHALL place exactly one source plugin at index 0 or have no source plugin (in which case the chain's input is its first plugin's input).
#### Scenario: incompatible IO is rejected at chain insertion
- **WHEN** a chain whose effective output is 2 channels has a plugin inserted whose `io.inputs[0]` is `{ count: 1 }` and not `'any'`
- **THEN** `chain.add()` throws `AudioError` whose `code === "AUDIO_FX.CHAIN_IO_INCOMPATIBLE"` and the error `context` names both the predecessor's output channel count and the rejected plugin's input channel count
#### Scenario: 'preserve' propagates effective output channel count
- **WHEN** a chain containing a `{ count: 2 }` source followed by three `{ count: 'preserve' }` plugins is asked for `getEffectiveOutputChannels()`
- **THEN** the result is `2`
**Verified by:** `src/base/plugin-io.test.ts::"validateChainIo rejects mismatched counts with AUDIO_FX.CHAIN_IO_INCOMPATIBLE"` AND `src/chain/plugin-chain.test.ts::"preserve propagates effective channel count along the chain"`
---
### Requirement: Capabilities — explicit feature flags
Every plugin SHALL declare a readonly `capabilities: PluginCapabilities` with `{ hasAudioNode, hasWorklet, hasSidechain, sampleAccurate, stateful, asyncSetup, preservesChannelCount, latencySamples }` (all `readonly`). `hasAudioNode === true` SHALL imply `createAudioNode` is defined; `hasWorklet === true` SHALL imply `getWorkletProcessor` is defined; `hasSidechain === true` SHALL imply `setSidechain` is defined. `preservesChannelCount` SHALL equal the result of computing IO equality from the plugin's declared `io`. `latencySamples` SHALL be the plugin's initial latency at construction; runtime latency changes flow through `getLatencySamples()` and the `onLatencyChanged` event.
#### Scenario: capability flag and method presence agree
- **WHEN** every exported plugin is checked
- **THEN** for each cap → method pair (`hasAudioNode` ↔ `createAudioNode`, `hasWorklet` ↔ `getWorkletProcessor`, `hasSidechain` ↔ `setSidechain`), the flag value matches whether the method is `typeof === 'function'`
**Verified by:** `src/base/capabilities.test.ts::"capability flags match method presence for every plugin"` AND `src/base/capabilities.test.ts::"preservesChannelCount derives from declared io"`
---
### Requirement: Plugin delay compensation (PDC)
Every plugin SHALL implement `getLatencySamples(): number` returning its current rendering latency in samples (0 for zero-latency plugins). Plugins whose latency can change at runtime (e.g. lookahead limiter with variable lookahead) SHALL emit a `latency-changed` notification via `onLatencyChanged(listener: (samples: number) => void): UnsubscribeFn`; the `AbstractAudioFxPlugin` base class SHALL provide a protected `notifyLatencyChanged(samples: number)` helper. `PluginChain.getEffectiveLatencySamples()` SHALL return the sum of `getLatencySamples()` across all non-bypassed plugins in the chain. The chain SHALL subscribe to each plugin's `onLatencyChanged` and emit its own `latency-changed` chain event whenever the effective total changes. The engine adapter SHALL use the chain-level effective latency to apply per-chain delay compensation across tracks so that mixing tracks of different effective latencies stays phase-aligned.
#### Scenario: chain effective latency sums non-bypassed plugins
- **WHEN** a chain contains three plugins reporting `[100, 0, 50]` samples and the middle is `bypass()`d
- **THEN** `chain.getEffectiveLatencySamples() === 150`; after `bypass(0, true)` it returns `50`; after un-bypassing all it returns `150`
#### Scenario: dynamic latency change propagates as chain event
- **WHEN** a plugin's internal latency changes from 0 to 64 samples and it calls `notifyLatencyChanged(64)`
- **THEN** every chain listener of `latency-changed` is invoked once with the new chain-level effective latency
**Verified by:** `src/chain/pdc.test.ts::"effective latency sums non-bypassed plugins"` AND `src/chain/pdc.test.ts::"dynamic latency change propagates as a chain latency-changed event"`
---
### Requirement: ChainContext and AmbientState propagation
The package SHALL export `ChainContext`, `AmbientState`, `PluginHandle`, and `BusRegistry` types from `./chain`. `PluginChain` SHALL hand every plugin a `ChainContext` instance at `setup()`; the instance reference SHALL be stable for the chain's lifetime so plugins may capture it. `ChainContext` SHALL expose:
- `chain: ChainHandle` — read-only view of the chain (id, name, length, configured `channels`, `sampleRate`, `blockSize`),
- `position: { index, total }` — the plugin's slot index and total chain length,
- `predecessors: ReadonlyArray<PluginHandle>` and `successors: ReadonlyArray<PluginHandle>` — read-only handles to peers (id, metadata, io, capabilities, bypassed, `getParameter`, `getLatencySamples`); peers SHALL NOT be mutable through these handles,
- `ambient: AmbientState` — the engine-pushed read-only view of `{ track, transport, listener?, extension }`,
- `buses: BusRegistry` — the project-level sidechain bus registry.
`AmbientState.track` SHALL carry `{ id, gainDb, pan, mute, solo, layout }`; `AmbientState.transport` SHALL carry `{ tempo, timeSignature, transportTimeSec, state, ppqn }`; `AmbientState.listener?` SHALL carry `{ position: Vec3, orientation: Quat, velocity: Vec3 }` when 3D context is available; `AmbientState.extension` is an open `Readonly<Record<string, unknown>>` for domain-specific data (game state, custom track tags, etc.). `PluginChain.updateAmbient(patch)` SHALL merge the patch into ambient state and emit an `ambient-changed` chain event; plugins SHALL receive the change on the next `process()` call (no in-flight block mutation).
#### Scenario: plugins read predecessor parameters during process
- **WHEN** a chain `[gain(gain=6dB), tracker]` is processed, where `tracker` reads `ctx.predecessors[0].getParameter('gain')` during `process`
- **THEN** `tracker` observes `6` and reflects it in its output
#### Scenario: ambient updates land on the next block
- **WHEN** `chain.updateAmbient({ transport: { ...prev, tempo: 140 } })` is called between blocks and the next block is processed
- **THEN** every plugin's `process` observes `ctx.ambient.transport.tempo === 140` AND the chain emitted one `ambient-changed` event
#### Scenario: peer handles are read-only
- **WHEN** any plugin attempts to mutate a value via a `PluginHandle` returned through `predecessors` or `successors`
- **THEN** no mutation method exists on `PluginHandle` and the TypeScript type forbids the call at compile time
**Verified by:** `src/chain/chain-context.test.ts::"plugins read predecessor parameters during process"` AND `src/chain/chain-context.test.ts::"updateAmbient lands on the next process block and emits ambient-changed"` AND `src/chain/chain-context.test.ts::"PluginHandle exposes no mutation methods"`
---
### Requirement: PluginChain primitive — composition, lifecycle, snapshot, events
The package SHALL export a `PluginChain` class from `./chain`. Composition methods:
- `add(plugin, at?: number)` — validates IO via `validateChainIo` against the predecessor's effective output and rejects with `AUDIO_FX.CHAIN_IO_INCOMPATIBLE` on mismatch; rejects with `AUDIO_FX.CHAIN_DUPLICATE_PLUGIN_ID` if a plugin with the same id is already in the chain; rejects with `AUDIO_FX.CHAIN_SOURCE_POSITION_INVALID` if the inserted plugin is a source plugin (per the "Channel topology" requirement) and `at !== 0` or another source already exists at index 0. Emits `plugin-added`.
- `remove(idOrIndex)` — disposes and removes; emits `plugin-removed`. `AUDIO_FX.CHAIN_SLOT_NOT_FOUND` on missing target.
- `move(from, to)` — reorders; revalidates IO across the whole chain; emits `plugin-moved`.
- `replace(idOrIndex, plugin, opts?: { crossfadeMs?: number })` — atomic swap; if `crossfadeMs > 0` the chain SHALL crossfade the previous plugin's output with the new plugin's output across that duration without dropping samples; emits `plugin-replaced`.
- `bypass(idOrIndex, bypassed: boolean)` — toggles the chain-level bypass wrapper; emits `bypass-changed`.
Lifecycle methods:
- `setup(config, ambient, opts?): Promise<void>` — calls each plugin's `setup` in parallel via `Promise.all`, propagating `opts.signal` to every plugin. Construction of the `ChainContext` for each plugin SHALL pin its `position` index and predecessor / successor handles.
- `process(input, output, ctx)` — walks plugins in order, threading per-slot scratch buffers; bypassed slots pass input through verbatim without allocation.
- `reset()` — calls every plugin's `reset()` in order; drops past parameter schedules per the "Parameter model" requirement.
- `dispose()` — calls every plugin's `dispose()`; unsubscribes all listeners.
Inspection methods:
- `plugins: ReadonlyArray<AudioFxPlugin>` — current ordered plugin list.
- `findById(id)` — `{ plugin, index } | undefined`.
- `getEffectiveLatencySamples()` and `getEffectiveOutputChannels()` — derived from current slot state.
- `snapshot(): ChainSnapshot` — returns a Zod-validated plain object capturing: `{ id, name, channels, sampleRate, blockSize, plugins: Array<{ pluginType: string, instanceId: string, bypassed: boolean, preset: PresetSnapshot }> }`.
- `restore(s, registry: PluginRegistry): Promise<void>` — rebuilds the chain from a snapshot using a `PluginRegistry` to instantiate plugins by `pluginType`; validates the snapshot against the Zod schema first; rejects with `AUDIO_FX.INVALID_PRESET` on validation failure.
Event surface via `on(event, listener): UnsubscribeFn` for: `'plugin-added' | 'plugin-removed' | 'plugin-moved' | 'plugin-replaced' | 'parameter-changed' | 'preset-applied' | 'bypass-changed' | 'ambient-changed' | 'latency-changed'`.
#### Scenario: chain process equals serial plugin process
- **WHEN** a chain `[a, b, c]` of three non-bypassed plugins processes a block
- **THEN** the output is bit-equal to running `a.process` then `b.process` (on a's output) then `c.process` (on b's output) on the same input
#### Scenario: bypassed slot reuses the input buffer
- **WHEN** a chain processes a block with the middle plugin bypassed
- **THEN** the middle plugin's `process` is NOT called AND the buffer at that slot is the same `Float32Array` instance as the predecessor's output (no allocation)
#### Scenario: snapshot then restore reproduces the chain
- **WHEN** a chain with parameters and bypass states is `snapshot()`-ed, a fresh chain is constructed, and `restore(s, registry)` is called
- **THEN** the restored chain has the same plugin order, parameters, bypass states, and `getEffectiveLatencySamples()` as the original
#### Scenario: replace with crossfade does not drop samples
- **WHEN** `replace(idx, newPlugin, { crossfadeMs: 50 })` is called during steady processing
- **THEN** sample count produced before and after replace equals input sample count exactly AND no sample is non-finite during the crossfade window
**Verified by:** `src/chain/plugin-chain.test.ts::"process equals serial plugin process for a non-bypassed chain"` AND `src/chain/plugin-chain.test.ts::"bypassed slot reuses the predecessor output buffer without allocation"` AND `src/chain/chain-snapshot.test.ts::"snapshot then restore reproduces order, parameters, and effective latency"` AND `src/chain/plugin-chain.test.ts::"replace with crossfade preserves sample count and stays finite"`
---
### Requirement: Project-level sidechain BusRegistry
The package SHALL define the `BusRegistry` interface in `./chain` with methods `get(name): SidechainBus | undefined`, `list(): ReadonlyArray<string>`, `publish(name, channels): SidechainPublisher`, and `subscribe(name, listener): UnsubscribeFn`. A `SidechainBus` SHALL expose `name`, `channels`, and `read(into: Float32Array[]): void` (block-aligned read into the caller's buffer). A `SidechainPublisher` SHALL expose `write(samples: Float32Array[]): void` and `close(): void`. The package SHALL also export a `DefaultBusRegistry` reference implementation used by `PluginChain` in standalone (non-engine) contexts and by tests. Engine adapters MAY supply their own implementation that integrates with their internal topology ordering. `read` invoked on a bus whose channel count does not match the caller's buffer length SHALL throw `AudioError` code `AUDIO_FX.BUS_CHANNEL_MISMATCH`. A bus graph that would form a cycle (chain A reads bus written by chain B which reads bus written by chain A) SHALL be rejected by the engine with `AUDIO_FX.BUS_CYCLE_DETECTED`; the chain itself does not detect cycles (it only sees its own end of the bus).
#### Scenario: publish + read round-trips a buffer
- **WHEN** a publisher writes a known buffer to `'kick'` and a consumer calls `bus.read(scratch)` after the write
- **THEN** `scratch` contains the published samples and `bus.channels` matches the publisher's declared channel count
#### Scenario: bus channel mismatch is rejected at read
- **WHEN** a 2-channel bus is read into a 1-channel scratch buffer
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.BUS_CHANNEL_MISMATCH"`
#### Scenario: missing bus returns undefined, not throws
- **WHEN** `ctx.buses.get('nonexistent')` is called
- **THEN** the result is `undefined` and no error is thrown (so consumers can branch on presence)
**Verified by:** `src/chain/bus-registry.test.ts::"publish and read round-trip a buffer"` AND `src/chain/bus-registry.test.ts::"read rejects channel mismatch with AUDIO_FX.BUS_CHANNEL_MISMATCH"` AND `src/chain/bus-registry.test.ts::"get returns undefined for a missing bus"`
---
### Requirement: Microphone plugin
The `./microphone` subpath SHALL export `MicrophonePlugin`, a complete mic processing chain modeling the upstream 494-line plugin. It SHALL expose parameter groups for frequency response (low/mid/high-shelf gains, presence band), resonance (peak frequency + Q), distortion (drive + mix), noise generation (white, pink, hum 50/60 Hz, hiss with level), dynamics (compressor threshold/ratio/attack/release + gate threshold/hold/release), character (wow rate/depth, flutter rate/depth, tape saturation amount, vintage warmth), and output gain. It SHALL implement `BlockProcessor`, SHALL be stateful (envelope followers, wow/flutter delay lines, noise generators), SHALL preserve channel count, SHALL declare `capabilities.hasAudioNode === false` and `capabilities.hasWorklet === true` (the worklet processor is the browser fast path), and SHALL register a versioned preset schema with the `vintage`, `radio`, `podcast`, `telephone`, and `clean` presets.
#### Scenario: gate silences signal below threshold
- **WHEN** `MicrophonePlugin` with gate enabled and threshold -40 dBFS processes a sustained -60 dBFS signal past the hold time
- **THEN** the corresponding output region is attenuated toward silence per the release envelope
#### Scenario: telephone preset rolls off below 300 Hz and above 3.4 kHz
- **WHEN** `MicrophonePlugin` with the `telephone` preset processes a full-band signal
- **THEN** the output RMS below 300 Hz and above 3.4 kHz is at least 20 dB lower than the unfiltered signal
**Verified by:** `src/plugins/microphone/microphone.test.ts::"gate silences signal below threshold past hold time"` AND `src/plugins/microphone/microphone.test.ts::"telephone preset rolls off below 300 Hz and above 3.4 kHz"`
---
### Requirement: GLaDOS character vocoder plugin
The `./glados` subpath SHALL export `GLaDOSPlugin`, a character vocoder voice transformation. It SHALL implement `BlockProcessor`, SHALL combine a phase-vocoder pitch shift, formant shifting, a ring modulator, a band-pass formant filter, and a configurable bit-crusher, exposing parameters `pitchShiftSemitones`, `formantShift`, `ringModFrequency`, `ringModDepth`, `formantQ`, `crushBitDepth`, and `wet`. It SHALL declare `capabilities.hasWorklet === true` and ship a worklet processor; the universal `BlockProcessor` path SHALL produce identical output to the worklet path within FFT reconstruction tolerance. It SHALL register a versioned preset schema with the `glados-default`, `glados-cake`, `glados-deception`, and `wheatley` presets.
#### Scenario: zero-effect preset is transparent within vocoder tolerance
- **WHEN** `GLaDOSPlugin` with `wet=0` processes a voice signal
- **THEN** the output equals the input sample-for-sample
#### Scenario: worklet path matches universal path
- **WHEN** the same input is processed both through the universal `BlockProcessor` and through the worklet
- **THEN** outputs match within phase-vocoder reconstruction tolerance
**Verified by:** `src/plugins/glados/glados.test.ts::"wet=0 is sample-for-sample transparent"` AND `src/plugins/glados/glados.test.ts::"universal and worklet paths produce equivalent output within tolerance"`
---
### Requirement: Track-style AudioPlayer source plugin
The `./audio-player` subpath SHALL export `AudioPlayerPlugin`, a track-style audio playback **source** plugin (`io.inputs[0].count === 0`). It SHALL expose `load(source: string | ArrayBuffer | AudioBuffer, opts?: { signal?: AbortSignal })`, transport-style controls `play(atSec?)`, `pause()`, `stop()`, `seek(timeSec)`, and the accessors `getCurrentTime(): number`, `getDuration(): number`, `isPlaying(): boolean`, `isLoaded(): boolean`. It SHALL implement `BlockProcessor`, rendering the loaded buffer into the output block from its current read position, respecting `playbackRate` and `loop` parameters. It SHALL emit a `'ended'` event when playback reaches the end and `loop` is false. Calling `play` before `load` SHALL throw `AudioError` with code `AUDIO_FX.SAMPLE_NOT_LOADED`. The plugin is distinct from `@suaveplan/audio-sprite-player`: sprite-player triggers short cues from a sprite map; audio-player is a single-track playback engine.
#### Scenario: play before load is rejected
- **WHEN** a caller constructs `AudioPlayerPlugin`, calls `play()` without first loading a source
- **THEN** it throws `AudioError` whose `code === "AUDIO_FX.SAMPLE_NOT_LOADED"`
#### Scenario: loaded source plays out into the chain
- **WHEN** a caller loads a known buffer, calls `play(0)`, and processes enough blocks to span the buffer's duration
- **THEN** the concatenated output equals the loaded buffer within sample-rate-conversion tolerance and `'ended'` fires after the last sample
**Verified by:** `src/plugins/audio-player/audio-player.test.ts::"play before load throws AUDIO_FX.SAMPLE_NOT_LOADED"` AND `src/plugins/audio-player/audio-player.test.ts::"loaded source plays out into the chain and ended fires"`
---
### Requirement: Game-audio building blocks — spatial-positional, proximity-voice, music-layer-mixer, voice-transform
The `./spatial-positional`, `./proximity-voice`, `./music-layer-mixer`, and `./voice-transform` subpaths SHALL each export an `AudioFxPlugin` implementing `BlockProcessor`, with `r3f-audio` consuming them as composition primitives and non-R3F games consuming them directly. None of them SHALL depend on `react`, `@react-three/fiber`, or `three`.
- `SpatialPositionalPlugin` SHALL apply distance attenuation per `rolloffFactor`, amplitude panning in the universal path, and HRTF panning via `createAudioNode()` returning a `PannerNode` in the browser. It SHALL read `ChainContext.ambient.listener` for `position` and `orientation`; if `ambient.listener` is `undefined` it SHALL fall back to the plugin's own `listenerPosition` parameter.
- `ProximityVoicePlugin` SHALL attenuate voice signals based on distance from `ambient.listener.position` to the plugin's `voicePosition` parameter, expose a VAD threshold below which output is muted, and optionally blend a spatial pan from the listener orientation. It SHALL preserve channel count.
- `MusicLayerMixerPlugin` SHALL cross-fade N music layers (each a loaded `AudioBuffer` registered via `loadLayer(id, buffer)`) based on a per-layer weight derived from `ambient.extension.gameState` (a `Readonly<Record<string, number>>` mapping a game-state axis name to a 0..1 value) and the plugin's `layerWeights` parameter map; transitions SHALL crossfade smoothly over the configured `transitionMs`. It SHALL be a **source plugin** (`io.inputs[0].count === 0`).
- `VoiceTransformPlugin` SHALL apply combinable pitch shift, formant shift, ring modulation, distortion, and reverb to voice signals with a preset system covering `child`, `giant`, `robot`, `monster`, and `echo` characters. `GLaDOSPlugin` SHALL be implementable as a specialization or wrapper of `VoiceTransformPlugin` (the spec does not mandate which).
#### Scenario: spatial-positional honours ambient listener over its own parameter
- **WHEN** `SpatialPositionalPlugin` is processed with `ChainContext.ambient.listener.position = {x: 10, y: 0, z: 0}` AND its own `listenerPosition` parameter set to `{x: 0, y: 0, z: 0}`
- **THEN** the rendered distance attenuation uses the ambient listener position, not the parameter fallback
#### Scenario: music-layer-mixer crossfades on game-state change
- **WHEN** two loaded layers `combat` and `exploration` are weighted via `ambient.extension.gameState = { combat: 0, exploration: 1 }`, then updated to `{ combat: 1, exploration: 0 }` with `transitionMs = 500`
- **THEN** the output blend transitions monotonically from the exploration layer to the combat layer over 500 ms with no audible click
**Verified by:** `src/plugins/spatial-positional/spatial-positional.test.ts::"honours ambient listener over the parameter fallback"` AND `src/plugins/music-layer-mixer/music-layer-mixer.test.ts::"crossfades layers monotonically on game-state change"`
---
### Requirement: Plugin registry for session restore
The `./registry` subpath SHALL export a `PluginRegistry` class with `register(pluginType: string, factory: () => AudioFxPlugin)`, `has(pluginType): boolean`, `create(pluginType): AudioFxPlugin`, and `list(): ReadonlyArray<string>`. The registry SHALL be consumed by `PluginChain.restore(snapshot, registry)` to instantiate plugins from their serialized `pluginType` strings. Every first-party plugin in this package SHALL self-register a default factory under its `metadata.id`; consumers may override or omit registrations to control which plugins are available for session restore. Restoring a snapshot whose `pluginType` is not in the registry SHALL reject with `AudioError` code `AUDIO_FX.CHAIN_SLOT_NOT_FOUND` carrying the missing type in `context`.
#### Scenario: restore uses the registry to instantiate plugins
- **WHEN** a snapshot containing `pluginType: "eq"` is restored against a registry that has the EQ factory registered
- **THEN** the chain contains an `EQPlugin` instance with the snapshot's parameters applied
#### Scenario: missing plugin type is rejected
- **WHEN** a snapshot containing an unregistered `pluginType` is restored
- **THEN** it rejects with `AudioError` whose `code === "AUDIO_FX.CHAIN_SLOT_NOT_FOUND"` and `context.pluginType` is the missing type
**Verified by:** `src/registry/plugin-registry.test.ts::"restore uses the registry to instantiate plugins by pluginType"` AND `src/registry/plugin-registry.test.ts::"restore rejects unregistered pluginType with AUDIO_FX.CHAIN_SLOT_NOT_FOUND"`
<!-- markdownlint-enable MD024 -->