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>
This commit is contained in:
Siavash Sameni
2026-04-12 19:38:37 +04:00
parent 24cc74d93c
commit 766c9df442
19 changed files with 829 additions and 32 deletions

View File

@@ -445,6 +445,15 @@ impl CallEncoder {
self.aec.feed_farend(farend);
}
/// Apply DRED tuning output to the encoder.
///
/// Called by the send loop after `DredTuner::update()` returns `Some`.
/// No-op when the active codec is Codec2 (DRED is Opus-only).
pub fn apply_dred_tuning(&mut self, tuning: wzp_proto::DredTuning) {
self.audio_enc.set_dred_duration(tuning.dred_frames);
self.audio_enc.set_expected_loss(tuning.expected_loss_pct);
}
/// Enable or disable acoustic echo cancellation.
pub fn set_aec_enabled(&mut self, enabled: bool) {
self.aec.set_enabled(enabled);
@@ -1442,4 +1451,131 @@ mod tests {
"frames_suppressed should be > 0"
);
}
// ---- DredTuner integration tests ----
/// End-to-end test: DredTuner reacts to simulated network degradation
/// and adjusts the encoder's DRED parameters via `apply_dred_tuning`.
#[test]
fn dred_tuner_adjusts_encoder_on_loss() {
use wzp_proto::DredTuner;
let mut enc = CallEncoder::new(&CallConfig {
profile: QualityProfile::GOOD,
suppression_enabled: false,
..Default::default()
});
let mut tuner = DredTuner::new(QualityProfile::GOOD.codec);
// Baseline: good network → baseline DRED (20 frames = 200 ms).
let baseline = tuner.current();
assert_eq!(baseline.dred_frames, 20);
// Warm up the tuner — first few updates may return Some as the
// EWMA initializes and expected_loss settles from the initial 15%.
for _ in 0..10 {
tuner.update(0.0, 50, 5);
}
// After settling, the tuning should be at baseline.
assert_eq!(tuner.current().dred_frames, 20);
// Simulate network degradation: 30% loss, 300ms RTT.
// The tuner should increase DRED frames above baseline.
let tuning = tuner.update(30.0, 300, 15);
assert!(tuning.is_some(), "loss spike should trigger tuning change");
let t = tuning.unwrap();
assert!(
t.dred_frames > 20,
"30% loss should increase DRED above baseline 20, got {}",
t.dred_frames
);
// Apply to encoder — should not panic.
enc.apply_dred_tuning(t);
// Verify the encoder still works after tuning.
let pcm = voice_frame_20ms(0);
let packets = enc.encode_frame(&pcm).unwrap();
assert!(!packets.is_empty(), "encoder must still produce packets after DRED tuning");
}
/// DredTuner jitter spike triggers pre-emptive DRED boost to ceiling.
#[test]
fn dred_tuner_spike_boosts_to_ceiling() {
use wzp_proto::DredTuner;
let mut tuner = DredTuner::new(CodecId::Opus24k);
// Establish low-jitter baseline.
for _ in 0..20 {
tuner.update(0.0, 50, 5);
}
assert!(!tuner.spike_boost_active());
// Jitter spikes to 40ms (8x baseline of ~5ms).
let tuning = tuner.update(0.0, 50, 40);
assert!(tuner.spike_boost_active(), "jitter spike should activate boost");
assert!(tuning.is_some());
// Ceiling for Opus24k is 50 frames = 500 ms.
assert_eq!(
tuning.unwrap().dred_frames, 50,
"spike should push to ceiling"
);
}
/// DredTuner is a no-op for Codec2 profiles.
#[test]
fn dred_tuner_noop_for_codec2() {
use wzp_proto::DredTuner;
let mut tuner = DredTuner::new(CodecId::Codec2_1200);
// Even extreme conditions produce no tuning output.
assert!(tuner.update(50.0, 800, 100).is_none());
assert_eq!(tuner.current().dred_frames, 0);
}
/// DredTuner + CallEncoder: full cycle through profile switch.
#[test]
fn dred_tuner_handles_profile_switch() {
use wzp_proto::DredTuner;
let mut enc = CallEncoder::new(&CallConfig {
profile: QualityProfile::GOOD,
suppression_enabled: false,
..Default::default()
});
let mut tuner = DredTuner::new(QualityProfile::GOOD.codec);
// Apply initial tuning on good network.
if let Some(t) = tuner.update(0.0, 50, 5) {
enc.apply_dred_tuning(t);
}
// Switch to degraded profile.
enc.set_profile(QualityProfile::DEGRADED).unwrap();
tuner.set_codec(QualityProfile::DEGRADED.codec);
// Opus6k baseline is 50 frames (500 ms), ceiling is 104 (1040 ms).
let baseline = tuner.current();
// After set_codec, the cached tuning should reflect old state;
// a fresh update gives the new codec's mapping.
let tuning = tuner.update(20.0, 200, 10);
assert!(tuning.is_some());
let t = tuning.unwrap();
assert!(
t.dred_frames >= 50,
"Opus6k with 20% loss should be at least baseline 50, got {}",
t.dred_frames
);
enc.apply_dred_tuning(t);
// Encode a 40ms frame (Opus6k uses 40ms frames = 1920 samples).
let pcm: Vec<i16> = (0..1920)
.map(|i| ((i as f32 * 0.1).sin() * 10_000.0) as i16)
.collect();
let packets = enc.encode_frame(&pcm).unwrap();
assert!(!packets.is_empty());
}
}

