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>
100 lines
3.4 KiB
Rust
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,
|
|
}),
|
|
}
|
|
}
|
|
}
|