# 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`, or `AudioWorklet` **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 `` 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 `AudioProvider`s (each gets its own listener bridge). #### Scenario: per-frame loop allocates no new objects - **WHEN** `` 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`'s `ChainContext.ambient.listener.position` equals 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 `` 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 `Object3D` is 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** `` 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 `src` reuses 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 `` SHALL register a per-frame (rate-limited to `raycastRate`, default 30 Hz) `Raycaster` walk from each active ``'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 than `setParameter` #### Scenario: raycaster is pooled across sources - **WHEN** 20 `` sources are active under one `` - **THEN** exactly one `Raycaster` instance 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 `` 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 `gameState` from `{ tension: 0 }` to `{ tension: 1 }` - **THEN** `engine.updateAmbient` is 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 `` 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. `` 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 `` participant moves from 1 m to 50 m from the listener with the proximity plugin's `rolloffFactor = 1` - **THEN** the plugin's output level decreases monotonically per the rolloff model #### Scenario: VoiceTransform applies preset changes - **WHEN** the consumer changes the `preset` prop 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 `` + `` + `` instances per provider. The gauge SHALL be inert when no exporter is registered. #### Scenario: gauge reflects mount and unmount - **WHEN** four `` 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_METADATA` is 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/`, `./hooks`, `./hooks/`, `./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-listener` only - **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"`