Files
2026-05-27 23:29:53 +01:00

148 lines
10 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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
`<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 `AudioProvider`s (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`'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
`<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 `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** `<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 `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
`<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 than `setParameter`
#### Scenario: raycaster is pooled across sources
- **WHEN** 20 `<PositionalAudio>` sources are active under one `<OcclusionProvider>`
- **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
`<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 `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
`<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'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 `<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_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/<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-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"`
<!-- markdownlint-enable MD024 -->