Initial Commit
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
# Design — enterprise-react-audio-stream
|
||||
|
||||
`@suaveplan/react-audio-stream` provides real-time audio streaming over the unified architecture. Its core insight: an incoming peer audio stream is just another **source plugin** on a dedicated chain, indistinguishable to the rest of the system from a local `AudioPlayerPlugin`. That uniformity gets us FX chains per peer, sidechain ducking from peers, analyser readouts per peer, and snapshot/restore of "studio sessions" for free — without the streaming code having to know about any of it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Position in the audio family
|
||||
|
||||
```
|
||||
consumer React app
|
||||
│
|
||||
▼
|
||||
react-audio-stream ← this package
|
||||
(StreamSession + per-peer source plugin + transports)
|
||||
│
|
||||
▼
|
||||
react-audio (provider + hooks)
|
||||
│
|
||||
▼
|
||||
audio-engine-browser (TimelineAdapter + chains)
|
||||
```
|
||||
|
||||
Per-peer sources are inserted on dedicated tracks in the engine's timeline. Track ids follow the convention `peer:<participantId>`. The chain owns FX; the source plugin owns playback of the incoming `MediaStream`.
|
||||
|
||||
---
|
||||
|
||||
## 2. `StreamSession` — transport-agnostic core
|
||||
|
||||
```ts
|
||||
class StreamSession {
|
||||
constructor(opts: StreamSessionOptions)
|
||||
|
||||
connect(roomId: string): Promise<void>
|
||||
join(): Promise<void>
|
||||
leave(): Promise<void>
|
||||
disconnect(): Promise<void>
|
||||
|
||||
readonly state: SessionState // FSM via @suaveplan/fsm
|
||||
readonly peers: ReadonlyMap<string, PeerChannel>
|
||||
|
||||
on(event: SessionEvent, listener): UnsubscribeFn
|
||||
// 'state-changed' | 'peer-joined' | 'peer-left' | 'peer-stream-added'
|
||||
// | 'peer-stream-removed' | 'transport-share-request' | 'transport-share-granted'
|
||||
// | 'interruption' | 'reconnect-attempt' | 'reconnect-failed'
|
||||
}
|
||||
```
|
||||
|
||||
The session does not speak any specific transport. It delegates negotiation, ICE, signalling, and stream attachment to an injected `StreamTransport` adapter (`WebRTCTransport`, `WebSocketTransport`, `TrysteroTransport`). The same session can run identically against any transport.
|
||||
|
||||
---
|
||||
|
||||
## 3. Per-peer source plugin
|
||||
|
||||
When a peer adds a stream, the session emits `'peer-stream-added'` with `{ participantId, stream: MediaStream }`. The provider listens, instantiates a `StreamSourcePlugin` (an `AudioFxPlugin` source — `io.inputs[0].count === 0`, captures from a `MediaStream`), inserts it into the chain of a new `peer:<participantId>` track, and exposes it via `usePeerStreams()`.
|
||||
|
||||
```ts
|
||||
class StreamSourcePlugin implements AudioFxPlugin {
|
||||
readonly io = { inputs: [{ count: 0 }], outputs: [{ count: 2, layout: 'stereo' }] }
|
||||
// browser path: createAudioNode wraps MediaStreamSourceNode
|
||||
// server path: not applicable (server doesn't have MediaStream)
|
||||
setSourceStream(stream: MediaStream): void
|
||||
process(_in, output, ctx) {
|
||||
// browser worklet path mirrors AudioNode path's behaviour
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The plugin is just like every other source plugin — `<PluginChainView>` renders it generically, `useParameter` writes its volume, the chain can have FX after it, the bus registry can read its post-FX output.
|
||||
|
||||
---
|
||||
|
||||
## 4. FSM session lifecycle
|
||||
|
||||
`@suaveplan/fsm` drives the session through:
|
||||
|
||||
```
|
||||
idle → connecting → connected (idle in room) → joining → joined (streaming)
|
||||
│ │
|
||||
└─→ disconnecting ─┴─→ disconnected
|
||||
↑
|
||||
(reconnect attempt brings us back to connecting)
|
||||
```
|
||||
|
||||
Transitions:
|
||||
|
||||
- `connect(roomId)` from `idle` → `connecting` → `connected`
|
||||
- `join()` from `connected` → `joining` → `joined`
|
||||
- `leave()` from `joined` → `connected`
|
||||
- `disconnect()` from any → `disconnecting` → `disconnected`
|
||||
- Network failure from `connected`/`joined` → `disconnected` (auto-reconnect triggers)
|
||||
|
||||
Illegal transitions throw `AudioError` with code `REACT_AUDIO_STREAM.INVALID_STATE`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Auto-reconnect
|
||||
|
||||
Backed by `@suaveplan/retry` (exponential back-off with jitter, capped at 30 s). On disconnect, the session classifies the interruption (see §6), and if the class is `network` or `signalling` it auto-reconnects unless `autoReconnect: false`. `media-permission` and explicit-disconnect do not auto-reconnect. Each attempt emits `'reconnect-attempt'`; success emits `'state-changed'`; final failure emits `'reconnect-failed'`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Interruption classification
|
||||
|
||||
When a stream drops, classify to one of:
|
||||
|
||||
- `network` — ICE failure, DNS fail, no route.
|
||||
- `media-permission` — `getUserMedia` revoked, OS audio device removed.
|
||||
- `signalling` — signalling-server disconnect.
|
||||
- `peer` — peer left explicitly, or peer's media track ended.
|
||||
- `unknown` — anything else.
|
||||
|
||||
The classifier reads the underlying error / event and applies a rule set. Classification feeds both the reconnect decision and the UI toast.
|
||||
|
||||
---
|
||||
|
||||
## 7. Transport-share mode
|
||||
|
||||
In a collaborative listening session a designated peer drives transport state for everyone:
|
||||
|
||||
- Designation happens via `session.requestTransportShare(peerId)` from any client; the target peer accepts with `session.grantTransportShare()`.
|
||||
- Once granted, the local transport's `play`/`pause`/`seek`/`setTempo` events are mirrored to a control channel on each peer's `PeerChannel`.
|
||||
- Each non-driving peer's local transport receives the events and applies them; their UI controls become advisory (no-op `play`/`pause` calls).
|
||||
- A driver disconnect transfers control back to "local"; UI surfaces this via toast.
|
||||
|
||||
---
|
||||
|
||||
## 8. WebSocket transport (server-relay fallback)
|
||||
|
||||
WebRTC is the default, but corporate networks and embedded contexts often need WebSocket-relayed audio. The `WebSocketTransport` packs raw PCM frames (or opus-encoded frames if `@suaveplan/audio-codec-ffmpeg-wasm` is available) and ships them through the `network-transport-websockets`. Latency is higher than WebRTC but it works through NATs that block UDP.
|
||||
|
||||
The transport is interchangeable; the session and source plugin do not care which.
|
||||
|
||||
---
|
||||
|
||||
## 9. Decisions and trade-offs
|
||||
|
||||
- **Per-peer source plugin = uniform consumer experience.** No special-case streaming code in `<PluginChainView>`, `useParameter`, `usePreset`, etc.
|
||||
- **Transport-agnostic session.** Same code drives WebRTC, WebSocket, or Trystero.
|
||||
- **FSM session lifecycle.** Avoids race conditions from imperative `if (state === 'X') …` checks; the FSM enforces legal transitions.
|
||||
- **Interruption classification before reconnect decision.** Re-permission prompts are user-driven; we don't endlessly retry through them.
|
||||
- **Transport-share via control channel, not engine state.** The driving peer's `Transport.on('play')` listener fires a control-channel message; receivers apply it locally. No special "shared transport" type — it's the local transport, just driven remotely.
|
||||
@@ -0,0 +1,130 @@
|
||||
# OpenSpec Change: enterprise-react-audio-stream
|
||||
|
||||
- **Change ID**: enterprise-react-audio-stream
|
||||
- **Package**: `@suaveplan/react-audio-stream`
|
||||
- **Category**: react
|
||||
- **Target path**: `packages/ui/react/react-audio-stream`
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-05-15
|
||||
- **Design**: `./design.md`
|
||||
- **Spec delta**: `./specs/react-audio-stream/spec.md`
|
||||
|
||||
> Replaces the prior 2026-04-24 draft flagged `NEEDS-REWRITE`. See `../REACT_AUDIO_WAVE_REWRITE_COMPLETE.md`.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Deliver `@suaveplan/react-audio-stream` on genesis from scratch at enterprise quality. The package provides WebRTC-based real-time audio streaming for the React audio surface. It is **transport-agnostic at its core** (a `StreamSession` abstraction with peer fan-out) and ships two transport implementations: WebRTC (default) and WebSocket (server-relay fallback). Sessions are integrated with the unified architecture: a remote peer's incoming audio is materialised as an `AudioPlayerPlugin`-shaped source on a dedicated chain (so it benefits from FX, sidechain ducking, analyser readouts), and the local outgoing audio is captured via the engine's tap-to-bus surface from any track's post-FX point.
|
||||
|
||||
The package exposes:
|
||||
|
||||
1. `<AudioStreamProvider>` — owns one `StreamSession` instance per provider, nested under `<AudioPlayerProvider>` (or any `<AudioProvider>`); accepts an injectable signalling adapter (default: WebSocket signalling).
|
||||
2. Hooks: `useAudioStream(participantId)`, `usePeerStreams()`, `useStreamControls()`, `useStreamReconnect()`, `useStreamMixer()`, `useStreamInterruption()`.
|
||||
3. Components: `<RemotePeerStrip>`, `<StreamMixer>` (per-peer level/mute/solo + transport-share toggle), `<StreamHealthIndicator>` (latency, packet loss, codec), `<StreamReconnectToast>`.
|
||||
4. Auto-reconnect with exponential back-off + interruption classification (network vs media-permission vs server-disconnect).
|
||||
5. Transport-share mode: a designated peer's transport play/pause/seek events drive every connected peer's local transport so collaborative listening stays in sync.
|
||||
6. OTel instrumentation (active sessions gauge, peer-connect / disconnect counters, ICE-failure counter, RTT histogram).
|
||||
|
||||
The package owns **no DSP, no audio context, no codec implementation** — every audio surface goes through `@suaveplan/react-audio` hooks; WebRTC peer streams become first-class source plugins on dedicated chains.
|
||||
|
||||
---
|
||||
|
||||
## Current-state audit (genesis)
|
||||
|
||||
The package does not exist on genesis yet — fresh build replacing the obsolete pre-tier 2026-04-24 draft. The upstream `react/media/react-audio-stream/` is layered on the monolithic `@suaveplan/react-audio` plugin surface; this rewrite re-derives every behaviour against the unified architecture.
|
||||
|
||||
---
|
||||
|
||||
## Parity Checklist (vs upstream)
|
||||
|
||||
| Capability | Upstream | Migrated | Notes |
|
||||
|---|:---:|:---:|---|
|
||||
| `ReactAudioStream` plugin class | ✅ | ⬆️ | Replaced by the `StreamSession` abstraction + per-peer `AudioPlayerPlugin`-shaped sources. |
|
||||
| Session management | ✅ | ✅ | Now formal lifecycle: `connect`/`join`/`leave`/`disconnect` with FSM. |
|
||||
| Peer stream fan-out | ✅ | ✅ | Per-peer `MediaStream` → `MediaStreamTrack` → engine source plugin. |
|
||||
| Per-stream mixing controls | ✅ | ⬆️ | Each peer gets a track + chain in the engine; user controls = `<StreamMixer>` over `useStreamMixer`. |
|
||||
| Effect routing | ✅ | ⬆️ | Per-peer chains support any FX from `audio-fx-plugin-base`. |
|
||||
| Transport-control sharing | ✅ | ⬆️ | Single designated peer drives transport; explicit transfer-of-control. |
|
||||
| Auto-reconnect | ✅ | ✅ | Exponential back-off with cap; user-overridable. |
|
||||
| Interruption classification | ✅ | ✅ | Network / permission / signalling / peer / unknown buckets. |
|
||||
| WebSocket transport | partial | ✅ | First-class equal-peer transport. |
|
||||
| Storybook coverage | ⚠ | ✅ | Every component default + interactive + edge. |
|
||||
| Coverage | ~82 % | ✅ | 100/100/100/100. |
|
||||
|
||||
No 🚫 rows.
|
||||
|
||||
---
|
||||
|
||||
## Available workspace deps on genesis
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@suaveplan/react-audio` (`workspace:^`) | Provider + hooks |
|
||||
| `@suaveplan/audio-fx-plugin-base` (`workspace:^`) | `PluginChain`, source plugin shape |
|
||||
| `@suaveplan/audio-transport` (`workspace:^`) | Transport for share mode |
|
||||
| `@suaveplan/network-transport-websockets` (`workspace:^`) | Default WebSocket transport |
|
||||
| `@suaveplan/network-discovery-trystero` (`workspace:^`) | Optional Trystero (BitTorrent-tracker WebRTC signalling) transport |
|
||||
| `@suaveplan/fsm` (`workspace:^`) | Session lifecycle state machine |
|
||||
| `@suaveplan/retry` (`workspace:^`) | Exponential back-off for reconnect |
|
||||
| `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `@suaveplan/events` | foundations |
|
||||
| `react` (peer), `zustand`, `zod` |
|
||||
|
||||
---
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
packages/ui/react/react-audio-stream/
|
||||
src/
|
||||
index.ts
|
||||
error-codes.ts
|
||||
provider/
|
||||
audio-stream-provider/
|
||||
provider-options/
|
||||
session/
|
||||
stream-session/
|
||||
session-fsm/
|
||||
peer-channel/
|
||||
stream-source-plugin/
|
||||
interruption-classifier/
|
||||
transports/
|
||||
webrtc/
|
||||
websocket/
|
||||
trystero/ ← optional subpath
|
||||
hooks/
|
||||
use-audio-stream/
|
||||
use-peer-streams/
|
||||
use-stream-controls/
|
||||
use-stream-reconnect/
|
||||
use-stream-mixer/
|
||||
use-stream-interruption/
|
||||
use-transport-share/
|
||||
components/
|
||||
remote-peer-strip/
|
||||
stream-mixer/
|
||||
stream-health-indicator/
|
||||
stream-reconnect-toast/
|
||||
__benchmarks__/
|
||||
session-fanout.bench.ts
|
||||
tests/
|
||||
integration.test.tsx
|
||||
purity.test.ts
|
||||
exports.test.ts
|
||||
e2e/
|
||||
two-peer-sync.spec.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Impact
|
||||
|
||||
**Breaking changes**: none — fresh build.
|
||||
|
||||
**Prerequisites**: `react-audio`, `audio-fx-plugin-base`, `audio-transport`, `network-transport-websockets`, `fsm`, `retry`, foundations.
|
||||
|
||||
### Affected dependents
|
||||
|
||||
- `@suaveplan/react-audio-player` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: `<PremiumPlayer>`'s `multiPeer` option consumes this package's `use-multi-peer-sync` hook through an optional subpath; created against this spec)
|
||||
|
||||
No other dependents (verified by `bunx turbo ls --filter=...@suaveplan/react-audio-stream`).
|
||||
@@ -0,0 +1,165 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: react-audio-stream (enterprise-react-audio-stream)
|
||||
|
||||
**Package**: `@suaveplan/react-audio-stream`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED 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/stream-session.test.ts::"network interruption triggers exponential-backoff reconnect"` AND `src/session/stream-session.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/session/stream-session.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.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/exports.test.ts::"every surface has a subpath export entry"` AND `tests/bundle-size/webrtc-only.size.test.ts::"webrtc transport does not bundle trystero"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,80 @@
|
||||
# Tasks: enterprise-react-audio-stream
|
||||
|
||||
**Change ID**: enterprise-react-audio-stream
|
||||
**Package**: `@suaveplan/react-audio-stream`
|
||||
**Target path**: `packages/ui/react/react-audio-stream`
|
||||
**Prerequisite packages on Verdaccio**: `@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`
|
||||
|
||||
---
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [ ] 1.1 Directory tree per proposal.
|
||||
- [ ] 1.2 `package.json` at `0.1.0`, `"sideEffects": false`, full subpath exports. React 19 peer; `@suaveplan/network-discovery-trystero` optional peer gated by `./transports/trystero`.
|
||||
- [ ] 1.3 `tsconfig.json`, `vitest.config.ts` (`happy-dom`).
|
||||
|
||||
## 2. Error codes
|
||||
|
||||
- [ ] 2.1 `src/error-codes.ts` — `defineCodes("REACT_AUDIO_STREAM", [...])`, `ReactAudioStreamError extends AudioError`.
|
||||
- [ ] 2.2 `error-codes.test.ts` — every code reachable.
|
||||
|
||||
## 3. Session core
|
||||
|
||||
- [ ] 3.1 `src/session/session-fsm/` — `@suaveplan/fsm`-driven FSM with the 7-state lifecycle.
|
||||
- [ ] 3.2 `src/session/stream-session/` — `StreamSession` class wrapping FSM + transport adapter + peer registry + control channel.
|
||||
- [ ] 3.3 `src/session/peer-channel/` — per-peer state and control-message wiring.
|
||||
- [ ] 3.4 `src/session/stream-source-plugin/` — `AudioFxPlugin` implementation that wraps a `MediaStream` as a source plugin (`io.inputs[0].count === 0`).
|
||||
- [ ] 3.5 `src/session/interruption-classifier/` — deterministic mapping from underlying cause to one of 5 classes.
|
||||
- [ ] 3.6 `session.test.ts` covering the entire FSM, peer add / remove, interruption classification, reconnect.
|
||||
|
||||
## 4. Transports
|
||||
|
||||
- [ ] 4.1 `src/transports/webrtc/` — `WebRTCTransport` using browser RTCPeerConnection + RTCDataChannel (for control + transport-share).
|
||||
- [ ] 4.2 `src/transports/websocket/` — `WebSocketTransport` using `@suaveplan/network-transport-websockets`; PCM or opus frames per option.
|
||||
- [ ] 4.3 `src/transports/trystero/` — optional subpath wrapping `@suaveplan/network-discovery-trystero`.
|
||||
|
||||
## 5. Provider + hooks
|
||||
|
||||
- [ ] 5.1 `src/provider/audio-stream-provider.tsx` — owns one `StreamSession` per provider, injects transport adapter, strict-mode safe lifecycle, dispose on unmount.
|
||||
- [ ] 5.2 `use-audio-stream(participantId)`.
|
||||
- [ ] 5.3 `use-peer-streams()`.
|
||||
- [ ] 5.4 `use-stream-controls()`.
|
||||
- [ ] 5.5 `use-stream-reconnect()` — exposes reconnect attempts + last error.
|
||||
- [ ] 5.6 `use-stream-mixer()` — per-peer level/mute/solo + share toggle, delegating to the peer chain.
|
||||
- [ ] 5.7 `use-stream-interruption()` — last interruption + class.
|
||||
- [ ] 5.8 `use-transport-share()` — request/grant/revoke + share status.
|
||||
|
||||
## 6. Components (each: `.tsx`, `.md`, `.test.tsx`, `.stories.tsx`)
|
||||
|
||||
- [ ] 6.1 `remote-peer-strip/`.
|
||||
- [ ] 6.2 `stream-mixer/` — DAW solo semantics across peers.
|
||||
- [ ] 6.3 `stream-health-indicator/` — latency, packet loss, codec.
|
||||
- [ ] 6.4 `stream-reconnect-toast/`.
|
||||
|
||||
## 7. OTel
|
||||
|
||||
- [ ] 7.1 Register all counters / gauge / histogram; wire instrumentation throughout lifecycle.
|
||||
|
||||
## 8. Benchmarks
|
||||
|
||||
- [ ] 8.1 `src/__benchmarks__/session-fanout.bench.ts` — 32 peers connected simultaneously.
|
||||
|
||||
## 9. Tests
|
||||
|
||||
- [ ] 9.1 `tests/integration.test.tsx` — two peers, one becomes driver, transport share works, network failure auto-reconnects.
|
||||
- [ ] 9.2 `tests/purity.test.ts` — built `dist/` references no audio context.
|
||||
- [ ] 9.3 `tests/exports.test.ts` — every surface subpath; trystero gated correctly.
|
||||
- [ ] 9.4 `tests/bundle-size/{root-only,webrtc-only,trystero-only}.size.test.ts`.
|
||||
- [ ] 9.5 `e2e/two-peer-sync.spec.ts` — Playwright two-browser test with a real signalling server.
|
||||
|
||||
## 10. Docs + verify
|
||||
|
||||
- [ ] 10.1 All co-located `.md` (≥200 words).
|
||||
- [ ] 10.2 `README.md` at package root with quick-start, transport-share example, FX-on-peer example.
|
||||
- [ ] 10.3 `bunx turbo lint typecheck test --filter=@suaveplan/react-audio-stream` green; coverage 100/100/100/100.
|
||||
|
||||
## 11. Spec reconciliation + archive
|
||||
|
||||
- [ ] 11.1 Walk every `### Requirement` and `**Verified by:**`.
|
||||
- [ ] 11.2 Fold spec delta into `openspec/specs/react-audio-stream/spec.md`.
|
||||
- [ ] 11.3 Move change folder to archive.
|
||||
Reference in New Issue
Block a user