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>
227 lines
19 KiB
Markdown
227 lines
19 KiB
Markdown
# 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](file:///home/mollusk/git/butter/peerspeak/src/audio/pipewire_impl.rs).
|
||
|
||
---
|
||
|
||
## 2. Module Map
|
||
|
||
All source code resides inside `src/`:
|
||
|
||
* **[src/lib.rs](file:///home/mollusk/git/butter/peerspeak/src/lib.rs)**: Root of the library. Handles logging path resolution via `dirs` and exposes the append-based `log_msg` helper.
|
||
* **[src/main.rs](file:///home/mollusk/git/butter/peerspeak/src/main.rs)**: Tiny entry point that configures the Tokio runtime and boots the Iced GUI.
|
||
* **`src/app/`**
|
||
* **[src/app/mod.rs](file:///home/mollusk/git/butter/peerspeak/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](file:///home/mollusk/git/butter/peerspeak/src/audio/mod.rs)**: Declares audio hardware interfaces (`AudioBackend`, `AudioDevice`, `AudioCaptureCallback`) and global targets like `PLAYBACK_TARGET_SAMPLES`.
|
||
* **[src/audio/pipewire_impl.rs](file:///home/mollusk/git/butter/peerspeak/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](file:///home/mollusk/git/butter/peerspeak/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](file:///home/mollusk/git/butter/peerspeak/src/codec/mod.rs)**: Abstract wrapper traits for audio compression.
|
||
* **[src/codec/opus_impl.rs](file:///home/mollusk/git/butter/peerspeak/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](file:///home/mollusk/git/butter/peerspeak/src/core/mod.rs)**: Implements the central room coordination, mixer logic, voice activity noise-gating, and the session shutdown sequencer.
|
||
* **[src/core/messages.rs](file:///home/mollusk/git/butter/peerspeak/src/core/messages.rs)**: Formulates the inter-thread messaging schemas (`CoreCommand` and `UiEvent`).
|
||
* **[src/core/jitter.rs](file:///home/mollusk/git/butter/peerspeak/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](file:///home/mollusk/git/butter/peerspeak/src/network/mod.rs)**: Formulates abstractions for low-latency P2P transports.
|
||
* **[src/network/iroh_impl.rs](file:///home/mollusk/git/butter/peerspeak/src/network/iroh_impl.rs)**: Integrates Iroh's endpoint system. Orchestrates connection supervisors, dial routines, and handshake logic.
|
||
* **[src/network/gossip.rs](file:///home/mollusk/git/butter/peerspeak/src/network/gossip.rs)**: Wraps `iroh-gossip` for discovery, room roster management, and state exchange.
|
||
* **[src/config.rs](file:///home/mollusk/git/butter/peerspeak/src/config.rs)**: Controls serialization and persistence of `AppConfig` to `~/.config/peerspeak/config.json`.
|
||
* **[src/notify.rs](file:///home/mollusk/git/butter/peerspeak/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](file:///home/mollusk/git/butter/peerspeak/src/audio/pipewire_impl.rs#L64) 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](file:///home/mollusk/git/butter/peerspeak/src/core/jitter.rs#L62) struct in [src/core/jitter.rs](file:///home/mollusk/git/butter/peerspeak/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 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](file:///home/mollusk/git/butter/peerspeak/src/core/jitter.rs#L121), it is discarded, and target delay grows by 1 frame (up to `12`) via [note_disruption](file:///home/mollusk/git/butter/peerspeak/src/core/jitter.rs#L104).
|
||
* **PLC Gap**: If the mixer polls a frame in [pop_frame](file:///home/mollusk/git/butter/peerspeak/src/core/jitter.rs#L144), 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](file:///home/mollusk/git/butter/peerspeak/src/core/jitter.rs#L104).
|
||
* *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](file:///home/mollusk/git/butter/peerspeak/src/core/jitter.rs#L144), the controller increments `clean_run` via [note_clean](file:///home/mollusk/git/butter/peerspeak/src/core/jitter.rs#L111).
|
||
* 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](file:///home/mollusk/git/butter/peerspeak/src/network/iroh_impl.rs#L148):
|
||
* 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](file:///home/mollusk/git/butter/peerspeak/src/core/mod.rs#L80) 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.
|