diff --git a/crates/wzp-codec/src/opus_enc.rs b/crates/wzp-codec/src/opus_enc.rs index 79f3080..207c976 100644 --- a/crates/wzp-codec/src/opus_enc.rs +++ b/crates/wzp-codec/src/opus_enc.rs @@ -1,25 +1,87 @@ //! Opus encoder wrapping the `opusic-c` crate (libopus 1.5.2). //! -//! Phase 0 of the DRED integration: swapped FFI backend from audiopus -//! (dead, libopus 1.3) to opusic-c (live, libopus 1.5.2). Behavior is -//! intentionally unchanged from the audiopus-based encoder — inband FEC -//! stays ON, DRED stays at duration 0. Phase 1 enables DRED and disables -//! inband FEC. See docs/PRD-dred-integration.md. +//! Phase 1 of the DRED integration: encoder-side DRED is enabled on every +//! Opus profile with a tiered duration (studio 100 ms / normal 200 ms / +//! degraded 500 ms), and Opus inband FEC (LBRR) is disabled because DRED +//! is the stronger mechanism for the same failure mode. The legacy behavior +//! is preserved behind the `AUDIO_USE_LEGACY_FEC` environment variable as a +//! runtime escape hatch for rollout. See `docs/PRD-dred-integration.md`. +//! +//! # DRED duration policy +//! +//! Rationale from the PRD: +//! - Studio tiers (Opus 32k/48k/64k): 100 ms — loss is rare on high-quality +//! networks; short window keeps decoder CPU modest. +//! - Normal tiers (Opus 16k/24k): 200 ms — balanced baseline covering common +//! VoIP loss patterns (20–150 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. +//! +//! # Why the 5% packet loss floor +//! +//! libopus 1.5's DRED emitter is gated on a non-zero `OPUS_SET_PACKET_LOSS_PERC`. +//! If the encoder thinks loss is 0, it skips DRED encoding even when the +//! duration is set. We force a 5% minimum so DRED emits continuously; real +//! measurements from the quality adapter override upward when loss exceeds +//! the floor. -use opusic_c::{ - Application, Bitrate, Channels, Encoder, InbandFec, SampleRate, Signal, -}; -use tracing::debug; +use opusic_c::{Application, Bitrate, Channels, Encoder, InbandFec, SampleRate, Signal}; +use tracing::{debug, warn}; use wzp_proto::{AudioEncoder, CodecError, CodecId, QualityProfile}; +/// Minimum `OPUS_SET_PACKET_LOSS_PERC` value used in DRED mode. libopus gates +/// DRED emission on a non-zero loss hint, so we keep it clamped to at least +/// this floor whenever DRED is active. +const DRED_LOSS_FLOOR_PCT: u8 = 5; + +/// Environment variable that reverts Phase 1 behavior to Phase 0 (inband FEC +/// on, DRED off, no loss floor). Read once per encoder construction. +const LEGACY_FEC_ENV: &str = "AUDIO_USE_LEGACY_FEC"; + +/// Returns the DRED duration in 10 ms frame units for a given Opus codec. +/// +/// Unit: each frame is 10 ms, so the max value of 104 corresponds to 1040 ms +/// of reconstructable history. Returns 0 for non-Opus codecs (DRED is not +/// emitted by the libopus encoder in that case anyway, but we avoid a +/// pointless FFI call). +/// +/// See the DRED duration policy in the module docs for per-tier rationale. +pub fn dred_duration_for(codec: CodecId) -> u8 { + match codec { + // Studio tiers — loss is rare, short window. + CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 10, + // Normal tiers — balanced baseline. + CodecId::Opus16k | CodecId::Opus24k => 20, + // Degraded tier — maximum burst resilience. + CodecId::Opus6k => 50, + // Non-Opus (Codec2 / CN): DRED is N/A. + CodecId::Codec2_1200 | CodecId::Codec2_3200 | CodecId::ComfortNoise => 0, + } +} + +/// Returns whether the legacy-FEC escape hatch is active. +/// +/// Read from `AUDIO_USE_LEGACY_FEC`. Any non-empty value activates legacy +/// mode; unset or empty leaves DRED enabled. +fn read_legacy_fec_env() -> bool { + match std::env::var(LEGACY_FEC_ENV) { + Ok(v) => !v.is_empty() && v != "0" && v.to_ascii_lowercase() != "false", + Err(_) => false, + } +} + /// Opus encoder implementing `AudioEncoder`. /// -/// Operates at 48 kHz mono. Supports frame sizes of 20 ms (960 samples) -/// and 40 ms (1920 samples). +/// Operates at 48 kHz mono. Supports 20 ms and 40 ms frames via the active +/// `QualityProfile`. pub struct OpusEncoder { inner: Encoder, codec_id: CodecId, frame_duration_ms: u8, + /// When `true`, revert to the Phase 0 behavior: inband FEC Mode1, DRED + /// disabled, no loss floor. Captured at construction time and not + /// re-read mid-call. + legacy_fec_mode: bool, } // SAFETY: OpusEncoder is only used via `&mut self` methods. The inner @@ -35,28 +97,80 @@ impl OpusEncoder { let encoder = Encoder::new(Channels::Mono, SampleRate::Hz48000, Application::Voip) .map_err(|e| CodecError::EncodeFailed(format!("opus encoder init: {e:?}")))?; + let legacy_fec_mode = read_legacy_fec_env(); + if legacy_fec_mode { + warn!( + "AUDIO_USE_LEGACY_FEC active — reverting Opus encoder to Phase 0 \ + behavior (inband FEC Mode1, no DRED)" + ); + } + let mut enc = Self { inner: encoder, codec_id: profile.codec, frame_duration_ms: profile.frame_duration_ms, + legacy_fec_mode, }; - enc.apply_bitrate(profile.codec)?; - enc.set_inband_fec(true); - enc.set_dtx(true); - // Voice signal type hint for better compression + // Common setup — bitrate, DTX, signal hint, complexity. These are + // identical regardless of the protection mode below. + enc.apply_bitrate(profile.codec)?; + enc.set_dtx(true); enc.inner .set_signal(Signal::Voice) .map_err(|e| CodecError::EncodeFailed(format!("set signal: {e:?}")))?; - - // Default complexity 7 — good quality/CPU trade-off for VoIP enc.inner .set_complexity(7) .map_err(|e| CodecError::EncodeFailed(format!("set complexity: {e:?}")))?; + // Protection mode: DRED (Phase 1 default) or legacy inband FEC. + enc.apply_protection_mode(profile.codec)?; + Ok(enc) } + /// Configure the protection mode for the active codec. + /// + /// In DRED mode (default): disable inband FEC, set DRED duration for the + /// codec tier, clamp packet_loss to the 5% floor so DRED stays active. + /// + /// In legacy mode: enable inband FEC Mode1 (Phase 0 behavior), leave + /// DRED and packet_loss at libopus defaults. + fn apply_protection_mode(&mut self, codec: CodecId) -> Result<(), CodecError> { + if self.legacy_fec_mode { + self.inner + .set_inband_fec(InbandFec::Mode1) + .map_err(|e| CodecError::EncodeFailed(format!("set inband FEC: {e:?}")))?; + // Leave DRED at 0 and packet_loss at default — matches Phase 0. + return Ok(()); + } + + // DRED path: disable the overlapping inband FEC, enable DRED with + // per-profile duration, floor packet_loss so DRED emits. + self.inner + .set_inband_fec(InbandFec::Off) + .map_err(|e| CodecError::EncodeFailed(format!("set inband FEC off: {e:?}")))?; + + let dred_frames = dred_duration_for(codec); + self.inner + .set_dred_duration(dred_frames) + .map_err(|e| CodecError::EncodeFailed(format!("set DRED duration: {e:?}")))?; + + self.inner + .set_packet_loss(DRED_LOSS_FLOOR_PCT) + .map_err(|e| CodecError::EncodeFailed(format!("set packet loss floor: {e:?}")))?; + + debug!( + codec = ?codec, + dred_frames, + dred_ms = dred_frames as u32 * 10, + loss_floor_pct = DRED_LOSS_FLOOR_PCT, + "opus encoder: DRED enabled" + ); + + Ok(()) + } + fn apply_bitrate(&mut self, codec: CodecId) -> Result<(), CodecError> { let bps = codec.bitrate_bps(); self.inner @@ -80,10 +194,36 @@ impl OpusEncoder { /// Hint the encoder about expected packet loss percentage (0-100). /// - /// Higher values cause the encoder to use more redundancy to survive - /// packet loss, at the expense of slightly higher bitrate. + /// In DRED mode, the value is floored at `DRED_LOSS_FLOOR_PCT` so the + /// encoder never drops DRED emission even on a perfect network. Real + /// loss measurements from the quality adapter override upward. + /// + /// In legacy mode, the value is passed through unchanged (min 0, max 100). pub fn set_expected_loss(&mut self, loss_pct: u8) { - let _ = self.inner.set_packet_loss(loss_pct.min(100)); + let clamped = if self.legacy_fec_mode { + loss_pct.min(100) + } else { + loss_pct.max(DRED_LOSS_FLOOR_PCT).min(100) + }; + let _ = self.inner.set_packet_loss(clamped); + } + + /// Set the DRED duration in 10 ms frame units (0 disables, max 104). + /// + /// No-op in legacy mode. Normally driven automatically by the active + /// quality profile via `apply_protection_mode`; this setter exists for + /// tests and for the rare case where a caller needs to override the + /// per-profile default. + pub fn set_dred_duration(&mut self, frames: u8) { + if self.legacy_fec_mode { + return; + } + let _ = self.inner.set_dred_duration(frames.min(104)); + } + + /// Test/introspection accessor: whether legacy FEC mode is active. + pub fn is_legacy_fec_mode(&self) -> bool { + self.legacy_fec_mode } } @@ -117,6 +257,9 @@ impl AudioEncoder for OpusEncoder { self.codec_id = profile.codec; self.frame_duration_ms = profile.frame_duration_ms; self.apply_bitrate(profile.codec)?; + // Refresh DRED duration for the new tier. apply_protection_mode + // is idempotent and handles the legacy-vs-DRED branch correctly. + self.apply_protection_mode(profile.codec)?; Ok(()) } other => Err(CodecError::UnsupportedTransition { @@ -133,12 +276,18 @@ impl AudioEncoder for OpusEncoder { } fn set_inband_fec(&mut self, enabled: bool) { - // opusic-c replaces the audiopus bool with an enum that distinguishes - // Mode1 (classical LBRR, equivalent to libopus 1.3's inband FEC) and - // Mode2 (newer, higher-quality variant added in 1.5). Phase 0 preserves - // pre-swap behavior by using Mode1, which is the direct equivalent of - // audiopus's `set_inband_fec(true)`. Phase 1 flips this to Off when - // DRED is enabled. + // In DRED mode, ignore external requests to re-enable inband FEC — + // running both mechanisms wastes bitrate on overlapping protection + // and opusic-c's own docs recommend disabling inband FEC when DRED + // is on. Trait callers that genuinely want classical FEC should set + // `AUDIO_USE_LEGACY_FEC=1` and re-create the encoder. + if !self.legacy_fec_mode { + debug!( + enabled, + "set_inband_fec ignored: DRED mode is active (set AUDIO_USE_LEGACY_FEC to revert)" + ); + return; + } let mode = if enabled { InbandFec::Mode1 } else { InbandFec::Off }; let _ = self.inner.set_inband_fec(mode); } @@ -174,9 +323,8 @@ mod tests { #[test] fn encoder_roundtrip_silence() { - use crate::opus_dec::OpusDecoder; let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap(); - let mut dec = OpusDecoder::new(QualityProfile::GOOD).unwrap(); + let mut dec = crate::opus_dec::OpusDecoder::new(QualityProfile::GOOD).unwrap(); let pcm_in = vec![0i16; 960]; // 20 ms silence let mut encoded = vec![0u8; 512]; let n = enc.encode(&pcm_in, &mut encoded).unwrap(); @@ -185,4 +333,133 @@ mod tests { let samples = dec.decode(&encoded[..n], &mut pcm_out).unwrap(); assert_eq!(samples, 960); } + + // ─── Phase 1 — DRED duration policy ───────────────────────────────────── + + #[test] + fn dred_duration_for_studio_tiers_is_100ms() { + assert_eq!(dred_duration_for(CodecId::Opus32k), 10); + assert_eq!(dred_duration_for(CodecId::Opus48k), 10); + assert_eq!(dred_duration_for(CodecId::Opus64k), 10); + } + + #[test] + fn dred_duration_for_normal_tiers_is_200ms() { + assert_eq!(dred_duration_for(CodecId::Opus16k), 20); + assert_eq!(dred_duration_for(CodecId::Opus24k), 20); + } + + #[test] + fn dred_duration_for_degraded_tier_is_500ms() { + assert_eq!(dred_duration_for(CodecId::Opus6k), 50); + } + + #[test] + fn dred_duration_for_codec2_is_zero() { + assert_eq!(dred_duration_for(CodecId::Codec2_3200), 0); + assert_eq!(dred_duration_for(CodecId::Codec2_1200), 0); + assert_eq!(dred_duration_for(CodecId::ComfortNoise), 0); + } + + // ─── Phase 1 — Legacy escape hatch ────────────────────────────────────── + + /// By default (env var unset), legacy mode is off. + /// + /// This test does NOT manipulate the environment to avoid flakiness + /// when the full suite runs in parallel. It only asserts on a freshly + /// created encoder in the ambient environment. + #[test] + fn default_mode_is_dred_not_legacy() { + // SAFETY: only run if the ambient env hasn't set the var externally. + if std::env::var(LEGACY_FEC_ENV).is_ok() { + return; // don't assert — someone set the env for a reason. + } + let enc = OpusEncoder::new(QualityProfile::GOOD).unwrap(); + assert!(!enc.is_legacy_fec_mode()); + } + + // ─── Phase 1 — Behavioral regression: roundtrip still works ───────────── + + #[test] + fn dred_mode_roundtrip_voice_pattern() { + // Use a realistic voice-like input (sine wave at speech frequencies) + // so the encoder emits meaningful DRED data rather than trivially + // compressible silence. + let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap(); + let mut dec = crate::opus_dec::OpusDecoder::new(QualityProfile::GOOD).unwrap(); + + let mut total_encoded_bytes = 0usize; + // Run 50 frames (1 second) so DRED fills up and starts emitting. + for frame_idx in 0..50 { + let pcm_in: Vec = (0..960) + .map(|i| { + let t = (frame_idx * 960 + i) as f64 / 48_000.0; + (8000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16 + }) + .collect(); + let mut encoded = vec![0u8; 512]; + let n = enc.encode(&pcm_in, &mut encoded).unwrap(); + assert!(n > 0); + total_encoded_bytes += n; + + let mut pcm_out = vec![0i16; 960]; + let samples = dec.decode(&encoded[..n], &mut pcm_out).unwrap(); + assert_eq!(samples, 960); + } + + // Effective bitrate after 1 second of encoding. + // Opus 24k base + ~1 kbps DRED ≈ 25 kbps ≈ 3125 bytes/sec. + // Allow generous headroom (2000 lower bound, 8000 upper bound) — + // this is a behavioral regression check, not a tight bitrate assertion. + // The exact value is printed with --nocapture for diagnostic use. + eprintln!( + "[phase1 bitrate probe] legacy_fec_mode={} total_encoded={} bytes/sec", + enc.is_legacy_fec_mode(), + total_encoded_bytes + ); + assert!( + total_encoded_bytes > 2000, + "encoder output too small: {total_encoded_bytes} bytes/sec (DRED likely not emitting)" + ); + assert!( + total_encoded_bytes < 8000, + "encoder output too large: {total_encoded_bytes} bytes/sec" + ); + } + + // ─── Phase 1 — set_profile updates DRED duration on tier switch ───────── + + #[test] + fn profile_switch_refreshes_dred_duration() { + // Start on GOOD (Opus 24k, DRED 20 frames), switch to DEGRADED + // (Opus 6k, DRED 50 frames). The encoder should accept both profile + // changes without error. We can't directly observe the DRED duration + // inside libopus, but apply_protection_mode returns Ok for both. + let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap(); + assert_eq!(enc.codec_id, CodecId::Opus24k); + + enc.set_profile(QualityProfile::DEGRADED).unwrap(); + assert_eq!(enc.codec_id, CodecId::Opus6k); + + enc.set_profile(QualityProfile::STUDIO_64K).unwrap(); + assert_eq!(enc.codec_id, CodecId::Opus64k); + } + + // ─── Phase 1 — Trait set_inband_fec is a no-op in DRED mode ───────────── + + #[test] + fn set_inband_fec_noop_in_dred_mode() { + if std::env::var(LEGACY_FEC_ENV).is_ok() { + return; + } + let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap(); + // Should not error, should not re-enable inband FEC internally. + enc.set_inband_fec(true); + // We can't directly query libopus's inband FEC state through opusic-c, + // but the call must not panic and the encoder must still work. + let pcm_in = vec![0i16; 960]; + let mut encoded = vec![0u8; 512]; + let n = enc.encode(&pcm_in, &mut encoded).unwrap(); + assert!(n > 0); + } }