Files
peerspeak/docs/ARCHITECTURE.md
molluskandClaude Opus 4.8 d50c05744f docs(architecture): correct stale playback buffer-quantum description
Section 3 claimed playout is pinned to exactly 1024 frames; the code now
follows the graph's Buffer::requested() quantum (pipewire_impl.rs:229-237),
with 1024 only as a fallback -- the doc described the pre-fix behavior that
caused crackle. Flagged by the 2026-06-15 Codex/GPT-5.5 review (backlog A20).
The broader ARCHITECTURE refresh (missing modules, persistent identity, signed
gossip, friends/W7, recording modes) remains under A20.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 05:53:16 -04:00

19 KiB
Raw Permalink Blame History

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)   |                    |
|    +-------------------+               +------------------+                    |
+--------------------------------------------------------------------------------+
  1. Tokio Async Runtime (Multi-Threaded): Operates the P2P network layers (Iroh, Gossip), config management, state transitions, event demuxing, and the GUI event loop.
  2. Real-Time Audio Thread (PipeWire): Runs in the PipeWire hardware process context to drive the audio hardware callbacks.
  3. 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 (HeapRb from the ringbuf crate) 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 dirs and exposes the append-based log_msg helper.
  • 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/audio/
    • src/audio/mod.rs: Declares audio hardware interfaces (AudioBackend, AudioDevice, AudioCaptureCallback) and global targets like PLAYBACK_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 Node to support manual device selection.
  • 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 (CoreCommand and UiEvent).
    • 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/config.rs: Controls serialization and persistence of AppConfig to ~/.config/peerspeak/config.json.
  • src/notify.rs: Operates system chime playback (pw-play/paplay/aplay) on background threads. Houses expand_tilde and validate_custom_path validation 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 Handling: The playout callback honors the graph's per-cycle quantum from Buffer::requested() (src/audio/pipewire_impl.rs:229-237), serving exactly that many frames each cycle and falling back to a safe cap (FALLBACK_FRAMES = 1024) only when the graph reports no quantum. This replaced an earlier hard pin to 1024 frames, which crackled on machines whose clock.quantum wasn't 1024; following the requested quantum makes playback correct on any graph quantum.
  • Jitter Buffers & Mix Loop: Incoming UDP datagrams are parsed in the Core datagram_task and routed to the peer's JitterBuffer (src/core/jitter.rs). Every 20ms, the Core mixer_task runs a tick:
    1. It pulls from each active peer's jitter buffer.
    2. If a sequence gap exists, it asks the Opus decoder to perform Packet Loss Concealment (PLC).
    3. The decoded PCM samples are scaled by the peer's volume slider factor.
    4. 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 ~40240ms). Defaults to DEFAULT_DELAY_FRAMES (3 frames, ~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     │ └──────────────────────┘
                         └──────────────────────┘
  1. 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 to 12) 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.
  2. Shrink (Slow Decrease):
    • Clean Playout: Each time a real, in-order frame is successfully popped and played in pop_frame, the controller increments clean_run via note_clean.
    • When clean_run reaches CLEAN_RUN_TO_SHRINK (250 frames, or ~5 seconds of continuous playback), the target delay is reduced by 1 frame (down to 2), and clean_run is reset.
    • Rationale: The link has been stable for a sustained period, indicating we can safely reclaim latency.
  3. 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 resets clean_run, but leaves target_delay untouched. This avoids inflating playout latency during natural pauses in conversation.
  4. Buffer Overflow Resync:
    • If the number of buffered packets exceeds MAX_BUFFERED_FRAMES (32 frames, ~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, and clean_run is reset. No change is made to target_delay.
  5. Prime-Timeout Safety Net:
    • When transitioning from idle to buffering, the mixer polls every 20ms and increments buffering_ticks if packets are present but their count is below target_delay.
    • If buffering_ticks reaches PRIME_TIMEOUT_TICKS (25 ticks, ~500ms), playout is primed and started immediately, even if the buffer length has not met target_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.

5. Network and P2P Transport

P2P voice chats utilize a dual-plane architecture:

  1. Control Plane (iroh-gossip): Distributes room roster join/leaves and audio state updates (mute/deafen) using Gossip.
  2. 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 EndpointId acts as the dialer.
  • The peer with the lexicographically higher EndpointId accepts 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)
  1. 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).
  2. First-Dial Gate: First-ever dial attempts (which emit Connecting during initial handshake) are ignored by the eviction logic. A peer must be present in the seen_connected set before a transport drop can trigger an eviction countdown.
  3. Idempotence: The arm_grace_timer helper 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.
  4. Eviction Execution: When the 45-second RECONNECT_GRACE timer 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_connected set, ensuring that a future rejoin starts with a clean slate.
  5. Graceful Disconnects: When a user clicks the "Leave" button, the client sends a GOODBYE_CODE = 1 signal 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 the seen_connected cache, 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 NetworkMode config to RelayNoDiscovery by 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.