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:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
.DS_Store
|
||||||
1785
Cargo.lock
generated
Normal file
1785
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
Cargo.toml
Normal file
51
Cargo.toml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"crates/wzp-proto",
|
||||||
|
"crates/wzp-codec",
|
||||||
|
"crates/wzp-fec",
|
||||||
|
"crates/wzp-crypto",
|
||||||
|
"crates/wzp-transport",
|
||||||
|
"crates/wzp-relay",
|
||||||
|
"crates/wzp-client",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
rust-version = "1.85"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
# Shared
|
||||||
|
bytes = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
async-trait = "0.1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
||||||
|
# Transport
|
||||||
|
quinn = "0.11"
|
||||||
|
|
||||||
|
# FEC
|
||||||
|
raptorq = "2"
|
||||||
|
|
||||||
|
# Codec
|
||||||
|
audiopus = "0.3.0-rc.0"
|
||||||
|
|
||||||
|
# Crypto
|
||||||
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||||
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
|
chacha20poly1305 = "0.10"
|
||||||
|
hkdf = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
|
# Workspace crates
|
||||||
|
wzp-proto = { path = "crates/wzp-proto" }
|
||||||
|
wzp-codec = { path = "crates/wzp-codec" }
|
||||||
|
wzp-fec = { path = "crates/wzp-fec" }
|
||||||
|
wzp-crypto = { path = "crates/wzp-crypto" }
|
||||||
|
wzp-transport = { path = "crates/wzp-transport" }
|
||||||
18
crates/wzp-client/Cargo.toml
Normal file
18
crates/wzp-client/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "wzp-client"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "WarzonePhone client library — for Android (JNI) and Windows desktop"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wzp-proto = { workspace = true }
|
||||||
|
wzp-codec = { workspace = true }
|
||||||
|
wzp-fec = { workspace = true }
|
||||||
|
wzp-crypto = { workspace = true }
|
||||||
|
wzp-transport = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
11
crates/wzp-client/src/lib.rs
Normal file
11
crates/wzp-client/src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//! WarzonePhone Client Library
|
||||||
|
//!
|
||||||
|
//! Client-side pipeline:
|
||||||
|
//! mic → encode → FEC → encrypt → send / recv → decrypt → FEC decode → decode → speaker
|
||||||
|
//!
|
||||||
|
//! Targets:
|
||||||
|
//! - Android (via JNI/uniffi)
|
||||||
|
//! - Windows desktop
|
||||||
|
//! - macOS/Linux (testing)
|
||||||
|
//!
|
||||||
|
//! Built after the 5 agent crates (proto, codec, fec, crypto, transport) are complete.
|
||||||
20
crates/wzp-codec/Cargo.toml
Normal file
20
crates/wzp-codec/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "wzp-codec"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "WarzonePhone audio codec layer — Opus + Codec2 encoding/decoding"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wzp-proto = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|
||||||
|
# Opus bindings
|
||||||
|
audiopus = { workspace = true }
|
||||||
|
|
||||||
|
# TODO: Add codec2-sys when implementing Codec2 support
|
||||||
|
# codec2-sys = "0.1"
|
||||||
|
# rubato = "0.15" # resampling
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
287
crates/wzp-codec/src/adaptive.rs
Normal file
287
crates/wzp-codec/src/adaptive.rs
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
//! Adaptive codec that wraps both Opus and Codec2, switching on the fly.
|
||||||
|
//!
|
||||||
|
//! `AdaptiveEncoder` and `AdaptiveDecoder` present a unified `AudioEncoder` /
|
||||||
|
//! `AudioDecoder` interface while transparently delegating to the appropriate
|
||||||
|
//! inner codec based on the current `QualityProfile`.
|
||||||
|
//!
|
||||||
|
//! Callers always work with 48 kHz PCM. When Codec2 is the active codec the
|
||||||
|
//! adaptive layer handles the 48 kHz ↔ 8 kHz resampling internally.
|
||||||
|
|
||||||
|
use tracing::debug;
|
||||||
|
use wzp_proto::{AudioDecoder, AudioEncoder, CodecError, CodecId, QualityProfile};
|
||||||
|
|
||||||
|
use crate::codec2_dec::Codec2Decoder;
|
||||||
|
use crate::codec2_enc::Codec2Encoder;
|
||||||
|
use crate::opus_dec::OpusDecoder;
|
||||||
|
use crate::opus_enc::OpusEncoder;
|
||||||
|
use crate::resample;
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Returns `true` when the codec operates at 8 kHz (i.e. a Codec2 variant).
|
||||||
|
fn is_codec2(codec: CodecId) -> bool {
|
||||||
|
matches!(codec, CodecId::Codec2_3200 | CodecId::Codec2_1200)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `QualityProfile` that only contains Opus-relevant fields.
|
||||||
|
fn opus_profile(profile: QualityProfile) -> QualityProfile {
|
||||||
|
// Clamp to Opus24k if the caller somehow passes a Codec2 profile.
|
||||||
|
let codec = if is_codec2(profile.codec) {
|
||||||
|
CodecId::Opus24k
|
||||||
|
} else {
|
||||||
|
profile.codec
|
||||||
|
};
|
||||||
|
QualityProfile { codec, ..profile }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `QualityProfile` that only contains Codec2-relevant fields.
|
||||||
|
fn codec2_profile(profile: QualityProfile) -> QualityProfile {
|
||||||
|
let codec = if is_codec2(profile.codec) {
|
||||||
|
profile.codec
|
||||||
|
} else {
|
||||||
|
CodecId::Codec2_3200
|
||||||
|
};
|
||||||
|
QualityProfile { codec, ..profile }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AdaptiveEncoder ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Adaptive encoder that delegates to either Opus or Codec2.
|
||||||
|
///
|
||||||
|
/// Input PCM is always 48 kHz mono. When Codec2 is selected the encoder
|
||||||
|
/// downsamples to 8 kHz before encoding.
|
||||||
|
pub struct AdaptiveEncoder {
|
||||||
|
opus: OpusEncoder,
|
||||||
|
codec2: Codec2Encoder,
|
||||||
|
active: CodecId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdaptiveEncoder {
|
||||||
|
/// Create a new adaptive encoder starting at the given profile.
|
||||||
|
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
|
||||||
|
let opus = OpusEncoder::new(opus_profile(profile))?;
|
||||||
|
let codec2 = Codec2Encoder::new(codec2_profile(profile))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
opus,
|
||||||
|
codec2,
|
||||||
|
active: profile.codec,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioEncoder for AdaptiveEncoder {
|
||||||
|
fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize, CodecError> {
|
||||||
|
if is_codec2(self.active) {
|
||||||
|
// Downsample 48 kHz → 8 kHz then encode via Codec2.
|
||||||
|
let pcm_8k = resample::resample_48k_to_8k(pcm);
|
||||||
|
self.codec2.encode(&pcm_8k, out)
|
||||||
|
} else {
|
||||||
|
self.opus.encode(pcm, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_id(&self) -> CodecId {
|
||||||
|
self.active
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||||
|
let prev = self.active;
|
||||||
|
self.active = profile.codec;
|
||||||
|
|
||||||
|
if is_codec2(profile.codec) {
|
||||||
|
debug!(from = ?prev, to = ?profile.codec, "adaptive encoder → Codec2");
|
||||||
|
self.codec2.set_profile(profile)
|
||||||
|
} else {
|
||||||
|
debug!(from = ?prev, to = ?profile.codec, "adaptive encoder → Opus");
|
||||||
|
self.opus.set_profile(profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn max_frame_bytes(&self) -> usize {
|
||||||
|
if is_codec2(self.active) {
|
||||||
|
self.codec2.max_frame_bytes()
|
||||||
|
} else {
|
||||||
|
self.opus.max_frame_bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_inband_fec(&mut self, enabled: bool) {
|
||||||
|
self.opus.set_inband_fec(enabled);
|
||||||
|
// No-op for Codec2 (per trait doc).
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_dtx(&mut self, enabled: bool) {
|
||||||
|
self.opus.set_dtx(enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AdaptiveDecoder ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Adaptive decoder that delegates to either Opus or Codec2.
|
||||||
|
///
|
||||||
|
/// Output PCM is always 48 kHz mono. When Codec2 is selected the decoder
|
||||||
|
/// upsamples the 8 kHz output to 48 kHz before returning.
|
||||||
|
pub struct AdaptiveDecoder {
|
||||||
|
opus: OpusDecoder,
|
||||||
|
codec2: Codec2Decoder,
|
||||||
|
active: CodecId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdaptiveDecoder {
|
||||||
|
/// Create a new adaptive decoder starting at the given profile.
|
||||||
|
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
|
||||||
|
let opus = OpusDecoder::new(opus_profile(profile))?;
|
||||||
|
let codec2 = Codec2Decoder::new(codec2_profile(profile))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
opus,
|
||||||
|
codec2,
|
||||||
|
active: profile.codec,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioDecoder for AdaptiveDecoder {
|
||||||
|
fn decode(&mut self, encoded: &[u8], pcm: &mut [i16]) -> Result<usize, CodecError> {
|
||||||
|
if is_codec2(self.active) {
|
||||||
|
// Decode into a temporary 8 kHz buffer, then upsample.
|
||||||
|
let c2_samples = self.codec2_frame_samples();
|
||||||
|
let mut buf_8k = vec![0i16; c2_samples];
|
||||||
|
let n = self.codec2.decode(encoded, &mut buf_8k)?;
|
||||||
|
let pcm_48k = resample::resample_8k_to_48k(&buf_8k[..n]);
|
||||||
|
let out_len = pcm_48k.len().min(pcm.len());
|
||||||
|
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
|
||||||
|
Ok(out_len)
|
||||||
|
} else {
|
||||||
|
self.opus.decode(encoded, pcm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> {
|
||||||
|
if is_codec2(self.active) {
|
||||||
|
let c2_samples = self.codec2_frame_samples();
|
||||||
|
let mut buf_8k = vec![0i16; c2_samples];
|
||||||
|
let n = self.codec2.decode_lost(&mut buf_8k)?;
|
||||||
|
let pcm_48k = resample::resample_8k_to_48k(&buf_8k[..n]);
|
||||||
|
let out_len = pcm_48k.len().min(pcm.len());
|
||||||
|
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
|
||||||
|
Ok(out_len)
|
||||||
|
} else {
|
||||||
|
self.opus.decode_lost(pcm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_id(&self) -> CodecId {
|
||||||
|
self.active
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||||
|
let prev = self.active;
|
||||||
|
self.active = profile.codec;
|
||||||
|
|
||||||
|
if is_codec2(profile.codec) {
|
||||||
|
debug!(from = ?prev, to = ?profile.codec, "adaptive decoder → Codec2");
|
||||||
|
self.codec2.set_profile(profile)
|
||||||
|
} else {
|
||||||
|
debug!(from = ?prev, to = ?profile.codec, "adaptive decoder → Opus");
|
||||||
|
self.opus.set_profile(profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdaptiveDecoder {
|
||||||
|
/// Number of 8 kHz samples expected for the current Codec2 frame.
|
||||||
|
fn codec2_frame_samples(&self) -> usize {
|
||||||
|
self.codec2.frame_samples()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoder_starts_with_correct_codec() {
|
||||||
|
let enc = AdaptiveEncoder::new(QualityProfile::GOOD).unwrap();
|
||||||
|
assert_eq!(enc.codec_id(), CodecId::Opus24k);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoder_starts_with_correct_codec() {
|
||||||
|
let dec = AdaptiveDecoder::new(QualityProfile::GOOD).unwrap();
|
||||||
|
assert_eq!(dec.codec_id(), CodecId::Opus24k);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoder_switches_opus_to_codec2() {
|
||||||
|
let mut enc = AdaptiveEncoder::new(QualityProfile::GOOD).unwrap();
|
||||||
|
assert_eq!(enc.codec_id(), CodecId::Opus24k);
|
||||||
|
|
||||||
|
enc.set_profile(QualityProfile::CATASTROPHIC).unwrap();
|
||||||
|
assert_eq!(enc.codec_id(), CodecId::Codec2_1200);
|
||||||
|
|
||||||
|
// Max frame bytes should reflect Codec2 now.
|
||||||
|
assert!(enc.max_frame_bytes() <= 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoder_switches_codec2_to_opus() {
|
||||||
|
let mut enc = AdaptiveEncoder::new(QualityProfile::CATASTROPHIC).unwrap();
|
||||||
|
assert_eq!(enc.codec_id(), CodecId::Codec2_1200);
|
||||||
|
|
||||||
|
enc.set_profile(QualityProfile::GOOD).unwrap();
|
||||||
|
assert_eq!(enc.codec_id(), CodecId::Opus24k);
|
||||||
|
assert!(enc.max_frame_bytes() > 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoder_switches_opus_to_codec2() {
|
||||||
|
let mut dec = AdaptiveDecoder::new(QualityProfile::GOOD).unwrap();
|
||||||
|
assert_eq!(dec.codec_id(), CodecId::Opus24k);
|
||||||
|
|
||||||
|
dec.set_profile(QualityProfile::CATASTROPHIC).unwrap();
|
||||||
|
assert_eq!(dec.codec_id(), CodecId::Codec2_1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoder_codec2_plc_produces_48k_silence() {
|
||||||
|
let mut dec = AdaptiveDecoder::new(QualityProfile::CATASTROPHIC).unwrap();
|
||||||
|
// Codec2 1200 @ 40ms → 320 samples at 8kHz → 1920 at 48kHz
|
||||||
|
let mut pcm = vec![0i16; 1920];
|
||||||
|
let n = dec.decode_lost(&mut pcm).unwrap();
|
||||||
|
assert_eq!(n, 1920);
|
||||||
|
// PLC from Codec2 stub is silence, upsampled silence is still silence.
|
||||||
|
assert!(pcm.iter().all(|&s| s == 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoder_opus_encode_works_after_switch() {
|
||||||
|
// Start on Codec2, switch to Opus, and encode a real frame.
|
||||||
|
let mut enc = AdaptiveEncoder::new(QualityProfile::CATASTROPHIC).unwrap();
|
||||||
|
enc.set_profile(QualityProfile::GOOD).unwrap();
|
||||||
|
|
||||||
|
// 20ms at 48kHz = 960 samples
|
||||||
|
let pcm = vec![0i16; 960];
|
||||||
|
let mut out = vec![0u8; 512];
|
||||||
|
let n = enc.encode(&pcm, &mut out).unwrap();
|
||||||
|
assert!(n > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoder_roundtrip_opus() {
|
||||||
|
let mut enc = AdaptiveEncoder::new(QualityProfile::GOOD).unwrap();
|
||||||
|
let mut dec = AdaptiveDecoder::new(QualityProfile::GOOD).unwrap();
|
||||||
|
|
||||||
|
let pcm_in = vec![0i16; 960]; // 20ms silence
|
||||||
|
let mut encoded = vec![0u8; 512];
|
||||||
|
let enc_bytes = enc.encode(&pcm_in, &mut encoded).unwrap();
|
||||||
|
assert!(enc_bytes > 0);
|
||||||
|
|
||||||
|
let mut pcm_out = vec![0i16; 960];
|
||||||
|
let dec_samples = dec.decode(&encoded[..enc_bytes], &mut pcm_out).unwrap();
|
||||||
|
assert_eq!(dec_samples, 960);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
crates/wzp-codec/src/codec2_dec.rs
Normal file
67
crates/wzp-codec/src/codec2_dec.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
//! Codec2 decoder — stub implementation.
|
||||||
|
//!
|
||||||
|
//! Codec2 operates at 8 kHz mono. Resampling back to 48 kHz is handled
|
||||||
|
//! externally (see `resample.rs` and `AdaptiveCodec`).
|
||||||
|
//!
|
||||||
|
//! This is a stub that returns an error on decode. When `codec2-sys`
|
||||||
|
//! is linked, replace the body of `decode()` with actual FFI calls.
|
||||||
|
|
||||||
|
use wzp_proto::{AudioDecoder, CodecError, CodecId, QualityProfile};
|
||||||
|
|
||||||
|
/// Stub Codec2 decoder implementing `AudioDecoder`.
|
||||||
|
///
|
||||||
|
/// Currently returns `CodecError::DecodeFailed` for decode operations.
|
||||||
|
/// PLC fills output with silence (zeros).
|
||||||
|
pub struct Codec2Decoder {
|
||||||
|
codec_id: CodecId,
|
||||||
|
frame_duration_ms: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Codec2Decoder {
|
||||||
|
/// Create a new stub Codec2 decoder.
|
||||||
|
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
|
||||||
|
Ok(Self {
|
||||||
|
codec_id: profile.codec,
|
||||||
|
frame_duration_ms: profile.frame_duration_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expected number of 8 kHz PCM output samples per frame.
|
||||||
|
pub fn frame_samples(&self) -> usize {
|
||||||
|
(8_000 * self.frame_duration_ms as usize) / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioDecoder for Codec2Decoder {
|
||||||
|
fn decode(&mut self, _encoded: &[u8], _pcm: &mut [i16]) -> Result<usize, CodecError> {
|
||||||
|
Err(CodecError::DecodeFailed(
|
||||||
|
"codec2-sys not yet linked".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> {
|
||||||
|
let samples = self.frame_samples();
|
||||||
|
let n = samples.min(pcm.len());
|
||||||
|
// Fill with silence as basic PLC
|
||||||
|
pcm[..n].fill(0);
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_id(&self) -> CodecId {
|
||||||
|
self.codec_id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||||
|
match profile.codec {
|
||||||
|
CodecId::Codec2_3200 | CodecId::Codec2_1200 => {
|
||||||
|
self.codec_id = profile.codec;
|
||||||
|
self.frame_duration_ms = profile.frame_duration_ms;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
other => Err(CodecError::UnsupportedTransition {
|
||||||
|
from: self.codec_id,
|
||||||
|
to: other,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
crates/wzp-codec/src/codec2_enc.rs
Normal file
66
crates/wzp-codec/src/codec2_enc.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
//! Codec2 encoder — stub implementation.
|
||||||
|
//!
|
||||||
|
//! Codec2 operates at 8 kHz mono. Resampling from 48 kHz is handled
|
||||||
|
//! externally (see `resample.rs` and `AdaptiveCodec`).
|
||||||
|
//!
|
||||||
|
//! This is a stub that returns an error on encode. When `codec2-sys`
|
||||||
|
//! is linked, replace the body of `encode()` with actual FFI calls.
|
||||||
|
|
||||||
|
use wzp_proto::{AudioEncoder, CodecError, CodecId, QualityProfile};
|
||||||
|
|
||||||
|
/// Stub Codec2 encoder implementing `AudioEncoder`.
|
||||||
|
///
|
||||||
|
/// Currently returns `CodecError::EncodeFailed` for all encode operations.
|
||||||
|
/// The structure is ready for drop-in replacement once `codec2-sys` is available.
|
||||||
|
pub struct Codec2Encoder {
|
||||||
|
codec_id: CodecId,
|
||||||
|
frame_duration_ms: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Codec2Encoder {
|
||||||
|
/// Create a new stub Codec2 encoder.
|
||||||
|
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
|
||||||
|
Ok(Self {
|
||||||
|
codec_id: profile.codec,
|
||||||
|
frame_duration_ms: profile.frame_duration_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expected number of 8 kHz PCM samples per frame.
|
||||||
|
pub fn frame_samples(&self) -> usize {
|
||||||
|
(8_000 * self.frame_duration_ms as usize) / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioEncoder for Codec2Encoder {
|
||||||
|
fn encode(&mut self, _pcm: &[i16], _out: &mut [u8]) -> Result<usize, CodecError> {
|
||||||
|
Err(CodecError::EncodeFailed(
|
||||||
|
"codec2-sys not yet linked".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_id(&self) -> CodecId {
|
||||||
|
self.codec_id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||||
|
match profile.codec {
|
||||||
|
CodecId::Codec2_3200 | CodecId::Codec2_1200 => {
|
||||||
|
self.codec_id = profile.codec;
|
||||||
|
self.frame_duration_ms = profile.frame_duration_ms;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
other => Err(CodecError::UnsupportedTransition {
|
||||||
|
from: self.codec_id,
|
||||||
|
to: other,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn max_frame_bytes(&self) -> usize {
|
||||||
|
// Codec2 3200bps @ 20ms = 64 bits = 8 bytes
|
||||||
|
// Codec2 1200bps @ 40ms = 48 bits = 6 bytes
|
||||||
|
// Allow generous headroom.
|
||||||
|
16
|
||||||
|
}
|
||||||
|
}
|
||||||
42
crates/wzp-codec/src/lib.rs
Normal file
42
crates/wzp-codec/src/lib.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
//! WarzonePhone Codec Layer
|
||||||
|
//!
|
||||||
|
//! Provides audio encoding/decoding with adaptive codec switching:
|
||||||
|
//! - Opus (24kbps / 16kbps / 6kbps) for normal to degraded conditions
|
||||||
|
//! - Codec2 (3200bps / 1200bps) via C bindings for catastrophic conditions
|
||||||
|
//!
|
||||||
|
//! ## Usage
|
||||||
|
//!
|
||||||
|
//! Use the factory functions [`create_encoder`] and [`create_decoder`] to get
|
||||||
|
//! trait-object encoders/decoders that handle adaptive switching internally.
|
||||||
|
|
||||||
|
pub mod adaptive;
|
||||||
|
pub mod codec2_dec;
|
||||||
|
pub mod codec2_enc;
|
||||||
|
pub mod opus_dec;
|
||||||
|
pub mod opus_enc;
|
||||||
|
pub mod resample;
|
||||||
|
|
||||||
|
pub use adaptive::{AdaptiveDecoder, AdaptiveEncoder};
|
||||||
|
pub use wzp_proto::{AudioDecoder, AudioEncoder, CodecId, QualityProfile};
|
||||||
|
|
||||||
|
/// Create an adaptive encoder starting at the given quality profile.
|
||||||
|
///
|
||||||
|
/// The returned encoder accepts 48 kHz mono PCM regardless of the active
|
||||||
|
/// codec; resampling is handled internally when Codec2 is selected.
|
||||||
|
pub fn create_encoder(profile: QualityProfile) -> Box<dyn AudioEncoder> {
|
||||||
|
Box::new(
|
||||||
|
AdaptiveEncoder::new(profile)
|
||||||
|
.expect("failed to create adaptive encoder"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an adaptive decoder starting at the given quality profile.
|
||||||
|
///
|
||||||
|
/// The returned decoder always produces 48 kHz mono PCM; upsampling from
|
||||||
|
/// Codec2's native 8 kHz is handled internally.
|
||||||
|
pub fn create_decoder(profile: QualityProfile) -> Box<dyn AudioDecoder> {
|
||||||
|
Box::new(
|
||||||
|
AdaptiveDecoder::new(profile)
|
||||||
|
.expect("failed to create adaptive decoder"),
|
||||||
|
)
|
||||||
|
}
|
||||||
93
crates/wzp-codec/src/opus_dec.rs
Normal file
93
crates/wzp-codec/src/opus_dec.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
//! Opus decoder wrapping the `audiopus` crate.
|
||||||
|
|
||||||
|
use audiopus::coder::Decoder;
|
||||||
|
use audiopus::{Channels, MutSignals, SampleRate};
|
||||||
|
use audiopus::packet::Packet;
|
||||||
|
use wzp_proto::{AudioDecoder, CodecError, CodecId, QualityProfile};
|
||||||
|
|
||||||
|
/// Opus decoder implementing `AudioDecoder`.
|
||||||
|
///
|
||||||
|
/// Operates at 48 kHz mono output.
|
||||||
|
pub struct OpusDecoder {
|
||||||
|
inner: Decoder,
|
||||||
|
codec_id: CodecId,
|
||||||
|
frame_duration_ms: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: Same reasoning as OpusEncoder — exclusive access via &mut self.
|
||||||
|
unsafe impl Sync for OpusDecoder {}
|
||||||
|
|
||||||
|
impl OpusDecoder {
|
||||||
|
/// Create a new Opus decoder for the given quality profile.
|
||||||
|
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
|
||||||
|
let decoder = Decoder::new(SampleRate::Hz48000, Channels::Mono)
|
||||||
|
.map_err(|e| CodecError::DecodeFailed(format!("opus decoder init: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
inner: decoder,
|
||||||
|
codec_id: profile.codec,
|
||||||
|
frame_duration_ms: profile.frame_duration_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expected number of output PCM samples per frame.
|
||||||
|
pub fn frame_samples(&self) -> usize {
|
||||||
|
(48_000 * self.frame_duration_ms as usize) / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioDecoder for OpusDecoder {
|
||||||
|
fn decode(&mut self, encoded: &[u8], pcm: &mut [i16]) -> Result<usize, CodecError> {
|
||||||
|
let expected = self.frame_samples();
|
||||||
|
if pcm.len() < expected {
|
||||||
|
return Err(CodecError::DecodeFailed(format!(
|
||||||
|
"output buffer too small: need {expected}, got {}",
|
||||||
|
pcm.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let packet = Packet::try_from(encoded)
|
||||||
|
.map_err(|e| CodecError::DecodeFailed(format!("invalid packet: {e}")))?;
|
||||||
|
let signals = MutSignals::try_from(pcm)
|
||||||
|
.map_err(|e| CodecError::DecodeFailed(format!("output signals: {e}")))?;
|
||||||
|
let n = self
|
||||||
|
.inner
|
||||||
|
.decode(Some(packet), signals, false)
|
||||||
|
.map_err(|e| CodecError::DecodeFailed(format!("opus decode: {e}")))?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> {
|
||||||
|
let expected = self.frame_samples();
|
||||||
|
if pcm.len() < expected {
|
||||||
|
return Err(CodecError::DecodeFailed(format!(
|
||||||
|
"output buffer too small: need {expected}, got {}",
|
||||||
|
pcm.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let signals = MutSignals::try_from(pcm)
|
||||||
|
.map_err(|e| CodecError::DecodeFailed(format!("output signals: {e}")))?;
|
||||||
|
let n = self
|
||||||
|
.inner
|
||||||
|
.decode(None, signals, false)
|
||||||
|
.map_err(|e| CodecError::DecodeFailed(format!("opus PLC: {e}")))?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_id(&self) -> CodecId {
|
||||||
|
self.codec_id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||||
|
match profile.codec {
|
||||||
|
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
|
||||||
|
self.codec_id = profile.codec;
|
||||||
|
self.frame_duration_ms = profile.frame_duration_ms;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
other => Err(CodecError::UnsupportedTransition {
|
||||||
|
from: self.codec_id,
|
||||||
|
to: other,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
crates/wzp-codec/src/opus_enc.rs
Normal file
109
crates/wzp-codec/src/opus_enc.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
//! Opus encoder wrapping the `audiopus` crate.
|
||||||
|
|
||||||
|
use audiopus::coder::Encoder;
|
||||||
|
use audiopus::{Application, Bitrate, Channels, SampleRate, Signal};
|
||||||
|
use tracing::debug;
|
||||||
|
use wzp_proto::{AudioEncoder, CodecError, CodecId, QualityProfile};
|
||||||
|
|
||||||
|
/// Opus encoder implementing `AudioEncoder`.
|
||||||
|
///
|
||||||
|
/// Operates at 48 kHz mono. Supports frame sizes of 20 ms (960 samples)
|
||||||
|
/// and 40 ms (1920 samples).
|
||||||
|
pub struct OpusEncoder {
|
||||||
|
inner: Encoder,
|
||||||
|
codec_id: CodecId,
|
||||||
|
frame_duration_ms: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: OpusEncoder is only used via `&mut self` methods. The inner
|
||||||
|
// audiopus Encoder contains a raw pointer that is !Sync, but we never
|
||||||
|
// share it across threads without exclusive access.
|
||||||
|
unsafe impl Sync for OpusEncoder {}
|
||||||
|
|
||||||
|
impl OpusEncoder {
|
||||||
|
/// Create a new Opus encoder for the given quality profile.
|
||||||
|
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
|
||||||
|
let encoder = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
|
||||||
|
.map_err(|e| CodecError::EncodeFailed(format!("opus encoder init: {e}")))?;
|
||||||
|
|
||||||
|
let mut enc = Self {
|
||||||
|
inner: encoder,
|
||||||
|
codec_id: profile.codec,
|
||||||
|
frame_duration_ms: profile.frame_duration_ms,
|
||||||
|
};
|
||||||
|
enc.apply_bitrate(profile.codec)?;
|
||||||
|
enc.set_inband_fec(true);
|
||||||
|
enc.set_dtx(true);
|
||||||
|
|
||||||
|
// Voice signal type hint for better compression
|
||||||
|
enc.inner
|
||||||
|
.set_signal(Signal::Voice)
|
||||||
|
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e}")))?;
|
||||||
|
|
||||||
|
Ok(enc)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_bitrate(&mut self, codec: CodecId) -> Result<(), CodecError> {
|
||||||
|
let bps = codec.bitrate_bps() as i32;
|
||||||
|
self.inner
|
||||||
|
.set_bitrate(Bitrate::BitsPerSecond(bps))
|
||||||
|
.map_err(|e| CodecError::EncodeFailed(format!("set bitrate: {e}")))?;
|
||||||
|
debug!(bitrate_bps = bps, "opus encoder bitrate set");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expected number of PCM samples per frame at current settings.
|
||||||
|
pub fn frame_samples(&self) -> usize {
|
||||||
|
(48_000 * self.frame_duration_ms as usize) / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioEncoder for OpusEncoder {
|
||||||
|
fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize, CodecError> {
|
||||||
|
let expected = self.frame_samples();
|
||||||
|
if pcm.len() != expected {
|
||||||
|
return Err(CodecError::EncodeFailed(format!(
|
||||||
|
"expected {expected} samples, got {}",
|
||||||
|
pcm.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let n = self
|
||||||
|
.inner
|
||||||
|
.encode(pcm, out)
|
||||||
|
.map_err(|e| CodecError::EncodeFailed(format!("opus encode: {e}")))?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_id(&self) -> CodecId {
|
||||||
|
self.codec_id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||||
|
match profile.codec {
|
||||||
|
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
|
||||||
|
self.codec_id = profile.codec;
|
||||||
|
self.frame_duration_ms = profile.frame_duration_ms;
|
||||||
|
self.apply_bitrate(profile.codec)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
other => Err(CodecError::UnsupportedTransition {
|
||||||
|
from: self.codec_id,
|
||||||
|
to: other,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn max_frame_bytes(&self) -> usize {
|
||||||
|
// Opus max packet for mono voice: ~500 bytes is generous.
|
||||||
|
// For 40ms at 24kbps: ~120 bytes typical, but we allow headroom.
|
||||||
|
512
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_inband_fec(&mut self, enabled: bool) {
|
||||||
|
let _ = self.inner.set_inband_fec(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_dtx(&mut self, enabled: bool) {
|
||||||
|
let _ = self.inner.set_dtx(enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
crates/wzp-codec/src/resample.rs
Normal file
82
crates/wzp-codec/src/resample.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
//! Simple linear resampler for 48 kHz <-> 8 kHz conversion.
|
||||||
|
//!
|
||||||
|
//! These are basic implementations suitable for voice. For higher quality,
|
||||||
|
//! replace with the `rubato` crate later.
|
||||||
|
|
||||||
|
/// Downsample from 48 kHz to 8 kHz (6:1 decimation with averaging).
|
||||||
|
///
|
||||||
|
/// Each output sample is the average of 6 consecutive input samples,
|
||||||
|
/// providing basic anti-aliasing via a box filter.
|
||||||
|
pub fn resample_48k_to_8k(input: &[i16]) -> Vec<i16> {
|
||||||
|
const RATIO: usize = 6;
|
||||||
|
let out_len = input.len() / RATIO;
|
||||||
|
let mut output = Vec::with_capacity(out_len);
|
||||||
|
|
||||||
|
for chunk in input.chunks_exact(RATIO) {
|
||||||
|
let sum: i32 = chunk.iter().map(|&s| s as i32).sum();
|
||||||
|
output.push((sum / RATIO as i32) as i16);
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upsample from 8 kHz to 48 kHz (1:6 interpolation with linear interp).
|
||||||
|
///
|
||||||
|
/// Linearly interpolates between each pair of input samples to produce
|
||||||
|
/// 6 output samples per input sample.
|
||||||
|
pub fn resample_8k_to_48k(input: &[i16]) -> Vec<i16> {
|
||||||
|
const RATIO: usize = 6;
|
||||||
|
if input.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let out_len = input.len() * RATIO;
|
||||||
|
let mut output = Vec::with_capacity(out_len);
|
||||||
|
|
||||||
|
for i in 0..input.len() {
|
||||||
|
let current = input[i] as i32;
|
||||||
|
let next = if i + 1 < input.len() {
|
||||||
|
input[i + 1] as i32
|
||||||
|
} else {
|
||||||
|
current // hold last sample
|
||||||
|
};
|
||||||
|
|
||||||
|
for j in 0..RATIO {
|
||||||
|
let interp = current + (next - current) * j as i32 / RATIO as i32;
|
||||||
|
output.push(interp as i16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_length() {
|
||||||
|
// 960 samples at 48kHz (20ms) -> 160 samples at 8kHz -> 960 samples at 48kHz
|
||||||
|
let input_48k = vec![0i16; 960];
|
||||||
|
let down = resample_48k_to_8k(&input_48k);
|
||||||
|
assert_eq!(down.len(), 160);
|
||||||
|
let up = resample_8k_to_48k(&down);
|
||||||
|
assert_eq!(up.len(), 960);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dc_signal_preserved() {
|
||||||
|
// A constant signal should survive resampling
|
||||||
|
let input = vec![1000i16; 960];
|
||||||
|
let down = resample_48k_to_8k(&input);
|
||||||
|
assert!(down.iter().all(|&s| s == 1000));
|
||||||
|
let up = resample_8k_to_48k(&down);
|
||||||
|
assert!(up.iter().all(|&s| s == 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_input() {
|
||||||
|
assert!(resample_48k_to_8k(&[]).is_empty());
|
||||||
|
assert!(resample_8k_to_48k(&[]).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
19
crates/wzp-crypto/Cargo.toml
Normal file
19
crates/wzp-crypto/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "wzp-crypto"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "WarzonePhone crypto layer — X25519 + ChaCha20-Poly1305, Warzone identity compatible"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wzp-proto = { workspace = true }
|
||||||
|
x25519-dalek = { workspace = true }
|
||||||
|
ed25519-dalek = { workspace = true }
|
||||||
|
chacha20poly1305 = { workspace = true }
|
||||||
|
hkdf = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
rand = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
204
crates/wzp-crypto/src/anti_replay.rs
Normal file
204
crates/wzp-crypto/src/anti_replay.rs
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
//! Sliding window replay protection.
|
||||||
|
//!
|
||||||
|
//! Tracks seen sequence numbers using a bitmap. Window size is 1024 packets.
|
||||||
|
//! Sequence numbers that are too old (more than WINDOW_SIZE behind the highest
|
||||||
|
//! seen) are rejected.
|
||||||
|
|
||||||
|
use wzp_proto::CryptoError;
|
||||||
|
|
||||||
|
/// Window size in packets.
|
||||||
|
const WINDOW_SIZE: u16 = 1024;
|
||||||
|
|
||||||
|
/// Sliding window anti-replay detector.
|
||||||
|
///
|
||||||
|
/// Uses a bitmap to track which sequence numbers have been seen within
|
||||||
|
/// the current window. Handles u16 wrapping correctly.
|
||||||
|
pub struct AntiReplayWindow {
|
||||||
|
/// Highest sequence number seen so far.
|
||||||
|
highest: u16,
|
||||||
|
/// Bitmap of seen packets. Bit i corresponds to (highest - i).
|
||||||
|
bitmap: Vec<u64>,
|
||||||
|
/// Whether any packet has been received yet.
|
||||||
|
initialized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AntiReplayWindow {
|
||||||
|
/// Number of u64 words needed for the bitmap.
|
||||||
|
const BITMAP_WORDS: usize = (WINDOW_SIZE as usize + 63) / 64;
|
||||||
|
|
||||||
|
/// Create a new anti-replay window.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
highest: 0,
|
||||||
|
bitmap: vec![0u64; Self::BITMAP_WORDS],
|
||||||
|
initialized: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a sequence number is valid (not a replay, not too old).
|
||||||
|
/// If valid, marks it as seen.
|
||||||
|
pub fn check_and_update(&mut self, seq: u16) -> Result<(), CryptoError> {
|
||||||
|
if !self.initialized {
|
||||||
|
self.initialized = true;
|
||||||
|
self.highest = seq;
|
||||||
|
self.set_bit(0);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let diff = seq.wrapping_sub(self.highest);
|
||||||
|
|
||||||
|
if diff == 0 {
|
||||||
|
// Duplicate of highest
|
||||||
|
return Err(CryptoError::ReplayDetected { seq });
|
||||||
|
}
|
||||||
|
|
||||||
|
if diff < 0x8000 {
|
||||||
|
// seq is ahead of highest (wrapping-aware: diff in [1, 0x7FFF])
|
||||||
|
let shift = diff as usize;
|
||||||
|
self.advance_window(shift);
|
||||||
|
self.highest = seq;
|
||||||
|
self.set_bit(0);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
// seq is behind highest (wrapping-aware: diff in [0x8000, 0xFFFF])
|
||||||
|
let behind = self.highest.wrapping_sub(seq) as usize;
|
||||||
|
if behind >= WINDOW_SIZE as usize {
|
||||||
|
return Err(CryptoError::ReplayDetected { seq });
|
||||||
|
}
|
||||||
|
if self.get_bit(behind) {
|
||||||
|
return Err(CryptoError::ReplayDetected { seq });
|
||||||
|
}
|
||||||
|
self.set_bit(behind);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance the window by `shift` positions (shift left = new bits at position 0).
|
||||||
|
fn advance_window(&mut self, shift: usize) {
|
||||||
|
if shift >= WINDOW_SIZE as usize {
|
||||||
|
for word in &mut self.bitmap {
|
||||||
|
*word = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to shift the entire bitmap right by `shift` bits.
|
||||||
|
// Bit 0 of word 0 is the most recent. Shifting right means
|
||||||
|
// old entries move to higher bit positions.
|
||||||
|
let word_shift = shift / 64;
|
||||||
|
let bit_shift = shift % 64;
|
||||||
|
|
||||||
|
// Move words
|
||||||
|
let len = self.bitmap.len();
|
||||||
|
for i in (0..len).rev() {
|
||||||
|
let mut val = 0u64;
|
||||||
|
if i >= word_shift {
|
||||||
|
val = self.bitmap[i - word_shift] << bit_shift;
|
||||||
|
if bit_shift > 0 && i > word_shift {
|
||||||
|
val |= self.bitmap[i - word_shift - 1] >> (64 - bit_shift);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.bitmap[i] = val;
|
||||||
|
}
|
||||||
|
// Clear the lower words that shifted in
|
||||||
|
for word in &mut self.bitmap[..word_shift.min(len)] {
|
||||||
|
*word = 0;
|
||||||
|
}
|
||||||
|
// Clear the lower bits of the first non-shifted word
|
||||||
|
if word_shift < len && bit_shift > 0 {
|
||||||
|
self.bitmap[word_shift] &= !((1u64 << bit_shift) - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_bit(&mut self, offset: usize) {
|
||||||
|
let word = offset / 64;
|
||||||
|
let bit = offset % 64;
|
||||||
|
if word < self.bitmap.len() {
|
||||||
|
self.bitmap[word] |= 1u64 << bit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_bit(&self, offset: usize) -> bool {
|
||||||
|
let word = offset / 64;
|
||||||
|
let bit = offset % 64;
|
||||||
|
if word < self.bitmap.len() {
|
||||||
|
(self.bitmap[word] >> bit) & 1 == 1
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AntiReplayWindow {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_packet_accepted() {
|
||||||
|
let mut w = AntiReplayWindow::new();
|
||||||
|
assert!(w.check_and_update(0).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_rejected() {
|
||||||
|
let mut w = AntiReplayWindow::new();
|
||||||
|
assert!(w.check_and_update(100).is_ok());
|
||||||
|
assert!(w.check_and_update(100).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sequential_accepted() {
|
||||||
|
let mut w = AntiReplayWindow::new();
|
||||||
|
for i in 0..200 {
|
||||||
|
assert!(w.check_and_update(i).is_ok(), "seq {} should be accepted", i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn out_of_order_within_window() {
|
||||||
|
let mut w = AntiReplayWindow::new();
|
||||||
|
assert!(w.check_and_update(100).is_ok());
|
||||||
|
assert!(w.check_and_update(95).is_ok());
|
||||||
|
assert!(w.check_and_update(98).is_ok());
|
||||||
|
assert!(w.check_and_update(102).is_ok());
|
||||||
|
assert!(w.check_and_update(99).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn old_packet_rejected() {
|
||||||
|
let mut w = AntiReplayWindow::new();
|
||||||
|
assert!(w.check_and_update(0).is_ok());
|
||||||
|
// Advance well past the window
|
||||||
|
assert!(w.check_and_update(2000).is_ok());
|
||||||
|
// seq 0 is now too old
|
||||||
|
assert!(w.check_and_update(0).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrapping_works() {
|
||||||
|
let mut w = AntiReplayWindow::new();
|
||||||
|
assert!(w.check_and_update(65530).is_ok());
|
||||||
|
assert!(w.check_and_update(65535).is_ok());
|
||||||
|
assert!(w.check_and_update(0).is_ok()); // wrapped
|
||||||
|
assert!(w.check_and_update(1).is_ok());
|
||||||
|
assert!(w.check_and_update(65535).is_err()); // duplicate
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn within_window_boundary() {
|
||||||
|
let mut w = AntiReplayWindow::new();
|
||||||
|
assert!(w.check_and_update(1023).is_ok());
|
||||||
|
// 1023 - 0 = 1023, exactly at window boundary
|
||||||
|
assert!(w.check_and_update(0).is_ok());
|
||||||
|
// But 1024 behind would be out
|
||||||
|
assert!(w.check_and_update(1024).is_ok());
|
||||||
|
// Now 0 is 1024 behind 1024, which is at the boundary limit
|
||||||
|
assert!(w.check_and_update(0).is_err()); // already seen or too old
|
||||||
|
}
|
||||||
|
}
|
||||||
214
crates/wzp-crypto/src/handshake.rs
Normal file
214
crates/wzp-crypto/src/handshake.rs
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
//! Warzone identity key exchange.
|
||||||
|
//!
|
||||||
|
//! Implements the `KeyExchange` trait from `wzp-proto`:
|
||||||
|
//! - Identity: 32-byte seed -> HKDF -> Ed25519 (signing) + X25519 (encryption)
|
||||||
|
//! - Fingerprint: SHA-256(Ed25519 pub)[:16]
|
||||||
|
//! - Per-call: ephemeral X25519 -> ChaCha20-Poly1305 session
|
||||||
|
|
||||||
|
use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
|
||||||
|
use hkdf::Hkdf;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
|
||||||
|
use wzp_proto::{CryptoError, CryptoSession, KeyExchange};
|
||||||
|
|
||||||
|
use crate::session::ChaChaSession;
|
||||||
|
|
||||||
|
/// Warzone-compatible key exchange implementation.
|
||||||
|
pub struct WarzoneKeyExchange {
|
||||||
|
/// Ed25519 signing key (identity).
|
||||||
|
signing_key: SigningKey,
|
||||||
|
/// X25519 static secret (derived from seed, used for identity encryption).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
x25519_static_secret: StaticSecret,
|
||||||
|
/// X25519 static public key.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
x25519_static_public: X25519PublicKey,
|
||||||
|
/// Ephemeral X25519 secret for the current call (set by generate_ephemeral).
|
||||||
|
ephemeral_secret: Option<StaticSecret>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyExchange for WarzoneKeyExchange {
|
||||||
|
fn from_identity_seed(seed: &[u8; 32]) -> Self {
|
||||||
|
// Derive Ed25519 signing key via HKDF
|
||||||
|
let hk = Hkdf::<Sha256>::new(None, seed);
|
||||||
|
let mut ed25519_bytes = [0u8; 32];
|
||||||
|
hk.expand(b"warzone-ed25519-identity", &mut ed25519_bytes)
|
||||||
|
.expect("HKDF expand for Ed25519 should not fail");
|
||||||
|
let signing_key = SigningKey::from_bytes(&ed25519_bytes);
|
||||||
|
|
||||||
|
// Derive X25519 static key via HKDF
|
||||||
|
let mut x25519_bytes = [0u8; 32];
|
||||||
|
hk.expand(b"warzone-x25519-identity", &mut x25519_bytes)
|
||||||
|
.expect("HKDF expand for X25519 should not fail");
|
||||||
|
let x25519_static_secret = StaticSecret::from(x25519_bytes);
|
||||||
|
let x25519_static_public = X25519PublicKey::from(&x25519_static_secret);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
signing_key,
|
||||||
|
x25519_static_secret,
|
||||||
|
x25519_static_public,
|
||||||
|
ephemeral_secret: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_ephemeral(&mut self) -> [u8; 32] {
|
||||||
|
let secret = StaticSecret::random_from_rng(OsRng);
|
||||||
|
let public = X25519PublicKey::from(&secret);
|
||||||
|
self.ephemeral_secret = Some(secret);
|
||||||
|
public.to_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identity_public_key(&self) -> [u8; 32] {
|
||||||
|
self.signing_key.verifying_key().to_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fingerprint(&self) -> [u8; 16] {
|
||||||
|
let pub_bytes = self.identity_public_key();
|
||||||
|
let hash = Sha256::digest(pub_bytes);
|
||||||
|
let mut fp = [0u8; 16];
|
||||||
|
fp.copy_from_slice(&hash[..16]);
|
||||||
|
fp
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign(&self, data: &[u8]) -> Vec<u8> {
|
||||||
|
let sig = self.signing_key.sign(data);
|
||||||
|
sig.to_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify(peer_identity_pub: &[u8; 32], data: &[u8], signature: &[u8]) -> bool {
|
||||||
|
let Ok(verifying_key) = VerifyingKey::from_bytes(peer_identity_pub) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(sig_bytes) = <[u8; 64]>::try_from(signature) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes);
|
||||||
|
verifying_key.verify(data, &sig).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_session(
|
||||||
|
&self,
|
||||||
|
peer_ephemeral_pub: &[u8; 32],
|
||||||
|
) -> Result<Box<dyn CryptoSession>, CryptoError> {
|
||||||
|
let secret = self
|
||||||
|
.ephemeral_secret
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
CryptoError::Internal("no ephemeral key generated; call generate_ephemeral first".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let peer_public = X25519PublicKey::from(*peer_ephemeral_pub);
|
||||||
|
// Use diffie_hellman with a clone of the StaticSecret
|
||||||
|
let secret_bytes: [u8; 32] = secret.to_bytes();
|
||||||
|
let secret_clone = StaticSecret::from(secret_bytes);
|
||||||
|
let shared_secret = secret_clone.diffie_hellman(&peer_public);
|
||||||
|
|
||||||
|
// Expand shared secret via HKDF
|
||||||
|
let hk = Hkdf::<Sha256>::new(None, shared_secret.as_bytes());
|
||||||
|
let mut session_key = [0u8; 32];
|
||||||
|
hk.expand(b"warzone-session-key", &mut session_key)
|
||||||
|
.expect("HKDF expand for session key should not fail");
|
||||||
|
|
||||||
|
Ok(Box::new(ChaChaSession::new(session_key)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deterministic_identity_from_seed() {
|
||||||
|
let seed = [0x42u8; 32];
|
||||||
|
let kx1 = WarzoneKeyExchange::from_identity_seed(&seed);
|
||||||
|
let kx2 = WarzoneKeyExchange::from_identity_seed(&seed);
|
||||||
|
assert_eq!(kx1.identity_public_key(), kx2.identity_public_key());
|
||||||
|
assert_eq!(kx1.fingerprint(), kx2.fingerprint());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_seeds_different_keys() {
|
||||||
|
let kx1 = WarzoneKeyExchange::from_identity_seed(&[0x01; 32]);
|
||||||
|
let kx2 = WarzoneKeyExchange::from_identity_seed(&[0x02; 32]);
|
||||||
|
assert_ne!(kx1.identity_public_key(), kx2.identity_public_key());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fingerprint_is_16_bytes_of_sha256() {
|
||||||
|
let seed = [0x99u8; 32];
|
||||||
|
let kx = WarzoneKeyExchange::from_identity_seed(&seed);
|
||||||
|
let fp = kx.fingerprint();
|
||||||
|
assert_eq!(fp.len(), 16);
|
||||||
|
|
||||||
|
// Verify manually
|
||||||
|
let pub_key = kx.identity_public_key();
|
||||||
|
let hash = Sha256::digest(pub_key);
|
||||||
|
assert_eq!(&fp[..], &hash[..16]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sign_and_verify() {
|
||||||
|
let seed = [0xAA; 32];
|
||||||
|
let kx = WarzoneKeyExchange::from_identity_seed(&seed);
|
||||||
|
let data = b"hello warzone";
|
||||||
|
let sig = kx.sign(data);
|
||||||
|
assert!(WarzoneKeyExchange::verify(
|
||||||
|
&kx.identity_public_key(),
|
||||||
|
data,
|
||||||
|
&sig
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_wrong_data_fails() {
|
||||||
|
let seed = [0xAA; 32];
|
||||||
|
let kx = WarzoneKeyExchange::from_identity_seed(&seed);
|
||||||
|
let sig = kx.sign(b"correct data");
|
||||||
|
assert!(!WarzoneKeyExchange::verify(
|
||||||
|
&kx.identity_public_key(),
|
||||||
|
b"wrong data",
|
||||||
|
&sig
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_wrong_key_fails() {
|
||||||
|
let kx1 = WarzoneKeyExchange::from_identity_seed(&[0x01; 32]);
|
||||||
|
let kx2 = WarzoneKeyExchange::from_identity_seed(&[0x02; 32]);
|
||||||
|
let sig = kx1.sign(b"data");
|
||||||
|
assert!(!WarzoneKeyExchange::verify(
|
||||||
|
&kx2.identity_public_key(),
|
||||||
|
b"data",
|
||||||
|
&sig
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_handshake_alice_bob_same_session_key() {
|
||||||
|
let mut alice = WarzoneKeyExchange::from_identity_seed(&[0xAA; 32]);
|
||||||
|
let mut bob = WarzoneKeyExchange::from_identity_seed(&[0xBB; 32]);
|
||||||
|
|
||||||
|
let alice_eph_pub = alice.generate_ephemeral();
|
||||||
|
let bob_eph_pub = bob.generate_ephemeral();
|
||||||
|
|
||||||
|
let mut alice_session = alice.derive_session(&bob_eph_pub).unwrap();
|
||||||
|
let mut bob_session = bob.derive_session(&alice_eph_pub).unwrap();
|
||||||
|
|
||||||
|
// Verify they can communicate: Alice encrypts, Bob decrypts
|
||||||
|
let header = b"call-header";
|
||||||
|
let plaintext = b"hello from alice";
|
||||||
|
|
||||||
|
let mut ciphertext = Vec::new();
|
||||||
|
alice_session
|
||||||
|
.encrypt(header, plaintext, &mut ciphertext)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut decrypted = Vec::new();
|
||||||
|
bob_session
|
||||||
|
.decrypt(header, &ciphertext, &mut decrypted)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(&decrypted, plaintext);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
crates/wzp-crypto/src/lib.rs
Normal file
23
crates/wzp-crypto/src/lib.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
//! WarzonePhone Crypto Layer
|
||||||
|
//!
|
||||||
|
//! Implements the cryptographic primitives compatible with the Warzone messenger identity model:
|
||||||
|
//! - Identity: 32-byte seed -> HKDF -> Ed25519 (signing) + X25519 (encryption)
|
||||||
|
//! - Fingerprint: SHA-256(Ed25519 pub)[:16]
|
||||||
|
//! - Per-call: Ephemeral X25519 key exchange -> ChaCha20-Poly1305 session
|
||||||
|
//! - Nonce: Derived from session_id + seq + direction (not transmitted)
|
||||||
|
//! - Rekeying: Periodic ephemeral exchange with HKDF mixing for forward secrecy
|
||||||
|
|
||||||
|
pub mod anti_replay;
|
||||||
|
pub mod handshake;
|
||||||
|
pub mod nonce;
|
||||||
|
pub mod rekey;
|
||||||
|
pub mod session;
|
||||||
|
|
||||||
|
pub use anti_replay::AntiReplayWindow;
|
||||||
|
pub use handshake::WarzoneKeyExchange;
|
||||||
|
pub use nonce::{build_nonce, Direction};
|
||||||
|
pub use rekey::RekeyManager;
|
||||||
|
pub use session::ChaChaSession;
|
||||||
|
|
||||||
|
// Re-export trait types from wzp-proto for convenience.
|
||||||
|
pub use wzp_proto::{CryptoError, CryptoSession, KeyExchange};
|
||||||
64
crates/wzp-crypto/src/nonce.rs
Normal file
64
crates/wzp-crypto/src/nonce.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
//! Nonce construction for ChaCha20-Poly1305.
|
||||||
|
//!
|
||||||
|
//! 12-byte nonce layout:
|
||||||
|
//! session_id[0..4] || sequence_number (u32 BE) || direction (1 byte) || padding (3 bytes zero)
|
||||||
|
|
||||||
|
/// Direction of packet flow, used in nonce construction.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Direction {
|
||||||
|
Send = 0,
|
||||||
|
Recv = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a 12-byte nonce from session_id, sequence number, and direction.
|
||||||
|
///
|
||||||
|
/// This deterministic construction allows both sides to derive the same nonce
|
||||||
|
/// without transmitting it, saving 12 bytes per packet.
|
||||||
|
pub fn build_nonce(session_id: &[u8; 4], seq: u32, direction: Direction) -> [u8; 12] {
|
||||||
|
let mut nonce = [0u8; 12];
|
||||||
|
nonce[0..4].copy_from_slice(session_id);
|
||||||
|
nonce[4..8].copy_from_slice(&seq.to_be_bytes());
|
||||||
|
nonce[8] = direction as u8;
|
||||||
|
// nonce[9..12] remain zero (padding)
|
||||||
|
nonce
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nonce_is_deterministic() {
|
||||||
|
let sid = [0xDE, 0xAD, 0xBE, 0xEF];
|
||||||
|
let n1 = build_nonce(&sid, 42, Direction::Send);
|
||||||
|
let n2 = build_nonce(&sid, 42, Direction::Send);
|
||||||
|
assert_eq!(n1, n2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nonce_differs_by_direction() {
|
||||||
|
let sid = [0x01, 0x02, 0x03, 0x04];
|
||||||
|
let send = build_nonce(&sid, 0, Direction::Send);
|
||||||
|
let recv = build_nonce(&sid, 0, Direction::Recv);
|
||||||
|
assert_ne!(send, recv);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nonce_differs_by_seq() {
|
||||||
|
let sid = [0x01, 0x02, 0x03, 0x04];
|
||||||
|
let n1 = build_nonce(&sid, 0, Direction::Send);
|
||||||
|
let n2 = build_nonce(&sid, 1, Direction::Send);
|
||||||
|
assert_ne!(n1, n2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nonce_layout_correct() {
|
||||||
|
let sid = [0xAA, 0xBB, 0xCC, 0xDD];
|
||||||
|
let seq: u32 = 0x00000100;
|
||||||
|
let nonce = build_nonce(&sid, seq, Direction::Recv);
|
||||||
|
assert_eq!(&nonce[0..4], &[0xAA, 0xBB, 0xCC, 0xDD]);
|
||||||
|
assert_eq!(&nonce[4..8], &[0x00, 0x00, 0x01, 0x00]);
|
||||||
|
assert_eq!(nonce[8], 1); // Recv
|
||||||
|
assert_eq!(&nonce[9..12], &[0, 0, 0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
132
crates/wzp-crypto/src/rekey.rs
Normal file
132
crates/wzp-crypto/src/rekey.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
//! Rekeying state machine for forward secrecy.
|
||||||
|
//!
|
||||||
|
//! Triggers rekeying every 2^16 packets. Uses HKDF to mix the old key
|
||||||
|
//! with the new DH result, then zeroizes the old key material.
|
||||||
|
|
||||||
|
use hkdf::Hkdf;
|
||||||
|
use sha2::Sha256;
|
||||||
|
use x25519_dalek::{PublicKey, StaticSecret};
|
||||||
|
|
||||||
|
/// Rekeying interval: every 2^16 packets.
|
||||||
|
const REKEY_INTERVAL: u64 = 1 << 16;
|
||||||
|
|
||||||
|
/// Manages rekeying decisions and key evolution.
|
||||||
|
pub struct RekeyManager {
|
||||||
|
/// Current symmetric key material (32 bytes).
|
||||||
|
current_key: [u8; 32],
|
||||||
|
/// Packet count at which last rekey occurred.
|
||||||
|
last_rekey_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RekeyManager {
|
||||||
|
/// Create a new `RekeyManager` with the initial session key.
|
||||||
|
pub fn new(initial_key: [u8; 32]) -> Self {
|
||||||
|
Self {
|
||||||
|
current_key: initial_key,
|
||||||
|
last_rekey_at: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether rekeying should occur based on packet count.
|
||||||
|
pub fn should_rekey(&self, packet_count: u64) -> bool {
|
||||||
|
packet_count.saturating_sub(self.last_rekey_at) >= REKEY_INTERVAL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform rekeying: mix old key + new DH shared secret via HKDF.
|
||||||
|
///
|
||||||
|
/// The old key is zeroized after the new key is derived.
|
||||||
|
/// Returns the new 32-byte symmetric key.
|
||||||
|
pub fn perform_rekey(
|
||||||
|
&mut self,
|
||||||
|
new_peer_pub: &[u8; 32],
|
||||||
|
our_new_secret: StaticSecret,
|
||||||
|
packet_count: u64,
|
||||||
|
) -> [u8; 32] {
|
||||||
|
let peer_public = PublicKey::from(*new_peer_pub);
|
||||||
|
let new_dh = our_new_secret.diffie_hellman(&peer_public);
|
||||||
|
|
||||||
|
// Mix old key (as salt) with new DH result (as IKM) via HKDF
|
||||||
|
let hk = Hkdf::<Sha256>::new(Some(&self.current_key), new_dh.as_bytes());
|
||||||
|
let mut new_key = [0u8; 32];
|
||||||
|
hk.expand(b"warzone-rekey", &mut new_key)
|
||||||
|
.expect("HKDF expand should not fail for 32 bytes");
|
||||||
|
|
||||||
|
// Zeroize old key for forward secrecy
|
||||||
|
self.current_key.fill(0);
|
||||||
|
|
||||||
|
// Install new key
|
||||||
|
self.current_key = new_key;
|
||||||
|
self.last_rekey_at = packet_count;
|
||||||
|
|
||||||
|
new_key
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a reference to the current key.
|
||||||
|
pub fn current_key(&self) -> &[u8; 32] {
|
||||||
|
&self.current_key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_rekey_at_interval() {
|
||||||
|
let mgr = RekeyManager::new([0xAA; 32]);
|
||||||
|
assert!(!mgr.should_rekey(0));
|
||||||
|
assert!(!mgr.should_rekey(65535));
|
||||||
|
assert!(mgr.should_rekey(65536));
|
||||||
|
assert!(mgr.should_rekey(100_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rekey_produces_different_key() {
|
||||||
|
let initial = [0xBB; 32];
|
||||||
|
let mut mgr = RekeyManager::new(initial);
|
||||||
|
|
||||||
|
let secret = StaticSecret::random_from_rng(OsRng);
|
||||||
|
let peer_secret = StaticSecret::random_from_rng(OsRng);
|
||||||
|
let peer_pub = PublicKey::from(&peer_secret).to_bytes();
|
||||||
|
|
||||||
|
let new_key = mgr.perform_rekey(&peer_pub, secret, 65536);
|
||||||
|
assert_ne!(new_key, initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn old_key_zeroized_after_rekey() {
|
||||||
|
let initial = [0xCC; 32];
|
||||||
|
let mut mgr = RekeyManager::new(initial);
|
||||||
|
|
||||||
|
let secret = StaticSecret::random_from_rng(OsRng);
|
||||||
|
let peer_secret = StaticSecret::random_from_rng(OsRng);
|
||||||
|
let peer_pub = PublicKey::from(&peer_secret).to_bytes();
|
||||||
|
|
||||||
|
// Save pointer to check zeroization
|
||||||
|
let _new_key = mgr.perform_rekey(&peer_pub, secret, 65536);
|
||||||
|
// The old key slot should now contain the new key, not the initial
|
||||||
|
assert_ne!(*mgr.current_key(), initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consistent_rekey_with_same_inputs() {
|
||||||
|
// Two managers with same initial key, same DH inputs, should get same result
|
||||||
|
let initial = [0xDD; 32];
|
||||||
|
let mut mgr1 = RekeyManager::new(initial);
|
||||||
|
let mut mgr2 = RekeyManager::new(initial);
|
||||||
|
|
||||||
|
// Use StaticSecret so we can clone the key bytes
|
||||||
|
let secret_bytes = [0x42u8; 32];
|
||||||
|
let secret1 = StaticSecret::from(secret_bytes);
|
||||||
|
let secret2 = StaticSecret::from(secret_bytes);
|
||||||
|
|
||||||
|
let peer_bytes = [0x77u8; 32];
|
||||||
|
let peer_secret = StaticSecret::from(peer_bytes);
|
||||||
|
let peer_pub = PublicKey::from(&peer_secret).to_bytes();
|
||||||
|
|
||||||
|
let k1 = mgr1.perform_rekey(&peer_pub, secret1, 65536);
|
||||||
|
let k2 = mgr2.perform_rekey(&peer_pub, secret2, 65536);
|
||||||
|
assert_eq!(k1, k2);
|
||||||
|
}
|
||||||
|
}
|
||||||
226
crates/wzp-crypto/src/session.rs
Normal file
226
crates/wzp-crypto/src/session.rs
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
//! ChaCha20-Poly1305 encryption session.
|
||||||
|
//!
|
||||||
|
//! Implements the `CryptoSession` trait for per-call media encryption.
|
||||||
|
//! Nonces are derived deterministically from session_id + sequence counter + direction.
|
||||||
|
|
||||||
|
use chacha20poly1305::aead::Aead;
|
||||||
|
use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce};
|
||||||
|
use x25519_dalek::{PublicKey, StaticSecret};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use wzp_proto::{CryptoError, CryptoSession};
|
||||||
|
|
||||||
|
use crate::nonce::{self, Direction};
|
||||||
|
use crate::rekey::RekeyManager;
|
||||||
|
|
||||||
|
/// Per-call symmetric encryption session using ChaCha20-Poly1305.
|
||||||
|
pub struct ChaChaSession {
|
||||||
|
/// AEAD cipher instance.
|
||||||
|
cipher: ChaCha20Poly1305,
|
||||||
|
/// Session ID (first 4 bytes of the derived key hash).
|
||||||
|
session_id: [u8; 4],
|
||||||
|
/// Send packet counter.
|
||||||
|
send_seq: u32,
|
||||||
|
/// Receive packet counter.
|
||||||
|
recv_seq: u32,
|
||||||
|
/// Rekeying state machine.
|
||||||
|
rekey_mgr: RekeyManager,
|
||||||
|
/// Pending ephemeral secret for rekey (stored until peer responds).
|
||||||
|
pending_rekey_secret: Option<StaticSecret>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChaChaSession {
|
||||||
|
/// Create a new session from a 32-byte shared secret.
|
||||||
|
pub fn new(shared_secret: [u8; 32]) -> Self {
|
||||||
|
use sha2::Digest;
|
||||||
|
let session_id_hash = sha2::Sha256::digest(&shared_secret);
|
||||||
|
let mut session_id = [0u8; 4];
|
||||||
|
session_id.copy_from_slice(&session_id_hash[..4]);
|
||||||
|
|
||||||
|
let cipher = ChaCha20Poly1305::new_from_slice(&shared_secret)
|
||||||
|
.expect("32-byte key is valid for ChaCha20Poly1305");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
cipher,
|
||||||
|
session_id,
|
||||||
|
send_seq: 0,
|
||||||
|
recv_seq: 0,
|
||||||
|
rekey_mgr: RekeyManager::new(shared_secret),
|
||||||
|
pending_rekey_secret: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install a new key (after rekeying).
|
||||||
|
fn install_key(&mut self, new_key: [u8; 32]) {
|
||||||
|
use sha2::Digest;
|
||||||
|
let session_id_hash = sha2::Sha256::digest(&new_key);
|
||||||
|
self.session_id.copy_from_slice(&session_id_hash[..4]);
|
||||||
|
self.cipher = ChaCha20Poly1305::new_from_slice(&new_key)
|
||||||
|
.expect("32-byte key is valid for ChaCha20Poly1305");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CryptoSession for ChaChaSession {
|
||||||
|
fn encrypt(
|
||||||
|
&mut self,
|
||||||
|
header_bytes: &[u8],
|
||||||
|
plaintext: &[u8],
|
||||||
|
out: &mut Vec<u8>,
|
||||||
|
) -> Result<(), CryptoError> {
|
||||||
|
let nonce_bytes = nonce::build_nonce(&self.session_id, self.send_seq, Direction::Send);
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
// Encrypt with AAD
|
||||||
|
use chacha20poly1305::aead::Payload;
|
||||||
|
let payload = Payload {
|
||||||
|
msg: plaintext,
|
||||||
|
aad: header_bytes,
|
||||||
|
};
|
||||||
|
|
||||||
|
let ciphertext = self
|
||||||
|
.cipher
|
||||||
|
.encrypt(nonce, payload)
|
||||||
|
.map_err(|_| CryptoError::Internal("encryption failed".into()))?;
|
||||||
|
|
||||||
|
out.extend_from_slice(&ciphertext);
|
||||||
|
self.send_seq = self.send_seq.wrapping_add(1);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrypt(
|
||||||
|
&mut self,
|
||||||
|
header_bytes: &[u8],
|
||||||
|
ciphertext: &[u8],
|
||||||
|
out: &mut Vec<u8>,
|
||||||
|
) -> Result<(), CryptoError> {
|
||||||
|
// Use Direction::Send to match the sender's nonce construction.
|
||||||
|
// The recv_seq counter tracks which packet from the peer we're decrypting.
|
||||||
|
let nonce_bytes = nonce::build_nonce(&self.session_id, self.recv_seq, Direction::Send);
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
use chacha20poly1305::aead::Payload;
|
||||||
|
let payload = Payload {
|
||||||
|
msg: ciphertext,
|
||||||
|
aad: header_bytes,
|
||||||
|
};
|
||||||
|
|
||||||
|
let plaintext = self
|
||||||
|
.cipher
|
||||||
|
.decrypt(nonce, payload)
|
||||||
|
.map_err(|_| CryptoError::DecryptionFailed)?;
|
||||||
|
|
||||||
|
out.extend_from_slice(&plaintext);
|
||||||
|
self.recv_seq = self.recv_seq.wrapping_add(1);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initiate_rekey(&mut self) -> Result<[u8; 32], CryptoError> {
|
||||||
|
let secret = StaticSecret::random_from_rng(OsRng);
|
||||||
|
let public = PublicKey::from(&secret);
|
||||||
|
self.pending_rekey_secret = Some(secret);
|
||||||
|
Ok(public.to_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn complete_rekey(&mut self, peer_ephemeral_pub: &[u8; 32]) -> Result<(), CryptoError> {
|
||||||
|
let secret = self
|
||||||
|
.pending_rekey_secret
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| CryptoError::RekeyFailed("no pending rekey".into()))?;
|
||||||
|
|
||||||
|
let total_packets = self.send_seq as u64 + self.recv_seq as u64;
|
||||||
|
let new_key = self.rekey_mgr.perform_rekey(peer_ephemeral_pub, secret, total_packets);
|
||||||
|
self.install_key(new_key);
|
||||||
|
|
||||||
|
// Reset sequence counters after rekey for nonce uniqueness
|
||||||
|
self.send_seq = 0;
|
||||||
|
self.recv_seq = 0;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_session_pair() -> (ChaChaSession, ChaChaSession) {
|
||||||
|
let key = [0x42u8; 32];
|
||||||
|
(ChaChaSession::new(key), ChaChaSession::new(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypt_decrypt_roundtrip() {
|
||||||
|
let (mut alice, mut bob) = make_session_pair();
|
||||||
|
let header = b"test-header";
|
||||||
|
let plaintext = b"hello warzone";
|
||||||
|
|
||||||
|
let mut ciphertext = Vec::new();
|
||||||
|
alice.encrypt(header, plaintext, &mut ciphertext).unwrap();
|
||||||
|
|
||||||
|
// Bob decrypts (his recv matches Alice's send)
|
||||||
|
let mut decrypted = Vec::new();
|
||||||
|
bob.decrypt(header, &ciphertext, &mut decrypted).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(&decrypted, plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decrypt_wrong_aad_fails() {
|
||||||
|
let (mut alice, mut bob) = make_session_pair();
|
||||||
|
let header = b"correct-header";
|
||||||
|
let plaintext = b"secret data";
|
||||||
|
|
||||||
|
let mut ciphertext = Vec::new();
|
||||||
|
alice.encrypt(header, plaintext, &mut ciphertext).unwrap();
|
||||||
|
|
||||||
|
let mut decrypted = Vec::new();
|
||||||
|
let result = bob.decrypt(b"wrong-header", &ciphertext, &mut decrypted);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decrypt_wrong_key_fails() {
|
||||||
|
let mut alice = ChaChaSession::new([0xAA; 32]);
|
||||||
|
let mut eve = ChaChaSession::new([0xBB; 32]);
|
||||||
|
|
||||||
|
let header = b"hdr";
|
||||||
|
let plaintext = b"secret";
|
||||||
|
|
||||||
|
let mut ciphertext = Vec::new();
|
||||||
|
alice.encrypt(header, plaintext, &mut ciphertext).unwrap();
|
||||||
|
|
||||||
|
let mut decrypted = Vec::new();
|
||||||
|
let result = eve.decrypt(header, &ciphertext, &mut decrypted);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_packets_roundtrip() {
|
||||||
|
let (mut alice, mut bob) = make_session_pair();
|
||||||
|
let header = b"hdr";
|
||||||
|
|
||||||
|
for i in 0..100 {
|
||||||
|
let msg = format!("message {}", i);
|
||||||
|
let mut ct = Vec::new();
|
||||||
|
alice.encrypt(header, msg.as_bytes(), &mut ct).unwrap();
|
||||||
|
|
||||||
|
let mut pt = Vec::new();
|
||||||
|
bob.decrypt(header, &ct, &mut pt).unwrap();
|
||||||
|
assert_eq!(pt, msg.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rekey_changes_key() {
|
||||||
|
let (mut alice, mut _bob) = make_session_pair();
|
||||||
|
|
||||||
|
let peer_secret = StaticSecret::random_from_rng(OsRng);
|
||||||
|
let peer_pub = PublicKey::from(&peer_secret).to_bytes();
|
||||||
|
|
||||||
|
let rekey_pub = alice.initiate_rekey().unwrap();
|
||||||
|
assert_ne!(rekey_pub, [0u8; 32]); // Should be a valid public key
|
||||||
|
|
||||||
|
alice.complete_rekey(&peer_pub).unwrap();
|
||||||
|
// Session is now rekeyed - counters reset
|
||||||
|
assert_eq!(alice.send_seq, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
crates/wzp-fec/Cargo.toml
Normal file
15
crates/wzp-fec/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "wzp-fec"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "WarzonePhone FEC layer — RaptorQ fountain codes with interleaving"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wzp-proto = { workspace = true }
|
||||||
|
raptorq = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
rand = { workspace = true }
|
||||||
91
crates/wzp-fec/src/adaptive.rs
Normal file
91
crates/wzp-fec/src/adaptive.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
//! Adaptive FEC configuration — maps `QualityProfile` to FEC encoder parameters.
|
||||||
|
|
||||||
|
use wzp_proto::QualityProfile;
|
||||||
|
|
||||||
|
use crate::encoder::RaptorQFecEncoder;
|
||||||
|
|
||||||
|
/// Adaptive FEC configuration derived from a `QualityProfile`.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AdaptiveFec {
|
||||||
|
/// Frames per FEC block.
|
||||||
|
pub frames_per_block: usize,
|
||||||
|
/// Repair ratio (0.0 = none, 1.0 = 100% overhead).
|
||||||
|
pub repair_ratio: f32,
|
||||||
|
/// Symbol size in bytes.
|
||||||
|
pub symbol_size: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdaptiveFec {
|
||||||
|
/// Default symbol size for adaptive configuration.
|
||||||
|
const DEFAULT_SYMBOL_SIZE: u16 = 256;
|
||||||
|
|
||||||
|
/// Create an adaptive FEC configuration from a quality profile.
|
||||||
|
///
|
||||||
|
/// Maps quality tiers:
|
||||||
|
/// - GOOD: 5 frames/block, 20% repair
|
||||||
|
/// - DEGRADED: 10 frames/block, 50% repair
|
||||||
|
/// - CATASTROPHIC: 8 frames/block, 100% repair
|
||||||
|
pub fn from_profile(profile: &QualityProfile) -> Self {
|
||||||
|
Self {
|
||||||
|
frames_per_block: profile.frames_per_block as usize,
|
||||||
|
repair_ratio: profile.fec_ratio,
|
||||||
|
symbol_size: Self::DEFAULT_SYMBOL_SIZE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a configured FEC encoder from this adaptive configuration.
|
||||||
|
pub fn build_encoder(&self) -> RaptorQFecEncoder {
|
||||||
|
RaptorQFecEncoder::new(self.frames_per_block, self.symbol_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the repair ratio for use with `FecEncoder::generate_repair()`.
|
||||||
|
pub fn ratio(&self) -> f32 {
|
||||||
|
self.repair_ratio
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimated overhead factor (1.0 + repair_ratio).
|
||||||
|
pub fn overhead_factor(&self) -> f32 {
|
||||||
|
1.0 + self.repair_ratio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use wzp_proto::FecEncoder;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn good_profile() {
|
||||||
|
let cfg = AdaptiveFec::from_profile(&QualityProfile::GOOD);
|
||||||
|
assert_eq!(cfg.frames_per_block, 5);
|
||||||
|
assert!((cfg.repair_ratio - 0.2).abs() < f32::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn degraded_profile() {
|
||||||
|
let cfg = AdaptiveFec::from_profile(&QualityProfile::DEGRADED);
|
||||||
|
assert_eq!(cfg.frames_per_block, 10);
|
||||||
|
assert!((cfg.repair_ratio - 0.5).abs() < f32::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catastrophic_profile() {
|
||||||
|
let cfg = AdaptiveFec::from_profile(&QualityProfile::CATASTROPHIC);
|
||||||
|
assert_eq!(cfg.frames_per_block, 8);
|
||||||
|
assert!((cfg.repair_ratio - 1.0).abs() < f32::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_encoder_from_profile() {
|
||||||
|
let cfg = AdaptiveFec::from_profile(&QualityProfile::DEGRADED);
|
||||||
|
let encoder = cfg.build_encoder();
|
||||||
|
assert_eq!(encoder.current_block_size(), 0);
|
||||||
|
assert_eq!(wzp_proto::FecEncoder::current_block_id(&encoder), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overhead_factor() {
|
||||||
|
let cfg = AdaptiveFec::from_profile(&QualityProfile::CATASTROPHIC);
|
||||||
|
assert!((cfg.overhead_factor() - 2.0).abs() < f32::EPSILON);
|
||||||
|
}
|
||||||
|
}
|
||||||
242
crates/wzp-fec/src/block_manager.rs
Normal file
242
crates/wzp-fec/src/block_manager.rs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
//! Block manager — tracks the lifecycle of FEC blocks on both encoder and decoder sides.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
/// Block lifecycle state on the encoder side.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum EncoderBlockState {
|
||||||
|
/// Block is currently being built (accumulating source symbols).
|
||||||
|
Building,
|
||||||
|
/// Block has been finalized and repair generated; awaiting transmission.
|
||||||
|
Pending,
|
||||||
|
/// All symbols for this block have been sent.
|
||||||
|
Sent,
|
||||||
|
/// Peer acknowledged receipt / successful decode.
|
||||||
|
Acknowledged,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block lifecycle state on the decoder side.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum DecoderBlockState {
|
||||||
|
/// Receiving symbols for this block.
|
||||||
|
Assembling,
|
||||||
|
/// Block successfully decoded.
|
||||||
|
Complete,
|
||||||
|
/// Block expired (too old, dropped).
|
||||||
|
Expired,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages encoder-side block tracking.
|
||||||
|
pub struct EncoderBlockManager {
|
||||||
|
/// Current block ID being built.
|
||||||
|
current_id: u8,
|
||||||
|
/// State of known blocks.
|
||||||
|
blocks: HashMap<u8, EncoderBlockState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EncoderBlockManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut blocks = HashMap::new();
|
||||||
|
blocks.insert(0, EncoderBlockState::Building);
|
||||||
|
Self {
|
||||||
|
current_id: 0,
|
||||||
|
blocks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the next block ID (advances the current building block).
|
||||||
|
pub fn next_block_id(&mut self) -> u8 {
|
||||||
|
let old = self.current_id;
|
||||||
|
// Mark old block as pending.
|
||||||
|
self.blocks.insert(old, EncoderBlockState::Pending);
|
||||||
|
|
||||||
|
self.current_id = self.current_id.wrapping_add(1);
|
||||||
|
self.blocks
|
||||||
|
.insert(self.current_id, EncoderBlockState::Building);
|
||||||
|
self.current_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current block ID being built.
|
||||||
|
pub fn current_id(&self) -> u8 {
|
||||||
|
self.current_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a block as fully sent.
|
||||||
|
pub fn mark_sent(&mut self, block_id: u8) {
|
||||||
|
self.blocks.insert(block_id, EncoderBlockState::Sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a block as acknowledged by the peer.
|
||||||
|
pub fn mark_acknowledged(&mut self, block_id: u8) {
|
||||||
|
self.blocks
|
||||||
|
.insert(block_id, EncoderBlockState::Acknowledged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the state of a block.
|
||||||
|
pub fn state(&self, block_id: u8) -> Option<EncoderBlockState> {
|
||||||
|
self.blocks.get(&block_id).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove old acknowledged blocks to limit memory.
|
||||||
|
pub fn prune_acknowledged(&mut self) {
|
||||||
|
self.blocks
|
||||||
|
.retain(|_, state| *state != EncoderBlockState::Acknowledged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EncoderBlockManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages decoder-side block tracking.
|
||||||
|
pub struct DecoderBlockManager {
|
||||||
|
/// State of known blocks.
|
||||||
|
blocks: HashMap<u8, DecoderBlockState>,
|
||||||
|
/// Set of completed block IDs.
|
||||||
|
completed: HashSet<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DecoderBlockManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
blocks: HashMap::new(),
|
||||||
|
completed: HashSet::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register that we are receiving symbols for a block.
|
||||||
|
pub fn touch(&mut self, block_id: u8) {
|
||||||
|
self.blocks
|
||||||
|
.entry(block_id)
|
||||||
|
.or_insert(DecoderBlockState::Assembling);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a block as successfully decoded.
|
||||||
|
pub fn mark_complete(&mut self, block_id: u8) {
|
||||||
|
self.blocks.insert(block_id, DecoderBlockState::Complete);
|
||||||
|
self.completed.insert(block_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a block as expired.
|
||||||
|
pub fn mark_expired(&mut self, block_id: u8) {
|
||||||
|
self.blocks.insert(block_id, DecoderBlockState::Expired);
|
||||||
|
self.completed.remove(&block_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a block has been fully decoded.
|
||||||
|
pub fn is_block_complete(&self, block_id: u8) -> bool {
|
||||||
|
self.completed.contains(&block_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the state of a block.
|
||||||
|
pub fn state(&self, block_id: u8) -> Option<DecoderBlockState> {
|
||||||
|
self.blocks.get(&block_id).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expire all blocks older than the given block_id (using wrapping distance).
|
||||||
|
pub fn expire_before(&mut self, block_id: u8) {
|
||||||
|
let to_expire: Vec<u8> = self
|
||||||
|
.blocks
|
||||||
|
.keys()
|
||||||
|
.copied()
|
||||||
|
.filter(|&id| {
|
||||||
|
let distance = block_id.wrapping_sub(id);
|
||||||
|
distance > 0 && distance <= 128
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for id in to_expire {
|
||||||
|
self.blocks.insert(id, DecoderBlockState::Expired);
|
||||||
|
self.completed.remove(&id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove expired blocks entirely to free memory.
|
||||||
|
pub fn prune_expired(&mut self) {
|
||||||
|
self.blocks
|
||||||
|
.retain(|_, state| *state != DecoderBlockState::Expired);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DecoderBlockManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoder_block_lifecycle() {
|
||||||
|
let mut mgr = EncoderBlockManager::new();
|
||||||
|
assert_eq!(mgr.current_id(), 0);
|
||||||
|
assert_eq!(mgr.state(0), Some(EncoderBlockState::Building));
|
||||||
|
|
||||||
|
let next = mgr.next_block_id();
|
||||||
|
assert_eq!(next, 1);
|
||||||
|
assert_eq!(mgr.state(0), Some(EncoderBlockState::Pending));
|
||||||
|
assert_eq!(mgr.state(1), Some(EncoderBlockState::Building));
|
||||||
|
|
||||||
|
mgr.mark_sent(0);
|
||||||
|
assert_eq!(mgr.state(0), Some(EncoderBlockState::Sent));
|
||||||
|
|
||||||
|
mgr.mark_acknowledged(0);
|
||||||
|
assert_eq!(mgr.state(0), Some(EncoderBlockState::Acknowledged));
|
||||||
|
|
||||||
|
mgr.prune_acknowledged();
|
||||||
|
assert_eq!(mgr.state(0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoder_block_lifecycle() {
|
||||||
|
let mut mgr = DecoderBlockManager::new();
|
||||||
|
|
||||||
|
mgr.touch(0);
|
||||||
|
assert_eq!(mgr.state(0), Some(DecoderBlockState::Assembling));
|
||||||
|
assert!(!mgr.is_block_complete(0));
|
||||||
|
|
||||||
|
mgr.mark_complete(0);
|
||||||
|
assert!(mgr.is_block_complete(0));
|
||||||
|
assert_eq!(mgr.state(0), Some(DecoderBlockState::Complete));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoder_expire_before() {
|
||||||
|
let mut mgr = DecoderBlockManager::new();
|
||||||
|
for i in 0..5u8 {
|
||||||
|
mgr.touch(i);
|
||||||
|
}
|
||||||
|
mgr.mark_complete(1);
|
||||||
|
|
||||||
|
mgr.expire_before(3);
|
||||||
|
|
||||||
|
// Blocks 0, 1, 2 should be expired
|
||||||
|
assert_eq!(mgr.state(0), Some(DecoderBlockState::Expired));
|
||||||
|
assert_eq!(mgr.state(1), Some(DecoderBlockState::Expired));
|
||||||
|
assert_eq!(mgr.state(2), Some(DecoderBlockState::Expired));
|
||||||
|
// Block 3 and 4 untouched
|
||||||
|
assert_eq!(mgr.state(3), Some(DecoderBlockState::Assembling));
|
||||||
|
assert_eq!(mgr.state(4), Some(DecoderBlockState::Assembling));
|
||||||
|
|
||||||
|
assert!(!mgr.is_block_complete(1)); // was complete but now expired
|
||||||
|
|
||||||
|
mgr.prune_expired();
|
||||||
|
assert_eq!(mgr.state(0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_block_id_wraps() {
|
||||||
|
let mut mgr = EncoderBlockManager::new();
|
||||||
|
// Start at 0, advance to 255 then wrap
|
||||||
|
for _ in 0..255 {
|
||||||
|
mgr.next_block_id();
|
||||||
|
}
|
||||||
|
assert_eq!(mgr.current_id(), 255);
|
||||||
|
let next = mgr.next_block_id();
|
||||||
|
assert_eq!(next, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
288
crates/wzp-fec/src/decoder.rs
Normal file
288
crates/wzp-fec/src/decoder.rs
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
//! RaptorQ FEC decoder — reassembles source blocks from received source and repair symbols.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder};
|
||||||
|
use wzp_proto::error::FecError;
|
||||||
|
use wzp_proto::FecDecoder;
|
||||||
|
|
||||||
|
/// Length prefix size (u16 little-endian), must match encoder.
|
||||||
|
const LEN_PREFIX: usize = 2;
|
||||||
|
|
||||||
|
/// State for one in-flight block being decoded.
|
||||||
|
struct BlockState {
|
||||||
|
/// Number of source symbols expected.
|
||||||
|
num_source_symbols: Option<usize>,
|
||||||
|
/// Collected encoding packets (source + repair).
|
||||||
|
packets: Vec<EncodingPacket>,
|
||||||
|
/// Symbol size in bytes.
|
||||||
|
symbol_size: u16,
|
||||||
|
/// Whether decoding has already succeeded for this block.
|
||||||
|
decoded: bool,
|
||||||
|
/// Cached decoded result.
|
||||||
|
result: Option<Vec<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RaptorQ-based FEC decoder that handles multiple concurrent blocks.
|
||||||
|
pub struct RaptorQFecDecoder {
|
||||||
|
/// Per-block decoder state, keyed by block_id.
|
||||||
|
blocks: HashMap<u8, BlockState>,
|
||||||
|
/// Symbol size (must match encoder).
|
||||||
|
symbol_size: u16,
|
||||||
|
/// Number of source symbols per block (from encoder config).
|
||||||
|
frames_per_block: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RaptorQFecDecoder {
|
||||||
|
/// Create a new decoder.
|
||||||
|
///
|
||||||
|
/// * `frames_per_block` — expected number of source symbols per block.
|
||||||
|
/// * `symbol_size` — must match the encoder's symbol size.
|
||||||
|
pub fn new(frames_per_block: usize, symbol_size: u16) -> Self {
|
||||||
|
Self {
|
||||||
|
blocks: HashMap::new(),
|
||||||
|
symbol_size,
|
||||||
|
frames_per_block,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create with default symbol size (256).
|
||||||
|
pub fn with_defaults(frames_per_block: usize) -> Self {
|
||||||
|
Self::new(frames_per_block, 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_or_create_block(&mut self, block_id: u8) -> &mut BlockState {
|
||||||
|
self.blocks.entry(block_id).or_insert_with(|| BlockState {
|
||||||
|
num_source_symbols: Some(self.frames_per_block),
|
||||||
|
packets: Vec::new(),
|
||||||
|
symbol_size: self.symbol_size,
|
||||||
|
decoded: false,
|
||||||
|
result: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FecDecoder for RaptorQFecDecoder {
|
||||||
|
fn add_symbol(
|
||||||
|
&mut self,
|
||||||
|
block_id: u8,
|
||||||
|
symbol_index: u8,
|
||||||
|
_is_repair: bool,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<(), FecError> {
|
||||||
|
let ss = self.symbol_size as usize;
|
||||||
|
let block = self.get_or_create_block(block_id);
|
||||||
|
|
||||||
|
if block.decoded {
|
||||||
|
// Already decoded, ignore additional symbols.
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data should already be at symbol_size (length-prefixed and padded by the encoder).
|
||||||
|
// But if caller sends raw data, pad it.
|
||||||
|
let mut padded = vec![0u8; ss];
|
||||||
|
let len = data.len().min(ss);
|
||||||
|
padded[..len].copy_from_slice(&data[..len]);
|
||||||
|
|
||||||
|
let esi = symbol_index as u32;
|
||||||
|
let packet = EncodingPacket::new(PayloadId::new(block_id, esi), padded);
|
||||||
|
block.packets.push(packet);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_decode(&mut self, block_id: u8) -> Result<Option<Vec<Vec<u8>>>, FecError> {
|
||||||
|
let frames_per_block = self.frames_per_block;
|
||||||
|
let block = match self.blocks.get_mut(&block_id) {
|
||||||
|
Some(b) => b,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(ref result) = block.result {
|
||||||
|
return Ok(Some(result.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let num_source = block.num_source_symbols.unwrap_or(frames_per_block);
|
||||||
|
let block_length = (num_source as u64) * (block.symbol_size as u64);
|
||||||
|
|
||||||
|
let config = ObjectTransmissionInformation::with_defaults(block_length, block.symbol_size);
|
||||||
|
let mut decoder = SourceBlockDecoder::new(block_id, &config, block_length);
|
||||||
|
|
||||||
|
let decoded = decoder.decode(block.packets.clone());
|
||||||
|
|
||||||
|
match decoded {
|
||||||
|
Some(data) => {
|
||||||
|
// Split decoded data into individual frames using the length prefix.
|
||||||
|
let ss = block.symbol_size as usize;
|
||||||
|
let mut frames = Vec::with_capacity(num_source);
|
||||||
|
for i in 0..num_source {
|
||||||
|
let offset = i * ss;
|
||||||
|
if offset + LEN_PREFIX > data.len() {
|
||||||
|
frames.push(Vec::new());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let payload_len = u16::from_le_bytes([
|
||||||
|
data[offset],
|
||||||
|
data[offset + 1],
|
||||||
|
]) as usize;
|
||||||
|
let payload_start = offset + LEN_PREFIX;
|
||||||
|
let payload_end = (payload_start + payload_len).min(data.len());
|
||||||
|
frames.push(data[payload_start..payload_end].to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
let block = self.blocks.get_mut(&block_id).unwrap();
|
||||||
|
block.decoded = true;
|
||||||
|
block.result = Some(frames.clone());
|
||||||
|
Ok(Some(frames))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expire_before(&mut self, block_id: u8) {
|
||||||
|
// Remove blocks with IDs "older" than block_id.
|
||||||
|
// With wrapping u8 IDs, we consider a block old if its distance
|
||||||
|
// (in the forward direction) to block_id is > 128.
|
||||||
|
self.blocks.retain(|&id, _| {
|
||||||
|
let distance = block_id.wrapping_sub(id);
|
||||||
|
// If distance is 0 or > 128, the block is current or "ahead" — keep it.
|
||||||
|
// If distance is 1..=128, the block is behind — remove it.
|
||||||
|
distance == 0 || distance > 128
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::encoder::{repair_packets_for_block, source_packets_for_block};
|
||||||
|
|
||||||
|
const SYMBOL_SIZE: u16 = 256;
|
||||||
|
const FRAMES_PER_BLOCK: usize = 5;
|
||||||
|
|
||||||
|
/// Helper: create test source symbols.
|
||||||
|
fn make_source_symbols(count: usize) -> Vec<Vec<u8>> {
|
||||||
|
(0..count)
|
||||||
|
.map(|i| {
|
||||||
|
let val = (i as u8).wrapping_mul(37).wrapping_add(7);
|
||||||
|
vec![val; 100]
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_with_all_source_symbols() {
|
||||||
|
let symbols = make_source_symbols(FRAMES_PER_BLOCK);
|
||||||
|
let source_pkts = source_packets_for_block(0, &symbols, SYMBOL_SIZE);
|
||||||
|
let mut decoder = RaptorQFecDecoder::new(FRAMES_PER_BLOCK, SYMBOL_SIZE);
|
||||||
|
|
||||||
|
// Feed all source symbols (using the length-prefixed padded data).
|
||||||
|
for (i, pkt) in source_pkts.iter().enumerate() {
|
||||||
|
decoder
|
||||||
|
.add_symbol(0, i as u8, false, pkt.data())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = decoder.try_decode(0).unwrap();
|
||||||
|
assert!(result.is_some());
|
||||||
|
let frames = result.unwrap();
|
||||||
|
assert_eq!(frames.len(), FRAMES_PER_BLOCK);
|
||||||
|
for (i, frame) in frames.iter().enumerate() {
|
||||||
|
assert_eq!(frame, &symbols[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test FEC recovery using raptorq directly, validating our encoding pipeline.
|
||||||
|
fn run_loss_test(num_frames: usize, repair_ratio: f32, drop_fraction: f32) {
|
||||||
|
use rand::seq::SliceRandom;
|
||||||
|
|
||||||
|
let symbols = make_source_symbols(num_frames);
|
||||||
|
let source_pkts = source_packets_for_block(0, &symbols, SYMBOL_SIZE);
|
||||||
|
let repair_pkts = repair_packets_for_block(0, &symbols, SYMBOL_SIZE, repair_ratio);
|
||||||
|
|
||||||
|
let mut all: Vec<EncodingPacket> = Vec::new();
|
||||||
|
all.extend(source_pkts);
|
||||||
|
all.extend(repair_pkts);
|
||||||
|
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
all.shuffle(&mut rng);
|
||||||
|
let keep = ((all.len() as f32) * (1.0 - drop_fraction)).ceil() as usize;
|
||||||
|
all.truncate(keep);
|
||||||
|
|
||||||
|
let block_len = (num_frames as u64) * (SYMBOL_SIZE as u64);
|
||||||
|
let config = ObjectTransmissionInformation::new(block_len, SYMBOL_SIZE, 1, 1, 1);
|
||||||
|
let mut dec = SourceBlockDecoder::new(0, &config, block_len);
|
||||||
|
let decoded = dec.decode(all);
|
||||||
|
assert!(decoded.is_some(), "Should recover with {:.0}% loss", drop_fraction * 100.0);
|
||||||
|
|
||||||
|
let data = decoded.unwrap();
|
||||||
|
let ss = SYMBOL_SIZE as usize;
|
||||||
|
for i in 0..num_frames {
|
||||||
|
let off = i * ss;
|
||||||
|
let plen = u16::from_le_bytes([data[off], data[off + 1]]) as usize;
|
||||||
|
assert_eq!(&data[off + 2..off + 2 + plen], &symbols[i][..], "Frame {i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_with_30pct_loss() { run_loss_test(FRAMES_PER_BLOCK, 0.5, 0.3); }
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_with_50pct_loss() { run_loss_test(FRAMES_PER_BLOCK, 1.0, 0.5); }
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_with_70pct_source_loss_heavy_repair() { run_loss_test(8, 2.0, 0.5); }
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expire_removes_old_blocks() {
|
||||||
|
let mut decoder = RaptorQFecDecoder::new(FRAMES_PER_BLOCK, SYMBOL_SIZE);
|
||||||
|
|
||||||
|
// Add symbols to blocks 0, 1, 2
|
||||||
|
for block_id in 0..3u8 {
|
||||||
|
decoder
|
||||||
|
.add_symbol(block_id, 0, false, &[block_id; 50])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(decoder.blocks.len(), 3);
|
||||||
|
|
||||||
|
// Expire before block 2 — should remove blocks 0 and 1
|
||||||
|
decoder.expire_before(2);
|
||||||
|
assert!(!decoder.blocks.contains_key(&0));
|
||||||
|
assert!(!decoder.blocks.contains_key(&1));
|
||||||
|
assert!(decoder.blocks.contains_key(&2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_blocks() {
|
||||||
|
let symbols_a = make_source_symbols(FRAMES_PER_BLOCK);
|
||||||
|
let symbols_b: Vec<Vec<u8>> = (0..FRAMES_PER_BLOCK)
|
||||||
|
.map(|i| vec![(i as u8).wrapping_add(100); 80])
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let pkts_a = source_packets_for_block(0, &symbols_a, SYMBOL_SIZE);
|
||||||
|
let pkts_b = source_packets_for_block(1, &symbols_b, SYMBOL_SIZE);
|
||||||
|
|
||||||
|
let mut decoder = RaptorQFecDecoder::new(FRAMES_PER_BLOCK, SYMBOL_SIZE);
|
||||||
|
|
||||||
|
// Interleave symbols from block 0 and block 1
|
||||||
|
for i in 0..FRAMES_PER_BLOCK {
|
||||||
|
decoder
|
||||||
|
.add_symbol(0, i as u8, false, pkts_a[i].data())
|
||||||
|
.unwrap();
|
||||||
|
decoder
|
||||||
|
.add_symbol(1, i as u8, false, pkts_b[i].data())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result_a = decoder.try_decode(0).unwrap().unwrap();
|
||||||
|
let result_b = decoder.try_decode(1).unwrap().unwrap();
|
||||||
|
|
||||||
|
for (i, frame) in result_a.iter().enumerate() {
|
||||||
|
assert_eq!(frame, &symbols_a[i]);
|
||||||
|
}
|
||||||
|
for (i, frame) in result_b.iter().enumerate() {
|
||||||
|
assert_eq!(frame, &symbols_b[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
214
crates/wzp-fec/src/encoder.rs
Normal file
214
crates/wzp-fec/src/encoder.rs
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
//! RaptorQ FEC encoder — accumulates source symbols into blocks and generates repair symbols.
|
||||||
|
|
||||||
|
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockEncoder};
|
||||||
|
use wzp_proto::error::FecError;
|
||||||
|
use wzp_proto::FecEncoder;
|
||||||
|
|
||||||
|
/// Maximum symbol size in bytes. Audio frames are typically < 200 bytes,
|
||||||
|
/// but we pad to a uniform size within a block.
|
||||||
|
/// Each symbol carries a 2-byte length prefix so recovered frames can be trimmed.
|
||||||
|
const DEFAULT_MAX_SYMBOL_SIZE: u16 = 256;
|
||||||
|
|
||||||
|
/// Length prefix size (u16 little-endian).
|
||||||
|
const LEN_PREFIX: usize = 2;
|
||||||
|
|
||||||
|
/// RaptorQ-based FEC encoder that groups audio frames into blocks
|
||||||
|
/// and generates fountain-code repair symbols.
|
||||||
|
pub struct RaptorQFecEncoder {
|
||||||
|
/// Current block ID (wraps at u8).
|
||||||
|
block_id: u8,
|
||||||
|
/// Maximum source symbols per block.
|
||||||
|
frames_per_block: usize,
|
||||||
|
/// Accumulated source symbols for the current block.
|
||||||
|
source_symbols: Vec<Vec<u8>>,
|
||||||
|
/// Symbol size used for encoding (all symbols padded to this size).
|
||||||
|
symbol_size: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RaptorQFecEncoder {
|
||||||
|
/// Create a new encoder.
|
||||||
|
///
|
||||||
|
/// * `frames_per_block` — number of source symbols per FEC block.
|
||||||
|
/// * `symbol_size` — max byte length of any single source symbol (frames are zero-padded).
|
||||||
|
pub fn new(frames_per_block: usize, symbol_size: u16) -> Self {
|
||||||
|
Self {
|
||||||
|
block_id: 0,
|
||||||
|
frames_per_block,
|
||||||
|
source_symbols: Vec::with_capacity(frames_per_block),
|
||||||
|
symbol_size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create with default symbol size (256 bytes).
|
||||||
|
pub fn with_defaults(frames_per_block: usize) -> Self {
|
||||||
|
Self::new(frames_per_block, DEFAULT_MAX_SYMBOL_SIZE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a contiguous data buffer from the accumulated source symbols,
|
||||||
|
/// each prefixed with a 2-byte length and zero-padded to `symbol_size`.
|
||||||
|
fn build_block_data(&self) -> Vec<u8> {
|
||||||
|
let ss = self.symbol_size as usize;
|
||||||
|
let mut data = vec![0u8; self.source_symbols.len() * ss];
|
||||||
|
for (i, sym) in self.source_symbols.iter().enumerate() {
|
||||||
|
let max_payload = ss - LEN_PREFIX;
|
||||||
|
let payload_len = sym.len().min(max_payload);
|
||||||
|
let offset = i * ss;
|
||||||
|
// Write 2-byte little-endian length prefix.
|
||||||
|
data[offset..offset + LEN_PREFIX]
|
||||||
|
.copy_from_slice(&(payload_len as u16).to_le_bytes());
|
||||||
|
// Write payload after prefix.
|
||||||
|
data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len]
|
||||||
|
.copy_from_slice(&sym[..payload_len]);
|
||||||
|
}
|
||||||
|
data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FecEncoder for RaptorQFecEncoder {
|
||||||
|
fn add_source_symbol(&mut self, data: &[u8]) -> Result<(), FecError> {
|
||||||
|
if self.source_symbols.len() >= self.frames_per_block {
|
||||||
|
return Err(FecError::BlockFull {
|
||||||
|
max: self.frames_per_block,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.source_symbols.push(data.to_vec());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_repair(&mut self, ratio: f32) -> Result<Vec<(u8, Vec<u8>)>, FecError> {
|
||||||
|
if self.source_symbols.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let block_data = self.build_block_data();
|
||||||
|
let config = ObjectTransmissionInformation::with_defaults(block_data.len() as u64, self.symbol_size);
|
||||||
|
let encoder = SourceBlockEncoder::new(self.block_id, &config, &block_data);
|
||||||
|
|
||||||
|
let num_source = self.source_symbols.len() as u32;
|
||||||
|
let num_repair = ((num_source as f32) * ratio).ceil() as u32;
|
||||||
|
if num_repair == 0 {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate repair packets starting from offset 0 (ESIs begin at num_source).
|
||||||
|
let repair_packets: Vec<EncodingPacket> = encoder.repair_packets(0, num_repair);
|
||||||
|
|
||||||
|
let result: Vec<(u8, Vec<u8>)> = repair_packets
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, pkt): (usize, EncodingPacket)| {
|
||||||
|
let idx = (num_source as u8).wrapping_add(i as u8);
|
||||||
|
(idx, pkt.data().to_vec())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finalize_block(&mut self) -> Result<u8, FecError> {
|
||||||
|
let completed = self.block_id;
|
||||||
|
self.block_id = self.block_id.wrapping_add(1);
|
||||||
|
self.source_symbols.clear();
|
||||||
|
Ok(completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_block_id(&self) -> u8 {
|
||||||
|
self.block_id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_block_size(&self) -> usize {
|
||||||
|
self.source_symbols.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a length-prefixed, padded block data buffer from raw symbols.
|
||||||
|
/// This matches what the encoder produces internally.
|
||||||
|
fn build_prefixed_block_data(symbols: &[Vec<u8>], symbol_size: u16) -> Vec<u8> {
|
||||||
|
let ss = symbol_size as usize;
|
||||||
|
let mut data = vec![0u8; symbols.len() * ss];
|
||||||
|
for (i, sym) in symbols.iter().enumerate() {
|
||||||
|
let max_payload = ss - LEN_PREFIX;
|
||||||
|
let payload_len = sym.len().min(max_payload);
|
||||||
|
let offset = i * ss;
|
||||||
|
data[offset..offset + LEN_PREFIX]
|
||||||
|
.copy_from_slice(&(payload_len as u16).to_le_bytes());
|
||||||
|
data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len]
|
||||||
|
.copy_from_slice(&sym[..payload_len]);
|
||||||
|
}
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: build source `EncodingPacket`s for a given block. Useful for
|
||||||
|
/// the decoder tests and interleaving.
|
||||||
|
pub fn source_packets_for_block(
|
||||||
|
block_id: u8,
|
||||||
|
symbols: &[Vec<u8>],
|
||||||
|
symbol_size: u16,
|
||||||
|
) -> Vec<EncodingPacket> {
|
||||||
|
let ss = symbol_size as usize;
|
||||||
|
let data = build_prefixed_block_data(symbols, symbol_size);
|
||||||
|
(0..symbols.len())
|
||||||
|
.map(|i| {
|
||||||
|
let offset = i * ss;
|
||||||
|
let sym_data = data[offset..offset + ss].to_vec();
|
||||||
|
EncodingPacket::new(PayloadId::new(block_id, i as u32), sym_data)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: 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> {
|
||||||
|
let data = build_prefixed_block_data(symbols, symbol_size);
|
||||||
|
let config = ObjectTransmissionInformation::with_defaults(data.len() as u64, symbol_size);
|
||||||
|
let encoder = SourceBlockEncoder::new(block_id, &config, &data);
|
||||||
|
let num_source = symbols.len() as u32;
|
||||||
|
let num_repair = ((num_source as f32) * ratio).ceil() as u32;
|
||||||
|
encoder.repair_packets(0, num_repair)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_symbols_and_finalize() {
|
||||||
|
let mut enc = RaptorQFecEncoder::with_defaults(5);
|
||||||
|
assert_eq!(enc.current_block_id(), 0);
|
||||||
|
assert_eq!(enc.current_block_size(), 0);
|
||||||
|
|
||||||
|
for i in 0..5 {
|
||||||
|
enc.add_source_symbol(&[i as u8; 100]).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(enc.current_block_size(), 5);
|
||||||
|
|
||||||
|
// Block full
|
||||||
|
assert!(enc.add_source_symbol(&[0u8; 100]).is_err());
|
||||||
|
|
||||||
|
let repair = enc.generate_repair(0.5).unwrap();
|
||||||
|
assert!(!repair.is_empty());
|
||||||
|
// 5 source * 0.5 = 3 repair (ceil)
|
||||||
|
assert_eq!(repair.len(), 3);
|
||||||
|
|
||||||
|
let id = enc.finalize_block().unwrap();
|
||||||
|
assert_eq!(id, 0);
|
||||||
|
assert_eq!(enc.current_block_id(), 1);
|
||||||
|
assert_eq!(enc.current_block_size(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn block_id_wraps() {
|
||||||
|
let mut enc = RaptorQFecEncoder::with_defaults(1);
|
||||||
|
for expected in 0..=255u8 {
|
||||||
|
assert_eq!(enc.current_block_id(), expected);
|
||||||
|
enc.add_source_symbol(&[expected; 10]).unwrap();
|
||||||
|
enc.finalize_block().unwrap();
|
||||||
|
}
|
||||||
|
// After 256 blocks, wraps back to 0
|
||||||
|
assert_eq!(enc.current_block_id(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
crates/wzp-fec/src/interleave.rs
Normal file
152
crates/wzp-fec/src/interleave.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
//! Temporal interleaving — spreads symbols from multiple FEC blocks across
|
||||||
|
//! transmission slots so that burst losses damage multiple blocks lightly
|
||||||
|
//! rather than one block fatally.
|
||||||
|
|
||||||
|
/// A symbol ready for transmission: (block_id, symbol_index, is_repair, data).
|
||||||
|
pub type Symbol = (u8, u8, bool, Vec<u8>);
|
||||||
|
|
||||||
|
/// Temporal interleaver that mixes symbols across multiple FEC blocks.
|
||||||
|
pub struct Interleaver {
|
||||||
|
/// Number of blocks to interleave across (spread depth).
|
||||||
|
depth: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Interleaver {
|
||||||
|
/// Create an interleaver with the given spread depth.
|
||||||
|
pub fn new(depth: usize) -> Self {
|
||||||
|
Self { depth }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create with default depth of 3 blocks.
|
||||||
|
pub fn with_default_depth() -> Self {
|
||||||
|
Self::new(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spread depth (number of blocks mixed together).
|
||||||
|
pub fn depth(&self) -> usize {
|
||||||
|
self.depth
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interleave symbols from multiple blocks into a single transmission sequence.
|
||||||
|
///
|
||||||
|
/// Each inner `Vec` contains the symbols for one FEC block.
|
||||||
|
/// The output interleaves them in round-robin fashion: symbol 0 from block A,
|
||||||
|
/// symbol 0 from block B, symbol 0 from block C, symbol 1 from block A, etc.
|
||||||
|
///
|
||||||
|
/// This ensures a burst loss of N consecutive packets only destroys at most
|
||||||
|
/// ceil(N/depth) symbols from any single block.
|
||||||
|
pub fn interleave(&self, blocks: &[Vec<Symbol>]) -> Vec<Symbol> {
|
||||||
|
if blocks.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_len = blocks.iter().map(|b| b.len()).max().unwrap_or(0);
|
||||||
|
let mut output = Vec::with_capacity(blocks.iter().map(|b| b.len()).sum());
|
||||||
|
|
||||||
|
for slot in 0..max_len {
|
||||||
|
for block in blocks {
|
||||||
|
if slot < block.len() {
|
||||||
|
output.push(block[slot].clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interleave_mixes_blocks() {
|
||||||
|
let interleaver = Interleaver::with_default_depth();
|
||||||
|
|
||||||
|
let block_a: Vec<Symbol> = (0..3)
|
||||||
|
.map(|i| (0u8, i as u8, false, vec![0xA0 + i as u8]))
|
||||||
|
.collect();
|
||||||
|
let block_b: Vec<Symbol> = (0..3)
|
||||||
|
.map(|i| (1u8, i as u8, false, vec![0xB0 + i as u8]))
|
||||||
|
.collect();
|
||||||
|
let block_c: Vec<Symbol> = (0..3)
|
||||||
|
.map(|i| (2u8, i as u8, false, vec![0xC0 + i as u8]))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let result = interleaver.interleave(&[block_a, block_b, block_c]);
|
||||||
|
|
||||||
|
assert_eq!(result.len(), 9);
|
||||||
|
|
||||||
|
// Round-robin: A0, B0, C0, A1, B1, C1, A2, B2, C2
|
||||||
|
assert_eq!(result[0].0, 0); // block A
|
||||||
|
assert_eq!(result[1].0, 1); // block B
|
||||||
|
assert_eq!(result[2].0, 2); // block C
|
||||||
|
assert_eq!(result[3].0, 0); // block A
|
||||||
|
assert_eq!(result[4].0, 1); // block B
|
||||||
|
assert_eq!(result[5].0, 2); // block C
|
||||||
|
|
||||||
|
// Verify symbol indices cycle correctly
|
||||||
|
assert_eq!(result[0].1, 0); // sym 0 from A
|
||||||
|
assert_eq!(result[3].1, 1); // sym 1 from A
|
||||||
|
assert_eq!(result[6].1, 2); // sym 2 from A
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interleave_unequal_lengths() {
|
||||||
|
let interleaver = Interleaver::new(2);
|
||||||
|
|
||||||
|
let block_a: Vec<Symbol> = (0..3)
|
||||||
|
.map(|i| (0u8, i as u8, false, vec![0xA0 + i as u8]))
|
||||||
|
.collect();
|
||||||
|
let block_b: Vec<Symbol> = (0..1)
|
||||||
|
.map(|i| (1u8, i as u8, false, vec![0xB0 + i as u8]))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let result = interleaver.interleave(&[block_a, block_b]);
|
||||||
|
|
||||||
|
// A0, B0, A1, A2
|
||||||
|
assert_eq!(result.len(), 4);
|
||||||
|
assert_eq!(result[0].0, 0); // A0
|
||||||
|
assert_eq!(result[1].0, 1); // B0
|
||||||
|
assert_eq!(result[2].0, 0); // A1
|
||||||
|
assert_eq!(result[3].0, 0); // A2
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interleave_empty() {
|
||||||
|
let interleaver = Interleaver::with_default_depth();
|
||||||
|
let result = interleaver.interleave(&[]);
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn burst_loss_distributed() {
|
||||||
|
// With 3-block interleaving and a burst of 6 consecutive losses,
|
||||||
|
// each block loses at most 2 symbols.
|
||||||
|
let interleaver = Interleaver::new(3);
|
||||||
|
|
||||||
|
let blocks: Vec<Vec<Symbol>> = (0..3)
|
||||||
|
.map(|b| {
|
||||||
|
(0..6)
|
||||||
|
.map(|i| (b as u8, i as u8, false, vec![b as u8; 10]))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let interleaved = interleaver.interleave(&blocks);
|
||||||
|
assert_eq!(interleaved.len(), 18);
|
||||||
|
|
||||||
|
// Simulate burst loss of 6 consecutive packets starting at index 3
|
||||||
|
let lost_range = 3..9;
|
||||||
|
let mut losses_per_block = [0u32; 3];
|
||||||
|
for idx in lost_range {
|
||||||
|
let block_id = interleaved[idx].0 as usize;
|
||||||
|
losses_per_block[block_id] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each block should lose exactly 2 (6 losses / 3 blocks)
|
||||||
|
for &loss in &losses_per_block {
|
||||||
|
assert_eq!(loss, 2, "Each block should lose at most 2 symbols from a burst of 6");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
crates/wzp-fec/src/lib.rs
Normal file
45
crates/wzp-fec/src/lib.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
//! WarzonePhone FEC Layer
|
||||||
|
//!
|
||||||
|
//! Forward Error Correction using RaptorQ fountain codes with temporal interleaving.
|
||||||
|
//!
|
||||||
|
//! This crate provides:
|
||||||
|
//! - [`RaptorQFecEncoder`] — accumulates audio frames into FEC blocks and generates repair symbols
|
||||||
|
//! - [`RaptorQFecDecoder`] — reassembles source blocks from received source and repair symbols
|
||||||
|
//! - [`Interleaver`] — spreads symbols across blocks to mitigate burst losses
|
||||||
|
//! - [`BlockManager`](block_manager) — tracks block lifecycle on encoder and decoder sides
|
||||||
|
//! - [`AdaptiveFec`] — maps quality profiles to FEC parameters
|
||||||
|
|
||||||
|
pub mod adaptive;
|
||||||
|
pub mod block_manager;
|
||||||
|
pub mod decoder;
|
||||||
|
pub mod encoder;
|
||||||
|
pub mod interleave;
|
||||||
|
|
||||||
|
pub use adaptive::AdaptiveFec;
|
||||||
|
pub use block_manager::{DecoderBlockManager, DecoderBlockState, EncoderBlockManager, EncoderBlockState};
|
||||||
|
pub use decoder::RaptorQFecDecoder;
|
||||||
|
pub use encoder::RaptorQFecEncoder;
|
||||||
|
pub use interleave::Interleaver;
|
||||||
|
|
||||||
|
pub use wzp_proto::{FecDecoder, FecEncoder, QualityProfile};
|
||||||
|
|
||||||
|
/// Create an encoder/decoder pair configured for the given quality profile.
|
||||||
|
pub fn create_fec_pair(
|
||||||
|
profile: &QualityProfile,
|
||||||
|
) -> (RaptorQFecEncoder, RaptorQFecDecoder) {
|
||||||
|
let cfg = AdaptiveFec::from_profile(profile);
|
||||||
|
let encoder = cfg.build_encoder();
|
||||||
|
let decoder = RaptorQFecDecoder::new(cfg.frames_per_block, cfg.symbol_size);
|
||||||
|
(encoder, decoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an encoder configured for the given quality profile.
|
||||||
|
pub fn create_encoder(profile: &QualityProfile) -> RaptorQFecEncoder {
|
||||||
|
AdaptiveFec::from_profile(profile).build_encoder()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a decoder configured for the given quality profile.
|
||||||
|
pub fn create_decoder(profile: &QualityProfile) -> RaptorQFecDecoder {
|
||||||
|
let cfg = AdaptiveFec::from_profile(profile);
|
||||||
|
RaptorQFecDecoder::new(cfg.frames_per_block, cfg.symbol_size)
|
||||||
|
}
|
||||||
17
crates/wzp-proto/Cargo.toml
Normal file
17
crates/wzp-proto/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "wzp-proto"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "WarzonePhone protocol types, traits, and core logic"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
bytes = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true }
|
||||||
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;
|
||||||
|
}
|
||||||
19
crates/wzp-relay/Cargo.toml
Normal file
19
crates/wzp-relay/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "wzp-relay"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "WarzonePhone relay daemon — ties codec, FEC, crypto, and transport together"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wzp-proto = { workspace = true }
|
||||||
|
wzp-codec = { workspace = true }
|
||||||
|
wzp-fec = { workspace = true }
|
||||||
|
wzp-crypto = { workspace = true }
|
||||||
|
wzp-transport = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
6
crates/wzp-relay/src/lib.rs
Normal file
6
crates/wzp-relay/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
//! WarzonePhone Relay Daemon
|
||||||
|
//!
|
||||||
|
//! Integration crate that wires together all layers into a relay pipeline:
|
||||||
|
//! recv → decrypt → FEC decode → jitter → FEC encode → encrypt → send
|
||||||
|
//!
|
||||||
|
//! Built after the 5 agent crates (proto, codec, fec, crypto, transport) are complete.
|
||||||
21
crates/wzp-transport/Cargo.toml
Normal file
21
crates/wzp-transport/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "wzp-transport"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "WarzonePhone transport layer — QUIC (quinn) with DATAGRAM frames"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wzp-proto = { workspace = true }
|
||||||
|
quinn = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
bytes = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
serde_json = "1"
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||||
|
rcgen = "0.13"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||||
153
crates/wzp-transport/src/config.rs
Normal file
153
crates/wzp-transport/src/config.rs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
//! QUIC configuration tuned for lossy VoIP links.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use quinn::crypto::rustls::QuicClientConfig;
|
||||||
|
use quinn::crypto::rustls::QuicServerConfig;
|
||||||
|
|
||||||
|
/// Create a server configuration with a self-signed certificate (for testing).
|
||||||
|
///
|
||||||
|
/// Tunes QUIC transport parameters for lossy VoIP:
|
||||||
|
/// - 30s idle timeout
|
||||||
|
/// - 5s keep-alive interval
|
||||||
|
/// - DATAGRAM extension enabled
|
||||||
|
/// - Conservative flow control for bandwidth-constrained links
|
||||||
|
pub fn server_config() -> (quinn::ServerConfig, Vec<u8>) {
|
||||||
|
let cert_key = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
|
||||||
|
.expect("failed to generate self-signed cert");
|
||||||
|
let cert_der = rustls::pki_types::CertificateDer::from(cert_key.cert);
|
||||||
|
let key_der =
|
||||||
|
rustls::pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der()).unwrap();
|
||||||
|
|
||||||
|
let mut server_crypto = rustls::ServerConfig::builder()
|
||||||
|
.with_no_client_auth()
|
||||||
|
.with_single_cert(vec![cert_der.clone()], key_der)
|
||||||
|
.expect("bad server cert/key");
|
||||||
|
server_crypto.alpn_protocols = vec![b"wzp".to_vec()];
|
||||||
|
|
||||||
|
let quic_server_config =
|
||||||
|
QuicServerConfig::try_from(server_crypto).expect("failed to create QuicServerConfig");
|
||||||
|
|
||||||
|
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_server_config));
|
||||||
|
let transport = transport_config();
|
||||||
|
server_config.transport_config(Arc::new(transport));
|
||||||
|
|
||||||
|
(server_config, cert_der.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a client configuration that trusts any certificate (for testing).
|
||||||
|
///
|
||||||
|
/// Uses the same VoIP-tuned transport parameters as the server.
|
||||||
|
pub fn client_config() -> quinn::ClientConfig {
|
||||||
|
let mut client_crypto = rustls::ClientConfig::builder()
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(Arc::new(SkipServerVerification))
|
||||||
|
.with_no_client_auth();
|
||||||
|
client_crypto.alpn_protocols = vec![b"wzp".to_vec()];
|
||||||
|
|
||||||
|
let quic_client_config =
|
||||||
|
QuicClientConfig::try_from(client_crypto).expect("failed to create QuicClientConfig");
|
||||||
|
|
||||||
|
let mut client_config = quinn::ClientConfig::new(Arc::new(quic_client_config));
|
||||||
|
let transport = transport_config();
|
||||||
|
client_config.transport_config(Arc::new(transport));
|
||||||
|
|
||||||
|
client_config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared transport configuration tuned for lossy VoIP.
|
||||||
|
fn transport_config() -> quinn::TransportConfig {
|
||||||
|
let mut config = quinn::TransportConfig::default();
|
||||||
|
|
||||||
|
// 30 second idle timeout
|
||||||
|
config.max_idle_timeout(Some(
|
||||||
|
quinn::IdleTimeout::try_from(Duration::from_secs(30)).unwrap(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// 5 second keep-alive interval
|
||||||
|
config.keep_alive_interval(Some(Duration::from_secs(5)));
|
||||||
|
|
||||||
|
// Enable DATAGRAM extension for unreliable media packets.
|
||||||
|
// Allow datagrams up to 1200 bytes (conservative for lossy links).
|
||||||
|
config.datagram_receive_buffer_size(Some(65536));
|
||||||
|
|
||||||
|
// Conservative flow control for bandwidth-constrained links
|
||||||
|
config.receive_window(quinn::VarInt::from_u32(256 * 1024)); // 256KB
|
||||||
|
config.send_window(128 * 1024); // 128KB
|
||||||
|
config.stream_receive_window(quinn::VarInt::from_u32(64 * 1024)); // 64KB per stream
|
||||||
|
|
||||||
|
// Aggressive initial RTT estimate for high-latency links
|
||||||
|
config.initial_rtt(Duration::from_millis(300));
|
||||||
|
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Certificate verifier that accepts any server certificate (testing only).
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct SkipServerVerification;
|
||||||
|
|
||||||
|
impl rustls::client::danger::ServerCertVerifier for SkipServerVerification {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||||
|
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||||
|
_ocsp_response: &[u8],
|
||||||
|
_now: rustls::pki_types::UnixTime,
|
||||||
|
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||||
|
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||||
|
// Support the schemes that rustls typically uses
|
||||||
|
vec![
|
||||||
|
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||||
|
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||||
|
rustls::SignatureScheme::ED25519,
|
||||||
|
rustls::SignatureScheme::RSA_PSS_SHA256,
|
||||||
|
rustls::SignatureScheme::RSA_PSS_SHA384,
|
||||||
|
rustls::SignatureScheme::RSA_PSS_SHA512,
|
||||||
|
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
||||||
|
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
||||||
|
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_config_creates_without_error() {
|
||||||
|
let (cfg, cert_der) = server_config();
|
||||||
|
assert!(!cert_der.is_empty());
|
||||||
|
// Verify the config was created (no panic)
|
||||||
|
drop(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_config_creates_without_error() {
|
||||||
|
let cfg = client_config();
|
||||||
|
drop(cfg);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
crates/wzp-transport/src/connection.rs
Normal file
54
crates/wzp-transport/src/connection.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
//! QUIC connection lifecycle management.
|
||||||
|
//!
|
||||||
|
//! Provides helpers for creating endpoints, connecting to peers, and accepting connections.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use wzp_proto::TransportError;
|
||||||
|
|
||||||
|
/// Create a QUIC endpoint bound to the given address.
|
||||||
|
///
|
||||||
|
/// If `server_config` is provided, the endpoint can accept incoming connections.
|
||||||
|
pub fn create_endpoint(
|
||||||
|
bind_addr: SocketAddr,
|
||||||
|
server_config: Option<quinn::ServerConfig>,
|
||||||
|
) -> Result<quinn::Endpoint, TransportError> {
|
||||||
|
let endpoint = if let Some(sc) = server_config {
|
||||||
|
quinn::Endpoint::server(sc, bind_addr)?
|
||||||
|
} else {
|
||||||
|
quinn::Endpoint::client(bind_addr)?
|
||||||
|
};
|
||||||
|
Ok(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to a remote peer using the given client configuration.
|
||||||
|
pub async fn connect(
|
||||||
|
endpoint: &quinn::Endpoint,
|
||||||
|
addr: SocketAddr,
|
||||||
|
server_name: &str,
|
||||||
|
config: quinn::ClientConfig,
|
||||||
|
) -> Result<quinn::Connection, TransportError> {
|
||||||
|
let connecting = endpoint.connect_with(config, addr, server_name).map_err(|e| {
|
||||||
|
TransportError::Internal(format!("connect error: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let connection = connecting.await.map_err(|e| {
|
||||||
|
TransportError::Internal(format!("connection failed: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept the next incoming connection on an endpoint.
|
||||||
|
pub async fn accept(endpoint: &quinn::Endpoint) -> Result<quinn::Connection, TransportError> {
|
||||||
|
let incoming = endpoint
|
||||||
|
.accept()
|
||||||
|
.await
|
||||||
|
.ok_or(TransportError::ConnectionLost)?;
|
||||||
|
|
||||||
|
let connection = incoming.await.map_err(|e| {
|
||||||
|
TransportError::Internal(format!("accept failed: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(connection)
|
||||||
|
}
|
||||||
84
crates/wzp-transport/src/datagram.rs
Normal file
84
crates/wzp-transport/src/datagram.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
//! DATAGRAM frame serialization for media packets.
|
||||||
|
//!
|
||||||
|
//! Wraps `MediaPacket` serialization with MTU awareness for QUIC DATAGRAM frames.
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use wzp_proto::MediaPacket;
|
||||||
|
|
||||||
|
/// Serialize a `MediaPacket` into bytes suitable for a QUIC DATAGRAM frame.
|
||||||
|
pub fn serialize_media(packet: &MediaPacket) -> Bytes {
|
||||||
|
packet.to_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize a `MediaPacket` from QUIC DATAGRAM frame bytes.
|
||||||
|
pub fn deserialize_media(data: Bytes) -> Option<MediaPacket> {
|
||||||
|
MediaPacket::from_bytes(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the maximum payload size for a QUIC DATAGRAM on this connection.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the peer does not support DATAGRAM frames.
|
||||||
|
pub fn max_datagram_payload(connection: &quinn::Connection) -> Option<usize> {
|
||||||
|
connection.max_datagram_size()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use wzp_proto::{CodecId, MediaHeader};
|
||||||
|
|
||||||
|
fn test_packet() -> MediaPacket {
|
||||||
|
MediaPacket {
|
||||||
|
header: MediaHeader {
|
||||||
|
version: 0,
|
||||||
|
is_repair: false,
|
||||||
|
codec_id: CodecId::Opus16k,
|
||||||
|
has_quality_report: false,
|
||||||
|
fec_ratio_encoded: 16,
|
||||||
|
seq: 42,
|
||||||
|
timestamp: 1000,
|
||||||
|
fec_block: 1,
|
||||||
|
fec_symbol: 0,
|
||||||
|
reserved: 0,
|
||||||
|
csrc_count: 0,
|
||||||
|
},
|
||||||
|
payload: Bytes::from_static(b"fake opus frame data"),
|
||||||
|
quality_report: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialize_deserialize_roundtrip() {
|
||||||
|
let packet = test_packet();
|
||||||
|
let data = serialize_media(&packet);
|
||||||
|
let decoded = deserialize_media(data).expect("deserialize should succeed");
|
||||||
|
assert_eq!(packet.header, decoded.header);
|
||||||
|
assert_eq!(packet.payload, decoded.payload);
|
||||||
|
assert_eq!(packet.quality_report, decoded.quality_report);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialize_deserialize_with_quality_report() {
|
||||||
|
let mut packet = test_packet();
|
||||||
|
packet.header.has_quality_report = true;
|
||||||
|
packet.quality_report = Some(wzp_proto::QualityReport {
|
||||||
|
loss_pct: 50,
|
||||||
|
rtt_4ms: 75,
|
||||||
|
jitter_ms: 10,
|
||||||
|
bitrate_cap_kbps: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
let data = serialize_media(&packet);
|
||||||
|
let decoded = deserialize_media(data).expect("deserialize should succeed");
|
||||||
|
assert_eq!(packet.header, decoded.header);
|
||||||
|
assert_eq!(packet.payload, decoded.payload);
|
||||||
|
assert_eq!(packet.quality_report, decoded.quality_report);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize_invalid_data_returns_none() {
|
||||||
|
let data = Bytes::from_static(b"too short");
|
||||||
|
assert!(deserialize_media(data).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
29
crates/wzp-transport/src/lib.rs
Normal file
29
crates/wzp-transport/src/lib.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
//! WarzonePhone Transport Layer
|
||||||
|
//!
|
||||||
|
//! QUIC-based transport using quinn with:
|
||||||
|
//! - DATAGRAM frames for unreliable media packets
|
||||||
|
//! - Reliable streams for signaling messages
|
||||||
|
//! - Path quality monitoring (EWMA loss, RTT, bandwidth estimation)
|
||||||
|
//! - Connection lifecycle management
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! - `config` — QUIC configuration tuned for lossy VoIP links
|
||||||
|
//! - `datagram` — DATAGRAM frame serialization and MTU management
|
||||||
|
//! - `reliable` — Length-prefixed JSON framing over reliable QUIC streams
|
||||||
|
//! - `path_monitor` — EWMA-based PathQuality estimation
|
||||||
|
//! - `quic` — `QuinnTransport` implementing the `MediaTransport` trait
|
||||||
|
//! - `connection` — Connection lifecycle (create endpoint, connect, accept)
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod connection;
|
||||||
|
pub mod datagram;
|
||||||
|
pub mod path_monitor;
|
||||||
|
pub mod quic;
|
||||||
|
pub mod reliable;
|
||||||
|
|
||||||
|
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};
|
||||||
263
crates/wzp-transport/src/path_monitor.rs
Normal file
263
crates/wzp-transport/src/path_monitor.rs
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
//! Network path quality estimation using EWMA smoothing.
|
||||||
|
//!
|
||||||
|
//! Tracks packet loss (via sequence number gaps), RTT, jitter, and bandwidth.
|
||||||
|
|
||||||
|
use wzp_proto::PathQuality;
|
||||||
|
|
||||||
|
/// EWMA smoothing factor.
|
||||||
|
const ALPHA: f64 = 0.1;
|
||||||
|
|
||||||
|
/// Monitors network path quality metrics.
|
||||||
|
pub struct PathMonitor {
|
||||||
|
/// EWMA-smoothed loss percentage (0.0 - 100.0).
|
||||||
|
loss_ewma: f64,
|
||||||
|
/// EWMA-smoothed RTT in milliseconds.
|
||||||
|
rtt_ewma: f64,
|
||||||
|
/// EWMA-smoothed jitter (RTT variance) in milliseconds.
|
||||||
|
jitter_ewma: f64,
|
||||||
|
/// Total bytes observed for bandwidth estimation.
|
||||||
|
bytes_sent: u64,
|
||||||
|
bytes_received: u64,
|
||||||
|
/// Timestamps for bandwidth calculation.
|
||||||
|
first_send_time_ms: Option<u64>,
|
||||||
|
last_send_time_ms: Option<u64>,
|
||||||
|
first_recv_time_ms: Option<u64>,
|
||||||
|
last_recv_time_ms: Option<u64>,
|
||||||
|
/// Sequence tracking for loss detection.
|
||||||
|
highest_sent_seq: Option<u16>,
|
||||||
|
total_sent: u64,
|
||||||
|
total_received: u64,
|
||||||
|
/// Last observed RTT for jitter calculation.
|
||||||
|
last_rtt_ms: Option<f64>,
|
||||||
|
/// Whether we have any observations yet.
|
||||||
|
initialized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PathMonitor {
|
||||||
|
/// Create a new path monitor with default (zero) initial values.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
loss_ewma: 0.0,
|
||||||
|
rtt_ewma: 0.0,
|
||||||
|
jitter_ewma: 0.0,
|
||||||
|
bytes_sent: 0,
|
||||||
|
bytes_received: 0,
|
||||||
|
first_send_time_ms: None,
|
||||||
|
last_send_time_ms: None,
|
||||||
|
first_recv_time_ms: None,
|
||||||
|
last_recv_time_ms: None,
|
||||||
|
highest_sent_seq: None,
|
||||||
|
total_sent: 0,
|
||||||
|
total_received: 0,
|
||||||
|
last_rtt_ms: None,
|
||||||
|
initialized: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that we sent a packet with the given sequence number and timestamp.
|
||||||
|
pub fn observe_sent(&mut self, seq: u16, timestamp_ms: u64) {
|
||||||
|
self.total_sent += 1;
|
||||||
|
self.highest_sent_seq = Some(seq);
|
||||||
|
|
||||||
|
if self.first_send_time_ms.is_none() {
|
||||||
|
self.first_send_time_ms = Some(timestamp_ms);
|
||||||
|
}
|
||||||
|
self.last_send_time_ms = Some(timestamp_ms);
|
||||||
|
|
||||||
|
// Estimate ~100 bytes per packet for bandwidth calculation
|
||||||
|
self.bytes_sent += 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that we received a packet with the given sequence number and timestamp.
|
||||||
|
pub fn observe_received(&mut self, seq: u16, timestamp_ms: u64) {
|
||||||
|
self.total_received += 1;
|
||||||
|
|
||||||
|
if self.first_recv_time_ms.is_none() {
|
||||||
|
self.first_recv_time_ms = Some(timestamp_ms);
|
||||||
|
}
|
||||||
|
self.last_recv_time_ms = Some(timestamp_ms);
|
||||||
|
|
||||||
|
self.bytes_received += 100;
|
||||||
|
|
||||||
|
// Estimate loss from sequence gaps.
|
||||||
|
// After we've sent some packets, compute instantaneous loss.
|
||||||
|
if self.total_sent > 0 {
|
||||||
|
let expected = self.total_sent;
|
||||||
|
let received = self.total_received;
|
||||||
|
let inst_loss = if expected > received {
|
||||||
|
((expected - received) as f64 / expected as f64) * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
if !self.initialized {
|
||||||
|
self.loss_ewma = inst_loss;
|
||||||
|
self.initialized = true;
|
||||||
|
} else {
|
||||||
|
self.loss_ewma = ALPHA * inst_loss + (1.0 - ALPHA) * self.loss_ewma;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = seq; // seq used implicitly via total counts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record an RTT observation in milliseconds.
|
||||||
|
pub fn observe_rtt(&mut self, rtt_ms: u32) {
|
||||||
|
let rtt = rtt_ms as f64;
|
||||||
|
|
||||||
|
// Update jitter (difference from last RTT, smoothed)
|
||||||
|
if let Some(last_rtt) = self.last_rtt_ms {
|
||||||
|
let diff = (rtt - last_rtt).abs();
|
||||||
|
if self.jitter_ewma == 0.0 {
|
||||||
|
self.jitter_ewma = diff;
|
||||||
|
} else {
|
||||||
|
self.jitter_ewma = ALPHA * diff + (1.0 - ALPHA) * self.jitter_ewma;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_rtt_ms = Some(rtt);
|
||||||
|
|
||||||
|
// Update RTT EWMA
|
||||||
|
if self.rtt_ewma == 0.0 {
|
||||||
|
self.rtt_ewma = rtt;
|
||||||
|
} else {
|
||||||
|
self.rtt_ewma = ALPHA * rtt + (1.0 - ALPHA) * self.rtt_ewma;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current estimated path quality.
|
||||||
|
pub fn quality(&self) -> PathQuality {
|
||||||
|
let bandwidth_kbps = self.estimate_bandwidth_kbps();
|
||||||
|
|
||||||
|
PathQuality {
|
||||||
|
loss_pct: self.loss_ewma as f32,
|
||||||
|
rtt_ms: self.rtt_ewma as u32,
|
||||||
|
jitter_ms: self.jitter_ewma as u32,
|
||||||
|
bandwidth_kbps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimate bandwidth in kbps from bytes received over time.
|
||||||
|
fn estimate_bandwidth_kbps(&self) -> u32 {
|
||||||
|
if let (Some(first), Some(last)) = (self.first_recv_time_ms, self.last_recv_time_ms) {
|
||||||
|
let duration_ms = last.saturating_sub(first);
|
||||||
|
if duration_ms > 0 {
|
||||||
|
// bytes_received * 8 bits / duration_ms * 1000 ms/s / 1000 bits/kbit
|
||||||
|
let bits = self.bytes_received * 8;
|
||||||
|
let kbps = bits as f64 / duration_ms as f64;
|
||||||
|
return kbps as u32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PathMonitor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initial_quality_is_zero() {
|
||||||
|
let monitor = PathMonitor::new();
|
||||||
|
let q = monitor.quality();
|
||||||
|
assert_eq!(q.loss_pct, 0.0);
|
||||||
|
assert_eq!(q.rtt_ms, 0);
|
||||||
|
assert_eq!(q.jitter_ms, 0);
|
||||||
|
assert_eq!(q.bandwidth_kbps, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rtt_ewma_smoothing() {
|
||||||
|
let mut monitor = PathMonitor::new();
|
||||||
|
|
||||||
|
// First observation sets the initial value
|
||||||
|
monitor.observe_rtt(100);
|
||||||
|
let q = monitor.quality();
|
||||||
|
assert_eq!(q.rtt_ms, 100);
|
||||||
|
|
||||||
|
// Second observation should be smoothed: 0.1 * 200 + 0.9 * 100 = 110
|
||||||
|
monitor.observe_rtt(200);
|
||||||
|
let q = monitor.quality();
|
||||||
|
assert_eq!(q.rtt_ms, 110);
|
||||||
|
|
||||||
|
// Third: 0.1 * 200 + 0.9 * 110 = 119
|
||||||
|
monitor.observe_rtt(200);
|
||||||
|
let q = monitor.quality();
|
||||||
|
assert_eq!(q.rtt_ms, 119);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jitter_from_rtt_variance() {
|
||||||
|
let mut monitor = PathMonitor::new();
|
||||||
|
|
||||||
|
monitor.observe_rtt(100);
|
||||||
|
// No jitter yet (only one observation)
|
||||||
|
assert_eq!(monitor.quality().jitter_ms, 0);
|
||||||
|
|
||||||
|
monitor.observe_rtt(150);
|
||||||
|
// Jitter = |150 - 100| = 50 (first jitter observation, sets directly)
|
||||||
|
assert_eq!(monitor.quality().jitter_ms, 50);
|
||||||
|
|
||||||
|
monitor.observe_rtt(140);
|
||||||
|
// diff = |140 - 150| = 10
|
||||||
|
// jitter = 0.1 * 10 + 0.9 * 50 = 46
|
||||||
|
assert_eq!(monitor.quality().jitter_ms, 46);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_packet_loss_from_gaps() {
|
||||||
|
let mut monitor = PathMonitor::new();
|
||||||
|
|
||||||
|
// Send 10 packets
|
||||||
|
for i in 0..10 {
|
||||||
|
monitor.observe_sent(i, i as u64 * 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive only 7 of them (30% loss)
|
||||||
|
for i in [0u16, 1, 2, 3, 5, 7, 9] {
|
||||||
|
monitor.observe_received(i, i as u64 * 20 + 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
let q = monitor.quality();
|
||||||
|
// After 7 observations, the EWMA should converge towards 30%
|
||||||
|
// The exact value depends on the EWMA progression
|
||||||
|
assert!(q.loss_pct > 0.0, "should detect some loss");
|
||||||
|
assert!(q.loss_pct < 100.0, "loss should be reasonable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bandwidth_estimation() {
|
||||||
|
let mut monitor = PathMonitor::new();
|
||||||
|
|
||||||
|
// Receive 100 packets over 1000ms, each ~100 bytes
|
||||||
|
for i in 0..100 {
|
||||||
|
monitor.observe_received(i, i as u64 * 10);
|
||||||
|
monitor.observe_sent(i, i as u64 * 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
let q = monitor.quality();
|
||||||
|
// 100 packets * 100 bytes * 8 bits / 990ms ~= 80.8 kbps
|
||||||
|
assert!(q.bandwidth_kbps > 0, "should estimate non-zero bandwidth");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_loss_when_all_received() {
|
||||||
|
let mut monitor = PathMonitor::new();
|
||||||
|
|
||||||
|
for i in 0..20 {
|
||||||
|
monitor.observe_sent(i, i as u64 * 20);
|
||||||
|
monitor.observe_received(i, i as u64 * 20 + 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
let q = monitor.quality();
|
||||||
|
assert!(
|
||||||
|
q.loss_pct < 1.0,
|
||||||
|
"loss should be near zero when all packets received"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
crates/wzp-transport/src/quic.rs
Normal file
130
crates/wzp-transport/src/quic.rs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
//! `QuinnTransport` — implements `MediaTransport` trait from wzp-proto.
|
||||||
|
//!
|
||||||
|
//! Wraps a `quinn::Connection` and provides unreliable media (DATAGRAM frames)
|
||||||
|
//! and reliable signaling (QUIC streams).
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use wzp_proto::{MediaPacket, MediaTransport, PathQuality, SignalMessage, TransportError};
|
||||||
|
|
||||||
|
use crate::datagram;
|
||||||
|
use crate::path_monitor::PathMonitor;
|
||||||
|
use crate::reliable;
|
||||||
|
|
||||||
|
/// QUIC-based transport implementing the `MediaTransport` trait.
|
||||||
|
pub struct QuinnTransport {
|
||||||
|
connection: quinn::Connection,
|
||||||
|
path_monitor: Mutex<PathMonitor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuinnTransport {
|
||||||
|
/// Create a new transport wrapping an established QUIC connection.
|
||||||
|
pub fn new(connection: quinn::Connection) -> Self {
|
||||||
|
Self {
|
||||||
|
connection,
|
||||||
|
path_monitor: Mutex::new(PathMonitor::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a reference to the underlying QUIC connection.
|
||||||
|
pub fn connection(&self) -> &quinn::Connection {
|
||||||
|
&self.connection
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the maximum datagram payload size, if datagrams are supported.
|
||||||
|
pub fn max_datagram_size(&self) -> Option<usize> {
|
||||||
|
datagram::max_datagram_payload(&self.connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl MediaTransport for QuinnTransport {
|
||||||
|
async fn send_media(&self, packet: &MediaPacket) -> Result<(), TransportError> {
|
||||||
|
let data = datagram::serialize_media(packet);
|
||||||
|
|
||||||
|
// Check MTU
|
||||||
|
if let Some(max_size) = self.connection.max_datagram_size() {
|
||||||
|
if data.len() > max_size {
|
||||||
|
return Err(TransportError::DatagramTooLarge {
|
||||||
|
size: data.len(),
|
||||||
|
max: max_size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record send observation
|
||||||
|
{
|
||||||
|
let mut monitor = self.path_monitor.lock().unwrap();
|
||||||
|
monitor.observe_sent(packet.header.seq, packet.header.timestamp as u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.connection.send_datagram(data).map_err(|e| {
|
||||||
|
TransportError::Internal(format!("send datagram error: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recv_media(&self) -> Result<Option<MediaPacket>, TransportError> {
|
||||||
|
let data = match self.connection.read_datagram().await {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(quinn::ConnectionError::ApplicationClosed(_)) => return Ok(None),
|
||||||
|
Err(quinn::ConnectionError::LocallyClosed) => return Ok(None),
|
||||||
|
Err(e) => {
|
||||||
|
return Err(TransportError::Internal(format!(
|
||||||
|
"recv datagram error: {e}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match datagram::deserialize_media(data) {
|
||||||
|
Some(packet) => {
|
||||||
|
// Record receive observation
|
||||||
|
{
|
||||||
|
let mut monitor = self.path_monitor.lock().unwrap();
|
||||||
|
monitor.observe_received(
|
||||||
|
packet.header.seq,
|
||||||
|
packet.header.timestamp as u64,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Some(packet))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!("received malformed media datagram");
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_signal(&self, msg: &SignalMessage) -> Result<(), TransportError> {
|
||||||
|
reliable::send_signal(&self.connection, msg).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recv_signal(&self) -> Result<Option<SignalMessage>, TransportError> {
|
||||||
|
match self.connection.accept_bi().await {
|
||||||
|
Ok((_send, mut recv)) => {
|
||||||
|
let msg = reliable::recv_signal(&mut recv).await?;
|
||||||
|
Ok(Some(msg))
|
||||||
|
}
|
||||||
|
Err(quinn::ConnectionError::ApplicationClosed(_)) => Ok(None),
|
||||||
|
Err(quinn::ConnectionError::LocallyClosed) => Ok(None),
|
||||||
|
Err(e) => Err(TransportError::Internal(format!(
|
||||||
|
"accept stream error: {e}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_quality(&self) -> PathQuality {
|
||||||
|
let monitor = self.path_monitor.lock().unwrap();
|
||||||
|
monitor.quality()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close(&self) -> Result<(), TransportError> {
|
||||||
|
self.connection.close(
|
||||||
|
quinn::VarInt::from_u32(0),
|
||||||
|
b"normal close",
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
58
crates/wzp-transport/src/reliable.rs
Normal file
58
crates/wzp-transport/src/reliable.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
//! Reliable stream transport for signaling messages.
|
||||||
|
//!
|
||||||
|
//! Uses length-prefixed framing (4-byte big-endian length + serde_json) over QUIC streams.
|
||||||
|
|
||||||
|
use bytes::{BufMut, BytesMut};
|
||||||
|
use quinn::Connection;
|
||||||
|
use wzp_proto::{SignalMessage, TransportError};
|
||||||
|
|
||||||
|
/// Send a signaling message over a new bidirectional QUIC stream.
|
||||||
|
///
|
||||||
|
/// Opens a new bidi stream, writes a length-prefixed JSON frame, then finishes the send side.
|
||||||
|
pub async fn send_signal(connection: &Connection, msg: &SignalMessage) -> Result<(), TransportError> {
|
||||||
|
let (mut send, _recv) = connection.open_bi().await.map_err(|e| {
|
||||||
|
TransportError::Internal(format!("failed to open bidi stream: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let json = serde_json::to_vec(msg)
|
||||||
|
.map_err(|e| TransportError::Internal(format!("signal serialize error: {e}")))?;
|
||||||
|
|
||||||
|
let mut frame = BytesMut::with_capacity(4 + json.len());
|
||||||
|
frame.put_u32(json.len() as u32);
|
||||||
|
frame.put_slice(&json);
|
||||||
|
|
||||||
|
send.write_all(&frame)
|
||||||
|
.await
|
||||||
|
.map_err(|e| TransportError::Internal(format!("stream write error: {e}")))?;
|
||||||
|
|
||||||
|
send.finish()
|
||||||
|
.map_err(|e| TransportError::Internal(format!("stream finish error: {e}")))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive a signaling message from a QUIC receive stream.
|
||||||
|
///
|
||||||
|
/// Reads a 4-byte big-endian length prefix, then the JSON payload.
|
||||||
|
pub async fn recv_signal(recv: &mut quinn::RecvStream) -> Result<SignalMessage, TransportError> {
|
||||||
|
// Read 4-byte length prefix
|
||||||
|
let mut len_buf = [0u8; 4];
|
||||||
|
recv.read_exact(&mut len_buf)
|
||||||
|
.await
|
||||||
|
.map_err(|e| TransportError::Internal(format!("stream read length error: {e}")))?;
|
||||||
|
|
||||||
|
let len = u32::from_be_bytes(len_buf) as usize;
|
||||||
|
if len > 1_048_576 {
|
||||||
|
return Err(TransportError::Internal(format!(
|
||||||
|
"signal message too large: {len} bytes"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut payload = vec![0u8; len];
|
||||||
|
recv.read_exact(&mut payload)
|
||||||
|
.await
|
||||||
|
.map_err(|e| TransportError::Internal(format!("stream read payload error: {e}")))?;
|
||||||
|
|
||||||
|
serde_json::from_slice(&payload)
|
||||||
|
.map_err(|e| TransportError::Internal(format!("signal deserialize error: {e}")))
|
||||||
|
}
|
||||||
62
docs/featherchat.md
Normal file
62
docs/featherchat.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# FeatherChat: Voice/Video Calling Integration with Warzone Messenger
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Voice/video calling system designed to integrate with the existing E2E encrypted Warzone messenger. Reuses the same identity, addressing, and key exchange infrastructure.
|
||||||
|
|
||||||
|
## Identity Model (reuse, not duplicate)
|
||||||
|
|
||||||
|
- **Identity**: 32-byte seed derives both keypairs via HKDF:
|
||||||
|
- Ed25519 (signing)
|
||||||
|
- X25519 (encryption)
|
||||||
|
- **Fingerprint**: `SHA-256(Ed25519 public key)[:16]`, displayed as `xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx`
|
||||||
|
- **Backup**: BIP39 mnemonic (24 words) for seed recovery
|
||||||
|
- **Storage**: Seed encrypted at rest with Argon2id + ChaCha20-Poly1305
|
||||||
|
- **Future**: Ethereum address as fingerprint (secp256k1 derived from same BIP39 seed)
|
||||||
|
|
||||||
|
## Addressing (reuse)
|
||||||
|
|
||||||
|
| Method | Format | Resolution |
|
||||||
|
|--------|--------|------------|
|
||||||
|
| Local alias | `@manwe` | Server resolves to fingerprint |
|
||||||
|
| Federated | `@manwe.b1.example.com` | DNS TXT record → fingerprint + server endpoint |
|
||||||
|
| ENS | `@manwe.eth` | Ethereum address → fingerprint (Phase 2-3) |
|
||||||
|
| Raw fingerprint | `xxxx:xxxx:...` | Direct lookup (always works as fallback) |
|
||||||
|
|
||||||
|
## Key Exchange (can extend)
|
||||||
|
|
||||||
|
- **X3DH** for session establishment:
|
||||||
|
- Ed25519 identity key
|
||||||
|
- X25519 ephemeral key
|
||||||
|
- Signed pre-keys
|
||||||
|
- **Double Ratchet** for forward secrecy on data channels
|
||||||
|
- **Pre-key bundles** stored on server, fetched by callers
|
||||||
|
|
||||||
|
## Server Infrastructure
|
||||||
|
|
||||||
|
- **Stack**: Rust (axum), sled DB, WebSocket for real-time
|
||||||
|
- **Trust model**: Server is untrusted relay — never sees plaintext
|
||||||
|
- **Groups**: Named, auto-created, per-member encryption
|
||||||
|
- **Federation**: Via DNS TXT records (Phase 3)
|
||||||
|
|
||||||
|
## Calling System Requirements
|
||||||
|
|
||||||
|
1. **Signaling**: Reuse existing WebSocket connection and identity
|
||||||
|
2. **Key derivation**: SRTP/DTLS keys derived from existing X3DH shared secret (or new ephemeral exchange per call)
|
||||||
|
3. **Call initiation**: `WireMessage::CallOffer`, `CallAnswer`, `CallIceCandidate` variants
|
||||||
|
4. **NAT traversal**: STUN/TURN server integration
|
||||||
|
5. **Group calls**: SFU (Selective Forwarding Unit) vs mesh topology for up to 50 users
|
||||||
|
6. **Codecs**: Opus for audio, VP8/VP9/AV1 for video
|
||||||
|
7. **E2E media encryption**: Insertable streams API (WebRTC) or custom SRTP
|
||||||
|
8. **Unified addressing**: A user calls `@manwe` the same way they message `@manwe`
|
||||||
|
|
||||||
|
## Degradation Strategy
|
||||||
|
|
||||||
|
Calls should degrade gracefully under unreliable/warzone network conditions:
|
||||||
|
|
||||||
|
```
|
||||||
|
Video (full) → Video (low res) → Audio (high quality) → Audio (low bitrate)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Support opportunistic cooperation
|
||||||
|
- Fall back to TURN/TCP through the existing WebSocket when UDP is blocked
|
||||||
Reference in New Issue
Block a user