Compare commits
9 Commits
6f4e8eb9f6
...
09a18b086b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09a18b086b | ||
|
|
f3c8e11995 | ||
|
|
4fb15fe7a3 | ||
|
|
e595fe6591 | ||
|
|
326aa491cc | ||
|
|
464e95a4bd | ||
|
|
fd95167705 | ||
|
|
9e7fea7633 | ||
|
|
993cf9ab7f |
17
Cargo.lock
generated
17
Cargo.lock
generated
@@ -3507,7 +3507,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-protocol"
|
name = "warzone-protocol"
|
||||||
version = "0.0.21"
|
version = "0.0.38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bincode",
|
"bincode",
|
||||||
@@ -4261,6 +4261,21 @@ dependencies = [
|
|||||||
"wzp-proto",
|
"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]]
|
[[package]]
|
||||||
name = "wzp-web"
|
name = "wzp-web"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ members = [
|
|||||||
"crates/wzp-relay",
|
"crates/wzp-relay",
|
||||||
"crates/wzp-client",
|
"crates/wzp-client",
|
||||||
"crates/wzp-web",
|
"crates/wzp-web",
|
||||||
|
"crates/wzp-wasm",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
87
README.md
Normal file
87
README.md
Normal 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
|
||||||
@@ -104,6 +104,11 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
|||||||
SignalMessage::Unmute => CallSignalType::Unmute,
|
SignalMessage::Unmute => CallSignalType::Unmute,
|
||||||
SignalMessage::Transfer { .. } => CallSignalType::Transfer,
|
SignalMessage::Transfer { .. } => CallSignalType::Transfer,
|
||||||
SignalMessage::TransferAck => CallSignalType::Offer, // reuse
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
190
crates/wzp-client/tests/long_session.rs
Normal file
190
crates/wzp-client/tests/long_session.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
454
crates/wzp-proto/src/bandwidth.rs
Normal file
454
crates/wzp-proto/src/bandwidth.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
//! - Identity = 32-byte seed → HKDF → Ed25519 (signing) + X25519 (encryption)
|
//! - Identity = 32-byte seed → HKDF → Ed25519 (signing) + X25519 (encryption)
|
||||||
//! - Fingerprint = SHA-256(Ed25519 pub)[:16]
|
//! - Fingerprint = SHA-256(Ed25519 pub)[:16]
|
||||||
|
|
||||||
|
pub mod bandwidth;
|
||||||
pub mod codec_id;
|
pub mod codec_id;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod jitter;
|
pub mod jitter;
|
||||||
@@ -27,6 +28,7 @@ pub use packet::{
|
|||||||
HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader, QualityReport,
|
HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader, QualityReport,
|
||||||
SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, FRAME_TYPE_MINI,
|
SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, FRAME_TYPE_MINI,
|
||||||
};
|
};
|
||||||
|
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
||||||
pub use quality::{AdaptiveQualityController, Tier};
|
pub use quality::{AdaptiveQualityController, Tier};
|
||||||
pub use session::{Session, SessionEvent, SessionState};
|
pub use session::{Session, SessionEvent, SessionState};
|
||||||
pub use traits::*;
|
pub use traits::*;
|
||||||
|
|||||||
@@ -591,6 +591,43 @@ pub enum SignalMessage {
|
|||||||
},
|
},
|
||||||
/// Acknowledge a transfer request.
|
/// Acknowledge a transfer request.
|
||||||
TransferAck,
|
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.
|
/// Reasons for ending a call.
|
||||||
@@ -776,6 +813,40 @@ mod tests {
|
|||||||
assert!(matches!(decoded, SignalMessage::TransferAck));
|
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]
|
#[test]
|
||||||
fn fec_ratio_encode_decode() {
|
fn fec_ratio_encode_decode() {
|
||||||
let ratio = 0.5;
|
let ratio = 0.5;
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ pub mod config;
|
|||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
pub mod presence;
|
||||||
pub mod probe;
|
pub mod probe;
|
||||||
|
pub mod relay_link;
|
||||||
pub mod room;
|
pub mod room;
|
||||||
|
pub mod route;
|
||||||
pub mod session_mgr;
|
pub mod session_mgr;
|
||||||
pub mod trunk;
|
pub mod trunk;
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use wzp_proto::MediaTransport;
|
|||||||
use wzp_relay::config::RelayConfig;
|
use wzp_relay::config::RelayConfig;
|
||||||
use wzp_relay::metrics::RelayMetrics;
|
use wzp_relay::metrics::RelayMetrics;
|
||||||
use wzp_relay::pipeline::{PipelineConfig, RelayPipeline};
|
use wzp_relay::pipeline::{PipelineConfig, RelayPipeline};
|
||||||
|
use wzp_relay::presence::PresenceRegistry;
|
||||||
use wzp_relay::room::{self, RoomManager};
|
use wzp_relay::room::{self, RoomManager};
|
||||||
use wzp_relay::session_mgr::SessionManager;
|
use wzp_relay::session_mgr::SessionManager;
|
||||||
|
|
||||||
@@ -176,11 +177,19 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.install_default()
|
.install_default()
|
||||||
.expect("failed to install rustls crypto provider");
|
.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
|
// Prometheus metrics
|
||||||
let metrics = Arc::new(RelayMetrics::new());
|
let metrics = Arc::new(RelayMetrics::new());
|
||||||
if let Some(port) = config.metrics_port {
|
if let Some(port) = config.metrics_port {
|
||||||
let m = metrics.clone();
|
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
|
// Generate ephemeral relay identity for crypto handshake
|
||||||
@@ -214,6 +223,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let mesh = wzp_relay::probe::ProbeMesh::new(
|
let mesh = wzp_relay::probe::ProbeMesh::new(
|
||||||
config.probe_targets.clone(),
|
config.probe_targets.clone(),
|
||||||
metrics.registry(),
|
metrics.registry(),
|
||||||
|
Some(presence.clone()),
|
||||||
);
|
);
|
||||||
info!(
|
info!(
|
||||||
targets = mesh.target_count(),
|
targets = mesh.target_count(),
|
||||||
@@ -244,6 +254,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let relay_seed_bytes = relay_seed.0;
|
let relay_seed_bytes = relay_seed.0;
|
||||||
let metrics = metrics.clone();
|
let metrics = metrics.clone();
|
||||||
let trunking_enabled = config.trunking_enabled;
|
let trunking_enabled = config.trunking_enabled;
|
||||||
|
let presence = presence.clone();
|
||||||
|
let route_resolver = route_resolver.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let addr = connection.remote_address();
|
let addr = connection.remote_address();
|
||||||
@@ -259,9 +271,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
||||||
|
|
||||||
// Probe connections use SNI "_probe" to identify themselves.
|
// 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" {
|
if room_name == "_probe" {
|
||||||
info!(%addr, "probe connection detected, entering Ping/Pong responder");
|
info!(%addr, "probe connection detected, entering Ping/Pong + presence responder");
|
||||||
loop {
|
loop {
|
||||||
match transport.recv_signal().await {
|
match transport.recv_signal().await {
|
||||||
Ok(Some(wzp_proto::SignalMessage::Ping { timestamp_ms })) => {
|
Ok(Some(wzp_proto::SignalMessage::Ping { timestamp_ms })) => {
|
||||||
@@ -272,8 +284,63 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
break;
|
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(®, &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(_)) => {
|
Ok(Some(_)) => {
|
||||||
// Ignore non-Ping signals on probe connections
|
// Ignore other signals on probe connections
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
info!(%addr, "probe connection closed");
|
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");
|
info!(%addr, room = %room_name, "client joining");
|
||||||
|
|
||||||
if let Some(remote) = remote_transport {
|
if let Some(remote) = remote_transport {
|
||||||
@@ -431,7 +504,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
trunking_enabled,
|
trunking_enabled,
|
||||||
).await;
|
).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.remove_session_metrics(&session_id_str);
|
||||||
metrics.active_sessions.dec();
|
metrics.active_sessions.dec();
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -201,11 +201,21 @@ impl RelayMetrics {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start an HTTP server serving GET /metrics and GET /mesh on the given port.
|
/// 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>) {
|
pub async fn serve_metrics(
|
||||||
use axum::{routing::get, Router};
|
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 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()
|
let app = Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/metrics",
|
"/metrics",
|
||||||
@@ -220,6 +230,92 @@ pub async fn serve_metrics(port: u16, metrics: Arc<RelayMetrics>) {
|
|||||||
let m = metrics_clone.clone();
|
let m = metrics_clone.clone();
|
||||||
async move { crate::probe::mesh_summary(m.registry()) }
|
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));
|
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
|
|||||||
333
crates/wzp-relay/src/presence.rs
Normal file
333
crates/wzp-relay/src/presence.rs
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,14 +156,19 @@ impl SlidingWindow {
|
|||||||
pub struct ProbeRunner {
|
pub struct ProbeRunner {
|
||||||
config: ProbeConfig,
|
config: ProbeConfig,
|
||||||
metrics: ProbeMetrics,
|
metrics: ProbeMetrics,
|
||||||
|
presence: Option<Arc<tokio::sync::Mutex<crate::presence::PresenceRegistry>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProbeRunner {
|
impl ProbeRunner {
|
||||||
/// Create a new probe runner, registering metrics with the given registry.
|
/// 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 target_str = config.target.to_string();
|
||||||
let metrics = ProbeMetrics::register(&target_str, registry);
|
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.
|
/// 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 jitter_gauge = self.metrics.jitter_ms.clone();
|
||||||
let up_gauge = self.metrics.up.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 {
|
let recv_handle = tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
match recv_transport.recv_signal().await {
|
match recv_transport.recv_signal().await {
|
||||||
@@ -230,8 +237,17 @@ impl ProbeRunner {
|
|||||||
loss_gauge.set(w.loss_pct());
|
loss_gauge.set(w.loss_pct());
|
||||||
jitter_gauge.set(w.jitter_ms());
|
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(_)) => {
|
Ok(Some(_)) => {
|
||||||
// Ignore non-Pong signals
|
// Ignore other signals
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
info!("probe recv: connection closed");
|
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 interval = tokio::time::interval(self.config.interval);
|
||||||
|
let mut ping_count: u64 = 0;
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
|
|
||||||
@@ -275,6 +292,24 @@ impl ProbeRunner {
|
|||||||
recv_handle.abort();
|
recv_handle.abort();
|
||||||
return Err(e.into());
|
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 {
|
impl ProbeMesh {
|
||||||
/// Create a new mesh coordinator, registering metrics for every target.
|
/// 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
|
let runners = targets
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|addr| {
|
.map(|addr| {
|
||||||
let config = ProbeConfig::new(addr);
|
let config = ProbeConfig::new(addr);
|
||||||
ProbeRunner::new(config, registry)
|
ProbeRunner::new(config, registry, presence.clone())
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Self { runners }
|
Self { runners }
|
||||||
@@ -409,6 +448,7 @@ mod tests {
|
|||||||
fn probe_metrics_register() {
|
fn probe_metrics_register() {
|
||||||
let registry = Registry::new();
|
let registry = Registry::new();
|
||||||
let _metrics = ProbeMetrics::register("127.0.0.1:4433", ®istry);
|
let _metrics = ProbeMetrics::register("127.0.0.1:4433", ®istry);
|
||||||
|
// (ProbeRunner::new signature changed but this test only checks ProbeMetrics)
|
||||||
|
|
||||||
let encoder = prometheus::TextEncoder::new();
|
let encoder = prometheus::TextEncoder::new();
|
||||||
let families = registry.gather();
|
let families = registry.gather();
|
||||||
@@ -526,7 +566,7 @@ mod tests {
|
|||||||
"127.0.0.2:4433".parse().unwrap(),
|
"127.0.0.2:4433".parse().unwrap(),
|
||||||
"127.0.0.3:4433".parse().unwrap(),
|
"127.0.0.3:4433".parse().unwrap(),
|
||||||
];
|
];
|
||||||
let mesh = ProbeMesh::new(targets, ®istry);
|
let mesh = ProbeMesh::new(targets, ®istry, None);
|
||||||
assert_eq!(mesh.target_count(), 3);
|
assert_eq!(mesh.target_count(), 3);
|
||||||
|
|
||||||
// Verify metrics were registered for each target
|
// Verify metrics were registered for each target
|
||||||
@@ -586,7 +626,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn mesh_zero_targets() {
|
fn mesh_zero_targets() {
|
||||||
let registry = Registry::new();
|
let registry = Registry::new();
|
||||||
let mesh = ProbeMesh::new(vec![], ®istry);
|
let mesh = ProbeMesh::new(vec![], ®istry, None);
|
||||||
assert_eq!(mesh.target_count(), 0);
|
assert_eq!(mesh.target_count(), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
483
crates/wzp-relay/src/relay_link.rs
Normal file
483
crates/wzp-relay/src/relay_link.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
265
crates/wzp-relay/src/route.rs
Normal file
265
crates/wzp-relay/src/route.rs
Normal 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(®, "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(®, "deadbeef");
|
||||||
|
assert_eq!(route, Route::DirectPeer(peer));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_not_found() {
|
||||||
|
let resolver = make_resolver();
|
||||||
|
let reg = PresenceRegistry::new();
|
||||||
|
|
||||||
|
let route = resolver.resolve(®, "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(®, "local_fp", 3), Route::Local);
|
||||||
|
// Remote lookup works via multi-hop
|
||||||
|
assert_eq!(
|
||||||
|
resolver.resolve_multi_hop(®, "remote_fp", 3),
|
||||||
|
Route::DirectPeer(peer)
|
||||||
|
);
|
||||||
|
// Not-found works via multi-hop
|
||||||
|
assert_eq!(
|
||||||
|
resolver.resolve_multi_hop(®, "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);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
crates/wzp-wasm/Cargo.toml
Normal file
25
crates/wzp-wasm/Cargo.toml
Normal 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
692
crates/wzp-wasm/src/lib.rs
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@
|
|||||||
.container { text-align: center; max-width: 420px; padding: 2rem; }
|
.container { text-align: center; max-width: 420px; padding: 2rem; }
|
||||||
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #00d4ff; }
|
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #00d4ff; }
|
||||||
.subtitle { color: #888; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
.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 { 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 { 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; }
|
.room-input input:focus { outline: none; border-color: #00d4ff; }
|
||||||
@@ -31,15 +35,22 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>WarzonePhone</h1>
|
<h1>WarzonePhone <span class="variant-badge" id="variantBadge">PURE</span></h1>
|
||||||
<p class="subtitle">Lossy VoIP Protocol</p>
|
<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">
|
<div class="room-input">
|
||||||
<label for="room">Room</label>
|
<label for="room">Room</label>
|
||||||
<input type="text" id="room" placeholder="enter room name" value="">
|
<input type="text" id="room" placeholder="enter room name" value="">
|
||||||
</div>
|
</div>
|
||||||
<button id="callBtn" onclick="toggleCall()">Connect</button>
|
<button id="callBtn">Connect</button>
|
||||||
<div class="controls" id="controls" style="display:none;">
|
<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>
|
</div>
|
||||||
<button id="pttBtn">Hold to Talk</button>
|
<button id="pttBtn">Hold to Talk</button>
|
||||||
<div class="level"><div class="level-bar" id="levelBar"></div></div>
|
<div class="level"><div class="level-bar" id="levelBar"></div></div>
|
||||||
@@ -47,302 +58,126 @@
|
|||||||
<div class="stats" id="stats"></div>
|
<div class="stats" id="stats"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="js/wzp-core.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const SAMPLE_RATE = 48000;
|
// ---------------------------------------------------------------------------
|
||||||
const FRAME_SIZE = 960;
|
// Load the selected variant script dynamically
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
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
|
|
||||||
(function() {
|
(function() {
|
||||||
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
var variant = WZPCore.detectVariant();
|
||||||
if (path && path !== 'index.html') {
|
var scriptMap = {
|
||||||
document.getElementById('room').value = path;
|
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() {
|
var ui = WZPCore.initUI({
|
||||||
if (active) stopCall();
|
onConnect: function(room) {
|
||||||
else startCall();
|
doConnect(room);
|
||||||
}
|
},
|
||||||
|
onDisconnect: function() {
|
||||||
|
doDisconnect();
|
||||||
|
},
|
||||||
|
onTransmit: function(tx) {
|
||||||
|
transmitting = tx;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
async function startCall() {
|
async function doConnect(room) {
|
||||||
const btn = document.getElementById('callBtn');
|
WZPCore.updateStatus('Requesting microphone...');
|
||||||
const room = getRoom();
|
|
||||||
if (!room) { setStatus('Enter a room name'); return; }
|
|
||||||
|
|
||||||
btn.disabled = true;
|
var audioCtx;
|
||||||
setStatus('Requesting microphone...');
|
try {
|
||||||
|
audioCtx = await WZPCore.startAudioContext();
|
||||||
|
} catch (e) {
|
||||||
|
WZPCore.updateStatus('Audio init failed: ' + e.message);
|
||||||
|
ui.setConnected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
// Build WebSocket URL
|
||||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
audio: { sampleRate: SAMPLE_RATE, channelCount: 1, echoCancellation: true, noiseSuppression: true }
|
var wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(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);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch(e) {
|
|
||||||
setStatus('Mic access denied: ' + e.message);
|
|
||||||
btn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
try {
|
||||||
|
await client.connect();
|
||||||
// Connect WebSocket with room name
|
} catch (e) {
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
WZPCore.updateStatus('Connection failed: ' + e.message);
|
||||||
const wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
|
ui.setConnected(false);
|
||||||
setStatus('Connecting to room: ' + room + '...');
|
return;
|
||||||
|
|
||||||
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 = () => {
|
// Start audio capture and playback
|
||||||
if (active) {
|
try {
|
||||||
setStatus('Error — reconnecting...');
|
capture = await WZPCore.connectCapture(audioCtx, function(pcmBuffer) {
|
||||||
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
|
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;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopCall() {
|
ui.setConnected(true);
|
||||||
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() {
|
function doDisconnect() {
|
||||||
const source = audioCtx.createMediaStreamSource(mediaStream);
|
if (capture) { capture.stop(); capture = null; }
|
||||||
const hasWorklet = await loadWorkletModule();
|
if (playback) { playback.stop(); playback = null; }
|
||||||
|
if (client) { client.disconnect(); client = null; }
|
||||||
|
|
||||||
if (hasWorklet) {
|
var audioCtx = WZPCore.getAudioContext();
|
||||||
captureNode = new AudioWorkletNode(audioCtx, 'wzp-capture-processor');
|
if (audioCtx && audioCtx.state !== 'closed') {
|
||||||
captureNode.port.onmessage = (e) => {
|
audioCtx.close();
|
||||||
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);
|
WZPCore.updateStatus('');
|
||||||
const source = audioCtx.createBufferSource();
|
WZPCore.updateStats('');
|
||||||
source.buffer = buffer;
|
document.getElementById('levelBar').style.width = '0%';
|
||||||
source.connect(audioCtx.destination);
|
|
||||||
const now = audioCtx.currentTime;
|
ui.setConnected(false);
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
378
crates/wzp-web/static/js/wzp-core.js
Normal file
378
crates/wzp-web/static/js/wzp-core.js
Normal 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,
|
||||||
|
};
|
||||||
524
crates/wzp-web/static/js/wzp-full.js
Normal file
524
crates/wzp-web/static/js/wzp-full.js
Normal 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;
|
||||||
345
crates/wzp-web/static/js/wzp-hybrid.js
Normal file
345
crates/wzp-web/static/js/wzp-hybrid.js
Normal 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;
|
||||||
168
crates/wzp-web/static/js/wzp-pure.js
Normal file
168
crates/wzp-web/static/js/wzp-pure.js
Normal 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
2
crates/wzp-web/static/wasm/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
package.json
|
||||||
|
*.d.ts
|
||||||
556
crates/wzp-web/static/wasm/wzp_wasm.js
Normal file
556
crates/wzp-web/static/wasm/wzp_wasm.js
Normal 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 };
|
||||||
BIN
crates/wzp-web/static/wasm/wzp_wasm_bg.wasm
Normal file
BIN
crates/wzp-web/static/wasm/wzp_wasm_bg.wasm
Normal file
Binary file not shown.
2
deps/featherchat
vendored
2
deps/featherchat
vendored
Submodule deps/featherchat updated: 4a4fa9fab4...5764719375
@@ -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.
|
||||||
|
|
||||||
```
|
## System Overview
|
||||||
Lossy / censored link
|
|
||||||
◄──────────────────────►
|
```mermaid
|
||||||
┌────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────┐
|
graph TB
|
||||||
│ Client │─QUIC─│ Relay A │─QUIC─│ Relay B │─QUIC─│ Destination │
|
subgraph "Client A (Browser/CLI)"
|
||||||
└────────┘ └─────────┘ └─────────┘ └─────────────┘
|
MIC[Microphone] --> DN[NoiseSupressor<br/>RNNoise ML]
|
||||||
│ │ │ │
|
DN --> SD[SilenceDetector<br/>VAD + Hangover]
|
||||||
Encode Forward Forward Decode
|
SD --> ENC[CallEncoder<br/>Opus/Codec2]
|
||||||
FEC FEC FEC FEC
|
ENC --> FEC_E[FEC Encoder<br/>RaptorQ]
|
||||||
Encrypt (opaque) (opaque) Decrypt
|
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]
|
||||||
|
|
||||||
```
|
RELAY[wzp-relay<br/>Relay Daemon]
|
||||||
┌──────────────────────────────────────────────┐
|
CLIENT[wzp-client<br/>CLI + Call Engine]
|
||||||
│ Application (Opus / Codec2 audio) │ wzp-codec
|
WEB[wzp-web<br/>Browser Bridge]
|
||||||
├──────────────────────────────────────────────┤
|
|
||||||
│ Redundancy (RaptorQ FEC + interleaving) │ wzp-fec
|
PROTO --> CODEC
|
||||||
├──────────────────────────────────────────────┤
|
PROTO --> FEC
|
||||||
│ Crypto (ChaCha20-Poly1305 + AEAD) │ wzp-crypto
|
PROTO --> CRYPTO
|
||||||
├──────────────────────────────────────────────┤
|
PROTO --> TRANSPORT
|
||||||
│ Transport (QUIC DATAGRAM + reliable stream) │ wzp-transport
|
|
||||||
├──────────────────────────────────────────────┤
|
CODEC --> CLIENT
|
||||||
│ Obfuscation (Phase 2 — trait defined) │ wzp-proto::ObfuscationLayer
|
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 Formats
|
||||||
|
|
||||||
## Wire Format
|
|
||||||
|
|
||||||
### MediaHeader (12 bytes)
|
### MediaHeader (12 bytes)
|
||||||
|
|
||||||
```
|
```
|
||||||
Byte 0: [V:1][T:1][CodecID:4][Q:1][FecRatioHi:1]
|
Byte 0: [V:1][T:1][CodecID:4][Q:1][FecHi:1]
|
||||||
Byte 1: [FecRatioLo:6][unused:2]
|
Byte 1: [FecLo:6][unused:2]
|
||||||
Byte 2-3: Sequence number (big-endian u16)
|
Bytes 2-3: sequence (u16 BE)
|
||||||
Byte 4-7: Timestamp in ms since session start (big-endian u32)
|
Bytes 4-7: timestamp_ms (u32 BE)
|
||||||
Byte 8: FEC block ID (wrapping u8)
|
Byte 8: fec_block_id (u8)
|
||||||
Byte 9: FEC symbol index within block
|
Byte 9: fec_symbol_idx (u8)
|
||||||
Byte 10: Reserved / flags
|
Byte 10: reserved
|
||||||
Byte 11: CSRC count (for future mixing)
|
Byte 11: csrc_count
|
||||||
|
|
||||||
|
V = version (0), T = is_repair, CodecID = codec, Q = quality_report appended
|
||||||
```
|
```
|
||||||
|
|
||||||
Field details:
|
### MiniHeader (4 bytes, compressed)
|
||||||
|
|
||||||
| 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.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Byte 0: loss_pct — 0-255 maps to 0-100% loss
|
Bytes 0-1: timestamp_delta_ms (u16 BE)
|
||||||
Byte 1: rtt_4ms — RTT in 4ms units (0-255 = 0-1020ms)
|
Bytes 2-3: payload_len (u16 BE)
|
||||||
Byte 2: jitter_ms — Jitter in milliseconds
|
|
||||||
Byte 3: bitrate_cap — Max receive bitrate in kbps
|
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`.
|
### TrunkFrame (batched datagrams)
|
||||||
|
|
||||||
### MediaPacket
|
|
||||||
|
|
||||||
A complete media packet on the wire:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
[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`.
|
### QualityReport (4 bytes, optional)
|
||||||
|
|
||||||
### 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)
|
|
||||||
|
|
||||||
```
|
```
|
||||||
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)`
|
### SignalMessage (JSON over reliable QUIC stream)
|
||||||
- `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`:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Idle -> Connecting -> Handshaking -> Active <-> Rekeying -> Active
|
[4-byte length prefix][serde_json payload]
|
||||||
|
|
|
||||||
Closed
|
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
|
## Quality Profiles
|
||||||
- Any state can transition to `Closed` via `Terminate` or `ConnectionLost`
|
|
||||||
- Invalid transitions produce a `TransitionError`
|
```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
|
## 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
|
subgraph "Forward Mode with --remote"
|
||||||
- When a participant sends a packet, the relay forwards it to all other participants
|
C4[Client] -->|QUIC| RA[Relay A]
|
||||||
- No transcoding -- packets are forwarded opaquely
|
RA -->|FEC decode then jitter then FEC encode| RB[Relay B]
|
||||||
- Rooms are auto-created when the first participant joins and auto-deleted when empty
|
RB -->|QUIC| C5[Client]
|
||||||
- Managed by `RoomManager` in `crates/wzp-relay/src/room.rs`
|
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
|
style RM fill:#ff9f43
|
||||||
- Two-pipeline architecture: upstream (client->remote) and downstream (remote->client)
|
style R1 fill:#fdcb6e
|
||||||
- Each direction has its own `RelayPipeline` with FEC decode/encode and jitter buffering
|
style PM fill:#0984e3
|
||||||
- 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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`
|
W->>R: QUIC connect (SNI = hashed room name)
|
||||||
- Idle timeout: 30 seconds
|
Note over W,R: AuthToken then Handshake then Join Room
|
||||||
- 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)
|
|
||||||
|
|
||||||
### 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)
|
loop Incoming audio
|
||||||
- **Reliable signaling**: QUIC bidirectional streams with length-prefixed JSON framing
|
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
|
```mermaid
|
||||||
- **RTT**: EWMA-smoothed round-trip time (alpha=0.1)
|
graph LR
|
||||||
- **Jitter**: EWMA of RTT variance (|current_rtt - previous_rtt|)
|
subgraph "Encoder"
|
||||||
- **Bandwidth**: Estimated from bytes received over elapsed time
|
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 |
|
subgraph "Decoder"
|
||||||
|-------|------------|----------------|---------|----------|
|
RCV --> DEINT[De-interleaver]
|
||||||
| Opus24k | 48 kHz | 20ms (960 samples) | 24 kbps | Good conditions |
|
DEINT --> RAPTORQ[RaptorQ Decoder<br/>Reconstruct from<br/>any K of K+R symbols]
|
||||||
| Opus16k | 48 kHz | 20ms | 16 kbps | Moderate conditions |
|
RAPTORQ --> OUT[Original Frames]
|
||||||
| Opus6k | 48 kHz | 40ms (1920 samples) | 6 kbps | Degraded conditions |
|
end
|
||||||
| Codec2_3200 | 8 kHz | 20ms (160 samples) | 3.2 kbps | Poor conditions |
|
|
||||||
| Codec2_1200 | 8 kHz | 40ms (320 samples) | 1.2 kbps | Catastrophic conditions |
|
|
||||||
|
|
||||||
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 |
|
||||||
|
|||||||
@@ -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-7. Missed call notifications — TODO (0.5d)
|
||||||
### WZP-FC-8. Cross-project identity verification — DONE (15 tests, 26dc848)
|
### WZP-FC-8. Cross-project identity verification — DONE (15 tests, 26dc848)
|
||||||
### WZP-FC-9. HKDF salt investigation — DONE (no mismatch)
|
### 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)
|
### FC-CRATE-1. Standalone warzone-protocol — DONE (v0.0.21, 4a4fa9f)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user