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

12 KiB

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 StreamSessions 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"