Compare commits
16 Commits
6f4e8eb9f6
...
feature/wz
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d33f3ed4e | ||
|
|
2de6e19956 | ||
|
|
ec437afbce | ||
|
|
137e7973c4 | ||
|
|
aa09275015 | ||
|
|
59bf3f6587 | ||
|
|
55d4004f86 | ||
|
|
09a18b086b | ||
|
|
f3c8e11995 | ||
|
|
4fb15fe7a3 | ||
|
|
e595fe6591 | ||
|
|
326aa491cc | ||
|
|
464e95a4bd | ||
|
|
fd95167705 | ||
|
|
9e7fea7633 | ||
|
|
993cf9ab7f |
62
Cargo.lock
generated
62
Cargo.lock
generated
@@ -169,6 +169,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core 0.4.5",
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
@@ -184,8 +185,10 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.24.0",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -220,7 +223,7 @@ dependencies = [
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-tungstenite 0.28.0",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -380,6 +383,12 @@ version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -3140,6 +3149,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite 0.24.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.28.0"
|
||||
@@ -3149,7 +3170,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
"tungstenite 0.28.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3366,6 +3387,24 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.8.5",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.28.0"
|
||||
@@ -3507,7 +3546,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-protocol"
|
||||
version = "0.0.21"
|
||||
version = "0.0.38"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bincode",
|
||||
@@ -4228,6 +4267,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"axum 0.7.9",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"prometheus",
|
||||
"quinn",
|
||||
"reqwest",
|
||||
@@ -4236,6 +4276,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"wzp-client",
|
||||
@@ -4261,6 +4302,21 @@ dependencies = [
|
||||
"wzp-proto",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wzp-wasm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chacha20poly1305",
|
||||
"getrandom 0.2.17",
|
||||
"hkdf",
|
||||
"js-sys",
|
||||
"rand 0.8.5",
|
||||
"raptorq",
|
||||
"sha2",
|
||||
"wasm-bindgen",
|
||||
"x25519-dalek",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wzp-web"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -9,6 +9,7 @@ members = [
|
||||
"crates/wzp-relay",
|
||||
"crates/wzp-client",
|
||||
"crates/wzp-web",
|
||||
"crates/wzp-wasm",
|
||||
]
|
||||
|
||||
[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::Transfer { .. } => CallSignalType::Transfer,
|
||||
SignalMessage::TransferAck => CallSignalType::Offer, // reuse
|
||||
SignalMessage::PresenceUpdate { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::RouteQuery { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::RouteResponse { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
//! - Fingerprint = SHA-256(Ed25519 pub)[:16]
|
||||
|
||||
pub mod bandwidth;
|
||||
pub mod codec_id;
|
||||
pub mod error;
|
||||
pub mod jitter;
|
||||
@@ -27,6 +28,7 @@ pub use packet::{
|
||||
HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader, QualityReport,
|
||||
SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, FRAME_TYPE_MINI,
|
||||
};
|
||||
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
||||
pub use quality::{AdaptiveQualityController, Tier};
|
||||
pub use session::{Session, SessionEvent, SessionState};
|
||||
pub use traits::*;
|
||||
|
||||
@@ -46,6 +46,23 @@ impl MediaHeader {
|
||||
/// Header size in bytes on the wire.
|
||||
pub const WIRE_SIZE: usize = 12;
|
||||
|
||||
/// Create a default header for raw PCM relay (used by WebSocket bridge).
|
||||
pub fn default_pcm() -> Self {
|
||||
Self {
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
codec_id: CodecId::Opus24k,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: 0,
|
||||
seq: 0,
|
||||
timestamp: 0,
|
||||
fec_block: 0,
|
||||
fec_symbol: 0,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode the FEC ratio float (0.0-2.0+) to a 7-bit value (0-127).
|
||||
pub fn encode_fec_ratio(ratio: f32) -> u8 {
|
||||
// Map 0.0-2.0 to 0-127, clamping at 127
|
||||
@@ -591,6 +608,43 @@ pub enum SignalMessage {
|
||||
},
|
||||
/// Acknowledge a transfer request.
|
||||
TransferAck,
|
||||
|
||||
/// Presence update from a peer relay (gossip protocol).
|
||||
/// Sent periodically over probe connections to share which fingerprints
|
||||
/// are connected to the sending relay.
|
||||
PresenceUpdate {
|
||||
/// Fingerprints currently connected to the sending relay.
|
||||
fingerprints: Vec<String>,
|
||||
/// Address of the sending relay (e.g., "192.168.1.10:4433").
|
||||
relay_addr: String,
|
||||
},
|
||||
|
||||
/// Ask a peer relay to look up a fingerprint in its registry.
|
||||
RouteQuery {
|
||||
fingerprint: String,
|
||||
ttl: u8,
|
||||
},
|
||||
/// Response to a route query.
|
||||
RouteResponse {
|
||||
fingerprint: String,
|
||||
found: bool,
|
||||
relay_chain: Vec<String>,
|
||||
},
|
||||
|
||||
/// Request to set up a forwarding session for a specific fingerprint.
|
||||
/// Sent over a relay link (`_relay` SNI) to ask the peer relay to
|
||||
/// create a room and forward media for the given session.
|
||||
SessionForward {
|
||||
session_id: String,
|
||||
target_fingerprint: String,
|
||||
source_relay: String,
|
||||
},
|
||||
/// Confirm that the forwarding session has been set up on the peer relay.
|
||||
/// The `room_name` tells the source relay which room to address media to.
|
||||
SessionForwardAck {
|
||||
session_id: String,
|
||||
room_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Reasons for ending a call.
|
||||
@@ -776,6 +830,40 @@ mod tests {
|
||||
assert!(matches!(decoded, SignalMessage::TransferAck));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_update_signal_roundtrip() {
|
||||
let msg = SignalMessage::PresenceUpdate {
|
||||
fingerprints: vec!["aabb".to_string(), "ccdd".to_string()],
|
||||
relay_addr: "10.0.0.1:4433".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::PresenceUpdate { fingerprints, relay_addr } => {
|
||||
assert_eq!(fingerprints.len(), 2);
|
||||
assert!(fingerprints.contains(&"aabb".to_string()));
|
||||
assert!(fingerprints.contains(&"ccdd".to_string()));
|
||||
assert_eq!(relay_addr, "10.0.0.1:4433");
|
||||
}
|
||||
_ => panic!("expected PresenceUpdate variant"),
|
||||
}
|
||||
|
||||
// Empty fingerprints list
|
||||
let msg_empty = SignalMessage::PresenceUpdate {
|
||||
fingerprints: vec![],
|
||||
relay_addr: "10.0.0.2:4433".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&msg_empty).unwrap();
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::PresenceUpdate { fingerprints, relay_addr } => {
|
||||
assert!(fingerprints.is_empty());
|
||||
assert_eq!(relay_addr, "10.0.0.2:4433");
|
||||
}
|
||||
_ => panic!("expected PresenceUpdate variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fec_ratio_encode_decode() {
|
||||
let ratio = 0.5;
|
||||
|
||||
@@ -25,7 +25,9 @@ serde_json = "1"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
quinn = { workspace = true }
|
||||
prometheus = "0.13"
|
||||
axum = { version = "0.7", default-features = false, features = ["tokio", "http1"] }
|
||||
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "ws"] }
|
||||
tower-http = { version = "0.6", features = ["fs"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
[[bin]]
|
||||
name = "wzp-relay"
|
||||
|
||||
@@ -39,6 +39,11 @@ pub struct RelayConfig {
|
||||
/// reducing per-packet QUIC datagram overhead.
|
||||
#[serde(default)]
|
||||
pub trunking_enabled: bool,
|
||||
/// Port for the WebSocket listener (browser clients connect here).
|
||||
/// If None, WebSocket support is disabled.
|
||||
pub ws_port: Option<u16>,
|
||||
/// Directory to serve static files from (HTML/JS/WASM for web clients).
|
||||
pub static_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for RelayConfig {
|
||||
@@ -55,6 +60,8 @@ impl Default for RelayConfig {
|
||||
probe_targets: Vec::new(),
|
||||
probe_mesh: false,
|
||||
trunking_enabled: false,
|
||||
ws_port: None,
|
||||
static_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,14 @@ pub mod config;
|
||||
pub mod handshake;
|
||||
pub mod metrics;
|
||||
pub mod pipeline;
|
||||
pub mod presence;
|
||||
pub mod probe;
|
||||
pub mod relay_link;
|
||||
pub mod room;
|
||||
pub mod route;
|
||||
pub mod session_mgr;
|
||||
pub mod trunk;
|
||||
pub mod ws;
|
||||
|
||||
pub use config::RelayConfig;
|
||||
pub use handshake::accept_handshake;
|
||||
|
||||
@@ -19,6 +19,7 @@ use wzp_proto::MediaTransport;
|
||||
use wzp_relay::config::RelayConfig;
|
||||
use wzp_relay::metrics::RelayMetrics;
|
||||
use wzp_relay::pipeline::{PipelineConfig, RelayPipeline};
|
||||
use wzp_relay::presence::PresenceRegistry;
|
||||
use wzp_relay::room::{self, RoomManager};
|
||||
use wzp_relay::session_mgr::SessionManager;
|
||||
|
||||
@@ -67,6 +68,19 @@ fn parse_args() -> RelayConfig {
|
||||
"--trunking" => {
|
||||
config.trunking_enabled = true;
|
||||
}
|
||||
"--ws-port" => {
|
||||
i += 1;
|
||||
config.ws_port = Some(
|
||||
args.get(i).expect("--ws-port requires a port number")
|
||||
.parse().expect("invalid --ws-port number"),
|
||||
);
|
||||
}
|
||||
"--static-dir" => {
|
||||
i += 1;
|
||||
config.static_dir = Some(
|
||||
args.get(i).expect("--static-dir requires a directory path").to_string(),
|
||||
);
|
||||
}
|
||||
"--mesh-status" => {
|
||||
// Print mesh table from a fresh registry and exit.
|
||||
// In practice this is useful after the relay has been running;
|
||||
@@ -88,6 +102,8 @@ fn parse_args() -> RelayConfig {
|
||||
eprintln!(" --probe-mesh Enable mesh mode (mark config flag, probes all --probe targets).");
|
||||
eprintln!(" --mesh-status Print mesh health table and exit (diagnostic).");
|
||||
eprintln!(" --trunking Enable trunk batching for outgoing media in room mode.");
|
||||
eprintln!(" --ws-port <port> WebSocket listener port for browser clients (e.g., 8080).");
|
||||
eprintln!(" --static-dir <dir> Directory to serve static files from (HTML/JS/WASM).");
|
||||
eprintln!();
|
||||
eprintln!("Room mode (default):");
|
||||
eprintln!(" Clients join rooms by name. Packets forwarded to all others (SFU).");
|
||||
@@ -176,11 +192,19 @@ async fn main() -> anyhow::Result<()> {
|
||||
.install_default()
|
||||
.expect("failed to install rustls crypto provider");
|
||||
|
||||
// Presence registry
|
||||
let presence = Arc::new(Mutex::new(PresenceRegistry::new()));
|
||||
|
||||
// Route resolver
|
||||
let route_resolver = Arc::new(wzp_relay::route::RouteResolver::new(config.listen_addr));
|
||||
|
||||
// Prometheus metrics
|
||||
let metrics = Arc::new(RelayMetrics::new());
|
||||
if let Some(port) = config.metrics_port {
|
||||
let m = metrics.clone();
|
||||
tokio::spawn(wzp_relay::metrics::serve_metrics(port, m));
|
||||
let p = Some(presence.clone());
|
||||
let rr = Some(route_resolver.clone());
|
||||
tokio::spawn(wzp_relay::metrics::serve_metrics(port, m, p, rr));
|
||||
}
|
||||
|
||||
// Generate ephemeral relay identity for crypto handshake
|
||||
@@ -214,6 +238,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let mesh = wzp_relay::probe::ProbeMesh::new(
|
||||
config.probe_targets.clone(),
|
||||
metrics.registry(),
|
||||
Some(presence.clone()),
|
||||
);
|
||||
info!(
|
||||
targets = mesh.target_count(),
|
||||
@@ -223,6 +248,20 @@ async fn main() -> anyhow::Result<()> {
|
||||
tokio::spawn(async move { mesh.run_all().await });
|
||||
}
|
||||
|
||||
// WebSocket server for browser clients
|
||||
if let Some(ws_port) = config.ws_port {
|
||||
let ws_state = wzp_relay::ws::WsState {
|
||||
room_mgr: room_mgr.clone(),
|
||||
session_mgr: session_mgr.clone(),
|
||||
auth_url: config.auth_url.clone(),
|
||||
metrics: metrics.clone(),
|
||||
presence: presence.clone(),
|
||||
};
|
||||
let static_dir = config.static_dir.clone();
|
||||
tokio::spawn(wzp_relay::ws::run_ws_server(ws_port, ws_state, static_dir));
|
||||
info!(ws_port, "WebSocket listener enabled for browser clients");
|
||||
}
|
||||
|
||||
if let Some(ref url) = config.auth_url {
|
||||
info!(url, "auth enabled — clients must present featherChat token");
|
||||
} else {
|
||||
@@ -244,6 +283,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
let relay_seed_bytes = relay_seed.0;
|
||||
let metrics = metrics.clone();
|
||||
let trunking_enabled = config.trunking_enabled;
|
||||
let presence = presence.clone();
|
||||
let route_resolver = route_resolver.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let addr = connection.remote_address();
|
||||
@@ -259,9 +300,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
||||
|
||||
// Probe connections use SNI "_probe" to identify themselves.
|
||||
// They skip auth + handshake and just do Ping->Pong.
|
||||
// They skip auth + handshake and just do Ping->Pong + presence gossip.
|
||||
if room_name == "_probe" {
|
||||
info!(%addr, "probe connection detected, entering Ping/Pong responder");
|
||||
info!(%addr, "probe connection detected, entering Ping/Pong + presence responder");
|
||||
loop {
|
||||
match transport.recv_signal().await {
|
||||
Ok(Some(wzp_proto::SignalMessage::Ping { timestamp_ms })) => {
|
||||
@@ -272,8 +313,63 @@ async fn main() -> anyhow::Result<()> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Some(wzp_proto::SignalMessage::PresenceUpdate { fingerprints, relay_addr })) => {
|
||||
// A peer relay is telling us which fingerprints it has
|
||||
let peer_addr: std::net::SocketAddr = relay_addr.parse().unwrap_or(addr);
|
||||
let fps: std::collections::HashSet<String> = fingerprints.into_iter().collect();
|
||||
{
|
||||
let mut reg = presence.lock().await;
|
||||
reg.update_peer(peer_addr, fps);
|
||||
}
|
||||
// Reply with our own local fingerprints
|
||||
let local_fps: Vec<String> = {
|
||||
let reg = presence.lock().await;
|
||||
reg.local_fingerprints().into_iter().collect()
|
||||
};
|
||||
let reply = wzp_proto::SignalMessage::PresenceUpdate {
|
||||
fingerprints: local_fps,
|
||||
relay_addr: addr.to_string(),
|
||||
};
|
||||
if let Err(e) = transport.send_signal(&reply).await {
|
||||
error!(%addr, "presence reply send error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Some(wzp_proto::SignalMessage::RouteQuery { fingerprint, ttl })) => {
|
||||
// Look up the fingerprint in our local registry
|
||||
let reg = presence.lock().await;
|
||||
let route = route_resolver.resolve(®, &fingerprint);
|
||||
drop(reg);
|
||||
|
||||
let (found, relay_chain) = match route {
|
||||
wzp_relay::route::Route::Local => {
|
||||
(true, vec![route_resolver.local_addr().to_string()])
|
||||
}
|
||||
wzp_relay::route::Route::DirectPeer(peer_addr) => {
|
||||
(true, vec![route_resolver.local_addr().to_string(), peer_addr.to_string()])
|
||||
}
|
||||
_ => {
|
||||
// Not found locally; if ttl > 0 we could forward
|
||||
// to other peers (future multi-hop). For now, reply not found.
|
||||
if ttl > 0 {
|
||||
// TODO: forward RouteQuery to other peers with ttl-1
|
||||
}
|
||||
(false, vec![])
|
||||
}
|
||||
};
|
||||
|
||||
let reply = wzp_proto::SignalMessage::RouteResponse {
|
||||
fingerprint,
|
||||
found,
|
||||
relay_chain,
|
||||
};
|
||||
if let Err(e) = transport.send_signal(&reply).await {
|
||||
error!(%addr, "route response send error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Some(_)) => {
|
||||
// Ignore non-Ping signals on probe connections
|
||||
// Ignore other signals on probe connections
|
||||
}
|
||||
Ok(None) => {
|
||||
info!(%addr, "probe connection closed");
|
||||
@@ -352,6 +448,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
// Register in presence registry
|
||||
if let Some(ref fp) = authenticated_fp {
|
||||
let mut reg = presence.lock().await;
|
||||
reg.register_local(fp, None, Some(room_name.clone()));
|
||||
}
|
||||
|
||||
info!(%addr, room = %room_name, "client joining");
|
||||
|
||||
if let Some(remote) = remote_transport {
|
||||
@@ -400,7 +502,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let participant_id = {
|
||||
let mut mgr = room_mgr.lock().await;
|
||||
match mgr.join(&room_name, addr, transport.clone(), authenticated_fp.as_deref()) {
|
||||
match mgr.join(&room_name, addr, room::ParticipantSender::Quic(transport.clone()), authenticated_fp.as_deref()) {
|
||||
Ok(id) => {
|
||||
metrics.active_rooms.set(mgr.list().len() as i64);
|
||||
id
|
||||
@@ -431,7 +533,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
trunking_enabled,
|
||||
).await;
|
||||
|
||||
// Participant disconnected — clean up per-session metrics
|
||||
// Participant disconnected — clean up presence + per-session metrics
|
||||
if let Some(ref fp) = authenticated_fp {
|
||||
let mut reg = presence.lock().await;
|
||||
reg.unregister_local(fp);
|
||||
}
|
||||
metrics.remove_session_metrics(&session_id_str);
|
||||
metrics.active_sessions.dec();
|
||||
{
|
||||
|
||||
@@ -201,11 +201,21 @@ impl RelayMetrics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start an HTTP server serving GET /metrics and GET /mesh on the given port.
|
||||
pub async fn serve_metrics(port: u16, metrics: Arc<RelayMetrics>) {
|
||||
use axum::{routing::get, Router};
|
||||
/// Start an HTTP server serving GET /metrics, GET /mesh, presence, and route endpoints on the given port.
|
||||
pub async fn serve_metrics(
|
||||
port: u16,
|
||||
metrics: Arc<RelayMetrics>,
|
||||
presence: Option<Arc<tokio::sync::Mutex<crate::presence::PresenceRegistry>>>,
|
||||
route_resolver: Option<Arc<crate::route::RouteResolver>>,
|
||||
) {
|
||||
use axum::{extract::Path, routing::get, Router};
|
||||
|
||||
let metrics_clone = metrics.clone();
|
||||
let presence_all = presence.clone();
|
||||
let presence_lookup = presence.clone();
|
||||
let presence_peers = presence.clone();
|
||||
let presence_route = presence;
|
||||
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/metrics",
|
||||
@@ -220,6 +230,92 @@ pub async fn serve_metrics(port: u16, metrics: Arc<RelayMetrics>) {
|
||||
let m = metrics_clone.clone();
|
||||
async move { crate::probe::mesh_summary(m.registry()) }
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/presence",
|
||||
get(move || {
|
||||
let reg = presence_all.clone();
|
||||
async move {
|
||||
match reg {
|
||||
Some(r) => {
|
||||
let r = r.lock().await;
|
||||
let entries: Vec<serde_json::Value> = r.all_known().into_iter().map(|(fp, loc)| {
|
||||
serde_json::json!({ "fingerprint": fp, "location": loc })
|
||||
}).collect();
|
||||
serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
|
||||
}
|
||||
None => "[]".to_string(),
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/presence/:fingerprint",
|
||||
get(move |Path(fingerprint): Path<String>| {
|
||||
let reg = presence_lookup.clone();
|
||||
async move {
|
||||
match reg {
|
||||
Some(r) => {
|
||||
let r = r.lock().await;
|
||||
match r.lookup(&fingerprint) {
|
||||
Some(loc) => serde_json::to_string_pretty(
|
||||
&serde_json::json!({ "fingerprint": fingerprint, "location": loc })
|
||||
).unwrap_or_else(|_| "{}".to_string()),
|
||||
None => serde_json::json!({ "fingerprint": fingerprint, "location": null }).to_string(),
|
||||
}
|
||||
}
|
||||
None => serde_json::json!({ "fingerprint": fingerprint, "location": null }).to_string(),
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/peers",
|
||||
get(move || {
|
||||
let reg = presence_peers.clone();
|
||||
async move {
|
||||
match reg {
|
||||
Some(r) => {
|
||||
let r = r.lock().await;
|
||||
let peers: Vec<serde_json::Value> = r.peers().iter().map(|(addr, peer)| {
|
||||
serde_json::json!({
|
||||
"addr": addr.to_string(),
|
||||
"fingerprints": peer.fingerprints.iter().collect::<Vec<_>>(),
|
||||
"rtt_ms": peer.rtt_ms,
|
||||
})
|
||||
}).collect();
|
||||
serde_json::to_string_pretty(&peers).unwrap_or_else(|_| "[]".to_string())
|
||||
}
|
||||
None => "[]".to_string(),
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/route/:fingerprint",
|
||||
get(move |Path(fingerprint): Path<String>| {
|
||||
let reg = presence_route.clone();
|
||||
let resolver = route_resolver.clone();
|
||||
async move {
|
||||
match (reg, resolver) {
|
||||
(Some(r), Some(res)) => {
|
||||
let r = r.lock().await;
|
||||
let route = res.resolve(&r, &fingerprint);
|
||||
let json = res.route_json(&fingerprint, &route);
|
||||
serde_json::to_string_pretty(&json)
|
||||
.unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
_ => {
|
||||
serde_json::json!({
|
||||
"fingerprint": fingerprint,
|
||||
"route": "not_found",
|
||||
"relay_chain": [],
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
|
||||
|
||||
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 {
|
||||
config: ProbeConfig,
|
||||
metrics: ProbeMetrics,
|
||||
presence: Option<Arc<tokio::sync::Mutex<crate::presence::PresenceRegistry>>>,
|
||||
}
|
||||
|
||||
impl ProbeRunner {
|
||||
/// Create a new probe runner, registering metrics with the given registry.
|
||||
pub fn new(config: ProbeConfig, registry: &Registry) -> Self {
|
||||
pub fn new(
|
||||
config: ProbeConfig,
|
||||
registry: &Registry,
|
||||
presence: Option<Arc<tokio::sync::Mutex<crate::presence::PresenceRegistry>>>,
|
||||
) -> Self {
|
||||
let target_str = config.target.to_string();
|
||||
let metrics = ProbeMetrics::register(&target_str, registry);
|
||||
Self { config, metrics }
|
||||
Self { config, metrics, presence }
|
||||
}
|
||||
|
||||
/// Run the probe forever. This function never returns under normal operation.
|
||||
@@ -215,6 +220,8 @@ impl ProbeRunner {
|
||||
let jitter_gauge = self.metrics.jitter_ms.clone();
|
||||
let up_gauge = self.metrics.up.clone();
|
||||
|
||||
let recv_presence = self.presence.clone();
|
||||
let recv_target = self.config.target;
|
||||
let recv_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
match recv_transport.recv_signal().await {
|
||||
@@ -230,8 +237,17 @@ impl ProbeRunner {
|
||||
loss_gauge.set(w.loss_pct());
|
||||
jitter_gauge.set(w.jitter_ms());
|
||||
}
|
||||
Ok(Some(SignalMessage::PresenceUpdate { fingerprints, relay_addr })) => {
|
||||
if let Some(ref reg) = recv_presence {
|
||||
// Parse the relay_addr; fall back to the connection target
|
||||
let addr = relay_addr.parse().unwrap_or(recv_target);
|
||||
let fps: std::collections::HashSet<String> = fingerprints.into_iter().collect();
|
||||
let mut r = reg.lock().await;
|
||||
r.update_peer(addr, fps);
|
||||
}
|
||||
}
|
||||
Ok(Some(_)) => {
|
||||
// Ignore non-Pong signals
|
||||
// Ignore other signals
|
||||
}
|
||||
Ok(None) => {
|
||||
info!("probe recv: connection closed");
|
||||
@@ -247,8 +263,9 @@ impl ProbeRunner {
|
||||
}
|
||||
});
|
||||
|
||||
// Send ping loop
|
||||
// Send ping loop (+ presence gossip every 10 pings)
|
||||
let mut interval = tokio::time::interval(self.config.interval);
|
||||
let mut ping_count: u64 = 0;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
@@ -275,6 +292,24 @@ impl ProbeRunner {
|
||||
recv_handle.abort();
|
||||
return Err(e.into());
|
||||
}
|
||||
|
||||
// Send presence update every 10 pings (~10 seconds)
|
||||
ping_count += 1;
|
||||
if ping_count % 10 == 0 {
|
||||
if let Some(ref reg) = self.presence {
|
||||
let fps: Vec<String> = {
|
||||
let r = reg.lock().await;
|
||||
r.local_fingerprints().into_iter().collect()
|
||||
};
|
||||
let msg = SignalMessage::PresenceUpdate {
|
||||
fingerprints: fps,
|
||||
relay_addr: self.config.target.to_string(),
|
||||
};
|
||||
if let Err(e) = transport.send_signal(&msg).await {
|
||||
warn!(target = %self.config.target, "presence update send error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,12 +324,16 @@ pub struct ProbeMesh {
|
||||
|
||||
impl ProbeMesh {
|
||||
/// Create a new mesh coordinator, registering metrics for every target.
|
||||
pub fn new(targets: Vec<SocketAddr>, registry: &Registry) -> Self {
|
||||
pub fn new(
|
||||
targets: Vec<SocketAddr>,
|
||||
registry: &Registry,
|
||||
presence: Option<Arc<tokio::sync::Mutex<crate::presence::PresenceRegistry>>>,
|
||||
) -> Self {
|
||||
let runners = targets
|
||||
.into_iter()
|
||||
.map(|addr| {
|
||||
let config = ProbeConfig::new(addr);
|
||||
ProbeRunner::new(config, registry)
|
||||
ProbeRunner::new(config, registry, presence.clone())
|
||||
})
|
||||
.collect();
|
||||
Self { runners }
|
||||
@@ -409,6 +448,7 @@ mod tests {
|
||||
fn probe_metrics_register() {
|
||||
let registry = Registry::new();
|
||||
let _metrics = ProbeMetrics::register("127.0.0.1:4433", ®istry);
|
||||
// (ProbeRunner::new signature changed but this test only checks ProbeMetrics)
|
||||
|
||||
let encoder = prometheus::TextEncoder::new();
|
||||
let families = registry.gather();
|
||||
@@ -526,7 +566,7 @@ mod tests {
|
||||
"127.0.0.2:4433".parse().unwrap(),
|
||||
"127.0.0.3:4433".parse().unwrap(),
|
||||
];
|
||||
let mesh = ProbeMesh::new(targets, ®istry);
|
||||
let mesh = ProbeMesh::new(targets, ®istry, None);
|
||||
assert_eq!(mesh.target_count(), 3);
|
||||
|
||||
// Verify metrics were registered for each target
|
||||
@@ -586,7 +626,7 @@ mod tests {
|
||||
#[test]
|
||||
fn mesh_zero_targets() {
|
||||
let registry = Registry::new();
|
||||
let mesh = ProbeMesh::new(vec![], ®istry);
|
||||
let mesh = ProbeMesh::new(vec![], ®istry, None);
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -27,11 +27,51 @@ fn next_id() -> ParticipantId {
|
||||
NEXT_PARTICIPANT_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// How to send data to a participant — either via QUIC transport or WebSocket channel.
|
||||
#[derive(Clone)]
|
||||
pub enum ParticipantSender {
|
||||
Quic(Arc<wzp_transport::QuinnTransport>),
|
||||
WebSocket(tokio::sync::mpsc::Sender<Bytes>),
|
||||
}
|
||||
|
||||
impl ParticipantSender {
|
||||
/// Send raw bytes to this participant.
|
||||
pub async fn send_raw(&self, data: &[u8]) -> Result<(), String> {
|
||||
match self {
|
||||
ParticipantSender::WebSocket(tx) => {
|
||||
tx.try_send(Bytes::copy_from_slice(data))
|
||||
.map_err(|e| format!("ws send: {e}"))
|
||||
}
|
||||
ParticipantSender::Quic(transport) => {
|
||||
let pkt = wzp_proto::MediaPacket {
|
||||
header: wzp_proto::packet::MediaHeader::default_pcm(),
|
||||
payload: Bytes::copy_from_slice(data),
|
||||
quality_report: None,
|
||||
};
|
||||
transport.send_media(&pkt).await.map_err(|e| format!("quic send: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a QUIC participant.
|
||||
pub fn is_quic(&self) -> bool {
|
||||
matches!(self, ParticipantSender::Quic(_))
|
||||
}
|
||||
|
||||
/// Get the QUIC transport if this is a QUIC participant.
|
||||
pub fn as_quic(&self) -> Option<&Arc<wzp_transport::QuinnTransport>> {
|
||||
match self {
|
||||
ParticipantSender::Quic(t) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A participant in a room.
|
||||
struct Participant {
|
||||
id: ParticipantId,
|
||||
_addr: std::net::SocketAddr,
|
||||
transport: Arc<wzp_transport::QuinnTransport>,
|
||||
sender: ParticipantSender,
|
||||
}
|
||||
|
||||
/// A room holding multiple participants.
|
||||
@@ -46,10 +86,10 @@ impl Room {
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, addr: std::net::SocketAddr, transport: Arc<wzp_transport::QuinnTransport>) -> ParticipantId {
|
||||
fn add(&mut self, addr: std::net::SocketAddr, sender: ParticipantSender) -> ParticipantId {
|
||||
let id = next_id();
|
||||
info!(room_size = self.participants.len() + 1, participant = id, %addr, "joined room");
|
||||
self.participants.push(Participant { id, _addr: addr, transport });
|
||||
self.participants.push(Participant { id, _addr: addr, sender });
|
||||
id
|
||||
}
|
||||
|
||||
@@ -58,11 +98,11 @@ impl Room {
|
||||
info!(room_size = self.participants.len(), participant = id, "left room");
|
||||
}
|
||||
|
||||
fn others(&self, exclude_id: ParticipantId) -> Vec<Arc<wzp_transport::QuinnTransport>> {
|
||||
fn others(&self, exclude_id: ParticipantId) -> Vec<ParticipantSender> {
|
||||
self.participants
|
||||
.iter()
|
||||
.filter(|p| p.id != exclude_id)
|
||||
.map(|p| p.transport.clone())
|
||||
.map(|p| p.sender.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -130,7 +170,7 @@ impl RoomManager {
|
||||
&mut self,
|
||||
room_name: &str,
|
||||
addr: std::net::SocketAddr,
|
||||
transport: Arc<wzp_transport::QuinnTransport>,
|
||||
sender: ParticipantSender,
|
||||
fingerprint: Option<&str>,
|
||||
) -> Result<ParticipantId, String> {
|
||||
if !self.is_authorized(room_name, fingerprint) {
|
||||
@@ -138,7 +178,18 @@ impl RoomManager {
|
||||
return Err("not authorized for this room".to_string());
|
||||
}
|
||||
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
|
||||
Ok(room.add(addr, transport))
|
||||
Ok(room.add(addr, sender))
|
||||
}
|
||||
|
||||
/// Join a room via WebSocket. Convenience wrapper around `join()`.
|
||||
pub fn join_ws(
|
||||
&mut self,
|
||||
room_name: &str,
|
||||
addr: std::net::SocketAddr,
|
||||
sender: tokio::sync::mpsc::Sender<Bytes>,
|
||||
fingerprint: Option<&str>,
|
||||
) -> Result<ParticipantId, String> {
|
||||
self.join(room_name, addr, ParticipantSender::WebSocket(sender), fingerprint)
|
||||
}
|
||||
|
||||
/// Leave a room. Removes the room if empty.
|
||||
@@ -152,12 +203,12 @@ impl RoomManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get transports for all OTHER participants in a room.
|
||||
/// Get senders for all OTHER participants in a room.
|
||||
pub fn others(
|
||||
&self,
|
||||
room_name: &str,
|
||||
participant_id: ParticipantId,
|
||||
) -> Vec<Arc<wzp_transport::QuinnTransport>> {
|
||||
) -> Vec<ParticipantSender> {
|
||||
self.rooms
|
||||
.get(room_name)
|
||||
.map(|r| r.others(participant_id))
|
||||
@@ -305,10 +356,14 @@ async fn run_participant_plain(
|
||||
// Forward to all others
|
||||
let pkt_bytes = pkt.payload.len() as u64;
|
||||
for other in &others {
|
||||
// Best-effort: if one send fails, continue to others
|
||||
if let Err(e) = other.send_media(&pkt).await {
|
||||
// Don't log every failure — they'll be cleaned up when their recv loop breaks
|
||||
let _ = e;
|
||||
match other {
|
||||
ParticipantSender::Quic(t) => {
|
||||
let _ = t.send_media(&pkt).await;
|
||||
}
|
||||
ParticipantSender::WebSocket(_) => {
|
||||
// WS clients receive raw payload bytes
|
||||
let _ = other.send_raw(&pkt.payload).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,14 +445,22 @@ async fn run_participant_trunked(
|
||||
|
||||
let pkt_bytes = pkt.payload.len() as u64;
|
||||
for other in &others {
|
||||
let peer_addr = other.connection().remote_address();
|
||||
match other {
|
||||
ParticipantSender::Quic(t) => {
|
||||
let peer_addr = t.connection().remote_address();
|
||||
let fwd = forwarders
|
||||
.entry(peer_addr)
|
||||
.or_insert_with(|| TrunkedForwarder::new(other.clone(), sid_bytes));
|
||||
.or_insert_with(|| TrunkedForwarder::new(t.clone(), sid_bytes));
|
||||
if let Err(e) = fwd.send(&pkt).await {
|
||||
let _ = e;
|
||||
}
|
||||
}
|
||||
ParticipantSender::WebSocket(_) => {
|
||||
// WS clients bypass trunking — send raw payload directly
|
||||
let _ = other.send_raw(&pkt.payload).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let fan_out = others.len() as u64;
|
||||
metrics.packets_forwarded.inc_by(fan_out);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
243
crates/wzp-relay/src/ws.rs
Normal file
243
crates/wzp-relay/src/ws.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
//! WebSocket transport for browser clients.
|
||||
//!
|
||||
//! Browsers connect via `GET /ws/{room}` → WebSocket upgrade.
|
||||
//! First message must be auth JSON (if auth is enabled).
|
||||
//! Subsequent messages are binary PCM frames forwarded to/from the room.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket},
|
||||
Path, State, WebSocketUpgrade,
|
||||
},
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tower_http::services::ServeDir;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::auth;
|
||||
use crate::metrics::RelayMetrics;
|
||||
use crate::presence::PresenceRegistry;
|
||||
use crate::room::RoomManager;
|
||||
use crate::session_mgr::SessionManager;
|
||||
|
||||
/// Shared state for WebSocket handlers.
|
||||
#[derive(Clone)]
|
||||
pub struct WsState {
|
||||
pub room_mgr: Arc<Mutex<RoomManager>>,
|
||||
pub session_mgr: Arc<Mutex<SessionManager>>,
|
||||
pub auth_url: Option<String>,
|
||||
pub metrics: Arc<RelayMetrics>,
|
||||
pub presence: Arc<Mutex<PresenceRegistry>>,
|
||||
}
|
||||
|
||||
/// Start the WebSocket + static file server.
|
||||
pub async fn run_ws_server(port: u16, state: WsState, static_dir: Option<String>) {
|
||||
let mut app = Router::new()
|
||||
.route("/ws/{room}", get(ws_upgrade_handler))
|
||||
.with_state(state);
|
||||
|
||||
if let Some(dir) = static_dir {
|
||||
info!(dir = %dir, "serving static files");
|
||||
app = app.fallback_service(ServeDir::new(dir));
|
||||
}
|
||||
|
||||
let addr: SocketAddr = ([0, 0, 0, 0], port).into();
|
||||
info!(%addr, "WebSocket server listening");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.expect("failed to bind WS listener");
|
||||
axum::serve(listener, app).await.expect("WS server failed");
|
||||
}
|
||||
|
||||
async fn ws_upgrade_handler(
|
||||
Path(room): Path<String>,
|
||||
State(state): State<WsState>,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_ws_connection(socket, room, state))
|
||||
}
|
||||
|
||||
async fn handle_ws_connection(socket: WebSocket, room: String, state: WsState) {
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
|
||||
// 1. Auth: if auth_url is set, first message must be {"type":"auth","token":"..."}
|
||||
let fingerprint: Option<String> = if let Some(ref auth_url) = state.auth_url {
|
||||
match ws_rx.next().await {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
match serde_json::from_str::<serde_json::Value>(&text) {
|
||||
Ok(parsed) if parsed["type"] == "auth" => {
|
||||
if let Some(token) = parsed["token"].as_str() {
|
||||
match auth::validate_token(auth_url, token).await {
|
||||
Ok(client) => {
|
||||
state.metrics.auth_attempts.with_label_values(&["ok"]).inc();
|
||||
info!(fingerprint = %client.fingerprint, "WS authenticated");
|
||||
let _ = ws_tx
|
||||
.send(Message::Text(r#"{"type":"auth_ok"}"#.into()))
|
||||
.await;
|
||||
Some(client.fingerprint)
|
||||
}
|
||||
Err(e) => {
|
||||
state
|
||||
.metrics
|
||||
.auth_attempts
|
||||
.with_label_values(&["fail"])
|
||||
.inc();
|
||||
let _ = ws_tx
|
||||
.send(Message::Text(
|
||||
format!(r#"{{"type":"auth_error","error":"{e}"}}"#)
|
||||
.into(),
|
||||
))
|
||||
.await;
|
||||
warn!("WS auth failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("WS auth: missing token field");
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
warn!("WS: expected auth message as first frame");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
warn!("WS: connection closed before auth");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let _ = ws_tx
|
||||
.send(Message::Text(r#"{"type":"auth_ok"}"#.into()))
|
||||
.await;
|
||||
None
|
||||
};
|
||||
|
||||
// 2. Create mpsc channel for outbound frames (room → browser)
|
||||
let (tx, mut rx) = mpsc::channel::<Bytes>(64);
|
||||
|
||||
// 3. Create session
|
||||
let session_id = {
|
||||
let mut smgr = state.session_mgr.lock().await;
|
||||
match smgr.create_session(&room, fingerprint.clone()) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
error!(room = %room, "WS session rejected: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
state.metrics.active_sessions.inc();
|
||||
|
||||
// 4. Join room with WS sender
|
||||
let addr: SocketAddr = ([0, 0, 0, 0], 0).into();
|
||||
let participant_id = {
|
||||
let mut mgr = state.room_mgr.lock().await;
|
||||
match mgr.join_ws(&room, addr, tx, fingerprint.as_deref()) {
|
||||
Ok(id) => {
|
||||
state.metrics.active_rooms.set(mgr.list().len() as i64);
|
||||
id
|
||||
}
|
||||
Err(e) => {
|
||||
error!(room = %room, "WS room join denied: {e}");
|
||||
state.metrics.active_sessions.dec();
|
||||
let mut smgr = state.session_mgr.lock().await;
|
||||
smgr.remove_session(session_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 5. Register presence
|
||||
if let Some(ref fp) = fingerprint {
|
||||
let mut reg = state.presence.lock().await;
|
||||
reg.register_local(fp, None, Some(room.clone()));
|
||||
}
|
||||
|
||||
info!(room = %room, participant = participant_id, "WS client joined");
|
||||
|
||||
// 6. Outbound task: mpsc rx → WS binary frames
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Some(data) = rx.recv().await {
|
||||
if ws_tx
|
||||
.send(Message::Binary(data.to_vec().into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 7. Inbound: WS recv → fan-out to room
|
||||
loop {
|
||||
match ws_rx.next().await {
|
||||
Some(Ok(Message::Binary(data))) => {
|
||||
let others = {
|
||||
let mgr = state.room_mgr.lock().await;
|
||||
mgr.others(&room, participant_id)
|
||||
};
|
||||
for other in &others {
|
||||
let _ = other.send_raw(&data).await;
|
||||
}
|
||||
state
|
||||
.metrics
|
||||
.packets_forwarded
|
||||
.inc_by(others.len() as u64);
|
||||
state
|
||||
.metrics
|
||||
.bytes_forwarded
|
||||
.inc_by(data.len() as u64 * others.len() as u64);
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Cleanup
|
||||
send_task.abort();
|
||||
info!(room = %room, participant = participant_id, "WS client disconnected");
|
||||
|
||||
if let Some(ref fp) = fingerprint {
|
||||
let mut reg = state.presence.lock().await;
|
||||
reg.unregister_local(fp);
|
||||
}
|
||||
|
||||
{
|
||||
let mut mgr = state.room_mgr.lock().await;
|
||||
mgr.leave(&room, participant_id);
|
||||
state.metrics.active_rooms.set(mgr.list().len() as i64);
|
||||
}
|
||||
|
||||
let session_id_str: String = session_id.iter().map(|b| format!("{b:02x}")).collect();
|
||||
state.metrics.remove_session_metrics(&session_id_str);
|
||||
state.metrics.active_sessions.dec();
|
||||
|
||||
{
|
||||
let mut smgr = state.session_mgr.lock().await;
|
||||
smgr.remove_session(session_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ws_state_is_clone() {
|
||||
// WsState must be Clone for axum's State extractor
|
||||
fn assert_clone<T: Clone>() {}
|
||||
assert_clone::<WsState>();
|
||||
}
|
||||
}
|
||||
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; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #00d4ff; }
|
||||
.subtitle { color: #888; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
||||
.variant-badge { display: inline-block; background: #2a2a4a; border: 1px solid #444; color: #00d4ff; font-size: 0.65rem; padding: 0.15rem 0.5rem; border-radius: 4px; margin-left: 0.4rem; vertical-align: middle; font-family: monospace; letter-spacing: 0.05em; }
|
||||
.variant-selector { margin-bottom: 1.2rem; display: flex; gap: 0.8rem; justify-content: center; flex-wrap: wrap; }
|
||||
.variant-selector label { font-size: 0.75rem; color: #888; cursor: pointer; display: flex; align-items: center; gap: 0.25rem; }
|
||||
.variant-selector input[type="radio"] { accent-color: #00d4ff; }
|
||||
.room-input { margin-bottom: 1.5rem; }
|
||||
.room-input input { background: #2a2a4a; border: 1px solid #444; color: #e0e0e0; padding: 0.6rem 1rem; font-size: 1rem; border-radius: 8px; width: 200px; text-align: center; }
|
||||
.room-input input:focus { outline: none; border-color: #00d4ff; }
|
||||
@@ -31,15 +35,22 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>WarzonePhone</h1>
|
||||
<h1>WarzonePhone <span class="variant-badge" id="variantBadge">PURE</span></h1>
|
||||
<p class="subtitle">Lossy VoIP Protocol</p>
|
||||
|
||||
<div class="variant-selector">
|
||||
<label><input type="radio" name="variant" value="pure"> Pure JS</label>
|
||||
<label><input type="radio" name="variant" value="hybrid"> Hybrid</label>
|
||||
<label><input type="radio" name="variant" value="full"> Full WASM</label>
|
||||
</div>
|
||||
|
||||
<div class="room-input">
|
||||
<label for="room">Room</label>
|
||||
<input type="text" id="room" placeholder="enter room name" value="">
|
||||
</div>
|
||||
<button id="callBtn" onclick="toggleCall()">Connect</button>
|
||||
<button id="callBtn">Connect</button>
|
||||
<div class="controls" id="controls" style="display:none;">
|
||||
<label><input type="checkbox" id="pttMode" onchange="togglePTT()"> Radio mode (push-to-talk)</label>
|
||||
<label><input type="checkbox" id="pttMode"> Radio mode (push-to-talk)</label>
|
||||
</div>
|
||||
<button id="pttBtn">Hold to Talk</button>
|
||||
<div class="level"><div class="level-bar" id="levelBar"></div></div>
|
||||
@@ -47,302 +58,158 @@
|
||||
<div class="stats" id="stats"></div>
|
||||
</div>
|
||||
|
||||
<script src="js/wzp-core.js"></script>
|
||||
<script>
|
||||
const SAMPLE_RATE = 48000;
|
||||
const FRAME_SIZE = 960;
|
||||
|
||||
let ws = null;
|
||||
let audioCtx = null;
|
||||
let mediaStream = null;
|
||||
let captureNode = null;
|
||||
let playbackNode = null;
|
||||
let active = false;
|
||||
let transmitting = true; // in open-mic mode, always transmitting
|
||||
let pttMode = false;
|
||||
let framesSent = 0;
|
||||
let framesRecv = 0;
|
||||
let startTime = 0;
|
||||
let statsInterval = null;
|
||||
|
||||
// Use room from URL path or input field
|
||||
function getRoom() {
|
||||
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
||||
if (path && path !== 'index.html') return path;
|
||||
const hash = location.hash.replace('#', '');
|
||||
if (hash) return hash;
|
||||
return document.getElementById('room').value.trim() || 'default';
|
||||
}
|
||||
|
||||
// Pre-fill room input from URL on page load
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load the selected variant script dynamically
|
||||
// ---------------------------------------------------------------------------
|
||||
(function() {
|
||||
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
||||
if (path && path !== 'index.html') {
|
||||
document.getElementById('room').value = path;
|
||||
}
|
||||
var variant = WZPCore.detectVariant();
|
||||
var scriptMap = {
|
||||
pure: 'js/wzp-pure.js',
|
||||
hybrid: 'js/wzp-hybrid.js',
|
||||
full: 'js/wzp-full.js',
|
||||
'ws': 'js/wzp-ws.js',
|
||||
'ws-fec': 'js/wzp-ws-fec.js',
|
||||
'ws-full': 'js/wzp-ws-full.js',
|
||||
};
|
||||
var src = scriptMap[variant] || scriptMap.pure;
|
||||
var s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.onload = function() { wzpBoot(); };
|
||||
s.onerror = function() {
|
||||
WZPCore.updateStatus('Failed to load variant: ' + variant);
|
||||
};
|
||||
document.body.appendChild(s);
|
||||
})();
|
||||
|
||||
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
||||
function setStats(msg) { document.getElementById('stats').textContent = msg; }
|
||||
// ---------------------------------------------------------------------------
|
||||
// Boot: wire UI to the loaded client variant
|
||||
// ---------------------------------------------------------------------------
|
||||
function wzpBoot() {
|
||||
var client = null;
|
||||
var capture = null;
|
||||
var playback = null;
|
||||
var transmitting = true;
|
||||
|
||||
function toggleCall() {
|
||||
if (active) stopCall();
|
||||
else startCall();
|
||||
}
|
||||
|
||||
async function startCall() {
|
||||
const btn = document.getElementById('callBtn');
|
||||
const room = getRoom();
|
||||
if (!room) { setStatus('Enter a room name'); return; }
|
||||
|
||||
btn.disabled = true;
|
||||
setStatus('Requesting microphone...');
|
||||
|
||||
try {
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { sampleRate: SAMPLE_RATE, channelCount: 1, echoCancellation: true, noiseSuppression: true }
|
||||
var ui = WZPCore.initUI({
|
||||
onConnect: function(room) {
|
||||
doConnect(room);
|
||||
},
|
||||
onDisconnect: function() {
|
||||
doDisconnect();
|
||||
},
|
||||
onTransmit: function(tx) {
|
||||
transmitting = tx;
|
||||
},
|
||||
});
|
||||
} catch(e) {
|
||||
setStatus('Mic access denied: ' + e.message);
|
||||
btn.disabled = false;
|
||||
|
||||
async function doConnect(room) {
|
||||
WZPCore.updateStatus('Requesting microphone...');
|
||||
|
||||
var audioCtx;
|
||||
try {
|
||||
audioCtx = await WZPCore.startAudioContext();
|
||||
} catch (e) {
|
||||
WZPCore.updateStatus('Audio init failed: ' + e.message);
|
||||
ui.setConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||
// Build WebSocket URL
|
||||
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
var wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
|
||||
|
||||
// Connect WebSocket with room name
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
|
||||
setStatus('Connecting to room: ' + room + '...');
|
||||
// Create client based on detected variant
|
||||
var variant = WZPCore.detectVariant();
|
||||
var ClientClass = {
|
||||
pure: window.WZPPureClient,
|
||||
hybrid: window.WZPHybridClient,
|
||||
full: window.WZPFullClient,
|
||||
'ws': window.WZPWsClient,
|
||||
'ws-fec': window.WZPWsFecClient,
|
||||
'ws-full': window.WZPWsFullClient,
|
||||
}[variant] || window.WZPPureClient;
|
||||
|
||||
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();
|
||||
var clientOpts = {
|
||||
wsUrl: wsUrl,
|
||||
room: room,
|
||||
onAudio: function(pcm) {
|
||||
if (playback) playback.play(pcm);
|
||||
},
|
||||
onStatus: function(msg) {
|
||||
WZPCore.updateStatus(msg);
|
||||
},
|
||||
onStats: function(stats) {
|
||||
WZPCore.updateStats(stats);
|
||||
},
|
||||
};
|
||||
|
||||
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');
|
||||
// Full variant: add WebTransport URL for direct relay connection
|
||||
if (variant === 'full') {
|
||||
clientOpts.url = location.origin.replace('http', 'https');
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
if (active) {
|
||||
setStatus('Error — reconnecting...');
|
||||
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
|
||||
}
|
||||
};
|
||||
}
|
||||
client = new ClientClass(clientOpts);
|
||||
|
||||
function stopCall() {
|
||||
active = false;
|
||||
const btn = document.getElementById('callBtn');
|
||||
btn.textContent = 'Connect';
|
||||
btn.classList.remove('active');
|
||||
btn.disabled = false;
|
||||
showControls(false);
|
||||
cleanupAudio();
|
||||
if (ws) { ws.close(); ws = null; }
|
||||
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
|
||||
setStatus('');
|
||||
setStats('');
|
||||
}
|
||||
|
||||
function cleanupAudio() {
|
||||
if (captureNode) { captureNode.disconnect(); captureNode = null; }
|
||||
if (playbackNode) { playbackNode.disconnect(); playbackNode = null; }
|
||||
if (audioCtx) { audioCtx.close(); audioCtx = null; workletLoaded = false; }
|
||||
if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; }
|
||||
}
|
||||
|
||||
let workletLoaded = false;
|
||||
|
||||
async function loadWorkletModule() {
|
||||
if (workletLoaded) return true;
|
||||
if (typeof AudioWorkletNode === 'undefined' || !audioCtx.audioWorklet) {
|
||||
console.warn('AudioWorklet API not supported in this browser — using ScriptProcessorNode fallback');
|
||||
return false;
|
||||
}
|
||||
// Load WASM for variants that need it
|
||||
if (client.loadWasm) {
|
||||
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;
|
||||
WZPCore.updateStatus('Loading WASM module...');
|
||||
await client.loadWasm();
|
||||
} catch (e) {
|
||||
WZPCore.updateStatus('WASM load failed: ' + e.message);
|
||||
ui.setConnected(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
} catch (e) {
|
||||
WZPCore.updateStatus('Connection failed: ' + e.message);
|
||||
ui.setConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start audio capture and playback
|
||||
try {
|
||||
capture = await WZPCore.connectCapture(audioCtx, function(pcmBuffer) {
|
||||
if (!transmitting) return;
|
||||
var pcm = new Int16Array(pcmBuffer);
|
||||
WZPCore.updateLevel(pcm);
|
||||
if (client) client.sendAudio(pcmBuffer);
|
||||
});
|
||||
|
||||
playback = await WZPCore.connectPlayback(audioCtx);
|
||||
} catch (e) {
|
||||
WZPCore.updateStatus('Audio error: ' + e.message);
|
||||
if (client) client.disconnect();
|
||||
client = null;
|
||||
ui.setConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
ui.setConnected(true);
|
||||
}
|
||||
|
||||
function doDisconnect() {
|
||||
if (capture) { capture.stop(); capture = null; }
|
||||
if (playback) { playback.stop(); playback = null; }
|
||||
if (client) { client.disconnect(); client = null; }
|
||||
|
||||
var audioCtx = WZPCore.getAudioContext();
|
||||
if (audioCtx && audioCtx.state !== 'closed') {
|
||||
audioCtx.close();
|
||||
}
|
||||
|
||||
WZPCore.updateStatus('');
|
||||
WZPCore.updateStats('');
|
||||
document.getElementById('levelBar').style.width = '0%';
|
||||
|
||||
ui.setConnected(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function startAudioCapture() {
|
||||
const source = audioCtx.createMediaStreamSource(mediaStream);
|
||||
const hasWorklet = await loadWorkletModule();
|
||||
|
||||
if (hasWorklet) {
|
||||
captureNode = new AudioWorkletNode(audioCtx, 'wzp-capture-processor');
|
||||
captureNode.port.onmessage = (e) => {
|
||||
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
|
||||
ws.send(e.data);
|
||||
framesSent++;
|
||||
|
||||
// Level meter from the PCM data
|
||||
const pcm = new Int16Array(e.data);
|
||||
let max = 0;
|
||||
for (let i = 0; i < pcm.length; i += 16) max = Math.max(max, Math.abs(pcm[i]));
|
||||
document.getElementById('levelBar').style.width = (max / 32768 * 100) + '%';
|
||||
};
|
||||
source.connect(captureNode);
|
||||
captureNode.connect(audioCtx.destination); // needed to keep worklet alive
|
||||
} else {
|
||||
// Fallback to ScriptProcessorNode (deprecated but widely supported)
|
||||
console.warn('Capture: using ScriptProcessorNode fallback');
|
||||
captureNode = audioCtx.createScriptProcessor(4096, 1, 1);
|
||||
let acc = new Float32Array(0);
|
||||
captureNode.onaudioprocess = (ev) => {
|
||||
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
|
||||
const input = ev.inputBuffer.getChannelData(0);
|
||||
const n = new Float32Array(acc.length + input.length);
|
||||
n.set(acc); n.set(input, acc.length); acc = n;
|
||||
while (acc.length >= FRAME_SIZE) {
|
||||
const frame = acc.slice(0, FRAME_SIZE); acc = acc.slice(FRAME_SIZE);
|
||||
const pcm = new Int16Array(FRAME_SIZE);
|
||||
for (let i = 0; i < FRAME_SIZE; i++) pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
|
||||
let max = 0;
|
||||
for (let i = 0; i < pcm.length; i += 16) max = Math.max(max, Math.abs(pcm[i]));
|
||||
document.getElementById('levelBar').style.width = (max / 32768 * 100) + '%';
|
||||
ws.send(pcm.buffer);
|
||||
framesSent++;
|
||||
}
|
||||
};
|
||||
source.connect(captureNode);
|
||||
captureNode.connect(audioCtx.destination);
|
||||
}
|
||||
}
|
||||
|
||||
async function startAudioPlayback() {
|
||||
const hasWorklet = await loadWorkletModule();
|
||||
|
||||
if (hasWorklet) {
|
||||
playbackNode = new AudioWorkletNode(audioCtx, 'wzp-playback-processor');
|
||||
playbackNode.connect(audioCtx.destination);
|
||||
} else {
|
||||
console.warn('Playback: using scheduled BufferSource fallback');
|
||||
playbackNode = null; // will use createBufferSource fallback in playAudio()
|
||||
}
|
||||
}
|
||||
|
||||
let nextPlayTime = 0;
|
||||
|
||||
function playAudio(pcmInt16) {
|
||||
if (!audioCtx) return;
|
||||
|
||||
if (playbackNode && playbackNode.port) {
|
||||
// AudioWorklet path — send Int16 PCM directly to the worklet for conversion
|
||||
playbackNode.port.postMessage(pcmInt16.buffer, [pcmInt16.buffer]);
|
||||
} else {
|
||||
// Fallback: scheduled BufferSource (convert Int16 -> Float32 on main thread)
|
||||
const floatData = new Float32Array(pcmInt16.length);
|
||||
for (let i = 0; i < pcmInt16.length; i++) {
|
||||
floatData[i] = pcmInt16[i] / 32768.0;
|
||||
}
|
||||
const buffer = audioCtx.createBuffer(1, floatData.length, SAMPLE_RATE);
|
||||
buffer.getChannelData(0).set(floatData);
|
||||
const source = audioCtx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(audioCtx.destination);
|
||||
const now = audioCtx.currentTime;
|
||||
if (nextPlayTime < now || nextPlayTime > now + 1.0) {
|
||||
nextPlayTime = now + 0.02;
|
||||
}
|
||||
source.start(nextPlayTime);
|
||||
nextPlayTime += buffer.duration;
|
||||
}
|
||||
}
|
||||
|
||||
function startStatsUpdate() {
|
||||
statsInterval = setInterval(() => {
|
||||
if (!active) { clearInterval(statsInterval); return; }
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
setStats(elapsed + 's | sent: ' + framesSent + ' | recv: ' + framesRecv);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// --- Push-to-talk ---
|
||||
|
||||
function togglePTT() {
|
||||
pttMode = document.getElementById('pttMode').checked;
|
||||
const btn = document.getElementById('pttBtn');
|
||||
if (pttMode) {
|
||||
transmitting = false;
|
||||
btn.style.display = 'block';
|
||||
} else {
|
||||
transmitting = true;
|
||||
btn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// PTT button — hold to talk (mouse + touch)
|
||||
document.getElementById('pttBtn').addEventListener('mousedown', () => { startTransmit(); });
|
||||
document.getElementById('pttBtn').addEventListener('mouseup', () => { stopTransmit(); });
|
||||
document.getElementById('pttBtn').addEventListener('mouseleave', () => { stopTransmit(); });
|
||||
document.getElementById('pttBtn').addEventListener('touchstart', (e) => { e.preventDefault(); startTransmit(); });
|
||||
document.getElementById('pttBtn').addEventListener('touchend', (e) => { e.preventDefault(); stopTransmit(); });
|
||||
|
||||
// Spacebar PTT
|
||||
document.addEventListener('keydown', (e) => { if (pttMode && active && e.code === 'Space' && !e.repeat) { e.preventDefault(); startTransmit(); } });
|
||||
document.addEventListener('keyup', (e) => { if (pttMode && active && e.code === 'Space') { e.preventDefault(); stopTransmit(); } });
|
||||
|
||||
function startTransmit() {
|
||||
if (!pttMode || !active) return;
|
||||
transmitting = true;
|
||||
document.getElementById('pttBtn').classList.add('transmitting');
|
||||
document.getElementById('pttBtn').textContent = 'Transmitting...';
|
||||
}
|
||||
|
||||
function stopTransmit() {
|
||||
if (!pttMode) return;
|
||||
transmitting = false;
|
||||
document.getElementById('pttBtn').classList.remove('transmitting');
|
||||
document.getElementById('pttBtn').textContent = 'Hold to Talk';
|
||||
}
|
||||
|
||||
// Show controls when connected
|
||||
function showControls(show) {
|
||||
document.getElementById('controls').style.display = show ? 'flex' : 'none';
|
||||
if (!show) {
|
||||
document.getElementById('pttBtn').style.display = 'none';
|
||||
pttMode = false;
|
||||
transmitting = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Set room from URL on load
|
||||
window.addEventListener('load', () => {
|
||||
const room = getRoom();
|
||||
if (room && room !== 'default') {
|
||||
document.getElementById('room').value = room;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
379
crates/wzp-web/static/js/wzp-core.js
Normal file
379
crates/wzp-web/static/js/wzp-core.js
Normal file
@@ -0,0 +1,379 @@
|
||||
// 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();
|
||||
const valid = ['pure', 'hybrid', 'full', 'ws', 'ws-fec', 'ws-full'];
|
||||
if (valid.includes(v)) 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,
|
||||
};
|
||||
579
crates/wzp-web/static/js/wzp-full.js
Normal file
579
crates/wzp-web/static/js/wzp-full.js
Normal file
@@ -0,0 +1,579 @@
|
||||
// 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 = (window.__WZP_BASE_URL || '') + '/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.wsUrl = options.wsUrl; // WS fallback 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.ws = null; // WebSocket fallback
|
||||
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._useWebTransport = false; // true if WT connected, false = WS fallback
|
||||
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;
|
||||
|
||||
this._status('Loading WASM module...');
|
||||
|
||||
// 1. Load WASM (FEC + crypto)
|
||||
this._wasmModule = await import(WZP_WASM_PATH);
|
||||
await this._wasmModule.default();
|
||||
|
||||
// 2. Try WebTransport first, fall back to WebSocket
|
||||
let wtSuccess = false;
|
||||
if (typeof WebTransport !== 'undefined' && this.url) {
|
||||
try {
|
||||
this._status('Trying WebTransport...');
|
||||
const wtUrl = this.url + '/' + encodeURIComponent(this.room);
|
||||
this.wt = new WebTransport(wtUrl);
|
||||
await Promise.race([
|
||||
this.wt.ready,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
|
||||
]);
|
||||
this.datagramWriter = this.wt.datagrams.writable.getWriter();
|
||||
this.datagramReader = this.wt.datagrams.readable.getReader();
|
||||
this._status('Performing key exchange...');
|
||||
await this._performKeyExchange();
|
||||
wtSuccess = true;
|
||||
this._useWebTransport = true;
|
||||
} catch (e) {
|
||||
console.warn('[wzp-full] WebTransport failed, falling back to WebSocket:', e.message);
|
||||
if (this.wt) { try { this.wt.close(); } catch (_) {} }
|
||||
this.wt = null;
|
||||
this.datagramWriter = null;
|
||||
this.datagramReader = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!wtSuccess) {
|
||||
// WebSocket fallback (same as hybrid — WASM loaded but uses WS transport)
|
||||
this._useWebTransport = false;
|
||||
await this._connectWebSocket();
|
||||
}
|
||||
|
||||
// 3. Initialise FEC
|
||||
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();
|
||||
|
||||
// 4. Start receive loop (WebTransport only — WS uses onmessage)
|
||||
if (this._useWebTransport) {
|
||||
this._recvLoop();
|
||||
this._status('Connected to room: ' + this.room + ' (WebTransport, encrypted, FEC active)');
|
||||
} else {
|
||||
this._status('Connected to room: ' + this.room + ' (WebSocket fallback, WASM FEC loaded)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket fallback connection (used when WebTransport unavailable).
|
||||
*/
|
||||
async _connectWebSocket() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._status('Connecting via WebSocket (fallback)...');
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this._status('WebSocket connected to room: ' + this.room);
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
if (!(event.data instanceof ArrayBuffer)) return;
|
||||
const pcm = new Int16Array(event.data);
|
||||
this.stats.recv++;
|
||||
if (this.onAudio) this.onAudio(pcm);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
if (this._connected) {
|
||||
this._cleanup();
|
||||
this._status('Disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
if (!this._connected) {
|
||||
this._cleanup();
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect and clean up all resources.
|
||||
*/
|
||||
disconnect() {
|
||||
this._connected = false;
|
||||
if (this.wt) {
|
||||
try { this.wt.close(); } catch (_) { /* ignore */ }
|
||||
this.wt = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch (_) { /* ignore */ }
|
||||
this.ws = 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) return;
|
||||
|
||||
// WebSocket fallback: send raw PCM like pure/hybrid
|
||||
if (!this._useWebTransport) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(pcmBuffer);
|
||||
this.sequence++;
|
||||
this.stats.sent++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!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 = (window.__WZP_BASE_URL || '') + '/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;
|
||||
592
crates/wzp-web/static/js/wzp-ws-fec.js
Normal file
592
crates/wzp-web/static/js/wzp-ws-fec.js
Normal file
@@ -0,0 +1,592 @@
|
||||
// WarzonePhone — WZP-WS-FEC client (Variant 5).
|
||||
// WebSocket transport, WZP wire protocol, WASM RaptorQ FEC.
|
||||
// Application-layer redundancy even over TCP.
|
||||
// Sends MediaPacket-formatted frames with FEC encoding.
|
||||
// Ready for direct relay WS support (no bridge translation needed).
|
||||
|
||||
'use strict';
|
||||
|
||||
// WASM module path (served from /wasm/ by the wzp-web bridge).
|
||||
const WZP_WS_FEC_WASM_PATH = (window.__WZP_BASE_URL || '') + '/wasm/wzp_wasm.js';
|
||||
|
||||
// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
|
||||
const WZP_WS_FEC_HEADER_SIZE = 12;
|
||||
|
||||
// FEC wire header: block_id(1) + symbol_idx(1) + is_repair(1) = 3 bytes.
|
||||
const WZP_WS_FEC_FEC_HEADER_SIZE = 3;
|
||||
|
||||
// FEC parameters.
|
||||
// A 960-sample Int16 PCM frame = 1920 bytes. We use symbol_size = 2048
|
||||
// (1920 payload + 2-byte length prefix + 126 bytes padding).
|
||||
const WZP_WS_FEC_BLOCK_SIZE = 5;
|
||||
const WZP_WS_FEC_SYMBOL_SIZE = 2048;
|
||||
|
||||
// Length prefix size within each FEC symbol.
|
||||
const WZP_WS_FEC_LENGTH_PREFIX = 2;
|
||||
|
||||
class WZPWsFecClient {
|
||||
/**
|
||||
* @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(Object) for UI stats
|
||||
*/
|
||||
constructor(options) {
|
||||
this.wsUrl = options.wsUrl;
|
||||
this.room = options.room;
|
||||
this.authToken = options.authToken || null;
|
||||
this.onAudio = options.onAudio || null;
|
||||
this.onStatus = options.onStatus || null;
|
||||
this.onStats = options.onStats || null;
|
||||
|
||||
this.ws = null;
|
||||
this.seq = 0;
|
||||
this.startTimestamp = 0;
|
||||
this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
|
||||
this._startTime = 0;
|
||||
this._statsInterval = null;
|
||||
this._connected = false;
|
||||
this._authenticated = false;
|
||||
|
||||
// WASM FEC instances (loaded in loadWasm() / connect()).
|
||||
this._wasmModule = null;
|
||||
this.fecEncoder = null;
|
||||
this.fecDecoder = null;
|
||||
this.wasmReady = false;
|
||||
|
||||
// Current FEC block counter for outgoing packets.
|
||||
this._fecBlockId = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the WASM FEC module.
|
||||
* Called automatically by connect(), or can be called early.
|
||||
*/
|
||||
async loadWasm() {
|
||||
if (this.wasmReady) return;
|
||||
try {
|
||||
this._wasmModule = await import(WZP_WS_FEC_WASM_PATH);
|
||||
await this._wasmModule.default();
|
||||
|
||||
this.fecEncoder = new this._wasmModule.WzpFecEncoder(
|
||||
WZP_WS_FEC_BLOCK_SIZE,
|
||||
WZP_WS_FEC_SYMBOL_SIZE
|
||||
);
|
||||
this.fecDecoder = new this._wasmModule.WzpFecDecoder(
|
||||
WZP_WS_FEC_BLOCK_SIZE,
|
||||
WZP_WS_FEC_SYMBOL_SIZE
|
||||
);
|
||||
this.wasmReady = true;
|
||||
console.log('[wzp-ws-fec] WASM FEC module loaded successfully');
|
||||
} catch (e) {
|
||||
console.error('[wzp-ws-fec] WASM FEC module failed to load:', e);
|
||||
this.wasmReady = false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 12-byte WZP MediaHeader.
|
||||
*
|
||||
* @param {number} seq Sequence number (u16)
|
||||
* @param {number} timestampMs Milliseconds since session start
|
||||
* @param {boolean} isRepair True if this is a FEC repair symbol
|
||||
* @param {number} codecId Codec ID (0=RawPcm16, 1=Opus16k, 2=Opus48k)
|
||||
* @param {number} fecBlock FEC block ID (u8)
|
||||
* @param {number} fecSymbol FEC symbol index (u8)
|
||||
* @param {number} fecRatio FEC ratio (0.0 to ~2.0)
|
||||
* @param {boolean} hasQuality Whether a quality report is attached
|
||||
* @returns {Uint8Array} 12-byte header
|
||||
*/
|
||||
_buildHeader(seq, timestampMs, isRepair = false, codecId = 0, fecBlock = 0, fecSymbol = 0, fecRatio = 0, hasQuality = false) {
|
||||
const buf = new ArrayBuffer(WZP_WS_FEC_HEADER_SIZE);
|
||||
const view = new DataView(buf);
|
||||
|
||||
const fecRatioEncoded = Math.min(127, Math.round(fecRatio * 63.5));
|
||||
const byte0 = ((0 & 0x01) << 7) // version=0
|
||||
| ((isRepair ? 1 : 0) << 6) // T bit
|
||||
| ((codecId & 0x0F) << 2) // CodecID
|
||||
| ((hasQuality ? 1 : 0) << 1) // Q bit
|
||||
| ((fecRatioEncoded >> 6) & 0x01); // FecRatioHi
|
||||
view.setUint8(0, byte0);
|
||||
|
||||
const byte1 = (fecRatioEncoded & 0x3F) << 2;
|
||||
view.setUint8(1, byte1);
|
||||
|
||||
view.setUint16(2, seq & 0xFFFF); // big-endian (default for DataView)
|
||||
view.setUint32(4, timestampMs & 0xFFFFFFFF); // big-endian
|
||||
view.setUint8(8, fecBlock & 0xFF);
|
||||
view.setUint8(9, fecSymbol & 0xFF);
|
||||
view.setUint8(10, 0); // reserved
|
||||
view.setUint8(11, 0); // csrc_count
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a 12-byte MediaHeader from received binary data.
|
||||
*
|
||||
* @param {Uint8Array} data At least 12 bytes
|
||||
* @returns {Object|null} Parsed header fields, or null if too short
|
||||
*/
|
||||
_parseHeader(data) {
|
||||
if (data.byteLength < WZP_WS_FEC_HEADER_SIZE) return null;
|
||||
const view = new DataView(data.buffer || data, data.byteOffset || 0, 12);
|
||||
const byte0 = view.getUint8(0);
|
||||
const byte1 = view.getUint8(1);
|
||||
const fecRatioEncoded = ((byte0 & 0x01) << 6) | ((byte1 >> 2) & 0x3F);
|
||||
return {
|
||||
version: (byte0 >> 7) & 1,
|
||||
isRepair: !!((byte0 >> 6) & 1),
|
||||
codecId: (byte0 >> 2) & 0x0F,
|
||||
hasQuality: !!((byte0 >> 1) & 1),
|
||||
fecRatio: fecRatioEncoded / 63.5,
|
||||
seq: view.getUint16(2),
|
||||
timestamp: view.getUint32(4),
|
||||
fecBlock: view.getUint8(8),
|
||||
fecSymbol: view.getUint8(9),
|
||||
reserved: view.getUint8(10),
|
||||
csrcCount: view.getUint8(11),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad a PCM frame into a FEC symbol with a 2-byte length prefix.
|
||||
* Symbol layout: [len_hi, len_lo, ...pcm_bytes..., ...zero_padding...]
|
||||
*
|
||||
* @param {Uint8Array} pcmBytes Raw PCM bytes
|
||||
* @returns {Uint8Array} Padded symbol of WZP_WS_FEC_SYMBOL_SIZE bytes
|
||||
*/
|
||||
_padToSymbol(pcmBytes) {
|
||||
const symbol = new Uint8Array(WZP_WS_FEC_SYMBOL_SIZE);
|
||||
const len = pcmBytes.length;
|
||||
symbol[0] = (len >> 8) & 0xFF;
|
||||
symbol[1] = len & 0xFF;
|
||||
symbol.set(pcmBytes, WZP_WS_FEC_LENGTH_PREFIX);
|
||||
return symbol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the original PCM payload from a FEC symbol (strip prefix + padding).
|
||||
*
|
||||
* @param {Uint8Array} symbol Symbol data (WZP_WS_FEC_SYMBOL_SIZE bytes)
|
||||
* @returns {Uint8Array} Original PCM bytes
|
||||
*/
|
||||
_unpadSymbol(symbol) {
|
||||
const len = (symbol[0] << 8) | symbol[1];
|
||||
if (len > WZP_WS_FEC_SYMBOL_SIZE - WZP_WS_FEC_LENGTH_PREFIX) {
|
||||
// Sanity check: if length is bogus, return empty.
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
return symbol.slice(WZP_WS_FEC_LENGTH_PREFIX, WZP_WS_FEC_LENGTH_PREFIX + len);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (WZP-WS-FEC) to room: ' + this.room + '...');
|
||||
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
|
||||
this.ws.onopen = () => {
|
||||
// Send auth if token provided.
|
||||
if (this.authToken) {
|
||||
this.ws.send(JSON.stringify({ type: 'auth', token: this.authToken }));
|
||||
}
|
||||
|
||||
this._connected = true;
|
||||
this._authenticated = !this.authToken;
|
||||
this.seq = 0;
|
||||
this.startTimestamp = Date.now();
|
||||
this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
|
||||
this._startTime = Date.now();
|
||||
this._fecBlockId = 0;
|
||||
this._startStatsTimer();
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
// Handle text messages (auth responses).
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'auth_ok') {
|
||||
this._authenticated = true;
|
||||
this._status('Authenticated (WZP-WS-FEC) to room: ' + this.room);
|
||||
}
|
||||
if (msg.type === 'auth_error') {
|
||||
this._status('Auth failed: ' + (msg.reason || 'unknown'));
|
||||
this.disconnect();
|
||||
}
|
||||
} catch(e) { /* ignore non-JSON text */ }
|
||||
return;
|
||||
}
|
||||
this._handleMessage(event);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
const was = this._connected;
|
||||
this._cleanup();
|
||||
if (was) this._status('Disconnected');
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
if (!this._connected) {
|
||||
this._cleanup();
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
} else {
|
||||
this._status('Connection error');
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
await Promise.all([wasmPromise, wsPromise]);
|
||||
|
||||
const fecStatus = this.wasmReady ? 'FEC ready' : 'FEC unavailable';
|
||||
this._status('Connected (WZP-WS-FEC) 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), but reset encoder/decoder.
|
||||
if (this.fecEncoder) {
|
||||
try { this.fecEncoder.free(); } catch (_) { /* ignore */ }
|
||||
this.fecEncoder = null;
|
||||
}
|
||||
if (this.fecDecoder) {
|
||||
try { this.fecDecoder.free(); } catch (_) { /* ignore */ }
|
||||
this.fecDecoder = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a PCM audio frame with FEC encoding over the WebSocket.
|
||||
*
|
||||
* Each PCM frame is padded to a FEC symbol (2048 bytes with length prefix)
|
||||
* and fed to the FEC encoder. When a block of 5 symbols completes, the
|
||||
* encoder outputs source + repair symbols. Each is sent as an individual
|
||||
* WZP MediaPacket with the appropriate fecBlock, fecSymbol, and isRepair
|
||||
* fields in the 12-byte header.
|
||||
*
|
||||
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
||||
*/
|
||||
async sendAudio(pcmBuffer) {
|
||||
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
if (!this.wasmReady || !this.fecEncoder) return;
|
||||
|
||||
const pcmBytes = new Uint8Array(pcmBuffer);
|
||||
|
||||
// Pad PCM frame to FEC symbol size with length prefix.
|
||||
const symbol = this._padToSymbol(pcmBytes);
|
||||
|
||||
// Feed to FEC encoder. Returns wire data when block completes.
|
||||
const fecOutput = this.fecEncoder.add_symbol(symbol);
|
||||
|
||||
if (fecOutput) {
|
||||
// Block completed — send all packets (source + repair).
|
||||
const packetSize = WZP_WS_FEC_FEC_HEADER_SIZE + WZP_WS_FEC_SYMBOL_SIZE;
|
||||
const timestampMs = Date.now() - this.startTimestamp;
|
||||
|
||||
for (let offset = 0; offset + packetSize <= fecOutput.length; offset += packetSize) {
|
||||
const blockId = fecOutput[offset];
|
||||
const symbolIdx = fecOutput[offset + 1];
|
||||
const isRepair = fecOutput[offset + 2] !== 0;
|
||||
const symbolData = fecOutput.slice(
|
||||
offset + WZP_WS_FEC_FEC_HEADER_SIZE,
|
||||
offset + packetSize
|
||||
);
|
||||
|
||||
// Build WZP MediaHeader for this FEC symbol.
|
||||
// fecRatio ~0.5 for 50% repair overhead: encoded = round(0.5 * 63.5) = 32
|
||||
const header = this._buildHeader(
|
||||
this.seq,
|
||||
timestampMs,
|
||||
isRepair,
|
||||
0, // codecId = RawPcm16
|
||||
blockId,
|
||||
symbolIdx,
|
||||
0.5, // fecRatio
|
||||
false // hasQuality
|
||||
);
|
||||
|
||||
// Wire frame: header(12) + symbol_data(2048)
|
||||
const packet = new Uint8Array(WZP_WS_FEC_HEADER_SIZE + symbolData.length);
|
||||
packet.set(header, 0);
|
||||
packet.set(symbolData, WZP_WS_FEC_HEADER_SIZE);
|
||||
|
||||
this.ws.send(packet.buffer);
|
||||
this.seq = (this.seq + 1) & 0xFFFF;
|
||||
this.stats.sent++;
|
||||
}
|
||||
|
||||
this._fecBlockId++;
|
||||
}
|
||||
// If block not yet complete, accumulate (no packets sent yet).
|
||||
}
|
||||
|
||||
/**
|
||||
* Test FEC encode -> simulate loss -> decode in the browser.
|
||||
* Demonstrates that the WASM RaptorQ module works correctly
|
||||
* with the WZP wire protocol symbol format.
|
||||
*
|
||||
* @param {Object} [opts]
|
||||
* @param {number} [opts.blockSize=5] Source symbols per block
|
||||
* @param {number} [opts.symbolSize=2048] Padded symbol size
|
||||
* @param {number} [opts.frameSize=1920] PCM frame size in bytes
|
||||
* @param {number} [opts.dropCount=2] Number of packets to drop (simulated 30%+ loss)
|
||||
* @returns {Object} Test results
|
||||
*/
|
||||
testFec(opts) {
|
||||
if (!this.wasmReady || !this._wasmModule) {
|
||||
return { success: false, error: 'WASM FEC module not loaded' };
|
||||
}
|
||||
|
||||
const blockSize = (opts && opts.blockSize) || 5;
|
||||
const symbolSize = (opts && opts.symbolSize) || WZP_WS_FEC_SYMBOL_SIZE;
|
||||
const frameSize = (opts && opts.frameSize) || 1920;
|
||||
const dropCount = (opts && opts.dropCount) || 2;
|
||||
|
||||
const FEC_HDR = 3; // block_id + symbol_idx + is_repair
|
||||
const packetSize = FEC_HDR + 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, padded to symbol size with length prefix.
|
||||
const originalFrames = [];
|
||||
const paddedSymbols = [];
|
||||
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;
|
||||
}
|
||||
originalFrames.push(frame);
|
||||
|
||||
// Pad with length prefix (same as _padToSymbol).
|
||||
const sym = new Uint8Array(symbolSize);
|
||||
sym[0] = (frameSize >> 8) & 0xFF;
|
||||
sym[1] = frameSize & 0xFF;
|
||||
sym.set(frame, 2);
|
||||
paddedSymbols.push(sym);
|
||||
}
|
||||
|
||||
// Encode: feed padded symbols to encoder.
|
||||
let wireData = null;
|
||||
for (const sym of paddedSymbols) {
|
||||
const result = encoder.add_symbol(sym);
|
||||
if (result) wireData = result;
|
||||
}
|
||||
|
||||
if (!wireData) {
|
||||
wireData = encoder.flush();
|
||||
}
|
||||
|
||||
// Parse wire packets.
|
||||
const packets = [];
|
||||
if (wireData) {
|
||||
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 + FEC_HDR, offset + packetSize),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sourcePackets = packets.filter(p => !p.isRepair).length;
|
||||
const repairPackets = packets.filter(p => p.isRepair).length;
|
||||
|
||||
// Simulate packet loss: drop `dropCount` source packets from the front.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify decoded data: extract original frames from decoded symbols.
|
||||
let success = false;
|
||||
if (decoded) {
|
||||
// decoded is the concatenated padded symbols. Extract original frames.
|
||||
const recoveredFrames = [];
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
const symOffset = i * symbolSize;
|
||||
if (symOffset + symbolSize <= decoded.length) {
|
||||
const sym = decoded.slice(symOffset, symOffset + symbolSize);
|
||||
const len = (sym[0] << 8) | sym[1];
|
||||
recoveredFrames.push(sym.slice(2, 2 + len));
|
||||
}
|
||||
}
|
||||
|
||||
success = recoveredFrames.length === blockSize;
|
||||
if (success) {
|
||||
for (let i = 0; i < blockSize && success; i++) {
|
||||
if (recoveredFrames[i].length !== originalFrames[i].length) {
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
for (let j = 0; j < originalFrames[i].length; j++) {
|
||||
if (recoveredFrames[i][j] !== originalFrames[i][j]) {
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Free WASM objects.
|
||||
encoder.free();
|
||||
decoder.free();
|
||||
|
||||
const elapsed = performance.now() - t0;
|
||||
|
||||
return {
|
||||
success,
|
||||
sourcePackets,
|
||||
repairPackets,
|
||||
totalPackets: packets.length,
|
||||
dropped: dropCount,
|
||||
recovered: !!decoded,
|
||||
symbolSize: symbolSize,
|
||||
frameSize: frameSize,
|
||||
elapsed: elapsed.toFixed(2) + 'ms',
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
_handleMessage(event) {
|
||||
if (!(event.data instanceof ArrayBuffer)) return;
|
||||
const data = new Uint8Array(event.data);
|
||||
if (data.length < WZP_WS_FEC_HEADER_SIZE) return;
|
||||
|
||||
const header = this._parseHeader(data);
|
||||
if (!header) return;
|
||||
|
||||
this.stats.recv++;
|
||||
|
||||
if (!this.wasmReady || !this.fecDecoder) {
|
||||
// No FEC decoder — cannot process FEC-encoded data.
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract symbol data (everything after 12-byte MediaHeader).
|
||||
const symbolData = data.slice(WZP_WS_FEC_HEADER_SIZE);
|
||||
|
||||
// Feed symbol to FEC decoder using header fields.
|
||||
const decoded = this.fecDecoder.add_symbol(
|
||||
header.fecBlock,
|
||||
header.fecSymbol,
|
||||
header.isRepair,
|
||||
symbolData
|
||||
);
|
||||
|
||||
if (decoded) {
|
||||
this.stats.fecRecovered++;
|
||||
|
||||
// decoded is concatenated padded symbols.
|
||||
// Each symbol is WZP_WS_FEC_SYMBOL_SIZE bytes with a 2-byte length prefix.
|
||||
for (let off = 0; off + WZP_WS_FEC_SYMBOL_SIZE <= decoded.length; off += WZP_WS_FEC_SYMBOL_SIZE) {
|
||||
const symbol = decoded.slice(off, off + WZP_WS_FEC_SYMBOL_SIZE);
|
||||
const pcmBytes = this._unpadSymbol(symbol);
|
||||
|
||||
if (pcmBytes.length > 0 && pcmBytes.length % 2 === 0) {
|
||||
const pcm = new Int16Array(
|
||||
pcmBytes.buffer,
|
||||
pcmBytes.byteOffset,
|
||||
pcmBytes.byteLength / 2
|
||||
);
|
||||
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.wasmReady,
|
||||
});
|
||||
}
|
||||
}, 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.WZPWsFecClient = WZPWsFecClient;
|
||||
749
crates/wzp-web/static/js/wzp-ws-full.js
Normal file
749
crates/wzp-web/static/js/wzp-ws-full.js
Normal file
@@ -0,0 +1,749 @@
|
||||
// WarzonePhone — WZP-WS-Full client (Variant 6).
|
||||
// WebSocket transport, WZP wire protocol, WASM FEC + ChaCha20-Poly1305 E2E.
|
||||
// Full encryption — relay sees only ciphertext.
|
||||
// Sends MediaPacket-formatted frames with FEC + encryption.
|
||||
// Ready for direct relay WS support (no bridge translation needed).
|
||||
|
||||
'use strict';
|
||||
|
||||
// WASM module path (served from /wasm/ by the wzp-web bridge).
|
||||
const WZP_WS_FULL_WASM_PATH = (window.__WZP_BASE_URL || '') + '/wasm/wzp_wasm.js';
|
||||
|
||||
// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
|
||||
const WZP_WS_FULL_HEADER_SIZE = 12;
|
||||
|
||||
// FEC wire header: block_id(1) + symbol_idx(1) + is_repair(1) = 3 bytes.
|
||||
const WZP_WS_FULL_FEC_HEADER_SIZE = 3;
|
||||
|
||||
// FEC parameters.
|
||||
// A 960-sample Int16 PCM frame = 1920 bytes. Symbol size = 2048
|
||||
// (1920 payload + 2-byte length prefix + 126 bytes padding).
|
||||
const WZP_WS_FULL_BLOCK_SIZE = 5;
|
||||
const WZP_WS_FULL_SYMBOL_SIZE = 2048;
|
||||
|
||||
// Length prefix size within each FEC symbol.
|
||||
const WZP_WS_FULL_LENGTH_PREFIX = 2;
|
||||
|
||||
// ChaCha20-Poly1305 tag size (16 bytes).
|
||||
const WZP_WS_FULL_TAG_SIZE = 16;
|
||||
|
||||
// X25519 public key size (32 bytes).
|
||||
const WZP_WS_FULL_PUBKEY_SIZE = 32;
|
||||
|
||||
class WZPWsFullClient {
|
||||
/**
|
||||
* @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(Object) for UI stats
|
||||
*/
|
||||
constructor(options) {
|
||||
this.wsUrl = options.wsUrl;
|
||||
this.room = options.room;
|
||||
this.authToken = options.authToken || null;
|
||||
this.onAudio = options.onAudio || null;
|
||||
this.onStatus = options.onStatus || null;
|
||||
this.onStats = options.onStats || null;
|
||||
|
||||
this.ws = null;
|
||||
this.seq = 0;
|
||||
this.startTimestamp = 0;
|
||||
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
|
||||
this._startTime = 0;
|
||||
this._statsInterval = null;
|
||||
this._connected = false;
|
||||
this._authenticated = false;
|
||||
|
||||
// WASM instances.
|
||||
this._wasmModule = null;
|
||||
this.fecEncoder = null;
|
||||
this.fecDecoder = null;
|
||||
this.cryptoSession = null;
|
||||
this._keyExchange = null;
|
||||
this.wasmReady = false;
|
||||
|
||||
// Key exchange state.
|
||||
this._keyExchangeComplete = false;
|
||||
this._keyExchangeResolve = null;
|
||||
this._keyExchangeReject = null;
|
||||
|
||||
// Current FEC block counter for outgoing packets.
|
||||
this._fecBlockId = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the WASM module (FEC + Crypto).
|
||||
* Called automatically by connect(), or can be called early.
|
||||
*/
|
||||
async loadWasm() {
|
||||
if (this.wasmReady) return;
|
||||
try {
|
||||
this._wasmModule = await import(WZP_WS_FULL_WASM_PATH);
|
||||
await this._wasmModule.default();
|
||||
this.wasmReady = true;
|
||||
console.log('[wzp-ws-full] WASM module loaded successfully');
|
||||
} catch (e) {
|
||||
console.error('[wzp-ws-full] WASM module failed to load:', e);
|
||||
this.wasmReady = false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 12-byte WZP MediaHeader.
|
||||
*
|
||||
* @param {number} seq Sequence number (u16)
|
||||
* @param {number} timestampMs Milliseconds since session start
|
||||
* @param {boolean} isRepair True if this is a FEC repair symbol
|
||||
* @param {number} codecId Codec ID (0=RawPcm16, 1=Opus16k, 2=Opus48k)
|
||||
* @param {number} fecBlock FEC block ID (u8)
|
||||
* @param {number} fecSymbol FEC symbol index (u8)
|
||||
* @param {number} fecRatio FEC ratio (0.0 to ~2.0)
|
||||
* @param {boolean} hasQuality Whether a quality report is attached
|
||||
* @returns {Uint8Array} 12-byte header
|
||||
*/
|
||||
_buildHeader(seq, timestampMs, isRepair = false, codecId = 0, fecBlock = 0, fecSymbol = 0, fecRatio = 0, hasQuality = false) {
|
||||
const buf = new ArrayBuffer(WZP_WS_FULL_HEADER_SIZE);
|
||||
const view = new DataView(buf);
|
||||
|
||||
const fecRatioEncoded = Math.min(127, Math.round(fecRatio * 63.5));
|
||||
const byte0 = ((0 & 0x01) << 7) // version=0
|
||||
| ((isRepair ? 1 : 0) << 6) // T bit
|
||||
| ((codecId & 0x0F) << 2) // CodecID
|
||||
| ((hasQuality ? 1 : 0) << 1) // Q bit
|
||||
| ((fecRatioEncoded >> 6) & 0x01); // FecRatioHi
|
||||
view.setUint8(0, byte0);
|
||||
|
||||
const byte1 = (fecRatioEncoded & 0x3F) << 2;
|
||||
view.setUint8(1, byte1);
|
||||
|
||||
view.setUint16(2, seq & 0xFFFF); // big-endian (default for DataView)
|
||||
view.setUint32(4, timestampMs & 0xFFFFFFFF); // big-endian
|
||||
view.setUint8(8, fecBlock & 0xFF);
|
||||
view.setUint8(9, fecSymbol & 0xFF);
|
||||
view.setUint8(10, 0); // reserved
|
||||
view.setUint8(11, 0); // csrc_count
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a 12-byte MediaHeader from received binary data.
|
||||
*
|
||||
* @param {Uint8Array} data At least 12 bytes
|
||||
* @returns {Object|null} Parsed header fields, or null if too short
|
||||
*/
|
||||
_parseHeader(data) {
|
||||
if (data.byteLength < WZP_WS_FULL_HEADER_SIZE) return null;
|
||||
const view = new DataView(data.buffer || data, data.byteOffset || 0, 12);
|
||||
const byte0 = view.getUint8(0);
|
||||
const byte1 = view.getUint8(1);
|
||||
const fecRatioEncoded = ((byte0 & 0x01) << 6) | ((byte1 >> 2) & 0x3F);
|
||||
return {
|
||||
version: (byte0 >> 7) & 1,
|
||||
isRepair: !!((byte0 >> 6) & 1),
|
||||
codecId: (byte0 >> 2) & 0x0F,
|
||||
hasQuality: !!((byte0 >> 1) & 1),
|
||||
fecRatio: fecRatioEncoded / 63.5,
|
||||
seq: view.getUint16(2),
|
||||
timestamp: view.getUint32(4),
|
||||
fecBlock: view.getUint8(8),
|
||||
fecSymbol: view.getUint8(9),
|
||||
reserved: view.getUint8(10),
|
||||
csrcCount: view.getUint8(11),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad a PCM frame into a FEC symbol with a 2-byte length prefix.
|
||||
*
|
||||
* @param {Uint8Array} pcmBytes Raw PCM bytes
|
||||
* @returns {Uint8Array} Padded symbol of WZP_WS_FULL_SYMBOL_SIZE bytes
|
||||
*/
|
||||
_padToSymbol(pcmBytes) {
|
||||
const symbol = new Uint8Array(WZP_WS_FULL_SYMBOL_SIZE);
|
||||
const len = pcmBytes.length;
|
||||
symbol[0] = (len >> 8) & 0xFF;
|
||||
symbol[1] = len & 0xFF;
|
||||
symbol.set(pcmBytes, WZP_WS_FULL_LENGTH_PREFIX);
|
||||
return symbol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the original PCM payload from a FEC symbol (strip prefix + padding).
|
||||
*
|
||||
* @param {Uint8Array} symbol Symbol data
|
||||
* @returns {Uint8Array} Original PCM bytes
|
||||
*/
|
||||
_unpadSymbol(symbol) {
|
||||
const len = (symbol[0] << 8) | symbol[1];
|
||||
if (len > WZP_WS_FULL_SYMBOL_SIZE - WZP_WS_FULL_LENGTH_PREFIX) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
return symbol.slice(WZP_WS_FULL_LENGTH_PREFIX, WZP_WS_FULL_LENGTH_PREFIX + len);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open WebSocket connection, load WASM, and perform key exchange.
|
||||
*
|
||||
* Key exchange protocol over WebSocket:
|
||||
* 1. After WS open, send our 32-byte X25519 public key as first binary message.
|
||||
* 2. First received binary message of exactly 32 bytes = peer's public key.
|
||||
* 3. Derive shared secret, create WzpCryptoSession.
|
||||
* 4. All subsequent binary messages are encrypted MediaPackets.
|
||||
*
|
||||
* @returns {Promise<void>} resolves when connected and key exchange completes
|
||||
*/
|
||||
async connect() {
|
||||
if (this._connected) return;
|
||||
|
||||
// Load WASM first (needed for key exchange).
|
||||
await this.loadWasm();
|
||||
|
||||
// Prepare key exchange.
|
||||
this._keyExchange = new this._wasmModule.WzpKeyExchange();
|
||||
this._keyExchangeComplete = false;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this._status('Connecting (WZP-WS-Full) to room: ' + this.room + '...');
|
||||
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.seq = 0;
|
||||
this.startTimestamp = Date.now();
|
||||
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
|
||||
this._startTime = Date.now();
|
||||
this._fecBlockId = 0;
|
||||
|
||||
// Send auth if token provided.
|
||||
if (this.authToken) {
|
||||
this.ws.send(JSON.stringify({ type: 'auth', token: this.authToken }));
|
||||
this._authenticated = false;
|
||||
} else {
|
||||
this._authenticated = true;
|
||||
// No auth needed — proceed directly to key exchange.
|
||||
this._status('Performing key exchange...');
|
||||
const ourPub = this._keyExchange.public_key();
|
||||
this.ws.send(new Uint8Array(ourPub).buffer);
|
||||
}
|
||||
|
||||
// Store resolve/reject for key exchange completion.
|
||||
this._keyExchangeResolve = resolve;
|
||||
this._keyExchangeReject = reject;
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
// Handle text messages (auth responses).
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'auth_ok') {
|
||||
this._authenticated = true;
|
||||
this._status('Authenticated, performing key exchange...');
|
||||
// Auth succeeded — now send public key for key exchange.
|
||||
const ourPub = this._keyExchange.public_key();
|
||||
this.ws.send(new Uint8Array(ourPub).buffer);
|
||||
}
|
||||
if (msg.type === 'auth_error') {
|
||||
this._status('Auth failed: ' + (msg.reason || 'unknown'));
|
||||
if (this._keyExchangeReject) {
|
||||
this._keyExchangeReject(new Error('Auth failed: ' + (msg.reason || 'unknown')));
|
||||
this._keyExchangeResolve = null;
|
||||
this._keyExchangeReject = null;
|
||||
}
|
||||
this._cleanup();
|
||||
}
|
||||
} catch(e) { /* ignore non-JSON text */ }
|
||||
return;
|
||||
}
|
||||
if (!this._keyExchangeComplete) {
|
||||
this._handleKeyExchange(event);
|
||||
} else {
|
||||
this._handleMessage(event);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
const was = this._connected;
|
||||
this._cleanup();
|
||||
if (was) {
|
||||
this._status('Disconnected');
|
||||
} else if (this._keyExchangeReject) {
|
||||
this._keyExchangeReject(new Error('Connection closed during key exchange'));
|
||||
this._keyExchangeResolve = null;
|
||||
this._keyExchangeReject = null;
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
if (!this._connected) {
|
||||
this._cleanup();
|
||||
if (this._keyExchangeReject) {
|
||||
this._keyExchangeReject(new Error('WebSocket connection failed'));
|
||||
this._keyExchangeResolve = null;
|
||||
this._keyExchangeReject = null;
|
||||
} else {
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
}
|
||||
} else {
|
||||
this._status('Connection error');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the key exchange: first binary message of 32 bytes = peer's public key.
|
||||
*/
|
||||
_handleKeyExchange(event) {
|
||||
if (!(event.data instanceof ArrayBuffer)) return;
|
||||
const data = new Uint8Array(event.data);
|
||||
|
||||
if (data.length === WZP_WS_FULL_PUBKEY_SIZE) {
|
||||
// Received peer's public key — derive shared secret.
|
||||
try {
|
||||
const peerPub = data;
|
||||
const secret = this._keyExchange.derive_shared_secret(peerPub);
|
||||
this.cryptoSession = new this._wasmModule.WzpCryptoSession(secret);
|
||||
|
||||
// Free key exchange object (no longer needed).
|
||||
this._keyExchange.free();
|
||||
this._keyExchange = null;
|
||||
|
||||
// Initialize FEC encoder/decoder.
|
||||
this.fecEncoder = new this._wasmModule.WzpFecEncoder(
|
||||
WZP_WS_FULL_BLOCK_SIZE,
|
||||
WZP_WS_FULL_SYMBOL_SIZE
|
||||
);
|
||||
this.fecDecoder = new this._wasmModule.WzpFecDecoder(
|
||||
WZP_WS_FULL_BLOCK_SIZE,
|
||||
WZP_WS_FULL_SYMBOL_SIZE
|
||||
);
|
||||
|
||||
this._keyExchangeComplete = true;
|
||||
this._connected = true;
|
||||
this._startStatsTimer();
|
||||
this._status('Connected (WZP-WS-Full) to room: ' + this.room + ' (encrypted, FEC active)');
|
||||
|
||||
if (this._keyExchangeResolve) {
|
||||
this._keyExchangeResolve();
|
||||
this._keyExchangeResolve = null;
|
||||
this._keyExchangeReject = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[wzp-ws-full] Key exchange failed:', e);
|
||||
if (this._keyExchangeReject) {
|
||||
this._keyExchangeReject(new Error('Key exchange failed: ' + e.message));
|
||||
this._keyExchangeResolve = null;
|
||||
this._keyExchangeReject = null;
|
||||
}
|
||||
this._cleanup();
|
||||
}
|
||||
}
|
||||
// Ignore non-32-byte messages during key exchange.
|
||||
}
|
||||
|
||||
/**
|
||||
* Close WebSocket and clean up all resources.
|
||||
*/
|
||||
disconnect() {
|
||||
this._connected = false;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this._stopStatsTimer();
|
||||
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;
|
||||
}
|
||||
if (this._keyExchange) {
|
||||
try { this._keyExchange.free(); } catch (_) { /* ignore */ }
|
||||
this._keyExchange = null;
|
||||
}
|
||||
this._keyExchangeComplete = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a PCM audio frame with FEC encoding + encryption over the WebSocket.
|
||||
*
|
||||
* Pipeline: PCM -> pad to FEC symbol -> FEC encode -> encrypt -> WS send.
|
||||
*
|
||||
* Each FEC symbol is encrypted individually with ChaCha20-Poly1305. The
|
||||
* 12-byte MediaHeader is used as AAD (authenticated but not encrypted),
|
||||
* so the relay can inspect routing fields without decrypting the payload.
|
||||
*
|
||||
* Wire format per packet:
|
||||
* header(12) + ciphertext(symbol_size) + tag(16)
|
||||
*
|
||||
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
||||
*/
|
||||
async sendAudio(pcmBuffer) {
|
||||
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
if (!this.cryptoSession || !this.fecEncoder) return;
|
||||
|
||||
const pcmBytes = new Uint8Array(pcmBuffer);
|
||||
|
||||
// Pad PCM frame to FEC symbol size with length prefix.
|
||||
const symbol = this._padToSymbol(pcmBytes);
|
||||
|
||||
// Feed to FEC encoder. Returns wire data when block completes.
|
||||
const fecOutput = this.fecEncoder.add_symbol(symbol);
|
||||
|
||||
if (fecOutput) {
|
||||
// Block completed — encrypt and send all packets (source + repair).
|
||||
const fecPacketSize = WZP_WS_FULL_FEC_HEADER_SIZE + WZP_WS_FULL_SYMBOL_SIZE;
|
||||
const timestampMs = Date.now() - this.startTimestamp;
|
||||
|
||||
for (let offset = 0; offset + fecPacketSize <= fecOutput.length; offset += fecPacketSize) {
|
||||
const blockId = fecOutput[offset];
|
||||
const symbolIdx = fecOutput[offset + 1];
|
||||
const isRepair = fecOutput[offset + 2] !== 0;
|
||||
const symbolData = fecOutput.slice(
|
||||
offset + WZP_WS_FULL_FEC_HEADER_SIZE,
|
||||
offset + fecPacketSize
|
||||
);
|
||||
|
||||
// Build WZP MediaHeader (used as AAD for encryption).
|
||||
// fecRatio ~0.5 for 50% repair overhead.
|
||||
const header = this._buildHeader(
|
||||
this.seq,
|
||||
timestampMs,
|
||||
isRepair,
|
||||
0, // codecId = RawPcm16
|
||||
blockId,
|
||||
symbolIdx,
|
||||
0.5, // fecRatio
|
||||
false // hasQuality
|
||||
);
|
||||
|
||||
// Encrypt: header as AAD, FEC symbol data as plaintext.
|
||||
// Returns ciphertext + tag (symbol_size + 16 bytes).
|
||||
const ciphertext = this.cryptoSession.encrypt(header, symbolData);
|
||||
this.stats.encrypted++;
|
||||
|
||||
// Wire frame: header(12) + ciphertext_with_tag
|
||||
const packet = new Uint8Array(WZP_WS_FULL_HEADER_SIZE + ciphertext.length);
|
||||
packet.set(header, 0);
|
||||
packet.set(ciphertext, WZP_WS_FULL_HEADER_SIZE);
|
||||
|
||||
this.ws.send(packet.buffer);
|
||||
this.seq = (this.seq + 1) & 0xFFFF;
|
||||
this.stats.sent++;
|
||||
}
|
||||
|
||||
this._fecBlockId++;
|
||||
}
|
||||
// If block not yet complete, accumulate (no packets sent yet).
|
||||
}
|
||||
|
||||
/**
|
||||
* Test crypto + FEC roundtrip entirely in WASM (no network).
|
||||
* Simulates: key exchange -> encrypt -> FEC encode -> simulate loss ->
|
||||
* FEC decode -> decrypt -> verify.
|
||||
*
|
||||
* @returns {Object} Test results
|
||||
*/
|
||||
testCryptoFec() {
|
||||
if (!this.wasmReady || !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());
|
||||
|
||||
let secretsMatch = aliceSecret.length === bobSecret.length;
|
||||
if (secretsMatch) {
|
||||
for (let i = 0; i < aliceSecret.length; i++) {
|
||||
if (aliceSecret[i] !== bobSecret[i]) { secretsMatch = false; break; }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Crypto sessions ---
|
||||
const aliceSession = new wasm.WzpCryptoSession(aliceSecret);
|
||||
const bobSession = new wasm.WzpCryptoSession(bobSecret);
|
||||
|
||||
// --- Encrypt + FEC encode ---
|
||||
const encoder = new wasm.WzpFecEncoder(WZP_WS_FULL_BLOCK_SIZE, WZP_WS_FULL_SYMBOL_SIZE);
|
||||
const decoder = new wasm.WzpFecDecoder(WZP_WS_FULL_BLOCK_SIZE, WZP_WS_FULL_SYMBOL_SIZE);
|
||||
|
||||
// Generate test PCM frames (known data).
|
||||
const originalFrames = [];
|
||||
for (let i = 0; i < WZP_WS_FULL_BLOCK_SIZE; i++) {
|
||||
const frame = new Uint8Array(1920);
|
||||
for (let j = 0; j < 1920; j++) {
|
||||
frame[j] = ((i * 37 + 7) + j) & 0xFF;
|
||||
}
|
||||
originalFrames.push(frame);
|
||||
}
|
||||
|
||||
// Pad and FEC-encode.
|
||||
const paddedSymbols = [];
|
||||
let wireData = null;
|
||||
for (const frame of originalFrames) {
|
||||
const sym = new Uint8Array(WZP_WS_FULL_SYMBOL_SIZE);
|
||||
sym[0] = (frame.length >> 8) & 0xFF;
|
||||
sym[1] = frame.length & 0xFF;
|
||||
sym.set(frame, 2);
|
||||
paddedSymbols.push(sym);
|
||||
|
||||
const result = encoder.add_symbol(sym);
|
||||
if (result) wireData = result;
|
||||
}
|
||||
|
||||
if (!wireData) wireData = encoder.flush();
|
||||
|
||||
// Parse FEC packets and encrypt each one.
|
||||
const FEC_HDR = WZP_WS_FULL_FEC_HEADER_SIZE;
|
||||
const fecPacketSize = FEC_HDR + WZP_WS_FULL_SYMBOL_SIZE;
|
||||
const encryptedPackets = [];
|
||||
|
||||
if (wireData) {
|
||||
for (let offset = 0; offset + fecPacketSize <= wireData.length; offset += fecPacketSize) {
|
||||
const blockId = wireData[offset];
|
||||
const symbolIdx = wireData[offset + 1];
|
||||
const isRepair = wireData[offset + 2] !== 0;
|
||||
const symbolData = wireData.slice(offset + FEC_HDR, offset + fecPacketSize);
|
||||
|
||||
// Build header for AAD (match wire protocol bit layout).
|
||||
const header = new Uint8Array(WZP_WS_FULL_HEADER_SIZE);
|
||||
const fecRatioEncoded = Math.min(127, Math.round(0.5 * 63.5)); // 50% FEC
|
||||
header[0] = ((isRepair ? 1 : 0) << 6)
|
||||
| ((0 & 0x0F) << 2) // codecId=0
|
||||
| ((fecRatioEncoded >> 6) & 0x01); // FecRatioHi
|
||||
header[1] = (fecRatioEncoded & 0x3F) << 2; // FecRatioLo
|
||||
header[8] = blockId;
|
||||
header[9] = symbolIdx;
|
||||
|
||||
// Encrypt with Alice's session.
|
||||
const ciphertext = aliceSession.encrypt(header, symbolData);
|
||||
|
||||
encryptedPackets.push({
|
||||
blockId, symbolIdx, isRepair, header, ciphertext,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sourcePackets = encryptedPackets.filter(p => !p.isRepair).length;
|
||||
const repairPackets = encryptedPackets.filter(p => p.isRepair).length;
|
||||
|
||||
// --- Simulate 30% loss (drop 2 of ~7 packets) ---
|
||||
const dropIndices = new Set([1, 3]);
|
||||
const surviving = encryptedPackets.filter((_, i) => !dropIndices.has(i));
|
||||
|
||||
// --- Decrypt + FEC decode on Bob's side ---
|
||||
let fecDecoded = null;
|
||||
let decryptOk = true;
|
||||
|
||||
for (const pkt of surviving) {
|
||||
let symbolData;
|
||||
try {
|
||||
symbolData = bobSession.decrypt(pkt.header, pkt.ciphertext);
|
||||
} catch (e) {
|
||||
decryptOk = false;
|
||||
break;
|
||||
}
|
||||
|
||||
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, symbolData);
|
||||
if (result) {
|
||||
fecDecoded = result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Verify recovered frames ---
|
||||
let fecOk = false;
|
||||
if (fecDecoded) {
|
||||
fecOk = true;
|
||||
for (let i = 0; i < WZP_WS_FULL_BLOCK_SIZE && fecOk; i++) {
|
||||
const symOffset = i * WZP_WS_FULL_SYMBOL_SIZE;
|
||||
if (symOffset + WZP_WS_FULL_SYMBOL_SIZE > fecDecoded.length) {
|
||||
fecOk = false;
|
||||
break;
|
||||
}
|
||||
const sym = fecDecoded.slice(symOffset, symOffset + WZP_WS_FULL_SYMBOL_SIZE);
|
||||
const len = (sym[0] << 8) | sym[1];
|
||||
const recovered = sym.slice(2, 2 + len);
|
||||
|
||||
if (recovered.length !== originalFrames[i].length) {
|
||||
fecOk = false;
|
||||
break;
|
||||
}
|
||||
for (let j = 0; j < recovered.length; j++) {
|
||||
if (recovered[j] !== originalFrames[i][j]) {
|
||||
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 && decryptOk && fecOk,
|
||||
secretsMatch,
|
||||
decryptOk,
|
||||
fecOk,
|
||||
sourcePackets,
|
||||
repairPackets,
|
||||
totalPackets: encryptedPackets.length,
|
||||
dropped: dropIndices.size,
|
||||
surviving: surviving.length,
|
||||
elapsed: elapsed.toFixed(2) + 'ms',
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
_handleMessage(event) {
|
||||
if (!(event.data instanceof ArrayBuffer)) return;
|
||||
const data = new Uint8Array(event.data);
|
||||
if (data.length < WZP_WS_FULL_HEADER_SIZE) return;
|
||||
|
||||
const header = this._parseHeader(data);
|
||||
if (!header) return;
|
||||
|
||||
this.stats.recv++;
|
||||
|
||||
if (!this.cryptoSession || !this.fecDecoder) return;
|
||||
|
||||
// Extract header bytes (AAD) and ciphertext.
|
||||
const headerBytes = data.slice(0, WZP_WS_FULL_HEADER_SIZE);
|
||||
const ciphertext = data.slice(WZP_WS_FULL_HEADER_SIZE);
|
||||
|
||||
// Decrypt.
|
||||
let symbolData;
|
||||
try {
|
||||
symbolData = this.cryptoSession.decrypt(headerBytes, ciphertext);
|
||||
this.stats.decrypted++;
|
||||
} catch (e) {
|
||||
// Decryption failure — corrupted or replayed packet.
|
||||
console.warn('[wzp-ws-full] decrypt failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Feed decrypted symbol to FEC decoder.
|
||||
const decoded = this.fecDecoder.add_symbol(
|
||||
header.fecBlock,
|
||||
header.fecSymbol,
|
||||
header.isRepair,
|
||||
symbolData
|
||||
);
|
||||
|
||||
if (decoded) {
|
||||
this.stats.fecRecovered++;
|
||||
|
||||
// decoded is concatenated padded symbols.
|
||||
// Each symbol is WZP_WS_FULL_SYMBOL_SIZE bytes with a 2-byte length prefix.
|
||||
for (let off = 0; off + WZP_WS_FULL_SYMBOL_SIZE <= decoded.length; off += WZP_WS_FULL_SYMBOL_SIZE) {
|
||||
const symbol = decoded.slice(off, off + WZP_WS_FULL_SYMBOL_SIZE);
|
||||
const pcmBytes = this._unpadSymbol(symbol);
|
||||
|
||||
if (pcmBytes.length > 0 && pcmBytes.length % 2 === 0) {
|
||||
const pcm = new Int16Array(
|
||||
pcmBytes.buffer,
|
||||
pcmBytes.byteOffset,
|
||||
pcmBytes.byteLength / 2
|
||||
);
|
||||
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,
|
||||
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._keyExchangeComplete = false;
|
||||
this._stopStatsTimer();
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch (_) { /* ignore */ }
|
||||
this.ws = 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;
|
||||
}
|
||||
if (this._keyExchange) {
|
||||
try { this._keyExchange.free(); } catch (_) { /* ignore */ }
|
||||
this._keyExchange = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
window.WZPWsFullClient = WZPWsFullClient;
|
||||
289
crates/wzp-web/static/js/wzp-ws.js
Normal file
289
crates/wzp-web/static/js/wzp-ws.js
Normal file
@@ -0,0 +1,289 @@
|
||||
// WarzonePhone — WZP-WS client (Variant 4).
|
||||
// WebSocket transport, WZP wire protocol, no WASM.
|
||||
// Sends MediaPacket-formatted frames instead of raw PCM.
|
||||
// Ready for direct relay WS support (no bridge translation needed).
|
||||
|
||||
'use strict';
|
||||
|
||||
// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
|
||||
const WZP_WS_HEADER_SIZE = 12;
|
||||
|
||||
class WZPWsClient {
|
||||
/**
|
||||
* @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.authToken = options.authToken || null;
|
||||
this.onAudio = options.onAudio || null;
|
||||
this.onStatus = options.onStatus || null;
|
||||
this.onStats = options.onStats || null;
|
||||
|
||||
this.ws = null;
|
||||
this.seq = 0;
|
||||
this.startTimestamp = 0;
|
||||
this.stats = { sent: 0, recv: 0 };
|
||||
this._startTime = 0;
|
||||
this._statsInterval = null;
|
||||
this._connected = false;
|
||||
this._authenticated = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 12-byte WZP MediaHeader.
|
||||
*
|
||||
* Wire layout (from wzp-proto::packet::MediaHeader):
|
||||
* Byte 0: V(1)|T(1)|CodecID(4)|Q(1)|FecRatioHi(1)
|
||||
* Byte 1: FecRatioLo(6)|Reserved(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)
|
||||
* @param {number} timestampMs Milliseconds since session start
|
||||
* @param {boolean} isRepair True if this is a FEC repair symbol
|
||||
* @param {number} codecId Codec ID (0=RawPcm16, 1=Opus16k, 2=Opus48k)
|
||||
* @param {number} fecBlock FEC block ID (u8)
|
||||
* @param {number} fecSymbol FEC symbol index (u8)
|
||||
* @param {number} fecRatio FEC ratio (0.0 to ~2.0)
|
||||
* @param {boolean} hasQuality Whether a quality report is attached
|
||||
* @returns {Uint8Array} 12-byte header
|
||||
*/
|
||||
_buildHeader(seq, timestampMs, isRepair = false, codecId = 0, fecBlock = 0, fecSymbol = 0, fecRatio = 0, hasQuality = false) {
|
||||
const buf = new ArrayBuffer(WZP_WS_HEADER_SIZE);
|
||||
const view = new DataView(buf);
|
||||
|
||||
const fecRatioEncoded = Math.min(127, Math.round(fecRatio * 63.5));
|
||||
const byte0 = ((0 & 0x01) << 7) // version=0
|
||||
| ((isRepair ? 1 : 0) << 6) // T bit
|
||||
| ((codecId & 0x0F) << 2) // CodecID
|
||||
| ((hasQuality ? 1 : 0) << 1) // Q bit
|
||||
| ((fecRatioEncoded >> 6) & 0x01); // FecRatioHi
|
||||
view.setUint8(0, byte0);
|
||||
|
||||
const byte1 = (fecRatioEncoded & 0x3F) << 2;
|
||||
view.setUint8(1, byte1);
|
||||
|
||||
view.setUint16(2, seq & 0xFFFF); // big-endian (default for DataView)
|
||||
view.setUint32(4, timestampMs & 0xFFFFFFFF); // big-endian
|
||||
view.setUint8(8, fecBlock & 0xFF);
|
||||
view.setUint8(9, fecSymbol & 0xFF);
|
||||
view.setUint8(10, 0); // reserved
|
||||
view.setUint8(11, 0); // csrc_count
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a 12-byte MediaHeader from received binary data.
|
||||
*
|
||||
* @param {Uint8Array} data At least 12 bytes
|
||||
* @returns {Object|null} Parsed header fields, or null if too short
|
||||
*/
|
||||
_parseHeader(data) {
|
||||
if (data.byteLength < WZP_WS_HEADER_SIZE) return null;
|
||||
const view = new DataView(data.buffer || data, data.byteOffset || 0, 12);
|
||||
const byte0 = view.getUint8(0);
|
||||
const byte1 = view.getUint8(1);
|
||||
const fecRatioEncoded = ((byte0 & 0x01) << 6) | ((byte1 >> 2) & 0x3F);
|
||||
return {
|
||||
version: (byte0 >> 7) & 1,
|
||||
isRepair: !!((byte0 >> 6) & 1),
|
||||
codecId: (byte0 >> 2) & 0x0F,
|
||||
hasQuality: !!((byte0 >> 1) & 1),
|
||||
fecRatio: fecRatioEncoded / 63.5,
|
||||
seq: view.getUint16(2),
|
||||
timestamp: view.getUint32(4),
|
||||
fecBlock: view.getUint8(8),
|
||||
fecSymbol: view.getUint8(9),
|
||||
reserved: view.getUint8(10),
|
||||
csrcCount: view.getUint8(11),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (WZP-WS) to room: ' + this.room + '...');
|
||||
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
|
||||
this.ws.onopen = () => {
|
||||
// Send auth if token provided.
|
||||
if (this.authToken) {
|
||||
this.ws.send(JSON.stringify({ type: 'auth', token: this.authToken }));
|
||||
}
|
||||
|
||||
this._connected = true;
|
||||
this._authenticated = !this.authToken; // authenticated immediately if no token needed
|
||||
this.seq = 0;
|
||||
this.startTimestamp = Date.now();
|
||||
this.stats = { sent: 0, recv: 0 };
|
||||
this._startTime = Date.now();
|
||||
this._status('Connected (WZP-WS) to room: ' + this.room);
|
||||
this._startStatsTimer();
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
// Handle text messages (auth responses).
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'auth_ok') {
|
||||
this._authenticated = true;
|
||||
this._status('Authenticated (WZP-WS) to room: ' + this.room);
|
||||
}
|
||||
if (msg.type === 'auth_error') {
|
||||
this._status('Auth failed: ' + (msg.reason || 'unknown'));
|
||||
this.disconnect();
|
||||
}
|
||||
} catch(e) { /* ignore non-JSON text */ }
|
||||
return;
|
||||
}
|
||||
this._handleMessage(event);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
const was = this._connected;
|
||||
this._cleanup();
|
||||
if (was) this._status('Disconnected');
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
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 wrapped in a WZP MediaPacket over the WebSocket.
|
||||
*
|
||||
* Wire format: 12-byte MediaHeader + raw PCM payload.
|
||||
* The relay can parse this natively without bridge translation.
|
||||
*
|
||||
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
||||
*/
|
||||
async sendAudio(pcmBuffer) {
|
||||
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const header = this._buildHeader(
|
||||
this.seq,
|
||||
Date.now() - this.startTimestamp,
|
||||
false, 0, 0, 0, 0, false
|
||||
);
|
||||
|
||||
// Combine header + payload into single binary frame.
|
||||
const pcmBytes = new Uint8Array(pcmBuffer);
|
||||
const packet = new Uint8Array(WZP_WS_HEADER_SIZE + pcmBytes.length);
|
||||
packet.set(header, 0);
|
||||
packet.set(pcmBytes, WZP_WS_HEADER_SIZE);
|
||||
|
||||
this.ws.send(packet.buffer);
|
||||
this.seq = (this.seq + 1) & 0xFFFF;
|
||||
this.stats.sent++;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
_handleMessage(event) {
|
||||
if (!(event.data instanceof ArrayBuffer)) return;
|
||||
const data = new Uint8Array(event.data);
|
||||
if (data.length < WZP_WS_HEADER_SIZE) return; // too small for header
|
||||
|
||||
const header = this._parseHeader(data);
|
||||
if (!header) return;
|
||||
|
||||
// Extract payload (everything after 12-byte header).
|
||||
// Payload is raw PCM Int16 samples.
|
||||
const payloadBytes = data.slice(WZP_WS_HEADER_SIZE);
|
||||
const pcm = new Int16Array(
|
||||
payloadBytes.buffer,
|
||||
payloadBytes.byteOffset,
|
||||
payloadBytes.byteLength / 2
|
||||
);
|
||||
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,
|
||||
});
|
||||
}
|
||||
}, 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.WZPWsClient = WZPWsClient;
|
||||
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.
|
||||
|
||||
```
|
||||
Lossy / censored link
|
||||
◄──────────────────────►
|
||||
┌────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────┐
|
||||
│ Client │─QUIC─│ Relay A │─QUIC─│ Relay B │─QUIC─│ Destination │
|
||||
└────────┘ └─────────┘ └─────────┘ └─────────────┘
|
||||
│ │ │ │
|
||||
Encode Forward Forward Decode
|
||||
FEC FEC FEC FEC
|
||||
Encrypt (opaque) (opaque) Decrypt
|
||||
## System Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Client A (Browser/CLI)"
|
||||
MIC[Microphone] --> DN[NoiseSupressor<br/>RNNoise ML]
|
||||
DN --> SD[SilenceDetector<br/>VAD + Hangover]
|
||||
SD --> ENC[CallEncoder<br/>Opus/Codec2]
|
||||
ENC --> FEC_E[FEC Encoder<br/>RaptorQ]
|
||||
FEC_E --> CRYPT_E[ChaCha20-Poly1305<br/>Encrypt]
|
||||
CRYPT_E --> QUIC_S[QUIC Datagram<br/>Send]
|
||||
|
||||
QUIC_R[QUIC Datagram<br/>Recv] --> CRYPT_D[ChaCha20-Poly1305<br/>Decrypt]
|
||||
CRYPT_D --> FEC_D[FEC Decoder<br/>RaptorQ]
|
||||
FEC_D --> JIT[JitterBuffer<br/>Adaptive Playout]
|
||||
JIT --> DEC[CallDecoder<br/>Opus/Codec2]
|
||||
DEC --> SPK[Speaker]
|
||||
end
|
||||
|
||||
subgraph "Relay (SFU)"
|
||||
ACCEPT[Accept QUIC] --> AUTH{Auth?}
|
||||
AUTH -->|token| VALIDATE[POST /v1/auth/validate]
|
||||
AUTH -->|no auth| HS
|
||||
VALIDATE --> HS[Crypto Handshake<br/>X25519 + Ed25519]
|
||||
HS --> ROOM[Room Manager<br/>Named Rooms via SNI]
|
||||
ROOM --> FWD[Forward to<br/>Other Participants]
|
||||
end
|
||||
|
||||
subgraph "Client B"
|
||||
B_SPK[Speaker]
|
||||
B_MIC[Microphone]
|
||||
end
|
||||
|
||||
QUIC_S -->|UDP/QUIC| ACCEPT
|
||||
FWD -->|UDP/QUIC| QUIC_R
|
||||
B_MIC -.->|same pipeline| ACCEPT
|
||||
FWD -.->|same pipeline| B_SPK
|
||||
|
||||
style MIC fill:#4a9eff
|
||||
style SPK fill:#4a9eff
|
||||
style B_MIC fill:#4a9eff
|
||||
style B_SPK fill:#4a9eff
|
||||
style ROOM fill:#ff9f43
|
||||
style CRYPT_E fill:#ee5a24
|
||||
style CRYPT_D fill:#ee5a24
|
||||
```
|
||||
|
||||
In the simplest deployment a single relay serves as the meeting point (room mode, SFU). Clients connect directly to one relay, which forwards media to all other participants in the same room. For censorship-resistant links, two relays can be chained: a client-facing relay forwards all traffic to a remote relay via QUIC.
|
||||
## Crate Dependency Graph
|
||||
|
||||
Room names are carried in the QUIC SNI field during the TLS handshake, so a single relay can host many independent rooms without additional signaling.
|
||||
```mermaid
|
||||
graph TD
|
||||
PROTO[wzp-proto<br/>Types, Traits, Wire Format]
|
||||
|
||||
## Protocol Stack
|
||||
CODEC[wzp-codec<br/>Opus + Codec2 + RNNoise]
|
||||
FEC[wzp-fec<br/>RaptorQ FEC]
|
||||
CRYPTO[wzp-crypto<br/>ChaCha20 + Identity]
|
||||
TRANSPORT[wzp-transport<br/>QUIC/Quinn]
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Application (Opus / Codec2 audio) │ wzp-codec
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Redundancy (RaptorQ FEC + interleaving) │ wzp-fec
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Crypto (ChaCha20-Poly1305 + AEAD) │ wzp-crypto
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Transport (QUIC DATAGRAM + reliable stream) │ wzp-transport
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Obfuscation (Phase 2 — trait defined) │ wzp-proto::ObfuscationLayer
|
||||
└──────────────────────────────────────────────┘
|
||||
RELAY[wzp-relay<br/>Relay Daemon]
|
||||
CLIENT[wzp-client<br/>CLI + Call Engine]
|
||||
WEB[wzp-web<br/>Browser Bridge]
|
||||
|
||||
PROTO --> CODEC
|
||||
PROTO --> FEC
|
||||
PROTO --> CRYPTO
|
||||
PROTO --> TRANSPORT
|
||||
|
||||
CODEC --> CLIENT
|
||||
FEC --> CLIENT
|
||||
CRYPTO --> CLIENT
|
||||
TRANSPORT --> CLIENT
|
||||
CODEC --> RELAY
|
||||
FEC --> RELAY
|
||||
CRYPTO --> RELAY
|
||||
TRANSPORT --> RELAY
|
||||
|
||||
CLIENT --> WEB
|
||||
TRANSPORT --> WEB
|
||||
CRYPTO --> WEB
|
||||
|
||||
FC[warzone-protocol<br/>featherChat Identity] -.->|path dep| CRYPTO
|
||||
|
||||
style PROTO fill:#6c5ce7
|
||||
style RELAY fill:#ff9f43
|
||||
style CLIENT fill:#00b894
|
||||
style WEB fill:#0984e3
|
||||
style FC fill:#fd79a8
|
||||
```
|
||||
|
||||
Audio and FEC are end-to-end between caller and callee. The relay operates on opaque, encrypted, FEC-protected packets. Crypto keys are never shared with relays.
|
||||
|
||||
## Wire Format
|
||||
## Wire Formats
|
||||
|
||||
### MediaHeader (12 bytes)
|
||||
|
||||
```
|
||||
Byte 0: [V:1][T:1][CodecID:4][Q:1][FecRatioHi:1]
|
||||
Byte 1: [FecRatioLo:6][unused:2]
|
||||
Byte 2-3: Sequence number (big-endian u16)
|
||||
Byte 4-7: Timestamp in ms since session start (big-endian u32)
|
||||
Byte 8: FEC block ID (wrapping u8)
|
||||
Byte 9: FEC symbol index within block
|
||||
Byte 10: Reserved / flags
|
||||
Byte 11: CSRC count (for future mixing)
|
||||
Byte 0: [V:1][T:1][CodecID:4][Q:1][FecHi:1]
|
||||
Byte 1: [FecLo:6][unused:2]
|
||||
Bytes 2-3: sequence (u16 BE)
|
||||
Bytes 4-7: timestamp_ms (u32 BE)
|
||||
Byte 8: fec_block_id (u8)
|
||||
Byte 9: fec_symbol_idx (u8)
|
||||
Byte 10: reserved
|
||||
Byte 11: csrc_count
|
||||
|
||||
V = version (0), T = is_repair, CodecID = codec, Q = quality_report appended
|
||||
```
|
||||
|
||||
Field details:
|
||||
|
||||
| Field | Bits | Description |
|
||||
|-------|------|-------------|
|
||||
| V | 1 | Protocol version (0 = v1) |
|
||||
| T | 1 | 1 = FEC repair packet, 0 = source media |
|
||||
| CodecID | 4 | Codec identifier (0=Opus24k, 1=Opus16k, 2=Opus6k, 3=Codec2_3200, 4=Codec2_1200) |
|
||||
| Q | 1 | QualityReport trailer appended |
|
||||
| FecRatio | 7 | FEC ratio encoded as 7-bit value (0-127 maps to 0.0-2.0) |
|
||||
| Seq | 16 | Wrapping packet sequence number |
|
||||
| Timestamp | 32 | Milliseconds since session start |
|
||||
| FEC block | 8 | Source block ID (wrapping) |
|
||||
| FEC symbol | 8 | Symbol index within the FEC block |
|
||||
| Reserved | 8 | Reserved flags |
|
||||
| CSRC count | 8 | Contributing source count (future) |
|
||||
|
||||
Defined in `crates/wzp-proto/src/packet.rs` as `MediaHeader`.
|
||||
|
||||
### QualityReport (4 bytes)
|
||||
|
||||
Appended to a media packet when the Q flag is set.
|
||||
### MiniHeader (4 bytes, compressed)
|
||||
|
||||
```
|
||||
Byte 0: loss_pct — 0-255 maps to 0-100% loss
|
||||
Byte 1: rtt_4ms — RTT in 4ms units (0-255 = 0-1020ms)
|
||||
Byte 2: jitter_ms — Jitter in milliseconds
|
||||
Byte 3: bitrate_cap — Max receive bitrate in kbps
|
||||
Bytes 0-1: timestamp_delta_ms (u16 BE)
|
||||
Bytes 2-3: payload_len (u16 BE)
|
||||
|
||||
Preceded by FRAME_TYPE_MINI (0x01). Full header every 50 frames (~1s).
|
||||
Saves 8 bytes/packet (67% header reduction).
|
||||
```
|
||||
|
||||
Defined in `crates/wzp-proto/src/packet.rs` as `QualityReport`.
|
||||
|
||||
### MediaPacket
|
||||
|
||||
A complete media packet on the wire:
|
||||
### TrunkFrame (batched datagrams)
|
||||
|
||||
```
|
||||
[MediaHeader: 12 bytes][Payload: variable][QualityReport: 4 bytes if Q=1]
|
||||
[count:u16]
|
||||
[session_id:2][len:u16][payload:len] x count
|
||||
|
||||
Packs multiple session packets into one QUIC datagram.
|
||||
Max 10 entries or 1200 bytes, flushed every 5ms.
|
||||
```
|
||||
|
||||
Defined in `crates/wzp-proto/src/packet.rs` as `MediaPacket`.
|
||||
|
||||
### SignalMessage (reliable stream)
|
||||
|
||||
Signaling uses length-prefixed JSON over reliable QUIC bidirectional streams. Each message opens a new bidi stream, writes a 4-byte big-endian length prefix followed by the JSON payload, then finishes the send side.
|
||||
|
||||
Variants defined in `crates/wzp-proto/src/packet.rs`:
|
||||
|
||||
- `CallOffer` — identity_pub, ephemeral_pub, signature, supported_profiles
|
||||
- `CallAnswer` — identity_pub, ephemeral_pub, signature, chosen_profile
|
||||
- `IceCandidate` — NAT traversal candidate string
|
||||
- `Rekey` — new_ephemeral_pub, signature
|
||||
- `QualityUpdate` — report, recommended_profile
|
||||
- `Ping` / `Pong` — timestamp_ms for RTT measurement
|
||||
- `Hangup` — reason (Normal, Busy, Declined, Timeout, Error)
|
||||
|
||||
## FEC Strategy
|
||||
|
||||
WarzonePhone uses **RaptorQ fountain codes** (via the `raptorq` crate) for forward error correction. This is implemented in `crates/wzp-fec/`.
|
||||
|
||||
### Block Structure
|
||||
|
||||
Audio frames are grouped into FEC blocks. Each block contains a fixed number of source symbols (configured per quality profile). Each source symbol is a single encoded audio frame, zero-padded to a uniform 256-byte symbol size with a 2-byte little-endian length prefix.
|
||||
|
||||
### Encoding Process
|
||||
|
||||
1. Audio frames are added to the encoder as source symbols
|
||||
2. When a block is full (`frames_per_block` symbols), repair symbols are generated
|
||||
3. The repair ratio determines how many repair symbols: `ceil(num_source * ratio)`
|
||||
4. Both source and repair packets are transmitted with the block ID and symbol index in the header
|
||||
|
||||
### Decoding Process
|
||||
|
||||
1. Received symbols (source or repair) are fed to the decoder keyed by block ID
|
||||
2. The decoder attempts reconstruction when sufficient symbols arrive
|
||||
3. RaptorQ can recover the full block from any `K` symbols out of `K + R` total (where K = source count, R = repair count)
|
||||
4. Old blocks are expired via wrapping u8 distance
|
||||
|
||||
### Interleaving
|
||||
|
||||
The `Interleaver` spreads symbols from multiple FEC blocks across transmission slots in round-robin fashion. With depth=3, a burst loss of 6 consecutive packets damages at most 2 symbols per block instead of 6 symbols in one block.
|
||||
|
||||
### FEC Configuration by Quality Tier
|
||||
|
||||
| Tier | Frames/Block | Repair Ratio | Total Bandwidth Overhead |
|
||||
|------|-------------|-------------|-------------------------|
|
||||
| GOOD | 5 | 0.2 (20%) | 1.2x |
|
||||
| DEGRADED | 10 | 0.5 (50%) | 1.5x |
|
||||
| CATASTROPHIC | 8 | 1.0 (100%) | 2.0x |
|
||||
|
||||
## Adaptive Quality
|
||||
|
||||
Three quality tiers drive codec and FEC selection. The controller is implemented in `crates/wzp-proto/src/quality.rs` as `AdaptiveQualityController`.
|
||||
|
||||
### Tier Thresholds
|
||||
|
||||
| Tier | Loss | RTT | Codec | FEC Ratio |
|
||||
|------|------|-----|-------|-----------|
|
||||
| GOOD | < 10% | < 400ms | Opus 24kbps, 20ms frames | 0.2 |
|
||||
| DEGRADED | 10-40% or 400-600ms | | Opus 6kbps, 40ms frames | 0.5 |
|
||||
| CATASTROPHIC | > 40% or > 600ms | | Codec2 1200bps, 40ms frames | 1.0 |
|
||||
|
||||
### Hysteresis
|
||||
|
||||
- **Downgrade**: Triggers after 3 consecutive reports in a worse tier (fast reaction)
|
||||
- **Upgrade**: Triggers after 10 consecutive reports in a better tier (slow, cautious)
|
||||
- **Step limit**: Upgrades move only one tier at a time (Catastrophic -> Degraded -> Good)
|
||||
- **History**: A sliding window of 20 recent reports is maintained for smoothing
|
||||
- **Force mode**: Manual `force_profile()` disables adaptive logic entirely
|
||||
|
||||
### QualityProfile Constants
|
||||
|
||||
```rust
|
||||
GOOD: Opus24k, fec=0.2, 20ms, 5 frames/block → 28.8 kbps total
|
||||
DEGRADED: Opus6k, fec=0.5, 40ms, 10 frames/block → 9.0 kbps total
|
||||
CATASTROPHIC: Codec2_1200, fec=1.0, 40ms, 8 frames/block → 2.4 kbps total
|
||||
```
|
||||
|
||||
## Encryption
|
||||
|
||||
Implemented in `crates/wzp-crypto/`.
|
||||
|
||||
### Identity Model (Warzone-Compatible)
|
||||
|
||||
- **Seed**: 32-byte random value (BIP39 mnemonic for backup)
|
||||
- **Ed25519**: Derived via `HKDF(seed, "warzone-ed25519-identity")` -- signing/identity
|
||||
- **X25519**: Derived via `HKDF(seed, "warzone-x25519-identity")` -- encryption
|
||||
- **Fingerprint**: `SHA-256(Ed25519_pub)[:16]` -- 128-bit identifier
|
||||
|
||||
### Per-Call Key Exchange
|
||||
|
||||
1. Each side generates an ephemeral X25519 keypair
|
||||
2. Ephemeral public keys are exchanged via `CallOffer`/`CallAnswer` signaling
|
||||
3. Signatures are computed: `Ed25519_sign(ephemeral_pub || context_string)`
|
||||
4. Shared secret: `X25519_DH(our_ephemeral_secret, peer_ephemeral_pub)`
|
||||
5. Session key: `HKDF(shared_secret, "warzone-session-key")` -> 32 bytes
|
||||
|
||||
### Nonce Construction (12 bytes, not transmitted)
|
||||
### QualityReport (4 bytes, optional)
|
||||
|
||||
```
|
||||
session_id[0..4] || sequence_number (u32 BE) || direction (1 byte) || padding (3 bytes zero)
|
||||
Byte 0: loss_pct (0-255 maps to 0-100%)
|
||||
Byte 1: rtt_4ms (0-255 maps to 0-1020ms)
|
||||
Byte 2: jitter_ms
|
||||
Byte 3: bitrate_cap_kbps
|
||||
```
|
||||
|
||||
- `session_id`: First 4 bytes of `SHA-256(session_key)`
|
||||
- `direction`: 0 = Send, 1 = Recv
|
||||
- Nonces are derived deterministically, saving 12 bytes per packet
|
||||
|
||||
### AEAD Encryption
|
||||
|
||||
- Algorithm: ChaCha20-Poly1305
|
||||
- AAD: The 12-byte MediaHeader (authenticated but not encrypted)
|
||||
- Tag: 16 bytes appended to ciphertext
|
||||
- Overhead per packet: 16 bytes
|
||||
|
||||
### Rekeying
|
||||
|
||||
- Trigger: Every 2^16 packets (65536)
|
||||
- Process: New ephemeral X25519 exchange, mixed with old key via HKDF
|
||||
- Key evolution: `HKDF(old_key as salt, new_DH_result, "warzone-rekey")`
|
||||
- Old key is zeroized after derivation (forward secrecy)
|
||||
- Sequence counters reset to 0 after rekey
|
||||
|
||||
### Anti-Replay
|
||||
|
||||
- Sliding window of 1024 packets using a bitmap
|
||||
- Sequence numbers too old (> 1024 behind highest seen) are rejected
|
||||
- Handles u16 wrapping correctly (RFC 1982 serial number arithmetic)
|
||||
- Implemented in `crates/wzp-crypto/src/anti_replay.rs` as `AntiReplayWindow`
|
||||
|
||||
## Jitter Buffer
|
||||
|
||||
Implemented in `crates/wzp-proto/src/jitter.rs` as `JitterBuffer`.
|
||||
|
||||
- **Structure**: BTreeMap keyed by sequence number for ordered playout
|
||||
- **Target depth**: 50 packets (1 second) default
|
||||
- **Max depth**: 250 packets (5 seconds at 20ms/frame)
|
||||
- **Min depth**: 25 packets (0.5 seconds) before playout begins
|
||||
- **Sequence wrapping**: RFC 1982 serial number arithmetic for u16
|
||||
- **Duplicate handling**: Silently dropped
|
||||
- **Late packets**: Packets arriving after their sequence has been played out are dropped
|
||||
- **Overflow**: When buffer exceeds max depth, oldest packets are evicted
|
||||
|
||||
### Playout Results
|
||||
|
||||
- `Packet(MediaPacket)` -- normal delivery
|
||||
- `Missing { seq }` -- gap detected, decoder should generate PLC
|
||||
- `NotReady` -- buffer not yet filled to minimum depth
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- No adaptive depth adjustment based on observed jitter (target_depth is configurable but not self-tuning in the current implementation)
|
||||
- No timestamp-based playout scheduling (uses sequence-number ordering only)
|
||||
- Jitter buffer drift has been observed during long echo tests
|
||||
|
||||
## Session State Machine
|
||||
|
||||
Defined in `crates/wzp-proto/src/session.rs`:
|
||||
### SignalMessage (JSON over reliable QUIC stream)
|
||||
|
||||
```
|
||||
Idle -> Connecting -> Handshaking -> Active <-> Rekeying -> Active
|
||||
|
|
||||
Closed
|
||||
[4-byte length prefix][serde_json payload]
|
||||
|
||||
Variants:
|
||||
CallOffer { identity_pub, ephemeral_pub, signature, supported_profiles }
|
||||
CallAnswer { identity_pub, ephemeral_pub, signature, chosen_profile }
|
||||
IceCandidate { candidate }
|
||||
Hangup { reason: Normal|Busy|Declined|Timeout|Error }
|
||||
AuthToken { token }
|
||||
Hold, Unhold, Mute, Unmute
|
||||
Transfer { target_fingerprint, relay_addr }
|
||||
TransferAck
|
||||
Rekey { new_ephemeral_pub, signature }
|
||||
QualityUpdate { report, recommended_profile }
|
||||
Ping/Pong { timestamp_ms }
|
||||
```
|
||||
|
||||
- Media flows during both `Active` and `Rekeying` states
|
||||
- Any state can transition to `Closed` via `Terminate` or `ConnectionLost`
|
||||
- Invalid transitions produce a `TransitionError`
|
||||
## Quality Profiles
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph GOOD ["GOOD (28.8 kbps)"]
|
||||
G_C[Opus 24kbps]
|
||||
G_F[FEC 20%]
|
||||
G_FR[20ms frames]
|
||||
end
|
||||
|
||||
subgraph DEGRADED ["DEGRADED (9.0 kbps)"]
|
||||
D_C[Opus 6kbps]
|
||||
D_F[FEC 50%]
|
||||
D_FR[40ms frames]
|
||||
end
|
||||
|
||||
subgraph CATASTROPHIC ["CATASTROPHIC (2.4 kbps)"]
|
||||
C_C[Codec2 1200bps]
|
||||
C_F[FEC 100%]
|
||||
C_FR[40ms frames]
|
||||
end
|
||||
|
||||
GOOD -->|"loss>5% or RTT>100ms<br/>3 consecutive reports"| DEGRADED
|
||||
DEGRADED -->|"loss>15% or RTT>200ms<br/>3 consecutive"| CATASTROPHIC
|
||||
CATASTROPHIC -->|"loss<5% and RTT<100ms<br/>3 consecutive"| DEGRADED
|
||||
DEGRADED -->|"loss<5% and RTT<100ms<br/>3 consecutive"| GOOD
|
||||
|
||||
style GOOD fill:#00b894
|
||||
style DEGRADED fill:#fdcb6e
|
||||
style CATASTROPHIC fill:#e17055
|
||||
```
|
||||
|
||||
## Cryptographic Handshake
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Caller
|
||||
participant R as Relay/Callee
|
||||
|
||||
Note over C: Derive identity from seed<br/>Ed25519 + X25519 via HKDF
|
||||
|
||||
C->>C: Generate ephemeral X25519
|
||||
C->>C: Sign(ephemeral_pub || "call-offer")
|
||||
C->>R: CallOffer { identity_pub, ephemeral_pub, signature, profiles }
|
||||
|
||||
R->>R: Verify Ed25519 signature
|
||||
R->>R: Generate ephemeral X25519
|
||||
R->>R: shared_secret = DH(eph_b, eph_a)
|
||||
R->>R: session_key = HKDF(shared_secret, "warzone-session-key")
|
||||
R->>R: Sign(ephemeral_pub || "call-answer")
|
||||
R->>C: CallAnswer { identity_pub, ephemeral_pub, signature, chosen_profile }
|
||||
|
||||
C->>C: Verify signature
|
||||
C->>C: shared_secret = DH(eph_a, eph_b)
|
||||
C->>C: session_key = HKDF(shared_secret)
|
||||
|
||||
Note over C,R: Both have identical ChaCha20-Poly1305 session key
|
||||
C->>R: Encrypted media (QUIC datagrams)
|
||||
R->>C: Encrypted media (QUIC datagrams)
|
||||
|
||||
Note over C,R: Rekey every 65,536 packets<br/>New ephemeral DH + HKDF mix
|
||||
```
|
||||
|
||||
## Identity Model (featherChat Compatible)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
SEED[32-byte Seed<br/>BIP39 Mnemonic 24 words] --> HKDF1[HKDF<br/>salt=None<br/>info=warzone-ed25519]
|
||||
SEED --> HKDF2[HKDF<br/>salt=None<br/>info=warzone-x25519]
|
||||
|
||||
HKDF1 --> ED[Ed25519 SigningKey<br/>Digital Signatures]
|
||||
HKDF2 --> X25519[X25519 StaticSecret<br/>Key Agreement]
|
||||
|
||||
ED --> VKEY[Ed25519 VerifyingKey<br/>Public]
|
||||
X25519 --> XPUB[X25519 PublicKey<br/>Public]
|
||||
|
||||
VKEY --> FP[Fingerprint<br/>SHA-256 pubkey truncated 16 bytes<br/>xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx]
|
||||
|
||||
style SEED fill:#6c5ce7
|
||||
style FP fill:#fd79a8
|
||||
style ED fill:#ee5a24
|
||||
style X25519 fill:#00b894
|
||||
```
|
||||
|
||||
## Relay Modes
|
||||
|
||||
### Room Mode (Default, SFU)
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Room Mode (Default SFU)"
|
||||
C1[Client 1] -->|QUIC SNI=room-hash| RM[Room Manager]
|
||||
C2[Client 2] -->|QUIC SNI=room-hash| RM
|
||||
C3[Client 3] -->|QUIC SNI=room-hash| RM
|
||||
RM --> R1[Room abc123]
|
||||
R1 -->|fan-out| C1
|
||||
R1 -->|fan-out| C2
|
||||
R1 -->|fan-out| C3
|
||||
end
|
||||
|
||||
- Clients join named rooms via QUIC SNI
|
||||
- When a participant sends a packet, the relay forwards it to all other participants
|
||||
- No transcoding -- packets are forwarded opaquely
|
||||
- Rooms are auto-created when the first participant joins and auto-deleted when empty
|
||||
- Managed by `RoomManager` in `crates/wzp-relay/src/room.rs`
|
||||
subgraph "Forward Mode with --remote"
|
||||
C4[Client] -->|QUIC| RA[Relay A]
|
||||
RA -->|FEC decode then jitter then FEC encode| RB[Relay B]
|
||||
RB -->|QUIC| C5[Client]
|
||||
end
|
||||
|
||||
### Forward Mode (`--remote`)
|
||||
subgraph "Probe Mode with --probe"
|
||||
PA[Relay A] -->|Ping 1/s ~50 bytes| PB[Relay B]
|
||||
PB -->|Pong| PA
|
||||
PA --> PM[Prometheus<br/>RTT Loss Jitter Up/Down]
|
||||
end
|
||||
|
||||
- All incoming traffic is forwarded to a remote relay via QUIC
|
||||
- Two-pipeline architecture: upstream (client->remote) and downstream (remote->client)
|
||||
- Each direction has its own `RelayPipeline` with FEC decode/encode and jitter buffering
|
||||
- Intended for chaining relays across censored/lossy boundaries
|
||||
|
||||
### Relay Pipeline (Forward Mode)
|
||||
|
||||
Implemented in `crates/wzp-relay/src/pipeline.rs` as `RelayPipeline`:
|
||||
|
||||
```
|
||||
Inbound: recv -> FEC decode -> jitter buffer -> pop
|
||||
Outbound: packet -> assign seq -> FEC encode -> repair packets -> send
|
||||
style RM fill:#ff9f43
|
||||
style R1 fill:#fdcb6e
|
||||
style PM fill:#0984e3
|
||||
```
|
||||
|
||||
The pipeline does NOT decode/re-encode audio. It operates on FEC-protected packets, managing loss recovery and re-FEC-encoding for the next hop.
|
||||
## Web Bridge Architecture
|
||||
|
||||
## Transport
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant W as wzp-web
|
||||
participant R as wzp-relay
|
||||
|
||||
Implemented in `crates/wzp-transport/` using QUIC via the `quinn` crate.
|
||||
B->>W: HTTPS GET /room-name
|
||||
W->>B: index.html (SPA)
|
||||
|
||||
### QUIC Configuration
|
||||
B->>W: WebSocket /ws/room-name
|
||||
Note over B,W: Optional auth JSON message
|
||||
|
||||
- ALPN protocol: `wzp`
|
||||
- Idle timeout: 30 seconds
|
||||
- Keep-alive interval: 5 seconds
|
||||
- DATAGRAM extension enabled (for unreliable media)
|
||||
- Datagram receive buffer: 64 KB
|
||||
- Receive window: 256 KB
|
||||
- Send window: 128 KB
|
||||
- Stream receive window: 64 KB per stream
|
||||
- Initial RTT estimate: 300ms (tuned for high-latency links)
|
||||
W->>R: QUIC connect (SNI = hashed room name)
|
||||
Note over W,R: AuthToken then Handshake then Join Room
|
||||
|
||||
### Media Transport
|
||||
loop Every 20ms
|
||||
B->>W: WS Binary Int16 x 960 PCM
|
||||
W->>W: CallEncoder Opus + FEC
|
||||
W->>R: QUIC Datagram encrypted
|
||||
end
|
||||
|
||||
- **Unreliable media**: QUIC DATAGRAM frames (no retransmission, no head-of-line blocking)
|
||||
- **Reliable signaling**: QUIC bidirectional streams with length-prefixed JSON framing
|
||||
loop Incoming audio
|
||||
R->>W: QUIC Datagram
|
||||
W->>W: CallDecoder FEC + Opus
|
||||
W->>B: WS Binary Int16 x 960 PCM
|
||||
end
|
||||
|
||||
### Path Quality Monitoring
|
||||
Note over B: AudioWorklet<br/>WZPCaptureProcessor mic to 960 frames<br/>WZPPlaybackProcessor ring buffer to speaker
|
||||
```
|
||||
|
||||
`PathMonitor` in `crates/wzp-transport/src/path_monitor.rs` tracks:
|
||||
## FEC Protection (RaptorQ)
|
||||
|
||||
- **Loss**: EWMA-smoothed percentage from sent/received packet counts
|
||||
- **RTT**: EWMA-smoothed round-trip time (alpha=0.1)
|
||||
- **Jitter**: EWMA of RTT variance (|current_rtt - previous_rtt|)
|
||||
- **Bandwidth**: Estimated from bytes received over elapsed time
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "Encoder"
|
||||
F1[Frame 1] --> BLK[Source Block<br/>5-10 frames]
|
||||
F2[Frame 2] --> BLK
|
||||
F3[Frame 3] --> BLK
|
||||
F4[Frame 4] --> BLK
|
||||
F5[Frame 5] --> BLK
|
||||
BLK --> SRC[5 Source Symbols]
|
||||
BLK --> REP[1-10 Repair Symbols<br/>ratio dependent]
|
||||
SRC --> INT[Interleaver<br/>depth=3]
|
||||
REP --> INT
|
||||
end
|
||||
|
||||
### Codec Selection by Tier
|
||||
subgraph "Network"
|
||||
INT --> LOSS{Packet Loss}
|
||||
LOSS -->|some lost| RCV[Received Symbols]
|
||||
end
|
||||
|
||||
| Codec | Sample Rate | Frame Duration | Bitrate | Use Case |
|
||||
|-------|------------|----------------|---------|----------|
|
||||
| Opus24k | 48 kHz | 20ms (960 samples) | 24 kbps | Good conditions |
|
||||
| Opus16k | 48 kHz | 20ms | 16 kbps | Moderate conditions |
|
||||
| Opus6k | 48 kHz | 40ms (1920 samples) | 6 kbps | Degraded conditions |
|
||||
| Codec2_3200 | 8 kHz | 20ms (160 samples) | 3.2 kbps | Poor conditions |
|
||||
| Codec2_1200 | 8 kHz | 40ms (320 samples) | 1.2 kbps | Catastrophic conditions |
|
||||
subgraph "Decoder"
|
||||
RCV --> DEINT[De-interleaver]
|
||||
DEINT --> RAPTORQ[RaptorQ Decoder<br/>Reconstruct from<br/>any K of K+R symbols]
|
||||
RAPTORQ --> OUT[Original Frames]
|
||||
end
|
||||
|
||||
Opus operates at 48 kHz natively. When Codec2 is selected, the adaptive codec layer handles 48 kHz <-> 8 kHz resampling transparently using a simple linear resampler (6:1 decimation/interpolation).
|
||||
style LOSS fill:#e17055
|
||||
style RAPTORQ fill:#00b894
|
||||
```
|
||||
|
||||
## Telemetry Stack
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Relay"
|
||||
RM[RelayMetrics<br/>sessions rooms packets]
|
||||
SM[SessionMetrics<br/>per-session jitter loss RTT]
|
||||
PM[ProbeMetrics<br/>inter-relay RTT loss]
|
||||
RM --> PROM1[GET /metrics :9090]
|
||||
SM --> PROM1
|
||||
PM --> PROM1
|
||||
end
|
||||
|
||||
subgraph "Web Bridge"
|
||||
WM[WebMetrics<br/>connections frames latency]
|
||||
WM --> PROM2[GET /metrics :8080]
|
||||
end
|
||||
|
||||
subgraph "Client"
|
||||
CM[JitterStats + QualityAdapter]
|
||||
CM --> JSONL[--metrics-file<br/>JSONL 1 line/sec]
|
||||
end
|
||||
|
||||
PROM1 --> GRAF[Grafana Dashboard<br/>4 rows 18 panels]
|
||||
PROM2 --> GRAF
|
||||
JSONL --> ANALYSIS[Offline Analysis]
|
||||
|
||||
style GRAF fill:#ff6b6b
|
||||
style PROM1 fill:#0984e3
|
||||
style PROM2 fill:#0984e3
|
||||
```
|
||||
|
||||
## Session State Machine
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Idle
|
||||
Idle --> Connecting: connect
|
||||
Connecting --> Handshaking: QUIC established
|
||||
Handshaking --> Active: CallOffer/Answer complete
|
||||
Active --> Rekeying: 65536 packets
|
||||
Rekeying --> Active: new key derived
|
||||
Active --> Closed: Hangup/Error/Timeout
|
||||
Rekeying --> Closed: Error
|
||||
Connecting --> Closed: Timeout
|
||||
Handshaking --> Closed: Signature fail
|
||||
|
||||
note right of Active: Media flows
|
||||
note right of Rekeying: Media continues while rekeying
|
||||
```
|
||||
|
||||
## Audio Processing Pipeline Detail
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Capture 20ms at 48kHz = 960 samples"
|
||||
MIC[Microphone / AudioWorklet] --> PCM[PCM i16 x 960]
|
||||
PCM --> RNN[RNNoise Denoise<br/>2 x 480 samples]
|
||||
RNN --> VAD{Silent?}
|
||||
VAD -->|Yes over 100ms| CN[ComfortNoise packet<br/>every 200ms]
|
||||
VAD -->|No or Hangover| OPUS[Opus/Codec2 Encode]
|
||||
end
|
||||
|
||||
subgraph "FEC + Crypto"
|
||||
OPUS --> SYMBOL[Pad to 256-byte symbol]
|
||||
CN --> SYMBOL
|
||||
SYMBOL --> BLOCK[Accumulate block<br/>5-10 symbols]
|
||||
BLOCK --> RAPTOR[RaptorQ encode<br/>+ repair symbols]
|
||||
RAPTOR --> INTERLEAVE[Interleave depth=3]
|
||||
INTERLEAVE --> HDR[Add MediaHeader<br/>or MiniHeader]
|
||||
HDR --> ENCRYPT[ChaCha20-Poly1305<br/>header=AAD payload=encrypted]
|
||||
ENCRYPT --> QUIC[QUIC Datagram]
|
||||
end
|
||||
|
||||
style RNN fill:#a29bfe
|
||||
style ENCRYPT fill:#ee5a24
|
||||
style RAPTOR fill:#00b894
|
||||
```
|
||||
|
||||
## Adaptive Jitter Buffer
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
PKT[Incoming Packet] --> SEQ{Sequence Check}
|
||||
SEQ -->|Duplicate| DROP[Drop + AntiReplay]
|
||||
SEQ -->|Valid| BUF[BTreeMap Buffer<br/>ordered by seq]
|
||||
|
||||
BUF --> ADAPT[AdaptivePlayoutDelay<br/>EMA jitter tracking]
|
||||
ADAPT --> TARGET[target_delay =<br/>ceil jitter_ema/20ms + 2]
|
||||
|
||||
BUF --> READY{depth >= target?}
|
||||
READY -->|No| WAIT[Wait / Underrun++]
|
||||
READY -->|Yes| POP[Pop lowest seq]
|
||||
POP --> DECODE[Decode to PCM]
|
||||
DECODE --> PLAY[Playout]
|
||||
|
||||
BUF --> OVERFLOW{depth > max?}
|
||||
OVERFLOW -->|Yes| EVICT[Drop oldest<br/>Overrun++]
|
||||
|
||||
style ADAPT fill:#fdcb6e
|
||||
style DROP fill:#e17055
|
||||
style EVICT fill:#e17055
|
||||
```
|
||||
|
||||
## Deployment Topology
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Region A"
|
||||
RA[wzp-relay A<br/>:4433 UDP]
|
||||
WA[wzp-web A<br/>:8080 HTTPS]
|
||||
WA --> RA
|
||||
end
|
||||
|
||||
subgraph "Region B"
|
||||
RB[wzp-relay B<br/>:4433 UDP]
|
||||
WB[wzp-web B<br/>:8080 HTTPS]
|
||||
WB --> RB
|
||||
end
|
||||
|
||||
RA <-->|Probe 1/s| RB
|
||||
|
||||
BA[Browser A] -->|WSS| WA
|
||||
BB[Browser B] -->|WSS| WB
|
||||
CA[CLI Client] -->|QUIC| RA
|
||||
|
||||
PROM[Prometheus] -->|scrape| RA
|
||||
PROM -->|scrape| RB
|
||||
PROM -->|scrape| WA
|
||||
PROM --> GRAF[Grafana]
|
||||
|
||||
FC[featherChat Server] -->|auth validate| RA
|
||||
FC -->|auth validate| RB
|
||||
|
||||
style RA fill:#ff9f43
|
||||
style RB fill:#ff9f43
|
||||
style GRAF fill:#ff6b6b
|
||||
style FC fill:#fd79a8
|
||||
```
|
||||
|
||||
## featherChat Integration Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as User A WZP Client
|
||||
participant FC as featherChat Server
|
||||
participant R as WZP Relay
|
||||
participant B as User B WZP Client
|
||||
|
||||
Note over A,B: Both users share BIP39 seed = same identity
|
||||
|
||||
A->>FC: WS CallSignal Offer payload=JSON SignalMessage
|
||||
FC->>B: WS CallSignal Offer payload + relay_addr + room
|
||||
|
||||
B->>R: QUIC connect SNI = hashed room
|
||||
B->>R: AuthToken fc_bearer_token
|
||||
R->>FC: POST /v1/auth/validate token
|
||||
FC->>R: valid true fingerprint ...
|
||||
B->>R: CallOffer then CallAnswer handshake
|
||||
|
||||
A->>R: QUIC connect same room
|
||||
A->>R: AuthToken + Handshake
|
||||
|
||||
Note over A,B: Both in same room media flows E2E encrypted
|
||||
A->>R: Encrypted media
|
||||
R->>B: Forward SFU no decryption
|
||||
B->>R: Encrypted media
|
||||
R->>A: Forward
|
||||
```
|
||||
|
||||
## Bandwidth Usage
|
||||
|
||||
| Profile | Audio | FEC Overhead | Total | Use Case |
|
||||
|---------|-------|-------------|-------|----------|
|
||||
| **GOOD** | 24 kbps (Opus) | 20% = 4.8 kbps | **28.8 kbps** | WiFi, LTE, good links |
|
||||
| **DEGRADED** | 6 kbps (Opus) | 50% = 3 kbps | **9.0 kbps** | 3G, congested WiFi |
|
||||
| **CATASTROPHIC** | 1.2 kbps (Codec2) | 100% = 1.2 kbps | **2.4 kbps** | Satellite, extreme loss |
|
||||
|
||||
With silence suppression: ~50% savings in typical conversations.
|
||||
With mini-frames: 8 bytes/packet saved (67% header reduction).
|
||||
With trunking: shared QUIC overhead across multiplexed sessions.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
warzonePhone/
|
||||
├── Cargo.toml # Workspace root
|
||||
├── crates/
|
||||
│ ├── wzp-proto/ # Protocol types, traits, wire format
|
||||
│ │ └── src/
|
||||
│ │ ├── codec_id.rs # CodecId, QualityProfile
|
||||
│ │ ├── error.rs # Error types
|
||||
│ │ ├── jitter.rs # JitterBuffer, AdaptivePlayoutDelay
|
||||
│ │ ├── packet.rs # MediaHeader, MiniHeader, TrunkFrame, SignalMessage
|
||||
│ │ ├── quality.rs # Tier, AdaptiveQualityController
|
||||
│ │ ├── session.rs # SessionState machine
|
||||
│ │ └── traits.rs # AudioEncoder, FecEncoder, CryptoSession, etc.
|
||||
│ ├── wzp-codec/ # Audio codecs
|
||||
│ │ └── src/
|
||||
│ │ ├── adaptive.rs # AdaptiveEncoder/Decoder (Opus + Codec2)
|
||||
│ │ ├── denoise.rs # NoiseSupressor (RNNoise/nnnoiseless)
|
||||
│ │ └── silence.rs # SilenceDetector, ComfortNoise
|
||||
│ ├── wzp-fec/ # Forward error correction
|
||||
│ │ └── src/
|
||||
│ │ ├── encoder.rs # RaptorQFecEncoder
|
||||
│ │ ├── decoder.rs # RaptorQFecDecoder
|
||||
│ │ └── interleave.rs # Interleaver (burst protection)
|
||||
│ ├── wzp-crypto/ # Cryptography + identity
|
||||
│ │ └── src/
|
||||
│ │ ├── identity.rs # Seed, Fingerprint, hash_room_name
|
||||
│ │ ├── handshake.rs # WarzoneKeyExchange (X25519 + Ed25519)
|
||||
│ │ ├── session.rs # ChaChaSession (ChaCha20-Poly1305)
|
||||
│ │ ├── nonce.rs # Deterministic nonce construction
|
||||
│ │ ├── anti_replay.rs # Sliding window replay protection
|
||||
│ │ └── rekey.rs # Forward secrecy rekeying
|
||||
│ ├── wzp-transport/ # QUIC transport layer
|
||||
│ │ └── src/lib.rs # QuinnTransport, send/recv media/signal/trunk
|
||||
│ ├── wzp-relay/ # Relay daemon
|
||||
│ │ └── src/
|
||||
│ │ ├── main.rs # CLI, connection loop, auth + handshake
|
||||
│ │ ├── room.rs # RoomManager, TrunkedForwarder
|
||||
│ │ ├── pipeline.rs # RelayPipeline (forward mode)
|
||||
│ │ ├── session_mgr.rs # SessionManager (limits, lifecycle)
|
||||
│ │ ├── auth.rs # featherChat token validation
|
||||
│ │ ├── handshake.rs # Relay-side accept_handshake
|
||||
│ │ ├── metrics.rs # Prometheus RelayMetrics + per-session
|
||||
│ │ ├── probe.rs # Inter-relay probes + ProbeMesh
|
||||
│ │ └── trunk.rs # TrunkBatcher
|
||||
│ ├── wzp-client/ # Call engine + CLI
|
||||
│ │ └── src/
|
||||
│ │ ├── cli.rs # CLI arg parsing + main
|
||||
│ │ ├── call.rs # CallEncoder, CallDecoder, QualityAdapter
|
||||
│ │ ├── handshake.rs # Client-side perform_handshake
|
||||
│ │ ├── featherchat.rs # CallSignal bridge
|
||||
│ │ ├── echo_test.rs # Automated echo quality test
|
||||
│ │ ├── drift_test.rs # Clock drift measurement
|
||||
│ │ ├── sweep.rs # Jitter buffer parameter sweep
|
||||
│ │ ├── metrics.rs # JSONL telemetry writer
|
||||
│ │ └── bench.rs # Component benchmarks
|
||||
│ └── wzp-web/ # Browser bridge
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs # Axum server, WS handler, TLS
|
||||
│ │ └── metrics.rs # Prometheus WebMetrics
|
||||
│ └── static/
|
||||
│ ├── index.html # SPA UI (room, PTT, level meter)
|
||||
│ └── audio-processor.js # AudioWorklet (capture + playback)
|
||||
├── deps/featherchat/ # Git submodule
|
||||
├── docs/
|
||||
│ ├── ARCHITECTURE.md # This file
|
||||
│ ├── TELEMETRY.md # Metrics specification
|
||||
│ ├── INTEGRATION_TASKS.md # featherChat task tracker
|
||||
│ ├── WZP-FC-SHARED-CRATES.md # Shared crate strategy
|
||||
│ └── grafana-dashboard.json # Pre-built Grafana dashboard
|
||||
└── scripts/
|
||||
└── build-linux.sh # Hetzner VM build
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
272 tests across all crates, 0 failures.
|
||||
|
||||
| Crate | Tests | Key Coverage |
|
||||
|-------|-------|-------------|
|
||||
| wzp-proto | 41 | Wire format, jitter buffer, quality tiers, mini-frames, trunking |
|
||||
| wzp-codec | 31 | Opus/Codec2 roundtrip, silence detection, noise suppression |
|
||||
| wzp-fec | 22 | RaptorQ encode/decode, loss recovery, interleaving |
|
||||
| wzp-crypto | 34 + 28 compat | Encrypt/decrypt, handshake, anti-replay, featherChat identity compat |
|
||||
| wzp-transport | 2 | QUIC connection setup |
|
||||
| wzp-relay | 40 + 4 integration | Room ACL, session mgmt, metrics, probes, mesh, trunking |
|
||||
| wzp-client | 30 + 2 integration | Encoder/decoder, quality adapter, silence, drift, sweep |
|
||||
| wzp-web | 2 | Metrics |
|
||||
|
||||
@@ -77,7 +77,9 @@ Based on featherChat commit 65f6390 — FUTURE_TASKS.md with WZP integration ite
|
||||
### WZP-FC-7. Missed call notifications — TODO (0.5d)
|
||||
### WZP-FC-8. Cross-project identity verification — DONE (15 tests, 26dc848)
|
||||
### WZP-FC-9. HKDF salt investigation — DONE (no mismatch)
|
||||
### WZP-FC-10. Web bridge shared auth — TODO (1-2d)
|
||||
### WZP-FC-10. Web bridge shared auth — DONE
|
||||
- FC: GET /v1/wzp/relay-config, CORS layer, service token
|
||||
- WZP: web bridge --auth-url validates browser tokens via FC
|
||||
### FC-CRATE-1. Standalone warzone-protocol — DONE (v0.0.21, 4a4fa9f)
|
||||
|
||||
---
|
||||
|
||||
473
docs/WEB_VARIANTS.md
Normal file
473
docs/WEB_VARIANTS.md
Normal file
@@ -0,0 +1,473 @@
|
||||
# WZP Web Client Variants
|
||||
|
||||
Three browser-based client implementations with different trade-offs between simplicity, features, and performance.
|
||||
|
||||
## Variant Comparison
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "Variant 1: Pure JS"
|
||||
P_MIC[Mic] --> P_WRK[AudioWorklet<br/>48kHz PCM]
|
||||
P_WRK --> P_WS[WebSocket<br/>TCP]
|
||||
P_WS --> P_BRIDGE[wzp-web Bridge<br/>Opus + FEC + Crypto]
|
||||
P_BRIDGE --> P_QUIC[QUIC Datagram<br/>to Relay]
|
||||
end
|
||||
|
||||
style P_BRIDGE fill:#ff9f43
|
||||
style P_WS fill:#74b9ff
|
||||
```
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "Variant 2: Hybrid"
|
||||
H_MIC[Mic] --> H_WRK[AudioWorklet<br/>48kHz PCM]
|
||||
H_WRK --> H_FEC[WASM RaptorQ<br/>FEC Encode]
|
||||
H_FEC --> H_WS[WebSocket<br/>TCP]
|
||||
H_WS --> H_BRIDGE[wzp-web Bridge<br/>Opus + Crypto]
|
||||
H_BRIDGE --> H_QUIC[QUIC Datagram<br/>to Relay]
|
||||
end
|
||||
|
||||
style H_FEC fill:#a29bfe
|
||||
style H_BRIDGE fill:#ff9f43
|
||||
style H_WS fill:#74b9ff
|
||||
```
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "Variant 3: Full WASM"
|
||||
F_MIC[Mic] --> F_WRK[AudioWorklet<br/>48kHz PCM]
|
||||
F_WRK --> F_FEC[WASM RaptorQ<br/>FEC Encode]
|
||||
F_FEC --> F_ENC[WASM ChaCha20<br/>Encrypt]
|
||||
F_ENC --> F_WT[WebTransport<br/>UDP Datagrams]
|
||||
F_WT --> F_RELAY[Direct to Relay<br/>No Bridge]
|
||||
end
|
||||
|
||||
style F_FEC fill:#a29bfe
|
||||
style F_ENC fill:#ee5a24
|
||||
style F_WT fill:#00b894
|
||||
```
|
||||
|
||||
## Summary Table
|
||||
|
||||
| | Pure JS | Hybrid | Full WASM |
|
||||
|--|---------|--------|-----------|
|
||||
| **Bundle** | ~20KB JS | ~120KB (JS + 337KB WASM) | ~20KB JS + 337KB WASM |
|
||||
| **Transport** | WebSocket (TCP) | WebSocket (TCP) | WebTransport (UDP) |
|
||||
| **Encryption** | Bridge-side (ChaCha20 on QUIC) | Bridge-side | Browser-side ChaCha20-Poly1305 WASM |
|
||||
| **FEC** | None | RaptorQ WASM (ready, not active over TCP) | RaptorQ WASM (active over UDP) |
|
||||
| **Codec** | Bridge Opus (server-side) | Bridge Opus | Browser Opus (future) / Bridge Opus |
|
||||
| **E2E Encrypted** | No (bridge sees plaintext PCM) | No (bridge sees plaintext PCM) | Yes (bridge eliminated) |
|
||||
| **Latency** | ~50-80ms (TCP overhead) | ~50-80ms (TCP) | ~20-40ms (UDP datagrams) |
|
||||
| **Loss Recovery** | TCP retransmit (adds latency) | TCP retransmit | RaptorQ FEC (no retransmit) |
|
||||
| **Browser Support** | All browsers | All browsers | Chrome 97+, Edge 97+, Firefox 114+, Safari 17.4+ |
|
||||
| **Relay Changes** | None | None | Needs HTTP/3 (h3-quinn) |
|
||||
| **Status** | Ready | Ready (FEC testable in console) | Architecture complete, needs relay HTTP/3 |
|
||||
|
||||
## Variant 1: Pure JS
|
||||
|
||||
The lightest implementation. No WASM, no FEC, no browser-side encryption. The `wzp-web` Rust bridge handles everything on the server side.
|
||||
|
||||
### Architecture
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant W as wzp-web Bridge
|
||||
participant R as wzp-relay
|
||||
|
||||
B->>B: getUserMedia() mic access
|
||||
B->>B: AudioWorklet captures 960 samples / 20ms
|
||||
|
||||
B->>W: WebSocket connect /ws/room-name
|
||||
W->>R: QUIC connect (SNI = hashed room)
|
||||
W->>R: Crypto handshake (X25519 + ChaCha20)
|
||||
|
||||
loop Every 20ms
|
||||
B->>W: WS Binary: Int16[960] raw PCM
|
||||
W->>W: Opus encode + FEC + Encrypt
|
||||
W->>R: QUIC Datagram
|
||||
end
|
||||
|
||||
loop Incoming
|
||||
R->>W: QUIC Datagram
|
||||
W->>W: Decrypt + FEC decode + Opus decode
|
||||
W->>B: WS Binary: Int16[960] raw PCM
|
||||
end
|
||||
|
||||
B->>B: AudioWorklet plays received PCM
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Browser (Pure JS)
|
||||
├── Capture: getUserMedia → AudioWorklet (WZPCaptureProcessor)
|
||||
│ └── 128-sample blocks accumulated → 960-sample frame
|
||||
│ └── Float32 → Int16 conversion
|
||||
│ └── postMessage(ArrayBuffer) to main thread
|
||||
├── Send: onmessage → ws.send(pcmBuffer)
|
||||
│ └── Binary WebSocket frame (1920 bytes = 960 × 2)
|
||||
├── Receive: ws.onmessage → ArrayBuffer
|
||||
│ └── Int16Array(960) → playback port
|
||||
└── Playback: AudioWorklet (WZPPlaybackProcessor)
|
||||
└── Ring buffer (max 120ms)
|
||||
└── Int16 → Float32 → output blocks
|
||||
```
|
||||
|
||||
### Files
|
||||
- `js/wzp-pure.js` — `WZPPureClient` class (~100 lines)
|
||||
- `js/wzp-core.js` — Shared UI + audio (used by all variants)
|
||||
- `audio-processor.js` — AudioWorklet (unchanged)
|
||||
|
||||
### Limitations
|
||||
- No packet loss recovery (TCP retransmit adds latency spikes)
|
||||
- Bridge sees plaintext audio (not E2E encrypted)
|
||||
- Full audio processing pipeline runs on server (Opus, FEC, crypto)
|
||||
- Each browser connection = one QUIC session on the bridge
|
||||
|
||||
---
|
||||
|
||||
## Variant 2: Hybrid (JS + WASM FEC)
|
||||
|
||||
Adds RaptorQ forward error correction via a small WASM module. Same WebSocket transport as Pure — the FEC module is loaded and functional but doesn't add value over TCP (no packet loss). It's ready to activate when WebTransport replaces WebSocket.
|
||||
|
||||
### Architecture
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant WASM as WASM Module
|
||||
participant W as wzp-web Bridge
|
||||
participant R as wzp-relay
|
||||
|
||||
B->>WASM: Load wzp_wasm.js (337KB)
|
||||
WASM-->>B: WzpFecEncoder + WzpFecDecoder ready
|
||||
|
||||
B->>W: WebSocket connect /ws/room-name
|
||||
W->>R: QUIC connect + handshake
|
||||
|
||||
loop Every 20ms
|
||||
B->>B: AudioWorklet captures PCM
|
||||
B->>WASM: fecEncoder.add_symbol(pcm_bytes)
|
||||
WASM-->>B: FEC packets (source + repair) when block complete
|
||||
B->>W: WS Binary: raw PCM (FEC not on wire over TCP)
|
||||
end
|
||||
|
||||
Note over B,WASM: FEC encode/decode proven via testFec()
|
||||
```
|
||||
|
||||
### WASM Module (wzp-wasm)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "wzp-wasm (337KB)"
|
||||
FE[WzpFecEncoder<br/>RaptorQ source block accumulator]
|
||||
FD[WzpFecDecoder<br/>RaptorQ reconstruction]
|
||||
KX[WzpKeyExchange<br/>X25519 ephemeral DH]
|
||||
CS[WzpCryptoSession<br/>ChaCha20-Poly1305]
|
||||
end
|
||||
|
||||
subgraph "Hybrid uses"
|
||||
FE
|
||||
FD
|
||||
end
|
||||
|
||||
subgraph "Full uses"
|
||||
FE
|
||||
FD
|
||||
KX
|
||||
CS
|
||||
end
|
||||
|
||||
style FE fill:#a29bfe
|
||||
style FD fill:#a29bfe
|
||||
style KX fill:#ee5a24
|
||||
style CS fill:#ee5a24
|
||||
```
|
||||
|
||||
### FEC Wire Format
|
||||
|
||||
```
|
||||
Per symbol (encoded by WASM, 259 bytes):
|
||||
┌──────────┬───────────┬──────────┬──────────────────┐
|
||||
│ block_id │ symbol_idx│ is_repair│ symbol_data │
|
||||
│ (1 byte) │ (1 byte) │ (1 byte) │ (256 bytes) │
|
||||
└──────────┴───────────┴──────────┴──────────────────┘
|
||||
|
||||
Symbol data internals (256 bytes):
|
||||
┌────────────┬──────────────────┬─────────┐
|
||||
│ length │ audio frame data │ padding │
|
||||
│ (2B LE) │ (variable) │ (zeros) │
|
||||
└────────────┴──────────────────┴─────────┘
|
||||
|
||||
Block = 5 source symbols + ceil(5 × 0.5) = 3 repair symbols = 8 total
|
||||
Any 5 of 8 received → full block recoverable (RaptorQ fountain code)
|
||||
```
|
||||
|
||||
### Testing FEC in Browser Console
|
||||
|
||||
```javascript
|
||||
// On any hybrid variant page, open console:
|
||||
client.testFec({ lossRate: 0.3, blockSize: 5, symbolSize: 256 })
|
||||
// Output: "FEC test passed — recovered from 30% loss"
|
||||
|
||||
client.testFec({ lossRate: 0.5 })
|
||||
// Output: "FEC test passed — recovered from 50% loss"
|
||||
```
|
||||
|
||||
### Files
|
||||
- `js/wzp-hybrid.js` — `WZPHybridClient` class (~150 lines)
|
||||
- `js/wzp-core.js` — Shared UI + audio
|
||||
- `wasm/wzp_wasm.js` + `wasm/wzp_wasm_bg.wasm` — WASM module (337KB)
|
||||
|
||||
### Limitations
|
||||
- FEC doesn't help over TCP WebSocket (no packet loss to recover from)
|
||||
- Bridge still sees plaintext audio
|
||||
- WebTransport activation is the unlock for FEC value
|
||||
|
||||
---
|
||||
|
||||
## Variant 3: Full WASM + WebTransport
|
||||
|
||||
The complete WZP client in the browser. No bridge server needed — the browser connects directly to the relay via WebTransport unreliable datagrams. All encryption and FEC happens in WASM.
|
||||
|
||||
### Architecture
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant WASM as WASM Module
|
||||
participant R as wzp-relay
|
||||
|
||||
B->>WASM: Load wzp_wasm.js
|
||||
WASM-->>B: FEC + Crypto + KeyExchange ready
|
||||
|
||||
B->>R: WebTransport connect (HTTPS/HTTP3)
|
||||
B->>R: Bidirectional stream open
|
||||
|
||||
Note over B,R: Key Exchange
|
||||
B->>WASM: kx = new WzpKeyExchange()
|
||||
B->>R: Stream: our X25519 public key (32 bytes)
|
||||
R->>B: Stream: relay X25519 public key (32 bytes)
|
||||
B->>WASM: secret = kx.derive_shared_secret(peer_pub)
|
||||
B->>WASM: session = new WzpCryptoSession(secret)
|
||||
|
||||
Note over B,R: Media Flow (Unreliable Datagrams)
|
||||
loop Every 20ms
|
||||
B->>B: AudioWorklet captures PCM
|
||||
B->>WASM: fecEncoder.add_symbol(pcm_bytes)
|
||||
WASM-->>B: FEC symbols when block complete
|
||||
B->>WASM: encrypted = session.encrypt(header, symbol)
|
||||
B->>R: WebTransport datagram (encrypted)
|
||||
end
|
||||
|
||||
loop Incoming
|
||||
R->>B: WebTransport datagram (encrypted)
|
||||
B->>WASM: plaintext = session.decrypt(header, ciphertext)
|
||||
B->>WASM: frames = fecDecoder.add_symbol(...)
|
||||
WASM-->>B: Decoded audio frames
|
||||
B->>B: AudioWorklet plays PCM
|
||||
end
|
||||
```
|
||||
|
||||
### Encryption Flow
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Key Exchange (once per session)"
|
||||
KX_A[Browser: WzpKeyExchange.new<br/>Generate X25519 keypair] --> PUB_A[Send public key<br/>32 bytes over stream]
|
||||
PUB_B[Receive relay public key<br/>32 bytes] --> DH[derive_shared_secret<br/>X25519 DH + HKDF-SHA256]
|
||||
DH --> SESSION[WzpCryptoSession<br/>ChaCha20-Poly1305 256-bit key]
|
||||
end
|
||||
|
||||
subgraph "Per-Packet Encryption"
|
||||
HDR[Build MediaHeader<br/>12 bytes AAD] --> ENC[session.encrypt<br/>header=AAD plaintext=audio]
|
||||
ENC --> NONCE[Nonce 12 bytes<br/>session_id 4 + seq 4 + dir 1 + pad 3]
|
||||
ENC --> CT[Ciphertext + 16-byte Poly1305 tag]
|
||||
CT --> DG[WebTransport datagram send]
|
||||
end
|
||||
|
||||
style SESSION fill:#ee5a24
|
||||
style NONCE fill:#fdcb6e
|
||||
```
|
||||
|
||||
### Nonce Construction (matches native wzp-crypto)
|
||||
|
||||
```
|
||||
Bytes 0-3: session_id (SHA-256(session_key)[:4])
|
||||
Bytes 4-7: sequence_number (u32 BE, incrementing)
|
||||
Byte 8: direction (0x00 = send, 0x01 = recv)
|
||||
Bytes 9-11: 0x000000 (padding)
|
||||
|
||||
Total: 12 bytes — deterministic, never reused (seq increments)
|
||||
```
|
||||
|
||||
### Send Pipeline Detail
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
MIC[Mic PCM Int16 x 960] --> PAD[Pad to 256 bytes<br/>2-byte LE length + data + zeros]
|
||||
PAD --> FEC[WzpFecEncoder.add_symbol<br/>Accumulate 5 frames per block]
|
||||
FEC -->|Block complete| SYMBOLS[5 source + 3 repair symbols]
|
||||
SYMBOLS --> HDR[Build 12-byte MediaHeader<br/>seq, timestamp, codec, fec_block, symbol_idx]
|
||||
HDR --> ENCRYPT[WzpCryptoSession.encrypt<br/>AAD=header, payload=symbol]
|
||||
ENCRYPT --> DG[WebTransport datagram<br/>header 12B + ciphertext + tag 16B]
|
||||
|
||||
style FEC fill:#a29bfe
|
||||
style ENCRYPT fill:#ee5a24
|
||||
style DG fill:#00b894
|
||||
```
|
||||
|
||||
### Receive Pipeline Detail
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
DG[WebTransport datagram] --> PARSE[Parse 12-byte MediaHeader]
|
||||
PARSE --> DECRYPT[WzpCryptoSession.decrypt<br/>AAD=header, ciphertext=rest]
|
||||
DECRYPT --> FEC_HDR[Parse 3-byte FEC header<br/>block_id + symbol_idx + is_repair]
|
||||
FEC_HDR --> FEC_D[WzpFecDecoder.add_symbol]
|
||||
FEC_D -->|Block decoded| FRAMES[Original audio frames]
|
||||
FRAMES --> UNPAD[Strip 2-byte length prefix + padding]
|
||||
UNPAD --> PLAY[AudioWorklet playback<br/>Int16 PCM x 960]
|
||||
|
||||
style DECRYPT fill:#ee5a24
|
||||
style FEC_D fill:#a29bfe
|
||||
style PLAY fill:#4a9eff
|
||||
```
|
||||
|
||||
### Testing Crypto + FEC in Browser Console
|
||||
|
||||
```javascript
|
||||
// On any full variant page, open console:
|
||||
client.testCryptoFec()
|
||||
// Tests: key exchange → encrypt → FEC encode → simulate 30% loss → FEC decode → decrypt
|
||||
// Output: "Crypto+FEC test passed — key exchange, encrypt, FEC(30% loss), decrypt all OK"
|
||||
```
|
||||
|
||||
### Files
|
||||
- `js/wzp-full.js` — `WZPFullClient` class (~250 lines)
|
||||
- `js/wzp-core.js` — Shared UI + audio
|
||||
- `wasm/wzp_wasm.js` + `wasm/wzp_wasm_bg.wasm` — WASM module (337KB, shared with hybrid)
|
||||
|
||||
### Requirements (not yet met)
|
||||
- Relay must support HTTP/3 WebTransport (h3-quinn integration)
|
||||
- Real TLS certificate (WebTransport requires valid HTTPS)
|
||||
- Browser with WebTransport support (Chrome 97+, Edge 97+, Firefox 114+, Safari 17.4+)
|
||||
|
||||
### Limitations
|
||||
- No Opus encoding in browser yet (sends raw PCM, relay/peer decodes)
|
||||
- Key exchange is simplified (no Ed25519 signature verification in WASM yet)
|
||||
- No adaptive quality switching in browser (server-side only)
|
||||
|
||||
---
|
||||
|
||||
## Shared Infrastructure
|
||||
|
||||
### wzp-core.js
|
||||
|
||||
Common code used by all three variants:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
CORE[wzp-core.js] --> DETECT[detectVariant<br/>URL ?variant= param]
|
||||
CORE --> ROOM[getRoom<br/>URL path / input field]
|
||||
CORE --> AUDIO[startAudioContext<br/>48kHz AudioContext]
|
||||
CORE --> CAP[connectCapture<br/>Mic to AudioWorklet]
|
||||
CORE --> PLAY[connectPlayback<br/>AudioWorklet to speaker]
|
||||
CORE --> UI[initUI<br/>Buttons, PTT, level meter]
|
||||
CORE --> STATUS[updateStatus / updateStats<br/>DOM updates]
|
||||
|
||||
CAP --> WORKLET[AudioWorklet<br/>or ScriptProcessor fallback]
|
||||
PLAY --> WORKLET
|
||||
|
||||
style CORE fill:#6c5ce7
|
||||
style WORKLET fill:#00b894
|
||||
```
|
||||
|
||||
### AudioWorklet Processors (audio-processor.js)
|
||||
|
||||
```
|
||||
WZPCaptureProcessor:
|
||||
AudioWorklet process() → 128 samples per call
|
||||
Buffer internally until 960 samples (20ms frame)
|
||||
Convert Float32 → Int16
|
||||
postMessage(ArrayBuffer) to main thread
|
||||
|
||||
WZPPlaybackProcessor:
|
||||
Receive Int16 PCM via port.onmessage
|
||||
Convert Int16 → Float32
|
||||
Write to ring buffer (max ~120ms / 6 frames)
|
||||
process() reads from ring buffer → output
|
||||
```
|
||||
|
||||
### index.html Boot Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant PAGE as index.html
|
||||
participant CORE as wzp-core.js
|
||||
participant VAR as Variant JS
|
||||
|
||||
PAGE->>CORE: Load (static script tag)
|
||||
CORE->>CORE: detectVariant() from URL
|
||||
PAGE->>VAR: Dynamic script load (wzp-pure/hybrid/full.js)
|
||||
VAR-->>PAGE: wzpBoot() called on load
|
||||
|
||||
PAGE->>CORE: initUI(callbacks)
|
||||
Note over PAGE: User clicks Connect
|
||||
|
||||
PAGE->>CORE: startAudioContext()
|
||||
PAGE->>VAR: new WZP*Client(options)
|
||||
PAGE->>VAR: client.connect()
|
||||
PAGE->>CORE: connectCapture(audioCtx, onFrame)
|
||||
PAGE->>CORE: connectPlayback(audioCtx)
|
||||
|
||||
loop Audio flowing
|
||||
CORE->>VAR: client.sendAudio(pcmBuffer)
|
||||
VAR->>CORE: onAudio(Int16Array) callback
|
||||
end
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Behind Caddy (recommended)
|
||||
|
||||
```
|
||||
# Caddyfile
|
||||
wzp.example.com {
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Relay
|
||||
./wzp-relay --listen 0.0.0.0:4433
|
||||
|
||||
# Web bridge (no --tls, Caddy handles SSL)
|
||||
./wzp-web --port 8080 --relay 127.0.0.1:4433
|
||||
```
|
||||
|
||||
### Direct TLS
|
||||
|
||||
```bash
|
||||
./wzp-web --port 443 --relay 127.0.0.1:4433 --tls \
|
||||
--cert /etc/letsencrypt/live/domain/fullchain.pem \
|
||||
--key /etc/letsencrypt/live/domain/privkey.pem
|
||||
```
|
||||
|
||||
### URL Patterns
|
||||
|
||||
```
|
||||
https://domain/room-name → Pure (default)
|
||||
https://domain/room-name?variant=pure → Pure JS
|
||||
https://domain/room-name?variant=hybrid → Hybrid (JS + WASM FEC)
|
||||
https://domain/room-name?variant=full → Full WASM (needs HTTP/3 relay)
|
||||
```
|
||||
|
||||
## Future Work
|
||||
|
||||
1. **Relay HTTP/3 support** (h3-quinn) — unlocks Full variant for production
|
||||
2. **Browser Opus encoding** — AudioEncoder API or Opus WASM, removes bridge dependency for Hybrid
|
||||
3. **Ed25519 signatures in WASM** — full identity verification in Full variant
|
||||
4. **Adaptive quality in browser** — monitor RTT/loss, switch profiles
|
||||
5. **WebTransport fallback to WebSocket** — Full variant auto-degrades if WebTransport unavailable
|
||||
257
docs/WS_RELAY_SPEC.md
Normal file
257
docs/WS_RELAY_SPEC.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# WS Support in wzp-relay — Implementation Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Add WebSocket listener to `wzp-relay` so browsers connect directly, eliminating `wzp-web` bridge.
|
||||
|
||||
```
|
||||
Before: Browser → WS → wzp-web → QUIC → wzp-relay
|
||||
After: Browser → WS → wzp-relay (handles both WS + QUIC)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
wzp-relay
|
||||
├── QUIC listener (:4433) — native clients, inter-relay
|
||||
├── WS listener (:8080) — browsers via Caddy
|
||||
│ ├── GET /ws/{room} — WebSocket upgrade
|
||||
│ └── Auth: first msg = {"type":"auth","token":"..."}
|
||||
└── Shared RoomManager — both transports in same rooms
|
||||
```
|
||||
|
||||
## Key Changes
|
||||
|
||||
### 1. Abstract `Participant` over transport type
|
||||
|
||||
**File: `room.rs`**
|
||||
|
||||
Currently:
|
||||
```rust
|
||||
struct Participant {
|
||||
id: ParticipantId,
|
||||
_addr: std::net::SocketAddr,
|
||||
transport: Arc<wzp_transport::QuinnTransport>,
|
||||
}
|
||||
```
|
||||
|
||||
Change to:
|
||||
```rust
|
||||
struct Participant {
|
||||
id: ParticipantId,
|
||||
_addr: std::net::SocketAddr,
|
||||
sender: ParticipantSender,
|
||||
}
|
||||
|
||||
/// How to send a media packet to a participant.
|
||||
enum ParticipantSender {
|
||||
Quic(Arc<wzp_transport::QuinnTransport>),
|
||||
WebSocket(tokio::sync::mpsc::Sender<bytes::Bytes>),
|
||||
}
|
||||
```
|
||||
|
||||
The `others()` method returns `Vec<ParticipantSender>` instead of `Vec<Arc<QuinnTransport>>`.
|
||||
|
||||
`ParticipantSender` implements a `send_pcm(&self, data: &[u8])` method:
|
||||
- **Quic**: wraps in `MediaPacket`, calls `transport.send_media()`
|
||||
- **WebSocket**: sends raw binary frame via the mpsc channel
|
||||
|
||||
### 2. Add `join_ws()` to RoomManager
|
||||
|
||||
```rust
|
||||
pub fn join_ws(
|
||||
&mut self,
|
||||
room_name: &str,
|
||||
addr: std::net::SocketAddr,
|
||||
sender: tokio::sync::mpsc::Sender<bytes::Bytes>,
|
||||
fingerprint: Option<&str>,
|
||||
) -> Result<ParticipantId, String>
|
||||
```
|
||||
|
||||
### 3. Add WS listener in `main.rs`
|
||||
|
||||
New flag: `--ws-port 8080`
|
||||
|
||||
```rust
|
||||
if let Some(ws_port) = config.ws_port {
|
||||
let room_mgr = room_mgr.clone();
|
||||
let auth_url = config.auth_url.clone();
|
||||
let metrics = metrics.clone();
|
||||
tokio::spawn(run_ws_server(ws_port, room_mgr, auth_url, metrics));
|
||||
}
|
||||
```
|
||||
|
||||
### 4. WebSocket handler (`ws.rs` — new file)
|
||||
|
||||
```rust
|
||||
use axum::{
|
||||
extract::{ws::{Message, WebSocket}, Path, WebSocketUpgrade},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
|
||||
async fn ws_handler(
|
||||
Path(room): Path<String>,
|
||||
ws: WebSocketUpgrade,
|
||||
/* state */
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_ws(socket, room, state))
|
||||
}
|
||||
|
||||
async fn handle_ws(mut socket: WebSocket, room: String, state: WsState) {
|
||||
let addr = /* peer addr */;
|
||||
|
||||
// 1. Auth: first message must be {"type":"auth","token":"..."}
|
||||
let fingerprint = if let Some(ref auth_url) = state.auth_url {
|
||||
match socket.recv().await {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
let parsed: serde_json::Value = serde_json::from_str(&text)?;
|
||||
if parsed["type"] == "auth" {
|
||||
let token = parsed["token"].as_str().unwrap();
|
||||
let client = auth::validate_token(auth_url, token).await?;
|
||||
Some(client.fingerprint)
|
||||
} else { return; }
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
} else { None };
|
||||
|
||||
// 2. Create mpsc channel for outbound frames
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<bytes::Bytes>(64);
|
||||
|
||||
// 3. Join room
|
||||
let participant_id = {
|
||||
let mut mgr = state.room_mgr.lock().await;
|
||||
mgr.join_ws(&room, addr, tx, fingerprint.as_deref())?
|
||||
};
|
||||
|
||||
// 4. Run send/recv loops
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
|
||||
// Outbound: mpsc rx → WS send
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Some(data) = rx.recv().await {
|
||||
if ws_tx.send(Message::Binary(data.to_vec())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Inbound: WS recv → fan-out to room
|
||||
loop {
|
||||
match ws_rx.next().await {
|
||||
Some(Ok(Message::Binary(data))) => {
|
||||
// Raw PCM Int16 from browser — fan-out to all others
|
||||
let others = {
|
||||
let mgr = state.room_mgr.lock().await;
|
||||
mgr.others(&room, participant_id)
|
||||
};
|
||||
for other in &others {
|
||||
other.send_raw(&data);
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Cleanup
|
||||
send_task.abort();
|
||||
let mut mgr = state.room_mgr.lock().await;
|
||||
mgr.leave(&room, participant_id);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Cross-transport fan-out
|
||||
|
||||
When a QUIC participant sends audio → WS participants receive raw PCM bytes.
|
||||
When a WS participant sends audio → QUIC participants receive a `MediaPacket`.
|
||||
|
||||
The `ParticipantSender::send_raw()` method:
|
||||
```rust
|
||||
impl ParticipantSender {
|
||||
async fn send_raw(&self, pcm_bytes: &[u8]) {
|
||||
match self {
|
||||
ParticipantSender::WebSocket(tx) => {
|
||||
let _ = tx.try_send(bytes::Bytes::copy_from_slice(pcm_bytes));
|
||||
}
|
||||
ParticipantSender::Quic(transport) => {
|
||||
// Wrap raw PCM in a MediaPacket
|
||||
let pkt = MediaPacket {
|
||||
header: MediaHeader::default_pcm(),
|
||||
payload: bytes::Bytes::copy_from_slice(pcm_bytes),
|
||||
quality_report: None,
|
||||
};
|
||||
let _ = transport.send_media(&pkt).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For QUIC→WS direction, `run_participant` extracts `pkt.payload` bytes and sends to WS channels.
|
||||
|
||||
### 6. Dependencies to add
|
||||
|
||||
```toml
|
||||
# wzp-relay/Cargo.toml
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
tokio = { version = "1", features = ["full"] } # already present
|
||||
```
|
||||
|
||||
### 7. Config change
|
||||
|
||||
```rust
|
||||
// config.rs
|
||||
pub struct RelayConfig {
|
||||
// ... existing fields ...
|
||||
pub ws_port: Option<u16>,
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Docker compose change (featherChat side)
|
||||
|
||||
Remove `wzp-web` service entirely. Update Caddy to proxy `/audio/*` to relay's WS port:
|
||||
|
||||
```yaml
|
||||
# Before:
|
||||
wzp-web:
|
||||
entrypoint: ["wzp-web"]
|
||||
command: ["--port", "8080", "--relay", "172.28.0.10:4433"]
|
||||
|
||||
# After: REMOVED. Relay handles WS directly.
|
||||
|
||||
wzp-relay:
|
||||
command:
|
||||
- "--listen"
|
||||
- "0.0.0.0:4433"
|
||||
- "--ws-port"
|
||||
- "8080"
|
||||
- "--auth-url"
|
||||
- "http://warzone-server:7700/v1/auth/validate"
|
||||
```
|
||||
|
||||
## What Stays the Same
|
||||
|
||||
- Browser's `startAudio()` — unchanged, still connects WS to `/audio/ws/ROOM`
|
||||
- Caddy proxies `/audio/*` → relay:8080 (same path, different backend)
|
||||
- Auth flow — same JSON token as first message
|
||||
- PCM format — same Int16 binary frames
|
||||
- QUIC clients — unchanged, still connect to :4433
|
||||
- Room naming, ACL, session management — all unchanged
|
||||
|
||||
## Testing
|
||||
|
||||
1. Start relay with `--ws-port 8080 --listen 0.0.0.0:4433`
|
||||
2. Open browser, initiate call via featherChat
|
||||
3. Verify audio flows (both directions)
|
||||
4. Verify QUIC + WS clients can be in same room (mixed mode)
|
||||
5. Verify auth works
|
||||
6. Verify room cleanup on disconnect
|
||||
|
||||
## Migration Path
|
||||
|
||||
1. Implement WS in relay
|
||||
2. Test with featherChat (no featherChat changes needed)
|
||||
3. Remove wzp-web from Docker stack
|
||||
4. Later: add WebTransport alongside WS
|
||||
Reference in New Issue
Block a user