//! 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 (0–104). Passed directly to /// `OpusEncoder::set_dred_duration()`. pub dred_frames: u8, /// Expected packet loss percentage (0–100). 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.0–100.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 { 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()); } }