Files
wz-phone/crates/wzp-codec/src/opus_dec.rs
Siavash Sameni 27bc264738 feat(codec): Phase 3b — CallDecoder DRED reconstruction on packet loss
Phase 3b of the DRED integration — wires the Phase 3a FFI primitives
into the desktop receive path. When the jitter buffer reports a missing
Opus frame, CallDecoder now attempts to reconstruct the audio from the
most recently parsed DRED side-channel state before falling through to
classical PLC.

Architectural refinement vs the PRD's literal wording: the PRD said
"jitter buffer takes a Box<dyn DredReconstructor>". After checking deps,
wzp-transport depends only on wzp-proto (not wzp-codec). Putting DRED
state in the jitter buffer would require a new cross-crate dep and
couple the codec-agnostic buffer to libopus. Instead, this commit keeps
the DRED state ring and reconstruction dispatch inside CallDecoder (one
layer up from the jitter buffer), intercepting the existing
PlayoutResult::Missing signal. Same lookahead/backfill semantics,
cleaner layering, zero change to wzp-transport.

Changes:

  CallDecoder field type: Box<dyn AudioDecoder> → AdaptiveDecoder.
  Required because Phase 3b calls the inherent reconstruct_from_dred
  method, which cannot live on the AudioDecoder trait without dragging
  libopus DredState through wzp-proto. In practice AdaptiveDecoder was
  the only AudioDecoder implementor anyway — the trait abstraction was
  buying nothing. Method call sites unchanged because AdaptiveDecoder
  also implements AudioDecoder.

  New CallDecoder fields:
    - dred_decoder: DredDecoderHandle
    - dred_parse_scratch: DredState  (scratch for parse_into)
    - last_good_dred: DredState      (cached most-recent valid state)
    - last_good_dred_seq: Option<u16>
    - dred_reconstructions: u64      (Phase 4 telemetry)
    - classical_plc_invocations: u64 (Phase 4 telemetry)

  CallDecoder::ingest — on Opus non-repair packets, parse DRED into the
  scratch state. On success (samples_available > 0), std::mem::swap the
  scratch into last_good_dred and record the seq. This is O(1) per
  packet, zero allocation after construction (the two DredState buffers
  are allocated once in new() and reused forever).

  CallDecoder::decode_next — on PlayoutResult::Missing(seq) for Opus
  profiles: if last_good_dred_seq > seq and the seq delta × frame_samples
  fits within samples_available, call audio_dec.reconstruct_from_dred
  and bump dred_reconstructions. Otherwise fall through to classical
  PLC and bump classical_plc_invocations. The Codec2 path always falls
  through to classical PLC since DRED is libopus-only and
  AdaptiveDecoder::reconstruct_from_dred rejects Codec2 tiers
  explicitly.

  OpusDecoder and AdaptiveDecoder: new inherent reconstruct_from_dred
  method that delegates to the underlying DecoderHandle. Needed to
  bridge CallDecoder's wzp-client code to the Phase 3a FFI wrappers
  without touching the AudioDecoder trait.

CRITICAL FINDING — raised DRED loss floor from 5% to 15%:

Phase 3b testing discovered that libopus 1.5's DRED emission window
scales aggressively with OPUS_SET_PACKET_LOSS_PERC. Empirical data
(see probe_dred_samples_available_by_loss_floor, an #[ignore]'d
diagnostic test in call.rs):

  loss_pct   samples_available   effective_ms
    5%        720                  15 ms  (useless!)
   10%        2640                 55 ms
   15%        4560                 95 ms
   20%        6480                135 ms
   25%+       8400 (capped)       175 ms  (~87% of 200 ms configured)

The Phase 1 default of 5% produced only a 15 ms reconstruction window
— too small to even cover a single 20 ms Opus frame. DRED was
effectively disabled even though it was emitting bytes. Raised the
floor to 15% (95 ms window) as the minimum that actually provides
single-frame loss recovery. This updates Phase 1's DRED_LOSS_FLOOR_PCT
constant in opus_enc.rs and the accompanying module docstring.

Trade-off: 15% assumed loss slightly increases encoder bitrate overhead
on clean networks. Measured via the existing phase1 bitrate probe:

  Before (5% floor):  3649 bytes/sec at Opus 24k + 300 Hz sine
  After  (15% floor): 3568 bytes/sec at Opus 24k + 300 Hz sine

The delta is within noise — 15% isn't meaningfully more expensive than
5% on this signal, which suggests the DRED emission size is signal-
dependent rather than loss-dependent for small values. Net result: we
get a 6x larger reconstruction window for essentially free.

