10 KiB
Capability Spec Delta: r3f-audio (enterprise-gaming-r3f-audio)
Package: @suaveplan/r3f-audio
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED Requirements
Requirement: Bridge-tier purity — no audio context, no DSP
@suaveplan/r3f-audio SHALL import zero browser audio APIs (AudioContext, OfflineAudioContext, AudioNode, AudioWorklet) and zero Node-only APIs. Every audio behaviour SHALL be delegated to plugins from @suaveplan/audio-plugins-fx; the package SHALL be a pure scene-graph-to-AmbientState bridge plus thin component wrappers around those plugins. The package's runtime dependencies SHALL be limited to @suaveplan/audio-plugins-fx, @suaveplan/react-audio, @suaveplan/audio-transport, @suaveplan/error, @suaveplan/types, @suaveplan/opentelemetry, zod, and tslib. react, @react-three/fiber, and three SHALL be peer dependencies only.
Scenario: built dist references no audio context
- WHEN the built
dist/is grep'd - THEN it MUST NOT reference
AudioContext,OfflineAudioContext,AudioNode, orAudioWorklet
Verified by: tests/purity.test.ts::"dist references no audio context" AND tests/purity.test.ts::"runtime dependency set matches the allowlist"
Requirement: AudioListener pushes camera pose into AmbientState every frame
<AudioListener target?> SHALL on every R3F render tick (via useFrame) read the world-space position, orientation, and velocity (computed as a per-frame discrete diff) of the target Object3D (default: the active R3F camera) and call engine.updateAmbient({ listener: { position, orientation, velocity } }). Vector and quaternion math SHALL use scratch instances captured at mount; the per-frame loop SHALL allocate zero new objects. The component SHALL be safe to nest under multiple AudioProviders (each gets its own listener bridge).
Scenario: per-frame loop allocates no new objects
- WHEN
<AudioListener>runs for 600 frames against an instrumented allocator - THEN the count of vector / quaternion allocations attributable to the listener bridge equals zero after the initial mount
Scenario: listener pose propagates to every chain
- WHEN the camera moves and the next R3F frame ticks
- THEN every active
PluginChain'sChainContext.ambient.listener.positionequals the camera's world-space position on the next processed audio block
Verified by: src/components/audio-listener/audio-listener.test.tsx::"per-frame loop allocates no new objects" AND tests/integration.test.tsx::"camera move propagates to every chain ambient.listener"
Requirement: PositionalAudio wraps SpatialPositionalPlugin
<PositionalAudio src position rolloff? maxDistance?> SHALL on mount acquire a SpatialPositionalPlugin instance from the provider's voice pool (or instantiate via PluginRegistry.create('spatial-positional') if pooling is disabled), insert it into the configured PluginChain, and load the audio source. On every R3F render tick the component SHALL write the wrapped Object3D's world-space position into the plugin's positionX/positionY/positionZ parameters. On unmount the plugin instance SHALL be released back to the pool (state reset) or disposed if pooling is disabled.
Scenario: position write reaches the plugin on the next frame
- WHEN the
Object3Dis moved to(10, 0, 0)and the next frame ticks - THEN
plugin.getParameter('positionX') === 10(within float tolerance)
Scenario: unmount returns the plugin to the pool
- WHEN
<PositionalAudio>unmounts under a provider with pooling enabled - THEN the plugin instance is returned to the pool with its state reset, and re-mounting the same
srcreuses the pooled instance rather than constructing a fresh one
Verified by: src/components/positional-audio/positional-audio.test.tsx::"position write reaches the plugin on the next frame" AND src/components/positional-audio/positional-audio.test.tsx::"unmount returns the plugin to the pool"
Requirement: OcclusionProvider smooths raycast results into a parameter curve
<OcclusionProvider occluders raycastRate? smoothingMs?> SHALL register a per-frame (rate-limited to raycastRate, default 30 Hz) Raycaster walk from each active <PositionalAudio>'s position to the listener position, sum attenuation across hits, and write the resulting dB value via plugin.setParameterCurve('occlusionDb', { type: 'linear', value, duration: smoothingMs / 1000 }). A single Raycaster instance SHALL be reused across all sources via a pool (no per-source allocation). The component SHALL handle the listener-or-source-absent case by writing occlusionDb = 0 for stale sources.
Scenario: smooths occlusion change into a curve
- WHEN an occluder is moved between listener and source between two frames
- THEN the spatial plugin observes
setParameterCurve('occlusionDb', { duration: smoothingMs / 1000 })rather thansetParameter
Scenario: raycaster is pooled across sources
- WHEN 20
<PositionalAudio>sources are active under one<OcclusionProvider> - THEN exactly one
Raycasterinstance is constructed and reused for every source's per-frame walk
Verified by: src/components/occlusion-provider/occlusion-provider.test.tsx::"smooths occlusion change into a parameter curve" AND src/components/occlusion-provider/occlusion-provider.test.tsx::"raycaster is pooled across sources"
Requirement: MusicLayerMixer binds gameState to ambient extension
<MusicLayerMixer layers gameState transitionMs?> SHALL on mount acquire a MusicLayerMixerPlugin instance, call plugin.loadLayer(id, buffer) for each entry in layers, and insert it as the chain's source plugin (index 0). When the gameState prop changes between renders, the component SHALL call engine.updateAmbient({ extension: { gameState: nextGameState } }). The crossfade duration SHALL be the plugin's configured transitionMs (set once at mount via the transitionMs prop); per-render-cycle prop changes do not need to re-configure the plugin.
Scenario: gameState change writes to ambient extension
- WHEN the consumer changes
gameStatefrom{ tension: 0 }to{ tension: 1 } - THEN
engine.updateAmbientis called once with{ extension: { gameState: { tension: 1 } } }
Verified by: src/components/music-layer-mixer/music-layer-mixer.test.tsx::"gameState prop change writes the new value to ambient extension"
Requirement: ProximityVoiceChat and VoiceTransform thin wrappers
<ProximityVoiceChat participantId stream position> SHALL wrap a ProximityVoicePlugin instance: attach stream as its source, write participant's world-space position into the plugin's voicePosition parameter every frame, and (on unmount) detach the stream and release the plugin to the pool. <VoiceTransform character preset stream> SHALL wrap a VoiceTransformPlugin instance: attach stream, apply the requested preset on mount and on preset prop change, and release on unmount.
Scenario: ProximityVoiceChat attenuates with distance
- WHEN a
<ProximityVoiceChat>participant moves from 1 m to 50 m from the listener with the proximity plugin'srolloffFactor = 1 - THEN the plugin's output level decreases monotonically per the rolloff model
Scenario: VoiceTransform applies preset changes
- WHEN the consumer changes the
presetprop from'robot'to'monster' - THEN the plugin's
applyPreset('monster')is called once and the output reflects the new transform on the next block
Verified by: src/components/proximity-voice-chat/proximity-voice-chat.test.tsx::"output level decreases monotonically with distance" AND src/components/voice-transform/voice-transform.test.tsx::"preset prop change applies a new preset"
Requirement: OpenTelemetry active-source gauge
The package SHALL register an @suaveplan/opentelemetry gauge r3f_audio_active_sources reporting the count of currently mounted <PositionalAudio> + <ProximityVoiceChat> + <VoiceTransform> instances per provider. The gauge SHALL be inert when no exporter is registered.
Scenario: gauge reflects mount and unmount
- WHEN four
<PositionalAudio>components mount, then two unmount - THEN the gauge reads 4 after the mounts and 2 after the unmounts
Verified by: src/components/positional-audio/positional-audio.test.tsx::"OTel active-source gauge tracks mount and unmount"
Requirement: Structured error codes
The package SHALL declare its codes via defineCodes("R3F_AUDIO", [...]) from @suaveplan/error, producing the dotted codes R3F_AUDIO.NO_PROVIDER, R3F_AUDIO.NO_AUDIO_LISTENER, R3F_AUDIO.PLUGIN_POOL_EXHAUSTED, and R3F_AUDIO.INVALID_OPTIONS. The package SHALL export R3fAudioErrorCode, R3F_AUDIO_ERROR_METADATA, registerR3fAudioErrorCodes(), and an R3fAudioError class extending AudioError from @suaveplan/audio-core. Every error thrown SHALL carry one of these codes; every registered code SHALL be reachable from a real call site.
Scenario: every registered code is reachable
- WHEN the test suite runs
- THEN each code in
R3F_AUDIO_ERROR_METADATAis asserted thrown by at least one test
Verified by: src/error-codes.test.ts::"every R3F_AUDIO code is reachable from a call site"
Requirement: Subpath exports per surface
package.json SHALL declare one exports entry per surface: ., ./components, ./components/<name>, ./hooks, ./hooks/<name>, ./util. sideEffects SHALL be false. Importing one component SHALL NOT pull in others.
Scenario: importing one component does not pull in others
- WHEN a consumer imports
@suaveplan/r3f-audio/components/audio-listeneronly - THEN the tree-shaken bundle contains zero bytes from
src/components/positional-audio/
Verified by: tests/exports.test.ts::"every surface has a subpath export entry" AND tests/bundle-size/listener-only.size.test.ts::"importing audio-listener does not bundle other components"