View File

@@ -116,6 +116,14 @@ impl AudioEncoder for AdaptiveEncoder {
fn set_dtx(&mut self, enabled: bool) {
self.opus.set_dtx(enabled);
}
fn set_expected_loss(&mut self, loss_pct: u8) {
self.opus.set_expected_loss(loss_pct);
}
fn set_dred_duration(&mut self, frames: u8) {
self.opus.set_dred_duration(frames);
}
}
// ─── AdaptiveDecoder ─────────────────────────────────────────────────────────

View File

@@ -14,8 +14,9 @@
//! networks; short window keeps decoder CPU modest.
//! - Normal tiers (Opus 16k/24k): 200 ms — balanced baseline covering common
//! VoIP loss patterns (20150 ms bursts from wifi roam, transient congestion).
//! - Degraded tier (Opus 6k): 500 ms — users on 6k are by definition on a
//! bad link; longer DRED buys maximum burst resilience where it matters.
//! - Degraded tier (Opus 6k): 1040 ms — users on 6k are by definition on a
//! bad link; the maximum libopus DRED window buys the best burst resilience
//! where it matters. The RDO-VAE naturally degrades quality at longer offsets.
//!
//! # Why the 15% packet loss floor
//!
@@ -78,8 +79,12 @@ pub fn dred_duration_for(codec: CodecId) -> u8 {
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 10,
// Normal tiers — balanced baseline.
CodecId::Opus16k | CodecId::Opus24k => 20,
// Degraded tier — maximum burst resilience.
CodecId::Opus6k => 50,
// Degraded tier — maximum burst resilience. 104 × 10 ms = 1040 ms,
// the highest value libopus 1.5 supports. Users on 6k are on a bad
// link by definition; the RDO-VAE naturally degrades quality at longer
// offsets, so the extra window costs only ~1-2 kbps additional overhead
// while buying substantially better burst resilience (up from 500 ms).
CodecId::Opus6k => 104,
// Non-Opus (Codec2 / CN): DRED is N/A.
CodecId::Codec2_1200 | CodecId::Codec2_3200 | CodecId::ComfortNoise => 0,
}
@@ -334,6 +339,14 @@ impl AudioEncoder for OpusEncoder {
fn set_dtx(&mut self, enabled: bool) {
let _ = self.inner.set_dtx(enabled);
}
fn set_expected_loss(&mut self, loss_pct: u8) {
OpusEncoder::set_expected_loss(self, loss_pct);
}
fn set_dred_duration(&mut self, frames: u8) {
OpusEncoder::set_dred_duration(self, frames);
}
}
#[cfg(test)]
@@ -389,8 +402,8 @@ mod tests {
}
#[test]
fn dred_duration_for_degraded_tier_is_500ms() {
assert_eq!(dred_duration_for(CodecId::Opus6k), 50);
fn dred_duration_for_degraded_tier_is_1040ms() {
assert_eq!(dred_duration_for(CodecId::Opus6k), 104);
}
#[test]

View File

@@ -0,0 +1,312 @@
//! 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());
}
}

View File

@@ -14,6 +14,7 @@
pub mod bandwidth;
pub mod codec_id;
pub mod dred_tuner;
pub mod error;
pub mod jitter;
pub mod packet;
@@ -30,6 +31,7 @@ pub use packet::{
FRAME_TYPE_MINI,
};
pub use bandwidth::{BandwidthEstimator, CongestionState};
pub use dred_tuner::{DredTuner, DredTuning};
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
pub use session::{Session, SessionEvent, SessionState};
pub use traits::*;

View File

