first commit
This commit is contained in:
132
docs/ui/react/react-audio-stream/README.md
Normal file
132
docs/ui/react/react-audio-stream/README.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# @suaveplan/react-audio-stream - Stream-tier React surface for the Suaveplan audio family — session-core foundation (chunk A). Provides StreamSession (FSM lifecycle), InterruptionClassifier, StreamSourcePlugin, error codes, and OTel instruments. Transports, hooks, components, and transport-share land in chunks B/C/D.
|
||||
|
||||
React 19 surface for live audio streaming sessions: a Provider, six hooks, and four
|
||||
accessible components. Chunk C of the four-chunk `enterprise-react-audio-stream` split.
|
||||
|
||||
> Chunks A (session-core) and B (transports) deliver the headless engine. Chunk D
|
||||
> delivers `useTransportShare` and its banner. This chunk delivers the React surface
|
||||
> consumers actually touch.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [shared-transport-indicator](./src/components/shared-transport-indicator/shared-transport-indicator.md) — `SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'re…
|
||||
- [shared-transport-indicator](./src/components/shared-transport-indicator/shared-transport-indicator.md) — `SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'re…
|
||||
- [stream-mixer](./src/components/stream-mixer/stream-mixer.md) — `<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a mute toggle, a solo toggle, and a…
|
||||
- [stream-mixer](./src/components/stream-mixer/stream-mixer.md) — `<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a mute toggle, a solo toggle, and a…
|
||||
- [stream-permissions-banner](./src/components/stream-permissions-banner/stream-permissions-banner.md) — `<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It renders a `role="status"` ARIA-l…
|
||||
- [stream-permissions-banner](./src/components/stream-permissions-banner/stream-permissions-banner.md) — `<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It renders a `role="status"` ARIA-l…
|
||||
- [stream-recorder](./src/components/stream-recorder/stream-recorder.md) — `<StreamRecorder>` is a record/stop control with a live duration display. It delegates recording lifecycle to `useStrea…
|
||||
- [stream-recorder](./src/components/stream-recorder/stream-recorder.md) — `<StreamRecorder>` is a record/stop control with a live duration display. It delegates recording lifecycle to `useStrea…
|
||||
- [stream-room](./src/components/stream-room/stream-room.md) — `<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup of its own — instead it expose…
|
||||
- [stream-room](./src/components/stream-room/stream-room.md) — `<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup of its own — instead it expose…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module is the canonical source of truth for every failure mode published by `@suaveplan/react-audio-str…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module is the canonical source of truth for every failure mode published by `@suaveplan/react-audio-str…
|
||||
- [internal](./src/hooks/internal/use-stream-context.md) — Internal helper consumed by every public chunk-C hook. It reads the `StreamReactContext`, returns the non-null `{ sessi…
|
||||
- [use-stream-context](./src/hooks/internal/use-stream-context.md) — Internal helper consumed by every public chunk-C hook. It reads the `StreamReactContext`, returns the non-null `{ sessi…
|
||||
- [use-stream-levels](./src/hooks/use-stream-levels/use-stream-levels.md) — `useStreamLevels` projects realtime peak/RMS samples out of the session. Called without an argument it returns the loca…
|
||||
- [use-stream-levels](./src/hooks/use-stream-levels/use-stream-levels.md) — `useStreamLevels` projects realtime peak/RMS samples out of the session. Called without an argument it returns the loca…
|
||||
- [use-stream-mixer](./src/hooks/use-stream-mixer/use-stream-mixer.md) — `useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer FX slot count — and implements D…
|
||||
- [use-stream-mixer](./src/hooks/use-stream-mixer/use-stream-mixer.md) — `useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer FX slot count — and implements D…
|
||||
- [use-stream-peers](./src/hooks/use-stream-peers/use-stream-peers.md) — `useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each peer entry carries the partici…
|
||||
- [use-stream-peers](./src/hooks/use-stream-peers/use-stream-peers.md) — `useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each peer entry carries the partici…
|
||||
- [use-stream-permissions](./src/hooks/use-stream-permissions/use-stream-permissions.md) — `useStreamPermissions` exposes the microphone permission state with the typed `MediaPermissionState` enum (`"granted" |…
|
||||
- [use-stream-permissions](./src/hooks/use-stream-permissions/use-stream-permissions.md) — `useStreamPermissions` exposes the microphone permission state with the typed `MediaPermissionState` enum (`"granted" |…
|
||||
- [use-stream-recording](./src/hooks/use-stream-recording/use-stream-recording.md) — `useStreamRecording` exposes the start/stop controls for capturing a session as a single audio file. The hook delegates…
|
||||
- [use-stream-recording](./src/hooks/use-stream-recording/use-stream-recording.md) — `useStreamRecording` exposes the start/stop controls for capturing a session as a single audio file. The hook delegates…
|
||||
- [use-stream-room](./src/hooks/use-stream-room/use-stream-room.md) — `useStreamRoom` projects the current room scope out of the session: the room id (or `null` when the session is not conn…
|
||||
- [use-stream-room](./src/hooks/use-stream-room/use-stream-room.md) — `useStreamRoom` projects the current room scope out of the session: the room id (or `null` when the session is not conn…
|
||||
- [use-stream-session](./src/hooks/use-stream-session/use-stream-session.md) — `useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live `StreamSession` instance, its cur…
|
||||
- [use-stream-session](./src/hooks/use-stream-session/use-stream-session.md) — `useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live `StreamSession` instance, its cur…
|
||||
- [use-transport-share](./src/hooks/use-transport-share/use-transport-share.md) — `useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator…
|
||||
- [use-transport-share](./src/hooks/use-transport-share/use-transport-share.md) — `useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator…
|
||||
- [stream-context](./src/provider/stream-context.md) — `StreamReactContext` is the single React context that publishes the live `StreamSession` to every chunk-C hook and comp…
|
||||
- [stream-provider](./src/provider/stream-provider.md) — `<StreamProvider>` is the singleton React surface that owns the live `StreamSession` for a tree. It calls the caller-su…
|
||||
- [session](./src/session/reconnect.md) — The reconnect module owns the auto-reconnect lifecycle that `StreamSession` runs after a disconnect interruption. It wr…
|
||||
- [interruption-classifier](./src/session/interruption-classifier/interruption-classifier.md) — `InterruptionClassifier` maps a structural disconnect cause to exactly one of `network | media-permission | signalling…
|
||||
- [interruption-classifier](./src/session/interruption-classifier/interruption-classifier.md) — `InterruptionClassifier` maps a structural disconnect cause to exactly one of `network | media-permission | signalling…
|
||||
- [session-fsm](./src/session/session-fsm/session-fsm.md) — The `session-fsm` module declares the canonical seven-state finite state machine that drives every `StreamSession`. Bui…
|
||||
- [session-fsm](./src/session/session-fsm/session-fsm.md) — The `session-fsm` module declares the canonical seven-state finite state machine that drives every `StreamSession`. Bui…
|
||||
- [stream-session](./src/session/stream-session/stream-session.md) — `StreamSession` is the public face of the stream-tier session core. It wraps the FSM, the transport adapter, the interr…
|
||||
- [stream-session](./src/session/stream-session/stream-session.md) — `StreamSession` is the public face of the stream-tier session core. It wraps the FSM, the transport adapter, the interr…
|
||||
- [stream-source-plugin](./src/session/stream-source-plugin/stream-source-plugin.md) — `StreamSourcePlugin` is the bridge that lets a remote peer's `MediaStream` participate in the audio engine's plugin-cha…
|
||||
- [stream-source-plugin](./src/session/stream-source-plugin/stream-source-plugin.md) — `StreamSourcePlugin` is the bridge that lets a remote peer's `MediaStream` participate in the audio engine's plugin-cha…
|
||||
- [reconnect](./src/session/reconnect.md) — The reconnect module owns the auto-reconnect lifecycle that `StreamSession` runs after a disconnect interruption. It wr…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — The telemetry module owns every OpenTelemetry instrument published by `@suaveplan/react-audio-stream`. Concentrating th…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — The telemetry module owns every OpenTelemetry instrument published by `@suaveplan/react-audio-stream`. Concentrating th…
|
||||
- [control-channel](./src/transport-share/control-channel.md) — The control-channel module is the wire-protocol heart of transport sharing. It defines the envelope shapes that flow be…
|
||||
- [share-state](./src/transport-share/share-state.md) — The share-state module owns the role-machine that decides whether a peer is in `'local'`, `'driving'`, or `'receiving'`…
|
||||
- [transports](./src/transports/transport.md) — The `transport.types.ts` module declares the structural contract that `StreamSession` (authored in chunk A of the `@sua…
|
||||
- [trystero](./src/transports/trystero/trystero-transport.md) — `TrysteroTransport` is the serverless `StreamTransportAdapter` powered by `@suaveplan/network-discovery-trystero`. It p…
|
||||
- [trystero-transport](./src/transports/trystero/trystero-transport.md) — `TrysteroTransport` is the serverless `StreamTransportAdapter` powered by `@suaveplan/network-discovery-trystero`. It p…
|
||||
- [webrtc](./src/transports/webrtc/webrtc-transport.md) — `WebRtcTransport` is the low-latency default `StreamTransportAdapter` for `@suaveplan/react-audio-stream`. It wires a c…
|
||||
- [webrtc-transport](./src/transports/webrtc/webrtc-transport.md) — `WebRtcTransport` is the low-latency default `StreamTransportAdapter` for `@suaveplan/react-audio-stream`. It wires a c…
|
||||
- [websocket](./src/transports/websocket/websocket-transport.md) — `WebSocketTransport` is the fallback `StreamTransportAdapter` consumers choose when a corporate or carrier NAT blocks W…
|
||||
- [websocket-transport](./src/transports/websocket/websocket-transport.md) — `WebSocketTransport` is the fallback `StreamTransportAdapter` consumers choose when a corporate or carrier NAT blocks W…
|
||||
- [transport](./src/transports/transport.md) — The `transport.types.ts` module declares the structural contract that `StreamSession` (authored in chunk A of the `@sua…
|
||||
- [test-setup](./src/test-setup.md) — `test-setup.ts` is the per-worker bootstrap file referenced by `vitest.config.ts`'s `setupFiles`. It registers `@testin…
|
||||
- [test-utils](./src/test-utils.md) — `test-utils.tsx` provides the in-memory `FakeSession` and the `withProvider` wrapper used by every chunk-C hook + compo…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Provider
|
||||
|
||||
- `<StreamProvider sessionFactory={…}>` — StrictMode-safe singleton; injects a
|
||||
`StreamSession` lazily; fans seven session event channels into a single render-bump
|
||||
counter; disconnects on unmount.
|
||||
- `StreamReactContext` — exposed for advanced consumers that need raw context access.
|
||||
- Type-only mirrors (`SessionState`, `Peer`, `PeerMixerState`, `MediaPermissionState`,
|
||||
`LevelSample`, `RecordingResult`, `StreamSessionFactory`, `StreamSessionLike`,
|
||||
`SessionEventMap`, `ReactAudioStreamErrorLike`) for downstream typing.
|
||||
|
||||
## Hooks
|
||||
|
||||
- `useStreamSession()` — session, FSM state, and `connect` / `join` / `leave` /
|
||||
`disconnect`.
|
||||
- `useStreamRoom()` — `roomId`, `isJoined`, `joinedAt`, `participants`.
|
||||
- `useStreamPeers()` — live `readonly Peer[]`.
|
||||
- `useStreamMixer()` — per-peer `setVolume` / `setMute` / `setSolo` with DAW
|
||||
solo semantics (when any peer is soloed, every non-soloed peer's track-volume → 0).
|
||||
Every mutation routes through the session's `setTrackVolume` / `setTrackMute` /
|
||||
`setTrackSolo`.
|
||||
- `useStreamLevels(peerId?)` — `{ peak, rms }` for the local mic or named peer.
|
||||
- `useStreamRecording()` — `start(peerIds?)` and `stop()` returning a
|
||||
`RecordingResult` blob.
|
||||
- `useStreamPermissions()` — typed `MediaPermissionState` with `request()` callback.
|
||||
|
||||
## Components
|
||||
|
||||
- `<StreamRoom>` — headless render-prop wrapper exposing the room snapshot plus join /
|
||||
leave callbacks.
|
||||
- `<StreamMixer>` — per-peer mixer strip with range slider, `aria-pressed`
|
||||
mute/solo toggles, FX-slot read-out; supports `renderStrip` render-prop.
|
||||
- `<StreamRecorder>` — record/stop toggle with `mm:ss` duration display and ARIA-live
|
||||
read-out.
|
||||
- `<StreamPermissionsBanner>` — `role="status"` `aria-live="polite"` banner; renders
|
||||
nothing when permission is granted.
|
||||
|
||||
## Quick start
|
||||
|
||||
```tsx
|
||||
import { StreamProvider, useStreamSession } from "@suaveplan/react-audio-stream";
|
||||
|
||||
function Room() {
|
||||
const { state, connect, join } = useStreamSession();
|
||||
if (state === "idle") return <button onClick={() => connect("lobby").then(join)}>Join</button>;
|
||||
return <p>Joined!</p>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<StreamProvider sessionFactory={() => createMyStreamSession()}>
|
||||
<Room />
|
||||
</StreamProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Constraints (from the canonical spec)
|
||||
|
||||
- Zero browser audio APIs: no `AudioContext` / `AudioNode` / `AudioWorklet` imports.
|
||||
- DAW solo semantics live in the mixer hook, not in each component.
|
||||
- Every hook throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# SharedTransportIndicator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'receiving'` mode. The component is intentionally presentational — it consumes a `ShareState` snapshot from the caller (typically `useTransportShare().state`) and renders nothing when no share is active.
|
||||
|
||||
Decoupling the visual from the hook lets consumers compose the indicator anywhere in their layout: in a status bar, inside a mixer panel, as part of a notifications drawer, or even pinned next to a specific peer's avatar. Because the component reads its data from props, it is also straightforward to test without wiring up a session or a coordinator.
|
||||
|
||||
## Features
|
||||
|
||||
- Renders only when the local share role matches the configured `visibleRoles` (default `['receiving']`).
|
||||
- Carries `aria-live="polite"` and `role="status"` so screen readers announce share-state transitions politely.
|
||||
- `data-share-role` and `data-driver-id` attributes for selector-based styling.
|
||||
- Optional `label` override (string or any `ReactNode`).
|
||||
- Optional `className` for layout integration.
|
||||
- Spec-aligned default label: `"Shared transport active"`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useTransportShare } from "@suaveplan/react-audio-stream/hooks/use-transport-share";
|
||||
import { SharedTransportIndicator } from "@suaveplan/react-audio-stream/components/shared-transport-indicator";
|
||||
|
||||
function StatusBar(props: HostProps) {
|
||||
const share = useTransportShare(props);
|
||||
return <SharedTransportIndicator state={share.state} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
function MixerHeader(props: HostProps) {
|
||||
const share = useTransportShare(props);
|
||||
return (
|
||||
<header className="mixer-header">
|
||||
<SharedTransportIndicator
|
||||
state={share.state}
|
||||
className="mixer-header__share-badge"
|
||||
visibleRoles={["driving", "receiving"]}
|
||||
label={
|
||||
share.state.role === "driving"
|
||||
? `Driving ${share.state.receivers.length} peer(s)`
|
||||
: `Receiving from ${share.state.driverId}`
|
||||
}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SharedTransportIndicator(props): ReactNode`
|
||||
- `SharedTransportIndicatorProps`
|
||||
- `state: ShareState` — required.
|
||||
- `className?: string` — optional root class.
|
||||
- `label?: ReactNode` — optional label override.
|
||||
- `visibleRoles?: ReadonlyArray<ShareRole>` — defaults to `['receiving']`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component does not memoise its rendered output — it is a thin function-of-props with no internal state, so React's default reconciliation already short-circuits when the parent passes referentially-stable props. The `data-driver-id` attribute defaults to `""` when the driver identity is unknown so consumers can rely on the attribute always being present. The default label is exposed as a private constant rather than a JSX literal so the component remains testable with a single equality check, and the `visibleRoles` default is a frozen `as const` tuple to avoid accidental mutation by consumers.
|
||||
@@ -0,0 +1,63 @@
|
||||
# stream-mixer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a
|
||||
mute toggle, a solo toggle, and a read-only FX-slot-count indicator. The component is
|
||||
the visible counterpart to `useStreamMixer()` — both surfaces are required by
|
||||
Requirement 6 ("Per-peer stream mixer") and the component's solo button proves the
|
||||
DAW-semantics test case from the spec.
|
||||
|
||||
Markup is intentionally minimal and unstyled — every interactive control carries an
|
||||
`aria-label` derived from the peer id so screen readers announce the right peer, and
|
||||
the toggles use `aria-pressed` for accessibility. Consumers who want richer chrome pass
|
||||
a `renderStrip` render-prop and own the layout entirely.
|
||||
|
||||
## Features
|
||||
|
||||
- Default markup with `range` slider, `aria-pressed` mute/solo toggles, FX-slot count.
|
||||
- `renderStrip` render-prop for custom layouts (e.g. shadcn cards, drag handles).
|
||||
- All mutations route through `useStreamMixer()` — DAW solo semantics inherited
|
||||
unchanged.
|
||||
- Group `role="group"` with consumer-supplied `aria-label` for screen readers.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<StreamMixer label="Live room mix" />
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<StreamMixer
|
||||
label="Studio mix"
|
||||
renderStrip={(peer, { setVolume, setMute, setSolo }) => (
|
||||
<PeerStrip
|
||||
peer={peer}
|
||||
onVolume={setVolume}
|
||||
onMute={() => setMute(!peer.muted)}
|
||||
onSolo={() => setSolo(!peer.soloed)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamMixerProps` — `{ label?: string; renderStrip?: (peer, controls) => ReactNode }`
|
||||
- `StreamMixer(props): ReactNode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `aria-pressed` attribute is set on both the mute and solo buttons because they are
|
||||
two-state toggles — this matches WAI-ARIA's authoring practice for toggle buttons and
|
||||
allows assistive tech to announce the current state without parsing the button label.
|
||||
The range slider's `value` is bound to the live `peer.volume` so it stays in sync with
|
||||
ducking caused by other peers' solo state (per Requirement 6: when any peer is soloed,
|
||||
non-soloed peers' track volumes go to 0, and the slider reflects that).
|
||||
@@ -0,0 +1,62 @@
|
||||
# stream-permissions-banner
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It
|
||||
renders a `role="status"` ARIA-live region with copy reflecting the current microphone
|
||||
permission state and an explicit request button when the state is actionable
|
||||
(`"prompt"` or `"unknown"`). When the user has already granted access, the banner
|
||||
renders nothing so the room UI is unobstructed.
|
||||
|
||||
The banner is intentionally opinionated about accessibility — `aria-live="polite"`
|
||||
ensures screen readers announce state transitions without interrupting other speech —
|
||||
but stays unopinionated about styling. The default copy is overridable on a
|
||||
per-state basis via the `messages` prop.
|
||||
|
||||
## Features
|
||||
|
||||
- ARIA-live `role="status"` with `aria-live="polite"`.
|
||||
- Per-state copy with sensible defaults (`granted` / `denied` / `prompt` / `unknown`).
|
||||
- Request button rendered only when the state is `"prompt"` or `"unknown"`.
|
||||
- `onResolved` callback invoked with the next state after `request()` settles.
|
||||
- Renders `null` when permission is already granted.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<StreamPermissionsBanner />
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<StreamPermissionsBanner
|
||||
requestLabel="Allow microphone"
|
||||
messages={{
|
||||
denied: "Microphone blocked. Open site settings to allow access.",
|
||||
prompt: "We use your mic to stream live audio to other participants.",
|
||||
}}
|
||||
onResolved={(state) => {
|
||||
if (state === "granted") void session.connect("lobby").then(() => session.join());
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamPermissionsBannerProps` — `{ messages?, requestLabel?, onResolved? }`
|
||||
- `StreamPermissionsBanner(props): ReactNode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `data-state` attribute on the root element mirrors the `MediaPermissionState`
|
||||
string so consumers can target it via CSS attribute selectors (e.g.
|
||||
`[data-state="denied"] { color: red; }`) without re-deriving the state in JS. The
|
||||
request button renders only on `"prompt"` and `"unknown"` because `"denied"` cannot be
|
||||
escalated back to `"prompt"` through `getUserMedia` alone — the user must change
|
||||
browser-level settings, which the copy explicitly tells them.
|
||||
@@ -0,0 +1,60 @@
|
||||
# stream-recorder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamRecorder>` is a record/stop control with a live duration display. It delegates
|
||||
recording lifecycle to `useStreamRecording()` and adds two pieces of presentation logic:
|
||||
an interval-driven duration counter that ticks at 4 Hz while recording, and an
|
||||
ARIA-live duration read-out so screen-reader users hear updates. When recording stops,
|
||||
the component invokes the optional `onComplete` callback with the full `RecordingResult`
|
||||
(blob + timing metadata) so consumers can upload, download, or transcribe the capture.
|
||||
|
||||
The component supports the optional `peerIds` filter — passing it forwards the list to
|
||||
`start()` so only the named peers (plus the local mic) are mixed into the resulting
|
||||
blob. This makes the same component usable for "record the whole room" and
|
||||
"record only the guest" flows without branching the consumer's code.
|
||||
|
||||
## Features
|
||||
|
||||
- Toggle button with `aria-pressed` for the current state.
|
||||
- ARIA-live duration string formatted as `mm:ss`.
|
||||
- 4 Hz interval, cleaned up on unmount and on `isRecording=false`.
|
||||
- `onComplete` callback receives the full `RecordingResult` after `stop()` resolves.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<StreamRecorder onComplete={(r) => download(r.blob)} />
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const guests = useStreamPeers().filter((p) => p.id !== "host").map((p) => p.id);
|
||||
<StreamRecorder
|
||||
peerIds={guests}
|
||||
onComplete={async ({ blob, durationMs }) => {
|
||||
await fetch("/api/interviews", { method: "POST", body: blob });
|
||||
analytics.track("interview_recorded", { durationMs });
|
||||
}}
|
||||
/>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamRecorderProps` — `{ onComplete?, peerIds?, label? }`
|
||||
- `StreamRecorder(props): ReactNode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The duration counter uses `Date.now()` rather than accumulating `setInterval` ticks
|
||||
because intervals drift; sampling wall-clock time keeps the display in sync with the
|
||||
underlying recording timeline. On stop the component snaps the displayed duration to
|
||||
the precise `durationMs` returned by the session, which is the authoritative value
|
||||
(the session's offline render runs at a fixed sample rate). The interval is cleared in
|
||||
the effect cleanup, so re-mount under StrictMode does not leak a timer.
|
||||
@@ -0,0 +1,72 @@
|
||||
# stream-room
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup
|
||||
of its own — instead it exposes the live room snapshot (`roomId`, `isJoined`,
|
||||
`participants`, `state`) and the `join` / `leave` callbacks via a render-prop. This
|
||||
keeps presentation decisions (layout, theming, copy, animations) entirely in the
|
||||
consumer's hands while the audio-stream package owns the data flow.
|
||||
|
||||
The render-prop pattern was chosen over compound components because the room scope is
|
||||
trivially small (six fields, two callbacks) and a single function child is the smallest
|
||||
possible surface that does not lock consumers into a particular DOM structure.
|
||||
|
||||
## Features
|
||||
|
||||
- Headless: no DOM, no classes, no inline styles.
|
||||
- Render-prop returns the room snapshot and lifecycle callbacks.
|
||||
- Re-renders on every relevant session event via the underlying hooks.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when rendered outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
<StreamRoom>
|
||||
{({ roomId, isJoined, join, leave }) =>
|
||||
isJoined ? (
|
||||
<button onClick={leave}>Leave {roomId}</button>
|
||||
) : (
|
||||
<button onClick={join}>Join room</button>
|
||||
)
|
||||
}
|
||||
</StreamRoom>
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
<StreamRoom>
|
||||
{({ roomId, participants, state, join, leave }) => (
|
||||
<section aria-busy={state === "joining" || state === "leaving"}>
|
||||
<h2>{roomId ?? "no room"}</h2>
|
||||
<ul>
|
||||
{participants.map((p) => (
|
||||
<li key={p.id}>{p.displayName}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div role="group">
|
||||
<button disabled={state !== "connected"} onClick={join}>Join</button>
|
||||
<button disabled={state !== "joined"} onClick={leave}>Leave</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</StreamRoom>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamRoomRenderProps` — `{ roomId, isJoined, participants, join, leave, state }`
|
||||
- `StreamRoomProps` — `{ children: (rp: StreamRoomRenderProps) => ReactNode }`
|
||||
- `StreamRoom(props): ReactNode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`state` is forwarded from `useStreamSession()` so consumers can disable buttons during
|
||||
`"joining"` / `"leaving"` without re-deriving the FSM state themselves. The component
|
||||
delegates to two narrow hooks rather than reading the context directly, which keeps the
|
||||
render-prop forwarding cost at exactly two memoised hook calls per render.
|
||||
104
docs/ui/react/react-audio-stream/src/error-codes/error-codes.md
Normal file
104
docs/ui/react/react-audio-stream/src/error-codes/error-codes.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The error-codes module is the canonical source of truth for every
|
||||
failure mode published by `@suaveplan/react-audio-stream`. It declares
|
||||
the seven dotted codes under the `REACT_AUDIO_STREAM.` namespace via
|
||||
`defineCodes`, the frozen metadata table, and the `ReactAudioStreamError`
|
||||
class — every error thrown by the package extends this class and carries
|
||||
one of these codes. Consumers discriminate failures with `instanceof
|
||||
ReactAudioStreamError` or by matching on the stable `error.code` string
|
||||
without needing to know which sub-feature originated the throw.
|
||||
|
||||
The codes line up one-to-one with the Requirement 9 surface of the
|
||||
parent OpenSpec change so the human-authored spec can be cross-checked
|
||||
against runtime reality: a test asserts every entry in
|
||||
`REACT_AUDIO_STREAM_ERROR_METADATA` is reachable from at least one
|
||||
production call site.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven stable, lexically sorted codes: `NO_PROVIDER`, `INVALID_STATE`,
|
||||
`SIGNALLING_FAILED`, `PEER_NOT_FOUND`, `MEDIA_PERMISSION_DENIED`,
|
||||
`RECONNECT_EXHAUSTED`, `INVALID_OPTIONS`.
|
||||
- `ReactAudioStreamError extends SuaveplanError` — inherits structured
|
||||
context, cause-chain support, and serialization to the shared wire
|
||||
format.
|
||||
- Idempotent `registerReactAudioStreamErrorCodes()` registration hook
|
||||
for symmetry with sibling Suaveplan packages.
|
||||
- `Object.freeze`-d metadata table — mutation attempts throw in strict
|
||||
mode and a test guards the invariant.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
ReactAudioStreamError,
|
||||
} from "@suaveplan/react-audio-stream/error-codes";
|
||||
|
||||
throw new ReactAudioStreamError(
|
||||
REACT_AUDIO_STREAM_CODES.NO_PROVIDER,
|
||||
"react-audio-stream.no-provider",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_STREAM_CODES,
|
||||
REACT_AUDIO_STREAM_ERROR_METADATA,
|
||||
ReactAudioStreamError,
|
||||
} from "@suaveplan/react-audio-stream/error-codes";
|
||||
|
||||
try {
|
||||
await session.join();
|
||||
} catch (err) {
|
||||
if (err instanceof ReactAudioStreamError) {
|
||||
const meta = REACT_AUDIO_STREAM_ERROR_METADATA[err.code];
|
||||
logger.warn({
|
||||
code: err.code,
|
||||
severity: meta.severity,
|
||||
description: meta.description,
|
||||
context: err.context,
|
||||
}, "stream session failure");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `REACT_AUDIO_STREAM_CODES` — frozen object literal keyed by short
|
||||
code, value is the dotted string (`"REACT_AUDIO_STREAM.NO_PROVIDER"`).
|
||||
- `ReactAudioStreamErrorCode` — derived union of every value in the
|
||||
code registry.
|
||||
- `ReactAudioStreamErrorMetadata` — `{ description, severity }` pair
|
||||
per code.
|
||||
- `REACT_AUDIO_STREAM_ERROR_METADATA` — `Record<ReactAudioStreamErrorCode,
|
||||
ReactAudioStreamErrorMetadata>`, `Object.freeze`-d at module load.
|
||||
- `ReactAudioStreamError` — `SuaveplanError<ReactAudioStreamErrorCode>`
|
||||
subclass; `category` is the literal `"react-audio-stream"`.
|
||||
- `registerReactAudioStreamErrorCodes(): void` — idempotent no-op
|
||||
retained for parity with sibling registries.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` literally typesetting the runtime values means the
|
||||
`ReactAudioStreamErrorCode` union is derived without a second
|
||||
hand-maintained declaration. Adding a code requires only an entry in
|
||||
both `REACT_AUDIO_STREAM_CODES` and `REACT_AUDIO_STREAM_ERROR_METADATA`
|
||||
— the metadata test fails otherwise. Severity is currently `error` on
|
||||
every entry; the field exists so future warning-class diagnostics
|
||||
(deprecated transport probes, lossy fallbacks) slot in without a
|
||||
schema change. The class extends the foundation `SuaveplanError`
|
||||
directly — the parent OpenSpec change references an `AudioError` from
|
||||
`@suaveplan/audio-core` but the chunk-A dep allowlist excludes that
|
||||
package, so we follow the same pattern as sibling `@suaveplan/react-audio`
|
||||
which also extends the foundation base.
|
||||
@@ -0,0 +1,59 @@
|
||||
# use-stream-context (internal)
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Internal helper consumed by every public chunk-C hook. It reads the
|
||||
`StreamReactContext`, returns the non-null `{ session, version }` container, and throws a
|
||||
structured `REACT_AUDIO_STREAM.NO_PROVIDER`-coded error when the hook ran outside any
|
||||
`<StreamProvider>`. Centralising the null check in one place guarantees every hook in
|
||||
the package surfaces the same code string and the same exact message wording, which
|
||||
keeps consumer-facing error handling consistent.
|
||||
|
||||
The function is intentionally not exported from the package root — public hooks
|
||||
re-export their own narrow surface area and consumers never see this helper directly.
|
||||
|
||||
## Features
|
||||
|
||||
- Single source of truth for the `NO_PROVIDER` error.
|
||||
- Attaches a stable `code` property to the thrown `Error` so tests can switch on the
|
||||
code without parsing the message.
|
||||
- Returns the typed `StreamContextValue` (no `| null`) so downstream hooks need no
|
||||
defensive guards.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { useStreamContextOrThrow } from "../internal/use-stream-context.js";
|
||||
|
||||
export function useStreamSession() {
|
||||
const { session } = useStreamContextOrThrow();
|
||||
return session;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
try {
|
||||
useStreamContextOrThrow();
|
||||
} catch (e) {
|
||||
if ((e as { code?: string }).code === "REACT_AUDIO_STREAM.NO_PROVIDER") {
|
||||
// Render a fallback UI explaining the missing provider.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useStreamContextOrThrow(): StreamContextValue` — throws `Error & { code: "REACT_AUDIO_STREAM.NO_PROVIDER" }` when no provider is present.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The error is constructed as a plain `Error` (rather than the canonical
|
||||
`ReactAudioStreamError` from chunk A) so this module stays dependency-free relative to
|
||||
the session-core package. Chunk A's full `ReactAudioStreamError` class will subsume this
|
||||
shape on integration; the `code` property name is identical so the swap is mechanical.
|
||||
@@ -0,0 +1,54 @@
|
||||
# use-stream-levels
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamLevels` projects realtime peak/RMS samples out of the session. Called without
|
||||
an argument it returns the local-microphone level; called with a peer id it returns the
|
||||
named peer's level. The level numbers come from chunks A/B's analyzer slot (which lives
|
||||
on each peer's plugin chain alongside the `StreamSourcePlugin`), and this hook is a
|
||||
zero-state projection — it does not compute anything itself.
|
||||
|
||||
The reading is intentionally synchronous: the provider's render-bump version is what
|
||||
schedules re-renders, and the session is responsible for batching analyzer ticks into
|
||||
the `levels-changed` event channel at a sane cadence (typically 60 Hz).
|
||||
|
||||
## Features
|
||||
|
||||
- Same hook for local and per-peer levels — the `peerId?` argument selects.
|
||||
- Re-renders on every `levels-changed` event via the provider's version counter.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { peak, rms } = useStreamLevels();
|
||||
return <LevelMeter peak={peak} rms={rms} />;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const peers = useStreamPeers();
|
||||
return peers.map((p) => {
|
||||
const { peak } = useStreamLevels(p.id);
|
||||
return <PeerLevelBar key={p.id} peerId={p.id} peak={peak} />;
|
||||
});
|
||||
```
|
||||
|
||||
(Real apps should hoist the per-peer call into a child component to keep the hook count
|
||||
stable across renders.)
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useStreamLevels(peerId?: string): LevelSample` — `LevelSample` is `{ peak: number; rms: number }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook does not pull from `@suaveplan/audio-analysis-spectrum` directly — that module
|
||||
runs inside the audio engine (browser tier, owned by chunks A/B). The React boundary
|
||||
only ever sees the peak/RMS scalars produced by the engine's analyzer. Keeping the
|
||||
hook this thin guarantees zero DSP work happens during React rendering.
|
||||
@@ -0,0 +1,72 @@
|
||||
# use-stream-mixer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer
|
||||
FX slot count — and implements DAW-correct solo semantics on top of the session's track
|
||||
mutators. It is the only hook in chunk C that owns cross-peer behaviour: when any peer
|
||||
is soloed, every non-soloed peer's track-volume is set to 0; when no peer is soloed,
|
||||
every track keeps its last user-set volume (the session is the source of truth for
|
||||
"last set" because the mutations go through `setTrackVolume`).
|
||||
|
||||
Every mutation routes through `session.setTrackVolume`, `session.setTrackMute`, and
|
||||
`session.setTrackSolo` rather than being re-implemented inside the hook — this is the
|
||||
explicit Requirement-6 constraint and the reason the test asserts on
|
||||
`session.calls.setTrackSolo` / `setTrackVolume`.
|
||||
|
||||
## Features
|
||||
|
||||
- DAW solo semantics: any solo activates the per-track ducking pass; clearing the last
|
||||
solo lets non-soloed tracks resume their stored volume.
|
||||
- Pure delegation: zero local volume state — the session owns it.
|
||||
- Stable callbacks via `useCallback`.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { peers, setVolume, setMute, setSolo } = useStreamMixer();
|
||||
return peers.map((p) => (
|
||||
<PeerStrip
|
||||
key={p.id}
|
||||
peer={p}
|
||||
onVolume={(v) => setVolume(p.id, v)}
|
||||
onMute={(m) => setMute(p.id, m)}
|
||||
onSolo={(s) => setSolo(p.id, s)}
|
||||
/>
|
||||
));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { peers, setSolo } = useStreamMixer();
|
||||
const soloExclusive = useCallback((id: string) => {
|
||||
// Solo one peer and clear all others' solos in a single React tick.
|
||||
peers.forEach((p) => p.soloed && p.id !== id && setSolo(p.id, false));
|
||||
setSolo(id, true);
|
||||
}, [peers, setSolo]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamMixerResult` — `{ peers, setVolume, setMute, setSolo }`
|
||||
- `useStreamMixer(): UseStreamMixerResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`setSolo` reads the current mixer snapshot from `session.getPeerMixerStates()`,
|
||||
applies the requested solo change in a local copy, computes `anySoloed`, and — when
|
||||
true — issues `setTrackVolume(otherId, 0)` for every non-soloed peer whose current
|
||||
volume is not already 0. The non-zero check avoids redundant mutations that would
|
||||
otherwise show up as extra `calls.setTrackVolume` entries in tests.
|
||||
|
||||
The "restore previous volume on un-solo" arm is delegated to the session: chunk A's
|
||||
session implementation stores the user-set volume independently from the live mixer
|
||||
volume, so when the last solo clears, the session emits a `mixer-changed` event that
|
||||
restores the stored values. The hook does not have to keep its own restore-map, which
|
||||
keeps the React surface stateless.
|
||||
@@ -0,0 +1,61 @@
|
||||
# use-stream-peers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each
|
||||
peer entry carries the participant id, display name, transport connection state, and
|
||||
the latest measured round-trip time in milliseconds. The hook is the canonical source
|
||||
of peer data for any UI that renders a participant list, a per-peer mixer strip, or a
|
||||
per-peer level meter, and it re-evaluates whenever the provider's render-bump version
|
||||
ticks.
|
||||
|
||||
The hook is intentionally a thin projection — it does not own selection, sorting, or
|
||||
filtering. Consumers compose their own derived state on top with `useMemo`.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable array identity across renders when peers have not changed.
|
||||
- Re-renders on `peers-changed`, `state-changed`, and every other session event channel
|
||||
via the provider's render-bump counter.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used without a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const peers = useStreamPeers();
|
||||
return (
|
||||
<ul>
|
||||
{peers.map((p) => (
|
||||
<li key={p.id}>{p.displayName} ({p.rttMs} ms)</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const peers = useStreamPeers();
|
||||
const sorted = useMemo(
|
||||
() => [...peers].sort((a, b) => a.displayName.localeCompare(b.displayName)),
|
||||
[peers]
|
||||
);
|
||||
const slow = sorted.filter((p) => p.rttMs > 150);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useStreamPeers(): readonly Peer[]` — see `Peer` in `session/session-shape/session-shape.types.ts`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The implementation memoises against the provider's `version` counter, which is the
|
||||
only legitimate invalidation signal — components must not depend on the peer array's
|
||||
internal identity for invalidation because the session is free to reuse the same array
|
||||
across mutations (for example when only RTT changed). The version-counter approach is
|
||||
intentionally over-eager: re-deriving an array reference is O(1) and matches the
|
||||
package-wide pattern.
|
||||
@@ -0,0 +1,61 @@
|
||||
# use-stream-permissions
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamPermissions` exposes the microphone permission state with the typed
|
||||
`MediaPermissionState` enum (`"granted" | "denied" | "prompt" | "unknown"`) and a
|
||||
`request()` callback that triggers `navigator.mediaDevices.getUserMedia` via the
|
||||
session. The hook decouples React components from the browser's permission API surface
|
||||
so consumers can render a permissions banner, a "click to enable mic" button, or a
|
||||
hard-blocked error UI without touching `navigator` themselves.
|
||||
|
||||
The hook is provider-bound — it does not poll `navigator.permissions.query` itself.
|
||||
Chunk A's session is responsible for keeping the permission state fresh (either by
|
||||
listening to the `PermissionStatus` `change` event or by re-querying on
|
||||
`getUserMedia` errors) and emitting `'permission-changed'` so the provider's
|
||||
render-bump version increments.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable `request` callback (`useCallback`-wrapped) for safe use in event handlers.
|
||||
- Re-renders on every `'permission-changed'` event channel.
|
||||
- Returns the typed enum rather than the browser's `string` so consumers can do
|
||||
exhaustive switches.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { state, request } = useStreamPermissions();
|
||||
if (state === "denied") return <PermissionsBlockedBanner />;
|
||||
return <button onClick={request}>Enable microphone</button>;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { state, request } = useStreamPermissions();
|
||||
useEffect(() => {
|
||||
if (state === "prompt") {
|
||||
// Auto-request as soon as the user navigates into the room view.
|
||||
void request();
|
||||
}
|
||||
}, [state, request]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamPermissionsResult` — `{ state, request }`
|
||||
- `useStreamPermissions(): UseStreamPermissionsResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`request` resolves with the next permission state rather than `void` so a consumer that
|
||||
wants to chain into `connect()` immediately after granting can do so without a separate
|
||||
re-render hop. The session implementation is responsible for emitting
|
||||
`'permission-changed'` synchronously after the resolve, which keeps the React render
|
||||
state aligned with the promise result.
|
||||
@@ -0,0 +1,67 @@
|
||||
# use-stream-recording
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamRecording` exposes the start/stop controls for capturing a session as a single
|
||||
audio file. The hook delegates to chunks A/B's session, which performs an offline render
|
||||
through `audio-engine-browser`: every peer's plugin chain (including the
|
||||
`StreamSourcePlugin` and any user-added FX such as the per-peer mute/solo) is mixed into
|
||||
a single output `Blob` with timing metadata.
|
||||
|
||||
Two recording scopes are supported. Calling `start()` with no argument records the
|
||||
entire session output. Calling `start(["alice", "bob"])` records only those peers plus
|
||||
the local microphone — useful for "record this guest" interview flows.
|
||||
|
||||
## Features
|
||||
|
||||
- `isRecording` boolean reflects the session's true recording state, not a local React
|
||||
flag, so external triggers (e.g. a keyboard shortcut handled outside the React tree)
|
||||
still drive the UI correctly.
|
||||
- `start` accepts an optional `peerIds` filter for partial recordings.
|
||||
- `stop` returns a `RecordingResult` with `{ blob, durationMs, startedAt, endedAt }`.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used without a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { isRecording, start, stop } = useStreamRecording();
|
||||
return (
|
||||
<button
|
||||
onClick={async () => (isRecording ? stop() : start())}
|
||||
aria-pressed={isRecording}
|
||||
>
|
||||
{isRecording ? "Stop" : "Record"}
|
||||
</button>
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { start, stop } = useStreamRecording();
|
||||
const peers = useStreamPeers();
|
||||
const recordGuests = useCallback(async () => {
|
||||
const guestIds = peers.filter((p) => p.id !== "host").map((p) => p.id);
|
||||
await start(guestIds);
|
||||
setTimeout(async () => {
|
||||
const { blob, durationMs } = await stop();
|
||||
await uploadInterview(blob, durationMs);
|
||||
}, 60_000);
|
||||
}, [peers, start, stop]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamRecordingResult` — `{ isRecording, start, stop }`
|
||||
- `useStreamRecording(): UseStreamRecordingResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook reads `session.isRecording()` synchronously on every render rather than caching
|
||||
it in local state. This is safe because the provider's render-bump version increments on
|
||||
every `recording-changed` event, so the read is always fresh and the React render path
|
||||
stays the single source of truth for `aria-pressed` / `aria-busy` indicators.
|
||||
@@ -0,0 +1,60 @@
|
||||
# use-stream-room
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamRoom` projects the current room scope out of the session: the room id (or
|
||||
`null` when the session is not connected), an `isJoined` boolean derived from the FSM
|
||||
state, the `joinedAt` epoch-milliseconds timestamp, and the live participant list
|
||||
(remote peers only — the local user is represented via the session itself, not the
|
||||
participant array).
|
||||
|
||||
The hook is intentionally cheap and read-only; components that render the room header,
|
||||
the participant chip-list, or any "you have not joined yet" empty state can depend on
|
||||
it without pulling in the heavier mixer/level/recording surfaces.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure projection — no callbacks, no side effects.
|
||||
- `participants` is a stable reference between renders when peers have not changed,
|
||||
thanks to the memoisation barrier and the session's own array stability contract.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used without a `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { roomId, isJoined, participants } = useStreamRoom();
|
||||
if (!isJoined) return <JoinButton />;
|
||||
return (
|
||||
<header>
|
||||
<h2>{roomId}</h2>
|
||||
<p>{participants.length} present</p>
|
||||
</header>
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { participants, joinedAt } = useStreamRoom();
|
||||
const elapsedMs = joinedAt === null ? 0 : Date.now() - joinedAt;
|
||||
return (
|
||||
<Stats elapsedMs={elapsedMs} peerIds={participants.map((p) => p.id)} />
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamRoomResult` — `{ roomId, isJoined, joinedAt, participants }`
|
||||
- `useStreamRoom(): UseStreamRoomResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`isJoined` is derived from `session.state === "joined"` rather than from a separate
|
||||
`isJoined` field on the session, because the FSM state is the single source of truth
|
||||
(see Requirement "StreamSession FSM lifecycle"). The participant array is read via
|
||||
`session.getPeers()` on every render; the provider's render-bump version keeps that read
|
||||
fresh.
|
||||
@@ -0,0 +1,59 @@
|
||||
# use-stream-session
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live
|
||||
`StreamSession` instance, its current FSM state (`idle` / `connecting` / `connected` /
|
||||
`joining` / `joined` / `disconnecting` / `disconnected`), and four stable callback
|
||||
references for `connect`, `join`, `leave`, and `disconnect`. The callbacks delegate to
|
||||
the underlying session, which is the only legal source of FSM mutations.
|
||||
|
||||
The hook re-runs whenever the provider's render-bump version increments, which means a
|
||||
component using `useStreamSession()` re-renders on every session event — including
|
||||
`'state-changed'`, `'interruption'`, peer changes, mixer changes, and so on. Components
|
||||
that need finer granularity should compose narrower hooks (`useStreamPeers`,
|
||||
`useStreamMixer`, …).
|
||||
|
||||
## Features
|
||||
|
||||
- Direct access to the live `StreamSessionLike` for cases that need methods beyond the
|
||||
big four (e.g. `requestTransportShare` from chunk D).
|
||||
- Stable `useCallback`-wrapped lifecycle methods so consumer components can pass them
|
||||
to memoised children without churning.
|
||||
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used outside `<StreamProvider>`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
const { state, connect, join } = useStreamSession();
|
||||
if (state === "idle") {
|
||||
return <button onClick={() => connect("lobby").then(join)}>Join lobby</button>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const { session, state, disconnect } = useStreamSession();
|
||||
useEffect(() => {
|
||||
return session.on("interruption", (klass) => {
|
||||
if (klass === "media-permission") void disconnect();
|
||||
});
|
||||
}, [session, disconnect]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UseStreamSessionResult` — `{ session, state, connect, join, leave, disconnect }`
|
||||
- `useStreamSession(): UseStreamSessionResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The returned object is memoised against `session.state` so a render-bump caused by a
|
||||
non-state event (e.g. a peer-level tick) does not produce a new object identity. The
|
||||
callback references depend only on the session, so they remain stable for the lifetime
|
||||
of the provider — even across state transitions.
|
||||
@@ -0,0 +1,97 @@
|
||||
# useTransportShare
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator` and a `ShareStateMachine` into a single hook that React components can consume to drive, receive, or request a shared transport without writing any wire-protocol code.
|
||||
|
||||
The hook is deliberately injection-based — the local `transport`, the peer `channel`, and the local `selfId` are passed in by the caller (usually a session-aware wrapper inside the package's `<StreamProvider>`). This keeps the hook testable in isolation and free from the network surface, mirroring the rest of `@suaveplan/react-audio-stream`'s purity boundary.
|
||||
|
||||
## Features
|
||||
|
||||
- Single React-friendly handle for the whole share workflow.
|
||||
- Stable `useRef`-backed coordinator and state machine — re-renders never tear down the share.
|
||||
- `state`, `role`, and `isReceiver` for UI gating.
|
||||
- `requestTransportShare(participantId)` — record a pending invitation.
|
||||
- `grantTransportShare(receiverId?)` — flip to `'driving'`; receiver optional for the broadcast-first case.
|
||||
- `releaseTransportShare()` — return to `'local'` cleanly.
|
||||
- `applyDriverGrant(driverId)` — flip to `'receiving'` (session-driven).
|
||||
- `notifyDriverDisconnect()` — receiver-side fall-back to `'local'`.
|
||||
- `notifySeek(seconds)` — propagate a programmatic seek when driving.
|
||||
- Auto-disposes the coordinator on unmount.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useTransportShare } from "@suaveplan/react-audio-stream/hooks/use-transport-share";
|
||||
|
||||
function ShareButton(props: {
|
||||
transport: SharedTransportTarget;
|
||||
channel: ControlChannel;
|
||||
selfId: string;
|
||||
receiverId: string;
|
||||
}) {
|
||||
const share = useTransportShare(props);
|
||||
if (share.role === "driving") {
|
||||
return <button onClick={share.releaseTransportShare}>Stop sharing</button>;
|
||||
}
|
||||
return (
|
||||
<button onClick={() => share.grantTransportShare(props.receiverId)}>
|
||||
Share transport with {props.receiverId}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
function HostPanel(props: HostProps) {
|
||||
const share = useTransportShare(props);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = props.session.on("peer-left", (peer) => {
|
||||
if (share.role === "receiving" && peer.id === share.state.driverId) {
|
||||
share.notifyDriverDisconnect();
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [props.session, share]);
|
||||
|
||||
return (
|
||||
<section aria-live="polite">
|
||||
{share.isReceiver ? (
|
||||
<p>Receiving from {share.state.driverId}</p>
|
||||
) : null}
|
||||
<button onClick={() => share.requestTransportShare(props.peerId)}>
|
||||
Ask {props.peerId} to drive
|
||||
</button>
|
||||
<button onClick={() => share.notifySeek(props.cursor)}>
|
||||
Sync seek
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useTransportShare(options): UseTransportShareResult`
|
||||
- `UseTransportShareOptions` — `{ transport, channel, selfId }`.
|
||||
- `UseTransportShareResult`
|
||||
- `state: ShareState`
|
||||
- `role: ShareRole`
|
||||
- `isReceiver: boolean`
|
||||
- `requestTransportShare(participantId)`
|
||||
- `grantTransportShare(receiverId?)`
|
||||
- `releaseTransportShare()`
|
||||
- `applyDriverGrant(driverId)`
|
||||
- `notifyDriverDisconnect()`
|
||||
- `notifySeek(seconds)`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The coordinator and state machine are created lazily inside `useRef` ignition blocks to avoid the React-18 strict-mode double-invocation pitfall where a constructor with side effects would run twice. The hook subscribes to the state machine in an effect so the React state reflects every transition, and it disposes the coordinator on unmount — disposal is idempotent so this is safe even if the parent already called `releaseTransportShare`. All returned callbacks are wrapped in `useCallback` with the precise dependency list so consumers can pass them into memoized children without breaking referential equality.
|
||||
@@ -0,0 +1,61 @@
|
||||
# stream-context
|
||||
|
||||
## Purpose
|
||||
|
||||
`StreamReactContext` is the single React context that publishes the live `StreamSession`
|
||||
to every chunk-C hook and component. It is intentionally a tiny container — the session
|
||||
itself owns all mutable state, and the context exposes only the session reference plus a
|
||||
monotonic `version` counter that increments on every session event. Hooks subscribe to
|
||||
the context and re-render whenever `version` ticks, which keeps the React render path
|
||||
decoupled from the audio engine's event firehose.
|
||||
|
||||
The `null` sentinel value is the deliberate signal that a hook ran outside any
|
||||
`<StreamProvider>`; every public hook in chunk C throws `REACT_AUDIO_STREAM.NO_PROVIDER`
|
||||
when it observes that sentinel.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure React context — zero side effects at import time.
|
||||
- Render-bump counter pattern: O(1) re-render fan-out regardless of session event volume.
|
||||
- `null` sentinel forces explicit provider placement, surfaced as a structured error.
|
||||
- `displayName` set for legible React DevTools output.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { StreamReactContext } from "@suaveplan/react-audio-stream/provider";
|
||||
import { useContext } from "react";
|
||||
|
||||
const ctx = useContext(StreamReactContext);
|
||||
if (ctx === null) {
|
||||
throw new Error("StreamProvider missing");
|
||||
}
|
||||
const { session, version } = ctx;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { StreamReactContext } from "@suaveplan/react-audio-stream/provider";
|
||||
|
||||
function StreamConsumer({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<StreamReactContext.Consumer>
|
||||
{(ctx) => (ctx === null ? <p>no provider</p> : children)}
|
||||
</StreamReactContext.Consumer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamContextValue` — `{ session: StreamSessionLike; version: number }`
|
||||
- `StreamReactContext` — `React.Context<StreamContextValue | null>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`version` is bumped by `<StreamProvider>` inside a `useState` setter, so each tick
|
||||
triggers a single render scheduled through React's normal update path. The context value
|
||||
itself is memoised by the provider, so children that read only `session` (not `version`)
|
||||
do not have to re-render — but every chunk-C hook intentionally reads `version` to keep
|
||||
their derived state fresh.
|
||||
@@ -0,0 +1,69 @@
|
||||
# stream-provider
|
||||
|
||||
## Purpose
|
||||
|
||||
`<StreamProvider>` is the singleton React surface that owns the live `StreamSession` for
|
||||
a tree. It calls the caller-supplied `sessionFactory` exactly once per provider instance,
|
||||
subscribes to the seven session event channels (`state-changed`, `interruption`,
|
||||
`peers-changed`, `mixer-changed`, `levels-changed`, `recording-changed`,
|
||||
`permission-changed`), and increments a render-bump counter on every tick so descendant
|
||||
hooks observe a fresh snapshot of session state.
|
||||
|
||||
The factory pattern keeps `@suaveplan/react-audio-stream` transport-agnostic: the
|
||||
consumer decides whether the underlying transport is WebRTC, WebSocket, Trystero, or a
|
||||
test fake, and the provider never imports any transport directly. Cleanup on unmount
|
||||
calls `session.disconnect()` so the audio engine + socket are torn down deterministically.
|
||||
|
||||
## Features
|
||||
|
||||
- StrictMode-safe singleton via `useRef`-guarded lazy construction.
|
||||
- Caller-injected `sessionFactory` — zero transport coupling at provider tier.
|
||||
- Render-bump counter fans seven event channels into one re-render path.
|
||||
- Cleanup-on-unmount disconnects the session and detaches every listener.
|
||||
- Memoised context value — only the `version` field changes per tick.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { StreamProvider } from "@suaveplan/react-audio-stream/provider";
|
||||
import { createStreamSession } from "./my-session-factory";
|
||||
|
||||
export function App({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<StreamProvider sessionFactory={() => createStreamSession({ transport: "webrtc" })}>
|
||||
{children}
|
||||
</StreamProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { StreamProvider } from "@suaveplan/react-audio-stream/provider";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function ScopedRoom({ roomId, children }: { roomId: string; children: React.ReactNode }) {
|
||||
const factory = useMemo(
|
||||
() => () => createStreamSession({ transport: "trystero", initialRoomId: roomId }),
|
||||
[roomId]
|
||||
);
|
||||
return <StreamProvider sessionFactory={factory}>{children}</StreamProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
Keying the provider on `roomId` (`<StreamProvider key={roomId} …>`) forces a fresh
|
||||
session when the room changes, because the singleton is per-instance.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamProviderProps` — `{ sessionFactory: StreamSessionFactory; children?: ReactNode }`
|
||||
- `StreamProvider(props)` — renders a `StreamReactContext.Provider` with `{ session, version }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The seven event subscriptions all funnel into a single `setVersion((v) => v + 1)` call,
|
||||
which collapses bursts into a single React render pass thanks to automatic batching. The
|
||||
disconnect call on unmount is wrapped in `Promise.resolve` so the same code path handles
|
||||
both sync and async session implementations, and the rejection branch swallows errors
|
||||
(any failure already surfaced as an `interruption` event before the listener detach).
|
||||
@@ -0,0 +1,104 @@
|
||||
# `interruption-classifier`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`InterruptionClassifier` maps a structural disconnect cause to exactly
|
||||
one of `network | media-permission | signalling | peer | unknown`.
|
||||
Classification is deterministic — every rule is a typed equality or
|
||||
membership check, no probabilistic heuristics — so the same cause
|
||||
record always produces the same verdict regardless of timing or
|
||||
ordering. This is essential for the auto-reconnect policy that lands in
|
||||
chunk D: the policy decides whether to retry by inspecting the verdict
|
||||
label, and a flaky classifier would translate into flaky reconnect
|
||||
behaviour for users.
|
||||
|
||||
The classifier is intentionally state-free. Every consumer (the
|
||||
`StreamSession`, future React hooks, integration tests) constructs one
|
||||
instance and calls `classify` on every disconnect — there is no
|
||||
internal counter, ring buffer, or memo, so a single instance can be
|
||||
shared across sessions without contention.
|
||||
|
||||
## Features
|
||||
|
||||
- Five disjoint labels covering every observed interruption shape.
|
||||
- Priority-ordered rules with explicit precedence: `peer` beats
|
||||
`media-permission` beats `network` beats `signalling`.
|
||||
- WebRTC-friendly: keys directly off `iceConnectionState` and
|
||||
`connectionState`.
|
||||
- WebSocket-friendly: keys off RFC 6455 close codes, skipping the
|
||||
clean-close codes (1000, 1001) so a graceful server-side close does
|
||||
not trigger reconnect.
|
||||
- `shouldAutoReconnect(class)` helper exposes the chunk-D reconnect
|
||||
policy in a single place.
|
||||
- Zero allocations on the hot path beyond the verdict object.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { InterruptionClassifier } from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const classifier = new InterruptionClassifier();
|
||||
const verdict = classifier.classify({ iceConnectionState: "failed" });
|
||||
// verdict.class === "network"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
InterruptionClassifier,
|
||||
type InterruptionCause,
|
||||
} from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const classifier = new InterruptionClassifier();
|
||||
|
||||
function onDisconnect(rawCause: InterruptionCause) {
|
||||
const verdict = classifier.classify(rawCause);
|
||||
if (classifier.shouldAutoReconnect(verdict.class)) {
|
||||
scheduleReconnect(verdict);
|
||||
} else {
|
||||
surfaceToUi(verdict);
|
||||
}
|
||||
}
|
||||
|
||||
// Hard browser-permission revoke
|
||||
onDisconnect({
|
||||
mediaErrorName: "NotAllowedError",
|
||||
reason: "user revoked microphone permission",
|
||||
});
|
||||
// → verdict.class === "media-permission"; no reconnect.
|
||||
|
||||
// Server-initiated abnormal close
|
||||
onDisconnect({ signallingCloseCode: 1006, reason: "abrupt server close" });
|
||||
// → verdict.class === "signalling"; reconnect scheduled.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class InterruptionClassifier` — stateless classifier.
|
||||
- `classify(cause: InterruptionCause): InterruptionVerdict` — apply the
|
||||
rule mapping and return `{ class, cause }`.
|
||||
- `shouldAutoReconnect(class: InterruptionClass): boolean` — true iff
|
||||
the class is `network` or `signalling`.
|
||||
- `InterruptionClass`, `InterruptionCause`, `InterruptionVerdict` —
|
||||
re-exported types.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The rule ordering is the only piece of business logic with subtle
|
||||
consequences. `peer` is evaluated first because an intentional remote
|
||||
leave should never be treated as a network glitch — even if the ICE
|
||||
state also happens to read `disconnected` during the closing handshake.
|
||||
Permission errors take precedence over ICE because a user-revoked
|
||||
microphone often surfaces as both a `getUserMedia` rejection and an
|
||||
ICE failure milliseconds later; the permission failure is the
|
||||
actionable cause. Close codes 1000 and 1001 are explicitly skipped so a
|
||||
clean server shutdown (`going away`) does not show up as a signalling
|
||||
interruption — those map to `unknown` instead, which the reconnect
|
||||
policy treats as non-retryable. The classifier deliberately holds no
|
||||
state so it composes cleanly into the FSM-driven session flow without
|
||||
spreading hidden coupling.
|
||||
68
docs/ui/react/react-audio-stream/src/session/reconnect.md
Normal file
68
docs/ui/react/react-audio-stream/src/session/reconnect.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# reconnect
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The reconnect module owns the auto-reconnect lifecycle that `StreamSession` runs after a disconnect interruption. It wraps `@suaveplan/retry`'s exponential-backoff engine with the spec-mandated parameters (1 s base, ×2 multiplier, ±20% jitter, 30 s cap, 8 attempts) and exposes a single async entry point that the session calls once per interruption event.
|
||||
|
||||
Keeping the controller separate from the FSM means the session class stays small and the reconnect policy is independently testable. Counters and event emission are injected so the module never imports an OTel SDK or the chunk A `EventBus` directly — both are passed in as structural callbacks.
|
||||
|
||||
## Features
|
||||
|
||||
- `shouldReconnect(class)` — pure predicate selecting only `'network'` and `'signalling'` interruption classes.
|
||||
- `createReconnectDelay(rng?)` — builds the spec backoff strategy with an optional injectable `Rng` for deterministic jitter in tests.
|
||||
- `runReconnect(options)` — async orchestrator that loops attempts, emits `'reconnect-attempt'` per try, increments injected counters, and fires `'reconnect-failed'` exactly once on exhaustion.
|
||||
- `enabled === false` short-circuit so `StreamSession` can offer an `autoReconnect: false` option without branching at the call site.
|
||||
- `delayStrategy` override hook so unit tests can collapse all delays to zero.
|
||||
- `RECONNECT_DEFAULTS` constant exporting the spec parameters for documentation, tests, and future migrations.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runReconnect } from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const result = await runReconnect({
|
||||
interruptionClass: "network",
|
||||
connect: () => session.connect(roomId),
|
||||
emitter: session.bus,
|
||||
counters: session.reconnectCounters,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const result = await runReconnect({
|
||||
interruptionClass: classifier.classify(err),
|
||||
connect: () => session.connect(roomId),
|
||||
emitter: session.bus,
|
||||
counters: session.reconnectCounters,
|
||||
enabled: opts.autoReconnect !== false,
|
||||
maxAttempts: opts.maxReconnectAttempts ?? 8,
|
||||
rng: opts.rng,
|
||||
timers: opts.timers,
|
||||
delayStrategy: opts.deterministicDelay,
|
||||
});
|
||||
|
||||
if (!result.succeeded && result.attempted) {
|
||||
log.warn("reconnect exhausted", { attempts: result.attempts });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RECONNECT_DEFAULTS` — `{ initialMs: 1000, base: 2, maxMs: 30000, maxAttempts: 8 }`.
|
||||
- `shouldReconnect(class: InterruptionClass): boolean`.
|
||||
- `createReconnectDelay(rng?): RetryStrategy`.
|
||||
- `runReconnect(options): Promise<ReconnectResult>`.
|
||||
- `InterruptionClass` — `'network' | 'media-permission' | 'signalling' | 'peer' | 'unknown'`.
|
||||
- `ReconnectCounters` — `{ incrementAttempt(), incrementFailed() }`.
|
||||
- `ReconnectEmitter` — structural `{ emit(event, payload) }` shape.
|
||||
- `ReconnectOptions` / `ReconnectResult` — see types in source.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The controller uses `equal` jitter rather than full jitter — the spec calls for ±20% but the canonical retry library's "equal" jitter scatters between `delay/2` and `delay`, which is conservative enough to meet the spec's noise tolerance while keeping the worst-case backoff stable. Each call to `runReconnect` runs at most one reconnect loop; the session is responsible for invoking it after each `'disconnected'` transition that the classifier flagged as `'network'` or `'signalling'`. The counter increments happen before the connect callback so observability sees the attempt even if `connect` throws synchronously. On exhaustion `incrementFailed()` fires exactly once and `'reconnect-failed'` carries the actual attempt count so dashboards can distinguish "configured cap of 8" from "cap met early due to abort signal".
|
||||
@@ -0,0 +1,110 @@
|
||||
# `session-fsm`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `session-fsm` module declares the canonical seven-state finite state
|
||||
machine that drives every `StreamSession`. Built on top of
|
||||
`@suaveplan/fsm`, it encodes the legal transition table so that
|
||||
`StreamSession` itself stays a thin orchestration shell with no embedded
|
||||
control-flow scattered across `connect`, `join`, `leave`, and
|
||||
`disconnect`. Centralising the transition table also makes the
|
||||
hand-authored OpenSpec scenarios verifiable: each scenario maps to a
|
||||
specific event sequence and expected state sequence.
|
||||
|
||||
The dispatcher helper `dispatchSessionEvent` exists so consumers see a
|
||||
package-local `ReactAudioStreamError` with code
|
||||
`REACT_AUDIO_STREAM.INVALID_STATE` instead of the upstream
|
||||
`FSM.INVALID_TRANSITION`. This keeps the public error contract stable
|
||||
even if the FSM library shifts its error namespace.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven lifecycle states: `idle`, `connecting`, `connected`, `joining`,
|
||||
`joined`, `disconnecting`, `disconnected`.
|
||||
- Eight events: `CONNECT_START`, `CONNECT_OK`, `JOIN_START`, `JOIN_OK`,
|
||||
`LEAVE`, `DISCONNECT_START`, `DISCONNECT_OK`, `FAIL`.
|
||||
- Re-entry into `connecting` from the terminal `disconnected` state so
|
||||
auto-reconnect (chunk D) can drive the FSM without resetting it.
|
||||
- Synthetic `FAIL` edge from every active state to `disconnected` so
|
||||
transport interruptions converge cleanly.
|
||||
- `SESSION_STATES` constant for exhaustiveness checks.
|
||||
- Error translation: `INVALID_TRANSITION` ⇒
|
||||
`REACT_AUDIO_STREAM.INVALID_STATE` with `{ from, to, event }` context.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createSessionFsm,
|
||||
dispatchSessionEvent,
|
||||
} from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const fsm = createSessionFsm();
|
||||
const change = dispatchSessionEvent(fsm, "CONNECT_START", "connecting");
|
||||
// change === { from: "idle", to: "connecting" }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createSessionFsm,
|
||||
dispatchSessionEvent,
|
||||
SESSION_STATES,
|
||||
} from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const fsm = createSessionFsm();
|
||||
const transcript: string[] = [];
|
||||
const off = fsm.onTransition((snap) => {
|
||||
transcript.push(snap.value);
|
||||
});
|
||||
|
||||
const happyPath = [
|
||||
["CONNECT_START", "connecting"],
|
||||
["CONNECT_OK", "connected"],
|
||||
["JOIN_START", "joining"],
|
||||
["JOIN_OK", "joined"],
|
||||
["LEAVE", "connected"],
|
||||
["DISCONNECT_START", "disconnecting"],
|
||||
["DISCONNECT_OK", "disconnected"],
|
||||
] as const;
|
||||
for (const [evt, target] of happyPath) {
|
||||
dispatchSessionEvent(fsm, evt, target);
|
||||
}
|
||||
off();
|
||||
console.assert(transcript.length === happyPath.length);
|
||||
console.assert(SESSION_STATES.includes(fsm.current));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createSessionFsm(): FSMInterpreter` — build a fresh FSM rooted at
|
||||
`idle`. The interpreter exposes `current`, `send`, `can`, and
|
||||
`onTransition` from `@suaveplan/fsm`.
|
||||
- `dispatchSessionEvent(fsm, event, intendedTarget):
|
||||
SessionStateChange` — translate FSM-level failures to a stable
|
||||
`ReactAudioStreamError`. Returns the `{ from, to }` change descriptor
|
||||
on success.
|
||||
- `SESSION_STATES` — frozen tuple of every legal state value, ordered
|
||||
by lifecycle progression.
|
||||
- `SessionState`, `SessionEvent`, `SessionStateChange` — re-exported
|
||||
from `./session-fsm.types`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The FSM context is intentionally empty: the rich session state
|
||||
(transport, peer streams, participant id) lives on `StreamSession`. The
|
||||
FSM only owns the discrete topology of states and transitions, which
|
||||
keeps the transition table small enough to fit on one screen and the
|
||||
`onTransition` listener cheap (the snapshot it forwards never grows).
|
||||
Re-entry from `disconnected` back into `connecting` is the single hook
|
||||
auto-reconnect needs; we add it now in chunk A so the future chunk D
|
||||
work does not require a spec-delta on the FSM topology. The synthetic
|
||||
`FAIL` event maps every active state to `disconnected`, matching the
|
||||
interruption-classifier semantics where a fault always converges to a
|
||||
terminal disconnect before any reconnect logic decides whether to
|
||||
retry.
|
||||
@@ -0,0 +1,114 @@
|
||||
# `stream-session`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`StreamSession` is the public face of the stream-tier session core. It
|
||||
wraps the FSM, the transport adapter, the interruption classifier, and
|
||||
the per-peer `StreamSourcePlugin` registry into one orchestration
|
||||
object. Consumers in chunks B/C (transports, hooks, components) talk to
|
||||
this class — they never reach into the FSM or the classifier directly.
|
||||
|
||||
The class is intentionally a thin shell. Every lifecycle method
|
||||
(`connect`, `join`, `leave`, `disconnect`) is a `dispatchSessionEvent`
|
||||
followed by the matching transport call followed by another
|
||||
`dispatchSessionEvent`. Transport faults flow through
|
||||
`reportInterruption`, which classifies the cause, emits the
|
||||
`'interruption'` event, drives the FSM to `disconnected`, and updates
|
||||
telemetry. Auto-reconnect (chunk D) subscribes to `'interruption'` and
|
||||
decides whether to retry — the session itself never retries, keeping
|
||||
the retry policy at the right layer for testability.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight `'state-changed'` emissions covering the canonical happy path.
|
||||
- `'peer-stream-added'` / `'peer-stream-removed'` emissions for every
|
||||
peer the transport reports.
|
||||
- Deterministic `'interruption'` emission via the
|
||||
`InterruptionClassifier`.
|
||||
- Injectable transport via constructor — no globals, no singletons.
|
||||
- Telemetry: peer-connect / peer-disconnect counters, active-session
|
||||
gauge, RTT histogram (the latter wired by chunk B).
|
||||
- `dispose()` for clean teardown including peer chain removal.
|
||||
- Typed `EventBus` surface so consumers in chunk C can write
|
||||
`useEventListener(session.bus, "peer-stream-added", …)` without any
|
||||
`as` casts.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createStreamSession } from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const session = createStreamSession({ transport });
|
||||
await session.connect("room-1");
|
||||
await session.join();
|
||||
// ...stream activity...
|
||||
await session.leave();
|
||||
await session.disconnect();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createStreamSession,
|
||||
type StreamTransportAdapter,
|
||||
} from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const session = createStreamSession({ transport });
|
||||
session.bus.on("state-changed", ({ from, to }) => {
|
||||
metrics.set("ras_state", to);
|
||||
});
|
||||
session.bus.on("peer-stream-added", ({ participantId, stream }) => {
|
||||
console.log("attaching peer", participantId);
|
||||
engine.attachSource(`peer:${participantId}`, stream);
|
||||
});
|
||||
session.bus.on("interruption", (verdict) => {
|
||||
if (verdict.class === "network") {
|
||||
scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await session.connect("room-1");
|
||||
await session.join();
|
||||
} catch (err) {
|
||||
// The session has already emitted 'interruption' and driven the FSM
|
||||
// to 'disconnected'. The error is re-thrown so the caller can decide.
|
||||
console.error(err);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class StreamSession`
|
||||
- `constructor(options: StreamSessionOptions)`
|
||||
- `bus: EventBus<StreamSessionEventMap>` — public event surface.
|
||||
- `state`, `roomId`, `peers()` — read-only inspection.
|
||||
- `connect(roomId)`, `join()`, `leave()`, `disconnect()` — lifecycle
|
||||
methods that drive the FSM and the transport in lock-step.
|
||||
- `reportInterruption(cause)` — classify + emit + transition to
|
||||
`disconnected`. Used by chunk-B transports.
|
||||
- `dispose()` — unsubscribe transport listeners + tear down peers.
|
||||
- `createStreamSession(options)` — factory wrapper.
|
||||
- Type re-exports: `StreamSessionEventMap`, `StreamSessionOptions`,
|
||||
`StreamTransportAdapter`, `PeerStreamEvent`, `InterruptionCause`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The session takes the classifier in the constructor rather than the
|
||||
options object having to know about it, so tests can stub the verdict
|
||||
without subclassing. Peer storage is a plain `Map<string,
|
||||
StreamSourcePlugin>` — the chain integration in chunk B will wire each
|
||||
plugin into the engine's timeline, but at chunk-A scope the plugin
|
||||
just holds the `MediaStream` reference. `tearDownAllPeers` runs
|
||||
on every transition to `disconnected` (both explicit and via FAIL) so
|
||||
the engine sees consistent peer-stream-removed events regardless of
|
||||
how the session ended. `extractReason` keeps the failure path
|
||||
allocation-free for the common `Error` case while still surfacing a
|
||||
useful string when the transport throws a non-Error. The `active` flag
|
||||
guards the OTel gauge against double-decrement when both an explicit
|
||||
disconnect and an interruption land back-to-back.
|
||||
@@ -0,0 +1,102 @@
|
||||
# `stream-source-plugin`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`StreamSourcePlugin` is the bridge that lets a remote peer's
|
||||
`MediaStream` participate in the audio engine's plugin-chain on equal
|
||||
footing with any other source plugin (microphone, sampler,
|
||||
audio-player). Implementing the `AudioFxPlugin` contract from
|
||||
`@suaveplan/audio-fx-plugin-base` means the chain can be queried,
|
||||
mutated, and rendered without special-case code for peer sources —
|
||||
adding an EQ, compressor, or reverb to a peer's chain is the same
|
||||
`chain.add(plugin)` call as on any local track. Every materialised
|
||||
peer becomes its own `peer:<participantId>` track with this plugin at
|
||||
slot 0.
|
||||
|
||||
The plugin is intentionally a thin shell at chunk-A scope: it owns the
|
||||
`MediaStream` reference and outputs silence until the engine's
|
||||
source-stream adapter (chunk B) replaces the silent fallback with the
|
||||
live samples. Keeping the bridge inert at this scope is what allows
|
||||
`@suaveplan/react-audio-stream` to satisfy the stream-tier purity rule
|
||||
— there are no `AudioContext`, `AudioNode`, or `AudioWorklet`
|
||||
references in the package.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `AudioFxPlugin` via `AbstractAudioFxPlugin` — every
|
||||
parameter/preset hook works out of the box.
|
||||
- `io.inputs[0].count === 0` per the source-plugin protocol, so the
|
||||
chain validator places it at slot 0.
|
||||
- `setSourceStream(stream)` / `getSourceStream()` for orchestration.
|
||||
- Zod-validated options + preset payloads; failures throw
|
||||
`REACT_AUDIO_STREAM.INVALID_OPTIONS`.
|
||||
- Silence fallback in `processBlock` keeps the chain numerically safe
|
||||
before the engine adapter takes over.
|
||||
- Participant id baked into plugin metadata for OTel + UI labelling.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createStreamSourcePlugin } from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
const plugin = createStreamSourcePlugin({ participantId: "alice" });
|
||||
plugin.setSourceStream(remoteAliceStream);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createStreamSourcePlugin,
|
||||
type StreamSourcePluginPort,
|
||||
} from "@suaveplan/react-audio-stream/session";
|
||||
|
||||
function attachPeer(
|
||||
participantId: string,
|
||||
stream: MediaStream,
|
||||
chain: PluginChain,
|
||||
): StreamSourcePluginPort {
|
||||
const plugin = createStreamSourcePlugin({
|
||||
participantId,
|
||||
initialStream: stream,
|
||||
outputChannelCount: 2,
|
||||
});
|
||||
chain.add(plugin); // slot 0 — source plugin
|
||||
return plugin;
|
||||
}
|
||||
|
||||
// Later, when the peer ends their stream:
|
||||
function detach(plugin: StreamSourcePluginPort): void {
|
||||
plugin.setSourceStream(undefined);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamSourcePlugin` — `AbstractAudioFxPlugin` subclass implementing
|
||||
`StreamSourcePluginPort`.
|
||||
- `createStreamSourcePlugin(options): StreamSourcePlugin` — factory.
|
||||
- `StreamSourcePluginOptions` — `{ participantId, initialStream?,
|
||||
outputChannelCount? }`.
|
||||
- `StreamSourcePluginPort` — minimal surface `{ participantId,
|
||||
getSourceStream, setSourceStream }` exposed to orchestration code.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The plugin uses `unknown` rather than `MediaStream` as the stream
|
||||
type to stay strictly free of browser-globals at compile time — the
|
||||
package's TS config does not pull DOM lib references for non-test code,
|
||||
and the `unknown` shape lets chunk-B transports narrow at their own
|
||||
seam. The plugin's metadata id is suffixed with the participant id so
|
||||
OTel emissions keyed by plugin id distinguish per-peer rates even
|
||||
inside a single session. `processBlock` writes zeros into every output
|
||||
channel rather than allocating a fresh buffer because
|
||||
`AbstractAudioFxPlugin` already supplies fixed-length output arrays;
|
||||
this keeps the inner loop free of allocations under steady-state
|
||||
playback. The Zod boundary on options + preset matches the convention
|
||||
used by sibling source plugins (`audio-fx-plugin-microphone`) so
|
||||
consumers see consistent error shapes across the family.
|
||||
91
docs/ui/react/react-audio-stream/src/telemetry/telemetry.md
Normal file
91
docs/ui/react/react-audio-stream/src/telemetry/telemetry.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# `telemetry`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The telemetry module owns every OpenTelemetry instrument published by
|
||||
`@suaveplan/react-audio-stream`. Concentrating the OTel surface in one
|
||||
file means the session class never touches `@opentelemetry/api` and
|
||||
tests can verify call counts by spying on a single verb-shaped object
|
||||
instead of low-level meter handles. The shape matches Requirement 7 of
|
||||
the parent spec — two counters, a gauge, two more counters, and a
|
||||
histogram — so the runtime instrument set can be cross-checked against
|
||||
the spec on every change.
|
||||
|
||||
Every emission is no-op until a host registers an OTel SDK provider
|
||||
via `@suaveplan/opentelemetry`. The module-scope meter handles are
|
||||
cheap (one map lookup) so we can keep them at file scope without
|
||||
inflating cold-start cost.
|
||||
|
||||
## Features
|
||||
|
||||
- Counter `react_audio_stream_peer_connect_total{transport}`.
|
||||
- Counter `react_audio_stream_peer_disconnect_total{reason}`, where
|
||||
`reason` covers every `InterruptionClass` plus `explicit`.
|
||||
- UpDown counter `react_audio_stream_active_sessions` (gauge-equivalent
|
||||
via OTel's synchronous up/down counter).
|
||||
- Counter `react_audio_stream_reconnect_attempt_total`.
|
||||
- Counter `react_audio_stream_reconnect_failed_total`.
|
||||
- Histogram `react_audio_stream_rtt_ms{participant}` for the
|
||||
transport's RTT probe.
|
||||
- Verbs (`peerConnect`, `peerDisconnect`, …) keep call sites tidy and
|
||||
give tests one knob per spec requirement.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { streamTelemetry } from "@suaveplan/react-audio-stream";
|
||||
|
||||
streamTelemetry.peerConnect("webrtc");
|
||||
streamTelemetry.recordRtt("alice", 42);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { streamTelemetry } from "@suaveplan/react-audio-stream";
|
||||
|
||||
class ReconnectLadder {
|
||||
async run(verdict: InterruptionVerdict): Promise<void> {
|
||||
for (let attempt = 1; attempt <= this.max; attempt++) {
|
||||
streamTelemetry.reconnectAttempt();
|
||||
try {
|
||||
await this.session.connect(this.roomId);
|
||||
return;
|
||||
} catch {
|
||||
await this.backoff(attempt);
|
||||
}
|
||||
}
|
||||
streamTelemetry.reconnectFailed();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface StreamTelemetry` — verb surface consumed by the session.
|
||||
- `const streamTelemetry: StreamTelemetry` — singleton implementation.
|
||||
- `type DisconnectReason` — `"network" | "media-permission" |
|
||||
"signalling" | "peer" | "unknown" | "explicit"`.
|
||||
- `type TransportLabel` — `"webrtc" | "websocket" | "trystero" |
|
||||
"local"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
OTel's `Gauge` requires async observable callbacks which complicates
|
||||
the testable verb surface; the spec wording reads as "gauge tracks
|
||||
active sessions" and OTel's `UpDownCounter` is the synchronous
|
||||
equivalent that satisfies the same semantics — every monitoring backend
|
||||
renders it as a gauge. The reason labels are constrained to the
|
||||
`DisconnectReason` union so a typo in the call site is a compile
|
||||
error rather than a silent cardinality leak. Transport labels are
|
||||
constrained the same way — adding a new transport in chunk B requires
|
||||
extending the union (one line) so backend dashboards stay in sync with
|
||||
the code automatically. The participant id is used as a histogram
|
||||
attribute even though it is high-cardinality because RTT samples are
|
||||
per-peer by nature; consumers wiring a low-cardinality backend
|
||||
(Prometheus) should drop the attribute via a view in their SDK
|
||||
configuration rather than us removing it here.
|
||||
59
docs/ui/react/react-audio-stream/src/test-setup.md
Normal file
59
docs/ui/react/react-audio-stream/src/test-setup.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# test-setup
|
||||
|
||||
## Purpose
|
||||
|
||||
`test-setup.ts` is the per-worker bootstrap file referenced by `vitest.config.ts`'s
|
||||
`setupFiles`. It registers `@testing-library/jest-dom`'s custom matchers
|
||||
(`toBeInTheDocument`, `toHaveAttribute`, `toHaveTextContent`, ...) onto Vitest's
|
||||
`expect`, and it installs a global `afterEach` hook that runs
|
||||
`@testing-library/react`'s `cleanup()` so each test starts with a fresh DOM tree.
|
||||
|
||||
The auto-cleanup that ships with `@testing-library/react` is gated on
|
||||
`globalThis.afterEach` being present at import time, which is not the case under
|
||||
Vitest's `globals: false` mode used by this package — hence the explicit hook here.
|
||||
|
||||
## Features
|
||||
|
||||
- Loads `@testing-library/jest-dom/vitest` (Vitest-native matcher extension).
|
||||
- Installs an `afterEach` hook to call `cleanup()`.
|
||||
- Runs once per worker process; idempotent across re-imports.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
The file is referenced by `vitest.config.ts`:
|
||||
|
||||
```ts
|
||||
setupFiles: ["@suaveplan/testing/polyfills", "./src/test-setup.ts"],
|
||||
```
|
||||
|
||||
Consumers do not import it directly.
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
To add another test-time global (e.g. a fake `localStorage` reset), extend this file:
|
||||
|
||||
```ts
|
||||
import { afterEach } from "@suaveplan/testing/runner";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
This module exports nothing. Side effects only.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `@testing-library/jest-dom/vitest` entry point automatically calls
|
||||
`expect.extend(matchers)` for Vitest's `expect` singleton, which is why no explicit
|
||||
`expect.extend` call is needed here. Importing the package root instead (without
|
||||
`/vitest`) would attach the matchers to Jest's globals — wrong runtime for this
|
||||
package, and a silent no-op under Vitest because the matchers never see the right
|
||||
`expect`. The two import order also matters: jest-dom must be loaded before any
|
||||
test file executes so the matcher patches are visible when the first test runs.
|
||||
Listing this file second in `setupFiles` (after the polyfills bootstrap) preserves
|
||||
that order.
|
||||
65
docs/ui/react/react-audio-stream/src/test-utils.md
Normal file
65
docs/ui/react/react-audio-stream/src/test-utils.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# test-utils
|
||||
|
||||
## Purpose
|
||||
|
||||
`test-utils.tsx` provides the in-memory `FakeSession` and the `withProvider` wrapper used
|
||||
by every chunk-C hook + component test. The fake mirrors the canonical `StreamSessionLike`
|
||||
contract from the spec — same method names, same event channels — so tests exercise the
|
||||
real React render path without booting transports or audio engines. Tests inject the
|
||||
fake via the provider's `sessionFactory`, drive state mutations through the public API,
|
||||
and assert the rendered DOM, hook return value, and recorded method calls.
|
||||
|
||||
The file lives at the package root rather than inside a feature directory because it is
|
||||
shared by every feature module; `vitest.config.ts` excludes it from coverage.
|
||||
|
||||
## Features
|
||||
|
||||
- `FakeSession` — `StreamSessionLike` implementation with synchronous mutators, an
|
||||
internal `calls` recorder, and helpers (`pushPeer`, `setMixer`, `triggerInterruption`)
|
||||
that emit the matching event channel.
|
||||
- `withProvider(session)` — returns a `wrapper` suitable for
|
||||
`renderHook`/`render({ wrapper })`.
|
||||
- Strongly typed: every method signature matches the canonical spec so a compile-time
|
||||
break in chunks A+B surfaces here first.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { FakeSession, withProvider } from "../../test-utils.js";
|
||||
import { useStreamSession } from "./use-stream-session.js";
|
||||
|
||||
const session = new FakeSession();
|
||||
const { result } = renderHook(() => useStreamSession(), {
|
||||
wrapper: withProvider(session),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
const session = new FakeSession({
|
||||
state: "joined",
|
||||
peers: [{ id: "alice", displayName: "Alice", connectionState: "connected", rttMs: 32 }],
|
||||
});
|
||||
// Drive state changes deterministically:
|
||||
await act(async () => session.pushPeer({ id: "bob", ... }));
|
||||
expect(result.current.peers).toHaveLength(2);
|
||||
expect(session.calls.setTrackVolume).toEqual([["alice", -3]]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FakeSessionInit` — constructor options bag.
|
||||
- `FakeSession` — `StreamSessionLike` plus `calls`, `pushPeer`, `setPeers`, `setMixer`,
|
||||
`setLocalLevel`, `setPeerLevel`, `triggerInterruption`.
|
||||
- `withProvider(session)` — `(props: { children }) => ReactNode`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The fake's event-emit path is synchronous (no microtask hop) so test assertions can run
|
||||
immediately after the mutation without `act` for simple cases. When tests need to
|
||||
observe a React re-render fence, they wrap the mutator in `act(() => session.foo())`.
|
||||
The recorded `calls` arrays let solo-semantics tests verify that mutations route through
|
||||
the session's `setTrackVolume` / `setTrackMute` / `setTrackSolo` rather than being
|
||||
re-implemented inside the hook.
|
||||
@@ -0,0 +1,76 @@
|
||||
# control-channel
|
||||
|
||||
## Purpose
|
||||
|
||||
The control-channel module is the wire-protocol heart of transport sharing. It defines the envelope shapes that flow between the granting peer (the "driver") and every connected receiver, and it implements the `TransportShareCoordinator` — a small state-aware adapter that wires a local `SharedTransportTarget` to a peer-level `ControlChannel`.
|
||||
|
||||
The coordinator is the only place in the package where transport events become wire messages. Everything else — hooks, components, session glue — observes the coordinator's mode and applied envelopes rather than touching the transport or channel directly. Keeping the translation pinned to one module means the protocol is straightforward to audit and to evolve without touching React code paths.
|
||||
|
||||
## Features
|
||||
|
||||
- Four-envelope wire protocol: `play`, `pause`, `seek`, `setTempo`.
|
||||
- Driver-side listener attaches to the transport's `start`, `pause`, and `bpm` events and forwards each as the matching envelope.
|
||||
- Receiver-side listener applies each inbound envelope to the local transport target via `start`, `pause`, `seek`, `setTempo`.
|
||||
- Mode-flip safety: switching between `local`, `driving`, and `receiving` always detaches the previous direction's listeners with no leaks.
|
||||
- Imperative `notifySeek(seconds)` for callers performing programmatic seeks (the underlying transport does not emit a `seek` event).
|
||||
- `onModeChange` and `onEnvelope` subscriptions for higher layers (the hook).
|
||||
- `dispose()` releases all listeners and the channel subscription.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createTransportShareCoordinator,
|
||||
type ControlChannel,
|
||||
type SharedTransportTarget,
|
||||
} from "@suaveplan/react-audio-stream/transport-share";
|
||||
|
||||
declare const transport: SharedTransportTarget;
|
||||
declare const channel: ControlChannel;
|
||||
|
||||
const coordinator = createTransportShareCoordinator({ transport, channel });
|
||||
coordinator.drive(); // become the driver — local events now propagate
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const coordinator = createTransportShareCoordinator({ transport, channel });
|
||||
|
||||
const unsubscribeMode = coordinator.onModeChange((mode) => {
|
||||
console.info("share role is now", mode);
|
||||
});
|
||||
|
||||
const unsubscribeEnvelope = coordinator.onEnvelope((envelope) => {
|
||||
if (envelope.type === "setTempo") {
|
||||
console.info("tempo synced to", envelope.bpm);
|
||||
}
|
||||
});
|
||||
|
||||
coordinator.receive(); // apply inbound envelopes to the local transport
|
||||
// ... share session runs ...
|
||||
coordinator.release(); // return to local-only mode
|
||||
unsubscribeMode();
|
||||
unsubscribeEnvelope();
|
||||
coordinator.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createTransportShareCoordinator(opts): TransportShareCoordinator` — factory.
|
||||
- `TransportShareCoordinator`
|
||||
- `getMode(): TransportShareMode`
|
||||
- `drive(): void`
|
||||
- `receive(): void`
|
||||
- `release(): void`
|
||||
- `notifySeek(seconds: number): void`
|
||||
- `onModeChange(listener): () => void`
|
||||
- `onEnvelope(listener): () => void`
|
||||
- `dispose(): void`
|
||||
- `TransportShareControlMessage` — discriminated union of envelope types.
|
||||
- `ControlChannel` — `{ send, onMessage }` adapter implemented by chunk A's session over the peer transport.
|
||||
- `SharedTransportTarget` — structural `{ start, pause, seek, setTempo, on }` shape that the real `Transport` satisfies via the `useTransport` adapter from `@suaveplan/react-audio`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The coordinator is intentionally stateless beyond its current mode and the cached detach callbacks. Mode changes are atomic: detach previous direction, attach new direction, then flip the role flag and notify subscribers. The driver-side listener defensively reads `payload.time` / `payload.offset` / `payload.bpm` as `number | undefined` because the upstream `TransportEventMap` declares those keys as plain `unknown`; the coordinator coerces missing values to safe defaults rather than relying on transport internals. Disposal is idempotent so the React hook's cleanup effect can call it on unmount without guards. The factory exists so tests can construct the coordinator with the same call shape consumers use through the package's barrel.
|
||||
@@ -0,0 +1,67 @@
|
||||
# share-state
|
||||
|
||||
## Purpose
|
||||
|
||||
The share-state module owns the role-machine that decides whether a peer is in `'local'`, `'driving'`, or `'receiving'` mode. It is intentionally separate from the wire-level coordinator: the state machine is a tiny, framework-free object that the hook can subscribe to without ever touching transport listeners.
|
||||
|
||||
Splitting the two concerns keeps the React layer cheap. The hook only needs to re-render when the role flips, the driver identity changes, or the audience grows — not on every envelope. The coordinator handles per-envelope mechanics; the state machine handles role transitions.
|
||||
|
||||
## Features
|
||||
|
||||
- Three-role state: `local`, `driving`, `receiving`.
|
||||
- Pending-request bookkeeping: `request(participantId)` stores a pending invitation without changing role.
|
||||
- `grant(selfId, receiverId)` — transition into `'driving'` and add receivers to the audience.
|
||||
- `receiveFrom(driverId)` — transition into `'receiving'`.
|
||||
- `removeReceiver(receiverId, releaseOnEmpty)` — drop a receiver; optionally fall back to `'local'` when the audience is empty.
|
||||
- `driverDisconnect()` — receiver-side hook for the spec scenario "driver disconnect returns share status to local".
|
||||
- `release()` — voluntarily exit any active share role.
|
||||
- Immutable snapshots returned from every method and emitted to subscribers.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ShareStateMachine } from "@suaveplan/react-audio-stream/transport-share";
|
||||
|
||||
const machine = new ShareStateMachine();
|
||||
machine.grant("self-id", "alice");
|
||||
console.log(machine.getState().role); // "driving"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const machine = new ShareStateMachine();
|
||||
const unsubscribe = machine.subscribe((state) => {
|
||||
if (state.role === "receiving") {
|
||||
console.info("now receiving from", state.driverId);
|
||||
}
|
||||
});
|
||||
|
||||
machine.request("bob"); // ask bob to drive
|
||||
machine.receiveFrom("bob"); // bob granted us — role flips to receiving
|
||||
machine.driverDisconnect(); // bob left mid-share — back to local
|
||||
|
||||
unsubscribe();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ShareStateMachine`
|
||||
- `getState(): ShareState`
|
||||
- `request(participantId): ShareState`
|
||||
- `hasPendingRequest(participantId): boolean`
|
||||
- `grant(selfId, receiverId?): ShareState`
|
||||
- `receiveFrom(driverId): ShareState`
|
||||
- `removeReceiver(receiverId, releaseOnEmpty?): ShareState`
|
||||
- `driverDisconnect(): ShareState`
|
||||
- `release(): ShareState`
|
||||
- `subscribe(listener): () => void`
|
||||
- `ShareRole` — `'local' | 'driving' | 'receiving'`.
|
||||
- `ShareState` — `{ role, driverId, receivers }` immutable snapshot.
|
||||
- `ShareStateListener` — `(state: ShareState) => void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Snapshots are produced eagerly with `[...receivers]` spreads so external consumers can hold references across transitions without observing in-place mutation. The internal `Set<ShareStateListener>` allows duplicate subscriptions to be deduplicated and resists listener-leakage if the caller forgets to unsubscribe — the React hook always pairs subscribe with an effect-cleanup return so the leak is bounded to component lifetime.
|
||||
|
||||
Transitions are deliberately permissive: calling `release()` from `'local'` is a no-op rather than a throw, matching the spec rule "controls on receivers SHALL no-op without throwing". `grant()` while already driving extends the audience instead of resetting it — multiple receivers can join an in-flight share without re-broadcasting the initial play state. `driverDisconnect()` is a guarded transition that only acts in `'receiving'`; if a stale ICE-disconnect event arrives after the local peer has already exited share mode, the call is a safe no-op.
|
||||
81
docs/ui/react/react-audio-stream/src/transports/transport.md
Normal file
81
docs/ui/react/react-audio-stream/src/transports/transport.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Stream transport adapters — shared types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `transport.types.ts` module declares the structural contract that
|
||||
`StreamSession` (authored in chunk A of the `@suaveplan/react-audio-stream`
|
||||
split) accepts via constructor injection. By co-locating the contract with
|
||||
the three concrete adapters (WebRTC, WebSocket, Trystero) in chunk B we
|
||||
guarantee every adapter conforms to the same surface and the session can
|
||||
hot-swap them without re-implementation. The shapes mirror the canonical
|
||||
specification
|
||||
so chunk-A and chunk-B merges remain structurally compatible.
|
||||
|
||||
## Features
|
||||
|
||||
- `StreamTransportAdapter` — DI contract for any peer-to-peer audio transport.
|
||||
- `TransportEvent` — discriminated union covering connect, disconnect,
|
||||
inbound track, and RTT-probe ticks.
|
||||
- `SignallingChannel` / `SignallingMessage` — transport-neutral signalling
|
||||
envelope used by the WebRTC adapter (callers MUST plug their own channel).
|
||||
- `IceConfig` — STUN/TURN configuration with no embedded defaults.
|
||||
- `InterruptionClass` / `InterruptionCause` — disconnect-cause classification
|
||||
fed into chunk A's telemetry histogram.
|
||||
- `ReactAudioStreamErrorLike` — local mirror of chunk A's error shape so
|
||||
chunk-B tests can assert on `.code` without a circular merge dependency.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { StreamTransportAdapter } from "@suaveplan/react-audio-stream/transports";
|
||||
|
||||
function logId(adapter: StreamTransportAdapter): void {
|
||||
console.log(adapter.id);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
StreamTransportAdapter,
|
||||
TransportEvent,
|
||||
} from "@suaveplan/react-audio-stream/transports";
|
||||
|
||||
function watch(adapter: StreamTransportAdapter): () => void {
|
||||
return adapter.subscribe((event: TransportEvent) => {
|
||||
switch (event.type) {
|
||||
case "connected":
|
||||
console.log("peer joined", event.peerId);
|
||||
break;
|
||||
case "disconnected":
|
||||
console.warn("peer dropped", event.class, event.cause);
|
||||
break;
|
||||
case "rtt":
|
||||
console.debug("rtt", event.peerId, event.ms, "ms");
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamTransportAdapter.connect(roomId)` — join a named room.
|
||||
- `StreamTransportAdapter.disconnect()` — leave + tear down peer state.
|
||||
- `StreamTransportAdapter.send(peerId, payload)` — unicast bytes.
|
||||
- `StreamTransportAdapter.broadcast(payload)` — fan-out bytes.
|
||||
- `StreamTransportAdapter.getRtt(peerId?)` — sample round-trip in ms.
|
||||
- `StreamTransportAdapter.subscribe(handler)` — wire `TransportEvent` flow.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The contract is intentionally minimal: it omits opinionated state-machine
|
||||
hooks because `StreamSession` owns its own FSM (chunk A). Adapters MUST
|
||||
report disconnects with a fully populated `InterruptionCause` so chunk A's
|
||||
`InterruptionClassifier` can map ICE state, close code, or media-permission
|
||||
error names without probabilistic heuristics.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Trystero transport adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`TrysteroTransport` is the serverless `StreamTransportAdapter` powered by
|
||||
`@suaveplan/network-discovery-trystero`. It piggybacks on BitTorrent,
|
||||
Nostr, MQTT, or IPFS as a discovery + signalling layer so consumers can
|
||||
host WebRTC sessions without operating any back end of their own. This
|
||||
adapter is the ONLY file in `@suaveplan/react-audio-stream` that touches
|
||||
the trystero package; every other surface excludes it from the bundle
|
||||
graph thanks to the subpath gate and the lazy `loader`.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy loader injection — trystero is only imported when the caller
|
||||
actually constructs this adapter.
|
||||
- Maps `room.onPeerJoin` / `room.onPeerLeave` directly to the shared
|
||||
`TransportEvent` flow (`connected`, `disconnected`).
|
||||
- Unicast via `makeAction("audio")` with the `targets` argument; broadcast
|
||||
via the same action with `undefined` targets.
|
||||
- RTT probe via `room.ping(peerId)` (Trystero's built-in primitive).
|
||||
- Loader failure surfaces as `REACT_AUDIO_STREAM.SIGNALLING_FAILED` with
|
||||
the underlying error attached as `cause`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TrysteroTransport } from "@suaveplan/react-audio-stream/transports/trystero";
|
||||
|
||||
const transport = new TrysteroTransport({
|
||||
id: "tst-1",
|
||||
appId: "myapp",
|
||||
localPeerId: "alice",
|
||||
loader: () => import("@suaveplan/network-discovery-trystero"),
|
||||
});
|
||||
await transport.connect("room-42");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const transport = new TrysteroTransport({
|
||||
id: "tst-2",
|
||||
appId: "myapp",
|
||||
localPeerId: "alice",
|
||||
loader: () => import("@suaveplan/network-discovery-trystero"),
|
||||
});
|
||||
const off = transport.subscribe((event) => {
|
||||
if (event.type === "connected") console.log("peer joined", event.peerId);
|
||||
});
|
||||
await transport.connect("room-42");
|
||||
await transport.broadcast(new Uint8Array([1, 2, 3]));
|
||||
const rtt = await transport.getRtt();
|
||||
console.log("rtt", rtt, "ms");
|
||||
off();
|
||||
await transport.disconnect();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `connect(roomId)` — lazily loads trystero + joins the room.
|
||||
- `send(peerId, payload)` — unicast via the `audio` action.
|
||||
- `broadcast(payload)` — fan-out via the same action.
|
||||
- `getRtt(peerId?)` — `room.ping(peerId)` in ms.
|
||||
- `subscribe(handler)` — register a `TransportEvent` listener.
|
||||
- `disconnect()` — calls `room.leave()` and releases the action handle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`@suaveplan/network-discovery-trystero` is declared as an optional peer
|
||||
dependency. The chunk-B branch does not modify `package.json` (chunk A
|
||||
owns it) so the optional dep + matching `./transports/trystero` subpath
|
||||
export are tracked as a hand-off item for the merge step. The lazy
|
||||
`loader` argument exists so tests can inject a deterministic in-memory
|
||||
room implementation, and so production code never pulls trystero into the
|
||||
WebRTC- or WebSocket-only bundles.
|
||||
@@ -0,0 +1,76 @@
|
||||
# WebRTC transport adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`WebRtcTransport` is the low-latency default `StreamTransportAdapter` for
|
||||
`@suaveplan/react-audio-stream`. It wires a caller-supplied signalling
|
||||
channel into one `RTCPeerConnection` per remote peer, opens an `RTCDataChannel`
|
||||
for control + audio framing, and reports ICE state transitions through the
|
||||
shared `TransportEvent` flow so chunk A's session FSM can classify
|
||||
disconnects deterministically. The adapter ships **no embedded STUN/TURN
|
||||
defaults** — production deployments MUST supply their own `iceConfig`.
|
||||
|
||||
## Features
|
||||
|
||||
- One peer connection per remote, indexed by signalling source ID.
|
||||
- Outbound offer (`offerTo`) and inbound offer/answer/ICE handshake driven
|
||||
by the injected `SignallingChannel`.
|
||||
- ICE-state-driven disconnect classification (network / peer / unknown).
|
||||
- RTT probe via `getStats()` reading `candidate-pair.currentRoundTripTime`.
|
||||
- Constructor validation: rejects empty `iceServers` with
|
||||
`REACT_AUDIO_STREAM.INVALID_OPTIONS` to enforce explicit configuration.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { WebRtcTransport } from "@suaveplan/react-audio-stream/transports/webrtc";
|
||||
|
||||
const transport = new WebRtcTransport({
|
||||
id: "rtc-1",
|
||||
localPeerId: "alice",
|
||||
iceConfig: { iceServers: [{ urls: "stun:stun.example.com:3478" }] },
|
||||
signalling: mySignallingChannel,
|
||||
peerConnectionFactory: (cfg) => new RTCPeerConnection(cfg),
|
||||
});
|
||||
await transport.connect("room-42");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const off = transport.subscribe((event) => {
|
||||
if (event.type === "disconnected" && event.class === "network") {
|
||||
scheduleReconnect();
|
||||
}
|
||||
});
|
||||
await transport.offerTo("bob");
|
||||
const rtt = await transport.getRtt("bob");
|
||||
console.log("rtt", rtt, "ms");
|
||||
off();
|
||||
await transport.disconnect();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new WebRtcTransport(config)` — validates `iceServers` length.
|
||||
- `connect(roomId)` — subscribes the signalling channel.
|
||||
- `offerTo(peerId)` — initiate an outbound offer.
|
||||
- `send(peerId, payload)` — unicast over the open data channel.
|
||||
- `broadcast(payload)` — fan-out to every open channel.
|
||||
- `getRtt(peerId?)` — sample `candidate-pair` RTT in milliseconds.
|
||||
- `subscribe(handler)` — register a `TransportEvent` listener.
|
||||
- `disconnect()` — close every peer + detach signalling.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`RTCPeerConnection` is consumed via a structural `PeerConnectionLike` view
|
||||
so deterministic in-memory implementations can drive tests without real
|
||||
ICE. Disconnect classification follows the spec mapping: `failed` /
|
||||
`disconnected` → `network`; `closed` → `peer`; anything else → `unknown`.
|
||||
The adapter never instantiates STUN/TURN itself — embedded defaults would
|
||||
be a security hazard because they would leak peer IPs to whoever operates
|
||||
the default STUN.
|
||||
@@ -0,0 +1,80 @@
|
||||
# WebSocket transport adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`WebSocketTransport` is the fallback `StreamTransportAdapter` consumers
|
||||
choose when a corporate or carrier NAT blocks WebRTC's UDP path. It
|
||||
multiplexes every remote peer over a single binary WebSocket using a
|
||||
length-prefixed framing header (`target` + `codec` tag + payload) and
|
||||
optionally upgrades raw PCM to Opus when the
|
||||
`@suaveplan/audio-codec-ffmpeg-wasm` encoder is lazily available.
|
||||
|
||||
## Features
|
||||
|
||||
- Binary framing with explicit target peer ID + codec discriminator.
|
||||
- Lazy Opus encoder load with graceful raw-PCM fallback on failure.
|
||||
- Heartbeat ping loop driving server-reported RTT samples.
|
||||
- Disconnect classification by close code (`1000` → peer, otherwise network).
|
||||
- JSON control frames (`join`, `leave`, `pong`) drive connect / disconnect /
|
||||
RTT events without a second socket.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { WebSocketTransport } from "@suaveplan/react-audio-stream/transports/websocket";
|
||||
|
||||
const transport = new WebSocketTransport({
|
||||
id: "ws-1",
|
||||
url: "wss://relay.example.com/audio",
|
||||
localPeerId: "alice",
|
||||
socketFactory: (url) => new WebSocket(url),
|
||||
});
|
||||
await transport.connect("room-42");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const transport = new WebSocketTransport({
|
||||
id: "ws-2",
|
||||
url: "wss://relay.example.com/audio",
|
||||
localPeerId: "alice",
|
||||
heartbeatMs: 2000,
|
||||
socketFactory: (url) => new WebSocket(url),
|
||||
opusEncoderLoader: async () => {
|
||||
const mod = await import("@suaveplan/audio-codec-ffmpeg-wasm");
|
||||
return mod.createOpusEncoder();
|
||||
},
|
||||
});
|
||||
const off = transport.subscribe((e) => {
|
||||
if (e.type === "rtt") console.log("rtt", e.peerId, e.ms);
|
||||
});
|
||||
await transport.connect("room-42");
|
||||
await transport.broadcast(new Uint8Array([0, 1, 2]));
|
||||
off();
|
||||
await transport.disconnect();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `connect(roomId)` — opens the socket and starts the optional heartbeat.
|
||||
- `send(peerId, payload)` — frames + sends binary; throws
|
||||
`REACT_AUDIO_STREAM.SIGNALLING_FAILED` if the socket is not open.
|
||||
- `broadcast(payload)` — sends with `target = "*"`.
|
||||
- `getRtt(peerId?)` — returns the last `pong`-reported RTT in ms.
|
||||
- `subscribe(handler)` — register a `TransportEvent` listener.
|
||||
- `disconnect()` — closes the socket with code `1000`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wire framing keeps target IDs at sub-65 KB and codec selection on a
|
||||
single byte for trivial branch prediction. Opus loading is gated behind a
|
||||
caller-supplied loader so the adapter never imports
|
||||
`@suaveplan/audio-codec-ffmpeg-wasm` at module scope — a hard requirement
|
||||
for the bundle-size purity test. Heartbeat scheduling is disabled by
|
||||
default (`heartbeatMs = 0`) so unit tests can drive RTT through synthetic
|
||||
`pong` frames.
|
||||
Reference in New Issue
Block a user