feat: WarzonePhone lossy VoIP protocol — Phase 1 complete
Rust workspace with 7 crates implementing a custom VoIP protocol designed for extremely lossy connections (5-70% loss, 100-500kbps, 300-800ms RTT). 89 tests passing across all crates. Crates: - wzp-proto: Wire format, traits, adaptive quality controller, jitter buffer, session FSM - wzp-codec: Opus encoder/decoder (audiopus), Codec2 stubs, adaptive switching, resampling - wzp-fec: RaptorQ fountain codes, interleaving, block management (proven 30-70% loss recovery) - wzp-crypto: X25519+ChaCha20-Poly1305, Warzone identity compatible, anti-replay, rekeying - wzp-transport: QUIC via quinn with DATAGRAM frames, path monitoring, signaling streams - wzp-relay: Integration stub (Phase 2) - wzp-client: Integration stub (Phase 2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
113
crates/wzp-proto/src/codec_id.rs
Normal file
113
crates/wzp-proto/src/codec_id.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Identifies the audio codec and bitrate configuration.
|
||||
///
|
||||
/// Encoded as 4 bits in the media packet header.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum CodecId {
|
||||
/// Opus at 24kbps (good conditions)
|
||||
Opus24k = 0,
|
||||
/// Opus at 16kbps (moderate conditions)
|
||||
Opus16k = 1,
|
||||
/// Opus at 6kbps (degraded conditions)
|
||||
Opus6k = 2,
|
||||
/// Codec2 at 3200bps (poor conditions)
|
||||
Codec2_3200 = 3,
|
||||
/// Codec2 at 1200bps (catastrophic conditions)
|
||||
Codec2_1200 = 4,
|
||||
}
|
||||
|
||||
impl CodecId {
|
||||
/// Nominal bitrate in bits per second.
|
||||
pub const fn bitrate_bps(self) -> u32 {
|
||||
match self {
|
||||
Self::Opus24k => 24_000,
|
||||
Self::Opus16k => 16_000,
|
||||
Self::Opus6k => 6_000,
|
||||
Self::Codec2_3200 => 3_200,
|
||||
Self::Codec2_1200 => 1_200,
|
||||
}
|
||||
}
|
||||
|
||||
/// Preferred frame duration in milliseconds.
|
||||
pub const fn frame_duration_ms(self) -> u8 {
|
||||
match self {
|
||||
Self::Opus24k => 20,
|
||||
Self::Opus16k => 20,
|
||||
Self::Opus6k => 40,
|
||||
Self::Codec2_3200 => 20,
|
||||
Self::Codec2_1200 => 40,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample rate expected by this codec.
|
||||
pub const fn sample_rate_hz(self) -> u32 {
|
||||
match self {
|
||||
Self::Opus24k | Self::Opus16k | Self::Opus6k => 48_000,
|
||||
Self::Codec2_3200 | Self::Codec2_1200 => 8_000,
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to decode from the 4-bit wire representation.
|
||||
pub const fn from_wire(val: u8) -> Option<Self> {
|
||||
match val {
|
||||
0 => Some(Self::Opus24k),
|
||||
1 => Some(Self::Opus16k),
|
||||
2 => Some(Self::Opus6k),
|
||||
3 => Some(Self::Codec2_3200),
|
||||
4 => Some(Self::Codec2_1200),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode to the 4-bit wire representation.
|
||||
pub const fn to_wire(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// Describes the complete quality configuration for a call session.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct QualityProfile {
|
||||
/// Active codec.
|
||||
pub codec: CodecId,
|
||||
/// FEC repair ratio (0.0 = no FEC, 1.0 = 100% overhead, 2.0 = 200% overhead).
|
||||
pub fec_ratio: f32,
|
||||
/// Audio frame duration in ms (20 or 40).
|
||||
pub frame_duration_ms: u8,
|
||||
/// Number of source frames per FEC block.
|
||||
pub frames_per_block: u8,
|
||||
}
|
||||
|
||||
impl QualityProfile {
|
||||
/// Good conditions: Opus 24kbps, light FEC.
|
||||
pub const GOOD: Self = Self {
|
||||
codec: CodecId::Opus24k,
|
||||
fec_ratio: 0.2,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
};
|
||||
|
||||
/// Degraded conditions: Opus 6kbps, moderate FEC.
|
||||
pub const DEGRADED: Self = Self {
|
||||
codec: CodecId::Opus6k,
|
||||
fec_ratio: 0.5,
|
||||
frame_duration_ms: 40,
|
||||
frames_per_block: 10,
|
||||
};
|
||||
|
||||
/// Catastrophic conditions: Codec2 1.2kbps, heavy FEC.
|
||||
pub const CATASTROPHIC: Self = Self {
|
||||
codec: CodecId::Codec2_1200,
|
||||
fec_ratio: 1.0,
|
||||
frame_duration_ms: 40,
|
||||
frames_per_block: 8,
|
||||
};
|
||||
|
||||
/// Estimated total bandwidth in kbps including FEC overhead.
|
||||
pub fn total_bitrate_kbps(&self) -> f32 {
|
||||
let base = self.codec.bitrate_bps() as f32 / 1000.0;
|
||||
base * (1.0 + self.fec_ratio)
|
||||
}
|
||||
}
|
||||
67
crates/wzp-proto/src/error.rs
Normal file
67
crates/wzp-proto/src/error.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors from audio codec operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CodecError {
|
||||
#[error("encode failed: {0}")]
|
||||
EncodeFailed(String),
|
||||
#[error("decode failed: {0}")]
|
||||
DecodeFailed(String),
|
||||
#[error("unsupported profile transition from {from:?} to {to:?}")]
|
||||
UnsupportedTransition {
|
||||
from: crate::CodecId,
|
||||
to: crate::CodecId,
|
||||
},
|
||||
}
|
||||
|
||||
/// Errors from FEC operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum FecError {
|
||||
#[error("source block is full (max {max} symbols)")]
|
||||
BlockFull { max: usize },
|
||||
#[error("decode impossible: need {needed} symbols, have {have}")]
|
||||
InsufficientSymbols { needed: usize, have: usize },
|
||||
#[error("invalid block id {0}")]
|
||||
InvalidBlock(u8),
|
||||
#[error("internal FEC error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Errors from cryptographic operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CryptoError {
|
||||
#[error("decryption failed (bad key or tampered data)")]
|
||||
DecryptionFailed,
|
||||
#[error("invalid public key")]
|
||||
InvalidPublicKey,
|
||||
#[error("rekey failed: {0}")]
|
||||
RekeyFailed(String),
|
||||
#[error("anti-replay: duplicate or old packet (seq={seq})")]
|
||||
ReplayDetected { seq: u16 },
|
||||
#[error("internal crypto error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Errors from transport operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TransportError {
|
||||
#[error("connection lost")]
|
||||
ConnectionLost,
|
||||
#[error("datagram too large: {size} bytes (max {max})")]
|
||||
DatagramTooLarge { size: usize, max: usize },
|
||||
#[error("connection timeout after {ms}ms")]
|
||||
Timeout { ms: u64 },
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("internal transport error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Errors from obfuscation layer.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ObfuscationError {
|
||||
#[error("obfuscation failed: {0}")]
|
||||
Failed(String),
|
||||
#[error("deobfuscation failed: invalid framing")]
|
||||
InvalidFraming,
|
||||
}
|
||||
307
crates/wzp-proto/src/jitter.rs
Normal file
307
crates/wzp-proto/src/jitter.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::packet::MediaPacket;
|
||||
|
||||
/// Adaptive jitter buffer that reorders packets by sequence number.
|
||||
///
|
||||
/// Designed for the lossy relay link with up to 5 seconds of buffering depth.
|
||||
/// Manages packet reordering, gap detection, and signals when PLC is needed.
|
||||
pub struct JitterBuffer {
|
||||
/// Packets waiting to be consumed, ordered by sequence number.
|
||||
buffer: BTreeMap<u16, MediaPacket>,
|
||||
/// Next sequence number expected for playout.
|
||||
next_playout_seq: u16,
|
||||
/// Maximum buffer depth in number of packets.
|
||||
max_depth: usize,
|
||||
/// Target buffer depth (adaptive, based on jitter).
|
||||
target_depth: usize,
|
||||
/// Minimum buffer depth.
|
||||
min_depth: usize,
|
||||
/// Whether we have received the first packet and initialized.
|
||||
initialized: bool,
|
||||
/// Statistics.
|
||||
stats: JitterStats,
|
||||
}
|
||||
|
||||
/// Jitter buffer statistics.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
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,
|
||||
}
|
||||
|
||||
/// Result of attempting to get the next packet for playout.
|
||||
#[derive(Debug)]
|
||||
pub enum PlayoutResult {
|
||||
/// A packet is available for playout.
|
||||
Packet(MediaPacket),
|
||||
/// The expected packet is missing — decoder should generate PLC.
|
||||
Missing { seq: u16 },
|
||||
/// Buffer is empty or not yet filled to target depth.
|
||||
NotReady,
|
||||
}
|
||||
|
||||
impl JitterBuffer {
|
||||
/// Create a new jitter buffer.
|
||||
///
|
||||
/// - `target_depth`: initial target buffer depth in packets
|
||||
/// - `max_depth`: absolute maximum (e.g., 250 packets = 5s at 20ms/frame)
|
||||
/// - `min_depth`: minimum depth before playout begins
|
||||
pub fn new(target_depth: usize, max_depth: usize, min_depth: usize) -> Self {
|
||||
Self {
|
||||
buffer: BTreeMap::new(),
|
||||
next_playout_seq: 0,
|
||||
max_depth,
|
||||
target_depth,
|
||||
min_depth,
|
||||
initialized: false,
|
||||
stats: JitterStats::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with default settings for 5-second max buffer at 20ms frames.
|
||||
pub fn default_5s() -> Self {
|
||||
Self::new(
|
||||
50, // target: 1 second
|
||||
250, // max: 5 seconds
|
||||
25, // min: 0.5 seconds before starting playout
|
||||
)
|
||||
}
|
||||
|
||||
/// Push a received packet into the buffer.
|
||||
pub fn push(&mut self, packet: MediaPacket) {
|
||||
let seq = packet.header.seq;
|
||||
self.stats.packets_received += 1;
|
||||
|
||||
if !self.initialized {
|
||||
self.next_playout_seq = seq;
|
||||
self.initialized = true;
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
if self.buffer.contains_key(&seq) {
|
||||
self.stats.packets_duplicate += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if packet is too old (already played out)
|
||||
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
||||
self.stats.packets_late += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// If we haven't started playout yet, adjust next_playout_seq to earliest known
|
||||
if self.stats.packets_played == 0 && seq_before(seq, self.next_playout_seq) {
|
||||
self.next_playout_seq = seq;
|
||||
}
|
||||
|
||||
self.buffer.insert(seq, packet);
|
||||
|
||||
// Evict oldest if over max depth
|
||||
while self.buffer.len() > self.max_depth {
|
||||
if let Some((&oldest_seq, _)) = self.buffer.first_key_value() {
|
||||
self.buffer.remove(&oldest_seq);
|
||||
// Advance playout seq past evicted packet
|
||||
if seq_before(self.next_playout_seq, oldest_seq.wrapping_add(1)) {
|
||||
self.next_playout_seq = oldest_seq.wrapping_add(1);
|
||||
self.stats.packets_lost += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.stats.current_depth = self.buffer.len();
|
||||
}
|
||||
|
||||
/// Get the next packet for playout.
|
||||
///
|
||||
/// Call this at the codec's frame rate (e.g., every 20ms).
|
||||
pub fn pop(&mut self) -> PlayoutResult {
|
||||
if !self.initialized {
|
||||
return PlayoutResult::NotReady;
|
||||
}
|
||||
|
||||
// Wait until we have enough buffered
|
||||
if self.buffer.len() < self.min_depth {
|
||||
// But only wait if we haven't started playing yet
|
||||
if self.stats.packets_played == 0 {
|
||||
return PlayoutResult::NotReady;
|
||||
}
|
||||
}
|
||||
|
||||
let seq = self.next_playout_seq;
|
||||
self.next_playout_seq = seq.wrapping_add(1);
|
||||
|
||||
if let Some(packet) = self.buffer.remove(&seq) {
|
||||
self.stats.packets_played += 1;
|
||||
self.stats.current_depth = self.buffer.len();
|
||||
PlayoutResult::Packet(packet)
|
||||
} else {
|
||||
self.stats.packets_lost += 1;
|
||||
self.stats.current_depth = self.buffer.len();
|
||||
PlayoutResult::Missing { seq }
|
||||
}
|
||||
}
|
||||
|
||||
/// Current buffer depth (number of packets stored).
|
||||
pub fn depth(&self) -> usize {
|
||||
self.buffer.len()
|
||||
}
|
||||
|
||||
/// Get current statistics.
|
||||
pub fn stats(&self) -> &JitterStats {
|
||||
&self.stats
|
||||
}
|
||||
|
||||
/// Reset the buffer (e.g., on call restart).
|
||||
pub fn reset(&mut self) {
|
||||
self.buffer.clear();
|
||||
self.initialized = false;
|
||||
self.stats = JitterStats::default();
|
||||
}
|
||||
|
||||
/// Adjust target depth based on observed jitter.
|
||||
pub fn set_target_depth(&mut self, depth: usize) {
|
||||
self.target_depth = depth.min(self.max_depth);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sequence number comparison with wrapping (RFC 1982 serial number arithmetic).
|
||||
/// Returns true if `a` comes before `b` in sequence space.
|
||||
fn seq_before(a: u16, b: u16) -> bool {
|
||||
let diff = b.wrapping_sub(a);
|
||||
diff > 0 && diff < 0x8000
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::packet::{MediaHeader, MediaPacket};
|
||||
use bytes::Bytes;
|
||||
use crate::CodecId;
|
||||
|
||||
fn make_packet(seq: u16) -> MediaPacket {
|
||||
MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
codec_id: CodecId::Opus24k,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: 0,
|
||||
seq,
|
||||
timestamp: seq as u32 * 20,
|
||||
fec_block: 0,
|
||||
fec_symbol: 0,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(vec![0u8; 60]),
|
||||
quality_report: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_ordered_playout() {
|
||||
let mut jb = JitterBuffer::new(3, 100, 2);
|
||||
|
||||
// Push 3 packets in order
|
||||
jb.push(make_packet(0));
|
||||
jb.push(make_packet(1));
|
||||
jb.push(make_packet(2));
|
||||
|
||||
// Should get them in order
|
||||
match jb.pop() {
|
||||
PlayoutResult::Packet(p) => assert_eq!(p.header.seq, 0),
|
||||
other => panic!("expected packet, got {:?}", other),
|
||||
}
|
||||
match jb.pop() {
|
||||
PlayoutResult::Packet(p) => assert_eq!(p.header.seq, 1),
|
||||
other => panic!("expected packet, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorders_out_of_order_packets() {
|
||||
let mut jb = JitterBuffer::new(3, 100, 2);
|
||||
|
||||
jb.push(make_packet(2));
|
||||
jb.push(make_packet(0));
|
||||
jb.push(make_packet(1));
|
||||
|
||||
match jb.pop() {
|
||||
PlayoutResult::Packet(p) => assert_eq!(p.header.seq, 0),
|
||||
other => panic!("expected packet 0, got {:?}", other),
|
||||
}
|
||||
match jb.pop() {
|
||||
PlayoutResult::Packet(p) => assert_eq!(p.header.seq, 1),
|
||||
other => panic!("expected packet 1, got {:?}", other),
|
||||
}
|
||||
match jb.pop() {
|
||||
PlayoutResult::Packet(p) => assert_eq!(p.header.seq, 2),
|
||||
other => panic!("expected packet 2, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_missing_packets() {
|
||||
let mut jb = JitterBuffer::new(2, 100, 1);
|
||||
|
||||
// Push packet 0 and 2 (skip 1)
|
||||
jb.push(make_packet(0));
|
||||
jb.push(make_packet(2));
|
||||
|
||||
match jb.pop() {
|
||||
PlayoutResult::Packet(p) => assert_eq!(p.header.seq, 0),
|
||||
other => panic!("expected packet 0, got {:?}", other),
|
||||
}
|
||||
match jb.pop() {
|
||||
PlayoutResult::Missing { seq } => assert_eq!(seq, 1),
|
||||
other => panic!("expected missing 1, got {:?}", other),
|
||||
}
|
||||
match jb.pop() {
|
||||
PlayoutResult::Packet(p) => assert_eq!(p.header.seq, 2),
|
||||
other => panic!("expected packet 2, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_duplicates() {
|
||||
let mut jb = JitterBuffer::new(2, 100, 1);
|
||||
jb.push(make_packet(0));
|
||||
jb.push(make_packet(0)); // duplicate
|
||||
assert_eq!(jb.stats().packets_duplicate, 1);
|
||||
assert_eq!(jb.depth(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seq_before_wrapping() {
|
||||
assert!(seq_before(0, 1));
|
||||
assert!(seq_before(65534, 65535));
|
||||
assert!(seq_before(65535, 0)); // wrap
|
||||
assert!(!seq_before(1, 0));
|
||||
assert!(!seq_before(5, 5)); // equal
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_ready_until_min_depth() {
|
||||
let mut jb = JitterBuffer::new(5, 100, 3);
|
||||
jb.push(make_packet(0));
|
||||
jb.push(make_packet(1));
|
||||
|
||||
// Only 2 packets, min_depth is 3
|
||||
match jb.pop() {
|
||||
PlayoutResult::NotReady => {}
|
||||
other => panic!("expected NotReady, got {:?}", other),
|
||||
}
|
||||
|
||||
jb.push(make_packet(2));
|
||||
// Now we have 3, should be ready
|
||||
match jb.pop() {
|
||||
PlayoutResult::Packet(p) => assert_eq!(p.header.seq, 0),
|
||||
other => panic!("expected packet 0, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
29
crates/wzp-proto/src/lib.rs
Normal file
29
crates/wzp-proto/src/lib.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
//! WarzonePhone Protocol — shared types, traits, and core logic.
|
||||
//!
|
||||
//! This crate defines the contracts between all other wzp-* crates.
|
||||
//! It contains:
|
||||
//! - Wire format types (MediaHeader, MediaPacket, SignalMessage)
|
||||
//! - Codec, FEC, crypto, and transport trait definitions
|
||||
//! - Adaptive quality controller
|
||||
//! - Jitter buffer
|
||||
//! - Session state machine
|
||||
//!
|
||||
//! Compatible with the Warzone messenger identity model:
|
||||
//! - Identity = 32-byte seed → HKDF → Ed25519 (signing) + X25519 (encryption)
|
||||
//! - Fingerprint = SHA-256(Ed25519 pub)[:16]
|
||||
|
||||
pub mod codec_id;
|
||||
pub mod error;
|
||||
pub mod jitter;
|
||||
pub mod packet;
|
||||
pub mod quality;
|
||||
pub mod session;
|
||||
pub mod traits;
|
||||
|
||||
// Re-export key types at crate root for convenience.
|
||||
pub use codec_id::{CodecId, QualityProfile};
|
||||
pub use error::*;
|
||||
pub use packet::{HangupReason, MediaHeader, MediaPacket, QualityReport, SignalMessage};
|
||||
pub use quality::{AdaptiveQualityController, Tier};
|
||||
pub use session::{Session, SessionEvent, SessionState};
|
||||
pub use traits::*;
|
||||
424
crates/wzp-proto/src/packet.rs
Normal file
424
crates/wzp-proto/src/packet.rs
Normal file
@@ -0,0 +1,424 @@
|
||||
use bytes::{Buf, BufMut, Bytes, BytesMut};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::CodecId;
|
||||
|
||||
/// 12-byte media packet header for the lossy link.
|
||||
///
|
||||
/// Wire layout:
|
||||
/// ```text
|
||||
/// 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
|
||||
/// Byte 9: FEC symbol index within block
|
||||
/// Byte 10: Reserved / flags
|
||||
/// Byte 11: CSRC count
|
||||
/// ```
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct MediaHeader {
|
||||
/// Protocol version (0 = v1).
|
||||
pub version: u8,
|
||||
/// true = FEC repair packet, false = source media.
|
||||
pub is_repair: bool,
|
||||
/// Codec identifier.
|
||||
pub codec_id: CodecId,
|
||||
/// Whether a QualityReport trailer is appended.
|
||||
pub has_quality_report: bool,
|
||||
/// FEC ratio as 7-bit value (0-127 maps to 0.0-1.0).
|
||||
pub fec_ratio_encoded: u8,
|
||||
/// Wrapping packet sequence number.
|
||||
pub seq: u16,
|
||||
/// Milliseconds since session start.
|
||||
pub timestamp: u32,
|
||||
/// FEC source block ID (wrapping).
|
||||
pub fec_block: u8,
|
||||
/// Symbol index within the FEC block.
|
||||
pub fec_symbol: u8,
|
||||
/// Reserved flags byte.
|
||||
pub reserved: u8,
|
||||
/// Number of contributing sources (for future mixing).
|
||||
pub csrc_count: u8,
|
||||
}
|
||||
|
||||
impl MediaHeader {
|
||||
/// Header size in bytes on the wire.
|
||||
pub const WIRE_SIZE: usize = 12;
|
||||
|
||||
/// Encode the FEC ratio float (0.0-2.0+) to a 7-bit value (0-127).
|
||||
pub fn encode_fec_ratio(ratio: f32) -> u8 {
|
||||
// Map 0.0-2.0 to 0-127, clamping at 127
|
||||
let scaled = (ratio * 63.5).round() as u8;
|
||||
scaled.min(127)
|
||||
}
|
||||
|
||||
/// Decode the 7-bit FEC ratio value back to a float.
|
||||
pub fn decode_fec_ratio(encoded: u8) -> f32 {
|
||||
(encoded & 0x7F) as f32 / 63.5
|
||||
}
|
||||
|
||||
/// Serialize to a 12-byte buffer.
|
||||
pub fn write_to(&self, buf: &mut impl BufMut) {
|
||||
// Byte 0: V(1) | T(1) | CodecID(4) | Q(1) | FecRatioHi(1)
|
||||
let byte0 = ((self.version & 0x01) << 7)
|
||||
| ((self.is_repair as u8) << 6)
|
||||
| ((self.codec_id.to_wire() & 0x0F) << 2)
|
||||
| ((self.has_quality_report as u8) << 1)
|
||||
| ((self.fec_ratio_encoded >> 6) & 0x01);
|
||||
buf.put_u8(byte0);
|
||||
|
||||
// Byte 1: FecRatioLo(6) | unused(2)
|
||||
let byte1 = (self.fec_ratio_encoded & 0x3F) << 2;
|
||||
buf.put_u8(byte1);
|
||||
|
||||
// Bytes 2-3: sequence number
|
||||
buf.put_u16(self.seq);
|
||||
|
||||
// Bytes 4-7: timestamp
|
||||
buf.put_u32(self.timestamp);
|
||||
|
||||
// Byte 8: FEC block
|
||||
buf.put_u8(self.fec_block);
|
||||
|
||||
// Byte 9: FEC symbol
|
||||
buf.put_u8(self.fec_symbol);
|
||||
|
||||
// Byte 10: reserved
|
||||
buf.put_u8(self.reserved);
|
||||
|
||||
// Byte 11: CSRC count
|
||||
buf.put_u8(self.csrc_count);
|
||||
}
|
||||
|
||||
/// Deserialize from a buffer. Returns None if insufficient data.
|
||||
pub fn read_from(buf: &mut impl Buf) -> Option<Self> {
|
||||
if buf.remaining() < Self::WIRE_SIZE {
|
||||
return None;
|
||||
}
|
||||
|
||||
let byte0 = buf.get_u8();
|
||||
let byte1 = buf.get_u8();
|
||||
|
||||
let version = (byte0 >> 7) & 0x01;
|
||||
let is_repair = ((byte0 >> 6) & 0x01) != 0;
|
||||
let codec_wire = (byte0 >> 2) & 0x0F;
|
||||
let has_quality_report = ((byte0 >> 1) & 0x01) != 0;
|
||||
let fec_ratio_hi = byte0 & 0x01;
|
||||
let fec_ratio_lo = (byte1 >> 2) & 0x3F;
|
||||
let fec_ratio_encoded = (fec_ratio_hi << 6) | fec_ratio_lo;
|
||||
|
||||
let codec_id = CodecId::from_wire(codec_wire)?;
|
||||
let seq = buf.get_u16();
|
||||
let timestamp = buf.get_u32();
|
||||
let fec_block = buf.get_u8();
|
||||
let fec_symbol = buf.get_u8();
|
||||
let reserved = buf.get_u8();
|
||||
let csrc_count = buf.get_u8();
|
||||
|
||||
Some(Self {
|
||||
version,
|
||||
is_repair,
|
||||
codec_id,
|
||||
has_quality_report,
|
||||
fec_ratio_encoded,
|
||||
seq,
|
||||
timestamp,
|
||||
fec_block,
|
||||
fec_symbol,
|
||||
reserved,
|
||||
csrc_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialize header to a new Bytes value.
|
||||
pub fn to_bytes(&self) -> Bytes {
|
||||
let mut buf = BytesMut::with_capacity(Self::WIRE_SIZE);
|
||||
self.write_to(&mut buf);
|
||||
buf.freeze()
|
||||
}
|
||||
}
|
||||
|
||||
/// Quality report appended to a media packet when Q flag is set (4 bytes).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QualityReport {
|
||||
/// Observed loss percentage (0-255 maps to 0-100%).
|
||||
pub loss_pct: u8,
|
||||
/// RTT estimate in 4ms units (0-255 = 0-1020ms).
|
||||
pub rtt_4ms: u8,
|
||||
/// Jitter in milliseconds.
|
||||
pub jitter_ms: u8,
|
||||
/// Maximum receive bitrate in kbps.
|
||||
pub bitrate_cap_kbps: u8,
|
||||
}
|
||||
|
||||
impl QualityReport {
|
||||
pub const WIRE_SIZE: usize = 4;
|
||||
|
||||
pub fn loss_percent(&self) -> f32 {
|
||||
self.loss_pct as f32 / 255.0 * 100.0
|
||||
}
|
||||
|
||||
pub fn rtt_ms(&self) -> u16 {
|
||||
self.rtt_4ms as u16 * 4
|
||||
}
|
||||
|
||||
pub fn write_to(&self, buf: &mut impl BufMut) {
|
||||
buf.put_u8(self.loss_pct);
|
||||
buf.put_u8(self.rtt_4ms);
|
||||
buf.put_u8(self.jitter_ms);
|
||||
buf.put_u8(self.bitrate_cap_kbps);
|
||||
}
|
||||
|
||||
pub fn read_from(buf: &mut impl Buf) -> Option<Self> {
|
||||
if buf.remaining() < Self::WIRE_SIZE {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
loss_pct: buf.get_u8(),
|
||||
rtt_4ms: buf.get_u8(),
|
||||
jitter_ms: buf.get_u8(),
|
||||
bitrate_cap_kbps: buf.get_u8(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A complete media packet (header + payload + optional quality report).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MediaPacket {
|
||||
pub header: MediaHeader,
|
||||
pub payload: Bytes,
|
||||
pub quality_report: Option<QualityReport>,
|
||||
}
|
||||
|
||||
impl MediaPacket {
|
||||
/// Serialize the entire packet to bytes.
|
||||
pub fn to_bytes(&self) -> Bytes {
|
||||
let qr_size = if self.quality_report.is_some() {
|
||||
QualityReport::WIRE_SIZE
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let total = MediaHeader::WIRE_SIZE + self.payload.len() + qr_size;
|
||||
let mut buf = BytesMut::with_capacity(total);
|
||||
|
||||
self.header.write_to(&mut buf);
|
||||
buf.put(self.payload.clone());
|
||||
if let Some(ref qr) = self.quality_report {
|
||||
qr.write_to(&mut buf);
|
||||
}
|
||||
|
||||
buf.freeze()
|
||||
}
|
||||
|
||||
/// Deserialize from bytes. `payload_len` must be known from context
|
||||
/// (e.g., total packet size minus header minus optional QR).
|
||||
pub fn from_bytes(data: Bytes) -> Option<Self> {
|
||||
let mut cursor = &data[..];
|
||||
let header = MediaHeader::read_from(&mut cursor)?;
|
||||
|
||||
let remaining = data.len() - MediaHeader::WIRE_SIZE;
|
||||
let (payload_len, quality_report) = if header.has_quality_report {
|
||||
if remaining < QualityReport::WIRE_SIZE {
|
||||
return None;
|
||||
}
|
||||
let pl = remaining - QualityReport::WIRE_SIZE;
|
||||
let qr_start = MediaHeader::WIRE_SIZE + pl;
|
||||
let mut qr_cursor = &data[qr_start..];
|
||||
let qr = QualityReport::read_from(&mut qr_cursor)?;
|
||||
(pl, Some(qr))
|
||||
} else {
|
||||
(remaining, None)
|
||||
};
|
||||
|
||||
let payload = data.slice(MediaHeader::WIRE_SIZE..MediaHeader::WIRE_SIZE + payload_len);
|
||||
|
||||
Some(Self {
|
||||
header,
|
||||
payload,
|
||||
quality_report,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Signaling messages sent over the reliable QUIC stream.
|
||||
///
|
||||
/// Compatible with Warzone messenger's identity model:
|
||||
/// - Identity keys are Ed25519 (signing) + X25519 (encryption) derived from a 32-byte seed via HKDF
|
||||
/// - Fingerprint = SHA-256(Ed25519 public key)[:16]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum SignalMessage {
|
||||
/// Call initiation (analogous to Warzone's WireMessage::CallOffer).
|
||||
CallOffer {
|
||||
/// Caller's Ed25519 identity public key (32 bytes).
|
||||
identity_pub: [u8; 32],
|
||||
/// Ephemeral X25519 public key for this call.
|
||||
ephemeral_pub: [u8; 32],
|
||||
/// Ed25519 signature over (ephemeral_pub || callee_fingerprint).
|
||||
signature: Vec<u8>,
|
||||
/// Supported quality profiles.
|
||||
supported_profiles: Vec<crate::QualityProfile>,
|
||||
},
|
||||
|
||||
/// Call acceptance (analogous to Warzone's WireMessage::CallAnswer).
|
||||
CallAnswer {
|
||||
/// Callee's Ed25519 identity public key (32 bytes).
|
||||
identity_pub: [u8; 32],
|
||||
/// Callee's ephemeral X25519 public key.
|
||||
ephemeral_pub: [u8; 32],
|
||||
/// Ed25519 signature over (ephemeral_pub || caller_fingerprint).
|
||||
signature: Vec<u8>,
|
||||
/// Chosen quality profile.
|
||||
chosen_profile: crate::QualityProfile,
|
||||
},
|
||||
|
||||
/// ICE candidate for NAT traversal.
|
||||
IceCandidate {
|
||||
candidate: String,
|
||||
},
|
||||
|
||||
/// Periodic rekeying (forward secrecy).
|
||||
Rekey {
|
||||
/// New ephemeral X25519 public key.
|
||||
new_ephemeral_pub: [u8; 32],
|
||||
/// Ed25519 signature over (new_ephemeral_pub || session_id).
|
||||
signature: Vec<u8>,
|
||||
},
|
||||
|
||||
/// Quality/profile change request.
|
||||
QualityUpdate {
|
||||
report: QualityReport,
|
||||
recommended_profile: crate::QualityProfile,
|
||||
},
|
||||
|
||||
/// Connection keepalive / RTT measurement.
|
||||
Ping { timestamp_ms: u64 },
|
||||
Pong { timestamp_ms: u64 },
|
||||
|
||||
/// End the call.
|
||||
Hangup { reason: HangupReason },
|
||||
}
|
||||
|
||||
/// Reasons for ending a call.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum HangupReason {
|
||||
Normal,
|
||||
Busy,
|
||||
Declined,
|
||||
Timeout,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn header_roundtrip() {
|
||||
let header = MediaHeader {
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
codec_id: CodecId::Opus24k,
|
||||
has_quality_report: true,
|
||||
fec_ratio_encoded: 42,
|
||||
seq: 12345,
|
||||
timestamp: 987654,
|
||||
fec_block: 7,
|
||||
fec_symbol: 3,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
};
|
||||
|
||||
let bytes = header.to_bytes();
|
||||
assert_eq!(bytes.len(), MediaHeader::WIRE_SIZE);
|
||||
|
||||
let mut cursor = &bytes[..];
|
||||
let decoded = MediaHeader::read_from(&mut cursor).unwrap();
|
||||
assert_eq!(header, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_repair_flag() {
|
||||
let header = MediaHeader {
|
||||
version: 0,
|
||||
is_repair: true,
|
||||
codec_id: CodecId::Codec2_1200,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: 127,
|
||||
seq: 65535,
|
||||
timestamp: u32::MAX,
|
||||
fec_block: 255,
|
||||
fec_symbol: 255,
|
||||
reserved: 0xFF,
|
||||
csrc_count: 0,
|
||||
};
|
||||
|
||||
let bytes = header.to_bytes();
|
||||
let mut cursor = &bytes[..];
|
||||
let decoded = MediaHeader::read_from(&mut cursor).unwrap();
|
||||
assert_eq!(header, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_report_roundtrip() {
|
||||
let qr = QualityReport {
|
||||
loss_pct: 128,
|
||||
rtt_4ms: 100,
|
||||
jitter_ms: 50,
|
||||
bitrate_cap_kbps: 200,
|
||||
};
|
||||
|
||||
let mut buf = BytesMut::new();
|
||||
qr.write_to(&mut buf);
|
||||
assert_eq!(buf.len(), QualityReport::WIRE_SIZE);
|
||||
|
||||
let mut cursor = &buf[..];
|
||||
let decoded = QualityReport::read_from(&mut cursor).unwrap();
|
||||
assert_eq!(qr, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_packet_roundtrip() {
|
||||
let packet = MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
codec_id: CodecId::Opus6k,
|
||||
has_quality_report: true,
|
||||
fec_ratio_encoded: 32,
|
||||
seq: 100,
|
||||
timestamp: 2000,
|
||||
fec_block: 1,
|
||||
fec_symbol: 0,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from_static(b"test audio data here"),
|
||||
quality_report: Some(QualityReport {
|
||||
loss_pct: 25,
|
||||
rtt_4ms: 75,
|
||||
jitter_ms: 10,
|
||||
bitrate_cap_kbps: 100,
|
||||
}),
|
||||
};
|
||||
|
||||
let bytes = packet.to_bytes();
|
||||
let decoded = MediaPacket::from_bytes(bytes).unwrap();
|
||||
|
||||
assert_eq!(packet.header, decoded.header);
|
||||
assert_eq!(packet.payload, decoded.payload);
|
||||
assert_eq!(packet.quality_report, decoded.quality_report);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fec_ratio_encode_decode() {
|
||||
let ratio = 0.5;
|
||||
let encoded = MediaHeader::encode_fec_ratio(ratio);
|
||||
let decoded = MediaHeader::decode_fec_ratio(encoded);
|
||||
assert!((decoded - ratio).abs() < 0.02);
|
||||
|
||||
let ratio_max = 2.0;
|
||||
let encoded_max = MediaHeader::encode_fec_ratio(ratio_max);
|
||||
assert_eq!(encoded_max, 127);
|
||||
}
|
||||
}
|
||||
249
crates/wzp-proto/src/quality.rs
Normal file
249
crates/wzp-proto/src/quality.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::packet::QualityReport;
|
||||
use crate::traits::QualityController;
|
||||
use crate::QualityProfile;
|
||||
|
||||
/// Network quality tier — drives codec and FEC selection.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Tier {
|
||||
/// loss < 10%, RTT < 400ms
|
||||
Good,
|
||||
/// loss 10-40% OR RTT 400-600ms
|
||||
Degraded,
|
||||
/// loss > 40% OR RTT > 600ms
|
||||
Catastrophic,
|
||||
}
|
||||
|
||||
impl Tier {
|
||||
pub fn profile(self) -> QualityProfile {
|
||||
match self {
|
||||
Self::Good => QualityProfile::GOOD,
|
||||
Self::Degraded => QualityProfile::DEGRADED,
|
||||
Self::Catastrophic => QualityProfile::CATASTROPHIC,
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine which tier a quality report belongs to.
|
||||
pub fn classify(report: &QualityReport) -> Self {
|
||||
let loss = report.loss_percent();
|
||||
let rtt = report.rtt_ms();
|
||||
|
||||
if loss > 40.0 || rtt > 600 {
|
||||
Self::Catastrophic
|
||||
} else if loss > 10.0 || rtt > 400 {
|
||||
Self::Degraded
|
||||
} else {
|
||||
Self::Good
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adaptive quality controller with hysteresis to prevent tier flapping.
|
||||
///
|
||||
/// - Downgrade: 3 consecutive reports in a worse tier
|
||||
/// - Upgrade: 10 consecutive reports in a better tier
|
||||
pub struct AdaptiveQualityController {
|
||||
current_tier: Tier,
|
||||
current_profile: QualityProfile,
|
||||
/// Count of consecutive reports suggesting a higher (better) tier.
|
||||
consecutive_up: u32,
|
||||
/// Count of consecutive reports suggesting a lower (worse) tier.
|
||||
consecutive_down: u32,
|
||||
/// Sliding window of recent reports for smoothing.
|
||||
history: VecDeque<QualityReport>,
|
||||
/// Whether the profile was manually forced (disables adaptive logic).
|
||||
forced: bool,
|
||||
}
|
||||
|
||||
/// Threshold for downgrading (fast reaction to degradation).
|
||||
const DOWNGRADE_THRESHOLD: u32 = 3;
|
||||
/// Threshold for upgrading (slow, cautious improvement).
|
||||
const UPGRADE_THRESHOLD: u32 = 10;
|
||||
/// Maximum history window size.
|
||||
const HISTORY_SIZE: usize = 20;
|
||||
|
||||
impl AdaptiveQualityController {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
current_tier: Tier::Good,
|
||||
current_profile: QualityProfile::GOOD,
|
||||
consecutive_up: 0,
|
||||
consecutive_down: 0,
|
||||
history: VecDeque::with_capacity(HISTORY_SIZE),
|
||||
forced: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current tier.
|
||||
pub fn tier(&self) -> Tier {
|
||||
self.current_tier
|
||||
}
|
||||
|
||||
fn try_transition(&mut self, observed_tier: Tier) -> Option<QualityProfile> {
|
||||
if observed_tier == self.current_tier {
|
||||
self.consecutive_up = 0;
|
||||
self.consecutive_down = 0;
|
||||
return None;
|
||||
}
|
||||
|
||||
let is_worse = match (self.current_tier, observed_tier) {
|
||||
(Tier::Good, Tier::Degraded | Tier::Catastrophic) => true,
|
||||
(Tier::Degraded, Tier::Catastrophic) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if is_worse {
|
||||
self.consecutive_up = 0;
|
||||
self.consecutive_down += 1;
|
||||
if self.consecutive_down >= DOWNGRADE_THRESHOLD {
|
||||
self.current_tier = observed_tier;
|
||||
self.current_profile = observed_tier.profile();
|
||||
self.consecutive_down = 0;
|
||||
return Some(self.current_profile);
|
||||
}
|
||||
} else {
|
||||
// Better conditions
|
||||
self.consecutive_down = 0;
|
||||
self.consecutive_up += 1;
|
||||
if self.consecutive_up >= UPGRADE_THRESHOLD {
|
||||
// Only upgrade one step at a time
|
||||
let next_tier = match self.current_tier {
|
||||
Tier::Catastrophic => Tier::Degraded,
|
||||
Tier::Degraded => Tier::Good,
|
||||
Tier::Good => return None,
|
||||
};
|
||||
self.current_tier = next_tier;
|
||||
self.current_profile = next_tier.profile();
|
||||
self.consecutive_up = 0;
|
||||
return Some(self.current_profile);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AdaptiveQualityController {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl QualityController for AdaptiveQualityController {
|
||||
fn observe(&mut self, report: &QualityReport) -> Option<QualityProfile> {
|
||||
// Store in history
|
||||
if self.history.len() >= HISTORY_SIZE {
|
||||
self.history.pop_front();
|
||||
}
|
||||
self.history.push_back(*report);
|
||||
|
||||
if self.forced {
|
||||
return None;
|
||||
}
|
||||
|
||||
let observed = Tier::classify(report);
|
||||
self.try_transition(observed)
|
||||
}
|
||||
|
||||
fn force_profile(&mut self, profile: QualityProfile) {
|
||||
self.current_profile = profile;
|
||||
self.forced = true;
|
||||
self.consecutive_up = 0;
|
||||
self.consecutive_down = 0;
|
||||
}
|
||||
|
||||
fn current_profile(&self) -> QualityProfile {
|
||||
self.current_profile
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_report(loss_pct_f: f32, rtt_ms: u16) -> QualityReport {
|
||||
QualityReport {
|
||||
loss_pct: (loss_pct_f / 100.0 * 255.0) as u8,
|
||||
rtt_4ms: (rtt_ms / 4) as u8,
|
||||
jitter_ms: 10,
|
||||
bitrate_cap_kbps: 200,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starts_at_good() {
|
||||
let ctrl = AdaptiveQualityController::new();
|
||||
assert_eq!(ctrl.tier(), Tier::Good);
|
||||
assert_eq!(ctrl.current_profile().codec, crate::CodecId::Opus24k);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downgrades_after_threshold() {
|
||||
let mut ctrl = AdaptiveQualityController::new();
|
||||
|
||||
// 2 bad reports — not enough
|
||||
let bad = make_report(50.0, 300);
|
||||
assert!(ctrl.observe(&bad).is_none());
|
||||
assert!(ctrl.observe(&bad).is_none());
|
||||
assert_eq!(ctrl.tier(), Tier::Good);
|
||||
|
||||
// 3rd bad report triggers downgrade
|
||||
let result = ctrl.observe(&bad);
|
||||
assert!(result.is_some());
|
||||
assert_eq!(ctrl.tier(), Tier::Catastrophic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrades_slowly() {
|
||||
let mut ctrl = AdaptiveQualityController::new();
|
||||
|
||||
// Force to catastrophic
|
||||
let bad = make_report(50.0, 300);
|
||||
for _ in 0..3 {
|
||||
ctrl.observe(&bad);
|
||||
}
|
||||
assert_eq!(ctrl.tier(), Tier::Catastrophic);
|
||||
|
||||
// 9 good reports — not enough
|
||||
let good = make_report(2.0, 100);
|
||||
for _ in 0..9 {
|
||||
assert!(ctrl.observe(&good).is_none());
|
||||
}
|
||||
assert_eq!(ctrl.tier(), Tier::Catastrophic);
|
||||
|
||||
// 10th good report triggers upgrade (one step: Catastrophic → Degraded)
|
||||
let result = ctrl.observe(&good);
|
||||
assert!(result.is_some());
|
||||
assert_eq!(ctrl.tier(), Tier::Degraded);
|
||||
|
||||
// Need another 10 to go from Degraded → Good
|
||||
for _ in 0..9 {
|
||||
assert!(ctrl.observe(&good).is_none());
|
||||
}
|
||||
let result = ctrl.observe(&good);
|
||||
assert!(result.is_some());
|
||||
assert_eq!(ctrl.tier(), Tier::Good);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_profile_disables_adaptive() {
|
||||
let mut ctrl = AdaptiveQualityController::new();
|
||||
ctrl.force_profile(QualityProfile::CATASTROPHIC);
|
||||
|
||||
// Bad reports don't trigger transitions when forced
|
||||
let bad = make_report(50.0, 300);
|
||||
for _ in 0..10 {
|
||||
assert!(ctrl.observe(&bad).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_classification() {
|
||||
assert_eq!(Tier::classify(&make_report(5.0, 200)), Tier::Good);
|
||||
assert_eq!(Tier::classify(&make_report(15.0, 200)), Tier::Degraded);
|
||||
assert_eq!(Tier::classify(&make_report(5.0, 500)), Tier::Degraded);
|
||||
assert_eq!(Tier::classify(&make_report(50.0, 200)), Tier::Catastrophic);
|
||||
assert_eq!(Tier::classify(&make_report(5.0, 700)), Tier::Catastrophic);
|
||||
}
|
||||
}
|
||||
204
crates/wzp-proto/src/session.rs
Normal file
204
crates/wzp-proto/src/session.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Session state machine for a call.
|
||||
///
|
||||
/// ```text
|
||||
/// Idle → Connecting → Handshaking → Active ⇄ Rekeying → Active
|
||||
/// ↓
|
||||
/// Closed
|
||||
/// ```
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SessionState {
|
||||
/// No active call. Waiting for initiation.
|
||||
Idle,
|
||||
/// Transport connection being established (QUIC handshake).
|
||||
Connecting,
|
||||
/// Crypto handshake in progress (X25519 key exchange, identity verification).
|
||||
Handshaking,
|
||||
/// Call is active — media flowing.
|
||||
Active,
|
||||
/// Rekeying in progress (forward secrecy rotation). Media continues flowing.
|
||||
Rekeying,
|
||||
/// Call has ended.
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Events that drive session state transitions.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SessionEvent {
|
||||
/// User initiates a call.
|
||||
Initiate,
|
||||
/// Transport connection established.
|
||||
Connected,
|
||||
/// Crypto handshake completed successfully.
|
||||
HandshakeComplete,
|
||||
/// Rekey initiated (local or remote).
|
||||
RekeyStart,
|
||||
/// Rekey completed successfully.
|
||||
RekeyComplete,
|
||||
/// Call ended (local hangup, remote hangup, or error).
|
||||
Terminate { reason: TerminateReason },
|
||||
/// Transport connection lost.
|
||||
ConnectionLost,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TerminateReason {
|
||||
LocalHangup,
|
||||
RemoteHangup,
|
||||
Timeout,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// Session state machine.
|
||||
pub struct Session {
|
||||
state: SessionState,
|
||||
/// Unique session identifier (random, generated at call initiation).
|
||||
session_id: [u8; 16],
|
||||
/// Timestamp of the last state transition (ms since epoch).
|
||||
last_transition_ms: u64,
|
||||
/// Number of successful rekeys in this session.
|
||||
rekey_count: u32,
|
||||
}
|
||||
|
||||
/// Error when a state transition is invalid.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("invalid transition from {from:?} on event {event}")]
|
||||
pub struct TransitionError {
|
||||
pub from: SessionState,
|
||||
pub event: String,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(session_id: [u8; 16]) -> Self {
|
||||
Self {
|
||||
state: SessionState::Idle,
|
||||
session_id,
|
||||
last_transition_ms: 0,
|
||||
rekey_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> SessionState {
|
||||
self.state
|
||||
}
|
||||
|
||||
pub fn session_id(&self) -> &[u8; 16] {
|
||||
&self.session_id
|
||||
}
|
||||
|
||||
pub fn rekey_count(&self) -> u32 {
|
||||
self.rekey_count
|
||||
}
|
||||
|
||||
/// Process an event and transition state.
|
||||
pub fn transition(
|
||||
&mut self,
|
||||
event: SessionEvent,
|
||||
now_ms: u64,
|
||||
) -> Result<SessionState, TransitionError> {
|
||||
let new_state = match (&self.state, &event) {
|
||||
(SessionState::Idle, SessionEvent::Initiate) => SessionState::Connecting,
|
||||
|
||||
(SessionState::Connecting, SessionEvent::Connected) => SessionState::Handshaking,
|
||||
(SessionState::Connecting, SessionEvent::Terminate { .. })
|
||||
| (SessionState::Connecting, SessionEvent::ConnectionLost) => SessionState::Closed,
|
||||
|
||||
(SessionState::Handshaking, SessionEvent::HandshakeComplete) => SessionState::Active,
|
||||
(SessionState::Handshaking, SessionEvent::Terminate { .. })
|
||||
| (SessionState::Handshaking, SessionEvent::ConnectionLost) => SessionState::Closed,
|
||||
|
||||
(SessionState::Active, SessionEvent::RekeyStart) => SessionState::Rekeying,
|
||||
(SessionState::Active, SessionEvent::Terminate { .. }) => SessionState::Closed,
|
||||
(SessionState::Active, SessionEvent::ConnectionLost) => SessionState::Closed,
|
||||
|
||||
(SessionState::Rekeying, SessionEvent::RekeyComplete) => {
|
||||
self.rekey_count += 1;
|
||||
SessionState::Active
|
||||
}
|
||||
(SessionState::Rekeying, SessionEvent::Terminate { .. })
|
||||
| (SessionState::Rekeying, SessionEvent::ConnectionLost) => SessionState::Closed,
|
||||
|
||||
_ => {
|
||||
return Err(TransitionError {
|
||||
from: self.state,
|
||||
event: format!("{event:?}"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
self.state = new_state;
|
||||
self.last_transition_ms = now_ms;
|
||||
Ok(new_state)
|
||||
}
|
||||
|
||||
/// Whether the session is in a state where media can flow.
|
||||
pub fn is_media_active(&self) -> bool {
|
||||
matches!(self.state, SessionState::Active | SessionState::Rekeying)
|
||||
}
|
||||
|
||||
/// Duration since last state transition.
|
||||
pub fn time_in_state_ms(&self, now_ms: u64) -> u64 {
|
||||
now_ms.saturating_sub(self.last_transition_ms)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_session() -> Session {
|
||||
Session::new([0u8; 16])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_path() {
|
||||
let mut s = make_session();
|
||||
assert_eq!(s.state(), SessionState::Idle);
|
||||
|
||||
s.transition(SessionEvent::Initiate, 0).unwrap();
|
||||
assert_eq!(s.state(), SessionState::Connecting);
|
||||
|
||||
s.transition(SessionEvent::Connected, 100).unwrap();
|
||||
assert_eq!(s.state(), SessionState::Handshaking);
|
||||
|
||||
s.transition(SessionEvent::HandshakeComplete, 200).unwrap();
|
||||
assert_eq!(s.state(), SessionState::Active);
|
||||
assert!(s.is_media_active());
|
||||
|
||||
s.transition(SessionEvent::RekeyStart, 60_000).unwrap();
|
||||
assert_eq!(s.state(), SessionState::Rekeying);
|
||||
assert!(s.is_media_active()); // media continues during rekey
|
||||
|
||||
s.transition(SessionEvent::RekeyComplete, 60_100).unwrap();
|
||||
assert_eq!(s.state(), SessionState::Active);
|
||||
assert_eq!(s.rekey_count(), 1);
|
||||
|
||||
s.transition(
|
||||
SessionEvent::Terminate {
|
||||
reason: TerminateReason::LocalHangup,
|
||||
},
|
||||
120_000,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.state(), SessionState::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_transition() {
|
||||
let mut s = make_session();
|
||||
let result = s.transition(SessionEvent::Connected, 0);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_lost_from_active() {
|
||||
let mut s = make_session();
|
||||
s.transition(SessionEvent::Initiate, 0).unwrap();
|
||||
s.transition(SessionEvent::Connected, 100).unwrap();
|
||||
s.transition(SessionEvent::HandshakeComplete, 200).unwrap();
|
||||
|
||||
s.transition(SessionEvent::ConnectionLost, 5000).unwrap();
|
||||
assert_eq!(s.state(), SessionState::Closed);
|
||||
}
|
||||
}
|
||||
246
crates/wzp-proto/src/traits.rs
Normal file
246
crates/wzp-proto/src/traits.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::*;
|
||||
use crate::packet::*;
|
||||
use crate::{CodecId, QualityProfile};
|
||||
|
||||
// ─── Audio Codec Traits ──────────────────────────────────────────────────────
|
||||
|
||||
/// Encodes PCM audio into compressed frames.
|
||||
pub trait AudioEncoder: Send + Sync {
|
||||
/// Encode PCM samples (16-bit mono) into a compressed frame.
|
||||
///
|
||||
/// Input sample rate depends on `codec_id()` — 48kHz for Opus, 8kHz for Codec2.
|
||||
/// Returns the number of bytes written to `out`.
|
||||
fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize, CodecError>;
|
||||
|
||||
/// Current codec identifier.
|
||||
fn codec_id(&self) -> CodecId;
|
||||
|
||||
/// Switch codec/bitrate configuration on the fly.
|
||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError>;
|
||||
|
||||
/// Maximum output bytes for a single frame at current settings.
|
||||
fn max_frame_bytes(&self) -> usize;
|
||||
|
||||
/// Enable/disable Opus inband FEC (no-op for Codec2).
|
||||
fn set_inband_fec(&mut self, _enabled: bool) {}
|
||||
|
||||
/// Enable/disable DTX (discontinuous transmission). No-op for Codec2.
|
||||
fn set_dtx(&mut self, _enabled: bool) {}
|
||||
}
|
||||
|
||||
/// Decodes compressed frames back to PCM audio.
|
||||
pub trait AudioDecoder: Send + Sync {
|
||||
/// Decode a compressed frame into PCM samples.
|
||||
/// Returns the number of samples written to `pcm`.
|
||||
fn decode(&mut self, encoded: &[u8], pcm: &mut [i16]) -> Result<usize, CodecError>;
|
||||
|
||||
/// Generate PLC (packet loss concealment) output for a missing frame.
|
||||
/// Returns the number of samples written.
|
||||
fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError>;
|
||||
|
||||
/// Current codec identifier.
|
||||
fn codec_id(&self) -> CodecId;
|
||||
|
||||
/// Switch codec/bitrate configuration.
|
||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError>;
|
||||
}
|
||||
|
||||
// ─── FEC Traits ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Encodes source symbols into FEC-protected blocks using fountain codes.
|
||||
pub trait FecEncoder: Send + Sync {
|
||||
/// Add a source symbol (one audio frame) to the current block.
|
||||
fn add_source_symbol(&mut self, data: &[u8]) -> Result<(), FecError>;
|
||||
|
||||
/// Generate repair symbols for the current block.
|
||||
///
|
||||
/// `ratio` is the repair overhead (e.g., 0.5 = 50% more symbols than source).
|
||||
/// Returns `(fec_symbol_index, repair_data)` pairs.
|
||||
fn generate_repair(&mut self, ratio: f32) -> Result<Vec<(u8, Vec<u8>)>, FecError>;
|
||||
|
||||
/// Finalize the current block and start a new one.
|
||||
/// Returns the block ID of the finalized block.
|
||||
fn finalize_block(&mut self) -> Result<u8, FecError>;
|
||||
|
||||
/// Current block ID being built.
|
||||
fn current_block_id(&self) -> u8;
|
||||
|
||||
/// Number of source symbols in the current block.
|
||||
fn current_block_size(&self) -> usize;
|
||||
}
|
||||
|
||||
/// Decodes FEC-protected blocks, recovering lost source symbols.
|
||||
pub trait FecDecoder: Send + Sync {
|
||||
/// Feed a received symbol (source or repair) into the decoder.
|
||||
fn add_symbol(
|
||||
&mut self,
|
||||
block_id: u8,
|
||||
symbol_index: u8,
|
||||
is_repair: bool,
|
||||
data: &[u8],
|
||||
) -> Result<(), FecError>;
|
||||
|
||||
/// Attempt to reconstruct the source block.
|
||||
///
|
||||
/// Returns `None` if not yet decodable (insufficient symbols).
|
||||
/// Returns `Some(Vec<source_frames>)` on success.
|
||||
fn try_decode(&mut self, block_id: u8) -> Result<Option<Vec<Vec<u8>>>, FecError>;
|
||||
|
||||
/// Drop state for blocks older than `block_id`.
|
||||
fn expire_before(&mut self, block_id: u8);
|
||||
}
|
||||
|
||||
// ─── Crypto Traits ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Compatible with Warzone messenger identity model:
|
||||
// Identity = 32-byte seed → HKDF → Ed25519 (signing) + X25519 (encryption)
|
||||
// Fingerprint = SHA-256(Ed25519 pub)[:16]
|
||||
|
||||
/// Per-call encryption session (symmetric, after key exchange).
|
||||
pub trait CryptoSession: Send + Sync {
|
||||
/// Encrypt a media packet payload.
|
||||
///
|
||||
/// `header_bytes` is used as AAD (authenticated but not encrypted).
|
||||
/// The encrypted output is written to `out` (ciphertext + 16-byte auth tag).
|
||||
fn encrypt(
|
||||
&mut self,
|
||||
header_bytes: &[u8],
|
||||
plaintext: &[u8],
|
||||
out: &mut Vec<u8>,
|
||||
) -> Result<(), CryptoError>;
|
||||
|
||||
/// Decrypt a media packet payload.
|
||||
///
|
||||
/// `header_bytes` is the AAD used during encryption.
|
||||
/// Returns decrypted plaintext in `out`.
|
||||
fn decrypt(
|
||||
&mut self,
|
||||
header_bytes: &[u8],
|
||||
ciphertext: &[u8],
|
||||
out: &mut Vec<u8>,
|
||||
) -> Result<(), CryptoError>;
|
||||
|
||||
/// Initiate rekeying. Returns the new ephemeral X25519 public key to send to the peer.
|
||||
fn initiate_rekey(&mut self) -> Result<[u8; 32], CryptoError>;
|
||||
|
||||
/// Complete rekeying with the peer's new ephemeral public key.
|
||||
fn complete_rekey(&mut self, peer_ephemeral_pub: &[u8; 32]) -> Result<(), CryptoError>;
|
||||
|
||||
/// Current encryption overhead in bytes (auth tag size).
|
||||
fn overhead(&self) -> usize {
|
||||
16 // ChaCha20-Poly1305 tag
|
||||
}
|
||||
}
|
||||
|
||||
/// Key exchange using the Warzone identity model.
|
||||
///
|
||||
/// The identity keypair (Ed25519 + X25519) is derived from the user's 32-byte seed
|
||||
/// via HKDF. Each call generates a new ephemeral X25519 keypair.
|
||||
pub trait KeyExchange: Send + Sync {
|
||||
/// Initialize from a Warzone identity seed.
|
||||
///
|
||||
/// The seed derives:
|
||||
/// - Ed25519 signing keypair (for identity/signatures)
|
||||
/// - X25519 static keypair (for encryption, though calls use ephemeral keys)
|
||||
fn from_identity_seed(seed: &[u8; 32]) -> Self
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Generate a new ephemeral X25519 keypair for this call.
|
||||
/// Returns the ephemeral public key to send to the peer.
|
||||
fn generate_ephemeral(&mut self) -> [u8; 32];
|
||||
|
||||
/// Get our Ed25519 identity public key.
|
||||
fn identity_public_key(&self) -> [u8; 32];
|
||||
|
||||
/// Get our fingerprint (SHA-256(Ed25519 pub)[:16]).
|
||||
fn fingerprint(&self) -> [u8; 16];
|
||||
|
||||
/// Sign data with our Ed25519 identity key.
|
||||
fn sign(&self, data: &[u8]) -> Vec<u8>;
|
||||
|
||||
/// Verify a signature from a peer's Ed25519 public key.
|
||||
fn verify(peer_identity_pub: &[u8; 32], data: &[u8], signature: &[u8]) -> bool
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Derive a CryptoSession from our ephemeral secret + peer's ephemeral public key.
|
||||
///
|
||||
/// The shared secret is computed via X25519 ECDH, then expanded via HKDF.
|
||||
fn derive_session(
|
||||
&self,
|
||||
peer_ephemeral_pub: &[u8; 32],
|
||||
) -> Result<Box<dyn CryptoSession>, CryptoError>;
|
||||
}
|
||||
|
||||
// ─── Transport Traits ────────────────────────────────────────────────────────
|
||||
|
||||
/// Transport layer for sending/receiving media and signaling.
|
||||
#[async_trait]
|
||||
pub trait MediaTransport: Send + Sync {
|
||||
/// Send a media packet (unreliable, via QUIC DATAGRAM frame).
|
||||
async fn send_media(&self, packet: &MediaPacket) -> Result<(), TransportError>;
|
||||
|
||||
/// Receive the next media packet. Returns None on clean shutdown.
|
||||
async fn recv_media(&self) -> Result<Option<MediaPacket>, TransportError>;
|
||||
|
||||
/// Send a signaling message (reliable, via QUIC stream).
|
||||
async fn send_signal(&self, msg: &SignalMessage) -> Result<(), TransportError>;
|
||||
|
||||
/// Receive the next signaling message. Returns None on clean shutdown.
|
||||
async fn recv_signal(&self) -> Result<Option<SignalMessage>, TransportError>;
|
||||
|
||||
/// Current estimated path quality metrics.
|
||||
fn path_quality(&self) -> PathQuality;
|
||||
|
||||
/// Close the transport gracefully.
|
||||
async fn close(&self) -> Result<(), TransportError>;
|
||||
}
|
||||
|
||||
/// Observed network path quality metrics.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PathQuality {
|
||||
/// Estimated packet loss percentage (0.0-100.0).
|
||||
pub loss_pct: f32,
|
||||
/// Smoothed round-trip time in milliseconds.
|
||||
pub rtt_ms: u32,
|
||||
/// Jitter (RTT variance) in milliseconds.
|
||||
pub jitter_ms: u32,
|
||||
/// Estimated available bandwidth in kbps.
|
||||
pub bandwidth_kbps: u32,
|
||||
}
|
||||
|
||||
// ─── Obfuscation Trait (Phase 2) ─────────────────────────────────────────────
|
||||
|
||||
/// Wraps/unwraps packets for DPI evasion on the client-relay link.
|
||||
pub trait ObfuscationLayer: Send + Sync {
|
||||
/// Wrap outgoing bytes with obfuscation (padding, framing, etc.).
|
||||
fn obfuscate(
|
||||
&mut self,
|
||||
data: &[u8],
|
||||
out: &mut Vec<u8>,
|
||||
) -> Result<(), crate::error::ObfuscationError>;
|
||||
|
||||
/// Unwrap incoming obfuscated bytes.
|
||||
fn deobfuscate(
|
||||
&mut self,
|
||||
data: &[u8],
|
||||
out: &mut Vec<u8>,
|
||||
) -> Result<(), crate::error::ObfuscationError>;
|
||||
}
|
||||
|
||||
// ─── Quality Controller Trait ────────────────────────────────────────────────
|
||||
|
||||
/// Adaptive quality controller that selects codec/FEC parameters based on link conditions.
|
||||
pub trait QualityController: Send + Sync {
|
||||
/// Feed a quality observation. Returns a new profile if a tier transition occurred.
|
||||
fn observe(&mut self, report: &QualityReport) -> Option<QualityProfile>;
|
||||
|
||||
/// Force a specific profile (overrides adaptive logic).
|
||||
fn force_profile(&mut self, profile: QualityProfile);
|
||||
|
||||
/// Current active quality profile.
|
||||
fn current_profile(&self) -> QualityProfile;
|
||||
}
|
||||
Reference in New Issue
Block a user