Add ARCHITECTURE.md Section 4 covering the jitter buffer's adaptive playout delay: controller state/params, the grow/shrink/silence/overflow/ prime-timeout transitions, and a state diagram; note it in the Section 2 module map. Sections renumbered 4-7 -> 5-8 (no internal cross-refs). Gemini-authored (junior) via the headless agy loop. Senior review caught + fixed an inaccuracy: the original called the strategy "AIMD (multiplicative decrease)" but the shrink is additive (-1, rate-limited by CLEAN_RUN_TO_SHRINK), not multiplicative; reworded accordingly. Numbers fact-checked against src/core/jitter.rs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
19 KiB
PeerSpeak Architecture Overview
This document provides a concise, code-accurate guide to the design and implementation of PeerSpeak. It details the system boundaries, module interactions, audio paths, and networking protocols.
1. High-Level Design & System Split
PeerSpeak is a decentralized, full-mesh P2P voice chat client. The application is divided into two distinct execution environments to ensure high audio quality:
+--------------------------------------------------------------------------------+
| Tokio Async Runtime (Multi-Threaded) |
| |
| +-------------------+ UiEvents +------------------+ |
| | Iced GUI |<--------------+ Core Loop | |
| | (src/app/mod.rs) | | (src/core/mod.rs)| |
| +---------+---------+ +--------+---------+ |
| | AppMessages | |
| v v |
| +---------+---------+ +--------+---------+ |
| | Core Controller | | Iroh Network & | |
| | (src/core/mod.rs) | | Gossip Router | |
| +-------------------+ +--------+---------+ |
+-------------------------------------------------|------------------------------+
|
lock-free ring buffers (HeapRb)
|
+-------------------------------------------------|------------------------------+
| Real-Time Audio | Thread (PipeWire) |
| v |
| +-------------------+ +--------+---------+ |
| | Capture Callback +-------------->| Playback Callback| |
| | (Mic Capture) | | (Speaker Out) | |
| +-------------------+ +------------------+ |
+--------------------------------------------------------------------------------+
- Tokio Async Runtime (Multi-Threaded): Operates the P2P network layers (Iroh, Gossip), config management, state transitions, event demuxing, and the GUI event loop.
- Real-Time Audio Thread (PipeWire): Runs in the PipeWire hardware process context to drive the audio hardware callbacks.
- Lock-Free Safety Boundary: To avoid priority inversion, audio drops, and stutters, the RT callback never allocates, locks mutexes, or performs blocking OS operations. Interaction with the async runtime is achieved using lock-free Single-Producer Single-Consumer (SPSC) ring buffers (
HeapRbfrom theringbufcrate) in src/audio/pipewire_impl.rs.
2. Module Map
All source code resides inside src/:
- src/lib.rs: Root of the library. Handles logging path resolution via
dirsand exposes the append-basedlog_msghelper. - src/main.rs: Tiny entry point that configures the Tokio runtime and boots the Iced GUI.
src/app/- src/app/mod.rs: Handles GUI rendering via Iced. Defines Catppuccin theme styling, user screens (
Home,Room,Settings), hotkey binding, per-peer volume sliders, and custom chime overrides with real-time✓/✗path validation.
- src/app/mod.rs: Handles GUI rendering via Iced. Defines Catppuccin theme styling, user screens (
src/audio/- src/audio/mod.rs: Declares audio hardware interfaces (
AudioBackend,AudioDevice,AudioCaptureCallback) and global targets likePLAYBACK_TARGET_SAMPLES. - src/audio/pipewire_impl.rs: Implements playout and capture streams via PipeWire native APIs (
pipewire-rs). Configures custom hardware buffer parameters to eliminate jitter. - src/audio/pw_cli.rs: Dynamically parses target nodes from
pw-cli list-objects Nodeto support manual device selection.
- src/audio/mod.rs: Declares audio hardware interfaces (
src/codec/- src/codec/mod.rs: Abstract wrapper traits for audio compression.
- src/codec/opus_impl.rs: Standardizes mono Opus encoding (frame size 960 samples @ 48kHz, 20ms) and decoding with built-in Packet Loss Concealment (PLC).
src/core/- src/core/mod.rs: Implements the central room coordination, mixer logic, voice activity noise-gating, and the session shutdown sequencer.
- src/core/messages.rs: Formulates the inter-thread messaging schemas (
CoreCommandandUiEvent). - src/core/jitter.rs: Provides a per-peer jitter buffer that reorders out-of-order frames, drops duplicate arrivals, and handles packet drops. It also holds an adaptive playout delay that grows on jitter/loss and shrinks on a sustained clean run (see Section 4).
src/network/- src/network/mod.rs: Formulates abstractions for low-latency P2P transports.
- src/network/iroh_impl.rs: Integrates Iroh's endpoint system. Orchestrates connection supervisors, dial routines, and handshake logic.
- src/network/gossip.rs: Wraps
iroh-gossipfor discovery, room roster management, and state exchange.
- src/config.rs: Controls serialization and persistence of
AppConfigto~/.config/peerspeak/config.json. - src/notify.rs: Operates system chime playback (
pw-play/paplay/aplay) on background threads. Housesexpand_tildeandvalidate_custom_pathvalidation helpers.
3. Playout and Capture Data Flow
Audio processing runs on a 20ms clock (960 samples at 48kHz mono S16LE):
[ Mic Audio Capture ]
│
▼ (RT Callback)
[ Ring Buffer: capture_prod ]
│
▼ (Capture Thread)
[ Opus Encoder ]
│
▼ (Datagram Task)
[ Outbound Net Socket ]
│
Network Link (QUIC Datagrams)
│
▼
[ Inbound Net Socket ]
│
▼ (Datagram Task)
[ Per-Peer Jitter Buffer ]
│
▼ (Mixer Task 20ms Tick)
[ Opus Decoder / Mix PCM ]
│
▼ (Mixer Task)
[ Ring Buffer: playback_prod ]
│
▼ (RT Callback)
[ Speaker Playout ]
- RT Playout Safety: The playout process callback inside src/audio/pipewire_impl.rs pops mixed PCM samples directly from the SPSC ring buffer. No allocations or mutex locks are used.
- Buffer Quantum Pinning: Playout streams enforce an explicit
SPA_TYPE_OBJECT_ParamBuffersconfiguration during stream setup, pinning the hardware buffer size to exactly 1024 frames (2048 bytes mono S16LE). This limits the driver from over-pulling beyond the SPSC ring buffer's capacity and eliminates playback crackle. - Jitter Buffers & Mix Loop: Incoming UDP datagrams are parsed in the Core
datagram_taskand routed to the peer'sJitterBuffer(src/core/jitter.rs). Every 20ms, the Coremixer_taskruns a tick:- It pulls from each active peer's jitter buffer.
- If a sequence gap exists, it asks the Opus decoder to perform Packet Loss Concealment (PLC).
- The decoded PCM samples are scaled by the peer's volume slider factor.
- All streams are mixed into a single mono channel, gated by the mic's RMS noise gate threshold, and pushed to the playout ring buffer.
4. Adaptive Playout Delay Controller
To absorb network jitter (packet arrival variation) without incurring unnecessary latency, PeerSpeak replaces a fixed playout buffer depth with an adaptive feedback controller. The controller operates purely on buffer observations without requiring wall-clock synchronization or NTP.
Controller State & Parameters
The controller tracks three key metrics per peer inside the JitterBuffer struct in src/core/jitter.rs:
- Target Delay (
target_delay): The number of frames that must accumulate in the buffer before playout is allowed to start (or restart after an underrun). Clamped to[MIN_DELAY_FRAMES, MAX_DELAY_FRAMES](i.e.,[2, 12]frames or ~40–240ms). Defaults toDEFAULT_DELAY_FRAMES(3frames, ~60ms). - Clean Run (
clean_run): The number of consecutive real, in-order frames played without disruption. Used to drive the slow shrink phase. - Buffering Ticks (
buffering_ticks): The number of 20ms mixer ticks spent in the buffering state since the first packet arrived.
Feedback Loop and Delay Adjustments
The controller employs a fast-grow / slow-shrink strategy: it reacts immediately to network disruptions (a one-frame increase per disruption event) but reclaims latency cautiously (a one-frame decrease, and only after a long clean run). Both adjustments are additive — the shrink's slowness comes from the CLEAN_RUN_TO_SHRINK gate, not from multiplicative scaling.
┌────────────────────────┐
│ Packet Event │
└───────────┬────────────┘
│
┌────────────────────────┼────────────────────────┐
▼ (Late/PLC Gap) ▼ (Clean Playout) ▼ (Silence/Underrun)
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ Grow Target (+1) │ │ Increment clean_run │ │ Reset clean_run │
│ Reset clean_run │ │ If clean_run >= 250: │ │ Go Idle (re-buffer) │
└──────────────────────┘ │ Shrink Target (-1) │ │ Target Unchanged │
│ Reset clean_run │ └──────────────────────┘
└──────────────────────┘
- Grow (Fast Increase):
- Late Arrival: If a packet arrives with a sequence number older than the next sequence to be played (
seq < next_seq) in insert, it is discarded, and target delay grows by 1 frame (up to12) via note_disruption. - PLC Gap: If the mixer polls a frame in pop_frame, the next sequence is missing, but later packets are already buffered, the decoder performs Packet Loss Concealment (PLC), and target delay grows by 1 frame (up to
12) via note_disruption. - Rationale: The jitter exceeded the current cushion. Growing the target delay ensures a deeper cushion next time the buffer primes.
- Late Arrival: If a packet arrives with a sequence number older than the next sequence to be played (
- Shrink (Slow Decrease):
- Clean Playout: Each time a real, in-order frame is successfully popped and played in pop_frame, the controller increments
clean_runvia note_clean. - When
clean_runreachesCLEAN_RUN_TO_SHRINK(250frames, or ~5 seconds of continuous playback), the target delay is reduced by 1 frame (down to2), andclean_runis reset. - Rationale: The link has been stable for a sustained period, indicating we can safely reclaim latency.
- Clean Playout: Each time a real, in-order frame is successfully popped and played in pop_frame, the controller increments
- Silence Neutrality:
- If a talker pauses speaking, the buffer runs dry and goes idle. This is a benign underrun rather than a network anomaly.
- The controller transitions the playout state to buffering (
next_seq = None) and resetsclean_run, but leavestarget_delayuntouched. This avoids inflating playout latency during natural pauses in conversation.
- Buffer Overflow Resync:
- If the number of buffered packets exceeds
MAX_BUFFERED_FRAMES(32frames, ~640ms), the buffer has fallen too far behind. - The oldest frame is discarded, the playout head (
next_seq) is resynced to the oldest remaining packet sequence, andclean_runis reset. No change is made totarget_delay.
- If the number of buffered packets exceeds
- Prime-Timeout Safety Net:
- When transitioning from idle to buffering, the mixer polls every 20ms and increments
buffering_ticksif packets are present but their count is belowtarget_delay. - If
buffering_ticksreachesPRIME_TIMEOUT_TICKS(25ticks, ~500ms), playout is primed and started immediately, even if the buffer length has not mettarget_delay. - Rationale: This prevents short utterances (e.g., "yes", "okay") from being held indefinitely in a deep target cushion, capping the worst-case startup latency.
- When transitioning from idle to buffering, the mixer polls every 20ms and increments
5. Network and P2P Transport
P2P voice chats utilize a dual-plane architecture:
- Control Plane (iroh-gossip): Distributes room roster join/leaves and audio state updates (mute/deafen) using Gossip.
- Data Plane (QUIC Datagrams): Voice payloads bypass routing tables and are sent directly between peer endpoints via QUIC datagrams to minimize latency.
Dialer vs. Acceptor Rule
To prevent race conditions where both peers try to dial each other simultaneously, PeerSpeak enforces a deterministic identity rule in src/network/iroh_impl.rs:
- The peer with the lexicographically lower
EndpointIdacts as the dialer. - The peer with the lexicographically higher
EndpointIdaccepts inbound connections.
Retained-Address Reconnect
To ensure reconnection reliability, the dialer caches each peer's complete EndpointAddr (direct IP endpoints + relay configurations) inside the Shared.addrs map. When a connection drops, the supervisor task's dial loop bypasses the gossip DHT lookup entirely and dials the cached address directly. This enables connections to recover even when gossip status is offline.
6. Reconnect and Eviction Model
Connection drops are managed via a dual-path armed grace timer to maintain a stable room state:
Drop Notice (gossip: PeerConnectionLost) ──┐
├──► [arm_grace_timer] ──► Evict after 45s
Drop Notice (transport: Connecting) ───────┘ (Idempotent check)
- Outage Triggers: An outage is detected via two parallel vectors:
- Gossip raises a
RoomEvent::PeerConnectionLost(due to gossip ping timeouts). - Transport raises a
ConnEvent::Connecting(due to QUIC connection loss).
- Gossip raises a
- First-Dial Gate: First-ever dial attempts (which emit
Connectingduring initial handshake) are ignored by the eviction logic. A peer must be present in theseen_connectedset before a transport drop can trigger an eviction countdown. - Idempotence: The
arm_grace_timerhelper in src/core/mod.rs verifies if a grace timer is already running for the peer. If present, the request is a no-op. This ensures a flapping network connection cannot repeatedly reset the eviction clock. - Eviction Execution: When the 45-second
RECONNECT_GRACEtimer expires:- The connection supervisor is aborted.
- The peer is removed from the room, and its audio buffers are cleared.
- The peer is scrubbed from the
seen_connectedset, ensuring that a future rejoin starts with a clean slate.
- Graceful Disconnects: When a user clicks the "Leave" button, the client sends a
GOODBYE_CODE = 1signal directly over the transport. The remote endpoint's supervisor processes this as a graceful shutdown, immediately evicting the peer and bypassing the 45s grace period.
7. Core loop & UI Flow
The core async orchestrator in src/core/mod.rs schedules five main concurrent tasks inside an ActiveSession:
event_task: Processes gossip events, peer updates, and room roster modifications.conn_event_task: Monitors connection state changes, manages theseen_connectedcache, and schedules grace eviction timers.mixer_task: Mixed peer audio playout pacing and RMS voice levels.datagram_task: Listens for raw incoming network datagrams and pushes them to peer jitter buffers.capture_thread: A dedicated blocking thread that handles microphone frame reads, runs Opus encoding, and submits outbound packets.
Updates are forwarded back to the Iced GUI via UiEvent channels, keeping room lists, speaking indicators, settings values, and chime status validation up to date in real time.
8. Config and Privacy
- Default Privacy: PeerSpeak sets the
NetworkModeconfig toRelayNoDiscoveryby default. This forces P2P connectivity through Iroh's relay nodes for NAT traversal without publishing the peer's endpoint metadata to the public n0 DNS directory. - Persisted UI State: Configuration values (devices, noise gates, custom chime file paths) are saved once when the settings page is exited via
AppMessage::NavigateBack. - WAV Custom Overrides: System chimes map to custom user paths defined in
AppConfig. The paths undergo tilde expansion (~/~/->dirs::home_dir()) and file validation checks, displaying a live checkmark or warning indicator in the Settings menu without blocking or writing to the disk during typing.