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 thecreateAudioNode/ worklet-source modules - THEN it MUST NOT reference
window,document,navigator,AudioContext,node:fs,node:path,process, orBuffer
Scenario: Tests pass under both runtimes
- WHEN the suite runs under
bun test(server) and under vitest with ahappy-domshim (browser) - THEN both runs pass with identical results and every plugin's
BlockProcessorpath 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 readonlyparameterIndex: ReadonlyMap<string, PluginParameter>(O(1) lookup), - a readonly
io: PluginIodeclaring its channel topology (see "Channel topology" requirement), - a readonly
capabilities: PluginCapabilitiesdeclaring its feature flags (see "Capabilities" requirement), - parameter accessors
getParameter(id),setParameter(id, value),setParameterCurve(id, curve), andsetParameterAt(id, value, transportTimeSec), - a
getLatencySamples(): numberaccessor andonLatencyChanged(listener): UnsubscribeFnevent, - preset methods
getPreset()andapplyPreset(preset), - the optional browser-native fast paths
createAudioNode?(ctx, chain)andgetWorkletProcessor?(), - 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
AudioFxPluginsurface (metadata,schema,parameters,parameterIndex,io,capabilities, the four parameter accessors,getLatencySamples,onLatencyChanged,getPreset,applyPreset) AND the fullBlockProcessorsurface (name,setup,process,reset,dispose)
Scenario: parameterIndex agrees with parameters
- WHEN any plugin instance is checked
- THEN for every entry
pinparameters,parameterIndex.get(p.id) === p, ANDparameterIndex.size === parameters.length
Scenario: Browser members are optional and absent by default
- WHEN a plugin that ships no browser path is constructed
- THEN
createAudioNodeandgetWorkletProcessorareundefined,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
processon a freshly constructed plugin without callingsetupfirst - THEN it throws
AudioErrorwhosecode === "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
setParameterwith a value above the parameter'smax - THEN
getParameterreturns exactlymaxand the next processed block reflects the clamped value
Scenario: linear curve ramps sample-accurately across blocks
- WHEN a caller sets a
linearcurve 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
setParameterwith an id not present inparameters - THEN it throws
AudioErrorwhosecode === "AUDIO_FX.UNKNOWN_PARAMETER"
Scenario: setParameterAt lands on the configured transport beat
- WHEN a caller calls
setParameterAt(id, target, transportTimeSec)while the chain's ambienttransportTimeSecis in the past relative totransportTimeSec, then processes blocks until the transport advances past the scheduled time - THEN the parameter reaches
targetby the block whose start sample maps totransportTimeSecwithin sample-accurate tolerance
Scenario: past schedule is dropped on transport rewind
- WHEN a caller schedules a future event with
setParameterAt, then the chain receives anupdateAmbientthat resetstransportTimeSecto before the schedule's time, thenreset()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 callsplugin.applyPreset(p) - THEN every parameter is restored to its captured value and
getPreset()deep-equalsp
Scenario: malformed preset is rejected at the boundary
- WHEN a caller calls
applyPresetwith an object missing a required field - THEN it throws
AudioErrorwhosecode === "AUDIO_FX.INVALID_PRESET"with the Zod issues incontextand no parameter is mutated
Scenario: future preset version is rejected
- WHEN a caller calls
applyPresetwith aschemaVersiongreater than the plugin supports - THEN it throws
AudioErrorwhosecode === "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
EQPluginis set to theflatpreset 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
setBandapplies 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
FilterPluginruns 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
CompressorPluginwith 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
LimiterPluginwith 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
GatePluginprocesses 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
DelayPluginprocesses an impulse with a knowndelayTimeand non-zerofeedback - THEN the output contains decaying repeats spaced by
delayTimeand the signal does not diverge
Scenario: fully-dry mix is transparent
- WHEN any time-based plugin is set to
wet = 0and processes a signal - THEN the output equals the input sample-for-sample
Scenario: reverb algorithmic fallback runs without an impulse response
- WHEN
ReverbPluginprocesses a block beforeloadImpulseResponsehas 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
TremoloPluginprocesses 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
TremoloPluginorAutoFilterPluginis set todepth = 0and 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
DistortionPluginin 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
DistortionPluginin 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
PitchShifterPluginwithsemitones = 0processes 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
PitchShifterPluginwithsemitones = 12processes 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
triggerAttackfor 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
polyphonyare 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
AudioErrorwhosecode === "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
loadSamplefor a note thentriggerAttackthat 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
triggerAttackfor a note with no loaded sample - THEN it throws
AudioErrorwhosecode === "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
SpatialAudioPluginprocesses 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
MixerPluginprocesses 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
createAudioNodeis exercised purely viasetup+process - THEN
createAudioNodeis not invoked and noBaseAudioContextreference is constructed
Scenario: worklet processor source is a self-contained string
- WHEN a plugin that implements
getWorkletProcessorreturns its source - THEN the returned value is a non-empty string containing a
registerProcessorcall 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
AudioErrorwhosecode === "AUDIO_FX.INVALID_OPTIONS"with the Zod issues incontext
Scenario: malformed parameter curve is rejected
- WHEN a caller calls
setParameterCurvewith a curve whosetypeis not one oflinear/exponential/target - THEN it throws
AudioErrorwhosecode === "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_METADATAis 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 andaudio_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
loadImpulseResponseorloadSampleis called with an already-aborted signal - THEN it throws
AudioErrorwhosecode === "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.ABORTEDand 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
./synthesizeror 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()throwsAudioErrorwhosecode === "AUDIO_FX.CHAIN_IO_INCOMPATIBLE"and the errorcontextnames 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 forgetEffectiveOutputChannels() - 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 istypeof === '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 isbypass()d - THEN
chain.getEffectiveLatencySamples() === 150; afterbypass(0, true)it returns50; after un-bypassing all it returns150
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-changedis 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, configuredchannels,sampleRate,blockSize),position: { index, total }— the plugin's slot index and total chain length,predecessors: ReadonlyArray<PluginHandle>andsuccessors: 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, wheretrackerreadsctx.predecessors[0].getParameter('gain')duringprocess - THEN
trackerobserves6and 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
processobservesctx.ambient.transport.tempo === 140AND the chain emitted oneambient-changedevent
Scenario: peer handles are read-only
- WHEN any plugin attempts to mutate a value via a
PluginHandlereturned throughpredecessorsorsuccessors - THEN no mutation method exists on
PluginHandleand 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 viavalidateChainIoagainst the predecessor's effective output and rejects withAUDIO_FX.CHAIN_IO_INCOMPATIBLEon mismatch; rejects withAUDIO_FX.CHAIN_DUPLICATE_PLUGIN_IDif a plugin with the same id is already in the chain; rejects withAUDIO_FX.CHAIN_SOURCE_POSITION_INVALIDif the inserted plugin is a source plugin (per the "Channel topology" requirement) andat !== 0or another source already exists at index 0. Emitsplugin-added.remove(idOrIndex)— disposes and removes; emitsplugin-removed.AUDIO_FX.CHAIN_SLOT_NOT_FOUNDon missing target.move(from, to)— reorders; revalidates IO across the whole chain; emitsplugin-moved.replace(idOrIndex, plugin, opts?: { crossfadeMs?: number })— atomic swap; ifcrossfadeMs > 0the chain SHALL crossfade the previous plugin's output with the new plugin's output across that duration without dropping samples; emitsplugin-replaced.bypass(idOrIndex, bypassed: boolean)— toggles the chain-level bypass wrapper; emitsbypass-changed.
Lifecycle methods:
setup(config, ambient, opts?): Promise<void>— calls each plugin'ssetupin parallel viaPromise.all, propagatingopts.signalto every plugin. Construction of theChainContextfor each plugin SHALL pin itspositionindex 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'sreset()in order; drops past parameter schedules per the "Parameter model" requirement.dispose()— calls every plugin'sdispose(); unsubscribes all listeners.
Inspection methods:
plugins: ReadonlyArray<AudioFxPlugin>— current ordered plugin list.findById(id)—{ plugin, index } | undefined.getEffectiveLatencySamples()andgetEffectiveOutputChannels()— 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 aPluginRegistryto instantiate plugins bypluginType; validates the snapshot against the Zod schema first; rejects withAUDIO_FX.INVALID_PRESETon 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.processthenb.process(on a's output) thenc.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
processis NOT called AND the buffer at that slot is the sameFloat32Arrayinstance 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, andrestore(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 callsbus.read(scratch)after the write - THEN
scratchcontains the published samples andbus.channelsmatches 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
AudioErrorwhosecode === "AUDIO_FX.BUS_CHANNEL_MISMATCH"
Scenario: missing bus returns undefined, not throws
- WHEN
ctx.buses.get('nonexistent')is called - THEN the result is
undefinedand 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
MicrophonePluginwith 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
MicrophonePluginwith thetelephonepreset 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
GLaDOSPluginwithwet=0processes 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
BlockProcessorand 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, callsplay()without first loading a source - THEN it throws
AudioErrorwhosecode === "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.
SpatialPositionalPluginSHALL apply distance attenuation perrolloffFactor, amplitude panning in the universal path, and HRTF panning viacreateAudioNode()returning aPannerNodein the browser. It SHALL readChainContext.ambient.listenerforpositionandorientation; ifambient.listenerisundefinedit SHALL fall back to the plugin's ownlistenerPositionparameter.ProximityVoicePluginSHALL attenuate voice signals based on distance fromambient.listener.positionto the plugin'svoicePositionparameter, expose a VAD threshold below which output is muted, and optionally blend a spatial pan from the listener orientation. It SHALL preserve channel count.MusicLayerMixerPluginSHALL cross-fade N music layers (each a loadedAudioBufferregistered vialoadLayer(id, buffer)) based on a per-layer weight derived fromambient.extension.gameState(aReadonly<Record<string, number>>mapping a game-state axis name to a 0..1 value) and the plugin'slayerWeightsparameter map; transitions SHALL crossfade smoothly over the configuredtransitionMs. It SHALL be a source plugin (io.inputs[0].count === 0).VoiceTransformPluginSHALL apply combinable pitch shift, formant shift, ring modulation, distortion, and reverb to voice signals with a preset system coveringchild,giant,robot,monster, andechocharacters.GLaDOSPluginSHALL be implementable as a specialization or wrapper ofVoiceTransformPlugin(the spec does not mandate which).
Scenario: spatial-positional honours ambient listener over its own parameter
- WHEN
SpatialPositionalPluginis processed withChainContext.ambient.listener.position = {x: 10, y: 0, z: 0}AND its ownlistenerPositionparameter 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
combatandexplorationare weighted viaambient.extension.gameState = { combat: 0, exploration: 1 }, then updated to{ combat: 1, exploration: 0 }withtransitionMs = 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
EQPlugininstance with the snapshot's parameters applied
Scenario: missing plugin type is rejected
- WHEN a snapshot containing an unregistered
pluginTypeis restored - THEN it rejects with
AudioErrorwhosecode === "AUDIO_FX.CHAIN_SLOT_NOT_FOUND"andcontext.pluginTypeis 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"