- 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>
313 lines
10 KiB
Rust
313 lines
10 KiB
Rust
//! 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<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());
|
||
}
|
||
}
|