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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user