Skip to content

Refactor Plan — Real-Time Media & P2P-First Distribution

Status:ImplementedReal-Time Media Transport & P2P-First Mesh feature spec into a file-grounded, phase-by-phase refactor of the current codebase. Every item cites the code it changes today.


1. What We Are Building

Make real-time audio a protocol property, not a player behavior, and make direct P2P the default distribution path with relays as fallback:

  1. 10 ms Opus frames as the default for real-time media (currently 20 ms), with 5 ms / 20 ms as tunable experiments (WS2, P6).
  2. Unreliable/datagram media channel — no retransmission, sequence + timestamp on every frame, no ACKs. Audio is a real-time channel, distinct from the reliable channel (chat/files/control): same packet transport, different rules (no retransmission, timestamps, jitter, drop-old).
  3. Adaptive small jitter buffer (20 → 30 → 50 ms) + audio-clock playback with a first-class live edge — drop obsolete frames, never chase a growing backlog.
  4. P2P-first mesh — listeners forward with capacity gating, peers can switch between Listener / Forwarder roles dynamically; relays engage only when mesh health degrades, and the stream migrates back.
  5. Per-stage latency telemetry so the latency budget is measured, not guessed.
  6. The UI observes the media system; it never sits on its critical path. No audio → IPC → UI → state → network. UI reads telemetry from the side.
  7. Capacity scales with listeners — the station feeds a small peer set and the mesh propagates; the more listeners, the more distribution capacity the network acquires, not a station-bound fan-out bottleneck.

The decision engine: direct → alternate peer → new peer → relay, always with a prepared alternate path so the switch happens at a known media timestamp, never as a reconnect-and-rebuffer gap.

2. The Speed Target

Primary target < 100 ms end-to-end under good conditions; secondary 50–80 ms. Latency is a budget — every stage is accounted for.

Current (measured by architecture, not instrumented):

StageToday (web / desktop)Source
Capture granularity200–500 ms (MediaRecorder webm timeslice)packages/ifm-station/src/mixer/recorder.ts:45,89 (timesliceMs = 200); packages/ifm-pwa/src/smokeStation.ts:148-162 (rec.start(500))
Encodebrowser-side, inside MediaRecorder
Transportreliable gossipsub over QUIC streams / WebSocket (retransmit + HOL blocking); media rides the voice lane at MEDIA prioritycrates/transport/src/libp2p_quic.rs, libp2p_browser.rs
Jitter/playbackMediaSource on webm clusters; catch-up only at >2 s gappackages/sdk/examples/listener/src/ListenerApp.tsx:184-223; packages/ifm-radio-listener/src/App.tsx:246-292
Typical total≈ 300 ms – 2 s

Target budget:

StageTargetRemoved by
Capture10 msAudioWorklet capture at 10 ms frame granularity (WS7)
Encode+2–3 msOpus 10 ms frame, no-alloc hot path (WS2)
Packetize+0.2 msMedia wire format (WS1)
Network+10–20 msdatagram channel + P2P-first (WS3/WS4)
Relay+1–2 msforward-only, immediate (WS5)
Jitter+20 msadaptive small buffer (WS2)
Decode+1–2 ms
Output+5 ms
Total≈ 50–70 ms

3. Current State (As-Is) — Gap Summary

The feature spec exists and is marked not yet implemented (docs/features/realtime-media-transport.md:3). The codebase has all the seams to receive it, but none of the media machinery is wired in.

AreaTodayTargetGap
Wire formatvoice = opaque webm blob; EncodedAudioFrame is in-memory onlymedia frame = stream ID + seq + ts + codec + duration + FEC flagsFull
Frame size20 ms default everywhere10 ms default for real-time mediaDefault
Transportreliable gossipsub/QUIC streams only; no datagram APIunreliable datagram media channelFull
Prioritysingle unbounded command channel; no priorityAudio 0 / Control 1 / Chat 2 / Files 3 / Logging 4Full
Jitter buffersequence-based, target 60 ms, arrival-based depth onlysmall adaptive (20/30/50 ms), audio-clock-driven, live edgePartial
P2P-firstRust always dials rendezvous; relay = serving only. SDK has hop priority peer>relay>originpath cost function + path manager + seamless switchPartial
Telemetryrelay RTT onlyper-stage latency + live-edge offsetFull
Capture/playbackMediaRecorder webm + MediaSourceAudioWorklet raw Opus, audio-clock playbackFull

