# 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:` 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 `` 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/`, `./components`, `./components/`, `./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"`