diff --git a/docs/superpowers/specs/2026-06-28-slice-1-webrtc-loopback-design.md b/docs/superpowers/specs/2026-06-28-slice-1-webrtc-loopback-design.md index 4b5ec3c..0024002 100644 --- a/docs/superpowers/specs/2026-06-28-slice-1-webrtc-loopback-design.md +++ b/docs/superpowers/specs/2026-06-28-slice-1-webrtc-loopback-design.md @@ -118,28 +118,50 @@ Stubs **do not** anticipate code. They lock the boundary shape; that is their on ### 3.1 Components -- **`RtcSession`** — owns a str0m `Live` instance plus the per-peer codec state (an Opus - decoder + an Opus encoder). One per WebRTC peer. +- **`RtcSession`** — owns a `str0m::Rtc` instance (the main sans-IO driver type; str0m + has no `Live` struct) plus the per-peer codec state (an Opus decoder + an Opus + encoder). One per WebRTC peer. - **`PcmFrame`** — the canonical tap format from ARCHITECTURE.md: 16-bit signed mono PCM @ 24 kHz, fixed frame size (20 ms = 480 samples). This is the single format every future brain/tap consumer speaks. Defined here in slice 1; `rutster-tap` will re-export it once that crate fills in (step 2 — keeps one canonical home). - **Codec pair** — `opus` crate (libopus via FFI; PORT_PLAN §7 disposition for Opus is 🦀 Core (FFI)). Decoder: `opus_decode` → fills a `PcmFrame`; Encoder: `opus_encode` - from ring-buffered PCM → RTP packet payload. + from ring-buffered PCM → Opus payload for str0m's Frame API. ### 3.2 Loop shape (Approach A from brainstorming) +str0m exposes two API surfaces: the **Frame API** (default; str0m handles +packetization internally, applications push/pop decoded media frames) and the **RTP API** +(opt-in via `RtcConfig::set_rtp_mode(true)`; raw RTP packets, the app does +packetization). Slice 1 uses the **Frame API** because slice 1's goal is to prove the +codec-to-PCM boundary, not to prove RTP packetization. The Frame API gives us decoded +audio frames as `MediaData` and accepts encoded audio back; str0m does the RTP/SRTP +framing underneath. RTP API is a candidate for step 5 (PSTN trunk) where raw-packet +control over the SIP/RTP boundary may matter. + ``` -str0m Live (sans-IO, polled on tokio — see §3.4) - ↓ Event::RtpPacket(pkt, rid) -decode Opus → PcmFrame (24 kHz mono, 20 ms) +str0m Rtc (sans-IO, polled on tokio — see §3.4) + ↓ Event::RtpPacket(RtpPacket) { …, payload } (single struct; RID is a field if present) + ↓ str0m delivers decoded audio as MediaData via the Frame API +decode Opus payload → PcmFrame (24 kHz mono, 20 ms) ↓ buffer PCM into playout ring (slice 1: echo; step 2 taps here) ↓ on each 20 ms tick -take 480 samples → encode Opus → str0m.write_rtp() +take 480 samples → encode Opus → push to str0m via Rtc::writer(mid)->Writer::write ``` +> **API-surface notes (verified against str0m 0.21 docs):** +> - `str0m::Rtc` is the main sans-IO driver; there is no `Live` type. `Rtc::handle_input(Input)` +> feeds network input; `Rtc::poll_output() -> Output` is the poll interface, where +> `Output::Timeout(Instant)` gives the next-deadline signal we sleep tokio until. +> - `Event::RtpPacket(RtpPacket)` is a single-struct variant (RID is a field on `RtpPacket`, +> not a second variant payload). +> - Inbound/`Event::RtpPacket`: in RTP mode you get raw packets; in slice 1's Frame-mode +> the canonical inbound event is `Event::MediaData(MediaData)` containing decoded media. +> - Outbound: `StreamTx::write_rtp` exists in RTP mode only; under the Frame API the path is +> `Rtc::writer(mid) -> Writer::write(...)`. + ### 3.3 The PCM-tap seam The point where decoded `PcmFrame`s emerge from the inbound side and where `PcmFrame`s @@ -163,10 +185,18 @@ Slice 1 wires an `EchoAudioPipe` (implements both traits) between sink and sourc Step 2 replaces `EchoAudioPipe` with a real tap client. No code changes to `RtcSession` itself in step 2 — that's the test of the seam. +**Who drives the traits:** the poll loop (§3.4) drives both. On each `poll_output()` cycle: +inbound `Event::MediaData` → decode → `sink.on_pcm_frame(PcmFrame)`; the outbound side +runs on the `Output::Timeout(Instant)` deadline → `source.next_pcm_frame()` → encode → +push to str0m. `RtcSession` owns the `Arc>` (or a +concrete `EchoAudioPipe` in slice 1 — no trait object needed yet; step 2 introduces +indirection when a real tap client replaces the pipe). + ### 3.4 Polling & the tokio-vs-dedicated-thread deviation -str0m is sans-IO; its `Live` API exposes `handle_input` / `needs_input_timeout` to drive -the poll. Slice 1 runs this poll on the tokio runtime. +str0m is sans-IO; its `Rtc` API exposes `handle_input(Input)` (feed network input) and +`poll_output() -> Output` (the poll interface, where `Output::Timeout(Instant)` gives the +next deadline we sleep tokio until). Slice 1 runs this poll on the tokio runtime. **This is an explicit, documented deviation from ARCHITECTURE.md**, which mandates *dedicated timing threads, never the shared tokio pool*. The deviation is **scoped to @@ -187,17 +217,23 @@ spec so it cannot be forgotten. ### 3.5 SRTP / DTLS DTLS-SRTP is mandatory from slice 1 (Security-as-product pillar). str0m handles DTLS+ -SRTP natively once fed a certificate; configured via `RtcConfig::with_dtls_cert` at -startup. +SRTP natively; configured via `RtcConfig::set_dtls_cert` at startup. If no cert is +passed, str0m auto-generates one — in slice 1 we explicitly pass our self-signed DTLS +cert (see §3.6) to make the cert ownership clear, but the auto-gen path is also +acceptable for a dev loop. **No plaintext RTP fallback path exists** in code or config. This is a deliberate floor, not a default. ### 3.6 DTLS certificate -Self-signed DTLS cert generated at startup, held in memory. No cert file in slice 1 -(cert rotation / persistence is a step-5 / trunk-integration concern). The cert is -regenerated on every server restart — fine for a dev loop. +Self-signed DTLS cert generated at startup, held in memory, **passed explicitly to +`RtcConfig::set_dtls_cert`** (per §3.5 — making cert ownership clear in code rather than +relying on str0m's auto-gen default). No cert file on disk in slice 1 (cert rotation / +persistence is a step-5 / trunk-integration concern). The cert is regenerated on every +server restart — acceptable for slice 1 because the browser fetches a fresh SDP answer +each session, so the DTLS fingerprint in the SDP always matches the current cert. No +caching layer exists to go stale. ### 3.7 Codec negotiation @@ -213,7 +249,9 @@ point. Slice 1's SDP module is a focused ~50-line mapper: parse the browser offer, extract the audio m-line, munge into our answer (Opus only, recv+send, DTLS fingerprint from our -cert, ICE ufrag/pwd from str0m). +cert, ICE ufrag/pwd via str0m's `IceCreds` / `Candidate` types at the crate root — str0m +has no public `str0m::ice` module; the ICE-relevant public surface lives at `str0m::`: +`Candidate`, `CandidateKind`, `IceCreds`, `IceConnectionState`). ### 3.8 Error handling on the hot path @@ -245,9 +283,9 @@ policy is match-and-continue). - `POST /v1/sessions` → mint a `RtcSession` (which owns a fresh `Channel`; the `ChannelId` *is* the session id). Returns `{ "session_id": "" }`. -- `POST /v1/sessions/:id/offer` (body: browser SDP offer, including all ICE - candidates — non-trickle) → core produces SDP answer (including its ICE candidates), - feeds candidates to str0m, returns the answer as `application/sdp`. +- `POST /v1/sessions/:id/offer` (body: browser SDP offer, `Content-Type: application/sdp`, + including all ICE candidates — non-trickle) → core produces SDP answer (including its + ICE candidates), feeds candidates to str0m, returns the answer as `application/sdp`. - `DELETE /v1/sessions/:id` → tear down: transition `Channel` to `Closing → Closed`, drop the `RtcSession`, close the peer connection cleanly via str0m. - `GET /` → serve the static HTML test client. @@ -291,14 +329,22 @@ A single self-contained HTML file with inline JS, no build step. Behavior: - Sessions held in an in-process `DashMap` in the binary crate. The `ChannelId` (a UUID newtype from `rutster-call-model`) is the session id surfaced - in the REST API. `RtcSession` owns both the str0m `Live` + codecs and the `Channel` + in the REST API. `RtcSession` owns both the str0m `Rtc` + codecs and the `Channel` (signaling state); see §3.1 and §5. -- Idle timeout: 5 min hard cutoff. "Idle" = no RTP packets received from the peer in - the last 60 s (a longer window than the strict "no RTCP" definition, because RTC - quiet periods are normal; no-RTP-for-60s is a real "the browser tab is dead" signal). - Implemented as a per-session deadline checked on each poll cycle; no per-session - tokio task spawned (which would clutter the runtime and pre-pave the wrong pattern - for the dedicated timing thread in step 4). +- **Idle timeout: 60 s of no RTP packets received from the peer → close the session.** + (RTC quiet periods are normal but 60 s of dead air is a real "the browser tab is + dead" signal —browser-refresh, network drop, etc. 5 min was originally considered but + rejected as too long to hold dead state; 60 s is tight enough to reclaim resources + promptly while tolerating natural inter-speech silences.) Implemented as a + per-session deadline checked on each poll cycle; no per-session tokio task spawned + (which would clutter the runtime and pre-pave the wrong pattern for the dedicated + timing thread in step 4). +- **Graceful shutdown (Ctrl-C / SIGTERM):** the slice installs a tokio signal handler + that drops the `DashMap` (and thus every `RtcSession`) on shutdown. Browsers see a + dead peer connection — acceptable for the dev loop; no in-flight call preservation + story in slice 1. Cleanly closing each str0m `Rtc` is a nice-to-have — we attempt it + but don't block shutdown on a peer that won't ack. POST/DELETE callers get TCP RST, + also acceptable for dev-loop. - Restart on browser refresh: new session. No resumability. ### 4.6 API posture @@ -429,9 +475,11 @@ audiohook primitive) needs to observe it — not before. - Opus⇄PCM roundtrip: encode known PCM → decode → assert RMS within tolerance. - SDP answer munger: feed a captured browser offer fixture, assert the answer is well-formed and Opus-only. - - `RtcSession` state transitions: drive a mocked str0m `Live` by injecting fake - events (str0m is sans-IO — its `Live` is fully testable without a network. This is - the sans-IO payoff we chose in Approach A.) + - `RtcSession` state transitions: drive a **real** `str0m::Rtc` instance with + synthetic `Input` events (str0m's `Rtc` is a concrete struct, not a trait — there's + nothing to mock. Sans-IO means we drive the real type with fake inputs, not a test + double. This is the sans-IO payoff we chose in Approach A, and it's stronger than + mocking: the production code path is exercised exactly.) - **Integration test in `rutster` binary crate:** - Spin up the axum server on an ephemeral port, use `reqwest` + a `webrtc-rs` *client* (or a minimal hand-rolled SDP answer parser) to simulate a peer. @@ -514,7 +562,8 @@ them, later slices can be sparser on the well-trodden patterns). | WebRTC stack | **str0m** | webrtc-rs; defer-and-compare | Sans-IO, Rust-native, designed for embedding in a custom media loop. Maps directly onto ARCHITECTURE.md's "dedicated timing threads, not the shared tokio pool." Smaller community but the right shape. | | Workspace shape | **Full architecture-shaped workspace** | Workspace: media lib + binary; single binary crate | Locks the ADR-0002 fused-vertical boundary from day one. Speculative crate boundaries risk not surviving real code, but stub crates cost ~zero and prevent a future rename churn. | | Loopback scope | **+ Terminate codec to PCM** | Bare RTP passthrough; audio-only echo | Proves the canonical PCM tap format, not just RTP passthrough. Pre-paves step 2 (the tap). | -| Media-loop structure | **A. str0m Live + codec hook** | B. Full sans-IO decomposition; C. Opaque Peer + dedicated thread now | Leans on str0m's pacing for slice 1; dedicated timing thread arrives in step 4 when barge-in needs determinism. Smallest code that proves the codec boundary. | +| Media-loop structure | **A. str0m Rtc + codec hook** | B. Full sans-IO decomposition; C. Opaque Peer + dedicated thread now | Leans on str0m's pacing for slice 1; dedicated timing thread arrives in step 4 when barge-in needs determinism. Smallest code that proves the codec boundary. | +| Media API path | **str0m Frame API** (default) | RTP API (`set_rtp_mode(true)`) | Frame API handles RTP packetization internally; slice 1's proof target is the codec-to-PCM boundary, not packetization. RTP API is a candidate for step 5 (PSTN trunk). | | Poll execution | tokio (slice 1 deviation) | Dedicated thread now | No reflex to time against; dedicated thread now is theater. Step 4 lands the dedicated thread. Explicit, documented deviation. | | HTTP TLS | None (dev-only plaintext) | Self-signed TLS now | Dev loop is loopback; the real security surface (DTLS-SRTP) is already mandatory. TLS needs the cert story from ARCHITECTURE.md, which lands with deployment posture. | | ICE | Non-trickle | Trickle | Simpler server; one round-trip. Real-world NATs (likely step 5) trigger the switch. |