4. Gap Analysis by Area (file-grounded)

4.1 crates/protocol — media wire format

Today:

  • Packet fields: crates/protocol/src/packet.rs:37-59. Signed view covers version/frequency/sender/timestamp/sequence/payload_type/payload (packet.rs:152-161); id/signature/ttl excluded so relays can decrement TTL.
  • EncodedAudioFrame { timestamp, sequence, codec, format, payload } is an in-memory boundary, not a wire type (crates/protocol/src/frame.rs:88-100). No stream ID, no per-frame duration field (only AudioFormat.frame_duration_ms, frame.rs:54), no FEC/redundancy flags.
  • WireCodec::decode hard-rejects any version ≠ 1 (wire.rs:89-92). Framing = MAGIC + u32 LE length + bincode(Packet) (wire.rs:52-94); bincode free functions = little-endian, fixint.
  • SDK mirrors Packet without signature (packages/sdk/src/types.ts:30-41).

Change needed (WS1):

  • Add a media datagram wire type on the unreliable channel — a compact MediaFrame (stream ID, seq u32, timestamp u64, codec, format, duration ms, FEC/redundancy flags, payload), per spec §34. Keep it separate from Packet so the reliable channel and its signature/version semantics are untouched → backward compatible.
  • Decide auth model for media frames (design decision D1).
  • Decide the framing/version story for the media channel (fresh channel → no shared version gate; still needs a version byte).

4.2 crates/audio — 10 ms frames, no-alloc hot path, audio clock

Today:

  • FRAME_SAMPLES = 960, FRAMES_PER_SECOND = 50 (crates/audio/src/lib.rs:40,42).
  • AudioConfig::Default = 20 ms, jitter target 3 frames (60 ms) / min 2 (40 ms) / max 10 (200 ms) (crates/audio/src/config.rs:41-58).
  • low_latency() preset already exists: 10 ms, jitter 4/2/12 frames = 40/20/120 ms (config.rs:67-76).
  • Hot path allocates per frame: encode_vec (codec.rs:58), encode_f32 conversion buffer (codec.rs:63), decode output vec![0i16; n] (codec.rs:111), encode_frame moves payload (codec.rs:83-89). At 100 fps this matters.
  • Jitter buffer is sequence-based, not arrival-time-based (jitter.rs:130-143 uses sequence lag); it never reads frame.timestamp; pop must be driven by the consumer (jitter.rs:95-110).

Change needed (WS2):

  • Make 10 ms the default for real-time media (flip Default, or route every real-time path through low_latency()); keep 20 ms valid for recording.
  • Add a 5 ms frame experiment (Opus supports 2.5/5/10/20/40/60 ms): same pipeline, measure CPU / packet rate / quality at 5 vs 10 ms. 10 ms is the shipping default; 5 ms is a fallback lever when capture/device latency dominates (spec §5).
  • Add SAMPLE_RATE/CHANNELS constants (currently hardcoded at config.rs:44-45 and duplicated in tests/codec).
  • Preallocated encode/decode buffers (no per-frame Vec).
  • Hot-path queue rule: the capture → encode → network path must use a bounded lock-free ring buffer, never a generic async channel — an unbounded/await-scheduled queue adds scheduling latency to every frame (spec §19).
  • Arrival-time-based jitter estimation + adaptive shrink (spec §7) and audio-clock-driven pop (spec §12): a timed drain at the audio device cadence instead of consumer-timed pops.
  • Live-edge tracking: expose playback position vs live edge (spec §16).

4.3 crates/transport — datagram channel + priority queues

