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

167 lines
12 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec: react-audio-stream
**Package**: `@suaveplan/react-audio-stream`
**Path**: `packages/ui/react/react-audio-stream/`
**Status**: SHIPPED (chunks A+B+C+D landed and integrated 2026-05-23)
> Every requirement below carries a `**Verified by:**` line. No exceptions.
## Requirements
### Requirement: Stream-tier purity — no DSP, no audio context
`@suaveplan/react-audio-stream` SHALL import zero browser audio APIs (`AudioContext`, `OfflineAudioContext`, `AudioNode`, `AudioWorklet`) and zero Node-only APIs. WebRTC and WebSocket primitives MAY be referenced behind injected transport adapters. The package's runtime `dependencies` SHALL be limited to `@suaveplan/react-audio`, `@suaveplan/audio-fx-plugin-base`, `@suaveplan/audio-transport`, `@suaveplan/network-transport-websockets`, `@suaveplan/fsm`, `@suaveplan/retry`, `@suaveplan/events`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `zustand`, `zod`, `tslib`. `react` SHALL be a peer dep; `@suaveplan/network-discovery-trystero` SHALL be an optional peer gated by the `./transports/trystero` subpath.
#### 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: StreamSession FSM lifecycle
The package SHALL export `StreamSession`, a class whose lifecycle is modelled by `@suaveplan/fsm` with the states `idle`, `connecting`, `connected`, `joining`, `joined`, `disconnecting`, `disconnected`. The methods `connect(roomId)`, `join()`, `leave()`, and `disconnect()` SHALL drive transitions and SHALL throw `ReactAudioStreamError` with code `REACT_AUDIO_STREAM.INVALID_STATE` for any transition the FSM does not allow from the current state. Each transition SHALL emit a typed `'state-changed'` event carrying `{ from, to }`.
#### Scenario: connect → join → leave → disconnect happy path
- **WHEN** a fresh session executes `await connect("room1"); await join(); await leave(); await disconnect()`
- **THEN** the FSM transitions through `idle → connecting → connected → joining → joined → connected → disconnecting → disconnected` AND eight `'state-changed'` events are emitted in order
#### Scenario: illegal transition throws REACT_AUDIO_STREAM.INVALID_STATE
- **WHEN** `join()` is called on a session in `idle`
- **THEN** it throws `ReactAudioStreamError` whose `code === "REACT_AUDIO_STREAM.INVALID_STATE"` with `from: "idle", to: "joining"` in `context`
**Verified by:** `src/session/session-fsm/session-fsm.test.ts::"connect to disconnect happy path emits 8 state-changed events in order"` AND `src/session/session-fsm/session-fsm.test.ts::"illegal transition throws REACT_AUDIO_STREAM.INVALID_STATE"`
---
### Requirement: Peer streams materialise as source plugins on dedicated tracks
When the session receives a peer audio stream, the package SHALL instantiate a `StreamSourcePlugin` (an `AudioFxPlugin` source with `io.inputs[0].count === 0`) for that participant, attach the `MediaStream` via the plugin's `setSourceStream`, insert the plugin at index 0 of a `peer:<participantId>` track's `PluginChain`, and emit `'peer-stream-added'`. The created chain SHALL support any additional FX from `audio-fx-plugin-base` (the peer's audio benefits from EQ, compression, reverb without special-case code). On `'peer-left'` or stream end, the chain SHALL be disposed and the track removed.
#### Scenario: incoming stream becomes a source plugin on a new track
- **WHEN** a fake-peer fixture emits a `MediaStream` for participant `"alice"`
- **THEN** the engine timeline contains a track with id `"peer:alice"`, its chain has exactly one source plugin whose source stream equals the supplied stream, and `'peer-stream-added'` was emitted once
#### Scenario: FX can be added to a peer chain without special-case code
- **WHEN** a consumer adds a `CompressorPlugin` to the `peer:alice` chain via the generic `chain.add(compressor)`
- **THEN** insertion succeeds, the compressor sits at index 1, and the peer's audio passes through it on the next block
**Verified by:** `src/session/stream-source-plugin/stream-source-plugin.test.ts::"incoming stream becomes a source plugin on a new track"` AND `src/session/stream-source-plugin/stream-source-plugin.test.ts::"FX can be added to a peer chain without special-case code"`
---
### Requirement: Auto-reconnect with exponential back-off
When the FSM transitions to `disconnected` due to an interruption classified as `network` or `signalling`, the session SHALL invoke `@suaveplan/retry` with exponential back-off (base 1 s, multiplier 2, jitter ±20 %, max 30 s) to attempt reconnection, capped at `maxReconnectAttempts` (default 8). Each attempt SHALL emit `'reconnect-attempt'` with the attempt number; success SHALL emit `'state-changed'` for `connecting`; final exhaustion SHALL emit `'reconnect-failed'` and leave the FSM in `disconnected`. Interruptions classified as `media-permission`, `peer`, or explicit `disconnect()` SHALL NOT trigger reconnect.
#### Scenario: network interruption triggers reconnect with back-off
- **WHEN** the session's connection fails with a network error and `autoReconnect !== false`
- **THEN** `'reconnect-attempt'` events fire at delays roughly `[1, 2, 4, 8, 16, 30, 30, 30]` s (jitter ±20 %), and after the configured max attempts `'reconnect-failed'` fires once
#### Scenario: media-permission interruption does not auto-reconnect
- **WHEN** the local `getUserMedia` permission is revoked mid-session
- **THEN** the session disconnects, classifies the interruption as `media-permission`, emits `'interruption'` with that class, and does NOT issue any reconnect attempt
**Verified by:** `src/session/reconnect.test.ts::"network interruption triggers exponential-backoff reconnect"` AND `src/session/reconnect.test.ts::"media-permission interruption does not auto-reconnect"`
---
### Requirement: Interruption classification
The package SHALL export an `InterruptionClassifier` that maps the underlying disconnect cause to exactly one of `network | media-permission | signalling | peer | unknown`. The session SHALL emit `'interruption'` carrying `{ class, cause }` on every disconnection from `connected` or `joined`. Classification SHALL use deterministic rule mappings (ICE state, getUserMedia error name, signalling-channel close code, peer-ended flag) — no probabilistic heuristics.
#### Scenario: ICE failure classifies as network
- **WHEN** the WebRTC `iceconnectionstate` transitions to `failed`
- **THEN** the interruption class is `network`
#### Scenario: getUserMedia NotAllowedError classifies as media-permission
- **WHEN** the local stream's source is revoked with a `NotAllowedError`
- **THEN** the interruption class is `media-permission`
**Verified by:** `src/session/interruption-classifier/interruption-classifier.test.ts::"ICE failed classifies as network"` AND `src/session/interruption-classifier/interruption-classifier.test.ts::"NotAllowedError classifies as media-permission"`
---
### Requirement: Transport-share mode
`StreamSession` SHALL expose `requestTransportShare(participantId)` and `grantTransportShare()`. When granted, the granting peer SHALL listen to its local `Transport` for `'play'`, `'pause'`, `'seek'`, `'setTempo'` events and forward each via a control message on the peer channel; every non-driving peer SHALL apply received events to its local `Transport`. The driving peer's disconnect SHALL transfer control back to local (each peer's local transport stays running with its current state). Receivers of share-mode events SHALL render a "Shared transport active" indicator in the UI; controls on receivers SHALL no-op without throwing.
#### Scenario: play on driver advances every receiver's transport
- **WHEN** the granting peer's local transport `play(0)` fires after share is granted
- **THEN** every connected receiver's local transport receives `play(0)` within the network RTT budget and advances accordingly
#### Scenario: driver disconnect transfers control back to local
- **WHEN** the driving peer disconnects mid-share
- **THEN** the share status returns to `'local'` on every receiver, their controls become active again, and their local transport continues uninterrupted
**Verified by:** `src/hooks/use-transport-share/use-transport-share.test.tsx::"play on driver advances every receiver's transport"` AND `src/hooks/use-transport-share/use-transport-share.test.tsx::"driver disconnect returns share status to local"`
---
### Requirement: Per-peer stream mixer
`<StreamMixer>` and `useStreamMixer()` SHALL expose per-peer controls: level (gain dB), mute, solo, transport-share toggle, and a per-peer FX slot count read from the peer's chain. Mutations SHALL go through the peer chain's `setTrackVolume` / `setTrackMute` / `setTrackSolo` rather than re-implementing solo logic locally. Solo across peers SHALL follow DAW semantics: when any peer is soloed, every non-soloed peer's track-volume is set to 0.
#### Scenario: solo follows DAW semantics across peers
- **WHEN** three peers are connected and one is soloed via `useStreamMixer().solo(peerId)`
- **THEN** the other two peers' track volumes go to 0 and the soloed peer's volume is at its set level
**Verified by:** `src/components/stream-mixer/stream-mixer.test.tsx::"solo across peers follows DAW semantics"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL register: a counter `react_audio_stream_peer_connect_total{transport}`, a counter `react_audio_stream_peer_disconnect_total{reason}` where `reason` ∈ {`network`,`media-permission`,`signalling`,`peer`,`explicit`,`unknown`}, a gauge `react_audio_stream_active_sessions`, a counter `react_audio_stream_reconnect_attempt_total`, a counter `react_audio_stream_reconnect_failed_total`, and a histogram `react_audio_stream_rtt_ms{participant}` reading from the transport's RTT probe. Instrumentation SHALL be inert when no exporter is registered.
#### Scenario: gauge tracks active sessions
- **WHEN** two `StreamSession`s connect, one disconnects
- **THEN** `react_audio_stream_active_sessions` reads 1 after the disconnect
**Verified by:** `src/telemetry/telemetry.test.ts::"OTel active-sessions gauge tracks connect and disconnect"`
---
### Requirement: Structured error codes
The package SHALL declare its codes via `defineCodes("REACT_AUDIO_STREAM", [...])` producing the dotted codes `REACT_AUDIO_STREAM.NO_PROVIDER`, `REACT_AUDIO_STREAM.INVALID_STATE`, `REACT_AUDIO_STREAM.SIGNALLING_FAILED`, `REACT_AUDIO_STREAM.PEER_NOT_FOUND`, `REACT_AUDIO_STREAM.MEDIA_PERMISSION_DENIED`, `REACT_AUDIO_STREAM.RECONNECT_EXHAUSTED`, and `REACT_AUDIO_STREAM.INVALID_OPTIONS`. Export `ReactAudioStreamErrorCode`, `REACT_AUDIO_STREAM_ERROR_METADATA`, `registerReactAudioStreamErrorCodes()`, and a `ReactAudioStreamError` class extending `AudioError` from `@suaveplan/audio-core`. Every registered code SHALL be reachable from a real call site exercised by tests.
#### Scenario: every registered code is reachable
- **WHEN** the test suite runs
- **THEN** each code in `REACT_AUDIO_STREAM_ERROR_METADATA` is asserted thrown by at least one test
**Verified by:** `src/error-codes/error-codes.test.ts::"every REACT_AUDIO_STREAM code is reachable from a call site"`
---
### Requirement: Subpath exports per surface and transport gating
`package.json` SHALL declare one `exports` entry per surface: `.`, `./provider`, `./session`, `./hooks`, `./hooks/<name>`, `./components`, `./components/<name>`, `./transports/webrtc`, `./transports/websocket`, `./transports/trystero`. `sideEffects` SHALL be `false`. The `./transports/trystero` subpath SHALL be the only path that pulls in `@suaveplan/network-discovery-trystero`; importing other paths SHALL NOT bundle it.
#### Scenario: importing webrtc transport does not bundle trystero
- **WHEN** a consumer imports `@suaveplan/react-audio-stream/transports/webrtc`
- **THEN** the tree-shaken bundle contains zero bytes from `@suaveplan/network-discovery-trystero`
**Verified by:** `tests/purity.test.ts::"every surface has a subpath export entry"` AND `tests/purity.test.ts::"webrtc transport does not bundle trystero"`
<!-- markdownlint-enable MD024 -->