11 KiB
Capability Spec Delta: audio-plugins-fx (extend-audio-plugins-fx-enterprise-extras)
Package: @suaveplan/audio-plugins-fx
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions. (Seeopenspec/AGENTS.mdSection 13.)
ADDED Requirements
Requirement: Audio analyzer plugin
The ./audio-analyzer subpath SHALL export AudioAnalyzerPlugin, an analysis-only tap point that passes input audio through unchanged while extracting frequency-domain and time-domain features. The plugin SHALL expose synchronous getFrequencyData() and getWaveformData() accessors returning the latest FFT magnitudes and the most recent input window respectively, plus async getTempo(), getPitch(), getSpectralFeatures(), getEnergy(), and getKey() getters that return immutable result records. It SHALL expose onAnalysisUpdate(callback) returning an unsubscribe function and call callback on a configurable cadence with a snapshot of the enabled analyses. It SHALL expose applyPreset(name) accepting one of "music" | "speech" | "percussion" | "ambient", each preset toggling a documented subset of analyses on or off. enable<X>Detection(boolean) setters and is<X>DetectionEnabled() accessors SHALL be available for tempo / pitch / spectral / beat analyses so callers can tune the CPU budget per use case.
Scenario: passes audio through unchanged
- WHEN
AudioAnalyzerPluginprocesses a non-silent input block - THEN the output block equals the input block sample-for-sample within IEEE-clean tolerance
Scenario: applyPreset toggles the documented subset of analyses
- WHEN
applyPreset("speech")is called on a default instance - THEN
isPitchDetectionEnabled()becomestrue,isBeatDetectionEnabled()becomesfalse, and the same toggles persist across the next block
Scenario: onAnalysisUpdate fires on the configured cadence
- WHEN
onAnalysisUpdate(callback)is registered and the plugin processes blocks for at least the configuredupdateIntervalMs - THEN
callbackis invoked at least once with anAnalysisUpdatecarrying the enabled analyses, and the returned unsubscribe stops further invocations
Verified by: src/plugins/audio-analyzer/audio-analyzer.test.ts::"output equals input sample-for-sample on the audio path" AND src/plugins/audio-analyzer/audio-analyzer.test.ts::"applyPreset('speech') toggles the documented analyses" AND src/plugins/audio-analyzer/audio-analyzer.test.ts::"onAnalysisUpdate fires once per updateInterval and unsubscribe stops it"
Requirement: Microphone simulation plugin
The ./microphone subpath SHALL export MicrophoneSimulationPlugin and a createMicrophonePlugin(options) factory. The plugin SHALL be a source (io.inputs[0].count === 0, io.outputs[0].count === 1) that synthesises microphone-coloured audio from an oscillator + filtered-noise + dynamics + wow-and-flutter chain. Construction options SHALL include the structured control groups documented in the main-branch microphone.types.ts (FrequencyControls, ResonanceControls, DistortionControls, NoiseControls, DynamicsControls, CompressorControls, GainControls, CharacterControls, WowAndFlutterControls). The plugin SHALL expose a getUISections() accessor returning a ReadonlyArray<MicrophoneUISection> describing every control as a typed MicrophoneUIControl (id, label, min, max, step, unit, group) so editors can render the UI without hard-coding the layout.
Scenario: source plugin produces non-silent output
- WHEN
MicrophoneSimulationPluginis constructed with defaults and processes a 4096-sample block - THEN the output's RMS exceeds
1e-4and every sample is finite
Scenario: UI section descriptor mirrors the configured controls
- WHEN
getUISections()is called on a default instance - THEN the returned sections enumerate every
*Controlsfield inMicrophoneConfigwith stableidstrings and boundedmin/maxvalues
Scenario: preset round-trip preserves the full control state
- WHEN
getPreset()is called, the resulting payload is fed intoapplyPreset()on a fresh instance, and the second instance's controls are read - THEN every nested field matches the source instance's pre-snapshot state
Verified by: src/plugins/microphone/microphone.test.ts::"default microphone produces non-silent output" AND src/plugins/microphone/microphone.test.ts::"getUISections enumerates every configured control" AND src/plugins/microphone/microphone.test.ts::"preset round-trip preserves the full microphone control state"
Requirement: GLaDOS voice-effect plugin
The ./glados subpath SHALL export GLaDOSPlugin, an insert plugin (io.inputs[0].count === io.outputs[0].count === "preserve") that runs the input audio through a fixed signal chain of pitch-shift → formant-shift → robotic-intensity tremolo → distortion → low-pass → reverb. The plugin SHALL expose typed setters for every stage: setPitchShift, setFormantShift, setRoboticIntensity, setDistortionAmount, setReverbAmount, setLowPassCutoff. It SHALL expose applyPreset(name) accepting one of "glados-mainline" | "glados-test-chamber" | "glados-cake-deception" | "wheatley" | "cave-johnson" | "robotic-generic", each preset setting all six stage values together. Calling a setter with an out-of-range value SHALL throw AudioFxError with code AUDIO_FX.INVALID_OPTIONS.
Scenario: applyPreset sets every stage at once
- WHEN
applyPreset("wheatley")is called on a default instance - THEN the six stage parameter values match the documented Wheatley preset record
Scenario: voice chain colours the input audibly
- WHEN the plugin processes a non-silent input block at the default
"glados-mainline"preset - THEN the output differs from the input (per-sample RMS difference >
1e-3) and every sample remains finite
Scenario: out-of-range setter is rejected
- WHEN
setRoboticIntensity(99)is called with the parameter range documented as[0, 1] - THEN the call throws
AudioFxErrorwhosecode === "AUDIO_FX.INVALID_OPTIONS"with the offending value incontext
Verified by: src/plugins/glados/glados.test.ts::"applyPreset('wheatley') sets every stage to the wheatley preset values" AND src/plugins/glados/glados.test.ts::"glados-mainline preset colours the input audibly" AND src/plugins/glados/glados.test.ts::"setRoboticIntensity rejects out-of-range values with AUDIO_FX.INVALID_OPTIONS"
Requirement: Game audio plugin
The ./game subpath SHALL export GameAudioPlugin, a composite source plugin that drives game-audio scenarios. It SHALL maintain a registry of spatial sources, music layers, and proximity-voice-chat participants:
- Spatial sources:
createSpatialSource(id, position),updateSpatialSourcePosition(id, position),applyDopplerEffect(id, velocity),applyOcclusion(id, level),cullDistantSources(maxDistance),getActiveSpatialSourceCount(). - Music layers:
addMusicLayer(name, buffer),removeMusicLayer(name),getMusicLayers(),crossfadeMusicLayers(from, to, durationMs),isCrossfading(). - Proximity voice chat:
enableProximityVoiceChat(config),addVoiceChatParticipant(id, position),removeVoiceChatParticipant(id),updateVoiceChatParticipantPosition(id, position),calculateVoiceAttenuation(sourcePosition),optimizeVoiceChatBandwidth(),getVoiceChatOptimizationLevel().
The plugin SHALL expose setListenerPosition / setListenerOrientation and apply distance attenuation per the Web Audio inverse-distance model (matching SpatialAudioPlugin's contract). Voice attenuation SHALL fall off monotonically as the participant moves away from the listener through the configured [minDistance, maxDistance] band.
Scenario: spatial source lifecycle is symmetric
- WHEN a caller
createSpatialSource("a", { x: 0, y: 0, z: 0 })then queriesgetActiveSpatialSourceCount() - THEN the count is
1; aftercullDistantSources(0)the count is0
Scenario: voice attenuation falls off monotonically with distance
- WHEN
calculateVoiceAttenuationis called for participants placed at increasing distances within[minDistance, maxDistance] - THEN the returned attenuation values strictly decrease across the sequence
Scenario: music-layer crossfade reports its in-flight state
- WHEN
crossfadeMusicLayers("intro", "main", 200)is called and immediately afterisCrossfading()is queried - THEN the call returns
true; after at least200ms of process time has elapsed (verified via the universalnowSecopt), it returnsfalse
Verified by: src/plugins/game/game-audio.test.ts::"createSpatialSource + cullDistantSources balance the source registry" AND src/plugins/game/game-audio.test.ts::"calculateVoiceAttenuation decreases monotonically with distance" AND src/plugins/game/game-audio.test.ts::"crossfadeMusicLayers flips isCrossfading until the configured duration elapses"
Requirement: Audio player plugin
The ./audio-player subpath SHALL export AudioPlayerPlugin, a source plugin (io.inputs[0].count === 0, io.outputs[0].count === 1) that decodes a raw Float32 PCM stream from a URL into an internal buffer and plays it back through the universal BlockProcessor path. The plugin SHALL expose load(url, { signal? }): Promise<void> accepting an AbortSignal; pre-aborted signal SHALL throw AudioFxError with code AUDIO_FX.ABORTED before any fetch. After load it SHALL expose play(), pause(), stop(), seek(timeSec), setVolume(0..1), setLoop(boolean), setPlaybackRate(0.25..4), and getState() returning { playing, paused, currentTimeSec, durationSec, volume, loop, playbackRate }. Triggering play() before any load() SHALL throw AudioFxError with code AUDIO_FX.SAMPLE_NOT_LOADED.
Scenario: load + play renders the loaded audio
- WHEN
load(dataUrl)resolves andplay()is called, then the plugin processes a block - THEN the output buffer contains the first samples of the loaded PCM scaled by
volume
Scenario: play without load is rejected
- WHEN
play()is called before anyload() - THEN the call throws
AudioFxErrorwhosecode === "AUDIO_FX.SAMPLE_NOT_LOADED"
Scenario: pre-aborted signal short-circuits load
- WHEN
load(url, { signal })is called with an already-aborted signal - THEN the call throws
AudioFxErrorwhosecode === "AUDIO_FX.ABORTED"before any fetch is issued
Scenario: setLoop with true re-plays after the end of the buffer
- WHEN a short buffer is loaded,
setLoop(true)is called, and the plugin processes more samples than the buffer contains - THEN the output stream wraps back to the start of the buffer without producing silence in between
Verified by: src/plugins/audio-player/audio-player.test.ts::"load + play renders the loaded buffer on the next process block" AND 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::"pre-aborted signal short-circuits before fetch" AND src/plugins/audio-player/audio-player.test.ts::"setLoop wraps playback back to the buffer start"