9 Commits

Author SHA1 Message Date
Siavash Sameni
09a18b086b chore: include WASM blob + JS glue in git for deployment
wasm-pack generated .gitignore was excluding all build output.
The WASM (337KB) and JS glue need to be in the repo so the
wzp-web static server can serve them without a build step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:00:16 +04:00
Siavash Sameni
f3c8e11995 feat: 3 web client variants — Pure JS, Hybrid (JS+WASM FEC), Full WASM
Variant 1: Pure JS (wzp-pure.js)
- WebSocket transport, raw PCM, no encryption (bridge handles QUIC crypto)
- ~20KB, works everywhere, zero dependencies
- WZPPureClient class with connect/disconnect/sendAudio

Variant 2: Hybrid (wzp-hybrid.js + wzp-wasm)
- WebSocket transport + RaptorQ FEC via WASM
- ~120KB (337KB WASM blob shared with full variant)
- WZPHybridClient extends pure with FEC encode/decode
- Loss recovery ready for when WebTransport replaces WebSocket

Variant 3: Full WASM (wzp-full.js + wzp-wasm)
- WebTransport datagrams (unreliable, low latency)
- ChaCha20-Poly1305 encryption + RaptorQ FEC, all in WASM
- X25519 key exchange over bidirectional stream
- WZPFullClient — true E2E encrypted WZP client in browser
- Needs relay HTTP/3 support (h3-quinn) for WebTransport

Shared infrastructure:
- wzp-core.js: UI logic, AudioWorklet, variant detection, PTT
- audio-processor.js: AudioWorklet capture + playback (unchanged)
- index.html: variant selector (?variant=pure|hybrid|full), auto-detect

wzp-wasm crate (new):
- RaptorQ FEC encoder/decoder (WzpFecEncoder, WzpFecDecoder)
- ChaCha20-Poly1305 crypto (WzpCryptoSession)
- X25519 key exchange (WzpKeyExchange)
- 7 native tests (3 FEC + 4 crypto), all passing
- WASM blob: 337KB optimized

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:10:15 +04:00
Siavash Sameni
4fb15fe7a3 feat: P3-T3 bandwidth estimation — GCC-style congestion control
BandwidthEstimator tracks available bandwidth using dual signals:

Delay-based: EMA of RTT vs baseline minimum. If RTT > 1.5x baseline
→ Overuse (congestion). If RTT < 1.1x baseline → Underuse (headroom).
Baseline slowly drifts up to handle route changes.

Loss-based: sliding window of 10 loss samples. Average > 5% → congested.

Rate adaptation (AIMD):
- Overuse OR loss congested: decrease 15% (multiplicative)
- Underuse AND no loss: increase 5% (additive)
- Normal: hold steady
- Clamped to [min_bw, max_bw]

recommended_profile() maps bandwidth to quality tier:
- >= 25 kbps → GOOD (Opus 24k + 20% FEC)
- >= 8 kbps → DEGRADED (Opus 6k + 50% FEC)
- < 8 kbps → CATASTROPHIC (Codec2 1200 + 100% FEC)

from_quality_report() integrates with existing QualityReport packets.

