Files
wz-phone/crates/wzp-proto/src/dred_tuner.rs
Siavash Sameni 766c9df442 feat(dred): continuous DRED tuning, PMTUD, extended Opus6k window
- DredTuner: maps live network metrics (loss/RTT/jitter) to continuous
  DRED duration every ~500ms instead of discrete tier-locked values.
  Includes jitter-spike detection for pre-emptive Starlink-style boost.
- Opus6k DRED extended from 500ms to 1040ms (max libopus 1.5 supports)
- PMTUD: quinn MtuDiscoveryConfig with upper_bound=1452, 300s interval
- TrunkedForwarder respects discovered MTU (was hard-coded 1200)
- QuinnPathSnapshot exposes quinn internal stats + discovered MTU
- AudioEncoder trait: set_expected_loss() + set_dred_duration() methods
- PathMonitor: sliding-window jitter variance for spike detection
- Integrated into both Android and desktop send tasks in engine.rs
- 14 new tests (10 tuner unit + 4 encoder integration)
- Updated ARCHITECTURE.md, PROGRESS.md, PRD-dred-integration, PRD-mtu

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:38:37 +04:00

313 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Continuous DRED tuning from real-time network metrics.
//!
//! Instead of locking DRED duration to 3 discrete quality tiers (100/200/500 ms),
//! `DredTuner` maps live path quality metrics to a continuous DRED duration and
//! expected-loss hint, updated every N packets. This makes DRED reactive within
//! ~200 ms instead of waiting for 3+ consecutive bad quality reports to trigger
//! a full tier transition.
//!
//! The tuner also implements pre-emptive jitter-spike detection ("sawtooth"
//! prediction): when jitter variance spikes >30% over a 200 ms window — typical
//! of Starlink satellite handovers — it temporarily boosts DRED to the maximum
//! allowed for the current codec before packets actually start dropping.
use crate::CodecId;
/// Output of a single tuning cycle.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DredTuning {
/// DRED duration in 10 ms frame units (0104). Passed directly to
/// `OpusEncoder::set_dred_duration()`.
pub dred_frames: u8,
/// Expected packet loss percentage (0100). Passed to
/// `OpusEncoder::set_expected_loss()`. Floored at 15% by the encoder
/// itself, but we pass the real value so the encoder can override upward.
pub expected_loss_pct: u8,
}
/// Minimum DRED frames for any Opus codec (matches DRED_LOSS_FLOOR_PCT logic:
/// at 15% loss, libopus 1.5 emits ~95 ms of DRED, which needs at least 10
/// frames configured to be useful).
const MIN_DRED_FRAMES: u8 = 5;
/// Maximum DRED frames libopus supports (104 × 10 ms = 1040 ms).
const MAX_DRED_FRAMES: u8 = 104;
/// Jitter variance spike ratio that triggers pre-emptive DRED boost.
const JITTER_SPIKE_RATIO: f32 = 1.3;
/// How many tuning cycles a jitter-spike boost persists (at 25 packets/cycle
/// and 20 ms/packet, 10 cycles ≈ 5 seconds).
const SPIKE_BOOST_COOLDOWN_CYCLES: u32 = 10;
/// Maps codec tier to its baseline DRED frames (used when network is healthy).
fn baseline_dred_frames(codec: CodecId) -> u8 {
match codec {
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 10, // 100 ms
CodecId::Opus16k | CodecId::Opus24k => 20, // 200 ms
CodecId::Opus6k => 50, // 500 ms
_ => 0,
}
}
/// Maps codec tier to its maximum allowed DRED frames under spike/bad conditions.
fn max_dred_frames_for(codec: CodecId) -> u8 {
match codec {
// Studio: cap at 300 ms (don't waste bitrate on good links)
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 30,
// Normal: cap at 500 ms
CodecId::Opus16k | CodecId::Opus24k => 50,
// Degraded: allow full 1040 ms
CodecId::Opus6k => MAX_DRED_FRAMES,
_ => 0,
}
}
/// Continuous DRED tuner driven by network path metrics.
pub struct DredTuner {
/// Current codec (determines baseline and ceiling).
codec: CodecId,
/// Last computed tuning output.
last_tuning: DredTuning,
/// EWMA-smoothed jitter for spike detection (in ms).
jitter_ewma: f32,
/// Remaining cooldown cycles for a jitter-spike boost.
spike_cooldown: u32,
/// Whether the tuner has received at least one observation.
initialized: bool,
}
impl DredTuner {
/// Create a new tuner for the given codec.
pub fn new(codec: CodecId) -> Self {
let baseline = baseline_dred_frames(codec);
Self {
codec,
last_tuning: DredTuning {
dred_frames: baseline,
expected_loss_pct: 15, // match DRED_LOSS_FLOOR_PCT
},
jitter_ewma: 0.0,
spike_cooldown: 0,
initialized: false,
}
}
/// Update the active codec (e.g. on tier transition). Resets spike state.
pub fn set_codec(&mut self, codec: CodecId) {
self.codec = codec;
self.spike_cooldown = 0;
}
/// Feed network metrics and compute new DRED parameters.
///
/// Call this every tuning cycle (e.g. every 25 packets ≈ 500 ms at 20 ms
/// frame duration).
///
/// - `loss_pct`: observed packet loss (0.0100.0)
/// - `rtt_ms`: smoothed round-trip time
/// - `jitter_ms`: current jitter estimate (RTT variance)
///
/// Returns `Some(tuning)` if the output changed, `None` if unchanged.
pub fn update(&mut self, loss_pct: f32, rtt_ms: u32, jitter_ms: u32) -> Option<DredTuning> {
if !self.codec.is_opus() {
return None;
}
let baseline = baseline_dred_frames(self.codec);
let ceiling = max_dred_frames_for(self.codec);
// --- Jitter spike detection ---
let jitter_f = jitter_ms as f32;
if !self.initialized {
self.jitter_ewma = jitter_f;
self.initialized = true;
} else {
// Fast-up (alpha=0.3), slow-down (alpha=0.05) asymmetric EWMA
let alpha = if jitter_f > self.jitter_ewma { 0.3 } else { 0.05 };
self.jitter_ewma = alpha * jitter_f + (1.0 - alpha) * self.jitter_ewma;
}
// Detect spike: instantaneous jitter > EWMA × 1.3
if self.jitter_ewma > 1.0 && jitter_f > self.jitter_ewma * JITTER_SPIKE_RATIO {
self.spike_cooldown = SPIKE_BOOST_COOLDOWN_CYCLES;
}
// Decrement cooldown
if self.spike_cooldown > 0 {
self.spike_cooldown -= 1;
}
// --- Compute DRED frames ---
let dred_frames = if self.spike_cooldown > 0 {
// During spike boost: jump to ceiling
ceiling
} else {
// Continuous mapping: scale linearly between baseline and ceiling
// based on loss percentage.
// 0% loss → baseline
// 40% loss → ceiling
let loss_clamped = loss_pct.clamp(0.0, 40.0);
let t = loss_clamped / 40.0;
let raw = baseline as f32 + t * (ceiling - baseline) as f32;
(raw as u8).clamp(MIN_DRED_FRAMES, ceiling)
};
// --- Compute expected loss hint ---
// Pass the real loss so the encoder can clamp at its own floor (15%).
// For RTT-driven boost: high RTT suggests impending loss, so add a
// phantom loss contribution to keep DRED emitting generously.
let rtt_loss_phantom = if rtt_ms > 200 {
((rtt_ms - 200) as f32 / 40.0).min(15.0)
} else {
0.0
};
let expected_loss = (loss_pct + rtt_loss_phantom).clamp(0.0, 100.0) as u8;
let tuning = DredTuning {
dred_frames,
expected_loss_pct: expected_loss,
};
if tuning != self.last_tuning {
self.last_tuning = tuning;
Some(tuning)
} else {
None
}
}
/// Get the last computed tuning without updating.
pub fn current(&self) -> DredTuning {
self.last_tuning
}
/// Whether a jitter-spike boost is currently active.
pub fn spike_boost_active(&self) -> bool {
self.spike_cooldown > 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn baseline_for_opus24k() {
let tuner = DredTuner::new(CodecId::Opus24k);
assert_eq!(tuner.current().dred_frames, 20); // 200 ms
}
#[test]
fn baseline_for_opus6k() {
let tuner = DredTuner::new(CodecId::Opus6k);
assert_eq!(tuner.current().dred_frames, 50); // 500 ms
}
#[test]
fn codec2_returns_none() {
let mut tuner = DredTuner::new(CodecId::Codec2_1200);
assert!(tuner.update(10.0, 100, 20).is_none());
}
#[test]
fn scales_with_loss() {
let mut tuner = DredTuner::new(CodecId::Opus24k);
// 0% loss → baseline (20 frames)
tuner.update(0.0, 50, 5);
assert_eq!(tuner.current().dred_frames, 20);
// 20% loss → midpoint between 20 and 50 = 35
tuner.update(20.0, 50, 5);
assert_eq!(tuner.current().dred_frames, 35);
// 40%+ loss → ceiling (50 frames)
tuner.update(40.0, 50, 5);
assert_eq!(tuner.current().dred_frames, 50);
}
#[test]
fn jitter_spike_triggers_boost() {
let mut tuner = DredTuner::new(CodecId::Opus24k);
// Establish baseline jitter
for _ in 0..20 {
tuner.update(0.0, 50, 10);
}
assert!(!tuner.spike_boost_active());
// Spike: jitter jumps to 50 ms (5x the EWMA of ~10)
tuner.update(0.0, 50, 50);
assert!(tuner.spike_boost_active());
// Should be at ceiling (50 frames = 500 ms for Opus24k)
assert_eq!(tuner.current().dred_frames, 50);
}
#[test]
fn spike_cooldown_decays() {
let mut tuner = DredTuner::new(CodecId::Opus24k);
// Establish baseline then spike
for _ in 0..20 {
tuner.update(0.0, 50, 10);
}
tuner.update(0.0, 50, 50);
assert!(tuner.spike_boost_active());
// Run through cooldown
for _ in 0..SPIKE_BOOST_COOLDOWN_CYCLES {
tuner.update(0.0, 50, 10);
}
assert!(!tuner.spike_boost_active());
// Should return to baseline
assert_eq!(tuner.current().dred_frames, 20);
}
#[test]
fn rtt_phantom_loss() {
let mut tuner = DredTuner::new(CodecId::Opus24k);
// High RTT (400ms) with 0% real loss
tuner.update(0.0, 400, 10);
// Phantom loss = (400-200)/40 = 5
assert_eq!(tuner.current().expected_loss_pct, 5);
}
#[test]
fn set_codec_resets_spike() {
let mut tuner = DredTuner::new(CodecId::Opus24k);
// Trigger spike
for _ in 0..20 {
tuner.update(0.0, 50, 10);
}
tuner.update(0.0, 50, 50);
assert!(tuner.spike_boost_active());
// Switch codec — spike should reset
tuner.set_codec(CodecId::Opus6k);
assert!(!tuner.spike_boost_active());
}
#[test]
fn opus6k_reaches_max_1040ms() {
let mut tuner = DredTuner::new(CodecId::Opus6k);
// High loss → should reach 104 frames (1040 ms)
tuner.update(40.0, 50, 5);
assert_eq!(tuner.current().dred_frames, MAX_DRED_FRAMES);
}
#[test]
fn returns_none_when_unchanged() {
let mut tuner = DredTuner::new(CodecId::Opus24k);
// First update always returns Some (initial → computed)
let first = tuner.update(0.0, 50, 5);
// Same inputs → None
let second = tuner.update(0.0, 50, 5);
assert!(first.is_some() || second.is_none());
}
}