Files
suaveplan/openspec/archive/2026-05-17-enterprise-audio-plugins-fx/specs/audio-plugins-fx/spec.md
2026-05-27 23:29:53 +01:00

56 KiB

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"