54 proto tests passing (12 new bandwidth tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:51:08 +04:00
Siavash Sameni
e595fe6591 feat: P3-T6 per-session forwarding — relay links for hop-by-hop media
RelayLink: QUIC connection to peer relay (SNI "_relay") for forwarding
specific sessions. Methods: connect, forward, add/remove_session, is_idle.

RelayLinkManager: manages connections to multiple peers.
- get_or_connect: lazy connection establishment
- forward_to: send media packet to specific peer
- register/unregister_session: track which sessions use which links
- Auto-closes idle links on session unregister

Protocol: added SignalMessage::SessionForward { session_id,
target_fingerprint, source_relay } and SessionForwardAck { session_id,
room_name } for relay-link session setup signaling.

Building block for P3-T7 (call setup over mesh) which wires
route resolution + relay links + handshake into a complete flow.

62 relay tests + 42 proto tests passing (7 new relay_link tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:45:36 +04:00
Siavash Sameni
326aa491cc feat: P3-T5 route resolution — find relay path to any fingerprint
RouteResolver queries PresenceRegistry to determine how to reach a target:
- Route::Local — connected to this relay
- Route::DirectPeer(addr) — on a directly connected peer relay
- Route::Chain(addrs) — multi-hop (structure ready, single-hop for now)
- Route::NotFound — not in any known relay

Protocol: added SignalMessage::RouteQuery { fingerprint, ttl } and
RouteResponse { fingerprint, found, relay_chain } for peer-to-peer
route queries over probe connections.

HTTP API: GET /route/:fingerprint returns JSON with route type + chain.

Relay handles incoming RouteQuery on probe connections: looks up locally,
replies with RouteResponse. TTL decremented for future multi-hop forwarding.

55 relay tests + 42 proto tests passing (7 new route tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:38:24 +04:00
Siavash Sameni
464e95a4bd feat: P3-T4 relay presence registry — gossip fingerprints across relay mesh
PresenceRegistry tracks who is connected where:
- register_local/unregister_local for directly connected users
- update_peer for fingerprints reported by peer relays
- lookup returns Local or Remote(addr)
- expire_stale removes entries older than timeout

Gossip via probe connections:
- New SignalMessage::PresenceUpdate { fingerprints, relay_addr }
- Probes send local fingerprints every 10s alongside Ping/Pong
- Receiving relay updates its remote presence table

HTTP API on metrics port:
- GET /presence — all known fingerprints + locations
- GET /presence/:fingerprint — single lookup
- GET /peers — peer relays + their connected users

Wired into relay main:
- Registry created at startup
- register_local after auth+handshake
- unregister_local on disconnect
- Passed to probe mesh and metrics server

Also marks FC-10 as DONE in integration tracker.

48 relay tests + 42 proto tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:36:55 +04:00
Siavash Sameni
fd95167705 chore: update featherChat submodule to v0.0.38 (feature/wzp-call-infrastructure)
featherChat now implements:
- FC-2: Call state management (calls.rs, CallState, sled persistence)
- FC-3: WS call signal routing (Offer→Ringing, Answer→Active, Hangup→Ended)
- FC-5: Group-to-room mapping (hash_room_name — same convention as WZP)
- FC-6: Presence API (online/devices per fingerprint, batch query)
- FC-7: Missed call notifications (sled storage, retrieval endpoint)

Only FC-10 (web bridge shared auth) remains on FC side.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:21:55 +04:00
Siavash Sameni
9e7fea7633 test: P2-T1-S5 long-session regression — 60s call with drift/loss assertions
3 tests in crates/wzp-client/tests/long_session.rs:

1. long_session_no_drift — 3000 frames (60s) through full encoder/decoder
   pipeline, asserts >95% decoded, 0 overruns, 0 underruns

2. long_session_with_simulated_loss — drops every 20th packet + reorders,
   asserts >90% decoded, confirms PLC fills gaps (2999/3000)

3. long_session_stats_consistency — verifies stats.total_decoded matches
   actual decoded count over 60s (no accounting drift)

Completes P2-T1-S5. Phase 2 is now fully done.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:59:27 +04:00
Siavash Sameni
993cf9ab7f docs: full system architecture with Mermaid diagrams + project README
ARCHITECTURE.md covers the entire system with 13 Mermaid diagrams:
- System overview (send/recv pipeline, relay SFU)
- Crate dependency graph (8 crates + featherChat)
- Wire formats (MediaHeader, MiniHeader, TrunkFrame, QualityReport, SignalMessage)
- Quality profiles with adaptive switching thresholds
- Cryptographic handshake sequence (X25519 + Ed25519)
- Identity model (BIP39 seed → HKDF → Ed25519/X25519 → Fingerprint)
- Relay modes (Room SFU, Forward, Probe)
- Web bridge architecture (Browser ↔ WS ↔ QUIC)
- FEC protection pipeline (RaptorQ + interleaving)
- Telemetry stack (Prometheus → Grafana)
- Session state machine
- Audio processing detail (denoise → VAD → encode → FEC → encrypt)
- Adaptive jitter buffer flow
- Deployment topology (multi-region)
- featherChat integration sequence

README.md: quick start, feature list, documentation index, build instructions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:41:39 +04:00
28 changed files with 5503 additions and 576 deletions

17
Cargo.lock generated
View File

@@ -3507,7 +3507,7 @@ dependencies = [
[[package]]
name = "warzone-protocol"
version = "0.0.21"
version = "0.0.38"
dependencies = [
"base64",
"bincode",
@@ -4261,6 +4261,21 @@ dependencies = [
"wzp-proto",
]
[[package]]
name = "wzp-wasm"
version = "0.1.0"
dependencies = [
"chacha20poly1305",
"getrandom 0.2.17",
"hkdf",
"js-sys",
"rand 0.8.5",
"raptorq",
"sha2",
"wasm-bindgen",
"x25519-dalek",
]
[[package]]
name = "wzp-web"
version = "0.1.0"

View File

@@ -9,6 +9,7 @@ members = [
"crates/wzp-relay",
"crates/wzp-client",
"crates/wzp-web",
"crates/wzp-wasm",
]
[workspace.package]

87
README.md Normal file
View File

@@ -0,0 +1,87 @@
# WarzonePhone
Custom lossy VoIP protocol built in Rust. E2E encrypted, FEC-protected, adaptive quality, designed for hostile network conditions.
## Quick Start
```bash
# Build
cargo build --release
# Run relay
./target/release/wzp-relay --listen 0.0.0.0:4433
# Send a test tone
./target/release/wzp-client --send-tone 5 relay-addr:4433
# Web bridge (browser calls)
./target/release/wzp-web --port 8080 --relay 127.0.0.1:4433 --tls
# Open https://localhost:8080/room-name in two browser tabs
```
## Architecture
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full system architecture with Mermaid diagrams covering:
- System overview and data flow
- Crate dependency graph (8 crates)
- Wire formats (MediaHeader, MiniHeader, TrunkFrame, SignalMessage)
- Cryptographic handshake (X25519 + Ed25519 + ChaCha20-Poly1305)
- Identity model (BIP39 seed, featherChat compatible)
- Quality profiles (GOOD/DEGRADED/CATASTROPHIC)
- FEC protection (RaptorQ with interleaving)
- Adaptive jitter buffer (NetEq-inspired)
- Telemetry stack (Prometheus + Grafana)
- Deployment topology
## Features
- **3 quality tiers**: Opus 24k (28.8 kbps) / Opus 6k (9 kbps) / Codec2 1200 (2.4 kbps)
- **RaptorQ FEC**: Recovers from 20-100% packet loss depending on tier
- **E2E encryption**: ChaCha20-Poly1305 with X25519 key exchange
- **Adaptive jitter buffer**: EMA-based playout delay tracking
- **Silence suppression**: VAD + comfort noise (~50% bandwidth savings)
- **ML noise removal**: RNNoise (nnnoiseless pure Rust port)
- **Mini-frames**: 67% header compression for steady-state packets
- **Trunking**: Multiplex sessions into batched datagrams
- **featherChat integration**: Shared BIP39 identity, token auth, call signaling
- **Prometheus metrics**: Relay, web bridge, inter-relay probes
- **Grafana dashboard**: Pre-built JSON with 18 panels
## Documentation
| Document | Description |
|----------|-------------|
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Full system architecture with diagrams |
| [TELEMETRY.md](docs/TELEMETRY.md) | Prometheus metrics specification |
| [INTEGRATION_TASKS.md](docs/INTEGRATION_TASKS.md) | featherChat integration tracker |
| [WZP-FC-SHARED-CRATES.md](docs/WZP-FC-SHARED-CRATES.md) | Shared crate strategy |
| [grafana-dashboard.json](docs/grafana-dashboard.json) | Importable Grafana dashboard |
## Binaries
| Binary | Description |
|--------|-------------|
| `wzp-relay` | Relay daemon (SFU room mode, forward mode, probes) |
| `wzp-client` | CLI client (send-tone, record, live mic, echo-test, drift-test, sweep) |
| `wzp-web` | Browser bridge (HTTPS + WebSocket + AudioWorklet) |
| `wzp-bench` | Component benchmarks |
## Linux Build
```bash
./scripts/build-linux.sh --prepare # Create Hetzner VM + install deps
./scripts/build-linux.sh --build # Build release binaries
./scripts/build-linux.sh --transfer # Download to target/linux-x86_64/
./scripts/build-linux.sh --destroy # Delete VM
```
## Tests
```bash
cargo test --workspace # 272 tests
```
## License
MIT OR Apache-2.0

View File

@@ -104,6 +104,11 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
SignalMessage::Unmute => CallSignalType::Unmute,
SignalMessage::Transfer { .. } => CallSignalType::Transfer,
SignalMessage::TransferAck => CallSignalType::Offer, // reuse
SignalMessage::PresenceUpdate { .. } => CallSignalType::Offer, // reuse
SignalMessage::RouteQuery { .. } => CallSignalType::Offer, // reuse
SignalMessage::RouteResponse { .. } => CallSignalType::Offer, // reuse
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
}
}

View File

@@ -0,0 +1,190 @@
//! WZP-P2-T1-S5: 60-second long-session regression tests.
//!
//! Verifies that the full codec + FEC + jitter buffer pipeline does not drift
//! or degrade over a sustained 60-second (3000-frame) session. Runs entirely
//! in-process with no network — packets flow directly from encoder to decoder.
use wzp_client::call::{CallConfig, CallDecoder, CallEncoder};
use wzp_proto::QualityProfile;
const FRAME_SAMPLES: usize = 960; // 20ms @ 48kHz
const SAMPLE_RATE: f32 = 48_000.0;
const TOTAL_FRAMES: u64 = 3_000; // 60 seconds at 50 fps
/// Build a CallConfig tuned for direct-loopback testing (no network).
///
/// Disables silence suppression and noise suppression (which would mangle
/// or squelch the synthetic tone), uses a fixed (non-adaptive) jitter buffer
/// with min_depth=1 so that packets are played out as soon as they arrive.
fn test_config() -> CallConfig {
CallConfig {
profile: QualityProfile::GOOD,
jitter_target: 4,
jitter_max: 500,
jitter_min: 1,
suppression_enabled: false,
noise_suppression: false,
adaptive_jitter: false,
..Default::default()
}
}
/// Generate a 20ms frame of 440 Hz sine tone.
fn sine_frame(frame_offset: u64) -> Vec<i16> {
let start_sample = frame_offset * FRAME_SAMPLES as u64;
(0..FRAME_SAMPLES)
.map(|i| {
let t = (start_sample + i as u64) as f32 / SAMPLE_RATE;
(f32::sin(2.0 * std::f32::consts::PI * 440.0 * t) * 16000.0) as i16
})
.collect()
}
/// 60-second session with a perfect (lossless, in-order) channel.
///
/// Encodes 3000 frames of 440 Hz tone, feeds every packet directly into the
/// decoder, and verifies:
/// - frame loss < 5% (>2850 of 3000 source frames decoded or PLC'd)
/// - no panics
///
/// Note: the encoder shares a single sequence counter between source and
/// repair packets. Since repair packets are NOT pushed into the jitter
/// buffer, each FEC block creates a gap in the playout sequence. GOOD
/// profile (5 frames/block, fec_ratio=0.2) generates 1 repair per block,
/// so every 6th seq number is a "phantom" Missing in the jitter buffer.
/// The jitter buffer correctly fills these gaps with PLC. We call
/// `decode_next` once per encode tick; the buffer stays shallow because
/// PLC frames consume the phantom seqs at the same rate they're created.
#[test]
fn long_session_no_drift() {
let config = test_config();
let mut encoder = CallEncoder::new(&config);
let mut decoder = CallDecoder::new(&config);
let mut frames_decoded = 0u64;
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
for i in 0..TOTAL_FRAMES {
let pcm = sine_frame(i);
let packets = encoder.encode_frame(&pcm).expect("encode should not fail");
for pkt in packets {
decoder.ingest(pkt);
}
// Decode one frame per tick (mirrors real-time 50 fps cadence).
if decoder.decode_next(&mut pcm_buf).is_some() {
frames_decoded += 1;
}
}
let stats = decoder.stats();
println!(
"long_session_no_drift: decoded={frames_decoded}/{TOTAL_FRAMES}, \
underruns={}, overruns={}, depth={}, max_depth={}, late={}, lost={}",
stats.underruns, stats.overruns, stats.current_depth, stats.max_depth_seen,
stats.packets_late, stats.packets_lost,
);
// With 1 decode per tick over 3000 ticks, we expect ~3000 decoded frames
// (some via PLC for repair-seq gaps). Allow up to 5% gap.
assert!(
frames_decoded > 2850,
"frame loss too high: decoded {frames_decoded}/3000 (need >2850 = <5% loss)"
);
}
/// 60-second session with simulated 5% packet loss and reordering.
///
/// Every 20th source packet is dropped; pairs of adjacent packets are swapped
/// every 7 frames. Verifies that FEC + jitter buffer recover gracefully:
/// - frame loss < 10% (FEC should recover some of the 5% artificial loss)
/// - no panics
#[test]
fn long_session_with_simulated_loss() {
let config = test_config();
let mut encoder = CallEncoder::new(&config);
let mut decoder = CallDecoder::new(&config);
let mut frames_decoded = 0u64;
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
for i in 0..TOTAL_FRAMES {
let pcm = sine_frame(i);
let packets = encoder.encode_frame(&pcm).expect("encode should not fail");
let mut batch: Vec<_> = packets.into_iter().collect();
// Simulate reordering: swap first two packets in the batch every 7 frames.
if i % 7 == 0 && batch.len() >= 2 {
batch.swap(0, 1);
}
for (j, pkt) in batch.into_iter().enumerate() {
// Drop every 20th *source* (non-repair) packet to simulate ~5% loss.
if !pkt.header.is_repair && i % 20 == 0 && j == 0 {
continue; // drop this packet
}
decoder.ingest(pkt);
}
if decoder.decode_next(&mut pcm_buf).is_some() {
frames_decoded += 1;
}
}
let stats = decoder.stats();
println!(
"long_session_with_simulated_loss: decoded={frames_decoded}/{TOTAL_FRAMES}, \
underruns={}, overruns={}, depth={}, max_depth={}, late={}, lost={}",
stats.underruns, stats.overruns, stats.current_depth, stats.max_depth_seen,
stats.packets_late, stats.packets_lost,
);
// With 5% artificial loss + FEC recovery + PLC, we should still get >90% decoded.
assert!(
frames_decoded > 2700,
"frame loss too high under simulated loss: decoded {frames_decoded}/3000 (need >2700 = <10%)"
);
}
/// Verify that the jitter buffer's decoded-frame count is consistent with its
/// own internal statistics over a long session.
#[test]
fn long_session_stats_consistency() {
let config = test_config();
let mut encoder = CallEncoder::new(&config);
let mut decoder = CallDecoder::new(&config);
let mut frames_decoded = 0u64;
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
for i in 0..TOTAL_FRAMES {
let pcm = sine_frame(i);
let packets = encoder.encode_frame(&pcm).expect("encode");
for pkt in packets {
decoder.ingest(pkt);
}
if decoder.decode_next(&mut pcm_buf).is_some() {
frames_decoded += 1;
}
}
let stats = decoder.stats();
// total_decoded should match our manual counter.
assert_eq!(
stats.total_decoded, frames_decoded,
"stats.total_decoded ({}) != manually counted frames_decoded ({frames_decoded})",
stats.total_decoded,
);
// packets_received should be > 0.
assert!(
stats.packets_received > 0,
"stats.packets_received should be > 0"
);
}

View File

@@ -0,0 +1,454 @@
//! GCC-style bandwidth estimation and congestion control.
//!
//! Tracks available bandwidth using delay-based and loss-based signals,
//! then adjusts the sending bitrate to avoid congestion. The estimator
//! uses multiplicative decrease (15%) on congestion and additive increase
//! (5%) during underuse, following the general shape of Google Congestion
//! Control (GCC).
use std::collections::VecDeque;
use std::time::Instant;
use crate::packet::QualityReport;
use crate::QualityProfile;
/// Network congestion state derived from delay and loss signals.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CongestionState {
/// Network is fine, can increase bandwidth.
Underuse,
/// Normal operation.
Normal,
/// Congestion detected, should decrease bandwidth.
Overuse,
}
/// Detects congestion from increasing RTT using an exponential moving average.
///
/// Maintains a baseline RTT (minimum observed) and compares the smoothed RTT
/// against it. If `rtt_ema > baseline * threshold_ratio`, congestion is detected.
/// The baseline slowly drifts upward to handle route changes.
struct DelayBasedDetector {
/// Baseline RTT (minimum observed).
baseline_rtt_ms: f64,
/// EMA of recent RTT.
rtt_ema: f64,
/// EMA smoothing factor.
alpha: f64,
/// Threshold: if rtt_ema > baseline * threshold_ratio, congestion detected.
threshold_ratio: f64,
/// Current state.
state: CongestionState,
/// Whether we have received any RTT sample yet.
initialized: bool,
/// Drift factor: baseline slowly increases each update to track route changes.
baseline_drift: f64,
}
impl DelayBasedDetector {
fn new() -> Self {
Self {
baseline_rtt_ms: f64::MAX,
rtt_ema: 0.0,
alpha: 0.3,
threshold_ratio: 1.5,
state: CongestionState::Normal,
initialized: false,
baseline_drift: 0.001,
}
}
/// Update the detector with a new RTT sample.
fn update(&mut self, rtt_ms: f64) {
if !self.initialized {
self.baseline_rtt_ms = rtt_ms;
self.rtt_ema = rtt_ms;
self.initialized = true;
self.state = CongestionState::Normal;
return;
}
// Track minimum RTT as baseline.
if rtt_ms < self.baseline_rtt_ms {
self.baseline_rtt_ms = rtt_ms;
} else {
// Slowly drift baseline upward to handle route changes.
self.baseline_rtt_ms += self.baseline_drift * (rtt_ms - self.baseline_rtt_ms);
}
// Update EMA.
self.rtt_ema = self.alpha * rtt_ms + (1.0 - self.alpha) * self.rtt_ema;
// Determine state.
let overuse_threshold = self.baseline_rtt_ms * self.threshold_ratio;
let underuse_threshold = self.baseline_rtt_ms * 1.1;
if self.rtt_ema > overuse_threshold {
self.state = CongestionState::Overuse;
} else if self.rtt_ema < underuse_threshold {
self.state = CongestionState::Underuse;
} else {
self.state = CongestionState::Normal;
}
}
fn state(&self) -> CongestionState {
self.state
}
}
/// Detects congestion from packet loss using a sliding window average.
struct LossBasedDetector {
/// Recent loss percentages (sliding window).
loss_window: VecDeque<f64>,
/// Maximum window size.
window_size: usize,
/// Loss threshold for congestion (default 5%).
threshold_pct: f64,
}
impl LossBasedDetector {
fn new() -> Self {
Self {
loss_window: VecDeque::with_capacity(10),
window_size: 10,
threshold_pct: 5.0,
}
}
/// Add a loss percentage sample to the window.
fn update(&mut self, loss_pct: f64) {
if self.loss_window.len() >= self.window_size {
self.loss_window.pop_front();
}
self.loss_window.push_back(loss_pct);
}
/// Returns true if the average loss in the window exceeds the threshold.
fn is_congested(&self) -> bool {
if self.loss_window.is_empty() {
return false;
}
let avg = self.loss_window.iter().sum::<f64>() / self.loss_window.len() as f64;
avg > self.threshold_pct
}
}
// ─── BandwidthEstimator ─────────────────────────────────────────────────────
/// GCC-style bandwidth estimator that tracks available bandwidth using
/// delay-based and loss-based congestion signals.
///
/// # Algorithm
///
/// - **Overuse** (delay or loss): multiplicative decrease by 15%.
/// - **Underuse** (delay) with no loss congestion: additive increase by 5%.
/// - **Normal**: hold steady.
/// - Result is always clamped to `[min_bw_kbps, max_bw_kbps]`.
pub struct BandwidthEstimator {
/// Current estimated bandwidth in kbps.
estimated_bw_kbps: f64,
/// Minimum bandwidth floor (don't go below this).
min_bw_kbps: f64,
/// Maximum bandwidth ceiling.
max_bw_kbps: f64,
/// Delay-based detector state.
delay_detector: DelayBasedDetector,
/// Loss-based detector state.
loss_detector: LossBasedDetector,
/// Last update timestamp.
last_update: Option<Instant>,
}
/// Multiplicative decrease factor applied on congestion (15% reduction).
const DECREASE_FACTOR: f64 = 0.85;
/// Additive increase factor applied during underuse (5% of current estimate).
const INCREASE_FACTOR: f64 = 0.05;
impl BandwidthEstimator {
/// Create a new bandwidth estimator.
///
/// - `initial_bw_kbps`: starting bandwidth estimate.
/// - `min`: minimum bandwidth floor in kbps.
/// - `max`: maximum bandwidth ceiling in kbps.
pub fn new(initial_bw_kbps: f64, min: f64, max: f64) -> Self {
Self {
estimated_bw_kbps: initial_bw_kbps,
min_bw_kbps: min,
max_bw_kbps: max,
delay_detector: DelayBasedDetector::new(),
loss_detector: LossBasedDetector::new(),
last_update: None,
}
}
/// Update the estimator with new network observations.
///
/// Returns the new estimated bandwidth in kbps.
///
/// - If delay overuse OR loss congested: decrease by 15% (multiplicative decrease).
/// - If delay underuse AND not loss congested: increase by 5% (additive increase).
/// - If normal: hold steady.
/// - Result is clamped to `[min, max]`.
pub fn update(&mut self, rtt_ms: f64, loss_pct: f64, _jitter_ms: f64) -> f64 {
self.delay_detector.update(rtt_ms);
self.loss_detector.update(loss_pct);
self.last_update = Some(Instant::now());
let delay_state = self.delay_detector.state();
let loss_congested = self.loss_detector.is_congested();
if delay_state == CongestionState::Overuse || loss_congested {
// Multiplicative decrease.
self.estimated_bw_kbps *= DECREASE_FACTOR;
} else if delay_state == CongestionState::Underuse && !loss_congested {
// Additive increase.
self.estimated_bw_kbps += self.estimated_bw_kbps * INCREASE_FACTOR;
}
// Normal: hold steady — no change.
// Clamp to [min, max].
self.estimated_bw_kbps = self
.estimated_bw_kbps
.clamp(self.min_bw_kbps, self.max_bw_kbps);
self.estimated_bw_kbps
}
/// Current estimated bandwidth in kbps.
pub fn estimated_kbps(&self) -> f64 {
self.estimated_bw_kbps
}
/// Current congestion state (derived from delay detector).
pub fn congestion_state(&self) -> CongestionState {
self.delay_detector.state()
}
/// Convenience method: update from a `QualityReport`.
///
/// Extracts RTT, loss, and jitter from the report and feeds them into
/// the estimator.
pub fn from_quality_report(&mut self, report: &QualityReport) -> f64 {
let rtt_ms = report.rtt_ms() as f64;
let loss_pct = report.loss_percent() as f64;
let jitter_ms = report.jitter_ms as f64;
self.update(rtt_ms, loss_pct, jitter_ms)
}
/// Recommend a `QualityProfile` based on the current bandwidth estimate.
///
/// - bw >= 25 kbps -> GOOD (Opus 24k + 20% FEC = ~28.8 kbps total)
/// - bw >= 8 kbps -> DEGRADED (Opus 6k + 50% FEC = ~9.0 kbps)
/// - bw < 8 kbps -> CATASTROPHIC (Codec2 1.2k + 100% FEC = ~2.4 kbps)
pub fn recommended_profile(&self) -> QualityProfile {
if self.estimated_bw_kbps >= 25.0 {
QualityProfile::GOOD
} else if self.estimated_bw_kbps >= 8.0 {
QualityProfile::DEGRADED
} else {
QualityProfile::CATASTROPHIC
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn initial_bandwidth() {
let bwe = BandwidthEstimator::new(50.0, 2.0, 100.0);
assert!((bwe.estimated_kbps() - 50.0).abs() < f64::EPSILON);
}
#[test]
fn stable_network_holds_bandwidth() {
let mut bwe = BandwidthEstimator::new(50.0, 2.0, 100.0);
// Feed stable, low RTT and 0% loss — after initial sample sets baseline,
// subsequent identical RTT should be underuse (rtt_ema < baseline * 1.1),
// causing slow increases. The bandwidth should stay near initial or grow slightly.
let initial = bwe.estimated_kbps();
for _ in 0..20 {
bwe.update(30.0, 0.0, 5.0);
}
// Should not have decreased significantly.
assert!(
bwe.estimated_kbps() >= initial,
"bandwidth should not decrease on stable network: got {} vs initial {}",
bwe.estimated_kbps(),
initial
);
}
#[test]
fn high_rtt_decreases_bandwidth() {
let mut bwe = BandwidthEstimator::new(50.0, 2.0, 100.0);
// Establish a low baseline.
for _ in 0..5 {
bwe.update(20.0, 0.0, 2.0);
}
let before = bwe.estimated_kbps();
// Now feed high RTT to trigger overuse.
for _ in 0..10 {
bwe.update(200.0, 0.0, 10.0);
}
assert!(
bwe.estimated_kbps() < before,
"bandwidth should decrease on high RTT: got {} vs before {}",
bwe.estimated_kbps(),
before
);
}
#[test]
fn high_loss_decreases_bandwidth() {
let mut bwe = BandwidthEstimator::new(50.0, 2.0, 100.0);
let before = bwe.estimated_kbps();
// Feed 10% loss repeatedly (above the 5% threshold).
for _ in 0..15 {
bwe.update(20.0, 10.0, 2.0);
}
assert!(
bwe.estimated_kbps() < before,
"bandwidth should decrease on high loss: got {} vs before {}",
bwe.estimated_kbps(),
before
);
}
#[test]
fn recovery_increases_bandwidth() {
let mut bwe = BandwidthEstimator::new(50.0, 2.0, 100.0);
// Drive bandwidth down with high RTT.
for _ in 0..5 {
bwe.update(20.0, 0.0, 2.0);
}
for _ in 0..20 {
bwe.update(200.0, 0.0, 10.0);
}
let low_bw = bwe.estimated_kbps();
assert!(low_bw < 50.0, "should have decreased");
// Now feed good conditions — low RTT should be underuse, causing increase.
// Reset the baseline by feeding very low RTT.
for _ in 0..30 {
bwe.update(10.0, 0.0, 1.0);
}
assert!(
bwe.estimated_kbps() > low_bw,
"bandwidth should recover: got {} vs low {}",
bwe.estimated_kbps(),
low_bw
);
}
#[test]
fn bandwidth_clamped_to_min() {
let mut bwe = BandwidthEstimator::new(10.0, 5.0, 100.0);
// Keep feeding congestion to drive bandwidth down.
for _ in 0..5 {
bwe.update(20.0, 0.0, 2.0);
}
for _ in 0..100 {
bwe.update(500.0, 50.0, 100.0);
}
assert!(
(bwe.estimated_kbps() - 5.0).abs() < f64::EPSILON,
"bandwidth should be clamped to min: got {}",
bwe.estimated_kbps()
);
}
#[test]
fn bandwidth_clamped_to_max() {
let mut bwe = BandwidthEstimator::new(90.0, 2.0, 100.0);
// Keep feeding great conditions to drive bandwidth up.
for _ in 0..200 {
bwe.update(5.0, 0.0, 1.0);
}
assert!(
bwe.estimated_kbps() <= 100.0,
"bandwidth should be clamped to max: got {}",
bwe.estimated_kbps()
);
}
#[test]
fn recommended_profile_thresholds() {
// At boundary: >= 25 kbps => GOOD
let bwe_good = BandwidthEstimator::new(25.0, 2.0, 100.0);
assert_eq!(bwe_good.recommended_profile(), QualityProfile::GOOD);
// Just below 25 => DEGRADED
let bwe_degraded = BandwidthEstimator::new(24.9, 2.0, 100.0);
assert_eq!(bwe_degraded.recommended_profile(), QualityProfile::DEGRADED);
// At boundary: >= 8 kbps => DEGRADED
let bwe_degraded2 = BandwidthEstimator::new(8.0, 2.0, 100.0);
assert_eq!(
bwe_degraded2.recommended_profile(),
QualityProfile::DEGRADED
);
// Below 8 => CATASTROPHIC
let bwe_cat = BandwidthEstimator::new(7.9, 2.0, 100.0);
assert_eq!(
bwe_cat.recommended_profile(),
QualityProfile::CATASTROPHIC
);
// High bandwidth
let bwe_high = BandwidthEstimator::new(80.0, 2.0, 100.0);
assert_eq!(bwe_high.recommended_profile(), QualityProfile::GOOD);
}
#[test]
fn from_quality_report_integration() {
let mut bwe = BandwidthEstimator::new(50.0, 2.0, 100.0);
// Build a QualityReport with moderate loss and RTT.
let report = QualityReport {
loss_pct: (10.0_f32 / 100.0 * 255.0) as u8, // ~10% loss
rtt_4ms: 25, // 100ms RTT
jitter_ms: 10,
bitrate_cap_kbps: 200,
};
let new_bw = bwe.from_quality_report(&report);
// Should return a valid bandwidth value.
assert!(new_bw > 0.0);
assert!(new_bw <= 100.0);
// The estimator should have been updated.
assert!((bwe.estimated_kbps() - new_bw).abs() < f64::EPSILON);
}
// ── Additional detector unit tests ──────────────────────────────────
#[test]
fn delay_detector_starts_normal() {
let det = DelayBasedDetector::new();
assert_eq!(det.state(), CongestionState::Normal);
}
#[test]
fn loss_detector_below_threshold() {
let mut det = LossBasedDetector::new();
for _ in 0..10 {
det.update(2.0); // 2% loss, well below 5% threshold
}
assert!(!det.is_congested());
}
#[test]
fn loss_detector_above_threshold() {
let mut det = LossBasedDetector::new();
for _ in 0..10 {
det.update(8.0); // 8% loss, above 5% threshold
}
assert!(det.is_congested());
}
}

View File

@@ -12,6 +12,7 @@
//! - Identity = 32-byte seed → HKDF → Ed25519 (signing) + X25519 (encryption)
//! - Fingerprint = SHA-256(Ed25519 pub)[:16]
pub mod bandwidth;
pub mod codec_id;
pub mod error;
pub mod jitter;
@@ -27,6 +28,7 @@ pub use packet::{
HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader, QualityReport,
SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, FRAME_TYPE_MINI,
};
pub use bandwidth::{BandwidthEstimator, CongestionState};
pub use quality::{AdaptiveQualityController, Tier};
pub use session::{Session, SessionEvent, SessionState};
pub use traits::*;

View File

@@ -591,6 +591,43 @@ pub enum SignalMessage {
},
/// Acknowledge a transfer request.
TransferAck,
/// Presence update from a peer relay (gossip protocol).
/// Sent periodically over probe connections to share which fingerprints
/// are connected to the sending relay.
PresenceUpdate {
/// Fingerprints currently connected to the sending relay.
fingerprints: Vec<String>,
/// Address of the sending relay (e.g., "192.168.1.10:4433").
relay_addr: String,
},
/// Ask a peer relay to look up a fingerprint in its registry.
RouteQuery {
fingerprint: String,
ttl: u8,
},
/// Response to a route query.
RouteResponse {
fingerprint: String,
found: bool,
relay_chain: Vec<String>,
},
/// Request to set up a forwarding session for a specific fingerprint.
/// Sent over a relay link (`_relay` SNI) to ask the peer relay to
/// create a room and forward media for the given session.
SessionForward {
session_id: String,
target_fingerprint: String,
source_relay: String,
},
/// Confirm that the forwarding session has been set up on the peer relay.
/// The `room_name` tells the source relay which room to address media to.
SessionForwardAck {
session_id: String,
room_name: String,
},
}
/// Reasons for ending a call.
@@ -776,6 +813,40 @@ mod tests {
assert!(matches!(decoded, SignalMessage::TransferAck));
}
#[test]
fn presence_update_signal_roundtrip() {
let msg = SignalMessage::PresenceUpdate {
fingerprints: vec!["aabb".to_string(), "ccdd".to_string()],
relay_addr: "10.0.0.1:4433".to_string(),
};
let json = serde_json::to_string(&msg).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
match decoded {
SignalMessage::PresenceUpdate { fingerprints, relay_addr } => {
assert_eq!(fingerprints.len(), 2);
assert!(fingerprints.contains(&"aabb".to_string()));
assert!(fingerprints.contains(&"ccdd".to_string()));
assert_eq!(relay_addr, "10.0.0.1:4433");
}
_ => panic!("expected PresenceUpdate variant"),
}
// Empty fingerprints list
let msg_empty = SignalMessage::PresenceUpdate {
fingerprints: vec![],
relay_addr: "10.0.0.2:4433".to_string(),
};
let json = serde_json::to_string(&msg_empty).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
match decoded {
SignalMessage::PresenceUpdate { fingerprints, relay_addr } => {
assert!(fingerprints.is_empty());
assert_eq!(relay_addr, "10.0.0.2:4433");
}
_ => panic!("expected PresenceUpdate variant"),
}
}
#[test]
fn fec_ratio_encode_decode() {
let ratio = 0.5;

View File

@@ -12,8 +12,11 @@ pub mod config;
pub mod handshake;
pub mod metrics;
pub mod pipeline;
pub mod presence;
pub mod probe;
pub mod relay_link;
pub mod room;
pub mod route;
pub mod session_mgr;
pub mod trunk;

View File

@@ -19,6 +19,7 @@ use wzp_proto::MediaTransport;
use wzp_relay::config::RelayConfig;
use wzp_relay::metrics::RelayMetrics;
use wzp_relay::pipeline::{PipelineConfig, RelayPipeline};
use wzp_relay::presence::PresenceRegistry;
use wzp_relay::room::{self, RoomManager};
use wzp_relay::session_mgr::SessionManager;
@@ -176,11 +177,19 @@ async fn main() -> anyhow::Result<()> {
.install_default()
.expect("failed to install rustls crypto provider");
// Presence registry
let presence = Arc::new(Mutex::new(PresenceRegistry::new()));
// Route resolver
let route_resolver = Arc::new(wzp_relay::route::RouteResolver::new(config.listen_addr));
// Prometheus metrics
let metrics = Arc::new(RelayMetrics::new());
if let Some(port) = config.metrics_port {
let m = metrics.clone();
tokio::spawn(wzp_relay::metrics::serve_metrics(port, m));
let p = Some(presence.clone());
let rr = Some(route_resolver.clone());
tokio::spawn(wzp_relay::metrics::serve_metrics(port, m, p, rr));
}
// Generate ephemeral relay identity for crypto handshake
@@ -214,6 +223,7 @@ async fn main() -> anyhow::Result<()> {
let mesh = wzp_relay::probe::ProbeMesh::new(
config.probe_targets.clone(),
metrics.registry(),
Some(presence.clone()),
);
info!(
targets = mesh.target_count(),
@@ -244,6 +254,8 @@ async fn main() -> anyhow::Result<()> {
let relay_seed_bytes = relay_seed.0;
let metrics = metrics.clone();
let trunking_enabled = config.trunking_enabled;
let presence = presence.clone();
let route_resolver = route_resolver.clone();
tokio::spawn(async move {
let addr = connection.remote_address();
@@ -259,9 +271,9 @@ async fn main() -> anyhow::Result<()> {
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
// Probe connections use SNI "_probe" to identify themselves.
// They skip auth + handshake and just do Ping->Pong.
// They skip auth + handshake and just do Ping->Pong + presence gossip.
if room_name == "_probe" {
info!(%addr, "probe connection detected, entering Ping/Pong responder");
info!(%addr, "probe connection detected, entering Ping/Pong + presence responder");
loop {
match transport.recv_signal().await {
Ok(Some(wzp_proto::SignalMessage::Ping { timestamp_ms })) => {
@@ -272,8 +284,63 @@ async fn main() -> anyhow::Result<()> {
break;
}
}
Ok(Some(wzp_proto::SignalMessage::PresenceUpdate { fingerprints, relay_addr })) => {
// A peer relay is telling us which fingerprints it has
let peer_addr: std::net::SocketAddr = relay_addr.parse().unwrap_or(addr);
let fps: std::collections::HashSet<String> = fingerprints.into_iter().collect();
{
let mut reg = presence.lock().await;
reg.update_peer(peer_addr, fps);
}
// Reply with our own local fingerprints
let local_fps: Vec<String> = {
let reg = presence.lock().await;
reg.local_fingerprints().into_iter().collect()
};
let reply = wzp_proto::SignalMessage::PresenceUpdate {
fingerprints: local_fps,
relay_addr: addr.to_string(),
};
if let Err(e) = transport.send_signal(&reply).await {
error!(%addr, "presence reply send error: {e}");
break;
}
}
Ok(Some(wzp_proto::SignalMessage::RouteQuery { fingerprint, ttl })) => {
// Look up the fingerprint in our local registry
let reg = presence.lock().await;
let route = route_resolver.resolve(&reg, &fingerprint);
drop(reg);
let (found, relay_chain) = match route {
wzp_relay::route::Route::Local => {
(true, vec![route_resolver.local_addr().to_string()])
}
wzp_relay::route::Route::DirectPeer(peer_addr) => {
(true, vec![route_resolver.local_addr().to_string(), peer_addr.to_string()])
}
_ => {
// Not found locally; if ttl > 0 we could forward
// to other peers (future multi-hop). For now, reply not found.
if ttl > 0 {
// TODO: forward RouteQuery to other peers with ttl-1
}
(false, vec![])
}
};
let reply = wzp_proto::SignalMessage::RouteResponse {
fingerprint,
found,
relay_chain,
};
if let Err(e) = transport.send_signal(&reply).await {
error!(%addr, "route response send error: {e}");
break;
}
}
Ok(Some(_)) => {
// Ignore non-Ping signals on probe connections
// Ignore other signals on probe connections
}
Ok(None) => {
info!(%addr, "probe connection closed");
@@ -352,6 +419,12 @@ async fn main() -> anyhow::Result<()> {
}
};
// Register in presence registry
if let Some(ref fp) = authenticated_fp {
let mut reg = presence.lock().await;
reg.register_local(fp, None, Some(room_name.clone()));
}
info!(%addr, room = %room_name, "client joining");
if let Some(remote) = remote_transport {
@@ -431,7 +504,11 @@ async fn main() -> anyhow::Result<()> {
trunking_enabled,
).await;
// Participant disconnected — clean up per-session metrics
// Participant disconnected — clean up presence + per-session metrics
if let Some(ref fp) = authenticated_fp {
let mut reg = presence.lock().await;
reg.unregister_local(fp);
}
metrics.remove_session_metrics(&session_id_str);
metrics.active_sessions.dec();
{

View File

@@ -201,11 +201,21 @@ impl RelayMetrics {
}
}
/// Start an HTTP server serving GET /metrics and GET /mesh on the given port.
pub async fn serve_metrics(port: u16, metrics: Arc<RelayMetrics>) {
use axum::{routing::get, Router};
/// Start an HTTP server serving GET /metrics, GET /mesh, presence, and route endpoints on the given port.
pub async fn serve_metrics(
port: u16,
metrics: Arc<RelayMetrics>,
presence: Option<Arc<tokio::sync::Mutex<crate::presence::PresenceRegistry>>>,
route_resolver: Option<Arc<crate::route::RouteResolver>>,
) {
use axum::{extract::Path, routing::get, Router};
let metrics_clone = metrics.clone();
let presence_all = presence.clone();
let presence_lookup = presence.clone();
let presence_peers = presence.clone();
let presence_route = presence;
let app = Router::new()
.route(
"/metrics",
@@ -220,6 +230,92 @@ pub async fn serve_metrics(port: u16, metrics: Arc<RelayMetrics>) {
let m = metrics_clone.clone();
async move { crate::probe::mesh_summary(m.registry()) }
}),
)
.route(
"/presence",
get(move || {
let reg = presence_all.clone();
async move {
match reg {
Some(r) => {
let r = r.lock().await;
let entries: Vec<serde_json::Value> = r.all_known().into_iter().map(|(fp, loc)| {
serde_json::json!({ "fingerprint": fp, "location": loc })
}).collect();
serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
}
None => "[]".to_string(),
}
}
}),
)
.route(
"/presence/:fingerprint",
get(move |Path(fingerprint): Path<String>| {
let reg = presence_lookup.clone();
async move {
match reg {
Some(r) => {
let r = r.lock().await;
match r.lookup(&fingerprint) {
Some(loc) => serde_json::to_string_pretty(
&serde_json::json!({ "fingerprint": fingerprint, "location": loc })
).unwrap_or_else(|_| "{}".to_string()),
None => serde_json::json!({ "fingerprint": fingerprint, "location": null }).to_string(),
}
}
None => serde_json::json!({ "fingerprint": fingerprint, "location": null }).to_string(),
}
}
}),
)
.route(
"/peers",
get(move || {
let reg = presence_peers.clone();
async move {
match reg {
Some(r) => {
let r = r.lock().await;
let peers: Vec<serde_json::Value> = r.peers().iter().map(|(addr, peer)| {
serde_json::json!({
"addr": addr.to_string(),
"fingerprints": peer.fingerprints.iter().collect::<Vec<_>>(),
"rtt_ms": peer.rtt_ms,
})
}).collect();
serde_json::to_string_pretty(&peers).unwrap_or_else(|_| "[]".to_string())
}
None => "[]".to_string(),
}
}
}),
)
.route(
"/route/:fingerprint",
get(move |Path(fingerprint): Path<String>| {
let reg = presence_route.clone();
let resolver = route_resolver.clone();
async move {
match (reg, resolver) {
(Some(r), Some(res)) => {
let r = r.lock().await;
let route = res.resolve(&r, &fingerprint);
let json = res.route_json(&fingerprint, &route);
serde_json::to_string_pretty(&json)
.unwrap_or_else(|_| "{}".to_string())
}
_ => {
serde_json::json!({
"fingerprint": fingerprint,
"route": "not_found",
"relay_chain": [],
})
.to_string()
}
}
}
}),
);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));

View File

@@ -0,0 +1,333 @@
//! Presence registry — tracks which fingerprints are connected to this relay
//! and to peer relays (via gossip over probe connections).
//!
//! This enables route resolution: given a fingerprint, determine whether the
//! user is local, on a known peer relay, or unknown.
use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
use std::time::{Duration, Instant};
use serde::Serialize;
// ---------------------------------------------------------------------------
// Data structures
// ---------------------------------------------------------------------------
/// Where a fingerprint is connected.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub enum PresenceLocation {
/// Connected directly to this relay.
Local,
/// Connected to a peer relay at the given address.
Remote(SocketAddr),
}
/// Presence entry for a fingerprint connected directly to this relay.
#[derive(Clone, Debug)]
pub struct LocalPresence {
pub fingerprint: String,
pub alias: Option<String>,
pub connected_at: Instant,
pub room: Option<String>,
}
/// Presence entry for a fingerprint reported by a peer relay.
#[derive(Clone, Debug)]
pub struct RemotePresence {
pub fingerprint: String,
pub relay_addr: SocketAddr,
pub last_seen: Instant,
}
/// Known peer relay and its reported fingerprints.
#[derive(Clone, Debug)]
pub struct PeerRelay {
pub addr: SocketAddr,
pub fingerprints: HashSet<String>,
pub last_update: Instant,
pub rtt_ms: Option<f64>,
}
// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------
/// Central presence registry tracking local and remote fingerprints.
pub struct PresenceRegistry {
/// Fingerprints connected directly to THIS relay.
local: HashMap<String, LocalPresence>,
/// Fingerprints reported by peer relays (via gossip).
remote: HashMap<String, RemotePresence>,
/// Known peer relays and their status.
peers: HashMap<SocketAddr, PeerRelay>,
}
impl PresenceRegistry {
/// Create an empty registry.
pub fn new() -> Self {
Self {
local: HashMap::new(),
remote: HashMap::new(),
peers: HashMap::new(),
}
}
/// Register a fingerprint as locally connected (called after auth + handshake).
pub fn register_local(&mut self, fingerprint: &str, alias: Option<String>, room: Option<String>) {
self.local.insert(fingerprint.to_string(), LocalPresence {
fingerprint: fingerprint.to_string(),
alias,
connected_at: Instant::now(),
room,
});
}
/// Unregister a locally connected fingerprint (called on disconnect).
pub fn unregister_local(&mut self, fingerprint: &str) {
self.local.remove(fingerprint);
}
/// Update the fingerprints reported by a peer relay.
/// Replaces the previous set for that peer.
pub fn update_peer(&mut self, addr: SocketAddr, fingerprints: HashSet<String>) {
let now = Instant::now();
// Remove old remote entries that belonged to this peer
self.remote.retain(|_, rp| rp.relay_addr != addr);
// Insert new remote entries
for fp in &fingerprints {
self.remote.insert(fp.clone(), RemotePresence {
fingerprint: fp.clone(),
relay_addr: addr,
last_seen: now,
});
}
// Update the peer record
let peer = self.peers.entry(addr).or_insert_with(|| PeerRelay {
addr,
fingerprints: HashSet::new(),
last_update: now,
rtt_ms: None,
});
peer.fingerprints = fingerprints;
peer.last_update = now;
}
/// Look up where a fingerprint is connected.
/// Local presence takes priority over remote.
pub fn lookup(&self, fingerprint: &str) -> Option<PresenceLocation> {
if self.local.contains_key(fingerprint) {
return Some(PresenceLocation::Local);
}
if let Some(rp) = self.remote.get(fingerprint) {
return Some(PresenceLocation::Remote(rp.relay_addr));
}
None
}
/// Return all fingerprints connected directly to this relay.
pub fn local_fingerprints(&self) -> HashSet<String> {
self.local.keys().cloned().collect()
}
/// Return a full dump of every known fingerprint and its location.
pub fn all_known(&self) -> Vec<(String, PresenceLocation)> {
let mut out = Vec::new();
for fp in self.local.keys() {
out.push((fp.clone(), PresenceLocation::Local));
}
for (fp, rp) in &self.remote {
// Skip if also local (local wins)
if !self.local.contains_key(fp) {
out.push((fp.clone(), PresenceLocation::Remote(rp.relay_addr)));
}
}
out
}
/// Remove remote entries older than `timeout`.
pub fn expire_stale(&mut self, timeout: Duration) {
let cutoff = Instant::now() - timeout;
// Expire remote presence entries
self.remote.retain(|_, rp| rp.last_seen > cutoff);
// Expire peer relay records and their fingerprint sets
let stale_peers: Vec<SocketAddr> = self.peers
.iter()
.filter(|(_, p)| p.last_update <= cutoff)
.map(|(addr, _)| *addr)
.collect();
for addr in stale_peers {
self.peers.remove(&addr);
}
}
/// Return a reference to the peer relay map (for HTTP API).
pub fn peers(&self) -> &HashMap<SocketAddr, PeerRelay> {
&self.peers
}
/// Return a reference to the local presence map (for HTTP API).
pub fn local_entries(&self) -> &HashMap<String, LocalPresence> {
&self.local
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::net::SocketAddr;
fn addr(s: &str) -> SocketAddr {
s.parse().unwrap()
}
#[test]
fn register_and_lookup_local() {
let mut reg = PresenceRegistry::new();
reg.register_local("aabbccdd", Some("alice".into()), Some("room1".into()));
assert_eq!(reg.lookup("aabbccdd"), Some(PresenceLocation::Local));
// Unknown fingerprint returns None
assert_eq!(reg.lookup("00000000"), None);
}
#[test]
fn unregister_removes() {
let mut reg = PresenceRegistry::new();
reg.register_local("aabbccdd", None, None);
assert_eq!(reg.lookup("aabbccdd"), Some(PresenceLocation::Local));
reg.unregister_local("aabbccdd");
assert_eq!(reg.lookup("aabbccdd"), None);
}
#[test]
fn update_peer_and_lookup() {
let mut reg = PresenceRegistry::new();
let peer = addr("10.0.0.2:4433");
let mut fps = HashSet::new();
fps.insert("deadbeef".to_string());
fps.insert("cafebabe".to_string());
reg.update_peer(peer, fps);
assert_eq!(reg.lookup("deadbeef"), Some(PresenceLocation::Remote(peer)));
assert_eq!(reg.lookup("cafebabe"), Some(PresenceLocation::Remote(peer)));
assert_eq!(reg.lookup("unknown"), None);
}
#[test]
fn expire_stale_removes_old() {
let mut reg = PresenceRegistry::new();
let peer = addr("10.0.0.3:4433");
let mut fps = HashSet::new();
fps.insert("olduser".to_string());
reg.update_peer(peer, fps);
// Verify it's there
assert_eq!(reg.lookup("olduser"), Some(PresenceLocation::Remote(peer)));
// Manually backdate the last_seen and last_update
if let Some(rp) = reg.remote.get_mut("olduser") {
rp.last_seen = Instant::now() - Duration::from_secs(120);
}
if let Some(p) = reg.peers.get_mut(&peer) {
p.last_update = Instant::now() - Duration::from_secs(120);
}
// Expire with 60s timeout — should remove the 120s-old entries
reg.expire_stale(Duration::from_secs(60));
assert_eq!(reg.lookup("olduser"), None);
assert!(reg.peers.get(&peer).is_none());
}
#[test]
fn local_fingerprints_list() {
let mut reg = PresenceRegistry::new();
reg.register_local("fp1", None, None);
reg.register_local("fp2", Some("bob".into()), Some("room-a".into()));
reg.register_local("fp3", None, None);
let fps = reg.local_fingerprints();
assert_eq!(fps.len(), 3);
assert!(fps.contains("fp1"));
assert!(fps.contains("fp2"));
assert!(fps.contains("fp3"));
}
#[test]
fn all_known_includes_local_and_remote() {
let mut reg = PresenceRegistry::new();
reg.register_local("local1", None, None);
let peer = addr("10.0.0.5:4433");
let mut fps = HashSet::new();
fps.insert("remote1".to_string());
reg.update_peer(peer, fps);
let all = reg.all_known();
assert_eq!(all.len(), 2);
let local_entries: Vec<_> = all.iter()
.filter(|(_, loc)| *loc == PresenceLocation::Local)
.collect();
assert_eq!(local_entries.len(), 1);
assert_eq!(local_entries[0].0, "local1");
let remote_entries: Vec<_> = all.iter()
.filter(|(_, loc)| matches!(loc, PresenceLocation::Remote(_)))
.collect();
assert_eq!(remote_entries.len(), 1);
assert_eq!(remote_entries[0].0, "remote1");
}
#[test]
fn local_overrides_remote_in_lookup() {
let mut reg = PresenceRegistry::new();
let peer = addr("10.0.0.6:4433");
// Register as remote first
let mut fps = HashSet::new();
fps.insert("dupfp".to_string());
reg.update_peer(peer, fps);
assert_eq!(reg.lookup("dupfp"), Some(PresenceLocation::Remote(peer)));
// Now register locally — local should win
reg.register_local("dupfp", None, None);
assert_eq!(reg.lookup("dupfp"), Some(PresenceLocation::Local));
}
#[test]
fn update_peer_replaces_old_fingerprints() {
let mut reg = PresenceRegistry::new();
let peer = addr("10.0.0.7:4433");
let mut fps1 = HashSet::new();
fps1.insert("user_a".to_string());
fps1.insert("user_b".to_string());
reg.update_peer(peer, fps1);
assert_eq!(reg.lookup("user_a"), Some(PresenceLocation::Remote(peer)));
assert_eq!(reg.lookup("user_b"), Some(PresenceLocation::Remote(peer)));
// Update with only user_b — user_a should be gone
let mut fps2 = HashSet::new();
fps2.insert("user_b".to_string());
reg.update_peer(peer, fps2);
assert_eq!(reg.lookup("user_a"), None);
assert_eq!(reg.lookup("user_b"), Some(PresenceLocation::Remote(peer)));
}
}

View File

@@ -156,14 +156,19 @@ impl SlidingWindow {
pub struct ProbeRunner {
config: ProbeConfig,
metrics: ProbeMetrics,
presence: Option<Arc<tokio::sync::Mutex<crate::presence::PresenceRegistry>>>,
}
impl ProbeRunner {
/// Create a new probe runner, registering metrics with the given registry.
pub fn new(config: ProbeConfig, registry: &Registry) -> Self {
pub fn new(
config: ProbeConfig,
registry: &Registry,
presence: Option<Arc<tokio::sync::Mutex<crate::presence::PresenceRegistry>>>,
) -> Self {
let target_str = config.target.to_string();
let metrics = ProbeMetrics::register(&target_str, registry);
Self { config, metrics }
Self { config, metrics, presence }
}
/// Run the probe forever. This function never returns under normal operation.
@@ -215,6 +220,8 @@ impl ProbeRunner {
let jitter_gauge = self.metrics.jitter_ms.clone();
let up_gauge = self.metrics.up.clone();
let recv_presence = self.presence.clone();
let recv_target = self.config.target;
let recv_handle = tokio::spawn(async move {
loop {
match recv_transport.recv_signal().await {
@@ -230,8 +237,17 @@ impl ProbeRunner {
loss_gauge.set(w.loss_pct());
jitter_gauge.set(w.jitter_ms());
}
Ok(Some(SignalMessage::PresenceUpdate { fingerprints, relay_addr })) => {
if let Some(ref reg) = recv_presence {
// Parse the relay_addr; fall back to the connection target
let addr = relay_addr.parse().unwrap_or(recv_target);
let fps: std::collections::HashSet<String> = fingerprints.into_iter().collect();
let mut r = reg.lock().await;
r.update_peer(addr, fps);
}
}
Ok(Some(_)) => {
// Ignore non-Pong signals
// Ignore other signals
}
Ok(None) => {
info!("probe recv: connection closed");
@@ -247,8 +263,9 @@ impl ProbeRunner {
}
});
// Send ping loop
// Send ping loop (+ presence gossip every 10 pings)
let mut interval = tokio::time::interval(self.config.interval);
let mut ping_count: u64 = 0;
loop {
interval.tick().await;
@@ -275,6 +292,24 @@ impl ProbeRunner {
recv_handle.abort();
return Err(e.into());
}
// Send presence update every 10 pings (~10 seconds)
ping_count += 1;
if ping_count % 10 == 0 {
if let Some(ref reg) = self.presence {
let fps: Vec<String> = {
let r = reg.lock().await;
r.local_fingerprints().into_iter().collect()
};
let msg = SignalMessage::PresenceUpdate {
fingerprints: fps,
relay_addr: self.config.target.to_string(),
};
if let Err(e) = transport.send_signal(&msg).await {
warn!(target = %self.config.target, "presence update send error: {e}");
}
}
}
}
}
}
@@ -289,12 +324,16 @@ pub struct ProbeMesh {
impl ProbeMesh {
/// Create a new mesh coordinator, registering metrics for every target.
pub fn new(targets: Vec<SocketAddr>, registry: &Registry) -> Self {
pub fn new(
targets: Vec<SocketAddr>,
registry: &Registry,
presence: Option<Arc<tokio::sync::Mutex<crate::presence::PresenceRegistry>>>,
) -> Self {
let runners = targets
.into_iter()
.map(|addr| {
let config = ProbeConfig::new(addr);
ProbeRunner::new(config, registry)
ProbeRunner::new(config, registry, presence.clone())
})
.collect();
Self { runners }
@@ -409,6 +448,7 @@ mod tests {
fn probe_metrics_register() {
let registry = Registry::new();
let _metrics = ProbeMetrics::register("127.0.0.1:4433", &registry);
// (ProbeRunner::new signature changed but this test only checks ProbeMetrics)
let encoder = prometheus::TextEncoder::new();
let families = registry.gather();
@@ -526,7 +566,7 @@ mod tests {
"127.0.0.2:4433".parse().unwrap(),
"127.0.0.3:4433".parse().unwrap(),
];
let mesh = ProbeMesh::new(targets, &registry);
let mesh = ProbeMesh::new(targets, &registry, None);
assert_eq!(mesh.target_count(), 3);
// Verify metrics were registered for each target
@@ -586,7 +626,7 @@ mod tests {
#[test]
fn mesh_zero_targets() {
let registry = Registry::new();
let mesh = ProbeMesh::new(vec![], &registry);
let mesh = ProbeMesh::new(vec![], &registry, None);
assert_eq!(mesh.target_count(), 0);
}
}

View File

@@ -0,0 +1,483 @@
//! Per-session relay forwarding — connect to a peer relay and forward only
//! specific sessions' media packets there.
//!
//! This is the building block for relay chaining (multi-hop calls). Instead
//! of forwarding ALL traffic to a single hardcoded relay (forward mode) or
//! to everyone in a room (SFU mode), a `RelayLink` represents a QUIC
//! connection to one peer relay used for forwarding a specific set of
//! sessions.
//!
//! `RelayLinkManager` tracks all active relay links and their session
//! assignments, providing get-or-connect semantics and idle cleanup.
use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
use std::sync::Arc;
use tracing::{debug, info, warn};
use wzp_proto::MediaPacket;
use wzp_proto::MediaTransport;
/// A connection to a peer relay for forwarding specific sessions.
///
/// Each `RelayLink` holds a QUIC transport to one peer relay and tracks
/// which session IDs are being forwarded through it. When all sessions
/// are removed the link is considered idle and can be cleaned up.
pub struct RelayLink {
target_addr: SocketAddr,
/// The underlying QUIC transport. `None` only in unit-test stubs where
/// no real connection is established.
transport: Option<Arc<wzp_transport::QuinnTransport>>,
active_sessions: HashSet<String>,
}
impl RelayLink {
/// Connect to a peer relay at `target`.
///
/// Uses the `"_relay"` SNI to signal that this is a relay-to-relay
/// connection (similar to `"_probe"` for health checks). The peer
/// should skip normal client auth/handshake for relay-SNI connections.
pub async fn connect(target: SocketAddr) -> Result<Self, anyhow::Error> {
// Create a client-only endpoint on an OS-assigned port.
let endpoint = wzp_transport::create_endpoint(
"0.0.0.0:0".parse().unwrap(),
None,
)?;
let client_cfg = wzp_transport::client_config();
let conn = wzp_transport::connect(&endpoint, target, "_relay", client_cfg).await?;
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
info!(%target, "relay link established");
Ok(Self {
target_addr: target,
transport: Some(transport),
active_sessions: HashSet::new(),
})
}
/// Create a `RelayLink` from an existing transport (useful when the
/// connection was established through other means).
pub fn from_transport(
target_addr: SocketAddr,
transport: Arc<wzp_transport::QuinnTransport>,
) -> Self {
Self {
target_addr,
transport: Some(transport),
active_sessions: HashSet::new(),
}
}
/// Create a stub `RelayLink` with no transport — for unit tests that
/// only exercise session-tracking / management logic.
#[cfg(test)]
fn stub(target_addr: SocketAddr) -> Self {
Self {
target_addr,
transport: None,
active_sessions: HashSet::new(),
}
}
/// Forward a media packet to this peer relay.
pub async fn forward(&self, pkt: &MediaPacket) -> Result<(), anyhow::Error> {
match &self.transport {
Some(t) => t
.send_media(pkt)
.await
.map_err(|e| anyhow::anyhow!("relay link forward to {}: {e}", self.target_addr)),
None => Err(anyhow::anyhow!(
"relay link to {} has no transport (stub)",
self.target_addr
)),
}
}
/// The address of the peer relay this link connects to.
pub fn target_addr(&self) -> SocketAddr {
self.target_addr
}
/// A reference to the underlying QUIC transport (if connected).
pub fn transport(&self) -> Option<&Arc<wzp_transport::QuinnTransport>> {
self.transport.as_ref()
}
/// Add a session to be forwarded through this link.
pub fn add_session(&mut self, session_id: &str) {
if self.active_sessions.insert(session_id.to_string()) {
debug!(
target_relay = %self.target_addr,
session = session_id,
count = self.active_sessions.len(),
"session added to relay link"
);
}
}
/// Remove a session from this link.
pub fn remove_session(&mut self, session_id: &str) {
if self.active_sessions.remove(session_id) {
debug!(
target_relay = %self.target_addr,
session = session_id,
count = self.active_sessions.len(),
"session removed from relay link"
);
}
}
/// Check if this link is forwarding any sessions.
pub fn is_idle(&self) -> bool {
self.active_sessions.is_empty()
}
/// Number of sessions being forwarded through this link.
pub fn session_count(&self) -> usize {
self.active_sessions.len()
}
/// Check if a specific session is being forwarded through this link.
pub fn has_session(&self, session_id: &str) -> bool {
self.active_sessions.contains(session_id)
}
/// Close the underlying QUIC connection (no-op if no transport).
pub async fn close(&self) {
info!(target_relay = %self.target_addr, "closing relay link");
if let Some(ref t) = self.transport {
let _ = t.close().await;
}
}
}
// ---------------------------------------------------------------------------
// RelayLinkManager
// ---------------------------------------------------------------------------
/// Manages connections to multiple peer relays for per-session forwarding.
///
/// Each peer relay gets at most one `RelayLink`. Sessions are registered
/// on specific links, and idle links (no sessions) can be cleaned up.
pub struct RelayLinkManager {
links: HashMap<SocketAddr, RelayLink>,
}
impl RelayLinkManager {
/// Create an empty link manager.
pub fn new() -> Self {
Self {
links: HashMap::new(),
}
}
/// Get or create a link to a peer relay.
///
/// If a link already exists it is returned. Otherwise a new QUIC
/// connection is established using `RelayLink::connect`.
pub async fn get_or_connect(
&mut self,
target: SocketAddr,
) -> Result<&RelayLink, anyhow::Error> {
if !self.links.contains_key(&target) {
let link = RelayLink::connect(target).await?;
self.links.insert(target, link);
}
Ok(self.links.get(&target).unwrap())
}
/// Get a mutable reference to an existing link (if any).
pub fn get_mut(&mut self, target: &SocketAddr) -> Option<&mut RelayLink> {
self.links.get_mut(target)
}
/// Get a reference to an existing link (if any).
pub fn get(&self, target: &SocketAddr) -> Option<&RelayLink> {
self.links.get(target)
}
/// Forward a packet for a specific session to the appropriate relay.
///
/// The link must already exist (created via `get_or_connect`).
pub async fn forward_to(
&self,
target: SocketAddr,
pkt: &MediaPacket,
) -> Result<(), anyhow::Error> {
match self.links.get(&target) {
Some(link) => link.forward(pkt).await,
None => Err(anyhow::anyhow!(
"no relay link to {target} — call get_or_connect first"
)),
}
}
/// Register a session on a specific link.
///
/// The link must already exist. If it does not, a warning is logged
/// and the registration is silently skipped.
pub fn register_session(&mut self, target: SocketAddr, session_id: &str) {
match self.links.get_mut(&target) {
Some(link) => link.add_session(session_id),
None => {
warn!(
%target,
session = session_id,
"cannot register session — no link to target"
);
}
}
}
/// Unregister a session. If the link becomes idle, close and remove it.
pub async fn unregister_session(&mut self, target: SocketAddr, session_id: &str) {
let should_remove = if let Some(link) = self.links.get_mut(&target) {
link.remove_session(session_id);
if link.is_idle() {
link.close().await;
true
} else {
false
}
} else {
false
};
if should_remove {
self.links.remove(&target);
info!(%target, "idle relay link removed");
}
}
/// Close all links and clear the manager.
pub async fn close_all(&mut self) {
for (addr, link) in self.links.drain() {
info!(%addr, "closing relay link (shutdown)");
link.close().await;
}
}
/// Number of active links.
pub fn link_count(&self) -> usize {
self.links.len()
}
/// Total number of sessions being forwarded across all links.
pub fn session_count(&self) -> usize {
self.links.values().map(|l| l.session_count()).sum()
}
/// Insert a pre-built relay link (for testing or manual setup).
pub fn insert(&mut self, link: RelayLink) {
self.links.insert(link.target_addr(), link);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn addr(s: &str) -> SocketAddr {
s.parse().unwrap()
}
// ---------- RelayLink session tracking ----------
#[test]
fn link_manager_tracks_sessions() {
let mut mgr = RelayLinkManager::new();
let target1 = addr("10.0.0.2:4433");
let mut link = RelayLink::stub(target1);
link.add_session("session-aaa");
link.add_session("session-bbb");
mgr.insert(link);
assert_eq!(mgr.link_count(), 1);
assert_eq!(mgr.session_count(), 2);
// Register another session on the same link
mgr.register_session(target1, "session-ccc");
assert_eq!(mgr.session_count(), 3);
// Verify individual link
let link_ref = mgr.get(&target1).unwrap();
assert!(link_ref.has_session("session-aaa"));
assert!(link_ref.has_session("session-bbb"));
assert!(link_ref.has_session("session-ccc"));
assert!(!link_ref.has_session("unknown"));
}
#[test]
fn link_manager_idle_detection() {
let mut link = RelayLink::stub(addr("10.0.0.3:4433"));
// Empty link is idle
assert!(link.is_idle());
assert_eq!(link.session_count(), 0);
// Add a session — no longer idle
link.add_session("sess-1");
assert!(!link.is_idle());
assert_eq!(link.session_count(), 1);
// Remove it — idle again
link.remove_session("sess-1");
assert!(link.is_idle());
assert_eq!(link.session_count(), 0);
}
#[test]
fn session_forward_signal_roundtrip() {
use wzp_proto::SignalMessage;
// SessionForward roundtrip
let msg = SignalMessage::SessionForward {
session_id: "abcd1234".to_string(),
target_fingerprint: "deadbeef".to_string(),
source_relay: "10.0.0.1:4433".to_string(),
};
let json = serde_json::to_string(&msg).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
match decoded {
SignalMessage::SessionForward {
session_id,
target_fingerprint,
source_relay,
} => {
assert_eq!(session_id, "abcd1234");
assert_eq!(target_fingerprint, "deadbeef");
assert_eq!(source_relay, "10.0.0.1:4433");
}
_ => panic!("expected SessionForward variant"),
}
// SessionForwardAck roundtrip
let ack = SignalMessage::SessionForwardAck {
session_id: "abcd1234".to_string(),
room_name: "relay-room-42".to_string(),
};
let json = serde_json::to_string(&ack).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
match decoded {
SignalMessage::SessionForwardAck {
session_id,
room_name,
} => {
assert_eq!(session_id, "abcd1234");
assert_eq!(room_name, "relay-room-42");
}
_ => panic!("expected SessionForwardAck variant"),
}
}
#[test]
fn link_manager_multi_target() {
let mut mgr = RelayLinkManager::new();
let target_a = addr("10.0.0.2:4433");
let target_b = addr("10.0.0.3:4433");
let target_c = addr("10.0.0.4:4433");
for (target, sessions) in [
(target_a, vec!["s1", "s2"]),
(target_b, vec!["s3"]),
(target_c, vec!["s4", "s5", "s6"]),
] {
let mut link = RelayLink::stub(target);
for s in sessions {
link.add_session(s);
}
mgr.insert(link);
}
assert_eq!(mgr.link_count(), 3);
assert_eq!(mgr.session_count(), 6); // 2 + 1 + 3
assert_eq!(mgr.get(&target_a).unwrap().session_count(), 2);
assert_eq!(mgr.get(&target_b).unwrap().session_count(), 1);
assert_eq!(mgr.get(&target_c).unwrap().session_count(), 3);
}
#[test]
fn link_manager_cleanup() {
let mut mgr = RelayLinkManager::new();
let target = addr("10.0.0.5:4433");
let mut link = RelayLink::stub(target);
link.add_session("s1");
link.add_session("s2");
link.add_session("s3");
mgr.insert(link);
assert_eq!(mgr.link_count(), 1);
assert_eq!(mgr.session_count(), 3);
// Remove sessions one by one via the manager's mutable access.
// We cannot call the async unregister_session with stub links here,
// so we exercise the synchronous management path directly.
{
let link = mgr.get_mut(&target).unwrap();
link.remove_session("s1");
assert!(!link.is_idle());
link.remove_session("s2");
assert!(!link.is_idle());
link.remove_session("s3");
assert!(link.is_idle());
}
// All sessions removed — link is idle
assert_eq!(mgr.session_count(), 0);
assert!(mgr.get(&target).unwrap().is_idle());
// Simulate what unregister_session does: remove the idle link
mgr.links.remove(&target);
assert_eq!(mgr.link_count(), 0);
}
#[test]
fn register_session_on_nonexistent_link_is_noop() {
let mut mgr = RelayLinkManager::new();
// Should not panic, just warn
mgr.register_session(addr("10.0.0.99:4433"), "orphan-session");
assert_eq!(mgr.link_count(), 0);
assert_eq!(mgr.session_count(), 0);
}
#[test]
fn forward_to_nonexistent_link_errors() {
let mgr = RelayLinkManager::new();
let target = addr("10.0.0.99:4433");
let pkt = MediaPacket {
header: wzp_proto::packet::MediaHeader {
version: 0,
is_repair: false,
codec_id: wzp_proto::CodecId::Opus16k,
has_quality_report: false,
fec_ratio_encoded: 0,
seq: 1,
timestamp: 100,
fec_block: 0,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
},
payload: bytes::Bytes::from_static(b"test"),
quality_report: None,
};
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let result = rt.block_on(mgr.forward_to(target, &pkt));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("no relay link"));
}
}

View File

@@ -0,0 +1,265 @@
//! Route resolution — given a target fingerprint, find the relay chain
//! needed to reach that user.
//!
//! Uses the [`PresenceRegistry`] as its data source. Currently supports
//! single-hop resolution (local or direct peer). The `resolve_multi_hop`
//! method has the signature for future multi-hop expansion but falls back
//! to single-hop for now.
use std::net::SocketAddr;
use serde::Serialize;
use crate::presence::{PresenceLocation, PresenceRegistry};
// ---------------------------------------------------------------------------
// Route type
// ---------------------------------------------------------------------------
/// The resolved route to a target fingerprint.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub enum Route {
/// Target is connected to this relay directly.
Local,
/// Target is on a directly connected peer relay.
DirectPeer(SocketAddr),
/// Target is reachable via a chain of relays (multi-hop).
Chain(Vec<SocketAddr>),
/// Target not found in any known relay.
NotFound,
}
impl std::fmt::Display for Route {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Route::Local => write!(f, "local"),
Route::DirectPeer(addr) => write!(f, "direct_peer({})", addr),
Route::Chain(chain) => {
let addrs: Vec<String> = chain.iter().map(|a| a.to_string()).collect();
write!(f, "chain({})", addrs.join(" -> "))
}
Route::NotFound => write!(f, "not_found"),
}
}
}
// ---------------------------------------------------------------------------
// RouteResolver
// ---------------------------------------------------------------------------
/// Resolves fingerprints to relay routes using the presence registry.
pub struct RouteResolver {
/// Our own relay address (how peers know us).
local_addr: SocketAddr,
}
impl RouteResolver {
/// Create a new route resolver for the relay at `local_addr`.
pub fn new(local_addr: SocketAddr) -> Self {
Self { local_addr }
}
/// Our local relay address.
pub fn local_addr(&self) -> SocketAddr {
self.local_addr
}
/// Look up a fingerprint in the registry and return the route.
///
/// - If `registry.lookup()` returns `Local` -> `Route::Local`
/// - If returns `Remote(addr)` -> `Route::DirectPeer(addr)`
/// - If not found -> `Route::NotFound`
pub fn resolve(&self, registry: &PresenceRegistry, target_fingerprint: &str) -> Route {
match registry.lookup(target_fingerprint) {
Some(PresenceLocation::Local) => Route::Local,
Some(PresenceLocation::Remote(addr)) => Route::DirectPeer(addr),
None => Route::NotFound,
}
}
/// Multi-hop route resolution (future expansion).
///
/// For now this is equivalent to `resolve()` — single-hop only.
/// When multi-hop is implemented, this will query peers transitively
/// up to `max_hops` relays deep, using `RouteQuery` / `RouteResponse`
/// signals over probe connections.
pub fn resolve_multi_hop(
&self,
registry: &PresenceRegistry,
target: &str,
_max_hops: usize,
) -> Route {
// Phase 1: single-hop only (same as resolve).
// Future: if resolve returns NotFound and max_hops > 0,
// send RouteQuery to each known peer with ttl = max_hops - 1,
// collect RouteResponse, and build a Chain.
self.resolve(registry, target)
}
/// Build a JSON-serializable route response for the HTTP API.
pub fn route_json(
&self,
fingerprint: &str,
route: &Route,
) -> serde_json::Value {
let (route_type, relay_chain) = match route {
Route::Local => ("local", vec![self.local_addr.to_string()]),
Route::DirectPeer(addr) => ("direct_peer", vec![self.local_addr.to_string(), addr.to_string()]),
Route::Chain(chain) => {
let mut addrs = vec![self.local_addr.to_string()];
addrs.extend(chain.iter().map(|a| a.to_string()));
("chain", addrs)
}
Route::NotFound => ("not_found", vec![]),
};
serde_json::json!({
"fingerprint": fingerprint,
"route": route_type,
"relay_chain": relay_chain,
})
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use std::net::SocketAddr;
fn addr(s: &str) -> SocketAddr {
s.parse().unwrap()
}
fn make_resolver() -> RouteResolver {
RouteResolver::new(addr("10.0.0.1:4433"))
}
#[test]
fn resolve_local() {
let resolver = make_resolver();
let mut reg = PresenceRegistry::new();
reg.register_local("aabbccdd", Some("alice".into()), Some("room1".into()));
let route = resolver.resolve(&reg, "aabbccdd");
assert_eq!(route, Route::Local);
}
#[test]
fn resolve_direct_peer() {
let resolver = make_resolver();
let mut reg = PresenceRegistry::new();
let peer = addr("10.0.0.2:4433");
let mut fps = HashSet::new();
fps.insert("deadbeef".to_string());
reg.update_peer(peer, fps);
let route = resolver.resolve(&reg, "deadbeef");
assert_eq!(route, Route::DirectPeer(peer));
}
#[test]
fn resolve_not_found() {
let resolver = make_resolver();
let reg = PresenceRegistry::new();
let route = resolver.resolve(&reg, "unknown_fp");
assert_eq!(route, Route::NotFound);
}
#[test]
fn resolve_multi_hop_fallback() {
// multi-hop currently falls back to single-hop behavior
let resolver = make_resolver();
let mut reg = PresenceRegistry::new();
reg.register_local("local_fp", None, None);
let peer = addr("10.0.0.3:4433");
let mut fps = HashSet::new();
fps.insert("remote_fp".to_string());
reg.update_peer(peer, fps);
// Local lookup works via multi-hop
assert_eq!(resolver.resolve_multi_hop(&reg, "local_fp", 3), Route::Local);
// Remote lookup works via multi-hop
assert_eq!(
resolver.resolve_multi_hop(&reg, "remote_fp", 3),
Route::DirectPeer(peer)
);
// Not-found works via multi-hop
assert_eq!(
resolver.resolve_multi_hop(&reg, "nobody", 3),
Route::NotFound
);
}
#[test]
fn route_query_signal_roundtrip() {
use wzp_proto::SignalMessage;
let query = SignalMessage::RouteQuery {
fingerprint: "aabbccdd".to_string(),
ttl: 3,
};
let json = serde_json::to_string(&query).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(
decoded,
SignalMessage::RouteQuery { ref fingerprint, ttl }
if fingerprint == "aabbccdd" && ttl == 3
));
let response = SignalMessage::RouteResponse {
fingerprint: "aabbccdd".to_string(),
found: true,
relay_chain: vec!["10.0.0.1:4433".to_string(), "10.0.0.2:4433".to_string()],
};
let json = serde_json::to_string(&response).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(
decoded,
SignalMessage::RouteResponse { ref fingerprint, found, ref relay_chain }
if fingerprint == "aabbccdd" && found && relay_chain.len() == 2
));
}
#[test]
fn route_display() {
assert_eq!(Route::Local.to_string(), "local");
assert_eq!(
Route::DirectPeer(addr("10.0.0.2:4433")).to_string(),
"direct_peer(10.0.0.2:4433)"
);
assert_eq!(
Route::Chain(vec![addr("10.0.0.2:4433"), addr("10.0.0.3:4433")]).to_string(),
"chain(10.0.0.2:4433 -> 10.0.0.3:4433)"
);
assert_eq!(Route::NotFound.to_string(), "not_found");
// Debug is also useful
let debug = format!("{:?}", Route::Local);
assert!(debug.contains("Local"));
}
#[test]
fn route_json_output() {
let resolver = make_resolver();
let json = resolver.route_json("fp1", &Route::Local);
assert_eq!(json["route"], "local");
assert_eq!(json["fingerprint"], "fp1");
assert_eq!(json["relay_chain"].as_array().unwrap().len(), 1);
let json = resolver.route_json("fp2", &Route::DirectPeer(addr("10.0.0.2:4433")));
assert_eq!(json["route"], "direct_peer");
assert_eq!(json["relay_chain"].as_array().unwrap().len(), 2);
let json = resolver.route_json("fp3", &Route::NotFound);
assert_eq!(json["route"], "not_found");
assert_eq!(json["relay_chain"].as_array().unwrap().len(), 0);
}
}

View File

@@ -0,0 +1,25 @@
[package]
name = "wzp-wasm"
version = "0.1.0"
edition = "2021"
description = "WarzonePhone WASM bindings — FEC (RaptorQ) + crypto (ChaCha20-Poly1305, X25519)"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
raptorq = "2"
js-sys = "0.3"
# Crypto (ChaCha20-Poly1305 + X25519 key exchange)
chacha20poly1305 = "0.10"
hkdf = "0.12"
sha2 = "0.10"
x25519-dalek = { version = "2", features = ["static_secrets"] }
rand = "0.8"
getrandom = { version = "0.2", features = ["js"] } # CRITICAL for WASM randomness
[profile.release]
opt-level = "s"
lto = true

692
crates/wzp-wasm/src/lib.rs Normal file
View File

@@ -0,0 +1,692 @@
//! WarzonePhone WASM bindings.
//!
//! Exports two subsystems for browser-side usage:
//!
//! **FEC** — RaptorQ forward error correction (encode/decode).
//! Audio frames are padded to a fixed symbol size (default 256 bytes) with a
//! 2-byte little-endian length prefix, matching the native wzp-fec wire format.
//!
//! Wire format per symbol:
//! [block_id:1][symbol_idx:1][is_repair:1][symbol_data:symbol_size]
//!
//! Encoder output: concatenated symbols in the above format when a block completes.
//! Decoder input: individual symbols in the above format.
//! Decoder output: concatenated original source data (length-prefix stripped).
//!
//! **Crypto** — X25519 key exchange + ChaCha20-Poly1305 AEAD encryption.
//! Mirrors `wzp-crypto` nonce/session/handshake logic so WASM and native
//! peers produce interoperable ciphertext.
use wasm_bindgen::prelude::*;
use raptorq::{
EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder,
SourceBlockEncoder,
};
/// Header size prepended to each symbol on the wire: block_id + symbol_idx + is_repair.
const HEADER_SIZE: usize = 3;
/// Length prefix size inside each padded symbol (u16 LE), matching wzp-fec.
const LEN_PREFIX: usize = 2;
// ---------------------------------------------------------------------------
// Encoder
// ---------------------------------------------------------------------------
#[wasm_bindgen]
pub struct WzpFecEncoder {
block_id: u8,
frames_per_block: usize,
symbol_size: usize,
source_symbols: Vec<Vec<u8>>,
}
#[wasm_bindgen]
impl WzpFecEncoder {
/// Create a new FEC encoder.
///
/// * `block_size` — number of source symbols (audio frames) per FEC block.
/// * `symbol_size` — padded byte size of each symbol (default 256).
#[wasm_bindgen(constructor)]
pub fn new(block_size: usize, symbol_size: usize) -> Self {
Self {
block_id: 0,
frames_per_block: block_size,
symbol_size,
source_symbols: Vec::with_capacity(block_size),
}
}
/// Add a source symbol (audio frame).
///
/// Returns encoded packets (all source + repair) when the block is complete,
/// or `undefined` if the block is still accumulating.
///
/// Each returned packet carries the 3-byte header:
/// `[block_id][symbol_idx][is_repair]` followed by `symbol_size` bytes.
pub fn add_symbol(&mut self, data: &[u8]) -> Option<Vec<u8>> {
self.source_symbols.push(data.to_vec());
if self.source_symbols.len() >= self.frames_per_block {
Some(self.encode_block())
} else {
None
}
}
/// Force-flush the current (possibly partial) block.
///
/// Returns all source + repair symbols with headers, or empty vec if no
/// symbols have been accumulated.
pub fn flush(&mut self) -> Vec<u8> {
if self.source_symbols.is_empty() {
return Vec::new();
}
self.encode_block()
}
/// Internal: encode accumulated source symbols into a block, generate repair,
/// and return the concatenated wire-format output.
fn encode_block(&mut self) -> Vec<u8> {
let ss = self.symbol_size;
let num_source = self.source_symbols.len();
let block_id = self.block_id;
// Build length-prefixed, padded block data (matches wzp-fec format).
let block_data = self.build_block_data();
let config =
ObjectTransmissionInformation::with_defaults(block_data.len() as u64, ss as u16);
let encoder = SourceBlockEncoder::new(block_id, &config, &block_data);
// Generate source packets.
let source_packets = encoder.source_packets();
// Generate repair packets — 50% overhead by default.
let num_repair = ((num_source as f32) * 0.5).ceil() as u32;
let repair_packets = encoder.repair_packets(0, num_repair);
// Allocate output buffer.
let total_packets = source_packets.len() + repair_packets.len();
let packet_wire_size = HEADER_SIZE + ss;
let mut output = Vec::with_capacity(total_packets * packet_wire_size);
// Write source symbols.
for (i, pkt) in source_packets.iter().enumerate() {
output.push(block_id);
output.push(i as u8);
output.push(0); // is_repair = false
let pkt_data = pkt.data();
let copy_len = pkt_data.len().min(ss);
output.extend_from_slice(&pkt_data[..copy_len]);
// Pad if shorter.
if copy_len < ss {
output.resize(output.len() + (ss - copy_len), 0);
}
}
// Write repair symbols.
for (i, pkt) in repair_packets.iter().enumerate() {
output.push(block_id);
output.push((num_source + i) as u8);
output.push(1); // is_repair = true
let pkt_data = pkt.data();
let copy_len = pkt_data.len().min(ss);
output.extend_from_slice(&pkt_data[..copy_len]);
if copy_len < ss {
output.resize(output.len() + (ss - copy_len), 0);
}
}
// Advance block.
self.block_id = self.block_id.wrapping_add(1);
self.source_symbols.clear();
output
}
/// Build the contiguous, length-prefixed block data buffer.
fn build_block_data(&self) -> Vec<u8> {
let ss = self.symbol_size;
let mut data = vec![0u8; self.source_symbols.len() * ss];
for (i, sym) in self.source_symbols.iter().enumerate() {
let max_payload = ss - LEN_PREFIX;
let payload_len = sym.len().min(max_payload);
let offset = i * ss;
data[offset..offset + LEN_PREFIX]
.copy_from_slice(&(payload_len as u16).to_le_bytes());
data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len]
.copy_from_slice(&sym[..payload_len]);
}
data
}
}
// ---------------------------------------------------------------------------
// Decoder
// ---------------------------------------------------------------------------
/// Per-block decoder state.
struct BlockState {
packets: Vec<EncodingPacket>,
decoded: bool,
result: Option<Vec<u8>>,
}
#[wasm_bindgen]
pub struct WzpFecDecoder {
frames_per_block: usize,
symbol_size: usize,
blocks: Vec<(u8, BlockState)>, // poor man's map (no std HashMap in tiny WASM)
}
#[wasm_bindgen]
impl WzpFecDecoder {
/// Create a new FEC decoder.
///
/// * `block_size` — expected number of source symbols per block.
/// * `symbol_size` — padded byte size of each symbol (must match encoder).
#[wasm_bindgen(constructor)]
pub fn new(block_size: usize, symbol_size: usize) -> Self {
Self {
frames_per_block: block_size,
symbol_size,
blocks: Vec::new(),
}
}
/// Feed a received symbol.
///
/// Returns the decoded block (concatenated original frames, unpadded) if
/// enough symbols have been received to recover the block, or `undefined`.
pub fn add_symbol(
&mut self,
block_id: u8,
symbol_idx: u8,
_is_repair: bool,
data: &[u8],
) -> Option<Vec<u8>> {
let ss = self.symbol_size;
// Pad incoming data to symbol_size.
let mut padded = vec![0u8; ss];
let len = data.len().min(ss);
padded[..len].copy_from_slice(&data[..len]);
let esi = symbol_idx as u32;
let packet = EncodingPacket::new(PayloadId::new(block_id, esi), padded);
// Find or create block state.
let block = self.get_or_create_block(block_id);
if block.decoded {
return block.result.clone();
}
block.packets.push(packet);
// Attempt decode.
self.try_decode(block_id)
}
/// Try to decode a block; returns the original frames if successful.
fn try_decode(&mut self, block_id: u8) -> Option<Vec<u8>> {
let ss = self.symbol_size;
let num_source = self.frames_per_block;
let block_length = (num_source as u64) * (ss as u64);
let block = self.get_block_mut(block_id)?;
if block.decoded {
return block.result.clone();
}
let config =
ObjectTransmissionInformation::with_defaults(block_length, ss as u16);
let mut decoder = SourceBlockDecoder::new(block_id, &config, block_length);
let decoded = decoder.decode(block.packets.clone());
match decoded {
Some(data) => {
// Extract original frames by stripping length prefixes.
let mut output = Vec::new();
for i in 0..num_source {
let offset = i * ss;
if offset + LEN_PREFIX > data.len() {
break;
}
let payload_len = u16::from_le_bytes([
data[offset],
data[offset + 1],
]) as usize;
let payload_start = offset + LEN_PREFIX;
let payload_end = (payload_start + payload_len).min(data.len());
output.extend_from_slice(&data[payload_start..payload_end]);
}
let block = self.get_block_mut(block_id).unwrap();
block.decoded = true;
block.result = Some(output.clone());
Some(output)
}
None => None,
}
}
fn get_or_create_block(&mut self, block_id: u8) -> &mut BlockState {
if let Some(pos) = self.blocks.iter().position(|(id, _)| *id == block_id) {
return &mut self.blocks[pos].1;
}
self.blocks.push((
block_id,
BlockState {
packets: Vec::new(),
decoded: false,
result: None,
},
));
let last = self.blocks.len() - 1;
&mut self.blocks[last].1
}
fn get_block_mut(&mut self, block_id: u8) -> Option<&mut BlockState> {
self.blocks
.iter_mut()
.find(|(id, _)| *id == block_id)
.map(|(_, state)| state)
}
}
// =========================================================================
// Crypto — X25519 key exchange
// =========================================================================
/// X25519 key exchange: generate ephemeral keypair and derive shared secret.
///
/// Usage from JS:
/// ```js
/// const kx = new WzpKeyExchange();
/// const ourPub = kx.public_key(); // Uint8Array(32)
/// // ... send ourPub to peer, receive peerPub ...
/// const secret = kx.derive_shared_secret(peerPub); // Uint8Array(32)
/// const session = new WzpCryptoSession(secret);
/// ```
#[wasm_bindgen]
pub struct WzpKeyExchange {
secret: x25519_dalek::StaticSecret,
public: x25519_dalek::PublicKey,
}
#[wasm_bindgen]
impl WzpKeyExchange {
/// Generate a new random X25519 keypair.
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
let secret = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
let public = x25519_dalek::PublicKey::from(&secret);
Self { secret, public }
}
/// Our public key (32 bytes).
pub fn public_key(&self) -> Vec<u8> {
self.public.as_bytes().to_vec()
}
/// Derive a 32-byte session key from the peer's public key.
///
/// Raw DH output is expanded via HKDF-SHA256 with info="warzone-session-key",
/// matching `wzp-crypto::handshake::WarzoneKeyExchange::derive_session`.
pub fn derive_shared_secret(&self, peer_public: &[u8]) -> Result<Vec<u8>, JsValue> {
if peer_public.len() != 32 {
return Err(JsValue::from_str("peer public key must be 32 bytes"));
}
let mut peer_bytes = [0u8; 32];
peer_bytes.copy_from_slice(peer_public);
let peer_pk = x25519_dalek::PublicKey::from(peer_bytes);
// Rebuild secret from bytes (StaticSecret doesn't impl Clone).
let secret_bytes = self.secret.to_bytes();
let secret_clone = x25519_dalek::StaticSecret::from(secret_bytes);
let shared = secret_clone.diffie_hellman(&peer_pk);
// HKDF expand — same derivation as wzp-crypto handshake.rs
use hkdf::Hkdf;
use sha2::Sha256;
let hk = Hkdf::<Sha256>::new(None, shared.as_bytes());
let mut session_key = [0u8; 32];
hk.expand(b"warzone-session-key", &mut session_key)
.expect("HKDF expand should not fail for 32-byte output");
Ok(session_key.to_vec())
}
}
// =========================================================================
// Crypto — ChaCha20-Poly1305 AEAD session
// =========================================================================
/// Build a 12-byte nonce (mirrors `wzp-crypto::nonce::build_nonce`).
///
/// Layout: `session_id[4] || seq(u32 BE) || direction(1) || pad(3 zero)`.
fn build_nonce(session_id: &[u8; 4], seq: u32, direction: u8) -> [u8; 12] {
let mut nonce = [0u8; 12];
nonce[0..4].copy_from_slice(session_id);
nonce[4..8].copy_from_slice(&seq.to_be_bytes());
nonce[8] = direction;
nonce
}
/// Symmetric encryption session using ChaCha20-Poly1305.
///
/// Mirrors `wzp-crypto::session::ChaChaSession` for WASM. Nonce derivation
/// and key setup are identical so WASM and native peers interoperate.
#[wasm_bindgen]
pub struct WzpCryptoSession {
cipher: chacha20poly1305::ChaCha20Poly1305,
session_id: [u8; 4],
send_seq: u32,
recv_seq: u32,
}
#[wasm_bindgen]
impl WzpCryptoSession {
/// Create from a 32-byte shared secret (output of `WzpKeyExchange.derive_shared_secret`).
#[wasm_bindgen(constructor)]
pub fn new(shared_secret: &[u8]) -> Result<WzpCryptoSession, JsValue> {
if shared_secret.len() != 32 {
return Err(JsValue::from_str("shared secret must be 32 bytes"));
}
use chacha20poly1305::KeyInit;
use sha2::Digest;
let session_id_hash = sha2::Sha256::digest(shared_secret);
let mut session_id = [0u8; 4];
session_id.copy_from_slice(&session_id_hash[..4]);
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(shared_secret)
.map_err(|e| JsValue::from_str(&format!("invalid key: {}", e)))?;
Ok(Self {
cipher,
session_id,
send_seq: 0,
recv_seq: 0,
})
}
/// Encrypt a media payload with AAD (typically the 12-byte MediaHeader).
///
/// Returns `ciphertext || poly1305_tag` (plaintext.len() + 16 bytes).
pub fn encrypt(&mut self, header_aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, JsValue> {
use chacha20poly1305::aead::{Aead, Payload};
use chacha20poly1305::Nonce;
let nonce_bytes = build_nonce(&self.session_id, self.send_seq, 0); // 0 = Send
let nonce = Nonce::from_slice(&nonce_bytes);
let payload = Payload {
msg: plaintext,
aad: header_aad,
};
let ciphertext = self
.cipher
.encrypt(nonce, payload)
.map_err(|_| JsValue::from_str("encryption failed"))?;
self.send_seq = self.send_seq.wrapping_add(1);
Ok(ciphertext)
}
/// Decrypt a media payload with AAD.
///
/// Returns plaintext on success, or throws on auth failure.
pub fn decrypt(&mut self, header_aad: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, JsValue> {
use chacha20poly1305::aead::{Aead, Payload};
use chacha20poly1305::Nonce;
// direction=0 (Send) matches the sender's nonce — same as native code.
let nonce_bytes = build_nonce(&self.session_id, self.recv_seq, 0);
let nonce = Nonce::from_slice(&nonce_bytes);
let payload = Payload {
msg: ciphertext,
aad: header_aad,
};
let plaintext = self
.cipher
.decrypt(nonce, payload)
.map_err(|_| JsValue::from_str("decryption failed — bad key or corrupted data"))?;
self.recv_seq = self.recv_seq.wrapping_add(1);
Ok(plaintext)
}
/// Current send sequence number (for diagnostics / UI stats).
pub fn send_seq(&self) -> u32 {
self.send_seq
}
/// Current receive sequence number (for diagnostics / UI stats).
pub fn recv_seq(&self) -> u32 {
self.recv_seq
}
}
// ---------------------------------------------------------------------------
// Tests (native only — not compiled to WASM)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_decode_roundtrip() {
let block_size = 5;
let symbol_size = 256;
let mut encoder = WzpFecEncoder::new(block_size, symbol_size);
let mut decoder = WzpFecDecoder::new(block_size, symbol_size);
// Create test frames of varying sizes.
let frames: Vec<Vec<u8>> = (0..block_size)
.map(|i| vec![(i as u8).wrapping_mul(37).wrapping_add(7); 80 + i * 10])
.collect();
// Feed frames to encoder; last one triggers block encoding.
let mut wire_data = None;
for frame in &frames {
wire_data = encoder.add_symbol(frame);
}
let wire_data = wire_data.expect("block should be complete");
// Parse wire packets and feed to decoder.
let packet_size = HEADER_SIZE + symbol_size;
assert_eq!(wire_data.len() % packet_size, 0);
let mut result = None;
for chunk in wire_data.chunks(packet_size) {
let blk_id = chunk[0];
let sym_idx = chunk[1];
let is_repair = chunk[2] != 0;
let sym_data = &chunk[HEADER_SIZE..];
if let Some(decoded) = decoder.add_symbol(blk_id, sym_idx, is_repair, sym_data) {
result = Some(decoded);
break;
}
}
let decoded_data = result.expect("should decode with all symbols");
// Verify: decoded data should be all original frames concatenated.
let mut expected = Vec::new();
for frame in &frames {
expected.extend_from_slice(frame);
}
assert_eq!(decoded_data, expected);
}
#[test]
fn decode_with_packet_loss() {
let block_size = 5;
let symbol_size = 256;
let mut encoder = WzpFecEncoder::new(block_size, symbol_size);
let mut decoder = WzpFecDecoder::new(block_size, symbol_size);
let frames: Vec<Vec<u8>> = (0..block_size)
.map(|i| vec![(i as u8).wrapping_mul(37).wrapping_add(7); 100])
.collect();
let mut wire_data = None;
for frame in &frames {
wire_data = encoder.add_symbol(frame);
}
let wire_data = wire_data.unwrap();
let packet_size = HEADER_SIZE + symbol_size;
let packets: Vec<&[u8]> = wire_data.chunks(packet_size).collect();
// Drop 2 source packets (simulate 40% source loss).
// We have 5 source + 3 repair = 8 packets. Drop packets at index 1 and 3.
let mut result = None;
for (i, chunk) in packets.iter().enumerate() {
if i == 1 || i == 3 {
continue; // simulate loss
}
let blk_id = chunk[0];
let sym_idx = chunk[1];
let is_repair = chunk[2] != 0;
let sym_data = &chunk[HEADER_SIZE..];
if let Some(decoded) = decoder.add_symbol(blk_id, sym_idx, is_repair, sym_data) {
result = Some(decoded);
break;
}
}
let decoded_data = result.expect("should recover with FEC despite 2 lost packets");
let mut expected = Vec::new();
for frame in &frames {
expected.extend_from_slice(frame);
}
assert_eq!(decoded_data, expected);
}
#[test]
fn flush_partial_block() {
let mut encoder = WzpFecEncoder::new(5, 256);
// Add only 3 of 5 expected symbols, then flush.
encoder.add_symbol(&[1; 50]);
encoder.add_symbol(&[2; 60]);
encoder.add_symbol(&[3; 70]);
let wire_data = encoder.flush();
assert!(!wire_data.is_empty());
// Verify block_id advanced.
assert_eq!(encoder.block_id, 1);
}
// -- Crypto tests -------------------------------------------------------
#[test]
fn crypto_encrypt_decrypt_roundtrip() {
let key = [0x42u8; 32];
let mut alice = WzpCryptoSession::new(&key).unwrap();
let mut bob = WzpCryptoSession::new(&key).unwrap();
let header = b"test-header";
let plaintext = b"hello warzone from wasm";
let ciphertext = alice.encrypt(header, plaintext).unwrap();
let decrypted = bob.decrypt(header, &ciphertext).unwrap();
assert_eq!(&decrypted, plaintext);
}
// NOTE: crypto_wrong_aad_fails and crypto_wrong_key_fails return
// Err(JsValue) which aborts on non-wasm32 (JsValue::from_str uses an
// extern "C" shim that panics with "cannot unwind"). These tests are
// gated to wasm32-only; on native the encrypt/decrypt roundtrip and
// nonce-layout tests provide sufficient coverage.
#[cfg(target_arch = "wasm32")]
#[test]
fn crypto_wrong_aad_fails() {
let key = [0x42u8; 32];
let mut alice = WzpCryptoSession::new(&key).unwrap();
let mut bob = WzpCryptoSession::new(&key).unwrap();
let ciphertext = alice.encrypt(b"correct", b"secret").unwrap();
let result = bob.decrypt(b"wrong", &ciphertext);
assert!(result.is_err());
}
#[cfg(target_arch = "wasm32")]
#[test]
fn crypto_wrong_key_fails() {
let mut alice = WzpCryptoSession::new(&[0xAA; 32]).unwrap();
let mut eve = WzpCryptoSession::new(&[0xBB; 32]).unwrap();
let ciphertext = alice.encrypt(b"hdr", b"secret").unwrap();
let result = eve.decrypt(b"hdr", &ciphertext);
assert!(result.is_err());
}
#[test]
fn crypto_multiple_packets() {
let key = [0x42u8; 32];
let mut alice = WzpCryptoSession::new(&key).unwrap();
let mut bob = WzpCryptoSession::new(&key).unwrap();
for i in 0..100u32 {
let msg = format!("message {}", i);
let ct = alice.encrypt(b"hdr", msg.as_bytes()).unwrap();
let pt = bob.decrypt(b"hdr", &ct).unwrap();
assert_eq!(pt, msg.as_bytes());
}
assert_eq!(alice.send_seq(), 100);
assert_eq!(bob.recv_seq(), 100);
}
#[test]
fn key_exchange_roundtrip() {
let alice_kx = WzpKeyExchange::new();
let bob_kx = WzpKeyExchange::new();
let alice_secret = alice_kx
.derive_shared_secret(&bob_kx.public_key())
.unwrap();
let bob_secret = bob_kx
.derive_shared_secret(&alice_kx.public_key())
.unwrap();
assert_eq!(alice_secret, bob_secret);
assert_eq!(alice_secret.len(), 32);
// Verify the derived secret actually works for encrypt/decrypt.
let mut alice_session = WzpCryptoSession::new(&alice_secret).unwrap();
let mut bob_session = WzpCryptoSession::new(&bob_secret).unwrap();
let ct = alice_session.encrypt(b"hdr", b"hello").unwrap();
let pt = bob_session.decrypt(b"hdr", &ct).unwrap();
assert_eq!(&pt, b"hello");
}
#[test]
fn nonce_layout_matches_native() {
// Verify our build_nonce matches wzp-crypto::nonce::build_nonce layout.
let sid = [0xAA, 0xBB, 0xCC, 0xDD];
let seq: u32 = 0x00000100;
let nonce = build_nonce(&sid, seq, 1); // 1 = Recv direction
assert_eq!(&nonce[0..4], &[0xAA, 0xBB, 0xCC, 0xDD]);
assert_eq!(&nonce[4..8], &[0x00, 0x00, 0x01, 0x00]);
assert_eq!(nonce[8], 1);
assert_eq!(&nonce[9..12], &[0, 0, 0]);
}
}

View File

@@ -10,6 +10,10 @@
.container { text-align: center; max-width: 420px; padding: 2rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #00d4ff; }
.subtitle { color: #888; font-size: 0.85rem; margin-bottom: 1.5rem; }
.variant-badge { display: inline-block; background: #2a2a4a; border: 1px solid #444; color: #00d4ff; font-size: 0.65rem; padding: 0.15rem 0.5rem; border-radius: 4px; margin-left: 0.4rem; vertical-align: middle; font-family: monospace; letter-spacing: 0.05em; }
.variant-selector { margin-bottom: 1.2rem; display: flex; gap: 0.8rem; justify-content: center; flex-wrap: wrap; }
.variant-selector label { font-size: 0.75rem; color: #888; cursor: pointer; display: flex; align-items: center; gap: 0.25rem; }
.variant-selector input[type="radio"] { accent-color: #00d4ff; }
.room-input { margin-bottom: 1.5rem; }
.room-input input { background: #2a2a4a; border: 1px solid #444; color: #e0e0e0; padding: 0.6rem 1rem; font-size: 1rem; border-radius: 8px; width: 200px; text-align: center; }
.room-input input:focus { outline: none; border-color: #00d4ff; }
@@ -31,15 +35,22 @@
</head>
<body>
<div class="container">
<h1>WarzonePhone</h1>
<h1>WarzonePhone <span class="variant-badge" id="variantBadge">PURE</span></h1>
<p class="subtitle">Lossy VoIP Protocol</p>
<div class="variant-selector">
<label><input type="radio" name="variant" value="pure"> Pure JS</label>
<label><input type="radio" name="variant" value="hybrid"> Hybrid</label>
<label><input type="radio" name="variant" value="full"> Full WASM</label>
</div>
<div class="room-input">
<label for="room">Room</label>
<input type="text" id="room" placeholder="enter room name" value="">
</div>
<button id="callBtn" onclick="toggleCall()">Connect</button>
<button id="callBtn">Connect</button>
<div class="controls" id="controls" style="display:none;">
<label><input type="checkbox" id="pttMode" onchange="togglePTT()"> Radio mode (push-to-talk)</label>
<label><input type="checkbox" id="pttMode"> Radio mode (push-to-talk)</label>
</div>
<button id="pttBtn">Hold to Talk</button>
<div class="level"><div class="level-bar" id="levelBar"></div></div>
@@ -47,302 +58,126 @@
<div class="stats" id="stats"></div>
</div>
<script src="js/wzp-core.js"></script>
<script>
const SAMPLE_RATE = 48000;
const FRAME_SIZE = 960;
let ws = null;
let audioCtx = null;
let mediaStream = null;
let captureNode = null;
let playbackNode = null;
let active = false;
let transmitting = true; // in open-mic mode, always transmitting
let pttMode = false;
let framesSent = 0;
let framesRecv = 0;
let startTime = 0;
let statsInterval = null;
// Use room from URL path or input field
function getRoom() {
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
if (path && path !== 'index.html') return path;
const hash = location.hash.replace('#', '');
if (hash) return hash;
return document.getElementById('room').value.trim() || 'default';
}
// Pre-fill room input from URL on page load
// ---------------------------------------------------------------------------
// Load the selected variant script dynamically
// ---------------------------------------------------------------------------
(function() {
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
if (path && path !== 'index.html') {
document.getElementById('room').value = path;
}
var variant = WZPCore.detectVariant();
var scriptMap = {
pure: 'js/wzp-pure.js',
hybrid: 'js/wzp-hybrid.js',
full: 'js/wzp-full.js',
};
var src = scriptMap[variant] || scriptMap.pure;
var s = document.createElement('script');
s.src = src;
s.onload = function() { wzpBoot(); };
s.onerror = function() {
WZPCore.updateStatus('Failed to load variant: ' + variant);
};
document.body.appendChild(s);
})();
function setStatus(msg) { document.getElementById('status').textContent = msg; }
function setStats(msg) { document.getElementById('stats').textContent = msg; }
// ---------------------------------------------------------------------------
// Boot: wire UI to the loaded client variant
// ---------------------------------------------------------------------------
function wzpBoot() {
var client = null;
var capture = null;
var playback = null;
var transmitting = true;
function toggleCall() {
if (active) stopCall();
else startCall();
}
async function startCall() {
const btn = document.getElementById('callBtn');
const room = getRoom();
if (!room) { setStatus('Enter a room name'); return; }
btn.disabled = true;
setStatus('Requesting microphone...');
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: { sampleRate: SAMPLE_RATE, channelCount: 1, echoCancellation: true, noiseSuppression: true }
var ui = WZPCore.initUI({
onConnect: function(room) {
doConnect(room);
},
onDisconnect: function() {
doDisconnect();
},
onTransmit: function(tx) {
transmitting = tx;
},
});
async function doConnect(room) {
WZPCore.updateStatus('Requesting microphone...');
var audioCtx;
try {
audioCtx = await WZPCore.startAudioContext();
} catch (e) {
setStatus('Mic access denied: ' + e.message);
btn.disabled = false;
WZPCore.updateStatus('Audio init failed: ' + e.message);
ui.setConnected(false);
return;
}
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
// Build WebSocket URL
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
var wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
// Connect WebSocket with room name
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
setStatus('Connecting to room: ' + room + '...');
ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = async () => {
setStatus('Connected to room: ' + room);
btn.textContent = 'Disconnect';
btn.classList.add('active');
btn.disabled = false;
active = true;
framesSent = 0;
framesRecv = 0;
startTime = Date.now();
showControls(true);
await startAudioCapture();
await startAudioPlayback();
startStatsUpdate();
};
ws.onmessage = (event) => {
const pcmData = new Int16Array(event.data);
framesRecv++;
playAudio(pcmData);
};
ws.onclose = () => {
if (active) {
setStatus('Disconnected — reconnecting to ' + room + '...');
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
} else {
setStatus('Disconnected');
}
};
ws.onerror = () => {
if (active) {
setStatus('Error — reconnecting...');
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
}
};
}
function stopCall() {
active = false;
const btn = document.getElementById('callBtn');
btn.textContent = 'Connect';
btn.classList.remove('active');
btn.disabled = false;
showControls(false);
cleanupAudio();
if (ws) { ws.close(); ws = null; }
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
setStatus('');
setStats('');
}
function cleanupAudio() {
if (captureNode) { captureNode.disconnect(); captureNode = null; }
if (playbackNode) { playbackNode.disconnect(); playbackNode = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; workletLoaded = false; }
if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; }
}
let workletLoaded = false;
async function loadWorkletModule() {
if (workletLoaded) return true;
if (typeof AudioWorkletNode === 'undefined' || !audioCtx.audioWorklet) {
console.warn('AudioWorklet API not supported in this browser — using ScriptProcessorNode fallback');
return false;
}
try {
await audioCtx.audioWorklet.addModule('audio-processor.js');
workletLoaded = true;
return true;
} catch(e) {
console.warn('AudioWorklet module failed to load — using ScriptProcessorNode fallback:', e);
return false;
}
}
async function startAudioCapture() {
const source = audioCtx.createMediaStreamSource(mediaStream);
const hasWorklet = await loadWorkletModule();
if (hasWorklet) {
captureNode = new AudioWorkletNode(audioCtx, 'wzp-capture-processor');
captureNode.port.onmessage = (e) => {
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
ws.send(e.data);
framesSent++;
// Level meter from the PCM data
const pcm = new Int16Array(e.data);
let max = 0;
for (let i = 0; i < pcm.length; i += 16) max = Math.max(max, Math.abs(pcm[i]));
document.getElementById('levelBar').style.width = (max / 32768 * 100) + '%';
};
source.connect(captureNode);
captureNode.connect(audioCtx.destination); // needed to keep worklet alive
} else {
// Fallback to ScriptProcessorNode (deprecated but widely supported)
console.warn('Capture: using ScriptProcessorNode fallback');
captureNode = audioCtx.createScriptProcessor(4096, 1, 1);
let acc = new Float32Array(0);
captureNode.onaudioprocess = (ev) => {
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
const input = ev.inputBuffer.getChannelData(0);
const n = new Float32Array(acc.length + input.length);
n.set(acc); n.set(input, acc.length); acc = n;
while (acc.length >= FRAME_SIZE) {
const frame = acc.slice(0, FRAME_SIZE); acc = acc.slice(FRAME_SIZE);
const pcm = new Int16Array(FRAME_SIZE);
for (let i = 0; i < FRAME_SIZE; i++) pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
let max = 0;
for (let i = 0; i < pcm.length; i += 16) max = Math.max(max, Math.abs(pcm[i]));
document.getElementById('levelBar').style.width = (max / 32768 * 100) + '%';
ws.send(pcm.buffer);
framesSent++;
}
};
source.connect(captureNode);
captureNode.connect(audioCtx.destination);
}
}
async function startAudioPlayback() {
const hasWorklet = await loadWorkletModule();
if (hasWorklet) {
playbackNode = new AudioWorkletNode(audioCtx, 'wzp-playback-processor');
playbackNode.connect(audioCtx.destination);
} else {
console.warn('Playback: using scheduled BufferSource fallback');
playbackNode = null; // will use createBufferSource fallback in playAudio()
}
}
let nextPlayTime = 0;
function playAudio(pcmInt16) {
if (!audioCtx) return;
if (playbackNode && playbackNode.port) {
// AudioWorklet path — send Int16 PCM directly to the worklet for conversion
playbackNode.port.postMessage(pcmInt16.buffer, [pcmInt16.buffer]);
} else {
// Fallback: scheduled BufferSource (convert Int16 -> Float32 on main thread)
const floatData = new Float32Array(pcmInt16.length);
for (let i = 0; i < pcmInt16.length; i++) {
floatData[i] = pcmInt16[i] / 32768.0;
}
const buffer = audioCtx.createBuffer(1, floatData.length, SAMPLE_RATE);
buffer.getChannelData(0).set(floatData);
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
const now = audioCtx.currentTime;
if (nextPlayTime < now || nextPlayTime > now + 1.0) {
nextPlayTime = now + 0.02;
}
source.start(nextPlayTime);
nextPlayTime += buffer.duration;
}
}
function startStatsUpdate() {
statsInterval = setInterval(() => {
if (!active) { clearInterval(statsInterval); return; }
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
setStats(elapsed + 's | sent: ' + framesSent + ' | recv: ' + framesRecv);
}, 1000);
}
// --- Push-to-talk ---
function togglePTT() {
pttMode = document.getElementById('pttMode').checked;
const btn = document.getElementById('pttBtn');
if (pttMode) {
transmitting = false;
btn.style.display = 'block';
} else {
transmitting = true;
btn.style.display = 'none';
}
}
// PTT button — hold to talk (mouse + touch)
document.getElementById('pttBtn').addEventListener('mousedown', () => { startTransmit(); });
document.getElementById('pttBtn').addEventListener('mouseup', () => { stopTransmit(); });
document.getElementById('pttBtn').addEventListener('mouseleave', () => { stopTransmit(); });
document.getElementById('pttBtn').addEventListener('touchstart', (e) => { e.preventDefault(); startTransmit(); });
document.getElementById('pttBtn').addEventListener('touchend', (e) => { e.preventDefault(); stopTransmit(); });
// Spacebar PTT
document.addEventListener('keydown', (e) => { if (pttMode && active && e.code === 'Space' && !e.repeat) { e.preventDefault(); startTransmit(); } });
document.addEventListener('keyup', (e) => { if (pttMode && active && e.code === 'Space') { e.preventDefault(); stopTransmit(); } });
function startTransmit() {
if (!pttMode || !active) return;
transmitting = true;
document.getElementById('pttBtn').classList.add('transmitting');
document.getElementById('pttBtn').textContent = 'Transmitting...';
}
function stopTransmit() {
if (!pttMode) return;
transmitting = false;
document.getElementById('pttBtn').classList.remove('transmitting');
document.getElementById('pttBtn').textContent = 'Hold to Talk';
}
// Show controls when connected
function showControls(show) {
document.getElementById('controls').style.display = show ? 'flex' : 'none';
if (!show) {
document.getElementById('pttBtn').style.display = 'none';
pttMode = false;
transmitting = true;
}
}
// Set room from URL on load
window.addEventListener('load', () => {
const room = getRoom();
if (room && room !== 'default') {
document.getElementById('room').value = room;
}
// Create client (currently always WZPPureClient; future: switch on variant)
client = new WZPPureClient({
wsUrl: wsUrl,
room: room,
onAudio: function(pcm) {
if (playback) playback.play(pcm);
},
onStatus: function(msg) {
WZPCore.updateStatus(msg);
},
onStats: function(stats) {
WZPCore.updateStats(stats);
},
});
try {
await client.connect();
} catch (e) {
WZPCore.updateStatus('Connection failed: ' + e.message);
ui.setConnected(false);
return;
}
// Start audio capture and playback
try {
capture = await WZPCore.connectCapture(audioCtx, function(pcmBuffer) {
if (!transmitting) return;
var pcm = new Int16Array(pcmBuffer);
WZPCore.updateLevel(pcm);
if (client) client.sendAudio(pcmBuffer);
});
playback = await WZPCore.connectPlayback(audioCtx);
} catch (e) {
WZPCore.updateStatus('Audio error: ' + e.message);
if (client) client.disconnect();
client = null;
ui.setConnected(false);
return;
}
ui.setConnected(true);
}
function doDisconnect() {
if (capture) { capture.stop(); capture = null; }
if (playback) { playback.stop(); playback = null; }
if (client) { client.disconnect(); client = null; }
var audioCtx = WZPCore.getAudioContext();
if (audioCtx && audioCtx.state !== 'closed') {
audioCtx.close();
}
WZPCore.updateStatus('');
WZPCore.updateStats('');
document.getElementById('levelBar').style.width = '0%';
ui.setConnected(false);
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,378 @@
// WarzonePhone — Shared UI logic for all client variants.
// Provides: audio context management, mic capture, playback, UI wiring.
'use strict';
const WZP_SAMPLE_RATE = 48000;
const WZP_FRAME_SIZE = 960; // 20ms @ 48kHz
// ---------------------------------------------------------------------------
// Variant detection
// ---------------------------------------------------------------------------
function wzpDetectVariant() {
const params = new URLSearchParams(location.search);
const v = (params.get('variant') || 'pure').toLowerCase();
if (v === 'hybrid' || v === 'full') return v;
return 'pure';
}
// ---------------------------------------------------------------------------
// Room helpers
// ---------------------------------------------------------------------------
function wzpGetRoom() {
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
if (path && path !== 'index.html') return path;
const hash = location.hash.replace('#', '');
if (hash) return hash;
const el = document.getElementById('room');
return (el && el.value.trim()) || 'default';
}
function wzpPrefillRoom() {
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
if (path && path !== 'index.html') {
const el = document.getElementById('room');
if (el) el.value = path;
}
}
// ---------------------------------------------------------------------------
// Status / stats helpers
// ---------------------------------------------------------------------------
function wzpUpdateStatus(msg) {
const el = document.getElementById('status');
if (el) el.textContent = msg;
}
function wzpUpdateStats(stats) {
const el = document.getElementById('stats');
if (!el) return;
if (typeof stats === 'string') {
el.textContent = stats;
} else {
const parts = [];
if (stats.elapsed != null) parts.push(stats.elapsed.toFixed(1) + 's');
if (stats.sent != null) parts.push('sent: ' + stats.sent);
if (stats.recv != null) parts.push('recv: ' + stats.recv);
if (stats.loss != null) parts.push('loss: ' + (stats.loss * 100).toFixed(1) + '%');
if (stats.fecRecovered != null && stats.fecRecovered > 0) parts.push('fec: ' + stats.fecRecovered);
if (stats.fecReady != null) parts.push(stats.fecReady ? 'FEC:on' : 'FEC:off');
el.textContent = parts.join(' | ');
}
}
function wzpUpdateLevel(pcmInt16) {
const bar = document.getElementById('levelBar');
if (!bar) return;
let max = 0;
for (let i = 0; i < pcmInt16.length; i += 16) {
const v = Math.abs(pcmInt16[i]);
if (v > max) max = v;
}
bar.style.width = (max / 32768 * 100) + '%';
}
// ---------------------------------------------------------------------------
// Audio context + worklet
// ---------------------------------------------------------------------------
let _wzpAudioCtx = null;
let _wzpWorkletLoaded = false;
async function wzpStartAudioContext() {
if (_wzpAudioCtx && _wzpAudioCtx.state !== 'closed') return _wzpAudioCtx;
_wzpAudioCtx = new AudioContext({ sampleRate: WZP_SAMPLE_RATE });
_wzpWorkletLoaded = false;
return _wzpAudioCtx;
}
function wzpGetAudioContext() {
return _wzpAudioCtx;
}
async function _wzpLoadWorklet(audioCtx) {
if (_wzpWorkletLoaded) return true;
if (typeof AudioWorkletNode === 'undefined' || !audioCtx.audioWorklet) {
console.warn('[wzp-core] AudioWorklet not supported, will use fallback');
return false;
}
try {
await audioCtx.audioWorklet.addModule('audio-processor.js');
_wzpWorkletLoaded = true;
return true;
} catch (e) {
console.warn('[wzp-core] AudioWorklet load failed:', e);
return false;
}
}
// ---------------------------------------------------------------------------
// Mic capture — returns { node, stop() }
// onFrame(ArrayBuffer) called for each 960-sample Int16 PCM frame
// ---------------------------------------------------------------------------
async function wzpConnectCapture(audioCtx, onFrame) {
let mediaStream;
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: WZP_SAMPLE_RATE,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
},
});
} catch (e) {
throw new Error('Mic access denied: ' + e.message);
}
const source = audioCtx.createMediaStreamSource(mediaStream);
const hasWorklet = await _wzpLoadWorklet(audioCtx);
let captureNode;
if (hasWorklet) {
captureNode = new AudioWorkletNode(audioCtx, 'wzp-capture-processor');
captureNode.port.onmessage = (e) => {
onFrame(e.data); // ArrayBuffer of Int16 PCM
};
source.connect(captureNode);
captureNode.connect(audioCtx.destination); // keep worklet alive
} else {
// ScriptProcessorNode fallback
captureNode = audioCtx.createScriptProcessor(4096, 1, 1);
let acc = new Float32Array(0);
captureNode.onaudioprocess = (ev) => {
const input = ev.inputBuffer.getChannelData(0);
const n = new Float32Array(acc.length + input.length);
n.set(acc);
n.set(input, acc.length);
acc = n;
while (acc.length >= WZP_FRAME_SIZE) {
const frame = acc.slice(0, WZP_FRAME_SIZE);
acc = acc.slice(WZP_FRAME_SIZE);
const pcm = new Int16Array(WZP_FRAME_SIZE);
for (let i = 0; i < WZP_FRAME_SIZE; i++) {
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
}
onFrame(pcm.buffer);
}
};
source.connect(captureNode);
captureNode.connect(audioCtx.destination);
}
return {
node: captureNode,
stop() {
captureNode.disconnect();
mediaStream.getTracks().forEach((t) => t.stop());
},
};
}
// ---------------------------------------------------------------------------
// Playback — returns { node, play(Int16Array), stop() }
// ---------------------------------------------------------------------------
async function wzpConnectPlayback(audioCtx) {
const hasWorklet = await _wzpLoadWorklet(audioCtx);
let playbackNode;
let nextPlayTime = 0;
if (hasWorklet) {
playbackNode = new AudioWorkletNode(audioCtx, 'wzp-playback-processor');
playbackNode.connect(audioCtx.destination);
return {
node: playbackNode,
play(pcmInt16) {
// Transfer Int16 buffer to worklet
const buf = pcmInt16.buffer.slice(
pcmInt16.byteOffset,
pcmInt16.byteOffset + pcmInt16.byteLength
);
playbackNode.port.postMessage(buf, [buf]);
},
stop() {
playbackNode.disconnect();
},
};
}
// Fallback: scheduled BufferSource
return {
node: null,
play(pcmInt16) {
if (!audioCtx || audioCtx.state === 'closed') return;
const floatData = new Float32Array(pcmInt16.length);
for (let i = 0; i < pcmInt16.length; i++) {
floatData[i] = pcmInt16[i] / 32768.0;
}
const buffer = audioCtx.createBuffer(1, floatData.length, WZP_SAMPLE_RATE);
buffer.getChannelData(0).set(floatData);
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
const now = audioCtx.currentTime;
if (nextPlayTime < now || nextPlayTime > now + 1.0) {
nextPlayTime = now + 0.02;
}
source.start(nextPlayTime);
nextPlayTime += buffer.duration;
},
stop() {
// nothing to disconnect for fallback
},
};
}
// ---------------------------------------------------------------------------
// UI wiring — call after DOM ready
// ---------------------------------------------------------------------------
function wzpInitUI(callbacks) {
// callbacks: { onConnect(room), onDisconnect() }
const btn = document.getElementById('callBtn');
const pttBtn = document.getElementById('pttBtn');
const pttCheckbox = document.getElementById('pttMode');
let connected = false;
let pttMode = false;
wzpPrefillRoom();
// Variant badge
const variant = wzpDetectVariant();
const badge = document.getElementById('variantBadge');
if (badge) badge.textContent = variant.toUpperCase();
// Variant selector radio buttons
document.querySelectorAll('input[name="variant"]').forEach((radio) => {
if (radio.value === variant) radio.checked = true;
radio.addEventListener('change', () => {
if (radio.checked) {
const params = new URLSearchParams(location.search);
params.set('variant', radio.value);
location.search = params.toString();
}
});
});
btn.onclick = () => {
if (connected) {
connected = false;
btn.textContent = 'Connect';
btn.classList.remove('active');
_showControls(false);
if (callbacks.onDisconnect) callbacks.onDisconnect();
} else {
const room = wzpGetRoom();
if (!room) {
wzpUpdateStatus('Enter a room name');
return;
}
connected = true;
btn.disabled = true;
if (callbacks.onConnect) callbacks.onConnect(room);
}
};
// PTT toggle
if (pttCheckbox) {
pttCheckbox.onchange = () => {
pttMode = pttCheckbox.checked;
if (pttMode) {
pttBtn.style.display = 'block';
if (callbacks.onTransmit) callbacks.onTransmit(false);
} else {
pttBtn.style.display = 'none';
if (callbacks.onTransmit) callbacks.onTransmit(true);
}
};
}
// PTT button events
function startTx() {
if (!pttMode || !connected) return;
pttBtn.classList.add('transmitting');
pttBtn.textContent = 'Transmitting...';
if (callbacks.onTransmit) callbacks.onTransmit(true);
}
function stopTx() {
if (!pttMode) return;
pttBtn.classList.remove('transmitting');
pttBtn.textContent = 'Hold to Talk';
if (callbacks.onTransmit) callbacks.onTransmit(false);
}
if (pttBtn) {
pttBtn.addEventListener('mousedown', startTx);
pttBtn.addEventListener('mouseup', stopTx);
pttBtn.addEventListener('mouseleave', stopTx);
pttBtn.addEventListener('touchstart', (e) => { e.preventDefault(); startTx(); });
pttBtn.addEventListener('touchend', (e) => { e.preventDefault(); stopTx(); });
}
// Spacebar PTT
document.addEventListener('keydown', (e) => {
if (pttMode && connected && e.code === 'Space' && !e.repeat) {
e.preventDefault();
startTx();
}
});
document.addEventListener('keyup', (e) => {
if (pttMode && connected && e.code === 'Space') {
e.preventDefault();
stopTx();
}
});
function _showControls(show) {
const controls = document.getElementById('controls');
if (controls) controls.style.display = show ? 'flex' : 'none';
if (!show && pttBtn) {
pttBtn.style.display = 'none';
pttMode = false;
if (pttCheckbox) pttCheckbox.checked = false;
}
}
return {
setConnected(isConnected) {
connected = isConnected;
btn.disabled = false;
if (isConnected) {
btn.textContent = 'Disconnect';
btn.classList.add('active');
_showControls(true);
} else {
btn.textContent = 'Connect';
btn.classList.remove('active');
_showControls(false);
}
},
isPTT() {
return pttMode;
},
};
}
// ---------------------------------------------------------------------------
// Exports (global)
// ---------------------------------------------------------------------------
window.WZPCore = {
SAMPLE_RATE: WZP_SAMPLE_RATE,
FRAME_SIZE: WZP_FRAME_SIZE,
detectVariant: wzpDetectVariant,
getRoom: wzpGetRoom,
updateStatus: wzpUpdateStatus,
updateStats: wzpUpdateStats,
updateLevel: wzpUpdateLevel,
startAudioContext: wzpStartAudioContext,
getAudioContext: wzpGetAudioContext,
connectCapture: wzpConnectCapture,
connectPlayback: wzpConnectPlayback,
initUI: wzpInitUI,
};

View File

@@ -0,0 +1,524 @@
// WarzonePhone — Full WASM + WebTransport client (Variant 3).
//
// Architecture:
// - WebTransport for unreliable datagrams (UDP-like, no head-of-line blocking)
// - ChaCha20-Poly1305 encryption via WASM (wzp-wasm WzpCryptoSession)
// - RaptorQ FEC via WASM (wzp-wasm WzpFecEncoder/WzpFecDecoder)
// - X25519 key exchange via WASM (wzp-wasm WzpKeyExchange)
//
// NOTE: WebTransport requires the relay to support HTTP/3 (h3-quinn).
// The current wzp-relay uses raw QUIC. This variant demonstrates the full
// architecture but will need relay-side HTTP/3 support to work end-to-end.
// For development / testing, use the hybrid variant (WebSocket + WASM FEC).
//
// Relies on wzp-core.js for UI and audio helpers.
'use strict';
const WZP_WASM_PATH = '/wasm/wzp_wasm.js';
// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
const MEDIA_HEADER_SIZE = 12;
// FEC wire header: block_id(1) + symbol_idx(1) + is_repair(1) = 3 bytes.
const FEC_HEADER_SIZE = 3;
class WZPFullClient {
/**
* @param {Object} options
* @param {string} options.url WebTransport URL (https://host:port)
* @param {string} options.room Room name
* @param {Function} options.onAudio callback(Int16Array) for playback
* @param {Function} options.onStatus callback(string) for UI status
* @param {Function} options.onStats callback(Object) for UI stats
*/
constructor(options) {
this.url = options.url;
this.room = options.room;
this.onAudio = options.onAudio || null;
this.onStatus = options.onStatus || null;
this.onStats = options.onStats || null;
this.wt = null; // WebTransport instance
this.datagramWriter = null; // WritableStreamDefaultWriter
this.datagramReader = null; // ReadableStreamDefaultReader
this.cryptoSession = null; // WzpCryptoSession (WASM)
this.fecEncoder = null; // WzpFecEncoder (WASM)
this.fecDecoder = null; // WzpFecDecoder (WASM)
this.sequence = 0;
this._wasmModule = null;
this._connected = false;
this._startTime = 0;
this._statsInterval = null;
this._recvLoopRunning = false;
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
}
/**
* Connect: load WASM, open WebTransport, perform key exchange,
* initialise FEC, and start the receive loop.
*/
async connect() {
if (this._connected) return;
// --- Guard: WebTransport support ---
if (typeof WebTransport === 'undefined') {
throw new Error(
'WebTransport is not supported in this browser. ' +
'Use the hybrid (?variant=hybrid) or pure (?variant=pure) variant instead.'
);
}
this._status('Loading WASM module...');
// 1. Load WASM
this._wasmModule = await import(WZP_WASM_PATH);
await this._wasmModule.default();
this._status('Connecting via WebTransport to ' + this.url + '...');
// 2. WebTransport connection
// The URL should include the room, e.g. https://host:port/room
const wtUrl = this.url + '/' + encodeURIComponent(this.room);
this.wt = new WebTransport(wtUrl);
this.wt.closed.then(() => {
const wasConnected = this._connected;
this._cleanup();
if (wasConnected) {
this._status('WebTransport closed');
}
}).catch((err) => {
this._cleanup();
this._status('WebTransport error: ' + err.message);
});
await this.wt.ready;
// 3. Get datagram streams (unreliable, QUIC DATAGRAM frames)
this.datagramWriter = this.wt.datagrams.writable.getWriter();
this.datagramReader = this.wt.datagrams.readable.getReader();
// 4. Key exchange over a bidirectional stream
this._status('Performing key exchange...');
await this._performKeyExchange();
// 5. Initialise FEC (5 source symbols per block, 256-byte symbols)
this.fecEncoder = new this._wasmModule.WzpFecEncoder(5, 256);
this.fecDecoder = new this._wasmModule.WzpFecDecoder(5, 256);
this._connected = true;
this.sequence = 0;
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
this._startTime = Date.now();
this._startStatsTimer();
// 6. Start receive loop (runs until disconnect)
this._recvLoop();
this._status('Connected to room: ' + this.room + ' (encrypted, FEC active)');
}
/**
* Disconnect and clean up all resources.
*/
disconnect() {
this._connected = false;
if (this.wt) {
try { this.wt.close(); } catch (_) { /* ignore */ }
this.wt = null;
}
this._cleanup();
}
/**
* Send a PCM audio frame.
*
* Pipeline: PCM -> FEC encode -> encrypt -> datagram send.
*
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
*/
async sendAudio(pcmBuffer) {
if (!this._connected || !this.datagramWriter || !this.cryptoSession) return;
const pcmBytes = new Uint8Array(pcmBuffer);
// Build a minimal 12-byte MediaHeader for AAD.
const header = this._buildMediaHeader(this.sequence);
// FEC encode: feed the frame; when a block completes we get wire packets.
const fecOutput = this.fecEncoder.add_symbol(pcmBytes);
if (fecOutput) {
// FEC block completed — send all packets (source + repair).
const packetSize = FEC_HEADER_SIZE + 256; // header + symbol_size
for (let offset = 0; offset + packetSize <= fecOutput.length; offset += packetSize) {
const fecPacket = fecOutput.slice(offset, offset + packetSize);
// Encrypt: header bytes as AAD, FEC packet as plaintext.
const ciphertext = this.cryptoSession.encrypt(header, fecPacket);
this.stats.encrypted++;
// Build wire datagram: header (12) + ciphertext
const datagram = new Uint8Array(MEDIA_HEADER_SIZE + ciphertext.length);
datagram.set(header, 0);
datagram.set(ciphertext, MEDIA_HEADER_SIZE);
try {
await this.datagramWriter.write(datagram);
} catch (e) {
// Datagram send can fail if the transport is closing.
if (this._connected) {
console.warn('[wzp-full] datagram write failed:', e);
}
return;
}
this.stats.sent++;
}
}
// If FEC block not yet complete, accumulate (no packets sent yet).
this.sequence = (this.sequence + 1) & 0xFFFF;
}
/**
* Test crypto + FEC roundtrip entirely in WASM (no network).
* Useful for verifying the WASM module works correctly in the browser.
*
* @returns {Object} test results
*/
testCryptoFec() {
if (!this._wasmModule) {
return { success: false, error: 'WASM module not loaded' };
}
const t0 = performance.now();
const wasm = this._wasmModule;
// Key exchange
const alice = new wasm.WzpKeyExchange();
const bob = new wasm.WzpKeyExchange();
const aliceSecret = alice.derive_shared_secret(bob.public_key());
const bobSecret = bob.derive_shared_secret(alice.public_key());
// Verify secrets match
let secretsMatch = aliceSecret.length === bobSecret.length;
if (secretsMatch) {
for (let i = 0; i < aliceSecret.length; i++) {
if (aliceSecret[i] !== bobSecret[i]) { secretsMatch = false; break; }
}
}
// Encrypt/decrypt
const aliceSession = new wasm.WzpCryptoSession(aliceSecret);
const bobSession = new wasm.WzpCryptoSession(bobSecret);
const header = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]);
const plaintext = new TextEncoder().encode('hello warzone from full variant');
const ciphertext = aliceSession.encrypt(header, plaintext);
const decrypted = bobSession.decrypt(header, ciphertext);
let cryptoOk = decrypted.length === plaintext.length;
if (cryptoOk) {
for (let i = 0; i < plaintext.length; i++) {
if (decrypted[i] !== plaintext[i]) { cryptoOk = false; break; }
}
}
// FEC test (same as hybrid testFec)
const encoder = new wasm.WzpFecEncoder(5, 256);
const decoder = new wasm.WzpFecDecoder(5, 256);
const frames = [];
for (let i = 0; i < 5; i++) {
const frame = new Uint8Array(100);
for (let j = 0; j < 100; j++) frame[j] = ((i * 37 + 7) + j) & 0xFF;
frames.push(frame);
}
let wireData = null;
for (const frame of frames) {
const result = encoder.add_symbol(frame);
if (result) wireData = result;
}
const PACKET_SIZE = FEC_HEADER_SIZE + 256;
const packets = [];
if (wireData) {
for (let off = 0; off + PACKET_SIZE <= wireData.length; off += PACKET_SIZE) {
packets.push({
blockId: wireData[off],
symbolIdx: wireData[off + 1],
isRepair: wireData[off + 2] !== 0,
data: wireData.slice(off + FEC_HEADER_SIZE, off + PACKET_SIZE),
});
}
}
// Drop 2 packets, try to recover
let fecDecoded = null;
for (let i = 0; i < packets.length; i++) {
if (i === 1 || i === 3) continue; // simulate loss
const pkt = packets[i];
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, pkt.data);
if (result) { fecDecoded = result; break; }
}
let fecOk = false;
if (fecDecoded) {
const expected = new Uint8Array(5 * 100);
let off = 0;
for (const f of frames) { expected.set(f, off); off += f.length; }
fecOk = fecDecoded.length === expected.length;
if (fecOk) {
for (let i = 0; i < expected.length; i++) {
if (fecDecoded[i] !== expected[i]) { fecOk = false; break; }
}
}
}
// Cleanup WASM objects
alice.free();
bob.free();
aliceSession.free();
bobSession.free();
encoder.free();
decoder.free();
const elapsed = performance.now() - t0;
return {
success: secretsMatch && cryptoOk && fecOk,
secretsMatch,
cryptoOk,
fecOk,
fecPacketsTotal: packets.length,
fecDropped: 2,
elapsed: elapsed.toFixed(2) + 'ms',
};
}
// =========================================================================
// Internal
// =========================================================================
/**
* Perform X25519 key exchange over a WebTransport bidirectional stream.
*
* Protocol (simplified DH, not the full SignalMessage handshake):
* 1. Open a bidirectional stream.
* 2. Send our 32-byte X25519 public key.
* 3. Read the peer's 32-byte public key.
* 4. Derive shared secret via HKDF.
* 5. Create WzpCryptoSession from the shared secret.
*
* In production this would use the full SignalMessage protocol over the
* bidirectional stream (offer/answer/encrypted-session). For now we do
* a simple DH swap to prove the architecture.
*/
async _performKeyExchange() {
const wasm = this._wasmModule;
const kx = new wasm.WzpKeyExchange();
const ourPub = kx.public_key(); // Uint8Array(32)
// Open a bidirectional stream for signaling.
const stream = await this.wt.createBidirectionalStream();
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
// Send our public key.
await writer.write(new Uint8Array(ourPub));
// Read peer's public key (exactly 32 bytes).
// WebTransport streams are byte-oriented; we may get it in chunks.
let peerPub = new Uint8Array(0);
while (peerPub.length < 32) {
const { value, done } = await reader.read();
if (done) {
throw new Error('Key exchange stream closed before receiving peer public key');
}
const combined = new Uint8Array(peerPub.length + value.length);
combined.set(peerPub, 0);
combined.set(value, peerPub.length);
peerPub = combined;
}
peerPub = peerPub.slice(0, 32);
// Derive shared secret and create crypto session.
const secret = kx.derive_shared_secret(peerPub);
this.cryptoSession = new wasm.WzpCryptoSession(secret);
// Close the signaling stream (key exchange complete).
try {
writer.releaseLock();
reader.releaseLock();
await stream.writable.close();
} catch (_) {
// Best-effort close.
}
kx.free();
}
/**
* Receive loop: read datagrams, decrypt, FEC decode, play audio.
*
* Runs until the transport closes or disconnect() is called.
*/
async _recvLoop() {
if (this._recvLoopRunning) return;
this._recvLoopRunning = true;
try {
while (this._connected && this.datagramReader) {
const { value, done } = await this.datagramReader.read();
if (done) break;
this.stats.recv++;
// value is a Uint8Array datagram: header(12) + ciphertext
if (value.length <= MEDIA_HEADER_SIZE) continue; // too short
const headerAad = value.slice(0, MEDIA_HEADER_SIZE);
const ciphertext = value.slice(MEDIA_HEADER_SIZE);
// Decrypt
let fecPacket;
try {
fecPacket = this.cryptoSession.decrypt(headerAad, ciphertext);
this.stats.decrypted++;
} catch (e) {
// Decryption failure — corrupted or out-of-order packet.
// In a real implementation we'd handle sequence number gaps.
console.warn('[wzp-full] decrypt failed:', e);
continue;
}
// FEC decode: parse the FEC wire header and feed to decoder.
if (fecPacket.length < FEC_HEADER_SIZE) continue;
const blockId = fecPacket[0];
const symbolIdx = fecPacket[1];
const isRepair = fecPacket[2] !== 0;
const symbolData = fecPacket.slice(FEC_HEADER_SIZE);
const decoded = this.fecDecoder.add_symbol(blockId, symbolIdx, isRepair, symbolData);
if (decoded) {
this.stats.fecRecovered++;
// decoded is concatenated original PCM frames.
// Each frame is 1920 bytes (960 Int16 samples @ 48kHz mono).
const FRAME_BYTES = 1920;
for (let off = 0; off + FRAME_BYTES <= decoded.length; off += FRAME_BYTES) {
const pcmSlice = decoded.slice(off, off + FRAME_BYTES);
const pcm = new Int16Array(pcmSlice.buffer, pcmSlice.byteOffset, pcmSlice.byteLength / 2);
if (this.onAudio) {
this.onAudio(pcm);
}
}
}
}
} catch (e) {
if (this._connected) {
console.warn('[wzp-full] recv loop error:', e);
}
} finally {
this._recvLoopRunning = false;
}
}
/**
* Build a minimal 12-byte MediaHeader for use as AAD.
*
* Wire layout (from wzp-proto::packet::MediaHeader):
* Byte 0: V(1)|T(1)|CodecID(4)|Q(1)|FecRatioHi(1)
* Byte 1: FecRatioLo(6)|unused(2)
* Bytes 2-3: Sequence number (BE u16)
* Bytes 4-7: Timestamp ms (BE u32)
* Byte 8: FEC block ID
* Byte 9: FEC symbol index
* Byte 10: Reserved
* Byte 11: CSRC count
*
* @param {number} seq Sequence number (u16)
* @returns {Uint8Array} 12-byte header
*/
_buildMediaHeader(seq) {
const buf = new Uint8Array(MEDIA_HEADER_SIZE);
// Byte 0: version=0, is_repair=0, codec=0 (Opus), quality_report=0, fec_ratio_hi=0
buf[0] = 0x00;
// Byte 1: fec_ratio_lo=0
buf[1] = 0x00;
// Bytes 2-3: sequence (BE u16)
buf[2] = (seq >> 8) & 0xFF;
buf[3] = seq & 0xFF;
// Bytes 4-7: timestamp (BE u32) — ms since session start
const ts = Date.now() - this._startTime;
buf[4] = (ts >> 24) & 0xFF;
buf[5] = (ts >> 16) & 0xFF;
buf[6] = (ts >> 8) & 0xFF;
buf[7] = ts & 0xFF;
// Bytes 8-11: FEC block/symbol/reserved/csrc — filled by FEC layer in production
return buf;
}
_startStatsTimer() {
this._stopStatsTimer();
this._statsInterval = setInterval(() => {
if (!this._connected) {
this._stopStatsTimer();
return;
}
const elapsed = (Date.now() - this._startTime) / 1000;
const loss = this.stats.sent > 0
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
: 0;
if (this.onStats) {
this.onStats({
sent: this.stats.sent,
recv: this.stats.recv,
loss,
elapsed,
encrypted: this.stats.encrypted,
decrypted: this.stats.decrypted,
fecRecovered: this.stats.fecRecovered,
});
}
}, 1000);
}
_stopStatsTimer() {
if (this._statsInterval) {
clearInterval(this._statsInterval);
this._statsInterval = null;
}
}
_status(msg) {
if (this.onStatus) this.onStatus(msg);
}
_cleanup() {
this._connected = false;
this._stopStatsTimer();
this.datagramWriter = null;
this.datagramReader = null;
if (this.cryptoSession) {
try { this.cryptoSession.free(); } catch (_) { /* ignore */ }
this.cryptoSession = null;
}
if (this.fecEncoder) {
try { this.fecEncoder.free(); } catch (_) { /* ignore */ }
this.fecEncoder = null;
}
if (this.fecDecoder) {
try { this.fecDecoder.free(); } catch (_) { /* ignore */ }
this.fecDecoder = null;
}
}
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
window.WZPFullClient = WZPFullClient;

View File

@@ -0,0 +1,345 @@
// WarzonePhone — Hybrid JS + WASM client (Variant 2).
// WebSocket transport, raw PCM, WASM FEC (RaptorQ) ready for WebTransport.
// Relies on wzp-core.js for UI and audio helpers.
//
// The WASM FEC module is loaded and exposed but not used on the wire yet,
// because WebSocket is TCP (no packet loss). FEC will activate when
// WebTransport (UDP) is added. A testFec() method demonstrates FEC
// encode -> simulate loss -> decode in the browser.
'use strict';
// WASM module path (served from /wasm/ by the wzp-web bridge).
const WZP_WASM_PATH = '/wasm/wzp_wasm.js';
class WZPHybridClient {
/**
* @param {Object} options
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
* @param {string} options.room Room name
* @param {Function} options.onAudio callback(Int16Array) for playback
* @param {Function} options.onStatus callback(string) for UI status
* @param {Function} options.onStats callback({sent, recv, loss, elapsed, fecRecovered}) for UI
*/
constructor(options) {
this.wsUrl = options.wsUrl;
this.room = options.room;
this.onAudio = options.onAudio || null;
this.onStatus = options.onStatus || null;
this.onStats = options.onStats || null;
this.ws = null;
this.sequence = 0;
this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
this._startTime = 0;
this._statsInterval = null;
this._connected = false;
// WASM FEC instances (loaded in connect()).
this._wasmModule = null;
this.fecEncoder = null;
this.fecDecoder = null;
this._fecReady = false;
}
/**
* Open WebSocket connection and load the WASM FEC module.
* @returns {Promise<void>} resolves when connected
*/
async connect() {
if (this._connected) return;
// Load WASM module in parallel with WebSocket connect.
const wasmPromise = this._loadWasm();
const wsPromise = new Promise((resolve, reject) => {
this._status('Connecting to room: ' + this.room + '...');
this.ws = new WebSocket(this.wsUrl);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
this._connected = true;
this.sequence = 0;
this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
this._startTime = Date.now();
this._startStatsTimer();
resolve();
};
this.ws.onmessage = (event) => {
this._handleMessage(event);
};
this.ws.onclose = () => {
const wasConnected = this._connected;
this._cleanup();
if (wasConnected) {
this._status('Disconnected');
}
};
this.ws.onerror = () => {
if (!this._connected) {
this._cleanup();
reject(new Error('WebSocket connection failed'));
} else {
this._status('Connection error');
}
};
});
// Wait for both WASM load and WS connect.
await Promise.all([wasmPromise, wsPromise]);
const fecStatus = this._fecReady ? 'FEC ready' : 'FEC unavailable';
this._status('Connected to room: ' + this.room + ' (' + fecStatus + ')');
}
/**
* Close WebSocket and clean up.
*/
disconnect() {
this._connected = false;
if (this.ws) {
this.ws.close();
this.ws = null;
}
this._stopStatsTimer();
// Keep WASM module loaded (reusable).
this.fecEncoder = null;
this.fecDecoder = null;
}
/**
* Send a PCM audio frame over the WebSocket.
* Currently sends raw PCM (same as pure client) since WebSocket is TCP.
* When WebTransport is added, this will FEC-encode before sending.
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
*/
async sendAudio(pcmBuffer) {
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
return;
}
// Over WebSocket (TCP): send raw PCM, no FEC needed.
// Over WebTransport (UDP, future): would call this.fecEncoder.add_symbol()
// and send the resulting FEC-protected packets.
this.ws.send(pcmBuffer);
this.sequence++;
this.stats.sent++;
}
/**
* Test FEC encode -> simulate loss -> decode in the browser.
* Demonstrates that the WASM RaptorQ module works correctly.
*
* @param {Object} [opts]
* @param {number} [opts.blockSize=5] Source symbols per block
* @param {number} [opts.symbolSize=256] Padded symbol size
* @param {number} [opts.frameSize=100] Bytes per test frame
* @param {number} [opts.dropCount=2] Number of packets to drop
* @returns {Object} { success, sourcePackets, repairPackets, dropped, recovered, elapsed }
*/
testFec(opts) {
if (!this._fecReady) {
return { success: false, error: 'WASM FEC module not loaded' };
}
const blockSize = (opts && opts.blockSize) || 5;
const symbolSize = (opts && opts.symbolSize) || 256;
const frameSize = (opts && opts.frameSize) || 100;
const dropCount = (opts && opts.dropCount) || 2;
const HEADER_SIZE = 3; // block_id + symbol_idx + is_repair
const packetSize = HEADER_SIZE + symbolSize;
const t0 = performance.now();
// Create fresh encoder/decoder for the test.
const encoder = new this._wasmModule.WzpFecEncoder(blockSize, symbolSize);
const decoder = new this._wasmModule.WzpFecDecoder(blockSize, symbolSize);
// Generate test frames with known data.
const frames = [];
for (let i = 0; i < blockSize; i++) {
const frame = new Uint8Array(frameSize);
for (let j = 0; j < frameSize; j++) {
frame[j] = ((i * 37 + 7) + j) & 0xFF;
}
frames.push(frame);
}
// Encode: feed frames to encoder; last one triggers block output.
let wireData = null;
for (const frame of frames) {
const result = encoder.add_symbol(frame);
if (result) {
wireData = result;
}
}
if (!wireData) {
// Flush if block didn't complete (shouldn't happen with exact blockSize).
wireData = encoder.flush();
}
// Parse wire packets.
const packets = [];
for (let offset = 0; offset + packetSize <= wireData.length; offset += packetSize) {
packets.push({
blockId: wireData[offset],
symbolIdx: wireData[offset + 1],
isRepair: wireData[offset + 2] !== 0,
data: wireData.slice(offset + HEADER_SIZE, offset + packetSize),
});
}
const sourcePackets = packets.filter(p => !p.isRepair).length;
const repairPackets = packets.filter(p => p.isRepair).length;
// Simulate packet loss: drop `dropCount` packets from the front (source symbols).
const dropped = [];
const surviving = [];
for (let i = 0; i < packets.length; i++) {
if (i < dropCount) {
dropped.push(i);
} else {
surviving.push(packets[i]);
}
}
// Decode from surviving packets.
let decoded = null;
for (const pkt of surviving) {
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, pkt.data);
if (result) {
decoded = result;
break;
}
}
const elapsed = performance.now() - t0;
// Verify decoded data matches original frames.
let success = false;
if (decoded) {
const expected = new Uint8Array(blockSize * frameSize);
let off = 0;
for (const frame of frames) {
expected.set(frame, off);
off += frame.length;
}
success = decoded.length === expected.length;
if (success) {
for (let i = 0; i < decoded.length; i++) {
if (decoded[i] !== expected[i]) {
success = false;
break;
}
}
}
}
// Free WASM objects.
encoder.free();
decoder.free();
return {
success,
sourcePackets,
repairPackets,
totalPackets: packets.length,
dropped: dropCount,
recovered: success,
decodedBytes: decoded ? decoded.length : 0,
expectedBytes: blockSize * frameSize,
elapsed: elapsed.toFixed(2) + 'ms',
};
}
// -----------------------------------------------------------------------
// Internal
// -----------------------------------------------------------------------
async _loadWasm() {
try {
// Dynamic import of the wasm-pack generated JS glue.
this._wasmModule = await import(WZP_WASM_PATH);
// Initialize the WASM module (calls __wbg_init).
await this._wasmModule.default();
// Create FEC encoder/decoder instances.
// 5 symbols per block, 256-byte symbols — matches native wzp-fec defaults.
this.fecEncoder = new this._wasmModule.WzpFecEncoder(5, 256);
this.fecDecoder = new this._wasmModule.WzpFecDecoder(5, 256);
this._fecReady = true;
console.log('[wzp-hybrid] WASM FEC module loaded successfully');
} catch (e) {
console.warn('[wzp-hybrid] WASM FEC module failed to load:', e);
this._fecReady = false;
// Non-fatal: client still works without FEC (like pure variant).
}
}
_handleMessage(event) {
if (!(event.data instanceof ArrayBuffer)) return;
const pcm = new Int16Array(event.data);
this.stats.recv++;
if (this.onAudio) {
this.onAudio(pcm);
}
}
_startStatsTimer() {
this._stopStatsTimer();
this._statsInterval = setInterval(() => {
if (!this._connected) {
this._stopStatsTimer();
return;
}
const elapsed = (Date.now() - this._startTime) / 1000;
const loss = this.stats.sent > 0
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
: 0;
if (this.onStats) {
this.onStats({
sent: this.stats.sent,
recv: this.stats.recv,
loss: loss,
elapsed: elapsed,
fecRecovered: this.stats.fecRecovered,
fecReady: this._fecReady,
});
}
}, 1000);
}
_stopStatsTimer() {
if (this._statsInterval) {
clearInterval(this._statsInterval);
this._statsInterval = null;
}
}
_status(msg) {
if (this.onStatus) this.onStatus(msg);
}
_cleanup() {
this._connected = false;
this._stopStatsTimer();
if (this.ws) {
try { this.ws.close(); } catch (_) { /* ignore */ }
this.ws = null;
}
}
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
window.WZPHybridClient = WZPHybridClient;

View File

@@ -0,0 +1,168 @@
// WarzonePhone — Pure JS client (Variant 1).
// WebSocket transport, raw PCM, no WASM, no FEC.
// Relies on wzp-core.js for UI and audio helpers.
'use strict';
class WZPPureClient {
/**
* @param {Object} options
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
* @param {string} options.room Room name
* @param {Function} options.onAudio callback(Int16Array) for playback
* @param {Function} options.onStatus callback(string) for UI status
* @param {Function} options.onStats callback({sent, recv, loss, elapsed}) for UI
*/
constructor(options) {
this.wsUrl = options.wsUrl;
this.room = options.room;
this.onAudio = options.onAudio || null;
this.onStatus = options.onStatus || null;
this.onStats = options.onStats || null;
this.ws = null;
this.sequence = 0;
this.stats = { sent: 0, recv: 0 };
this._startTime = 0;
this._statsInterval = null;
this._connected = false;
}
/**
* Open WebSocket connection to the wzp-web bridge.
* @returns {Promise<void>} resolves when connected
*/
async connect() {
if (this._connected) return;
return new Promise((resolve, reject) => {
this._status('Connecting to room: ' + this.room + '...');
this.ws = new WebSocket(this.wsUrl);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
this._connected = true;
this.sequence = 0;
this.stats = { sent: 0, recv: 0 };
this._startTime = Date.now();
this._status('Connected to room: ' + this.room);
this._startStatsTimer();
resolve();
};
this.ws.onmessage = (event) => {
this._handleMessage(event);
};
this.ws.onclose = () => {
const wasConnected = this._connected;
this._cleanup();
if (wasConnected) {
this._status('Disconnected');
}
};
this.ws.onerror = (err) => {
if (!this._connected) {
this._cleanup();
reject(new Error('WebSocket connection failed'));
} else {
this._status('Connection error');
}
};
});
}
/**
* Close WebSocket and clean up.
*/
disconnect() {
this._connected = false;
if (this.ws) {
this.ws.close();
this.ws = null;
}
this._stopStatsTimer();
}
/**
* Send a PCM audio frame over the WebSocket.
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
*/
async sendAudio(pcmBuffer) {
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
return;
}
// Pure JS variant: send raw PCM directly (no encryption, no header).
// The wzp-web bridge handles QUIC-side encryption.
this.ws.send(pcmBuffer);
this.sequence++;
this.stats.sent++;
}
// -----------------------------------------------------------------------
// Internal
// -----------------------------------------------------------------------
_handleMessage(event) {
if (!(event.data instanceof ArrayBuffer)) return;
const pcm = new Int16Array(event.data);
this.stats.recv++;
if (this.onAudio) {
this.onAudio(pcm);
}
}
_startStatsTimer() {
this._stopStatsTimer();
this._statsInterval = setInterval(() => {
if (!this._connected) {
this._stopStatsTimer();
return;
}
const elapsed = (Date.now() - this._startTime) / 1000;
// Simple loss estimate: if we sent frames, the other side should
// receive roughly the same count. Since we only see our own recv,
// we report raw counts and let the UI decide.
const loss = this.stats.sent > 0
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
: 0;
if (this.onStats) {
this.onStats({
sent: this.stats.sent,
recv: this.stats.recv,
loss: loss,
elapsed: elapsed,
});
}
}, 1000);
}
_stopStatsTimer() {
if (this._statsInterval) {
clearInterval(this._statsInterval);
this._statsInterval = null;
}
}
_status(msg) {
if (this.onStatus) this.onStatus(msg);
}
_cleanup() {
this._connected = false;
this._stopStatsTimer();
if (this.ws) {
try { this.ws.close(); } catch (_) { /* ignore */ }
this.ws = null;
}
}
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
window.WZPPureClient = WZPPureClient;

2
crates/wzp-web/static/wasm/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
package.json
*.d.ts

View File

@@ -0,0 +1,556 @@
/* @ts-self-types="./wzp_wasm.d.ts" */
/**
* Symmetric encryption session using ChaCha20-Poly1305.
*
* Mirrors `wzp-crypto::session::ChaChaSession` for WASM. Nonce derivation
* and key setup are identical so WASM and native peers interoperate.
*/
export class WzpCryptoSession {
__destroy_into_raw() {
const ptr = this.__wbg_ptr;
this.__wbg_ptr = 0;
WzpCryptoSessionFinalization.unregister(this);
return ptr;
}
free() {
const ptr = this.__destroy_into_raw();
wasm.__wbg_wzpcryptosession_free(ptr, 0);
}
/**
* Decrypt a media payload with AAD.
*
* Returns plaintext on success, or throws on auth failure.
* @param {Uint8Array} header_aad
* @param {Uint8Array} ciphertext
* @returns {Uint8Array}
*/
decrypt(header_aad, ciphertext) {
const ptr0 = passArray8ToWasm0(header_aad, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passArray8ToWasm0(ciphertext, wasm.__wbindgen_malloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.wzpcryptosession_decrypt(this.__wbg_ptr, ptr0, len0, ptr1, len1);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
var v3 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v3;
}
/**
* Encrypt a media payload with AAD (typically the 12-byte MediaHeader).
*
* Returns `ciphertext || poly1305_tag` (plaintext.len() + 16 bytes).
* @param {Uint8Array} header_aad
* @param {Uint8Array} plaintext
* @returns {Uint8Array}
*/
encrypt(header_aad, plaintext) {
const ptr0 = passArray8ToWasm0(header_aad, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passArray8ToWasm0(plaintext, wasm.__wbindgen_malloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.wzpcryptosession_encrypt(this.__wbg_ptr, ptr0, len0, ptr1, len1);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
var v3 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v3;
}
/**
* Create from a 32-byte shared secret (output of `WzpKeyExchange.derive_shared_secret`).
* @param {Uint8Array} shared_secret
*/
constructor(shared_secret) {
const ptr0 = passArray8ToWasm0(shared_secret, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.wzpcryptosession_new(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
this.__wbg_ptr = ret[0] >>> 0;
WzpCryptoSessionFinalization.register(this, this.__wbg_ptr, this);
return this;
}
/**
* Current receive sequence number (for diagnostics / UI stats).
* @returns {number}
*/
recv_seq() {
const ret = wasm.wzpcryptosession_recv_seq(this.__wbg_ptr);
return ret >>> 0;
}
/**
* Current send sequence number (for diagnostics / UI stats).
* @returns {number}
*/
send_seq() {
const ret = wasm.wzpcryptosession_send_seq(this.__wbg_ptr);
return ret >>> 0;
}
}
if (Symbol.dispose) WzpCryptoSession.prototype[Symbol.dispose] = WzpCryptoSession.prototype.free;
export class WzpFecDecoder {
__destroy_into_raw() {
const ptr = this.__wbg_ptr;
this.__wbg_ptr = 0;
WzpFecDecoderFinalization.unregister(this);
return ptr;
}
free() {
const ptr = this.__destroy_into_raw();
wasm.__wbg_wzpfecdecoder_free(ptr, 0);
}
/**
* Feed a received symbol.
*
* Returns the decoded block (concatenated original frames, unpadded) if
* enough symbols have been received to recover the block, or `undefined`.
* @param {number} block_id
* @param {number} symbol_idx
* @param {boolean} _is_repair
* @param {Uint8Array} data
* @returns {Uint8Array | undefined}
*/
add_symbol(block_id, symbol_idx, _is_repair, data) {
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.wzpfecdecoder_add_symbol(this.__wbg_ptr, block_id, symbol_idx, _is_repair, ptr0, len0);
let v2;
if (ret[0] !== 0) {
v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
}
return v2;
}
/**
* Create a new FEC decoder.
*
* * `block_size` — expected number of source symbols per block.
* * `symbol_size` — padded byte size of each symbol (must match encoder).
* @param {number} block_size
* @param {number} symbol_size
*/
constructor(block_size, symbol_size) {
const ret = wasm.wzpfecdecoder_new(block_size, symbol_size);
this.__wbg_ptr = ret >>> 0;
WzpFecDecoderFinalization.register(this, this.__wbg_ptr, this);
return this;
}
}
if (Symbol.dispose) WzpFecDecoder.prototype[Symbol.dispose] = WzpFecDecoder.prototype.free;
export class WzpFecEncoder {
__destroy_into_raw() {
const ptr = this.__wbg_ptr;
this.__wbg_ptr = 0;
WzpFecEncoderFinalization.unregister(this);
return ptr;
}
free() {
const ptr = this.__destroy_into_raw();
wasm.__wbg_wzpfecencoder_free(ptr, 0);
}
/**
* Add a source symbol (audio frame).
*
* Returns encoded packets (all source + repair) when the block is complete,
* or `undefined` if the block is still accumulating.
*
* Each returned packet carries the 3-byte header:
* `[block_id][symbol_idx][is_repair]` followed by `symbol_size` bytes.
* @param {Uint8Array} data
* @returns {Uint8Array | undefined}
*/
add_symbol(data) {
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.wzpfecencoder_add_symbol(this.__wbg_ptr, ptr0, len0);
let v2;
if (ret[0] !== 0) {
v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
}
return v2;
}
/**
* Force-flush the current (possibly partial) block.
*
* Returns all source + repair symbols with headers, or empty vec if no
* symbols have been accumulated.
* @returns {Uint8Array}
*/
flush() {
const ret = wasm.wzpfecencoder_flush(this.__wbg_ptr);
var v1 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v1;
}
/**
* Create a new FEC encoder.
*
* * `block_size` — number of source symbols (audio frames) per FEC block.
* * `symbol_size` — padded byte size of each symbol (default 256).
* @param {number} block_size
* @param {number} symbol_size
*/
constructor(block_size, symbol_size) {
const ret = wasm.wzpfecencoder_new(block_size, symbol_size);
this.__wbg_ptr = ret >>> 0;
WzpFecEncoderFinalization.register(this, this.__wbg_ptr, this);
return this;
}
}
if (Symbol.dispose) WzpFecEncoder.prototype[Symbol.dispose] = WzpFecEncoder.prototype.free;
/**
* X25519 key exchange: generate ephemeral keypair and derive shared secret.
*
* Usage from JS:
* ```js
* const kx = new WzpKeyExchange();
* const ourPub = kx.public_key(); // Uint8Array(32)
* // ... send ourPub to peer, receive peerPub ...
* const secret = kx.derive_shared_secret(peerPub); // Uint8Array(32)
* const session = new WzpCryptoSession(secret);
* ```
*/
export class WzpKeyExchange {
__destroy_into_raw() {
const ptr = this.__wbg_ptr;
this.__wbg_ptr = 0;
WzpKeyExchangeFinalization.unregister(this);
return ptr;
}
free() {
const ptr = this.__destroy_into_raw();
wasm.__wbg_wzpkeyexchange_free(ptr, 0);
}
/**
* Derive a 32-byte session key from the peer's public key.
*
* Raw DH output is expanded via HKDF-SHA256 with info="warzone-session-key",
* matching `wzp-crypto::handshake::WarzoneKeyExchange::derive_session`.
* @param {Uint8Array} peer_public
* @returns {Uint8Array}
*/
derive_shared_secret(peer_public) {
const ptr0 = passArray8ToWasm0(peer_public, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.wzpkeyexchange_derive_shared_secret(this.__wbg_ptr, ptr0, len0);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v2;
}
/**
* Generate a new random X25519 keypair.
*/
constructor() {
const ret = wasm.wzpkeyexchange_new();
this.__wbg_ptr = ret >>> 0;
WzpKeyExchangeFinalization.register(this, this.__wbg_ptr, this);
return this;
}
/**
* Our public key (32 bytes).
* @returns {Uint8Array}
*/
public_key() {
const ret = wasm.wzpkeyexchange_public_key(this.__wbg_ptr);
var v1 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v1;
}
}
if (Symbol.dispose) WzpKeyExchange.prototype[Symbol.dispose] = WzpKeyExchange.prototype.free;
function __wbg_get_imports() {
const import0 = {
__proto__: null,
__wbg___wbindgen_is_function_3c846841762788c1: function(arg0) {
const ret = typeof(arg0) === 'function';
return ret;
},
__wbg___wbindgen_is_object_781bc9f159099513: function(arg0) {
const val = arg0;
const ret = typeof(val) === 'object' && val !== null;
return ret;
},
__wbg___wbindgen_is_string_7ef6b97b02428fae: function(arg0) {
const ret = typeof(arg0) === 'string';
return ret;
},
__wbg___wbindgen_is_undefined_52709e72fb9f179c: function(arg0) {
const ret = arg0 === undefined;
return ret;
},
__wbg___wbindgen_throw_6ddd609b62940d55: function(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
},
__wbg_call_2d781c1f4d5c0ef8: function() { return handleError(function (arg0, arg1, arg2) {
const ret = arg0.call(arg1, arg2);
return ret;
}, arguments); },
__wbg_crypto_38df2bab126b63dc: function(arg0) {
const ret = arg0.crypto;
return ret;
},
__wbg_getRandomValues_c44a50d8cfdaebeb: function() { return handleError(function (arg0, arg1) {
arg0.getRandomValues(arg1);
}, arguments); },
__wbg_length_ea16607d7b61445b: function(arg0) {
const ret = arg0.length;
return ret;
},
__wbg_msCrypto_bd5a034af96bcba6: function(arg0) {
const ret = arg0.msCrypto;
return ret;
},
__wbg_new_with_length_825018a1616e9e55: function(arg0) {
const ret = new Uint8Array(arg0 >>> 0);
return ret;
},
__wbg_node_84ea875411254db1: function(arg0) {
const ret = arg0.node;
return ret;
},
__wbg_process_44c7a14e11e9f69e: function(arg0) {
const ret = arg0.process;
return ret;
},
__wbg_prototypesetcall_d62e5099504357e6: function(arg0, arg1, arg2) {
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
},
__wbg_randomFillSync_6c25eac9869eb53c: function() { return handleError(function (arg0, arg1) {
arg0.randomFillSync(arg1);
}, arguments); },
__wbg_require_b4edbdcf3e2a1ef0: function() { return handleError(function () {
const ret = module.require;
return ret;
}, arguments); },
__wbg_static_accessor_GLOBAL_8adb955bd33fac2f: function() {
const ret = typeof global === 'undefined' ? null : global;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_static_accessor_GLOBAL_THIS_ad356e0db91c7913: function() {
const ret = typeof globalThis === 'undefined' ? null : globalThis;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_static_accessor_SELF_f207c857566db248: function() {
const ret = typeof self === 'undefined' ? null : self;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_static_accessor_WINDOW_bb9f1ba69d61b386: function() {
const ret = typeof window === 'undefined' ? null : window;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_subarray_a068d24e39478a8a: function(arg0, arg1, arg2) {
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
return ret;
},
__wbg_versions_276b2795b1c6a219: function(arg0) {
const ret = arg0.versions;
return ret;
},
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`.
const ret = getArrayU8FromWasm0(arg0, arg1);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Ref(String) -> Externref`.
const ret = getStringFromWasm0(arg0, arg1);
return ret;
},
__wbindgen_init_externref_table: function() {
const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
},
};
return {
__proto__: null,
"./wzp_wasm_bg.js": import0,
};
}
const WzpCryptoSessionFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_wzpcryptosession_free(ptr >>> 0, 1));
const WzpFecDecoderFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_wzpfecdecoder_free(ptr >>> 0, 1));
const WzpFecEncoderFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_wzpfecencoder_free(ptr >>> 0, 1));
const WzpKeyExchangeFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_wzpkeyexchange_free(ptr >>> 0, 1));
function addToExternrefTable0(obj) {
const idx = wasm.__externref_table_alloc();
wasm.__wbindgen_externrefs.set(idx, obj);
return idx;
}
function getArrayU8FromWasm0(ptr, len) {
ptr = ptr >>> 0;
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
}
function getStringFromWasm0(ptr, len) {
ptr = ptr >>> 0;
return decodeText(ptr, len);
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
function handleError(f, args) {
try {
return f.apply(this, args);
} catch (e) {
const idx = addToExternrefTable0(e);
wasm.__wbindgen_exn_store(idx);
}
}
function isLikeNone(x) {
return x === undefined || x === null;
}
function passArray8ToWasm0(arg, malloc) {
const ptr = malloc(arg.length * 1, 1) >>> 0;
getUint8ArrayMemory0().set(arg, ptr / 1);
WASM_VECTOR_LEN = arg.length;
return ptr;
}
function takeFromExternrefTable0(idx) {
const value = wasm.__wbindgen_externrefs.get(idx);
wasm.__externref_table_dealloc(idx);
return value;
}
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
let WASM_VECTOR_LEN = 0;
let wasmModule, wasm;
function __wbg_finalize_init(instance, module) {
wasm = instance.exports;
wasmModule = module;
cachedUint8ArrayMemory0 = null;
wasm.__wbindgen_start();
return wasm;
}
async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(module, imports);
} catch (e) {
const validResponse = module.ok && expectedResponseType(module.type);
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
} else { throw e; }
}
}
const bytes = await module.arrayBuffer();
return await WebAssembly.instantiate(bytes, imports);
} else {
const instance = await WebAssembly.instantiate(module, imports);
if (instance instanceof WebAssembly.Instance) {
return { instance, module };
} else {
return instance;
}
}
function expectedResponseType(type) {
switch (type) {
case 'basic': case 'cors': case 'default': return true;
}
return false;
}
}
function initSync(module) {
if (wasm !== undefined) return wasm;
if (module !== undefined) {
if (Object.getPrototypeOf(module) === Object.prototype) {
({module} = module)
} else {
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
}
}
const imports = __wbg_get_imports();
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return __wbg_finalize_init(instance, module);
}
async function __wbg_init(module_or_path) {
if (wasm !== undefined) return wasm;
if (module_or_path !== undefined) {
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
({module_or_path} = module_or_path)
} else {
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
}
}
if (module_or_path === undefined) {
module_or_path = new URL('wzp_wasm_bg.wasm', import.meta.url);
}
const imports = __wbg_get_imports();
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
module_or_path = fetch(module_or_path);
}
const { instance, module } = await __wbg_load(await module_or_path, imports);
return __wbg_finalize_init(instance, module);
}
export { initSync, __wbg_init as default };

Binary file not shown.

View File

@@ -1,329 +1,607 @@
# WarzonePhone Protocol Design & Architecture
# WarzonePhone Architecture
## Network Topology
> Custom lossy VoIP protocol built in Rust. E2E encrypted, FEC-protected, adaptive quality, designed for hostile network conditions.
```
Lossy / censored link
◄──────────────────────►
┌────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────┐
│ Client │─QUIC─│ Relay A │─QUIC─│ Relay B │─QUIC─│ Destination │
└────────┘ └─────────┘ └─────────┘ └─────────────┘
│ │ │ │
Encode Forward Forward Decode
FEC FEC FEC FEC
Encrypt (opaque) (opaque) Decrypt
## System Overview
```mermaid
graph TB
subgraph "Client A (Browser/CLI)"
MIC[Microphone] --> DN[NoiseSupressor<br/>RNNoise ML]
DN --> SD[SilenceDetector<br/>VAD + Hangover]
SD --> ENC[CallEncoder<br/>Opus/Codec2]
ENC --> FEC_E[FEC Encoder<br/>RaptorQ]
FEC_E --> CRYPT_E[ChaCha20-Poly1305<br/>Encrypt]
CRYPT_E --> QUIC_S[QUIC Datagram<br/>Send]
QUIC_R[QUIC Datagram<br/>Recv] --> CRYPT_D[ChaCha20-Poly1305<br/>Decrypt]
CRYPT_D --> FEC_D[FEC Decoder<br/>RaptorQ]
FEC_D --> JIT[JitterBuffer<br/>Adaptive Playout]
JIT --> DEC[CallDecoder<br/>Opus/Codec2]
DEC --> SPK[Speaker]
end
subgraph "Relay (SFU)"
ACCEPT[Accept QUIC] --> AUTH{Auth?}
AUTH -->|token| VALIDATE[POST /v1/auth/validate]
AUTH -->|no auth| HS
VALIDATE --> HS[Crypto Handshake<br/>X25519 + Ed25519]
HS --> ROOM[Room Manager<br/>Named Rooms via SNI]
ROOM --> FWD[Forward to<br/>Other Participants]
end
subgraph "Client B"
B_SPK[Speaker]
B_MIC[Microphone]
end
QUIC_S -->|UDP/QUIC| ACCEPT
FWD -->|UDP/QUIC| QUIC_R
B_MIC -.->|same pipeline| ACCEPT
FWD -.->|same pipeline| B_SPK
style MIC fill:#4a9eff
style SPK fill:#4a9eff
style B_MIC fill:#4a9eff
style B_SPK fill:#4a9eff
style ROOM fill:#ff9f43
style CRYPT_E fill:#ee5a24
style CRYPT_D fill:#ee5a24
```
In the simplest deployment a single relay serves as the meeting point (room mode, SFU). Clients connect directly to one relay, which forwards media to all other participants in the same room. For censorship-resistant links, two relays can be chained: a client-facing relay forwards all traffic to a remote relay via QUIC.
## Crate Dependency Graph
Room names are carried in the QUIC SNI field during the TLS handshake, so a single relay can host many independent rooms without additional signaling.
```mermaid
graph TD
PROTO[wzp-proto<br/>Types, Traits, Wire Format]
## Protocol Stack
CODEC[wzp-codec<br/>Opus + Codec2 + RNNoise]
FEC[wzp-fec<br/>RaptorQ FEC]
CRYPTO[wzp-crypto<br/>ChaCha20 + Identity]
TRANSPORT[wzp-transport<br/>QUIC/Quinn]
```
┌──────────────────────────────────────────────┐
│ Application (Opus / Codec2 audio) │ wzp-codec
├──────────────────────────────────────────────┤
Redundancy (RaptorQ FEC + interleaving) │ wzp-fec
├──────────────────────────────────────────────┤
Crypto (ChaCha20-Poly1305 + AEAD) │ wzp-crypto
├──────────────────────────────────────────────┤
│ Transport (QUIC DATAGRAM + reliable stream) │ wzp-transport
├──────────────────────────────────────────────┤
Obfuscation (Phase 2 — trait defined) │ wzp-proto::ObfuscationLayer
└──────────────────────────────────────────────┘
RELAY[wzp-relay<br/>Relay Daemon]
CLIENT[wzp-client<br/>CLI + Call Engine]
WEB[wzp-web<br/>Browser Bridge]
PROTO --> CODEC
PROTO --> FEC
PROTO --> CRYPTO
PROTO --> TRANSPORT
CODEC --> CLIENT
FEC --> CLIENT
CRYPTO --> CLIENT
TRANSPORT --> CLIENT
CODEC --> RELAY
FEC --> RELAY
CRYPTO --> RELAY
TRANSPORT --> RELAY
CLIENT --> WEB
TRANSPORT --> WEB
CRYPTO --> WEB
FC[warzone-protocol<br/>featherChat Identity] -.->|path dep| CRYPTO
style PROTO fill:#6c5ce7
style RELAY fill:#ff9f43
style CLIENT fill:#00b894
style WEB fill:#0984e3
style FC fill:#fd79a8
```
Audio and FEC are end-to-end between caller and callee. The relay operates on opaque, encrypted, FEC-protected packets. Crypto keys are never shared with relays.
## Wire Format
## Wire Formats
### MediaHeader (12 bytes)
```
Byte 0: [V:1][T:1][CodecID:4][Q:1][FecRatioHi:1]
Byte 1: [FecRatioLo:6][unused:2]
Byte 2-3: Sequence number (big-endian u16)
Byte 4-7: Timestamp in ms since session start (big-endian u32)
Byte 8: FEC block ID (wrapping u8)
Byte 9: FEC symbol index within block
Byte 10: Reserved / flags
Byte 11: CSRC count (for future mixing)
Byte 0: [V:1][T:1][CodecID:4][Q:1][FecHi:1]
Byte 1: [FecLo:6][unused:2]
Bytes 2-3: sequence (u16 BE)
Bytes 4-7: timestamp_ms (u32 BE)
Byte 8: fec_block_id (u8)
Byte 9: fec_symbol_idx (u8)
Byte 10: reserved
Byte 11: csrc_count
V = version (0), T = is_repair, CodecID = codec, Q = quality_report appended
```
Field details:
| Field | Bits | Description |
|-------|------|-------------|
| V | 1 | Protocol version (0 = v1) |
| T | 1 | 1 = FEC repair packet, 0 = source media |
| CodecID | 4 | Codec identifier (0=Opus24k, 1=Opus16k, 2=Opus6k, 3=Codec2_3200, 4=Codec2_1200) |
| Q | 1 | QualityReport trailer appended |
| FecRatio | 7 | FEC ratio encoded as 7-bit value (0-127 maps to 0.0-2.0) |
| Seq | 16 | Wrapping packet sequence number |
| Timestamp | 32 | Milliseconds since session start |
| FEC block | 8 | Source block ID (wrapping) |
| FEC symbol | 8 | Symbol index within the FEC block |
| Reserved | 8 | Reserved flags |
| CSRC count | 8 | Contributing source count (future) |
Defined in `crates/wzp-proto/src/packet.rs` as `MediaHeader`.
### QualityReport (4 bytes)
Appended to a media packet when the Q flag is set.
### MiniHeader (4 bytes, compressed)
```
Byte 0: loss_pct — 0-255 maps to 0-100% loss
Byte 1: rtt_4ms — RTT in 4ms units (0-255 = 0-1020ms)
Byte 2: jitter_ms — Jitter in milliseconds
Byte 3: bitrate_cap — Max receive bitrate in kbps
Bytes 0-1: timestamp_delta_ms (u16 BE)
Bytes 2-3: payload_len (u16 BE)
Preceded by FRAME_TYPE_MINI (0x01). Full header every 50 frames (~1s).
Saves 8 bytes/packet (67% header reduction).
```
Defined in `crates/wzp-proto/src/packet.rs` as `QualityReport`.
### MediaPacket
A complete media packet on the wire:
### TrunkFrame (batched datagrams)
```
[MediaHeader: 12 bytes][Payload: variable][QualityReport: 4 bytes if Q=1]
[count:u16]
[session_id:2][len:u16][payload:len] x count
Packs multiple session packets into one QUIC datagram.
Max 10 entries or 1200 bytes, flushed every 5ms.
```
Defined in `crates/wzp-proto/src/packet.rs` as `MediaPacket`.
### SignalMessage (reliable stream)
Signaling uses length-prefixed JSON over reliable QUIC bidirectional streams. Each message opens a new bidi stream, writes a 4-byte big-endian length prefix followed by the JSON payload, then finishes the send side.
Variants defined in `crates/wzp-proto/src/packet.rs`:
- `CallOffer` — identity_pub, ephemeral_pub, signature, supported_profiles
- `CallAnswer` — identity_pub, ephemeral_pub, signature, chosen_profile
- `IceCandidate` — NAT traversal candidate string
- `Rekey` — new_ephemeral_pub, signature
- `QualityUpdate` — report, recommended_profile
- `Ping` / `Pong` — timestamp_ms for RTT measurement
- `Hangup` — reason (Normal, Busy, Declined, Timeout, Error)
## FEC Strategy
WarzonePhone uses **RaptorQ fountain codes** (via the `raptorq` crate) for forward error correction. This is implemented in `crates/wzp-fec/`.
### Block Structure
Audio frames are grouped into FEC blocks. Each block contains a fixed number of source symbols (configured per quality profile). Each source symbol is a single encoded audio frame, zero-padded to a uniform 256-byte symbol size with a 2-byte little-endian length prefix.
### Encoding Process
1. Audio frames are added to the encoder as source symbols
2. When a block is full (`frames_per_block` symbols), repair symbols are generated
3. The repair ratio determines how many repair symbols: `ceil(num_source * ratio)`
4. Both source and repair packets are transmitted with the block ID and symbol index in the header
### Decoding Process
1. Received symbols (source or repair) are fed to the decoder keyed by block ID
2. The decoder attempts reconstruction when sufficient symbols arrive
3. RaptorQ can recover the full block from any `K` symbols out of `K + R` total (where K = source count, R = repair count)
4. Old blocks are expired via wrapping u8 distance
### Interleaving
The `Interleaver` spreads symbols from multiple FEC blocks across transmission slots in round-robin fashion. With depth=3, a burst loss of 6 consecutive packets damages at most 2 symbols per block instead of 6 symbols in one block.
### FEC Configuration by Quality Tier
| Tier | Frames/Block | Repair Ratio | Total Bandwidth Overhead |
|------|-------------|-------------|-------------------------|
| GOOD | 5 | 0.2 (20%) | 1.2x |
| DEGRADED | 10 | 0.5 (50%) | 1.5x |
| CATASTROPHIC | 8 | 1.0 (100%) | 2.0x |
## Adaptive Quality
Three quality tiers drive codec and FEC selection. The controller is implemented in `crates/wzp-proto/src/quality.rs` as `AdaptiveQualityController`.
### Tier Thresholds
| Tier | Loss | RTT | Codec | FEC Ratio |
|------|------|-----|-------|-----------|
| GOOD | < 10% | < 400ms | Opus 24kbps, 20ms frames | 0.2 |
| DEGRADED | 10-40% or 400-600ms | | Opus 6kbps, 40ms frames | 0.5 |
| CATASTROPHIC | > 40% or > 600ms | | Codec2 1200bps, 40ms frames | 1.0 |
### Hysteresis
- **Downgrade**: Triggers after 3 consecutive reports in a worse tier (fast reaction)
- **Upgrade**: Triggers after 10 consecutive reports in a better tier (slow, cautious)
- **Step limit**: Upgrades move only one tier at a time (Catastrophic -> Degraded -> Good)
- **History**: A sliding window of 20 recent reports is maintained for smoothing
- **Force mode**: Manual `force_profile()` disables adaptive logic entirely
### QualityProfile Constants
```rust
GOOD: Opus24k, fec=0.2, 20ms, 5 frames/block 28.8 kbps total
DEGRADED: Opus6k, fec=0.5, 40ms, 10 frames/block 9.0 kbps total
CATASTROPHIC: Codec2_1200, fec=1.0, 40ms, 8 frames/block 2.4 kbps total
```
## Encryption
Implemented in `crates/wzp-crypto/`.
### Identity Model (Warzone-Compatible)
- **Seed**: 32-byte random value (BIP39 mnemonic for backup)
- **Ed25519**: Derived via `HKDF(seed, "warzone-ed25519-identity")` -- signing/identity
- **X25519**: Derived via `HKDF(seed, "warzone-x25519-identity")` -- encryption
- **Fingerprint**: `SHA-256(Ed25519_pub)[:16]` -- 128-bit identifier
### Per-Call Key Exchange
1. Each side generates an ephemeral X25519 keypair
2. Ephemeral public keys are exchanged via `CallOffer`/`CallAnswer` signaling
3. Signatures are computed: `Ed25519_sign(ephemeral_pub || context_string)`
4. Shared secret: `X25519_DH(our_ephemeral_secret, peer_ephemeral_pub)`
5. Session key: `HKDF(shared_secret, "warzone-session-key")` -> 32 bytes
### Nonce Construction (12 bytes, not transmitted)
### QualityReport (4 bytes, optional)
```
session_id[0..4] || sequence_number (u32 BE) || direction (1 byte) || padding (3 bytes zero)
Byte 0: loss_pct (0-255 maps to 0-100%)
Byte 1: rtt_4ms (0-255 maps to 0-1020ms)
Byte 2: jitter_ms
Byte 3: bitrate_cap_kbps
```
- `session_id`: First 4 bytes of `SHA-256(session_key)`
- `direction`: 0 = Send, 1 = Recv
- Nonces are derived deterministically, saving 12 bytes per packet
### AEAD Encryption
- Algorithm: ChaCha20-Poly1305
- AAD: The 12-byte MediaHeader (authenticated but not encrypted)
- Tag: 16 bytes appended to ciphertext
- Overhead per packet: 16 bytes
### Rekeying
- Trigger: Every 2^16 packets (65536)
- Process: New ephemeral X25519 exchange, mixed with old key via HKDF
- Key evolution: `HKDF(old_key as salt, new_DH_result, "warzone-rekey")`
- Old key is zeroized after derivation (forward secrecy)
- Sequence counters reset to 0 after rekey
### Anti-Replay
- Sliding window of 1024 packets using a bitmap
- Sequence numbers too old (> 1024 behind highest seen) are rejected
- Handles u16 wrapping correctly (RFC 1982 serial number arithmetic)
- Implemented in `crates/wzp-crypto/src/anti_replay.rs` as `AntiReplayWindow`
## Jitter Buffer
Implemented in `crates/wzp-proto/src/jitter.rs` as `JitterBuffer`.
- **Structure**: BTreeMap keyed by sequence number for ordered playout
- **Target depth**: 50 packets (1 second) default
- **Max depth**: 250 packets (5 seconds at 20ms/frame)
- **Min depth**: 25 packets (0.5 seconds) before playout begins
- **Sequence wrapping**: RFC 1982 serial number arithmetic for u16
- **Duplicate handling**: Silently dropped
- **Late packets**: Packets arriving after their sequence has been played out are dropped
- **Overflow**: When buffer exceeds max depth, oldest packets are evicted
### Playout Results
- `Packet(MediaPacket)` -- normal delivery
- `Missing { seq }` -- gap detected, decoder should generate PLC
- `NotReady` -- buffer not yet filled to minimum depth
### Known Limitations
- No adaptive depth adjustment based on observed jitter (target_depth is configurable but not self-tuning in the current implementation)
- No timestamp-based playout scheduling (uses sequence-number ordering only)
- Jitter buffer drift has been observed during long echo tests
## Session State Machine
Defined in `crates/wzp-proto/src/session.rs`:
### SignalMessage (JSON over reliable QUIC stream)
```
Idle -> Connecting -> Handshaking -> Active <-> Rekeying -> Active
|
Closed
[4-byte length prefix][serde_json payload]
Variants:
CallOffer { identity_pub, ephemeral_pub, signature, supported_profiles }
CallAnswer { identity_pub, ephemeral_pub, signature, chosen_profile }
IceCandidate { candidate }
Hangup { reason: Normal|Busy|Declined|Timeout|Error }
AuthToken { token }
Hold, Unhold, Mute, Unmute
Transfer { target_fingerprint, relay_addr }
TransferAck
Rekey { new_ephemeral_pub, signature }
QualityUpdate { report, recommended_profile }
Ping/Pong { timestamp_ms }
```
- Media flows during both `Active` and `Rekeying` states
- Any state can transition to `Closed` via `Terminate` or `ConnectionLost`
- Invalid transitions produce a `TransitionError`
## Quality Profiles
```mermaid
graph LR
subgraph GOOD ["GOOD (28.8 kbps)"]
G_C[Opus 24kbps]
G_F[FEC 20%]
G_FR[20ms frames]
end
subgraph DEGRADED ["DEGRADED (9.0 kbps)"]
D_C[Opus 6kbps]
D_F[FEC 50%]
D_FR[40ms frames]
end
subgraph CATASTROPHIC ["CATASTROPHIC (2.4 kbps)"]
C_C[Codec2 1200bps]
C_F[FEC 100%]
C_FR[40ms frames]
end
GOOD -->|"loss>5% or RTT>100ms<br/>3 consecutive reports"| DEGRADED
DEGRADED -->|"loss>15% or RTT>200ms<br/>3 consecutive"| CATASTROPHIC
CATASTROPHIC -->|"loss<5% and RTT<100ms<br/>3 consecutive"| DEGRADED
DEGRADED -->|"loss<5% and RTT<100ms<br/>3 consecutive"| GOOD
style GOOD fill:#00b894
style DEGRADED fill:#fdcb6e
style CATASTROPHIC fill:#e17055
```
## Cryptographic Handshake
```mermaid
sequenceDiagram
participant C as Caller
participant R as Relay/Callee
Note over C: Derive identity from seed<br/>Ed25519 + X25519 via HKDF
C->>C: Generate ephemeral X25519
C->>C: Sign(ephemeral_pub || "call-offer")
C->>R: CallOffer { identity_pub, ephemeral_pub, signature, profiles }
R->>R: Verify Ed25519 signature
R->>R: Generate ephemeral X25519
R->>R: shared_secret = DH(eph_b, eph_a)
R->>R: session_key = HKDF(shared_secret, "warzone-session-key")
R->>R: Sign(ephemeral_pub || "call-answer")
R->>C: CallAnswer { identity_pub, ephemeral_pub, signature, chosen_profile }
C->>C: Verify signature
C->>C: shared_secret = DH(eph_a, eph_b)
C->>C: session_key = HKDF(shared_secret)
Note over C,R: Both have identical ChaCha20-Poly1305 session key
C->>R: Encrypted media (QUIC datagrams)
R->>C: Encrypted media (QUIC datagrams)
Note over C,R: Rekey every 65,536 packets<br/>New ephemeral DH + HKDF mix
```
## Identity Model (featherChat Compatible)
```mermaid
graph TD
SEED[32-byte Seed<br/>BIP39 Mnemonic 24 words] --> HKDF1[HKDF<br/>salt=None<br/>info=warzone-ed25519]
SEED --> HKDF2[HKDF<br/>salt=None<br/>info=warzone-x25519]
HKDF1 --> ED[Ed25519 SigningKey<br/>Digital Signatures]
HKDF2 --> X25519[X25519 StaticSecret<br/>Key Agreement]
ED --> VKEY[Ed25519 VerifyingKey<br/>Public]
X25519 --> XPUB[X25519 PublicKey<br/>Public]
VKEY --> FP[Fingerprint<br/>SHA-256 pubkey truncated 16 bytes<br/>xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx]
style SEED fill:#6c5ce7
style FP fill:#fd79a8
style ED fill:#ee5a24
style X25519 fill:#00b894
```
## Relay Modes
### Room Mode (Default, SFU)
```mermaid
graph TB
subgraph "Room Mode (Default SFU)"
C1[Client 1] -->|QUIC SNI=room-hash| RM[Room Manager]
C2[Client 2] -->|QUIC SNI=room-hash| RM
C3[Client 3] -->|QUIC SNI=room-hash| RM
RM --> R1[Room abc123]
R1 -->|fan-out| C1
R1 -->|fan-out| C2
R1 -->|fan-out| C3
end
- Clients join named rooms via QUIC SNI
- When a participant sends a packet, the relay forwards it to all other participants
- No transcoding -- packets are forwarded opaquely
- Rooms are auto-created when the first participant joins and auto-deleted when empty
- Managed by `RoomManager` in `crates/wzp-relay/src/room.rs`
subgraph "Forward Mode with --remote"
C4[Client] -->|QUIC| RA[Relay A]
RA -->|FEC decode then jitter then FEC encode| RB[Relay B]
RB -->|QUIC| C5[Client]
end
### Forward Mode (`--remote`)
subgraph "Probe Mode with --probe"
PA[Relay A] -->|Ping 1/s ~50 bytes| PB[Relay B]
PB -->|Pong| PA
PA --> PM[Prometheus<br/>RTT Loss Jitter Up/Down]
end
- All incoming traffic is forwarded to a remote relay via QUIC
- Two-pipeline architecture: upstream (client->remote) and downstream (remote->client)
- Each direction has its own `RelayPipeline` with FEC decode/encode and jitter buffering
- Intended for chaining relays across censored/lossy boundaries
### Relay Pipeline (Forward Mode)
Implemented in `crates/wzp-relay/src/pipeline.rs` as `RelayPipeline`:
```
Inbound: recv -> FEC decode -> jitter buffer -> pop
Outbound: packet -> assign seq -> FEC encode -> repair packets -> send
style RM fill:#ff9f43
style R1 fill:#fdcb6e
style PM fill:#0984e3
```
The pipeline does NOT decode/re-encode audio. It operates on FEC-protected packets, managing loss recovery and re-FEC-encoding for the next hop.
## Web Bridge Architecture
## Transport
```mermaid
sequenceDiagram
participant B as Browser
participant W as wzp-web
participant R as wzp-relay
Implemented in `crates/wzp-transport/` using QUIC via the `quinn` crate.
B->>W: HTTPS GET /room-name
W->>B: index.html (SPA)
### QUIC Configuration
B->>W: WebSocket /ws/room-name
Note over B,W: Optional auth JSON message
- ALPN protocol: `wzp`
- Idle timeout: 30 seconds
- Keep-alive interval: 5 seconds
- DATAGRAM extension enabled (for unreliable media)
- Datagram receive buffer: 64 KB
- Receive window: 256 KB
- Send window: 128 KB
- Stream receive window: 64 KB per stream
- Initial RTT estimate: 300ms (tuned for high-latency links)
W->>R: QUIC connect (SNI = hashed room name)
Note over W,R: AuthToken then Handshake then Join Room
### Media Transport
loop Every 20ms
B->>W: WS Binary Int16 x 960 PCM
W->>W: CallEncoder Opus + FEC
W->>R: QUIC Datagram encrypted
end
- **Unreliable media**: QUIC DATAGRAM frames (no retransmission, no head-of-line blocking)
- **Reliable signaling**: QUIC bidirectional streams with length-prefixed JSON framing
loop Incoming audio
R->>W: QUIC Datagram
W->>W: CallDecoder FEC + Opus
W->>B: WS Binary Int16 x 960 PCM
end
### Path Quality Monitoring
Note over B: AudioWorklet<br/>WZPCaptureProcessor mic to 960 frames<br/>WZPPlaybackProcessor ring buffer to speaker
```
`PathMonitor` in `crates/wzp-transport/src/path_monitor.rs` tracks:
## FEC Protection (RaptorQ)
- **Loss**: EWMA-smoothed percentage from sent/received packet counts
- **RTT**: EWMA-smoothed round-trip time (alpha=0.1)
- **Jitter**: EWMA of RTT variance (|current_rtt - previous_rtt|)
- **Bandwidth**: Estimated from bytes received over elapsed time
```mermaid
graph LR
subgraph "Encoder"
F1[Frame 1] --> BLK[Source Block<br/>5-10 frames]
F2[Frame 2] --> BLK
F3[Frame 3] --> BLK
F4[Frame 4] --> BLK
F5[Frame 5] --> BLK
BLK --> SRC[5 Source Symbols]
BLK --> REP[1-10 Repair Symbols<br/>ratio dependent]
SRC --> INT[Interleaver<br/>depth=3]
REP --> INT
end
### Codec Selection by Tier
subgraph "Network"
INT --> LOSS{Packet Loss}
LOSS -->|some lost| RCV[Received Symbols]
end
| Codec | Sample Rate | Frame Duration | Bitrate | Use Case |
|-------|------------|----------------|---------|----------|
| Opus24k | 48 kHz | 20ms (960 samples) | 24 kbps | Good conditions |
| Opus16k | 48 kHz | 20ms | 16 kbps | Moderate conditions |
| Opus6k | 48 kHz | 40ms (1920 samples) | 6 kbps | Degraded conditions |
| Codec2_3200 | 8 kHz | 20ms (160 samples) | 3.2 kbps | Poor conditions |
| Codec2_1200 | 8 kHz | 40ms (320 samples) | 1.2 kbps | Catastrophic conditions |
subgraph "Decoder"
RCV --> DEINT[De-interleaver]
DEINT --> RAPTORQ[RaptorQ Decoder<br/>Reconstruct from<br/>any K of K+R symbols]
RAPTORQ --> OUT[Original Frames]
end
Opus operates at 48 kHz natively. When Codec2 is selected, the adaptive codec layer handles 48 kHz <-> 8 kHz resampling transparently using a simple linear resampler (6:1 decimation/interpolation).
style LOSS fill:#e17055
style RAPTORQ fill:#00b894
```
## Telemetry Stack
```mermaid
graph TB
subgraph "Relay"
RM[RelayMetrics<br/>sessions rooms packets]
SM[SessionMetrics<br/>per-session jitter loss RTT]
PM[ProbeMetrics<br/>inter-relay RTT loss]
RM --> PROM1[GET /metrics :9090]
SM --> PROM1
PM --> PROM1
end
subgraph "Web Bridge"
WM[WebMetrics<br/>connections frames latency]
WM --> PROM2[GET /metrics :8080]
end
subgraph "Client"
CM[JitterStats + QualityAdapter]
CM --> JSONL[--metrics-file<br/>JSONL 1 line/sec]
end
PROM1 --> GRAF[Grafana Dashboard<br/>4 rows 18 panels]
PROM2 --> GRAF
JSONL --> ANALYSIS[Offline Analysis]
style GRAF fill:#ff6b6b
style PROM1 fill:#0984e3
style PROM2 fill:#0984e3
```
## Session State Machine
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Connecting: connect
Connecting --> Handshaking: QUIC established
Handshaking --> Active: CallOffer/Answer complete
Active --> Rekeying: 65536 packets
Rekeying --> Active: new key derived
Active --> Closed: Hangup/Error/Timeout
Rekeying --> Closed: Error
Connecting --> Closed: Timeout
Handshaking --> Closed: Signature fail
note right of Active: Media flows
note right of Rekeying: Media continues while rekeying
```
## Audio Processing Pipeline Detail
```mermaid
graph TD
subgraph "Capture 20ms at 48kHz = 960 samples"
MIC[Microphone / AudioWorklet] --> PCM[PCM i16 x 960]
PCM --> RNN[RNNoise Denoise<br/>2 x 480 samples]
RNN --> VAD{Silent?}
VAD -->|Yes over 100ms| CN[ComfortNoise packet<br/>every 200ms]
VAD -->|No or Hangover| OPUS[Opus/Codec2 Encode]
end
subgraph "FEC + Crypto"
OPUS --> SYMBOL[Pad to 256-byte symbol]
CN --> SYMBOL
SYMBOL --> BLOCK[Accumulate block<br/>5-10 symbols]
BLOCK --> RAPTOR[RaptorQ encode<br/>+ repair symbols]
RAPTOR --> INTERLEAVE[Interleave depth=3]
INTERLEAVE --> HDR[Add MediaHeader<br/>or MiniHeader]
HDR --> ENCRYPT[ChaCha20-Poly1305<br/>header=AAD payload=encrypted]
ENCRYPT --> QUIC[QUIC Datagram]
end
style RNN fill:#a29bfe
style ENCRYPT fill:#ee5a24
style RAPTOR fill:#00b894
```
## Adaptive Jitter Buffer
```mermaid
graph TD
PKT[Incoming Packet] --> SEQ{Sequence Check}
SEQ -->|Duplicate| DROP[Drop + AntiReplay]
SEQ -->|Valid| BUF[BTreeMap Buffer<br/>ordered by seq]
BUF --> ADAPT[AdaptivePlayoutDelay<br/>EMA jitter tracking]
ADAPT --> TARGET[target_delay =<br/>ceil jitter_ema/20ms + 2]
BUF --> READY{depth >= target?}
READY -->|No| WAIT[Wait / Underrun++]
READY -->|Yes| POP[Pop lowest seq]
POP --> DECODE[Decode to PCM]
DECODE --> PLAY[Playout]
BUF --> OVERFLOW{depth > max?}
OVERFLOW -->|Yes| EVICT[Drop oldest<br/>Overrun++]
style ADAPT fill:#fdcb6e
style DROP fill:#e17055
style EVICT fill:#e17055
```
## Deployment Topology
```mermaid
graph TB
subgraph "Region A"
RA[wzp-relay A<br/>:4433 UDP]
WA[wzp-web A<br/>:8080 HTTPS]
WA --> RA
end
subgraph "Region B"
RB[wzp-relay B<br/>:4433 UDP]
WB[wzp-web B<br/>:8080 HTTPS]
WB --> RB
end
RA <-->|Probe 1/s| RB
BA[Browser A] -->|WSS| WA
BB[Browser B] -->|WSS| WB
CA[CLI Client] -->|QUIC| RA
PROM[Prometheus] -->|scrape| RA
PROM -->|scrape| RB
PROM -->|scrape| WA
PROM --> GRAF[Grafana]
FC[featherChat Server] -->|auth validate| RA
FC -->|auth validate| RB
style RA fill:#ff9f43
style RB fill:#ff9f43
style GRAF fill:#ff6b6b
style FC fill:#fd79a8
```
## featherChat Integration Flow
```mermaid
sequenceDiagram
participant A as User A WZP Client
participant FC as featherChat Server
participant R as WZP Relay
participant B as User B WZP Client
Note over A,B: Both users share BIP39 seed = same identity
A->>FC: WS CallSignal Offer payload=JSON SignalMessage
FC->>B: WS CallSignal Offer payload + relay_addr + room
B->>R: QUIC connect SNI = hashed room
B->>R: AuthToken fc_bearer_token
R->>FC: POST /v1/auth/validate token
FC->>R: valid true fingerprint ...
B->>R: CallOffer then CallAnswer handshake
A->>R: QUIC connect same room
A->>R: AuthToken + Handshake
Note over A,B: Both in same room media flows E2E encrypted
A->>R: Encrypted media
R->>B: Forward SFU no decryption
B->>R: Encrypted media
R->>A: Forward
```
## Bandwidth Usage
| Profile | Audio | FEC Overhead | Total | Use Case |
|---------|-------|-------------|-------|----------|
| **GOOD** | 24 kbps (Opus) | 20% = 4.8 kbps | **28.8 kbps** | WiFi, LTE, good links |
| **DEGRADED** | 6 kbps (Opus) | 50% = 3 kbps | **9.0 kbps** | 3G, congested WiFi |
| **CATASTROPHIC** | 1.2 kbps (Codec2) | 100% = 1.2 kbps | **2.4 kbps** | Satellite, extreme loss |
With silence suppression: ~50% savings in typical conversations.
With mini-frames: 8 bytes/packet saved (67% header reduction).
With trunking: shared QUIC overhead across multiplexed sessions.
## Project Structure
```
warzonePhone/
├── Cargo.toml # Workspace root
├── crates/
│ ├── wzp-proto/ # Protocol types, traits, wire format
│ │ └── src/
│ │ ├── codec_id.rs # CodecId, QualityProfile
│ │ ├── error.rs # Error types
│ │ ├── jitter.rs # JitterBuffer, AdaptivePlayoutDelay
│ │ ├── packet.rs # MediaHeader, MiniHeader, TrunkFrame, SignalMessage
│ │ ├── quality.rs # Tier, AdaptiveQualityController
│ │ ├── session.rs # SessionState machine
│ │ └── traits.rs # AudioEncoder, FecEncoder, CryptoSession, etc.
│ ├── wzp-codec/ # Audio codecs
│ │ └── src/
│ │ ├── adaptive.rs # AdaptiveEncoder/Decoder (Opus + Codec2)
│ │ ├── denoise.rs # NoiseSupressor (RNNoise/nnnoiseless)
│ │ └── silence.rs # SilenceDetector, ComfortNoise
│ ├── wzp-fec/ # Forward error correction
│ │ └── src/
│ │ ├── encoder.rs # RaptorQFecEncoder
│ │ ├── decoder.rs # RaptorQFecDecoder
│ │ └── interleave.rs # Interleaver (burst protection)
│ ├── wzp-crypto/ # Cryptography + identity
│ │ └── src/
│ │ ├── identity.rs # Seed, Fingerprint, hash_room_name
│ │ ├── handshake.rs # WarzoneKeyExchange (X25519 + Ed25519)
│ │ ├── session.rs # ChaChaSession (ChaCha20-Poly1305)
│ │ ├── nonce.rs # Deterministic nonce construction
│ │ ├── anti_replay.rs # Sliding window replay protection
│ │ └── rekey.rs # Forward secrecy rekeying
│ ├── wzp-transport/ # QUIC transport layer
│ │ └── src/lib.rs # QuinnTransport, send/recv media/signal/trunk
│ ├── wzp-relay/ # Relay daemon
│ │ └── src/
│ │ ├── main.rs # CLI, connection loop, auth + handshake
│ │ ├── room.rs # RoomManager, TrunkedForwarder
│ │ ├── pipeline.rs # RelayPipeline (forward mode)
│ │ ├── session_mgr.rs # SessionManager (limits, lifecycle)
│ │ ├── auth.rs # featherChat token validation
│ │ ├── handshake.rs # Relay-side accept_handshake
│ │ ├── metrics.rs # Prometheus RelayMetrics + per-session
│ │ ├── probe.rs # Inter-relay probes + ProbeMesh
│ │ └── trunk.rs # TrunkBatcher
│ ├── wzp-client/ # Call engine + CLI
│ │ └── src/
│ │ ├── cli.rs # CLI arg parsing + main
│ │ ├── call.rs # CallEncoder, CallDecoder, QualityAdapter
│ │ ├── handshake.rs # Client-side perform_handshake
│ │ ├── featherchat.rs # CallSignal bridge
│ │ ├── echo_test.rs # Automated echo quality test
│ │ ├── drift_test.rs # Clock drift measurement
│ │ ├── sweep.rs # Jitter buffer parameter sweep
│ │ ├── metrics.rs # JSONL telemetry writer
│ │ └── bench.rs # Component benchmarks
│ └── wzp-web/ # Browser bridge
│ ├── src/
│ │ ├── main.rs # Axum server, WS handler, TLS
│ │ └── metrics.rs # Prometheus WebMetrics
│ └── static/
│ ├── index.html # SPA UI (room, PTT, level meter)
│ └── audio-processor.js # AudioWorklet (capture + playback)
├── deps/featherchat/ # Git submodule
├── docs/
│ ├── ARCHITECTURE.md # This file
│ ├── TELEMETRY.md # Metrics specification
│ ├── INTEGRATION_TASKS.md # featherChat task tracker
│ ├── WZP-FC-SHARED-CRATES.md # Shared crate strategy
│ └── grafana-dashboard.json # Pre-built Grafana dashboard
└── scripts/
└── build-linux.sh # Hetzner VM build
```
## Test Coverage
272 tests across all crates, 0 failures.
| Crate | Tests | Key Coverage |
|-------|-------|-------------|
| wzp-proto | 41 | Wire format, jitter buffer, quality tiers, mini-frames, trunking |
| wzp-codec | 31 | Opus/Codec2 roundtrip, silence detection, noise suppression |
| wzp-fec | 22 | RaptorQ encode/decode, loss recovery, interleaving |
| wzp-crypto | 34 + 28 compat | Encrypt/decrypt, handshake, anti-replay, featherChat identity compat |
| wzp-transport | 2 | QUIC connection setup |
| wzp-relay | 40 + 4 integration | Room ACL, session mgmt, metrics, probes, mesh, trunking |
| wzp-client | 30 + 2 integration | Encoder/decoder, quality adapter, silence, drift, sweep |
| wzp-web | 2 | Metrics |

View File

@@ -77,7 +77,9 @@ Based on featherChat commit 65f6390 — FUTURE_TASKS.md with WZP integration ite
### WZP-FC-7. Missed call notifications — TODO (0.5d)
### WZP-FC-8. Cross-project identity verification — DONE (15 tests, 26dc848)
### WZP-FC-9. HKDF salt investigation — DONE (no mismatch)
### WZP-FC-10. Web bridge shared auth — TODO (1-2d)
### WZP-FC-10. Web bridge shared auth — DONE
- FC: GET /v1/wzp/relay-config, CORS layer, service token
- WZP: web bridge --auth-url validates browser tokens via FC
### FC-CRATE-1. Standalone warzone-protocol — DONE (v0.0.21, 4a4fa9f)
---