Today:

  • Transport trait exposes only broadcast / publish / send — all reliable (crates/transport/src/lib.rs:62-219). Grep for datagram|unreliable across crates/ → zero matches.
  • Production transport: single unbounded tokio::sync::mpsc Command channel (libp2p_quic.rs:445); broadcastgossipsub.publish (libp2p_quic.rs:1138-1141, 2604-2615). QUIC via libp2p 0.56 is stream-only — quinn datagrams are not exposed.
  • WebRTC is not implemented (only config flags; core/src/config.rs:98-105, desktop/src/core.rs:813 forces webrtc = false).
  • InMemoryMesh (memory.rs) / SharedMeshTransport (shared.rs) for tests.

Change needed (WS3):

  • Add a media channel to the Transport trait: send_media/broadcast_media
    • on_media callback + arrival timestamps, mirroring the packet API so all transports stay total (default: "media not supported" — no panic paths).
  • Implement it over quinn datagrams in the QUIC transport (add quinn with datagram support; wire quinn's datagram sender/receiver into the existing async swarm event loop as a parallel path).
  • Bounded priority command queues (Audio 0 > Control 1 > Chat 2 > Files 3 > Logging 4) replacing the single unbounded channel; media never queues behind file/chat (invariant 6 — docs/architecture/invariants.md:46-51).
  • Media traffic bypasses gossipsub fanout/dedup (it is a stream, not a topic broadcast) — a separate send path.
  • InMemoryMesh/SharedMeshTransport get a media path so Rust tests can exercise the pipeline end-to-end without sockets.

Done (WS3):

  • MediaFn callback type (frame + arrival wall-clock ms) and now_ms() in crates/transport/src/lib.rs; Transport::send_media / broadcast_media / on_media with no-op / error defaults so every transport stays total.
  • InMemoryMesh and SharedMeshTransport implement the media channel (stream, not topic: broadcast reaches every peer's on_media sink regardless of tuning; media bypasses the packet registry/gossip). Covered by 4 new tests (crates/transport/src/memory.rs, shared.rs).
  • PriorityLevel (Media 0 > Control 1 > Chat 2 > Files 3 > Logging 4) and a bounded, drop-old PriorityQueue in crates/transport/src/priority.rs (5 tests). Audio never blocks behind a lower-priority backlog; queues are bounded (spec §15 drop-old), so a slow consumer cannot stall media.

Remaining (WS3b — production libp2p side, follow-up):

  • Wire quinn datagrams into the libp2p QUIC transport's async event loop (libp2p 0.56 exposes no datagram API — this is the gap-table note at the end of this file). Until then the libp2p transport returns the trait default "media not supported" and core falls back to the legacy reliable voice path.
  • Migrate the single unbounded Command channel (libp2p_quic.rs:445) to the new PriorityQueue.

4.4 crates/core — media pipeline wiring

Today:

  • Core does not depend on ifm-audio (verified: crates/core/Cargo.toml has no audio dep). Voice = opaque PayloadType::Voice bytes (crates/core/src/node.rs:288-332); voice init-segment (webm/fMP4) caching + replay for mid-stream join (node.rs:964-988, is_stream_init 1086-1089).
  • No priority/queue handling; every packet goes through gossip dedup + TTL (gossip/src/lib.rs:108-121).

Change needed (WS4):

  • Wire ifm-audio into the node as the media pipeline (encode PCM → MediaFrametransport.send_media; receive → jitter buffer → PCM).
  • Media frames bypass gossip dedup/TTL (dedup by stream seq, not packet id).
  • Per-stream state: stream id ↔ source, sequence continuity, live edge.
  • Keep the existing webm init-segment replay for the legacy path during transition (WS7 removes the need eventually).

Done (WS4):

  • ifm-audio added to crates/core deps; Node owns a MediaPipeline (crates/core/src/media.rs) with an outbound encoder (lazy) and bounded per-stream inbound jitter buffer + decoder (MAX_RX_STREAMS, idle-evicted).
  • Node API: send_media(pcm) / send_media_f32(pcm) (encode → stamped MediaFrametransport.broadcast_media), handle_media_received(frame, arrival_ms) (wired to Transport::on_media in build_with_transport), media_poll() (audio-clock drain via JitterBuffer::pop_at, loss gaps → Opus PLC concealed frames), media_stats() (jitter / latency / live-edge / arrival clock — spec §13).
  • Media bypasses gossip: a separate on_media callback feeds the pipeline directly; frames never enter handle_received, the gossip cache, or the presence/file packet paths. Dedup + reordering live in the per-stream jitter buffer, keyed by stream sequence — never packet id.
  • Per-stream state explicit: stream id ↔ source (BLAKE3 of node id), sequence continuity (last_seq / last_played_seq), live edge (offset + is_behind at 50ms — spec §16).
  • Tests: pipeline units (encode stamping, out-of-order replay, PLC concealment, telemetry) + end-to-end two-node shared-mesh round-trip (A sends 440Hz sine, B decodes, correlates, tracks stats). cargo test --workspace green.
  • Legacy webm init-segment replay retained untouched for the transition path.

4.5 crates/gossip — media exclusion

Today: every payload type shares dedup cache + TTL (gossip/src/lib.rs:17-51,108-121). Change: media frames never enter the gossip path (handled in WS3/WS4); gossip stays for chat/control/presence/files.

4.6 crates/rendezvous / crates/discovery — path intelligence

Today:

  • Rendezvous = bootstrap/discovery only, never proxies traffic (crates/rendezvous/). Discovery has signed records + RelayScore + nearest-by-RTT relay selection (crates/discovery/src/registry.rs), StationRecord.transports (record.rs:203), protocol-compat filtering (registry.rs:792-795).
  • No peer-capacity advertisement, no path cost function.

Change needed (WS6):

  • Extend discovery records with PEER_CAPACITY (upload capacity, available upload, latency-to-station, loss, jitter, relay capacity) per spec §24.
  • Dynamic peer roles: a peer is Listener, Source, or Forwarder and can transition (Listener → Forwarder → Listener) as capacity/health allows; forwarding is always voluntary and revocable (spec §24-25).
  • Multi-upstream mesh: every peer may have several candidate upstreams (station, peer, relay); if the active upstream dies, fail over to a prepared alternate without a re-buffer gap (spec §25, §31).
  • Add routing cost function (latency + jitter + loss penalty + hop penalty + congestion) + continuous path-health measurement (spec §29-30) fed by transport + relay telemetry; prefer direct P2P, engage a relay only when the mesh path degrades (spec §33).
  • Local/geographic affinity (spec §32, future): prefer nearby peers first so co-located listeners cluster naturally; relay geo-placement is an infrastructure decision, not a protocol one.
  • Keep rendezvous bootstrap-only (server-optional invariant preserved).

4.7 crates/desktop — Tauri bridge

Today:

  • node_publish maps "voice" → PayloadType::Voice (crates/desktop/src/core.rs:743,762,774); node_decode_voice decodes exactly one 20 ms/960-sample Opus frame (core.rs:411-425); voice published as base64 webm from the webview.
  • AppKind { Station, RelayStation, RadioListener } shell (desktop/src/lib.rs:24-28).

Change needed (WS7):

  • New IPC surface for raw Opus media frames + media telemetry (per-stage latency DTO in desktop/src/dto.rs, mirroring StatsDto at dto.rs:84-101).
  • node_decode_voice must accept any frame size (read duration from the frame).
  • Desktop capture latency is as important as networking: expose OS audio buffer configuration (e.g. CoreAudio / WASAPI buffer size) and feed the encoder from a lock-free ring buffer off the audio callback — avoid callback → async channel → worker → packet queue → network stacking scheduling latency (spec §18-19).

4.8 Relay plane — fallback only, forward-only (no JS hub)

Today: the JS relay hub (relay.mjs) is deleted — the mesh entry is always the rendezvous manifest, and the relay pool is gossiped on the relay control topic (crates/transport/src/libp2p_quic.rs / libp2p_browser.rs). Relays forward only verified packets (gossipsub Accept, public headers only), and media rides the voice lane as raw-bincode MediaFrame at MEDIA priority — no transcode, immediate forwarding.

Change needed (WS5) — DONE:

  • Bounded per-class command queues with drop-old (spec §15) — CommandQueue/PriorityQueue in crates/transport/src/priority.rs, wired into both real transports: a slow listener (or a file backlog) can never stall media.
  • Self-forwarding transports (gossipsub) skip the node-level relay (Transport::self_forwarding()), halving relay wire traffic.
  • Remaining: forward-delay telemetry at the relay ingest/write stages; seamless path migration at a known media timestamp (Phase 4).

4.9 SDK (packages/sdk) — path manager + media API

Today:

  • The core's path scorer ranks direct peers vs pool relays from measured signal (Node::path_ranked, set_signal_quality) — direct-first, relay assist on degradation.
  • InProcessRadio.publish(freq, data, "voice") emits voice events (packages/sdk/src/radio/in-process.ts:125-192); 30 s seen-TTL so relay init replay isn't dropped (in-process.ts:34-44).

Change needed (WS6):

  • Media wire types mirrored from crates/protocol (stream ID, seq, ts, duration, FEC flags) — packages/sdk/src/types.ts, utils/encoding.ts (SDK/AGENTS rule: must match crates/protocol).
  • Path manager with the decision engine (spec §33): direct → alternate peer → new peer → relay, plus migrate-back-to-P2P; seamless switch at a known media timestamp.
  • Media telemetry events (per-stage latency, live-edge offset).

4.10 Frontends — capture & playback (packages/ifm-station, ifm-radio-listener, ifm-pwa, SDK examples)

Today:

  • Capture: MediaRecorder webm, 200 ms timeslice (recorder.ts:45,89); PWA 500 ms (smokeStation.ts:148-162); example station mirrors desktop (packages/sdk/examples/station/src/mixer/recorder.ts — sync desktop→example).
  • Playback: MediaSource + SourceBuffer("audio/webm;codecs=opus"), FIFO drain, live-edge jump only when gap > 2 s, 30 s eviction (examples/listener/src/ListenerApp.tsx:184-223,234-261,281-316; ifm-radio-listener/src/App.tsx:246-292,301-331).
  • No AudioWorklet anywhere; no audio-clock playback.

Change needed (WS7):

  • Capture: AudioWorklet node at the mixer output bus → 10 ms raw Opus MediaFrames (Opus 10 ms in the browser via libopus/WASM or the browser's encoder), published directly. MediaRecorder stays for the record path only. The AudioWorklet → encoder → network hop uses a bounded ring buffer so nothing awaits a scheduler or a slow consumer in the frame path.
  • Playback: AudioWorklet decoder + audio-clock playback of raw Opus frames with adaptive jitter buffer and live-edge tracking; MediaSource webm remains as the compatibility/legacy path.
  • UI observes, never steers, media timing: the latency dashboard, live-edge indicator, and latency-mode selector (LOW LATENCY / BALANCED / STABLE, spec §14) read telemetry emitted beside the media path; no UI action is ever on the frame's critical path.
  • Desktop is the source of truth for shared code (invariant 9) — the mixer AudioWorklet changes land in ifm-station first, then sync to packages/sdk/examples/station/src/mixer/.

4.11 Telemetry & observability

Today: only relay RTT (RelayViewDto.rtt_ms, RelayScoreDto.rtt_ms, dto.rs:199-214; relay.ts:515-575). StatsDto counts packets/bytes/dups only.

Change needed (WS8):

  • Timestamps at: capture (EncodedAudioFrame.timestamp exists, frame.rs:91), transport enqueue, transport deliver, relay forward (hub + Rust relay), jitter commit, decode, playback → end-to-end + per-stage latency.
  • Live-edge offset per listener; queue depth; loss.
  • Surface via StatsDto-style DTOs (dto.rs), SDK events, hub metrics, and the dashboard UI.

5. Design Decisions to Lock Before Coding

#DecisionRecommendationNotes
D1Media-frame authStream-level: signed stream manifest (Ed25519) + per-frame MAC, OR sign every frameEd25519 ~ tens of µs — signing every 10 ms frame is fine on desktop, heavy-ish on low-end embedded. Stream-auth is the scale answer; matches "signed records" pattern in discovery
D2Web media transportWebTransport (datagram-capable, UDP) as primary for web media; WebSocket as fallbackWebSocket is TCP — cannot reliably hit <100 ms under loss; WebRTC data channels are the alternative but heavier. Desktop already has QUIC
D310 ms as default vs low_latency() everywhereFlip AudioConfig::Default to 10 ms for real-time; keep 20 ms for the record pathCleanest; one config, no mode drift. Update invariants + crates/AGENTS.md frame text
D4Legacy webm path during transitionKeep webm init-segment + MediaSource path running in parallel until AudioWorklet lands everywhereAvoids a big-bang frontend swap; the hub keeps replaying init segments
D5Media frame vs Packet reuseNew compact MediaFrame datagram, separate from PacketKeeps reliable channel + signature + version untouched; backward compatible
D6Per-listener media queuesBounded ring with drop-old, one per session (hub + relay + node forwarders)Spec §15; prevents slow-consumer stall
D7Capture/encode hot pathBounded lock-free ring buffer off the audio callback; never a generic async channelSpec §19; async scheduling adds latency and backpressure to the frame path

6. Workstreams (Dependency Order)

Rust first, then SDK, then apps — per cross-repository-change order.

  1. WS1 — Protocol: MediaFrame wire type + framing + version byte; tests in crates/protocol.
  2. WS2 — Audio: 10 ms default, constants, no-alloc hot path, arrival-time jitter + audio-clock drain + live edge; tests in crates/audio.
  3. WS3 — Transport: datagram media channel (quinn), priority queues, media path in InMemoryMesh/SharedMeshTransport; tests in crates/transport.
  4. WS4 — Core: media pipeline wiring, per-stream state, media bypasses gossip; cargo test -p ifm-core.
  5. WS5 — Hub: bounded per-session queues, forward-delay telemetry.
  6. WS6 — SDK + path manager: mirrored media types, path cost engine, seamless switch, telemetry events; capacity advertisement in discovery.
  7. WS7 — Frontends: AudioWorklet capture/playback, raw-Opus publish, latency UI; desktop first, sync to examples (invariant 9).
  8. WS8 — Telemetry: DTOs, hub metrics, dashboard, live-edge.
  9. WS9 — Invariants & docs: update invariants, crates/AGENTS.md, packages/sdk/AGENTS.md, and docs that describe the old path.

7. Phase Plan with Verification

PhaseScopeDone when (acceptance)
P1 — Transport foundationWS1, WS2, WS3, WS4: media frame type, 10 ms pipeline, datagram channel, core wiringmedia frame round-trips over InMemoryMesh; 10 ms encode/decode at ≤ budget; lost frame → PLC, no wait; cargo test --workspace green
P2 — Forward-only relays + telemetryWS5, WS8 (relay parts): bounded queues, forward-delay metricsslow-listener isolation verified in hub; dashboard shows relay forward delay
P3 — P2P-first meshWS6 (path/capacity), discovery recordsa 3-node Rust mesh streams direct without a relay; listener forwarding with capacity gating; PEER_CAPACITY on records
P4 — Fallback + seamless switchpath manager full, migrate-backrelay engages on threshold breach; switch at known media timestamp, no audible gap; stream returns to P2P on recovery
P5 — Web + UXWS7, WS8 (UI): AudioWorklet capture/playback, live edge, latency modesdesktop + web listener shows per-stage latency; live edge within jitter target; legacy webm path still works
P6 — Advancedsliding redundancy, selective FEC, capture-side tuning, 5 ms frame benchmarkacceptance criteria in feature spec §37 all met; end-to-end < 100 ms, trending 50–80 ms; 5 vs 10 ms frame decision backed by CPU/packet-rate/quality data

8. Invariants & Docs to Update (WS9)

  • docs/architecture/invariants.md:15 (invariant 2 "adaptive 20ms jitter buffering") → frame-duration agnostic, 10 ms real-time default.
  • docs/architecture/invariants.md:46-51 (invariant 6 "20ms frames") → 10 ms + confirm datagram/unreliable media path is real.
  • crates/AGENTS.md:19,29 ("48kHz mono 20ms"; "20ms/50fps hot loop") → 10 ms / 100 fps framing.
  • docs/features/realtime-media-transport.md:3 status → implemented (incrementally).
  • docs/audio/README.md, docs/architecture/audio.md, docs/architecture/README.md — align "20ms default" statements with 10 ms real-time default.
  • docs/packet/README.md:28-42 — stale fixed-offset layout vs actual bincode encoding (audit while touching protocol).
  • docs/demos/* — webm/MediaRecorder path becomes legacy/compat; P2P-first becomes the described default (already partially aligned).
  • packages/sdk/AGENTS.md — media type mirroring rule extended to MediaFrame.

9. Risks & Mitigations

  • Web < 100 ms depends on D2 (WebTransport/WebRTC). WebSocket/TCP can't deliver it under loss → phase web media behind the new datagram transport and keep webm/WebSocket as the fallback, never the reverse.
  • MediaRecorder → AudioWorklet is the largest single change. Browser Opus encode at 10 ms in an AudioWorklet is novel; mitigate with WASM libopus fallback and the parallel legacy path (D4).
  • libp2p 0.56 has no datagram API. Adding quinn datagrams alongside libp2p risks transport-state divergence (same socket?); mitigate by scoping the datagram socket to media only and documenting the split.
  • Per-frame signing on low-end embedded (D1). Stream-auth mitigates.
  • Changing the 20 ms default ripples into decode helpers and tests (node_decode_voice 960-sample assumption, codec.rs tests) — handled in WS2 with frame-size-aware decode.

10. Out of Scope (this refactor)

  • Clock synchronization / station timeline (spec §21 — future).
  • Sliding redundancy + selective FEC (Phase P6; spec §17).
  • Regional/geographic relay placement (spec §32 — infrastructure decision, not code; the protocol only exposes the latency/loss/jitter signals the path manager needs to prefer nearby paths).
  • Per-app OS audio capture (getDisplayMedia per-app) — native-layer only.
  • The recording pipeline redesign (only capture/transport/playback change).

5. Implementation Tracker

PhaseGoalStatusPR / Notes
WS1Wire format✅ DoneMediaFrame defined in crates/protocol/src/media.rs (stream id, seq u32, ts u64, codec, format, duration, FEC flags, payload)
WS210ms audio pipeline✅ Done10ms default config; frame-size-agnostic decode; arrival-time jitter 20/30/50ms + audio-clock pop + live edge (crates/audio/src/{config,codec,jitter}.rs)
WS3Transport datagrams + priority✅ DoneDatagram channel on Transport (send_media/broadcast_media/on_media + arrival ts); InMemory/Shared impl; PriorityLevel/PriorityQueue; media tests
WS4Pipeline wiring (core)✅ DoneMediaPipeline in crates/core/src/media.rs (encode→MediaFrame→broadcast_media, per-stream jitter+decode, live edge, stats); Node API send_media/send_media_f32/handle_media_received/media_poll/media_stats; media bypasses gossip; unit + end-to-end two-node tests
WS5Relay forwarding✅ DoneBounded per-class command queues with drop-old (CommandQueue/PriorityQueue, crates/transport/src/priority.rs) wired into QUIC + browser transports; self-forwarding transports skip the node-level relay; media rides the voice lane at MEDIA priority
WS6Path intelligence✅ PartialHop resolution + HOP_PRIORITY (peer > relay > origin) in SDK; PEER_CAPACITY advertisement + routing cost function not yet implemented
WS7UI / Capture refactor✅ PartialAudioWorklet capture in packages/ifm-station/src/audio/; frame-size-aware voice decode; playback remains MediaSource/legacy
WS8Telemetry & Observability✅ DoneMediaStats/StreamStats (per-stage latency, jitter, loss, live edge) in crates/core/src/media.rs
WS9Invariants & Docs✅ DoneInvariants + feature spec + this plan aligned to 10ms standard

Released under the MIT License.