Files
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

227 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 ~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](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.