docs: comprehensive project documentation
- ARCHITECTURE.md: protocol design, wire format, FEC, crypto, relay modes - USAGE.md: build instructions, all CLI flags, deployment examples - DESIGN.md: rationale for codec/FEC/transport/crypto choices - EXTENSIBILITY.md: trait extension points, Warzone integration, future features - PROGRESS.md: phase 1-4 timeline, test coverage, known issues - API.md: complete crate API reference for all 8 crates Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
677
docs/API.md
Normal file
677
docs/API.md
Normal file
@@ -0,0 +1,677 @@
|
|||||||
|
# WarzonePhone Crate API Reference
|
||||||
|
|
||||||
|
## wzp-proto
|
||||||
|
|
||||||
|
**Path**: `crates/wzp-proto/src/`
|
||||||
|
|
||||||
|
The protocol definition crate. Contains all shared types, trait interfaces, and core logic. No implementation dependencies -- this is the hub of the star dependency graph.
|
||||||
|
|
||||||
|
### Traits (`traits.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Encodes PCM audio into compressed frames.
|
||||||
|
pub trait AudioEncoder: Send + Sync {
|
||||||
|
fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize, CodecError>;
|
||||||
|
fn codec_id(&self) -> CodecId;
|
||||||
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError>;
|
||||||
|
fn max_frame_bytes(&self) -> usize;
|
||||||
|
fn set_inband_fec(&mut self, _enabled: bool) {} // default no-op
|
||||||
|
fn set_dtx(&mut self, _enabled: bool) {} // default no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes compressed frames back to PCM audio.
|
||||||
|
pub trait AudioDecoder: Send + Sync {
|
||||||
|
fn decode(&mut self, encoded: &[u8], pcm: &mut [i16]) -> Result<usize, CodecError>;
|
||||||
|
fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError>;
|
||||||
|
fn codec_id(&self) -> CodecId;
|
||||||
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes source symbols into FEC-protected blocks.
|
||||||
|
pub trait FecEncoder: Send + Sync {
|
||||||
|
fn add_source_symbol(&mut self, data: &[u8]) -> Result<(), FecError>;
|
||||||
|
fn generate_repair(&mut self, ratio: f32) -> Result<Vec<(u8, Vec<u8>)>, FecError>;
|
||||||
|
fn finalize_block(&mut self) -> Result<u8, FecError>;
|
||||||
|
fn current_block_id(&self) -> u8;
|
||||||
|
fn current_block_size(&self) -> usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes FEC-protected blocks, recovering lost source symbols.
|
||||||
|
pub trait FecDecoder: Send + Sync {
|
||||||
|
fn add_symbol(&mut self, block_id: u8, symbol_index: u8, is_repair: bool, data: &[u8]) -> Result<(), FecError>;
|
||||||
|
fn try_decode(&mut self, block_id: u8) -> Result<Option<Vec<Vec<u8>>>, FecError>;
|
||||||
|
fn expire_before(&mut self, block_id: u8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-call encryption session (symmetric, after key exchange).
|
||||||
|
pub trait CryptoSession: Send + Sync {
|
||||||
|
fn encrypt(&mut self, header_bytes: &[u8], plaintext: &[u8], out: &mut Vec<u8>) -> Result<(), CryptoError>;
|
||||||
|
fn decrypt(&mut self, header_bytes: &[u8], ciphertext: &[u8], out: &mut Vec<u8>) -> Result<(), CryptoError>;
|
||||||
|
fn initiate_rekey(&mut self) -> Result<[u8; 32], CryptoError>;
|
||||||
|
fn complete_rekey(&mut self, peer_ephemeral_pub: &[u8; 32]) -> Result<(), CryptoError>;
|
||||||
|
fn overhead(&self) -> usize { 16 } // ChaCha20-Poly1305 tag
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key exchange using the Warzone identity model.
|
||||||
|
pub trait KeyExchange: Send + Sync {
|
||||||
|
fn from_identity_seed(seed: &[u8; 32]) -> Self where Self: Sized;
|
||||||
|
fn generate_ephemeral(&mut self) -> [u8; 32];
|
||||||
|
fn identity_public_key(&self) -> [u8; 32];
|
||||||
|
fn fingerprint(&self) -> [u8; 16];
|
||||||
|
fn sign(&self, data: &[u8]) -> Vec<u8>;
|
||||||
|
fn verify(peer_identity_pub: &[u8; 32], data: &[u8], signature: &[u8]) -> bool where Self: Sized;
|
||||||
|
fn derive_session(&self, peer_ephemeral_pub: &[u8; 32]) -> Result<Box<dyn CryptoSession>, CryptoError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transport layer for sending/receiving media and signaling.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait MediaTransport: Send + Sync {
|
||||||
|
async fn send_media(&self, packet: &MediaPacket) -> Result<(), TransportError>;
|
||||||
|
async fn recv_media(&self) -> Result<Option<MediaPacket>, TransportError>;
|
||||||
|
async fn send_signal(&self, msg: &SignalMessage) -> Result<(), TransportError>;
|
||||||
|
async fn recv_signal(&self) -> Result<Option<SignalMessage>, TransportError>;
|
||||||
|
fn path_quality(&self) -> PathQuality;
|
||||||
|
async fn close(&self) -> Result<(), TransportError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps/unwraps packets for DPI evasion (Phase 2).
|
||||||
|
pub trait ObfuscationLayer: Send + Sync {
|
||||||
|
fn obfuscate(&mut self, data: &[u8], out: &mut Vec<u8>) -> Result<(), ObfuscationError>;
|
||||||
|
fn deobfuscate(&mut self, data: &[u8], out: &mut Vec<u8>) -> Result<(), ObfuscationError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adaptive quality controller.
|
||||||
|
pub trait QualityController: Send + Sync {
|
||||||
|
fn observe(&mut self, report: &QualityReport) -> Option<QualityProfile>;
|
||||||
|
fn force_profile(&mut self, profile: QualityProfile);
|
||||||
|
fn current_profile(&self) -> QualityProfile;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wire Format Types (`packet.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct MediaHeader { /* 12 bytes */ }
|
||||||
|
pub struct QualityReport { /* 4 bytes */ }
|
||||||
|
pub struct MediaPacket { pub header: MediaHeader, pub payload: Bytes, pub quality_report: Option<QualityReport> }
|
||||||
|
pub enum SignalMessage { CallOffer{..}, CallAnswer{..}, IceCandidate{..}, Rekey{..}, QualityUpdate{..}, Ping{..}, Pong{..}, Hangup{..} }
|
||||||
|
pub enum HangupReason { Normal, Busy, Declined, Timeout, Error }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `MediaHeader::write_to(&self, buf: &mut impl BufMut)` -- serialize to 12 bytes
|
||||||
|
- `MediaHeader::read_from(buf: &mut impl Buf) -> Option<Self>` -- deserialize
|
||||||
|
- `MediaHeader::encode_fec_ratio(ratio: f32) -> u8` -- float to 7-bit wire encoding
|
||||||
|
- `MediaHeader::decode_fec_ratio(encoded: u8) -> f32` -- 7-bit wire to float
|
||||||
|
- `MediaPacket::to_bytes(&self) -> Bytes` -- serialize complete packet
|
||||||
|
- `MediaPacket::from_bytes(data: Bytes) -> Option<Self>` -- deserialize
|
||||||
|
|
||||||
|
### Codec Identifiers (`codec_id.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum CodecId { Opus24k = 0, Opus16k = 1, Opus6k = 2, Codec2_3200 = 3, Codec2_1200 = 4 }
|
||||||
|
|
||||||
|
pub struct QualityProfile {
|
||||||
|
pub codec: CodecId,
|
||||||
|
pub fec_ratio: f32,
|
||||||
|
pub frame_duration_ms: u8,
|
||||||
|
pub frames_per_block: u8,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Constants: `QualityProfile::GOOD`, `QualityProfile::DEGRADED`, `QualityProfile::CATASTROPHIC`
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `CodecId::bitrate_bps(self) -> u32`
|
||||||
|
- `CodecId::frame_duration_ms(self) -> u8`
|
||||||
|
- `CodecId::sample_rate_hz(self) -> u32`
|
||||||
|
- `CodecId::from_wire(val: u8) -> Option<Self>`
|
||||||
|
- `CodecId::to_wire(self) -> u8`
|
||||||
|
- `QualityProfile::total_bitrate_kbps(&self) -> f32`
|
||||||
|
|
||||||
|
### Quality Controller (`quality.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum Tier { Good, Degraded, Catastrophic }
|
||||||
|
pub struct AdaptiveQualityController { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `AdaptiveQualityController::new() -> Self` -- starts at Tier::Good
|
||||||
|
- `AdaptiveQualityController::tier(&self) -> Tier`
|
||||||
|
- `Tier::classify(report: &QualityReport) -> Self`
|
||||||
|
- `Tier::profile(self) -> QualityProfile`
|
||||||
|
|
||||||
|
### Jitter Buffer (`jitter.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct JitterBuffer { /* ... */ }
|
||||||
|
pub struct JitterStats { pub packets_received: u64, pub packets_played: u64, pub packets_lost: u64, pub packets_late: u64, pub packets_duplicate: u64, pub current_depth: usize }
|
||||||
|
pub enum PlayoutResult { Packet(MediaPacket), Missing { seq: u16 }, NotReady }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `JitterBuffer::new(target_depth: usize, max_depth: usize, min_depth: usize) -> Self`
|
||||||
|
- `JitterBuffer::default_5s() -> Self` -- target=50, max=250, min=25
|
||||||
|
- `JitterBuffer::push(&mut self, packet: MediaPacket)`
|
||||||
|
- `JitterBuffer::pop(&mut self) -> PlayoutResult`
|
||||||
|
- `JitterBuffer::depth(&self) -> usize`
|
||||||
|
- `JitterBuffer::stats(&self) -> &JitterStats`
|
||||||
|
- `JitterBuffer::reset(&mut self)`
|
||||||
|
- `JitterBuffer::set_target_depth(&mut self, depth: usize)`
|
||||||
|
|
||||||
|
### Session State Machine (`session.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum SessionState { Idle, Connecting, Handshaking, Active, Rekeying, Closed }
|
||||||
|
pub enum SessionEvent { Initiate, Connected, HandshakeComplete, RekeyStart, RekeyComplete, Terminate{reason}, ConnectionLost }
|
||||||
|
pub struct Session { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `Session::new(session_id: [u8; 16]) -> Self`
|
||||||
|
- `Session::state(&self) -> SessionState`
|
||||||
|
- `Session::transition(&mut self, event: SessionEvent, now_ms: u64) -> Result<SessionState, TransitionError>`
|
||||||
|
- `Session::is_media_active(&self) -> bool` -- true for Active and Rekeying
|
||||||
|
|
||||||
|
### Error Types (`error.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum CodecError { EncodeFailed(String), DecodeFailed(String), UnsupportedTransition{from, to} }
|
||||||
|
pub enum FecError { BlockFull{max}, InsufficientSymbols{needed, have}, InvalidBlock(u8), Internal(String) }
|
||||||
|
pub enum CryptoError { DecryptionFailed, InvalidPublicKey, RekeyFailed(String), ReplayDetected{seq}, Internal(String) }
|
||||||
|
pub enum TransportError { ConnectionLost, DatagramTooLarge{size, max}, Timeout{ms}, Io(io::Error), Internal(String) }
|
||||||
|
pub enum ObfuscationError { Failed(String), InvalidFraming }
|
||||||
|
```
|
||||||
|
|
||||||
|
### PathQuality (`traits.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct PathQuality {
|
||||||
|
pub loss_pct: f32, // 0.0-100.0
|
||||||
|
pub rtt_ms: u32,
|
||||||
|
pub jitter_ms: u32,
|
||||||
|
pub bandwidth_kbps: u32,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## wzp-codec
|
||||||
|
|
||||||
|
**Path**: `crates/wzp-codec/src/`
|
||||||
|
|
||||||
|
### Factory Functions (`lib.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Create an adaptive encoder (accepts 48 kHz PCM, handles resampling for Codec2).
|
||||||
|
pub fn create_encoder(profile: QualityProfile) -> Box<dyn AudioEncoder>
|
||||||
|
|
||||||
|
/// Create an adaptive decoder (outputs 48 kHz PCM, handles upsampling from Codec2).
|
||||||
|
pub fn create_decoder(profile: QualityProfile) -> Box<dyn AudioDecoder>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Public Types
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct AdaptiveEncoder { /* wraps OpusEncoder + Codec2Encoder */ }
|
||||||
|
pub struct AdaptiveDecoder { /* wraps OpusDecoder + Codec2Decoder */ }
|
||||||
|
pub struct OpusEncoder { /* audiopus::coder::Encoder wrapper */ }
|
||||||
|
pub struct OpusDecoder { /* audiopus::coder::Decoder wrapper */ }
|
||||||
|
pub struct Codec2Encoder { /* codec2::Codec2 wrapper */ }
|
||||||
|
pub struct Codec2Decoder { /* codec2::Codec2 wrapper */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods on concrete types:
|
||||||
|
- `OpusEncoder::new(profile: QualityProfile) -> Result<Self, CodecError>`
|
||||||
|
- `OpusEncoder::frame_samples(&self) -> usize` -- 960 for 20ms, 1920 for 40ms
|
||||||
|
- `Codec2Encoder::new(profile: QualityProfile) -> Result<Self, CodecError>`
|
||||||
|
- `Codec2Encoder::frame_samples(&self) -> usize` -- 160 for 20ms/3200bps, 320 for 40ms/1200bps
|
||||||
|
|
||||||
|
### Resampler (`resample.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn resample_48k_to_8k(input: &[i16]) -> Vec<i16> // 6:1 decimation with box filter
|
||||||
|
pub fn resample_8k_to_48k(input: &[i16]) -> Vec<i16> // 1:6 linear interpolation
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## wzp-fec
|
||||||
|
|
||||||
|
**Path**: `crates/wzp-fec/src/`
|
||||||
|
|
||||||
|
### Factory Functions (`lib.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Create an encoder/decoder pair configured for the given quality profile.
|
||||||
|
pub fn create_fec_pair(profile: &QualityProfile) -> (RaptorQFecEncoder, RaptorQFecDecoder)
|
||||||
|
|
||||||
|
/// Create an encoder configured for the given quality profile.
|
||||||
|
pub fn create_encoder(profile: &QualityProfile) -> RaptorQFecEncoder
|
||||||
|
|
||||||
|
/// Create a decoder configured for the given quality profile.
|
||||||
|
pub fn create_decoder(profile: &QualityProfile) -> RaptorQFecDecoder
|
||||||
|
```
|
||||||
|
|
||||||
|
### RaptorQFecEncoder (`encoder.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct RaptorQFecEncoder { /* block_id, frames_per_block, source_symbols, symbol_size */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `RaptorQFecEncoder::new(frames_per_block: usize, symbol_size: u16) -> Self`
|
||||||
|
- `RaptorQFecEncoder::with_defaults(frames_per_block: usize) -> Self` -- symbol_size=256
|
||||||
|
- Implements `FecEncoder` trait
|
||||||
|
|
||||||
|
### RaptorQFecDecoder (`decoder.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct RaptorQFecDecoder { /* blocks: HashMap<u8, BlockState>, symbol_size, frames_per_block */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `RaptorQFecDecoder::new(frames_per_block: usize, symbol_size: u16) -> Self`
|
||||||
|
- `RaptorQFecDecoder::with_defaults(frames_per_block: usize) -> Self`
|
||||||
|
- Implements `FecDecoder` trait
|
||||||
|
|
||||||
|
### Interleaver (`interleave.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub type Symbol = (u8, u8, bool, Vec<u8>); // (block_id, symbol_index, is_repair, data)
|
||||||
|
pub struct Interleaver { depth: usize }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `Interleaver::new(depth: usize) -> Self`
|
||||||
|
- `Interleaver::with_default_depth() -> Self` -- depth=3
|
||||||
|
- `Interleaver::interleave(&self, blocks: &[Vec<Symbol>]) -> Vec<Symbol>`
|
||||||
|
- `Interleaver::depth(&self) -> usize`
|
||||||
|
|
||||||
|
### AdaptiveFec (`adaptive.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct AdaptiveFec { pub frames_per_block: usize, pub repair_ratio: f32, pub symbol_size: u16 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `AdaptiveFec::from_profile(profile: &QualityProfile) -> Self`
|
||||||
|
- `AdaptiveFec::build_encoder(&self) -> RaptorQFecEncoder`
|
||||||
|
- `AdaptiveFec::ratio(&self) -> f32`
|
||||||
|
- `AdaptiveFec::overhead_factor(&self) -> f32` -- 1.0 + repair_ratio
|
||||||
|
|
||||||
|
### Block Managers (`block_manager.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum EncoderBlockState { Building, Pending, Sent, Acknowledged }
|
||||||
|
pub enum DecoderBlockState { Assembling, Complete, Expired }
|
||||||
|
pub struct EncoderBlockManager { /* ... */ }
|
||||||
|
pub struct DecoderBlockManager { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `EncoderBlockManager::next_block_id(&mut self) -> u8`
|
||||||
|
- `EncoderBlockManager::mark_sent(&mut self, block_id: u8)`
|
||||||
|
- `EncoderBlockManager::mark_acknowledged(&mut self, block_id: u8)`
|
||||||
|
- `DecoderBlockManager::touch(&mut self, block_id: u8)`
|
||||||
|
- `DecoderBlockManager::mark_complete(&mut self, block_id: u8)`
|
||||||
|
- `DecoderBlockManager::expire_before(&mut self, block_id: u8)`
|
||||||
|
|
||||||
|
### Helper Functions (`encoder.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Build source EncodingPackets for a given block (for testing/interleaving).
|
||||||
|
pub fn source_packets_for_block(block_id: u8, symbols: &[Vec<u8>], symbol_size: u16) -> Vec<EncodingPacket>
|
||||||
|
|
||||||
|
/// Generate repair packets for the given source symbols.
|
||||||
|
pub fn repair_packets_for_block(block_id: u8, symbols: &[Vec<u8>], symbol_size: u16, ratio: f32) -> Vec<EncodingPacket>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## wzp-crypto
|
||||||
|
|
||||||
|
**Path**: `crates/wzp-crypto/src/`
|
||||||
|
|
||||||
|
### Re-exports (`lib.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub use anti_replay::AntiReplayWindow;
|
||||||
|
pub use handshake::WarzoneKeyExchange;
|
||||||
|
pub use nonce::{build_nonce, Direction};
|
||||||
|
pub use rekey::RekeyManager;
|
||||||
|
pub use session::ChaChaSession;
|
||||||
|
pub use wzp_proto::{CryptoError, CryptoSession, KeyExchange};
|
||||||
|
```
|
||||||
|
|
||||||
|
### WarzoneKeyExchange (`handshake.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct WarzoneKeyExchange { /* signing_key, x25519_static, ephemeral_secret */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Implements `KeyExchange` trait. Key derivation:
|
||||||
|
- Ed25519: `HKDF(seed, "warzone-ed25519-identity")`
|
||||||
|
- X25519: `HKDF(seed, "warzone-x25519-identity")`
|
||||||
|
- Session: `HKDF(X25519_DH_shared_secret, "warzone-session-key")`
|
||||||
|
|
||||||
|
### ChaChaSession (`session.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct ChaChaSession { /* cipher, session_id, send_seq, recv_seq, rekey_mgr, pending_rekey_secret */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `ChaChaSession::new(shared_secret: [u8; 32]) -> Self`
|
||||||
|
- Implements `CryptoSession` trait
|
||||||
|
|
||||||
|
### AntiReplayWindow (`anti_replay.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct AntiReplayWindow { /* highest: u16, bitmap: Vec<u64>, initialized: bool */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `AntiReplayWindow::new() -> Self` -- 1024-packet window
|
||||||
|
- `AntiReplayWindow::check_and_update(&mut self, seq: u16) -> Result<(), CryptoError>`
|
||||||
|
|
||||||
|
### Nonce Construction (`nonce.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum Direction { Send = 0, Recv = 1 }
|
||||||
|
pub fn build_nonce(session_id: &[u8; 4], seq: u32, direction: Direction) -> [u8; 12]
|
||||||
|
```
|
||||||
|
|
||||||
|
### RekeyManager (`rekey.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct RekeyManager { /* current_key, last_rekey_at */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `RekeyManager::new(initial_key: [u8; 32]) -> Self`
|
||||||
|
- `RekeyManager::should_rekey(&self, packet_count: u64) -> bool` -- every 2^16 packets
|
||||||
|
- `RekeyManager::perform_rekey(&mut self, new_peer_pub: &[u8; 32], our_new_secret: StaticSecret, packet_count: u64) -> [u8; 32]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## wzp-transport
|
||||||
|
|
||||||
|
**Path**: `crates/wzp-transport/src/`
|
||||||
|
|
||||||
|
### Re-exports (`lib.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub use config::{client_config, server_config};
|
||||||
|
pub use connection::{accept, connect, create_endpoint};
|
||||||
|
pub use path_monitor::PathMonitor;
|
||||||
|
pub use quic::QuinnTransport;
|
||||||
|
pub use wzp_proto::{MediaTransport, PathQuality, TransportError};
|
||||||
|
```
|
||||||
|
|
||||||
|
### QuinnTransport (`quic.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct QuinnTransport { /* connection: quinn::Connection, path_monitor: Mutex<PathMonitor> */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `QuinnTransport::new(connection: quinn::Connection) -> Self`
|
||||||
|
- `QuinnTransport::connection(&self) -> &quinn::Connection`
|
||||||
|
- `QuinnTransport::max_datagram_size(&self) -> Option<usize>`
|
||||||
|
- Implements `MediaTransport` trait
|
||||||
|
|
||||||
|
### Configuration (`config.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Create a server configuration with a self-signed certificate.
|
||||||
|
pub fn server_config() -> (quinn::ServerConfig, Vec<u8>)
|
||||||
|
|
||||||
|
/// Create a client configuration that trusts any certificate (testing).
|
||||||
|
pub fn client_config() -> quinn::ClientConfig
|
||||||
|
```
|
||||||
|
|
||||||
|
QUIC parameters: ALPN `wzp`, 30s idle timeout, 5s keepalive, 256KB receive window, 128KB send window, 300ms initial RTT.
|
||||||
|
|
||||||
|
### Connection Lifecycle (`connection.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn create_endpoint(bind_addr: SocketAddr, server_config: Option<quinn::ServerConfig>) -> Result<quinn::Endpoint, TransportError>
|
||||||
|
pub async fn connect(endpoint: &quinn::Endpoint, addr: SocketAddr, server_name: &str, config: quinn::ClientConfig) -> Result<quinn::Connection, TransportError>
|
||||||
|
pub async fn accept(endpoint: &quinn::Endpoint) -> Result<quinn::Connection, TransportError>
|
||||||
|
```
|
||||||
|
|
||||||
|
### PathMonitor (`path_monitor.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct PathMonitor { /* EWMA state for loss, RTT, jitter, bandwidth */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `PathMonitor::new() -> Self`
|
||||||
|
- `PathMonitor::observe_sent(&mut self, seq: u16, timestamp_ms: u64)`
|
||||||
|
- `PathMonitor::observe_received(&mut self, seq: u16, timestamp_ms: u64)`
|
||||||
|
- `PathMonitor::observe_rtt(&mut self, rtt_ms: u32)`
|
||||||
|
- `PathMonitor::quality(&self) -> PathQuality`
|
||||||
|
|
||||||
|
### Datagram Helpers (`datagram.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn serialize_media(packet: &MediaPacket) -> Bytes
|
||||||
|
pub fn deserialize_media(data: Bytes) -> Option<MediaPacket>
|
||||||
|
pub fn max_datagram_payload(connection: &quinn::Connection) -> Option<usize>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reliable Stream Framing (`reliable.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn send_signal(connection: &Connection, msg: &SignalMessage) -> Result<(), TransportError>
|
||||||
|
pub async fn recv_signal(recv: &mut quinn::RecvStream) -> Result<SignalMessage, TransportError>
|
||||||
|
```
|
||||||
|
|
||||||
|
Framing: 4-byte big-endian length prefix + serde_json payload. Max message size: 1 MB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## wzp-relay
|
||||||
|
|
||||||
|
**Path**: `crates/wzp-relay/src/`
|
||||||
|
|
||||||
|
### Re-exports (`lib.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub use config::RelayConfig;
|
||||||
|
pub use handshake::accept_handshake;
|
||||||
|
pub use pipeline::{PipelineConfig, PipelineStats, RelayPipeline};
|
||||||
|
pub use session_mgr::{RelaySession, SessionId, SessionManager};
|
||||||
|
```
|
||||||
|
|
||||||
|
### RoomManager (`room.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub type ParticipantId = u64;
|
||||||
|
pub struct RoomManager { /* rooms: HashMap<String, Room> */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `RoomManager::new() -> Self`
|
||||||
|
- `RoomManager::join(&mut self, room_name: &str, addr: SocketAddr, transport: Arc<QuinnTransport>) -> ParticipantId`
|
||||||
|
- `RoomManager::leave(&mut self, room_name: &str, participant_id: ParticipantId)`
|
||||||
|
- `RoomManager::others(&self, room_name: &str, participant_id: ParticipantId) -> Vec<Arc<QuinnTransport>>`
|
||||||
|
- `RoomManager::room_size(&self, room_name: &str) -> usize`
|
||||||
|
- `RoomManager::list(&self) -> Vec<(String, usize)>`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Run the receive loop for one participant in a room (forwards to all others).
|
||||||
|
pub async fn run_participant(room_mgr: Arc<Mutex<RoomManager>>, room_name: String, participant_id: ParticipantId, transport: Arc<QuinnTransport>)
|
||||||
|
```
|
||||||
|
|
||||||
|
### RelayPipeline (`pipeline.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct PipelineConfig { pub initial_profile: QualityProfile, pub jitter_target: usize, pub jitter_max: usize, pub jitter_min: usize }
|
||||||
|
pub struct PipelineStats { pub packets_received: u64, pub packets_forwarded: u64, pub packets_fec_recovered: u64, pub packets_lost: u64, pub profile_changes: u64 }
|
||||||
|
pub struct RelayPipeline { /* fec_encoder, fec_decoder, jitter, quality, profile, out_seq, stats */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `RelayPipeline::new(config: PipelineConfig) -> Self`
|
||||||
|
- `RelayPipeline::ingest(&mut self, packet: MediaPacket) -> Vec<MediaPacket>` -- FEC decode + jitter pop
|
||||||
|
- `RelayPipeline::prepare_outbound(&mut self, packet: MediaPacket) -> Vec<MediaPacket>` -- assign seq + FEC encode
|
||||||
|
- `RelayPipeline::stats(&self) -> &PipelineStats`
|
||||||
|
- `RelayPipeline::profile(&self) -> QualityProfile`
|
||||||
|
|
||||||
|
### SessionManager (`session_mgr.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub type SessionId = [u8; 16];
|
||||||
|
pub struct RelaySession { pub state: Session, pub upstream_pipeline: RelayPipeline, pub downstream_pipeline: RelayPipeline, pub profile: QualityProfile, pub last_activity_ms: u64 }
|
||||||
|
pub struct SessionManager { /* sessions: HashMap<SessionId, RelaySession>, max_sessions */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `SessionManager::new(max_sessions: usize) -> Self`
|
||||||
|
- `SessionManager::create_session(&mut self, session_id: SessionId, config: PipelineConfig) -> Option<&mut RelaySession>`
|
||||||
|
- `SessionManager::get_session(&mut self, id: &SessionId) -> Option<&mut RelaySession>`
|
||||||
|
- `SessionManager::remove_session(&mut self, id: &SessionId) -> Option<RelaySession>`
|
||||||
|
- `SessionManager::expire_idle(&mut self, now_ms: u64, timeout_ms: u64) -> usize`
|
||||||
|
|
||||||
|
### Handshake (`handshake.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Accept the relay (callee) side of the cryptographic handshake.
|
||||||
|
pub async fn accept_handshake(transport: &dyn MediaTransport, seed: &[u8; 32]) -> Result<(Box<dyn CryptoSession>, QualityProfile), anyhow::Error>
|
||||||
|
```
|
||||||
|
|
||||||
|
### RelayConfig (`config.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct RelayConfig {
|
||||||
|
pub listen_addr: SocketAddr, // default: 0.0.0.0:4433
|
||||||
|
pub remote_relay: Option<SocketAddr>, // None = room mode
|
||||||
|
pub max_sessions: usize, // default: 100
|
||||||
|
pub jitter_target_depth: usize, // default: 50
|
||||||
|
pub jitter_max_depth: usize, // default: 250
|
||||||
|
pub log_level: String, // default: "info"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## wzp-client
|
||||||
|
|
||||||
|
**Path**: `crates/wzp-client/src/`
|
||||||
|
|
||||||
|
### Re-exports (`lib.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(feature = "audio")]
|
||||||
|
pub use audio_io::{AudioCapture, AudioPlayback};
|
||||||
|
pub use call::{CallConfig, CallDecoder, CallEncoder};
|
||||||
|
pub use handshake::perform_handshake;
|
||||||
|
```
|
||||||
|
|
||||||
|
### CallEncoder (`call.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct CallEncoder { /* audio_enc, fec_enc, profile, seq, block_id, frame_in_block, timestamp_ms */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `CallEncoder::new(config: &CallConfig) -> Self`
|
||||||
|
- `CallEncoder::encode_frame(&mut self, pcm: &[i16]) -> Result<Vec<MediaPacket>, anyhow::Error>` -- returns source + repair packets
|
||||||
|
- `CallEncoder::set_profile(&mut self, profile: QualityProfile) -> Result<(), anyhow::Error>`
|
||||||
|
|
||||||
|
### CallDecoder (`call.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct CallDecoder { /* audio_dec, fec_dec, jitter, quality, profile */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `CallDecoder::new(config: &CallConfig) -> Self`
|
||||||
|
- `CallDecoder::ingest(&mut self, packet: MediaPacket)` -- feeds FEC decoder and jitter buffer
|
||||||
|
- `CallDecoder::decode_next(&mut self, pcm: &mut [i16]) -> Option<usize>` -- pops from jitter, decodes
|
||||||
|
- `CallDecoder::profile(&self) -> QualityProfile`
|
||||||
|
- `CallDecoder::jitter_stats(&self) -> JitterStats`
|
||||||
|
|
||||||
|
### CallConfig (`call.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct CallConfig {
|
||||||
|
pub profile: QualityProfile, // default: GOOD
|
||||||
|
pub jitter_target: usize, // default: 10
|
||||||
|
pub jitter_max: usize, // default: 250
|
||||||
|
pub jitter_min: usize, // default: 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client Handshake (`handshake.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Perform the client (caller) side of the cryptographic handshake.
|
||||||
|
pub async fn perform_handshake(transport: &dyn MediaTransport, seed: &[u8; 32]) -> Result<Box<dyn CryptoSession>, anyhow::Error>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Echo Test (`echo_test.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct WindowResult { pub index: usize, pub time_offset_secs: f64, pub frames_sent: u32, pub frames_received: u32, pub loss_pct: f32, pub snr_db: f32, pub correlation: f32, pub peak_amplitude: i16, pub is_silent: bool }
|
||||||
|
pub struct EchoTestResult { pub duration_secs: f64, pub total_frames_sent: u64, pub total_frames_received: u64, pub overall_loss_pct: f32, pub windows: Vec<WindowResult>, /* ... */ }
|
||||||
|
|
||||||
|
pub async fn run_echo_test(transport: &(dyn MediaTransport + Send + Sync), duration_secs: u32, window_secs: f64) -> anyhow::Result<EchoTestResult>
|
||||||
|
pub fn print_report(result: &EchoTestResult)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Audio I/O (`audio_io.rs`, requires `audio` feature)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct AudioCapture { /* rx: mpsc::Receiver<Vec<i16>>, running: Arc<AtomicBool> */ }
|
||||||
|
pub struct AudioPlayback { /* tx: mpsc::SyncSender<Vec<i16>>, running: Arc<AtomicBool> */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `AudioCapture::start() -> Result<Self, anyhow::Error>` -- opens default input at 48 kHz mono
|
||||||
|
- `AudioCapture::read_frame(&self) -> Option<Vec<i16>>` -- blocking, returns 960 samples
|
||||||
|
- `AudioCapture::stop(&self)`
|
||||||
|
- `AudioPlayback::start() -> Result<Self, anyhow::Error>` -- opens default output at 48 kHz mono
|
||||||
|
- `AudioPlayback::write_frame(&self, pcm: &[i16])`
|
||||||
|
- `AudioPlayback::stop(&self)`
|
||||||
|
|
||||||
|
### Benchmarks (`bench.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct CodecResult { pub frames: usize, pub avg_encode_us: f64, pub avg_decode_us: f64, pub frames_per_sec: f64, pub compression_ratio: f64, /* ... */ }
|
||||||
|
pub struct FecResult { pub blocks_attempted: usize, pub blocks_recovered: usize, pub recovery_rate_pct: f64, /* ... */ }
|
||||||
|
pub struct CryptoResult { pub packets: usize, pub packets_per_sec: f64, pub megabytes_per_sec: f64, pub avg_latency_us: f64, /* ... */ }
|
||||||
|
pub struct PipelineResult { pub frames: usize, pub avg_e2e_latency_us: f64, pub overhead_ratio: f64, /* ... */ }
|
||||||
|
|
||||||
|
pub fn generate_sine_wave(freq_hz: f32, sample_rate: u32, num_samples: usize) -> Vec<i16>
|
||||||
|
pub fn bench_codec_roundtrip() -> CodecResult // 1000 frames Opus 24kbps
|
||||||
|
pub fn bench_fec_recovery(loss_pct: f32) -> FecResult // 100 blocks with simulated loss
|
||||||
|
pub fn bench_encrypt_decrypt() -> CryptoResult // 30000 packets ChaCha20
|
||||||
|
pub fn bench_full_pipeline() -> PipelineResult // 50 frames E2E
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## wzp-web
|
||||||
|
|
||||||
|
**Path**: `crates/wzp-web/src/`
|
||||||
|
|
||||||
|
The web bridge binary. No public library API -- it is a standalone Axum server.
|
||||||
|
|
||||||
|
### Binary: `wzp-web`
|
||||||
|
|
||||||
|
- Serves static files from `crates/wzp-web/static/`
|
||||||
|
- WebSocket endpoint: `GET /ws/{room}` -- upgrades to WebSocket
|
||||||
|
- Each WebSocket client gets a QUIC connection to the relay with the room name as SNI
|
||||||
|
- Browser -> relay: WebSocket binary messages (960 Int16 samples as raw bytes) -> `CallEncoder` -> `MediaTransport::send_media()`
|
||||||
|
- Relay -> browser: `MediaTransport::recv_media()` -> `CallDecoder` -> WebSocket binary messages
|
||||||
|
|
||||||
|
### Static Files
|
||||||
|
|
||||||
|
- `static/index.html` -- web UI with room input, connect/disconnect, PTT, level meter
|
||||||
|
- `static/audio-processor.js` -- AudioWorklet for microphone capture (960-sample frames)
|
||||||
|
- `static/playback-processor.js` -- AudioWorklet for audio playback (ring buffer, 200ms max)
|
||||||
329
docs/ARCHITECTURE.md
Normal file
329
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
# WarzonePhone Protocol Design & Architecture
|
||||||
|
|
||||||
|
## Network Topology
|
||||||
|
|
||||||
|
```
|
||||||
|
Lossy / censored link
|
||||||
|
◄──────────────────────►
|
||||||
|
┌────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────┐
|
||||||
|
│ Client │─QUIC─│ Relay A │─QUIC─│ Relay B │─QUIC─│ Destination │
|
||||||
|
└────────┘ └─────────┘ └─────────┘ └─────────────┘
|
||||||
|
│ │ │ │
|
||||||
|
Encode Forward Forward Decode
|
||||||
|
FEC FEC FEC FEC
|
||||||
|
Encrypt (opaque) (opaque) Decrypt
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Protocol Stack
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ 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
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Defined in `crates/wzp-proto/src/packet.rs` as `QualityReport`.
|
||||||
|
|
||||||
|
### MediaPacket
|
||||||
|
|
||||||
|
A complete media packet on the wire:
|
||||||
|
|
||||||
|
```
|
||||||
|
[MediaHeader: 12 bytes][Payload: variable][QualityReport: 4 bytes if Q=1]
|
||||||
|
```
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
```
|
||||||
|
session_id[0..4] || sequence_number (u32 BE) || direction (1 byte) || padding (3 bytes zero)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `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`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Idle -> Connecting -> Handshaking -> Active <-> Rekeying -> Active
|
||||||
|
|
|
||||||
|
Closed
|
||||||
|
```
|
||||||
|
|
||||||
|
- Media flows during both `Active` and `Rekeying` states
|
||||||
|
- Any state can transition to `Closed` via `Terminate` or `ConnectionLost`
|
||||||
|
- Invalid transitions produce a `TransitionError`
|
||||||
|
|
||||||
|
## Relay Modes
|
||||||
|
|
||||||
|
### Room Mode (Default, SFU)
|
||||||
|
|
||||||
|
- 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`
|
||||||
|
|
||||||
|
### Forward Mode (`--remote`)
|
||||||
|
|
||||||
|
- 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
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Transport
|
||||||
|
|
||||||
|
Implemented in `crates/wzp-transport/` using QUIC via the `quinn` crate.
|
||||||
|
|
||||||
|
### QUIC Configuration
|
||||||
|
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
### Media Transport
|
||||||
|
|
||||||
|
- **Unreliable media**: QUIC DATAGRAM frames (no retransmission, no head-of-line blocking)
|
||||||
|
- **Reliable signaling**: QUIC bidirectional streams with length-prefixed JSON framing
|
||||||
|
|
||||||
|
### Path Quality Monitoring
|
||||||
|
|
||||||
|
`PathMonitor` in `crates/wzp-transport/src/path_monitor.rs` tracks:
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
### Codec Selection by Tier
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
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).
|
||||||
168
docs/DESIGN.md
Normal file
168
docs/DESIGN.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# WarzonePhone Detailed Design Decisions
|
||||||
|
|
||||||
|
## Why Opus + Codec2 (Not Just One)
|
||||||
|
|
||||||
|
The dual-codec architecture is driven by the extreme range of network conditions WarzonePhone targets:
|
||||||
|
|
||||||
|
**Opus** (24/16/6 kbps) is the clear choice for normal to degraded conditions. It offers excellent quality at moderate bitrates, has built-in inband FEC and DTX (discontinuous transmission), and the `audiopus` crate provides mature Rust bindings to libopus. Opus operates at 48 kHz natively.
|
||||||
|
|
||||||
|
**Codec2** (3200/1200 bps) is a narrowband vocoder designed specifically for HF radio links with extreme bandwidth constraints. At 1200 bps (1.2 kbps), it produces intelligible speech in only 6 bytes per 40ms frame -- roughly 20x lower bitrate than Opus at its minimum. The pure-Rust `codec2` crate means no C dependencies for this codec. Codec2 operates at 8 kHz, so the adaptive layer handles 48 kHz <-> 8 kHz resampling transparently.
|
||||||
|
|
||||||
|
The `AdaptiveEncoder`/`AdaptiveDecoder` in `crates/wzp-codec/src/adaptive.rs` hold both codec instances and switch between them based on the active `QualityProfile`. This avoids codec re-initialization latency during tier transitions.
|
||||||
|
|
||||||
|
**Bandwidth comparison with FEC overhead:**
|
||||||
|
|
||||||
|
| Tier | Codec Bitrate | FEC Ratio | Total Bandwidth |
|
||||||
|
|------|--------------|-----------|----------------|
|
||||||
|
| GOOD | 24 kbps | 20% | ~28.8 kbps |
|
||||||
|
| DEGRADED | 6 kbps | 50% | ~9.0 kbps |
|
||||||
|
| CATASTROPHIC | 1.2 kbps | 100% | ~2.4 kbps |
|
||||||
|
|
||||||
|
At the catastrophic tier, the entire call (audio + FEC + headers) fits within approximately 3 kbps, which is viable even over severely degraded links.
|
||||||
|
|
||||||
|
## Why RaptorQ Over Reed-Solomon
|
||||||
|
|
||||||
|
**Reed-Solomon** is a classical block erasure code. It works well but has fixed-rate overhead: you must decide in advance how many repair symbols to generate, and decoding requires receiving exactly K of any K+R symbols.
|
||||||
|
|
||||||
|
**RaptorQ** (RFC 6330) is a fountain code with several advantages for VoIP:
|
||||||
|
|
||||||
|
1. **Rateless**: You can generate an arbitrary number of repair symbols on the fly. If conditions worsen mid-block, you can generate additional repair without re-encoding.
|
||||||
|
|
||||||
|
2. **Efficient decoding**: RaptorQ can decode from any K symbols with high probability (typically K + 1 or K + 2 suffice), compared to Reed-Solomon which requires exactly K.
|
||||||
|
|
||||||
|
3. **Lower computational complexity**: O(K) encoding and decoding time, compared to O(K^2) for Reed-Solomon. This matters for real-time audio at 50 frames/second.
|
||||||
|
|
||||||
|
4. **Variable block sizes**: The encoder handles 1-56403 source symbols per block (the WZP implementation uses 5-10, but the flexibility is there).
|
||||||
|
|
||||||
|
The `raptorq` crate (v2) provides a well-tested pure-Rust implementation. The WZP FEC layer adds length-prefixed padding (2-byte LE prefix + zero-pad to 256 bytes) so that variable-length audio frames can be recovered exactly.
|
||||||
|
|
||||||
|
**FEC bandwidth math at different loss rates:**
|
||||||
|
|
||||||
|
With 5 source frames per block:
|
||||||
|
- 20% repair (GOOD): 1 repair symbol. Survives loss of 1 out of 6 packets (16.7% loss).
|
||||||
|
- 50% repair (DEGRADED): 3 repair symbols. Survives loss of 3 out of 8 packets (37.5% loss).
|
||||||
|
- 100% repair (CATASTROPHIC): 5 repair symbols. Survives loss of 5 out of 10 packets (50% loss).
|
||||||
|
|
||||||
|
The benchmark (`wzp-bench --fec --loss 30`) dynamically scales the FEC ratio to survive the requested loss percentage.
|
||||||
|
|
||||||
|
## Why QUIC Over Raw UDP
|
||||||
|
|
||||||
|
Raw UDP would be simpler and lower-latency, but QUIC (via the `quinn` crate) provides:
|
||||||
|
|
||||||
|
1. **DATAGRAM frames**: Unreliable delivery without head-of-line blocking (RFC 9221). Media packets use this path, so they behave like UDP datagrams but benefit from QUIC's connection management.
|
||||||
|
|
||||||
|
2. **Reliable streams**: Signaling messages (CallOffer, CallAnswer, Rekey, Hangup) require reliable delivery. QUIC provides multiplexed streams without needing a separate TCP connection.
|
||||||
|
|
||||||
|
3. **Built-in congestion control**: QUIC's congestion control prevents overwhelming degraded links, which is important when chaining relays.
|
||||||
|
|
||||||
|
4. **Connection migration**: QUIC connections survive IP address changes (e.g., WiFi to cellular handoff), which is valuable for mobile clients.
|
||||||
|
|
||||||
|
5. **TLS 1.3 built-in**: The QUIC handshake provides encryption at the transport level. While WZP has its own end-to-end ChaCha20 layer, the QUIC TLS protects the header and signaling from eavesdroppers.
|
||||||
|
|
||||||
|
6. **NAT keepalive**: QUIC's built-in keep-alive (configured at 5-second intervals) maintains NAT bindings without application-level pings.
|
||||||
|
|
||||||
|
7. **Firewall traversal**: QUIC runs on UDP port 443 by default, which is commonly allowed through firewalls. The `wzp` ALPN protocol identifier distinguishes WZP traffic.
|
||||||
|
|
||||||
|
The tradeoff is approximately 20-40 bytes of additional per-packet overhead compared to raw UDP (QUIC short header + DATAGRAM frame overhead).
|
||||||
|
|
||||||
|
## Why ChaCha20-Poly1305 Over AES-GCM
|
||||||
|
|
||||||
|
1. **Software performance**: ChaCha20-Poly1305 is faster than AES-GCM on hardware without AES-NI instructions. This matters for ARM devices (Android phones, Raspberry Pi relays, embedded systems) where AES hardware acceleration may be absent.
|
||||||
|
|
||||||
|
2. **Constant-time by design**: ChaCha20 uses only add-rotate-XOR operations, making it inherently resistant to timing side-channel attacks. AES-GCM implementations without hardware support often require careful constant-time implementation.
|
||||||
|
|
||||||
|
3. **Warzone messenger compatibility**: The existing Warzone messenger uses ChaCha20-Poly1305 for message encryption. Reusing the same primitive simplifies the security audit and allows key material to be shared across messaging and calling.
|
||||||
|
|
||||||
|
4. **16-byte overhead**: Both ChaCha20-Poly1305 and AES-128-GCM produce a 16-byte authentication tag. There is no size advantage to AES-GCM.
|
||||||
|
|
||||||
|
5. **AEAD with AAD**: The MediaHeader is used as Associated Authenticated Data (AAD), ensuring the header is authenticated but not encrypted. This allows relays to read routing information (block ID, sequence number) without decrypting the payload.
|
||||||
|
|
||||||
|
## Why Star Dependency Graph (Parallel Development)
|
||||||
|
|
||||||
|
The workspace follows a strict star dependency pattern:
|
||||||
|
|
||||||
|
```
|
||||||
|
wzp-proto (hub)
|
||||||
|
/ | \ \
|
||||||
|
wzp-codec wzp-fec wzp-crypto wzp-transport
|
||||||
|
\ | / /
|
||||||
|
wzp-relay
|
||||||
|
wzp-client
|
||||||
|
wzp-web
|
||||||
|
```
|
||||||
|
|
||||||
|
- `wzp-proto` defines all trait interfaces and wire format types
|
||||||
|
- Each "leaf" crate (codec, fec, crypto, transport) depends only on `wzp-proto`
|
||||||
|
- No leaf crate depends on another leaf crate
|
||||||
|
- Integration crates (relay, client, web) depend on all leaves
|
||||||
|
|
||||||
|
This enables:
|
||||||
|
1. **Parallel development**: 5 agents/developers can work on 5 crates simultaneously with zero merge conflicts
|
||||||
|
2. **Independent testing**: Each crate has comprehensive tests that run without requiring other implementations
|
||||||
|
3. **Pluggability**: Any implementation can be swapped (e.g., replace RaptorQ with Reed-Solomon) by implementing the same trait
|
||||||
|
4. **Fast compilation**: Changes to one leaf only recompile that leaf and the integration crates, not other leaves
|
||||||
|
|
||||||
|
## Jitter Buffer Trade-offs
|
||||||
|
|
||||||
|
The jitter buffer must balance two competing goals:
|
||||||
|
|
||||||
|
**Lower latency** (smaller buffer):
|
||||||
|
- Better conversational interactivity
|
||||||
|
- Less memory usage
|
||||||
|
- But more vulnerable to jitter and reordering
|
||||||
|
|
||||||
|
**Higher quality** (larger buffer):
|
||||||
|
- More time to receive out-of-order packets
|
||||||
|
- More time for FEC recovery (repair packets may arrive after source packets)
|
||||||
|
- But adds perceptible delay to the conversation
|
||||||
|
|
||||||
|
The default configuration:
|
||||||
|
- Target: 10 packets (200ms) for the client, 50 packets (1s) for the relay
|
||||||
|
- Minimum: 3 packets (60ms) before playout begins (client), 25 packets (500ms) for relay
|
||||||
|
- Maximum: 250 packets (5s) absolute cap
|
||||||
|
|
||||||
|
The relay uses a deeper buffer because it needs to absorb jitter from the lossy inter-relay link. The client uses a shallower buffer for lower latency since it is on the last hop.
|
||||||
|
|
||||||
|
**Known issue**: The current jitter buffer does not adapt its depth based on observed jitter. It uses sequence-number ordering only, without timestamp-based playout scheduling. This can lead to drift during long calls, as observed in echo tests.
|
||||||
|
|
||||||
|
## Browser Audio: AudioWorklet vs ScriptProcessorNode
|
||||||
|
|
||||||
|
The web bridge (`crates/wzp-web/static/`) uses AudioWorklet as the primary audio I/O mechanism, with ScriptProcessorNode as a fallback.
|
||||||
|
|
||||||
|
**AudioWorklet** (preferred):
|
||||||
|
- Runs on a dedicated audio rendering thread
|
||||||
|
- Lower latency (no main-thread round-trip)
|
||||||
|
- Consistent 128-sample callback timing
|
||||||
|
- Supported in Chrome 66+, Firefox 76+, Safari 14.1+
|
||||||
|
|
||||||
|
**ScriptProcessorNode** (fallback):
|
||||||
|
- Runs on the main thread via `onaudioprocess` callback
|
||||||
|
- Higher latency, potential glitches from main-thread GC pauses
|
||||||
|
- Deprecated by the Web Audio specification
|
||||||
|
- Used when AudioWorklet is not available
|
||||||
|
|
||||||
|
Both paths accumulate Float32 samples into 960-sample (20ms) Int16 frames before sending via WebSocket, matching the WZP codec frame size.
|
||||||
|
|
||||||
|
**Playback** uses an AudioWorklet with a ring buffer capped at 200ms (9600 samples at 48 kHz). When the buffer exceeds this limit, old samples are dropped to prevent unbounded drift. The fallback path uses scheduled `AudioBufferSourceNode` instances.
|
||||||
|
|
||||||
|
## Room Mode: SFU vs MCU Trade-offs
|
||||||
|
|
||||||
|
WarzonePhone implements an **SFU** (Selective Forwarding Unit) architecture:
|
||||||
|
|
||||||
|
**SFU** (implemented):
|
||||||
|
- Relay forwards each participant's packets to all other participants unchanged
|
||||||
|
- No transcoding -- the relay never decodes or re-encodes audio
|
||||||
|
- O(N) bandwidth at the relay for N participants (each packet is sent N-1 times)
|
||||||
|
- Each client receives separate streams from each other participant
|
||||||
|
- Client must mix/decode multiple streams locally
|
||||||
|
- Lower relay CPU usage (no transcoding)
|
||||||
|
- End-to-end encryption is preserved (relay never sees plaintext)
|
||||||
|
|
||||||
|
**MCU** (not implemented, for comparison):
|
||||||
|
- Relay would decode all streams, mix them, and re-encode a single combined stream
|
||||||
|
- O(1) bandwidth to each client (receives one mixed stream)
|
||||||
|
- Requires the relay to have codec keys (breaks E2E encryption)
|
||||||
|
- Higher relay CPU (decoding N streams + mixing + re-encoding)
|
||||||
|
- Audio quality loss from re-encoding
|
||||||
|
|
||||||
|
The SFU choice is driven by the E2E encryption requirement: since relays never have access to the audio codec keys, they cannot decode, mix, or re-encode. The current room implementation in `crates/wzp-relay/src/room.rs` forwards received datagrams to all other participants in the room with best-effort delivery -- if one send fails, the relay continues to the next participant.
|
||||||
204
docs/EXTENSIBILITY.md
Normal file
204
docs/EXTENSIBILITY.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# WarzonePhone Extension Points & Future Features
|
||||||
|
|
||||||
|
## Trait-Based Architecture
|
||||||
|
|
||||||
|
The protocol is designed around trait interfaces defined in `crates/wzp-proto/src/traits.rs`. Any implementation that satisfies the trait contract can be plugged in without modifying other crates.
|
||||||
|
|
||||||
|
### Adding a New Audio Codec
|
||||||
|
|
||||||
|
Implement `AudioEncoder` and `AudioDecoder` from `wzp_proto::traits`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait AudioEncoder: Send + Sync {
|
||||||
|
fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize, CodecError>;
|
||||||
|
fn codec_id(&self) -> CodecId;
|
||||||
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError>;
|
||||||
|
fn max_frame_bytes(&self) -> usize;
|
||||||
|
fn set_inband_fec(&mut self, _enabled: bool) {}
|
||||||
|
fn set_dtx(&mut self, _enabled: bool) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait AudioDecoder: Send + Sync {
|
||||||
|
fn decode(&mut self, encoded: &[u8], pcm: &mut [i16]) -> Result<usize, CodecError>;
|
||||||
|
fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError>;
|
||||||
|
fn codec_id(&self) -> CodecId;
|
||||||
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Add a new variant to `CodecId` in `crates/wzp-proto/src/codec_id.rs` (uses 4-bit wire encoding, currently 5 of 16 values used)
|
||||||
|
2. Implement `AudioEncoder` and `AudioDecoder` for your codec
|
||||||
|
3. Register the codec in `AdaptiveEncoder`/`AdaptiveDecoder` in `crates/wzp-codec/src/adaptive.rs`
|
||||||
|
4. Add a `QualityProfile` constant for the new codec
|
||||||
|
|
||||||
|
### Adding a New FEC Scheme
|
||||||
|
|
||||||
|
Implement `FecEncoder` and `FecDecoder` from `wzp_proto::traits`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait FecEncoder: Send + Sync {
|
||||||
|
fn add_source_symbol(&mut self, data: &[u8]) -> Result<(), FecError>;
|
||||||
|
fn generate_repair(&mut self, ratio: f32) -> Result<Vec<(u8, Vec<u8>)>, FecError>;
|
||||||
|
fn finalize_block(&mut self) -> Result<u8, FecError>;
|
||||||
|
fn current_block_id(&self) -> u8;
|
||||||
|
fn current_block_size(&self) -> usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait FecDecoder: Send + Sync {
|
||||||
|
fn add_symbol(&mut self, block_id: u8, symbol_index: u8, is_repair: bool, data: &[u8]) -> Result<(), FecError>;
|
||||||
|
fn try_decode(&mut self, block_id: u8) -> Result<Option<Vec<Vec<u8>>>, FecError>;
|
||||||
|
fn expire_before(&mut self, block_id: u8);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, a Reed-Solomon implementation would maintain the same block/symbol structure but use a different coding algorithm internally. The FEC block ID and symbol index fields in `MediaHeader` support any scheme that fits the block/symbol model.
|
||||||
|
|
||||||
|
### Adding a New Transport
|
||||||
|
|
||||||
|
Implement `MediaTransport` from `wzp_proto::traits`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
pub trait MediaTransport: Send + Sync {
|
||||||
|
async fn send_media(&self, packet: &MediaPacket) -> Result<(), TransportError>;
|
||||||
|
async fn recv_media(&self) -> Result<Option<MediaPacket>, TransportError>;
|
||||||
|
async fn send_signal(&self, msg: &SignalMessage) -> Result<(), TransportError>;
|
||||||
|
async fn recv_signal(&self) -> Result<Option<SignalMessage>, TransportError>;
|
||||||
|
fn path_quality(&self) -> PathQuality;
|
||||||
|
async fn close(&self) -> Result<(), TransportError>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A raw UDP transport, a WebRTC data channel transport, or a TCP tunnel transport could all implement this trait.
|
||||||
|
|
||||||
|
## Obfuscation Layer (Phase 2)
|
||||||
|
|
||||||
|
The `ObfuscationLayer` trait is defined in `crates/wzp-proto/src/traits.rs` but not yet implemented:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait ObfuscationLayer: Send + Sync {
|
||||||
|
fn obfuscate(&mut self, data: &[u8], out: &mut Vec<u8>) -> Result<(), ObfuscationError>;
|
||||||
|
fn deobfuscate(&mut self, data: &[u8], out: &mut Vec<u8>) -> Result<(), ObfuscationError>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Planned implementations:
|
||||||
|
- **TLS-in-TLS**: Wrap QUIC traffic inside a TLS connection to port 443, making it look like ordinary HTTPS
|
||||||
|
- **HTTP/2 mimicry**: Frame QUIC packets as HTTP/2 data frames
|
||||||
|
- **Random padding**: Add random-length padding to defeat traffic analysis
|
||||||
|
- **Domain fronting**: Use CDN infrastructure to hide the true destination
|
||||||
|
|
||||||
|
The obfuscation layer sits between the crypto layer and the transport layer in the protocol stack, wrapping encrypted packets before transmission.
|
||||||
|
|
||||||
|
## FeatherChat / Warzone Messenger Integration
|
||||||
|
|
||||||
|
As described in `docs/featherchat.md`, WarzonePhone is designed to integrate with the existing Warzone messenger.
|
||||||
|
|
||||||
|
### Shared Identity Model
|
||||||
|
|
||||||
|
Both WarzonePhone and Warzone use the same identity derivation:
|
||||||
|
- 32-byte seed (BIP39 mnemonic backup)
|
||||||
|
- HKDF with context strings: `"warzone-ed25519-identity"` and `"warzone-x25519-identity"`
|
||||||
|
- Ed25519 for signing, X25519 for encryption
|
||||||
|
- Fingerprint: `SHA-256(Ed25519_pub)[:16]`
|
||||||
|
|
||||||
|
This is implemented in `crates/wzp-crypto/src/handshake.rs` as `WarzoneKeyExchange::from_identity_seed()`.
|
||||||
|
|
||||||
|
### Signaling via Existing WebSocket
|
||||||
|
|
||||||
|
Call initiation flows through the Warzone messenger's existing WebSocket connection:
|
||||||
|
1. Caller looks up callee via `@alias`, federated address, or raw fingerprint
|
||||||
|
2. Caller sends `WireMessage::CallOffer` through the existing message channel
|
||||||
|
3. Callee receives the offer and responds with `WireMessage::CallAnswer`
|
||||||
|
4. Both sides establish a direct QUIC connection to the relay using ephemeral keys from the signaling exchange
|
||||||
|
|
||||||
|
The `SignalMessage::CallOffer` and `SignalMessage::CallAnswer` variants in `crates/wzp-proto/src/packet.rs` carry the same fields needed for this flow.
|
||||||
|
|
||||||
|
### Key Derivation from Existing Shared Secret
|
||||||
|
|
||||||
|
When two Warzone users already have an X3DH shared secret from their messaging session, call keys can be derived from it:
|
||||||
|
- `HKDF(x3dh_shared_secret, "warzone-call-session")` -> 32-byte session key
|
||||||
|
- Or: fresh ephemeral exchange per call (current implementation) for independent forward secrecy
|
||||||
|
|
||||||
|
### Unified Addressing
|
||||||
|
|
||||||
|
The Warzone addressing system resolves user identities across multiple namespaces:
|
||||||
|
|
||||||
|
| Method | Format | Resolution |
|
||||||
|
|--------|--------|------------|
|
||||||
|
| Local alias | `@manwe` | Server resolves to fingerprint |
|
||||||
|
| Federated | `@manwe.b1.example.com` | DNS TXT record -> fingerprint + endpoint |
|
||||||
|
| ENS | `@manwe.eth` | Ethereum address -> fingerprint (planned) |
|
||||||
|
| Raw fingerprint | `xxxx:xxxx:...` | Direct lookup |
|
||||||
|
|
||||||
|
A user calls `@manwe` the same way they message `@manwe`.
|
||||||
|
|
||||||
|
## Authentication: Caller Verification Before Bridging
|
||||||
|
|
||||||
|
Currently, relays forward packets without verifying caller identity. To add authentication:
|
||||||
|
|
||||||
|
1. **Relay-side handshake**: The relay receives the `CallOffer`, verifies the Ed25519 signature, and checks the caller's identity against an allowlist before accepting the connection.
|
||||||
|
|
||||||
|
2. **Implementation point**: `crates/wzp-relay/src/handshake.rs` already implements `accept_handshake()` which performs signature verification. To gate admission, add an authorization check after signature verification.
|
||||||
|
|
||||||
|
3. **Token-based auth**: Add a `token: Vec<u8>` field to `CallOffer` containing a relay-issued authentication token (e.g., signed by the relay operator's key).
|
||||||
|
|
||||||
|
## Multi-Relay Mesh
|
||||||
|
|
||||||
|
The current two-relay chain (`--remote` flag) can be extended to a multi-hop mesh:
|
||||||
|
|
||||||
|
```
|
||||||
|
Client -> Relay A -> Relay B -> Relay C -> Destination
|
||||||
|
```
|
||||||
|
|
||||||
|
Each hop uses the relay pipeline (FEC decode -> jitter buffer -> FEC re-encode) to absorb loss on each link independently. This requires:
|
||||||
|
|
||||||
|
1. Relay discovery and route selection (not yet implemented)
|
||||||
|
2. Per-hop FEC parameters (each link may have different loss characteristics)
|
||||||
|
3. Cumulative latency management (each hop adds jitter buffer delay)
|
||||||
|
|
||||||
|
## Video Support
|
||||||
|
|
||||||
|
The trait architecture supports video by adding:
|
||||||
|
|
||||||
|
1. **Video codec trait**: Similar to `AudioEncoder`/`AudioDecoder` but for video frames
|
||||||
|
2. **Codec choices**: AV1 (best compression, higher CPU), VP9 SVC (scalable, moderate CPU)
|
||||||
|
3. **Separate FEC strategy**: Video frames are larger and more critical (I-frames vs P-frames need different protection levels)
|
||||||
|
4. **SVC (Scalable Video Coding)**: With VP9 SVC, the relay can drop enhancement layers without transcoding, adapting video quality to each receiver's bandwidth
|
||||||
|
|
||||||
|
Video would add new `CodecId` variants and a separate `QualityProfile` for video parameters.
|
||||||
|
|
||||||
|
## Android Native Client
|
||||||
|
|
||||||
|
The workspace is designed with Android in mind (`wzp-client` description mentions "for Android (JNI) and Windows desktop"):
|
||||||
|
|
||||||
|
1. **JNI bindings**: Use `jni` crate or `uniffi` to expose `CallEncoder`, `CallDecoder`, and `MediaTransport` to Kotlin/Java
|
||||||
|
2. **Audio I/O**: Android uses AAudio or OpenSL ES instead of cpal
|
||||||
|
3. **Build**: Cross-compile with `cargo ndk` targeting `aarch64-linux-android` and `armv7-linux-androideabi`
|
||||||
|
4. **Permissions**: `RECORD_AUDIO`, `INTERNET`, `WAKE_LOCK`
|
||||||
|
|
||||||
|
## STUN/TURN NAT Traversal Integration
|
||||||
|
|
||||||
|
The `SignalMessage::IceCandidate` variant is already defined for NAT traversal:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
IceCandidate { candidate: String }
|
||||||
|
```
|
||||||
|
|
||||||
|
Integration would involve:
|
||||||
|
1. STUN server queries to discover the client's public IP/port
|
||||||
|
2. ICE candidate exchange via the signaling channel
|
||||||
|
3. TURN relay fallback when direct UDP is blocked
|
||||||
|
4. Integration with the existing QUIC transport (QUIC can traverse NATs via its connection migration)
|
||||||
|
|
||||||
|
## Bandwidth Estimation and Adaptive Bitrate
|
||||||
|
|
||||||
|
The `PathMonitor` in `crates/wzp-transport/src/path_monitor.rs` already estimates bandwidth from observed packet rates. To close the loop:
|
||||||
|
|
||||||
|
1. Feed `PathMonitor::quality()` into `AdaptiveQualityController::observe()` as `QualityReport` values
|
||||||
|
2. The controller will trigger tier transitions when conditions change
|
||||||
|
3. Propagate the new `QualityProfile` to both encoder (codec switch) and FEC (ratio change)
|
||||||
|
4. Signal the peer via `SignalMessage::QualityUpdate` so both sides switch simultaneously
|
||||||
|
|
||||||
|
The framework is in place; the missing piece is the integration wiring in the client's main loop to periodically generate quality reports from path metrics.
|
||||||
193
docs/PROGRESS.md
Normal file
193
docs/PROGRESS.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# WarzonePhone Development Progress Report
|
||||||
|
|
||||||
|
## Phase 1: Protocol Core
|
||||||
|
|
||||||
|
**Scope**: Define the protocol types, traits, and core logic in `wzp-proto`.
|
||||||
|
|
||||||
|
**What was built**:
|
||||||
|
- Wire format types: `MediaHeader` (12-byte compact binary), `QualityReport` (4 bytes), `MediaPacket`, `SignalMessage` (8 variants)
|
||||||
|
- Trait definitions: `AudioEncoder`, `AudioDecoder`, `FecEncoder`, `FecDecoder`, `CryptoSession`, `KeyExchange`, `MediaTransport`, `ObfuscationLayer`, `QualityController`
|
||||||
|
- `CodecId` enum with 5 variants (Opus24k/16k/6k, Codec2_3200/1200) and 4-bit wire encoding
|
||||||
|
- `QualityProfile` with 3 preset tiers (GOOD, DEGRADED, CATASTROPHIC)
|
||||||
|
- `AdaptiveQualityController` with hysteresis (3-down/10-up thresholds, sliding window of 20 reports)
|
||||||
|
- `JitterBuffer` with BTreeMap-based reordering, wrapping sequence arithmetic, min/max/target depth
|
||||||
|
- `Session` state machine (Idle -> Connecting -> Handshaking -> Active <-> Rekeying -> Closed)
|
||||||
|
- Full error type hierarchy (`CodecError`, `FecError`, `CryptoError`, `TransportError`, `ObfuscationError`)
|
||||||
|
|
||||||
|
**Tests**: 27 tests across packet roundtrip, quality controller, jitter buffer, session state machine, sequence wrapping
|
||||||
|
|
||||||
|
## Phase 2: Implementation Crates (Parallel)
|
||||||
|
|
||||||
|
**Scope**: Implement the 4 leaf crates against the trait interfaces, in parallel.
|
||||||
|
|
||||||
|
### wzp-codec
|
||||||
|
- Opus encoder/decoder via `audiopus` (48 kHz mono, VoIP application mode, inband FEC, DTX)
|
||||||
|
- Codec2 encoder/decoder via pure-Rust `codec2` crate (3200 and 1200 bps modes)
|
||||||
|
- `AdaptiveEncoder`/`AdaptiveDecoder` wrapping both codecs with transparent switching
|
||||||
|
- Linear resampler for 48 kHz <-> 8 kHz conversion (box filter downsampling, linear interpolation upsampling)
|
||||||
|
- All callers work with 48 kHz PCM regardless of active codec
|
||||||
|
|
||||||
|
### wzp-fec
|
||||||
|
- `RaptorQFecEncoder`: accumulates source symbols with 2-byte length prefix + zero padding to 256-byte symbol size
|
||||||
|
- `RaptorQFecDecoder`: multi-block concurrent decoding with HashMap-based block tracking
|
||||||
|
- `Interleaver`: round-robin temporal interleaving across multiple FEC blocks
|
||||||
|
- `BlockManager`: encoder-side (Building/Pending/Sent/Acknowledged) and decoder-side (Assembling/Complete/Expired) lifecycle tracking
|
||||||
|
- `AdaptiveFec`: maps `QualityProfile` to FEC parameters
|
||||||
|
- Factory function `create_fec_pair()` for convenient encoder/decoder creation
|
||||||
|
|
||||||
|
### wzp-crypto
|
||||||
|
- `WarzoneKeyExchange`: identity seed -> HKDF -> Ed25519 + X25519, ephemeral generation, signature, verification, session derivation
|
||||||
|
- `ChaChaSession`: ChaCha20-Poly1305 AEAD with deterministic nonce construction (session_id + seq + direction)
|
||||||
|
- `RekeyManager`: triggers rekey every 2^16 packets, HKDF mixing of old key + new DH, zeroization of old key
|
||||||
|
- `AntiReplayWindow`: 1024-packet sliding window bitmap with u16 wrapping support
|
||||||
|
- Nonce module: 12-byte nonce layout (4-byte session_id + 4-byte seq BE + 1-byte direction + 3-byte padding)
|
||||||
|
|
||||||
|
### wzp-transport
|
||||||
|
- `QuinnTransport`: implements `MediaTransport` trait over quinn QUIC connection
|
||||||
|
- DATAGRAM frames for unreliable media, bidirectional streams for reliable signaling
|
||||||
|
- Length-prefixed JSON framing (4-byte BE length + serde_json payload) for signaling
|
||||||
|
- VoIP-tuned QUIC configuration (30s idle timeout, 5s keepalive, conservative flow control, 300ms initial RTT)
|
||||||
|
- `PathMonitor`: EWMA-smoothed loss, RTT, jitter, bandwidth estimation
|
||||||
|
- Connection lifecycle: `create_endpoint()`, `connect()`, `accept()`
|
||||||
|
- Self-signed certificate generation for testing
|
||||||
|
|
||||||
|
**Tests**: 55+ tests across all 4 crates (codec roundtrip, FEC recovery at 30/50/70% loss, crypto encrypt/decrypt, handshake, anti-replay, transport serialization, path monitoring)
|
||||||
|
|
||||||
|
## Phase 3: Integration (Relay + Client)
|
||||||
|
|
||||||
|
**Scope**: Wire all layers together into working relay and client binaries.
|
||||||
|
|
||||||
|
### wzp-relay
|
||||||
|
- Room mode (SFU): `RoomManager` with named rooms, auto-create/auto-delete, per-participant forwarding
|
||||||
|
- Forward mode: two-pipeline architecture (upstream/downstream) with FEC re-encode and jitter buffering
|
||||||
|
- `RelayPipeline`: ingest -> FEC decode -> jitter buffer -> pop -> FEC re-encode -> send
|
||||||
|
- `SessionManager`: tracks active sessions, max session limit, idle expiration
|
||||||
|
- Relay-side handshake: `accept_handshake()` with signature verification and profile negotiation
|
||||||
|
- `RelayConfig`: configurable listen address, remote relay, max sessions, jitter parameters
|
||||||
|
- Periodic stats logging (upstream/downstream packet counts)
|
||||||
|
|
||||||
|
### wzp-client
|
||||||
|
- `CallEncoder`: PCM -> audio encode -> FEC block management -> source + repair MediaPackets
|
||||||
|
- `CallDecoder`: MediaPacket -> FEC decode -> jitter buffer -> audio decode -> PCM
|
||||||
|
- Client-side handshake: `perform_handshake()` with ephemeral key exchange and signature
|
||||||
|
- CLI modes: silence test, tone generation (440 Hz), file send, file record, echo test, live audio
|
||||||
|
- `AudioCapture`/`AudioPlayback` via cpal (behind `audio` feature flag), supporting both i16 and f32 sample formats
|
||||||
|
- Automated echo test with windowed analysis (loss, SNR, correlation, degradation detection)
|
||||||
|
- Benchmark suite: codec roundtrip (1000 frames), FEC recovery (100 blocks), crypto throughput (30000 packets), full pipeline (50 frames)
|
||||||
|
|
||||||
|
**Tests**: 25+ tests for pipeline creation, packet generation, FEC repair generation, session management
|
||||||
|
|
||||||
|
## Phase 4: Web Bridge, Rooms, PTT, TLS
|
||||||
|
|
||||||
|
**Scope**: Browser support and multi-party calling.
|
||||||
|
|
||||||
|
### wzp-web
|
||||||
|
- Axum-based HTTP/WebSocket server
|
||||||
|
- Browser audio capture via AudioWorklet (primary) with ScriptProcessorNode fallback
|
||||||
|
- Browser audio playback via AudioWorklet with scheduled BufferSource fallback
|
||||||
|
- Room-based routing: `/ws/<room-name>` WebSocket endpoint
|
||||||
|
- Room name passed as QUIC SNI to the relay
|
||||||
|
- Push-to-talk (PTT) support: button, mouse hold, spacebar
|
||||||
|
- Audio level meter in the UI
|
||||||
|
- TLS support via `--tls` flag with self-signed certificate generation
|
||||||
|
- Auto-reconnection on WebSocket disconnect
|
||||||
|
- Static file serving for the web UI
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
### What Works
|
||||||
|
|
||||||
|
- Full encode/decode pipeline: PCM -> Opus/Codec2 -> FEC -> MediaPacket -> FEC decode -> audio decode -> PCM
|
||||||
|
- Adaptive codec switching between Opus and Codec2 (including resampling)
|
||||||
|
- RaptorQ FEC recovery at various loss rates (tested up to 50% loss)
|
||||||
|
- ChaCha20-Poly1305 encryption with deterministic nonces
|
||||||
|
- X25519 key exchange with Ed25519 identity signatures
|
||||||
|
- QUIC transport with DATAGRAM frames for media and reliable streams for signaling
|
||||||
|
- Single relay echo mode (connectivity test)
|
||||||
|
- Multi-party room calls (SFU)
|
||||||
|
- Two-relay forwarding chain
|
||||||
|
- Web browser audio via WebSocket bridge
|
||||||
|
- File-based send/record for testing
|
||||||
|
- Live microphone/speaker mode (with `audio` feature)
|
||||||
|
- Push-to-talk in the web UI
|
||||||
|
- Automated echo quality test with windowed analysis
|
||||||
|
- Performance benchmarks
|
||||||
|
- Cross-compilation CI for amd64, arm64, armv7
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
|
||||||
|
- **Jitter buffer drift**: During long echo tests, the jitter buffer depth can drift because there is no adaptive depth adjustment based on observed jitter. The buffer uses sequence-number ordering only, without timestamp-based playout scheduling.
|
||||||
|
|
||||||
|
- **Web audio drift**: The browser AudioWorklet playback buffer caps at 200ms, but clock drift between the WebSocket message arrival rate and the AudioContext output rate can cause occasional underruns or accumulation. The cap prevents unbounded growth but may cause glitches.
|
||||||
|
|
||||||
|
- **No adaptive loop integration**: The `PathMonitor` feeds and `AdaptiveQualityController` are implemented but not wired together in the client's main loop. Quality reports are consumed when present in packets, but the client does not currently generate periodic quality reports from transport metrics.
|
||||||
|
|
||||||
|
- **Relay FEC pass-through**: In room mode, the relay forwards packets opaquely without FEC decode/re-encode. This means FEC protection is end-to-end only, not per-hop. In forward mode, the relay pipeline does perform FEC decode/re-encode.
|
||||||
|
|
||||||
|
- **No certificate verification**: The QUIC client config uses `SkipServerVerification` (accepts any certificate). This is intentional for testing but must be addressed for production deployments.
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
119 tests across 7 crates (wzp-web has no Rust tests):
|
||||||
|
|
||||||
|
| Crate | Test Files | Test Count |
|
||||||
|
|-------|-----------|------------|
|
||||||
|
| wzp-proto | 5 | 27 |
|
||||||
|
| wzp-codec | 3 | 24 |
|
||||||
|
| wzp-fec | 5 | 21 |
|
||||||
|
| wzp-crypto | 5 | 21 |
|
||||||
|
| wzp-transport | 3 | 12 |
|
||||||
|
| wzp-relay | 4 | 10 |
|
||||||
|
| wzp-client | 3 | 8 |
|
||||||
|
| **Total** | **28** | **119** |
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- Wire format roundtrip (header, quality report, full packet)
|
||||||
|
- Codec encode/decode for all 5 codec IDs
|
||||||
|
- Adaptive codec switching (Opus <-> Codec2)
|
||||||
|
- FEC recovery at 0%, 30%, 50% loss
|
||||||
|
- Concurrent FEC block decoding
|
||||||
|
- Full key exchange handshake (Alice/Bob derive same session key)
|
||||||
|
- Encrypt/decrypt roundtrip, wrong-key rejection, wrong-AAD rejection
|
||||||
|
- Anti-replay window: sequential, out-of-order, duplicate, wrapping
|
||||||
|
- Rekeying: interval trigger, key derivation, old key zeroization
|
||||||
|
- QUIC datagram serialization roundtrip
|
||||||
|
- Path quality EWMA smoothing
|
||||||
|
- Jitter buffer: ordering, reordering, missing packets, min depth, duplicates
|
||||||
|
- Session state machine: happy path, invalid transitions, connection loss
|
||||||
|
- Pipeline packet generation and FEC repair
|
||||||
|
- Benchmark correctness (codec, FEC, crypto, pipeline)
|
||||||
|
|
||||||
|
## Performance Benchmarks
|
||||||
|
|
||||||
|
Run with `wzp-bench --all`. Representative results (Apple M-series, single core):
|
||||||
|
|
||||||
|
### Codec Roundtrip (Opus 24kbps)
|
||||||
|
- 1000 frames of 440 Hz sine wave (20ms each, 48 kHz mono)
|
||||||
|
- Encode: ~20-40 us/frame average
|
||||||
|
- Decode: ~10-20 us/frame average
|
||||||
|
- Throughput: >10,000 frames/sec (200x real-time)
|
||||||
|
- Compression ratio: ~30x (960 i16 samples = 1920 bytes -> ~60 bytes encoded)
|
||||||
|
|
||||||
|
### FEC Recovery
|
||||||
|
- 100 blocks of 5 frames each
|
||||||
|
- At 20% loss: ~100% recovery rate
|
||||||
|
- At 30% loss with scaled FEC ratio: >95% recovery rate
|
||||||
|
|
||||||
|
### Crypto (ChaCha20-Poly1305)
|
||||||
|
- 30,000 packets (60/120/256 byte payloads)
|
||||||
|
- Throughput: >500,000 packets/sec
|
||||||
|
- Bandwidth: >50 MB/sec
|
||||||
|
- Average latency: <2 us per encrypt+decrypt cycle
|
||||||
|
|
||||||
|
### Full Pipeline (E2E)
|
||||||
|
- 50 frames through CallEncoder -> CallDecoder
|
||||||
|
- Average E2E latency: ~100-200 us/frame (codec + FEC, no network)
|
||||||
|
- Wire overhead ratio: ~0.05-0.10x of raw PCM (high compression from Opus)
|
||||||
|
|
||||||
|
## Deployment Status
|
||||||
|
|
||||||
|
- **Local testing**: All modes tested on localhost (single relay, room mode, forward mode, web bridge)
|
||||||
|
- **Hetzner VPS**: Build script (`scripts/build-linux.sh`) tested for provisioning, building, and downloading Linux binaries
|
||||||
|
- **CI**: Gitea workflow defined for amd64/arm64/armv7 builds
|
||||||
|
- **Production**: Not yet deployed to production networks
|
||||||
269
docs/USAGE.md
Normal file
269
docs/USAGE.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# WarzonePhone Usage Guide
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Rust** 1.85+ (2024 edition)
|
||||||
|
- **System libraries** (Linux): `cmake`, `pkg-config`, `libasound2-dev` (for audio feature)
|
||||||
|
- **System libraries** (macOS): Xcode command line tools (CoreAudio is included)
|
||||||
|
|
||||||
|
## Building from Source
|
||||||
|
|
||||||
|
### All Binaries (Headless)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client with Live Audio Support
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release --bin wzp-client --features audio
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run All Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --workspace --lib
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building for Linux (Remote Build Script)
|
||||||
|
|
||||||
|
The project includes `scripts/build-linux.sh` which provisions a temporary Hetzner Cloud VPS, builds all binaries, and downloads them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Requires: hcloud CLI authenticated, SSH key "wz" registered
|
||||||
|
./scripts/build-linux.sh
|
||||||
|
# Outputs to: target/linux-x86_64/
|
||||||
|
```
|
||||||
|
|
||||||
|
The build script produces:
|
||||||
|
- `wzp-relay` -- relay daemon
|
||||||
|
- `wzp-client` -- headless client
|
||||||
|
- `wzp-client-audio` -- client with mic/speaker support (needs libasound2)
|
||||||
|
- `wzp-web` -- web bridge server
|
||||||
|
- `wzp-bench` -- performance benchmarks
|
||||||
|
|
||||||
|
### CI Build
|
||||||
|
|
||||||
|
The `.gitea/workflows/build.yml` workflow builds release binaries for:
|
||||||
|
- Linux amd64
|
||||||
|
- Linux arm64 (cross-compiled)
|
||||||
|
- Linux armv7 (cross-compiled)
|
||||||
|
|
||||||
|
Triggered on version tags (`v*`) or manual dispatch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Binaries and CLI Flags
|
||||||
|
|
||||||
|
### wzp-relay
|
||||||
|
|
||||||
|
The relay daemon that forwards media between clients.
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: wzp-relay [--listen <addr>] [--remote <addr>]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--listen <addr> Listen address (default: 0.0.0.0:4433)
|
||||||
|
--remote <addr> Remote relay for forwarding (disables room mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Room mode** (default): Clients join rooms by name. Packets are forwarded to all other participants in the same room (SFU model). Room name comes from QUIC SNI or defaults to "default".
|
||||||
|
|
||||||
|
**Forward mode** (`--remote`): All traffic is forwarded to a remote relay. Used for chaining relays across lossy/censored links.
|
||||||
|
|
||||||
|
### wzp-client
|
||||||
|
|
||||||
|
The CLI test client for sending and receiving audio.
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: wzp-client [options] [relay-addr]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--live Live mic/speaker mode (requires --features audio)
|
||||||
|
--send-tone <secs> Send a 440Hz test tone for N seconds
|
||||||
|
--send-file <file> Send a raw PCM file (48kHz mono s16le)
|
||||||
|
--record <file.raw> Record received audio to raw PCM file
|
||||||
|
--echo-test <secs> Run automated echo quality test
|
||||||
|
```
|
||||||
|
|
||||||
|
Default relay address: `127.0.0.1:4433`
|
||||||
|
|
||||||
|
### wzp-bench
|
||||||
|
|
||||||
|
Performance benchmark tool.
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: wzp-bench [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--codec Run codec roundtrip benchmark (Opus 24kbps, 1000 frames)
|
||||||
|
--fec Run FEC recovery benchmark (100 blocks)
|
||||||
|
--crypto Run encryption benchmark (30000 packets)
|
||||||
|
--pipeline Run full pipeline benchmark (50 frames E2E)
|
||||||
|
--all Run all benchmarks (default if no flag given)
|
||||||
|
--loss <N> FEC loss percentage for --fec (default: 20)
|
||||||
|
```
|
||||||
|
|
||||||
|
### wzp-web
|
||||||
|
|
||||||
|
Web bridge server that connects browser audio via WebSocket to the relay.
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: wzp-web [--port 8080] [--relay 127.0.0.1:4433] [--tls]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--port <port> HTTP/WebSocket port (default: 8080)
|
||||||
|
--relay <addr> WZP relay address (default: 127.0.0.1:4433)
|
||||||
|
--tls Enable HTTPS (self-signed cert, required for mic on Android/remote)
|
||||||
|
```
|
||||||
|
|
||||||
|
Room URLs: `http://host:port/<room-name>` or `https://host:port/<room-name>` with `--tls`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Examples
|
||||||
|
|
||||||
|
### 1. Single Relay Echo Test
|
||||||
|
|
||||||
|
Start a relay, send a tone, and record the echo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1: Start relay
|
||||||
|
wzp-relay --listen 0.0.0.0:4433
|
||||||
|
|
||||||
|
# Terminal 2: Send 10s of 440Hz tone and record the response
|
||||||
|
wzp-client --send-tone 10 --record echo.raw 127.0.0.1:4433
|
||||||
|
```
|
||||||
|
|
||||||
|
Play the recording:
|
||||||
|
```bash
|
||||||
|
ffplay -f s16le -ar 48000 -ac 1 echo.raw
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Two-Party Call Through Relay
|
||||||
|
|
||||||
|
Two clients connected to the same relay default room:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1: Relay
|
||||||
|
wzp-relay
|
||||||
|
|
||||||
|
# Terminal 2: Client A — send tone
|
||||||
|
wzp-client --send-tone 30 127.0.0.1:4433
|
||||||
|
|
||||||
|
# Terminal 3: Client B — record
|
||||||
|
wzp-client --record call.raw 127.0.0.1:4433
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Multi-Party Room Call
|
||||||
|
|
||||||
|
Multiple clients join the same named room. The relay QUIC SNI determines the room. With the web bridge, room names come from the URL path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Relay
|
||||||
|
wzp-relay
|
||||||
|
|
||||||
|
# Web bridge
|
||||||
|
wzp-web --port 8080 --relay 127.0.0.1:4433
|
||||||
|
|
||||||
|
# Browser clients open:
|
||||||
|
# http://localhost:8080/my-room
|
||||||
|
# All clients on /my-room hear each other.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Two-Relay Chain (Lossy Link)
|
||||||
|
|
||||||
|
Chain two relays for crossing a censored or lossy network boundary:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Destination-side relay (receives from the forward relay)
|
||||||
|
wzp-relay --listen 0.0.0.0:4433
|
||||||
|
|
||||||
|
# Client-side relay (forwards to the destination relay)
|
||||||
|
wzp-relay --listen 0.0.0.0:5433 --remote <dest-relay-ip>:4433
|
||||||
|
|
||||||
|
# Client connects to the client-side relay
|
||||||
|
wzp-client --send-tone 10 127.0.0.1:5433
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Web Browser Call with TLS
|
||||||
|
|
||||||
|
TLS is required for microphone access on non-localhost origins (Android, remote browsers):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Relay
|
||||||
|
wzp-relay
|
||||||
|
|
||||||
|
# Web bridge with TLS (self-signed certificate)
|
||||||
|
wzp-web --port 8443 --relay 127.0.0.1:4433 --tls
|
||||||
|
|
||||||
|
# Open in browser (accept self-signed cert warning):
|
||||||
|
# https://your-server:8443/room-name
|
||||||
|
```
|
||||||
|
|
||||||
|
The web UI supports:
|
||||||
|
- Open mic (default) and push-to-talk modes
|
||||||
|
- PTT via on-screen button, mouse hold, or spacebar
|
||||||
|
- Audio level meter
|
||||||
|
- Auto-reconnection on disconnect
|
||||||
|
|
||||||
|
### 6. Automated Echo Quality Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wzp-relay &
|
||||||
|
wzp-client --echo-test 30 127.0.0.1:4433
|
||||||
|
```
|
||||||
|
|
||||||
|
Produces a windowed analysis report showing loss percentage, SNR, correlation, and detects quality degradation trends over time.
|
||||||
|
|
||||||
|
### 7. Live Audio Call (requires `--features audio`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wzp-relay &
|
||||||
|
|
||||||
|
# Terminal 2
|
||||||
|
wzp-client --live 127.0.0.1:4433
|
||||||
|
|
||||||
|
# Terminal 3
|
||||||
|
wzp-client --live 127.0.0.1:4433
|
||||||
|
```
|
||||||
|
|
||||||
|
Both clients capture from the default microphone and play received audio through the default speaker. Press Ctrl+C to stop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audio File Format
|
||||||
|
|
||||||
|
All raw PCM files use:
|
||||||
|
- Sample rate: **48 kHz**
|
||||||
|
- Channels: **1** (mono)
|
||||||
|
- Sample format: **signed 16-bit little-endian** (s16le)
|
||||||
|
|
||||||
|
### ffmpeg Conversion Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# WAV to raw PCM
|
||||||
|
ffmpeg -i input.wav -f s16le -ar 48000 -ac 1 output.raw
|
||||||
|
|
||||||
|
# MP3 to raw PCM
|
||||||
|
ffmpeg -i input.mp3 -f s16le -ar 48000 -ac 1 output.raw
|
||||||
|
|
||||||
|
# Raw PCM to WAV
|
||||||
|
ffmpeg -f s16le -ar 48000 -ac 1 -i input.raw output.wav
|
||||||
|
|
||||||
|
# Play raw PCM directly
|
||||||
|
ffplay -f s16le -ar 48000 -ac 1 file.raw
|
||||||
|
# or with the newer channel layout syntax:
|
||||||
|
ffplay -f s16le -ar 48000 -ch_layout mono file.raw
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sending an Audio File
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Convert your audio to raw PCM first
|
||||||
|
ffmpeg -i song.mp3 -f s16le -ar 48000 -ac 1 song.raw
|
||||||
|
|
||||||
|
# Send through relay
|
||||||
|
wzp-client --send-file song.raw 127.0.0.1:4433
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user