@@ -1100,6 +1100,7 @@ mod tests {
supported_profiles: vec![],
caller_reflexive_addr: Some("192.0.2.1:4433".into()),
caller_local_addrs: Vec::new(),
caller_build_version: None,
};
let forward = SignalMessage::FederatedSignalForward {
inner: Box::new(inner),
@@ -1142,6 +1143,7 @@ mod tests {
chosen_profile: None,
callee_reflexive_addr: Some("198.51.100.9:4433".into()),
callee_local_addrs: Vec::new(),
callee_build_version: None,
},
SignalMessage::CallRinging { call_id: "c1".into() },
SignalMessage::Hangup { reason: HangupReason::Normal, call_id: None },
@@ -1177,6 +1179,7 @@ mod tests {
supported_profiles: vec![],
caller_reflexive_addr: Some("192.0.2.1:4433".into()),
caller_local_addrs: Vec::new(),
caller_build_version: None,
};
let json = serde_json::to_string(&offer).unwrap();
assert!(
@@ -1205,6 +1208,7 @@ mod tests {
supported_profiles: vec![],
caller_reflexive_addr: None,
caller_local_addrs: Vec::new(),
caller_build_version: None,
};
let json_none = serde_json::to_string(&offer_none).unwrap();
assert!(
@@ -1222,6 +1226,7 @@ mod tests {
chosen_profile: None,
callee_reflexive_addr: Some("198.51.100.9:4433".into()),
callee_local_addrs: Vec::new(),
callee_build_version: None,
};
let decoded: SignalMessage =
serde_json::from_str(&serde_json::to_string(&answer).unwrap()).unwrap();

View File

@@ -28,6 +28,13 @@ pub trait AudioEncoder: Send + Sync {
/// Enable/disable DTX (discontinuous transmission). No-op for Codec2.
fn set_dtx(&mut self, _enabled: bool) {}
/// Hint the encoder about expected packet loss (0100). In DRED mode the
/// encoder floors this at 15% internally. No-op for Codec2.
fn set_expected_loss(&mut self, _loss_pct: u8) {}
/// Set DRED duration in 10 ms frame units (0104). No-op for Codec2.
fn set_dred_duration(&mut self, _frames: u8) {}
}
/// Decodes compressed frames back to PCM audio.

View File

@@ -382,18 +382,32 @@ impl TrunkedForwarder {
/// Create a new trunked forwarder.
///
/// `session_id` tags every entry pushed into the batcher so the receiver
/// can demultiplex packets by session.
/// can demultiplex packets by session. The batcher's `max_bytes` is
/// initialized from the transport's current PMTUD-discovered MTU so that
/// trunk frames fill the largest datagram the path supports (instead of
/// the conservative 1200-byte default).
pub fn new(transport: Arc<wzp_transport::QuinnTransport>, session_id: [u8; 2]) -> Self {
let mut batcher = TrunkBatcher::new();
if let Some(mtu) = transport.max_datagram_size() {
batcher.max_bytes = mtu;
}
Self {
transport,
batcher: TrunkBatcher::new(),
batcher,
session_id,
}
}
/// Push a media packet into the batcher. If the batcher is full it will
/// flush automatically and the resulting trunk frame is sent immediately.
///
/// Also refreshes `max_bytes` from the transport's PMTUD-discovered MTU
/// so the batcher fills larger datagrams as the path MTU grows.
pub async fn send(&mut self, pkt: &wzp_proto::MediaPacket) -> anyhow::Result<()> {
// Refresh batcher limit from PMTUD (cheap: reads an atomic in quinn).
if let Some(mtu) = self.transport.max_datagram_size() {
self.batcher.max_bytes = mtu;
}
let payload: Bytes = pkt.to_bytes();
if let Some(frame) = self.batcher.push(self.session_id, payload) {
self.send_frame(&frame)?;

View File

@@ -52,6 +52,7 @@ fn alice_offer(call_id: &str) -> SignalMessage {
supported_profiles: vec![],
caller_reflexive_addr: Some(ALICE_ADDR.into()),
caller_local_addrs: Vec::new(),
caller_build_version: None,
}
}
@@ -132,6 +133,7 @@ fn bob_answer(call_id: &str) -> SignalMessage {
chosen_profile: None,
callee_reflexive_addr: Some(BOB_ADDR.into()),
callee_local_addrs: Vec::new(),
callee_build_version: None,
}
}

View File

@@ -105,6 +105,7 @@ fn mk_offer(call_id: &str, caller_reflexive_addr: Option<&str>) -> SignalMessage
supported_profiles: vec![],
caller_reflexive_addr: caller_reflexive_addr.map(String::from),
caller_local_addrs: Vec::new(),
caller_build_version: None,
}
}
@@ -122,6 +123,7 @@ fn mk_answer(
chosen_profile: None,
callee_reflexive_addr: callee_reflexive_addr.map(String::from),
callee_local_addrs: Vec::new(),
callee_build_version: None,
}
}

View File

@@ -123,7 +123,6 @@ fn transport_config() -> quinn::TransportConfig {
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
@@ -134,6 +133,26 @@ fn transport_config() -> quinn::TransportConfig {
// Aggressive initial RTT estimate for high-latency links
config.initial_rtt(Duration::from_millis(300));
// PMTUD (Path MTU Discovery) — quinn 0.11 enables this by default but
// with conservative bounds (initial 1200, upper 1452). We keep the safe
// initial_mtu of 1200 so the first packets always get through, but raise
// upper_bound so the binary search can discover larger MTUs on paths that
// support them. Typical results:
// - Ethernet/fiber: discovers ~1452 (Ethernet MTU minus IP/UDP/QUIC)
// - WireGuard/VPN: discovers ~1380-1420
// - Starlink: discovers ~1400-1452
// - Cellular: stays at 1200-1300
// Black hole detection automatically falls back to 1200 if probes fail.
// This matters for future video frames which can be 1-50 KB and benefit
// from fewer application-layer fragments per frame.
let mut mtu_config = quinn::MtuDiscoveryConfig::default();
mtu_config
.upper_bound(1452)
.interval(Duration::from_secs(300)) // re-probe every 5 min
.black_hole_cooldown(Duration::from_secs(30)); // retry faster on lossy links
config.mtu_discovery_config(Some(mtu_config));
config.initial_mtu(1200); // safe starting point
config
}

View File

@@ -25,7 +25,7 @@ pub mod reliable;
pub use config::{client_config, server_config, server_config_from_seed, tls_fingerprint};
pub use connection::{accept, connect, create_endpoint, create_ipv6_endpoint};
pub use path_monitor::PathMonitor;
pub use quic::QuinnTransport;
pub use quic::{QuinnPathSnapshot, QuinnTransport};
pub use wzp_proto::{MediaTransport, PathQuality, TransportError};
// Re-export the quinn Endpoint type so downstream crates (wzp-desktop) can

View File

@@ -2,11 +2,17 @@
//!
//! Tracks packet loss (via sequence number gaps), RTT, jitter, and bandwidth.
use std::collections::VecDeque;
use wzp_proto::PathQuality;
/// EWMA smoothing factor.
const ALPHA: f64 = 0.1;
/// Maximum number of RTT samples in the jitter variance sliding window.
/// At ~50 packets/sec (20 ms frame), 10 samples ≈ 200 ms.
const JITTER_VARIANCE_WINDOW_SIZE: usize = 10;
/// Monitors network path quality metrics.
pub struct PathMonitor {
/// EWMA-smoothed loss percentage (0.0 - 100.0).
@@ -31,6 +37,8 @@ pub struct PathMonitor {
last_rtt_ms: Option<f64>,
/// Whether we have any observations yet.
initialized: bool,
/// Sliding window of recent RTT samples for variance calculation.
rtt_window: VecDeque<f64>,
}
impl PathMonitor {
@@ -51,6 +59,7 @@ impl PathMonitor {
total_received: 0,
last_rtt_ms: None,
initialized: false,
rtt_window: VecDeque::with_capacity(JITTER_VARIANCE_WINDOW_SIZE),
}
}
@@ -122,6 +131,12 @@ impl PathMonitor {
} else {
self.rtt_ewma = ALPHA * rtt + (1.0 - ALPHA) * self.rtt_ewma;
}
// Maintain sliding window for variance calculation
if self.rtt_window.len() >= JITTER_VARIANCE_WINDOW_SIZE {
self.rtt_window.pop_front();
}
self.rtt_window.push_back(rtt);
}
/// Get the current estimated path quality.
@@ -155,6 +170,20 @@ impl PathMonitor {
0
}
/// Compute the jitter (RTT standard deviation) over the sliding window.
///
/// Returns the standard deviation in milliseconds, or 0.0 if insufficient
/// samples. Used by `DredTuner` for spike detection.
pub fn jitter_variance_ms(&self) -> f64 {
let n = self.rtt_window.len();
if n < 2 {
return 0.0;
}
let mean = self.rtt_window.iter().sum::<f64>() / n as f64;
let var = self.rtt_window.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / n as f64;
var.sqrt()
}
/// Detect whether a network handoff likely occurred.
///
/// Returns `true` if the most recent RTT jitter measurement exceeds 3x

View File

@@ -13,6 +13,29 @@ use crate::datagram;
use crate::path_monitor::PathMonitor;
use crate::reliable;
/// Snapshot of quinn's QUIC-level path statistics.
///
/// Provides more accurate loss/RTT data than `PathMonitor`'s sequence-gap
/// heuristic because quinn sees ACK frames and congestion signals directly.
#[derive(Clone, Copy, Debug)]
pub struct QuinnPathSnapshot {
/// Smoothed RTT in milliseconds (from quinn's congestion controller).
pub rtt_ms: u32,
/// Cumulative loss percentage (lost_packets / sent_packets × 100).
pub loss_pct: f32,
/// Total congestion events observed by the QUIC stack.
pub congestion_events: u64,
/// Current congestion window in bytes.
pub cwnd: u64,
/// Total packets sent on this path.
pub sent_packets: u64,
/// Total packets lost on this path.
pub lost_packets: u64,
/// Current PMTUD-discovered maximum datagram payload size (bytes).
/// Starts at `initial_mtu` (1200) and grows as PMTUD probes succeed.
pub current_mtu: usize,
}
/// QUIC-based transport implementing the `MediaTransport` trait.
pub struct QuinnTransport {
connection: quinn::Connection,
@@ -66,6 +89,31 @@ impl QuinnTransport {
datagram::max_datagram_payload(&self.connection)
}
/// Snapshot of QUIC-level path stats from quinn, useful for DRED tuning.
///
/// Returns `(rtt_ms, loss_pct, congestion_events)` derived from quinn's
/// internal congestion controller — more accurate than our own sequence-gap
/// heuristic in `PathMonitor` because quinn sees ACK frames directly.
pub fn quinn_path_stats(&self) -> QuinnPathSnapshot {
let stats = self.connection.stats();
let rtt_ms = stats.path.rtt.as_millis() as u32;
let loss_pct = if stats.path.sent_packets > 0 {
(stats.path.lost_packets as f32 / stats.path.sent_packets as f32) * 100.0
} else {
0.0
};
let current_mtu = self.connection.max_datagram_size().unwrap_or(1200);
QuinnPathSnapshot {
rtt_ms,
loss_pct,
congestion_events: stats.path.congestion_events,
cwnd: stats.path.cwnd,
sent_packets: stats.path.sent_packets,
lost_packets: stats.path.lost_packets,
current_mtu,
}
}
/// Send an encoded [`TrunkFrame`] as a single QUIC datagram.
pub fn send_trunk(&self, frame: &TrunkFrame) -> Result<(), TransportError> {
let data = frame.encode();

View File

@@ -513,6 +513,13 @@ impl CallEngine {
encoder.set_aec_enabled(false);
let mut buf = vec![0i16; frame_samples];
// Continuous DRED tuning: poll quinn path stats every 25
// frames (~500 ms at 20 ms/frame) and adjust DRED duration +
// expected-loss hint based on real-time network conditions.
let mut dred_tuner = wzp_proto::DredTuner::new(config.profile.codec);
let mut frames_since_dred_poll: u32 = 0;
const DRED_POLL_INTERVAL: u32 = 25;
let mut heartbeat = std::time::Instant::now();
let mut last_rms: u32 = 0;
let mut last_pkt_bytes: usize = 0;
@@ -602,6 +609,34 @@ impl CallEngine {
Err(e) => error!("encode: {e}"),
}
// DRED tuner: poll quinn path stats periodically and
// adjust encoder DRED duration + expected-loss hint.
frames_since_dred_poll += 1;
if frames_since_dred_poll >= DRED_POLL_INTERVAL {
frames_since_dred_poll = 0;
let snap = send_t.quinn_path_stats();
let pq = send_t.path_quality();
if let Some(tuning) = dred_tuner.update(
snap.loss_pct,
snap.rtt_ms,
pq.jitter_ms,
) {
encoder.apply_dred_tuning(tuning);
if wzp_codec::dred_verbose_logs() {
info!(
dred_frames = tuning.dred_frames,
dred_ms = tuning.dred_frames as u32 * 10,
expected_loss = tuning.expected_loss_pct,
quinn_loss = format!("{:.1}", snap.loss_pct),
quinn_rtt = snap.rtt_ms,
jitter = pq.jitter_ms,
spike = dred_tuner.spike_boost_active(),
"DRED tuner adjusted encoder"
);
}
}
}
// Heartbeat every 2s with capture+encode+send state
if heartbeat.elapsed() >= std::time::Duration::from_secs(2) {
let fs = send_fs.load(Ordering::Relaxed);
@@ -1250,6 +1285,11 @@ impl CallEngine {
encoder.set_aec_enabled(false); // OS AEC or none
let mut buf = vec![0i16; frame_samples];
// Continuous DRED tuning (same as Android send task).
let mut dred_tuner = wzp_proto::DredTuner::new(config.profile.codec);
let mut frames_since_dred_poll: u32 = 0;
const DRED_POLL_INTERVAL: u32 = 25;
loop {
if !send_r.load(Ordering::Relaxed) {
break;
@@ -1285,6 +1325,21 @@ impl CallEngine {
}
Err(e) => error!("encode: {e}"),
}
// DRED tuner: poll quinn path stats periodically.
frames_since_dred_poll += 1;
if frames_since_dred_poll >= DRED_POLL_INTERVAL {
frames_since_dred_poll = 0;
let snap = send_t.quinn_path_stats();
let pq = send_t.path_quality();
if let Some(tuning) = dred_tuner.update(
snap.loss_pct,
snap.rtt_ms,
pq.jitter_ms,
) {
encoder.apply_dred_tuning(tuning);
}
}
}
});

View File

@@ -103,11 +103,13 @@ sequenceDiagram
participant RNN as RNNoise<br/>(2 x 480)
participant VAD as SilenceDetector
participant Codec as Opus / Codec2
participant DT as DredTuner<br/>(wzp-proto)
participant FEC as RaptorQ FEC
participant INT as Interleaver<br/>(depth=3)
participant HDR as MediaHeader<br/>(12B or Mini 4B)
participant Enc as ChaCha20-Poly1305
participant QUIC as QUIC Datagram
participant QPS as QuinnPathSnapshot
Mic->>Ring: f32 x 512 (macOS callback)
Ring->>Ring: Accumulate to 960 samples
@@ -118,10 +120,19 @@ sequenceDiagram
else Silence (>100ms)
VAD->>Codec: ComfortNoise (every 200ms)
end
Codec->>FEC: Compressed bytes (pad to 256B symbol)
FEC->>FEC: Accumulate block (5-10 symbols)
FEC->>INT: Source + repair symbols
INT->>HDR: Interleaved packets
Note over QPS,DT: Every 25 frames (~500ms)
QPS->>DT: loss_pct, rtt_ms, jitter_ms
DT->>Codec: set_dred_duration() + set_expected_loss()
alt Opus tier (any bitrate)
Codec->>HDR: Compressed bytes + DRED side-channel (no RaptorQ)
else Codec2 tier
Codec->>FEC: Compressed bytes (pad to 256B symbol)
FEC->>FEC: Accumulate block (5-10 symbols)
FEC->>INT: Source + repair symbols
INT->>HDR: Interleaved packets
end
HDR->>Enc: Header as AAD
Enc->>QUIC: Encrypted payload + 16B tag
```
@@ -134,6 +145,9 @@ sequenceDiagram
- Silence detection uses VAD + 100ms hangover before switching to ComfortNoise
- FEC symbols are padded to **256 bytes** with a 2-byte LE length prefix
- MiniHeaders (4 bytes) replace full headers (12 bytes) for 49 of every 50 frames
- DRED tuner polls quinn path stats every 25 frames (~500ms) and adjusts DRED lookback duration continuously
- Opus tiers bypass RaptorQ entirely -- DRED handles loss recovery at the codec layer
- Opus6k DRED window: 1040ms (maximum libopus allows)
## Audio Decode Pipeline
@@ -154,13 +168,30 @@ sequenceDiagram
Dec->>AR: Decrypt (header = AAD)
AR->>AR: Check seq window (reject replay)
AR->>HDR: Verified packet
HDR->>DEINT: MediaHeader + payload
DEINT->>FEC: Reordered symbols by block
FEC->>FEC: Attempt decode (need K of K+R)
FEC->>JIT: Recovered audio frames
alt Opus packet
HDR->>JIT: Direct to jitter buffer (no FEC/interleave)
else Codec2 packet
HDR->>DEINT: MediaHeader + payload
DEINT->>FEC: Reordered symbols by block
FEC->>FEC: Attempt decode (need K of K+R)
FEC->>JIT: Recovered audio frames
end
JIT->>JIT: BTreeMap ordered by seq
JIT->>JIT: Wait until depth >= target
JIT->>Codec: Pop lowest seq frame
alt Packet present
JIT->>Codec: Pop lowest seq frame
else Packet missing (Opus)
JIT->>Codec: DRED reconstruction (neural)
alt DRED fails or unavailable
Codec->>Codec: Classical PLC fallback
end
else Packet missing (Codec2)
Codec->>Codec: Classical PLC
end
Codec->>Ring: PCM i16 x 960
Ring->>SPK: Audio callback pulls samples
```
@@ -172,6 +203,8 @@ sequenceDiagram
- Jitter buffer target: **10 packets (200ms)** for client, **50 packets (1s)** for relay
- Desktop client uses **direct playout** (no jitter buffer) with lock-free ring
- Codec2 frames at 8 kHz are resampled to 48 kHz transparently
- DRED reconstruction: on packet loss, decoder tries neural DRED reconstruction before falling back to classical PLC
- Jitter-spike detection pre-emptively boosts DRED to ceiling when jitter variance spikes >30%
## Relay SFU Forwarding
@@ -348,7 +381,7 @@ Used for 49 of every 50 frames (~1s cycle). Saves 8 bytes per packet (67% header
[session_id: 2][len: u16][payload: len] x count
```
Packs multiple session packets into one QUIC datagram. Maximum 10 entries or 1200 bytes, flushed every 5ms.
Packs multiple session packets into one QUIC datagram. Maximum 10 entries or PMTUD-discovered MTU (starts at 1200, grows to ~1452 on Ethernet), flushed every 5ms.
### QualityReport (4 bytes, optional trailer)
@@ -361,6 +394,40 @@ Byte 3: bitrate_cap_kbps (0-255 kbps)
Appended to a media packet when the Q flag is set in the MediaHeader.
## Path MTU Discovery
Quinn's PLPMTUD is enabled with:
- `initial_mtu`: 1200 bytes (QUIC minimum, always safe)
- `upper_bound`: 1452 bytes (Ethernet minus IP/UDP/QUIC headers)
- `interval`: 300s (re-probe every 5 minutes)
- `black_hole_cooldown`: 30s (faster retry on lossy links)
The discovered MTU is exposed via `QuinnPathSnapshot::current_mtu` and used by:
- `TrunkedForwarder`: refreshes `max_bytes` on every send to fill larger datagrams
- Future video framer: larger MTU = fewer application-layer fragments per frame
## Continuous DRED Tuning
Instead of locking DRED duration to 3 discrete quality tiers, the `DredTuner` (in `wzp-proto::dred_tuner`) maps live path quality to a continuous DRED duration:
| Input | Source | Update Rate |
|-------|--------|-------------|
| Loss % | `QuinnPathSnapshot::loss_pct` (from quinn ACK frames) | Every 25 packets (~500ms) |
| RTT ms | `QuinnPathSnapshot::rtt_ms` (quinn congestion controller) | Every 25 packets |
| Jitter ms | `PathMonitor::jitter_ms` (EWMA of RTT variance) | Every 25 packets |
### Mapping Logic
- **Baseline**: codec-tier default (Studio=100ms, Good=200ms, Degraded=500ms)
- **Ceiling**: codec-tier max (Studio=300ms, Good=500ms, Degraded=1040ms)
- **Continuous**: linear interpolation between baseline and ceiling based on loss (0%->baseline, 40%->ceiling)
- **RTT phantom loss**: high RTT (>200ms) adds phantom loss contribution to keep DRED generous
- **Jitter spike**: >30% EWMA spike pre-emptively boosts to ceiling for ~5s cooldown
### Output
`DredTuning { dred_frames: u8, expected_loss_pct: u8 }` -> fed to `CallEncoder::apply_dred_tuning()` -> `OpusEncoder::set_dred_duration()` + `set_expected_loss()`
## Signal Message Handshake Flow
```mermaid

View File

@@ -358,3 +358,31 @@ End-to-end testing, in order:
- **OSCE enable**: opusic-c has an `osce` feature flag for Opus Speech Coding Enhancement, a separate libopus 1.5 neural post-processor. Out of scope for this PRD but should be the next audio-quality follow-up. Probably one-line enable once opusic-c is in.
- **Upstream PR to opusic-c**: our own `dred_ffi.rs` wrapper should be proven in production first, then the fixes upstreamed to `opusic-c/src/dred.rs` (preserve `dred_end`, fix `dred_offset` double-pass, expose `DredPacket` externally). Follow-up task, not blocking this PRD.
- **`feat/desktop-audio-rewrite` merge**: the vendored `audiopus_sys` patch on that branch becomes obsolete under this PRD. Coordinate removal with whoever owns that branch.
## Phase A: Continuous DRED Tuning (Implemented 2026-04-12)
Phase A extends the discrete tier-locked DRED durations from Phases 1-3 with continuous, network-driven tuning.
### What was built
- **`DredTuner`** (`crates/wzp-proto/src/dred_tuner.rs`): Maps `(loss_pct, rtt_ms, jitter_ms)``(dred_frames, expected_loss_pct)` continuously
- **Quinn stats exposure** (`crates/wzp-transport/src/quic.rs`): `QuinnPathSnapshot` provides quinn's internal RTT, loss, congestion events — more accurate than sequence-gap heuristics
- **Jitter variance window** (`crates/wzp-transport/src/path_monitor.rs`): 10-sample sliding window for RTT standard deviation, used for spike detection
- **`AudioEncoder` trait extensions** (`crates/wzp-proto/src/traits.rs`): `set_expected_loss()` and `set_dred_duration()` with default no-op, overridden by `OpusEncoder` and `AdaptiveEncoder`
- **Engine integration** (`desktop/src-tauri/src/engine.rs`): Both Android and desktop send tasks poll every 25 frames and apply tuning
### Opus6k DRED extended
`dred_duration_for(Opus6k)` changed from 50 (500ms) to 104 (1040ms) — the maximum libopus 1.5 supports. The RDO-VAE's quality-vs-offset curve makes this nearly free in bitrate terms while doubling burst resilience on the worst links.
### Jitter spike detection ("Sawtooth" prediction)
When instantaneous jitter exceeds the EWMA × 1.3 (asymmetric: fast-up α=0.3, slow-down α=0.05), the tuner enters spike-boost mode:
- DRED immediately jumps to the codec tier's ceiling
- Cooldown: 10 cycles (~5 seconds at 25 packets/cycle)
- Designed for Starlink satellite handover sawtooth jitter pattern
### Test coverage
- 10 unit tests for tuner math (baseline, scaling, spike, cooldown, codec switch, Codec2 no-op)
- 4 integration tests (encoder adjustment, spike boost, Codec2 no-op, profile switch with encode verification)

View File

@@ -57,3 +57,28 @@ When the path MTU is small, the relay or client should:
- MTU-based codec selection (future, needs adaptive quality)
## Effort: 1 day
## Implementation Status (2026-04-12)
Phase 1 is now implemented:
### What was built
- **Transport config** (`crates/wzp-transport/src/config.rs`):
- `MtuDiscoveryConfig` with `upper_bound=1452`, `interval=300s`, `black_hole_cooldown=30s`
- `initial_mtu=1200` (safe QUIC minimum)
- Quinn's PLPMTUD binary-searches from 1200 up to 1452 automatically
- **`QuinnPathSnapshot::current_mtu`** (`crates/wzp-transport/src/quic.rs`):
- Reads `connection.max_datagram_size()` which reflects the PMTUD-discovered value
- Available to all callers via `transport.quinn_path_stats()`
- **Trunk batcher MTU-aware** (`crates/wzp-relay/src/room.rs`):
- `TrunkedForwarder::new()` initializes `max_bytes` from discovered MTU
- `send()` refreshes `max_bytes` on every call (cheap atomic read in quinn)
- Federation trunk frames grow automatically as PMTUD discovers larger paths
### Phases 2-3 status
- Phase 2 (handle MTU failures): Already handled — `send_media()`/`send_trunk()` check `max_datagram_size()` and return `DatagramTooLarge` errors. These are logged and the packet is dropped gracefully.
- Phase 3 (codec-aware MTU): Not yet implemented. Future video frames will need application-layer fragmentation when they exceed the discovered MTU.

View File

@@ -120,7 +120,7 @@
- **Web audio drift**: The browser AudioWorklet playback buffer caps at 200ms, but clock drift between the WebSocket message arrival rate and the AudioContext output rate can cause occasional underruns or accumulation. The cap prevents unbounded growth but may cause glitches.
- **No adaptive loop integration**: The `PathMonitor` feeds and `AdaptiveQualityController` are implemented but not wired together in the client's main loop. Quality reports are consumed when present in packets, but the client does not currently generate periodic quality reports from transport metrics.
- **No adaptive loop integration (partially resolved)**: PathMonitor and DredTuner are wired into the send loop — DRED duration adapts continuously. Full AdaptiveQualityController integration (codec tier switching from transport metrics) remains TODO.
- **Relay FEC pass-through**: In room mode, the relay forwards packets opaquely without FEC decode/re-encode. This means FEC protection is end-to-end only, not per-hop. In forward mode, the relay pipeline does perform FEC decode/re-encode.
@@ -128,18 +128,18 @@
## Test Coverage
119 tests across 7 crates (wzp-web has no Rust tests):
307+ tests across 7 crates (wzp-web has no Rust tests):
| Crate | Test Files | Test Count |
|-------|-----------|------------|
| wzp-proto | 5 | 27 |
| wzp-codec | 3 | 24 |
| wzp-fec | 5 | 21 |
| wzp-crypto | 5 | 21 |
| wzp-transport | 3 | 12 |
| wzp-relay | 4 | 10 |
| wzp-client | 3 | 8 |
| **Total** | **28** | **119** |
| Crate | Test Count |
|-------|------------|
| wzp-proto | ~79 |
| wzp-codec | ~69 |
| wzp-fec | ~21 |
| wzp-crypto | ~21 |
| wzp-transport | ~11 |
| wzp-relay | ~50 |
| wzp-client | ~57 |
| **Total** | **307+** |
Tests cover:
- Wire format roundtrip (header, quality report, full packet)
@@ -214,3 +214,28 @@ Run with `wzp-bench --all`. Representative results (Apple M-series, single core)
- `build-tauri-android.sh --arch arm64|armv7|all`
- Separate per-arch APKs (~25MB each vs ~50MB universal)
- Release APKs signed with `wzp-release.jks` via `apksigner`
### Continuous DRED Tuning (Phase A: opus-DRED-v2)
- `DredTuner` in `wzp-proto::dred_tuner` maps live network metrics to continuous DRED duration
- Polls quinn path stats every 25 frames (~500ms): loss%, RTT, jitter
- Linear interpolation between baseline and ceiling per codec tier (not discrete tier jumps)
- Jitter-spike detection: >30% EWMA spike pre-emptively boosts DRED to ceiling for ~5s
- RTT phantom loss: high RTT (>200ms) adds phantom contribution to keep DRED generous
- `set_expected_loss()` and `set_dred_duration()` added to `AudioEncoder` trait
- Integrated into both Android and desktop send tasks in engine.rs
### Extended DRED Window
- Opus6k DRED duration increased from 500ms to 1040ms (max libopus 1.5 supports)
- RDO-VAE naturally degrades quality at longer offsets — extra window costs ~1-2 kbps
### PMTUD (Path MTU Discovery)
- Quinn's PLPMTUD explicitly configured: initial 1200, upper bound 1452, 300s interval
- `QuinnPathSnapshot` exposes discovered MTU via `current_mtu` field
- `TrunkedForwarder` refreshes `max_bytes` from PMTUD (was hard-coded 1200)
- Federation trunk frames now fill the discovered path MTU automatically
### New Tests
- 4 DRED tuner integration tests in wzp-client (encoder adjustment, spike boost, Codec2 no-op, profile switch)
- 10 unit tests in wzp-proto for DredTuner mapping logic
- Jitter variance window tests in wzp-transport PathMonitor
- Pre-existing test fixes: added missing `build_version` fields to 7 SignalMessage constructors