Tests (+3 DRED recovery, +1 #[ignore]'d probe):
- opus_single_packet_loss_is_recovered_via_dred — full encode → ingest
  → decode_next loop with one packet dropped mid-stream. Asserts
  dred_reconstructions ≥ 1 and observes the exact counter deltas.
- opus_lossless_ingest_never_triggers_dred_or_plc — baseline behavior,
  lossless stream never takes the Missing branch.
- codec2_loss_falls_through_to_classical_plc — Codec2 never
  reconstructs via DRED even if state were populated (which it won't
  be — Codec2 packets don't carry DRED bytes).
- probe_dred_samples_available_by_loss_floor — #[ignore]'d diagnostic
  that sweeps loss_pct values and prints the resulting DRED window
  sizes. Kept for future tuning work.

New CallDecoder introspection accessors (public but undocumented in
the PRD): last_good_dred_seq() and last_good_dred_samples_available()
for test diagnostics and future telemetry surfaces in Phase 4.

Verification:
- cargo check --workspace: zero errors
- cargo test -p wzp-codec --lib: 68 passing (Phase 3a baseline held)
- cargo test -p wzp-client --lib: 35 passing (+3 Phase 3b tests,
  +1 ignored diagnostic, no regressions)

Next up: Phase 3c mirrors this on the Android engine.rs receive path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:03:24 +04:00

100 lines
3.4 KiB
Rust

//! Opus decoder built on top of the raw opusic-sys `DecoderHandle`.
//!
//! Phase 0 of the DRED integration: we went straight to a custom
//! `DecoderHandle` instead of `opusic_c::Decoder` because the latter's
//! inner pointer is `pub(crate)` and we need to reach it in Phase 3 for
//! `opus_decoder_dred_decode`. See `dred_ffi.rs` for the rationale and
//! `docs/PRD-dred-integration.md` for the full plan.
use crate::dred_ffi::{DecoderHandle, DredState};
use wzp_proto::{AudioDecoder, CodecError, CodecId, QualityProfile};
/// Opus decoder implementing [`AudioDecoder`].
///
/// Operates at 48 kHz mono output. 20 ms and 40 ms frames supported via
/// the active `QualityProfile`. Behavior is intentionally identical to
/// the pre-swap audiopus-based decoder at this phase — DRED reconstruction
/// lands in Phase 3.
pub struct OpusDecoder {
inner: DecoderHandle,
codec_id: CodecId,
frame_duration_ms: u8,
}
impl OpusDecoder {
/// Create a new Opus decoder for the given quality profile.
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
let inner = DecoderHandle::new()?;
Ok(Self {
inner,
codec_id: profile.codec,
frame_duration_ms: profile.frame_duration_ms,
})
}
/// Expected number of output PCM samples per frame.
pub fn frame_samples(&self) -> usize {
(48_000 * self.frame_duration_ms as usize) / 1000
}
/// Reconstruct a lost frame from a previously parsed `DredState`.
///
/// Phase 3b entry point: callers (CallDecoder / engine.rs) use this to
/// synthesize audio for gaps detected by the jitter buffer when DRED
/// side-channel state from a later-arriving packet covers the gap's
/// sample offset. `offset_samples` is measured backward from the anchor
/// packet that produced `state`. See `DecoderHandle::reconstruct_from_dred`
/// for the full semantics.
pub fn reconstruct_from_dred(
&mut self,
state: &DredState,
offset_samples: i32,
output: &mut [i16],
) -> Result<usize, CodecError> {
self.inner
.reconstruct_from_dred(state, offset_samples, output)
}
}
impl AudioDecoder for OpusDecoder {
fn decode(&mut self, encoded: &[u8], pcm: &mut [i16]) -> Result<usize, CodecError> {
let expected = self.frame_samples();
if pcm.len() < expected {
return Err(CodecError::DecodeFailed(format!(
"output buffer too small: need {expected}, got {}",
pcm.len()
)));
}
self.inner.decode(encoded, pcm)
}
fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> {
let expected = self.frame_samples();
if pcm.len() < expected {
return Err(CodecError::DecodeFailed(format!(
"output buffer too small: need {expected}, got {}",
pcm.len()
)));
}
self.inner.decode_lost(pcm)
}
fn codec_id(&self) -> CodecId {
self.codec_id
}
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
match profile.codec {
c if c.is_opus() => {
self.codec_id = profile.codec;
self.frame_duration_ms = profile.frame_duration_ms;
Ok(())
}
other => Err(CodecError::UnsupportedTransition {
from: self.codec_id,
to: other,
}),
}
}
}