feat(audio): libopus packet-loss concealment on the client audio plane
The 0xC9 audio datagrams ride the lossy plane with no FEC, and no client ever consulted the per-packet sequence: a lost 5 ms Opus packet played out as a hard gap in the ring — an audible click/pop on every drop, i.e. constantly on the Wi-Fi links where video loss is already being FEC-absorbed. Now a shared `AudioGapTracker` (punktfunk-core::audio — pure data, wrap-safe, unit-tested incl. u32 wraparound / reorder / duplicate cases) tells the decoder how many packets went missing immediately before each received one, and both native clients (pf-client-core PipeWire path, Android AAudio path) synthesize that many frames of libopus packet-loss concealment first: `decode` with empty input (the opus crate maps it to a NULL data pointer = PLC), sized by the last real frame's sample count. Interpolated fade instead of a click. Bounds: a gap is capped at 10 packets (50 ms) — libopus PLC fades to silence after a few frames anyway, so past the cap the rings' existing underrun/re-prime path takes over. Reorders and duplicates conceal nothing (the plane has no reorder buffer; playing a late packet where it lands is the existing behaviour). In-band Opus FEC (LBRR) is deliberately NOT used: the host sends 5 ms frames and LBRR needs ≥10 ms frames to carry anything. The cap is a crate-private const so cbindgen keeps it out of the C ABI header. Host cargo tests + clippy green; android crate verified via cargo ndk check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -355,45 +355,70 @@ fn decode_loop(
|
||||
};
|
||||
let mut pcm = vec![0f32; pcm_scratch];
|
||||
let mut window_peak = 0f32; // loudest |sample| since the last log — tells a tone from silence
|
||||
while !shutdown.load(Ordering::Relaxed) {
|
||||
let mut gaps = punktfunk_core::audio::AudioGapTracker::new();
|
||||
let mut frame_samples = 0usize; // per-channel samples of the last decoded frame — the PLC unit
|
||||
'pump: while !shutdown.load(Ordering::Relaxed) {
|
||||
match client.next_audio(Duration::from_millis(5)) {
|
||||
Ok(pkt) => match dec.decode_float(&pkt.data, &mut pcm, false) {
|
||||
Ok(samples) => {
|
||||
let n = samples * channels;
|
||||
for &s in &pcm[..n] {
|
||||
window_peak = window_peak.max(s.abs());
|
||||
Ok(pkt) => {
|
||||
// Conceal lost packets (a seq gap) with libopus PLC before decoding the one that
|
||||
// arrived: empty input synthesizes `frame_samples` of interpolation per missing
|
||||
// packet — an inaudible fade instead of the click a hard gap makes in the ring.
|
||||
for _ in 0..gaps.missing_before(pkt.seq) {
|
||||
let plc = frame_samples * channels;
|
||||
if plc == 0 {
|
||||
break; // no decoded frame yet to size the concealment from
|
||||
}
|
||||
// The ring's pre-reservation in `start` assumes the protocol's 5 ms (≤480-f32/ch)
|
||||
// frames; a larger frame would force a one-time realloc on the RT thread. Catch a
|
||||
// future host frame-size change here in debug, not as a silent audio glitch.
|
||||
debug_assert!(
|
||||
n <= 5 * ms,
|
||||
"audio frame {n} f32 exceeds the 5 ms ring reserve"
|
||||
);
|
||||
let count = counters.opus_decoded.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
// Reuse a recycled buffer if the callback handed one back; only allocate when the
|
||||
// free-list is momentarily empty (startup / after a backpressure drop).
|
||||
let mut buf = free_rx
|
||||
.try_recv()
|
||||
.unwrap_or_else(|_| Vec::with_capacity(pcm_scratch));
|
||||
buf.clear();
|
||||
buf.extend_from_slice(&pcm[..n]);
|
||||
match tx.try_send(buf) {
|
||||
Ok(()) | Err(TrySendError::Full(_)) => {} // drop-newest under backpressure
|
||||
Err(TrySendError::Disconnected(_)) => break,
|
||||
}
|
||||
if count % 600 == 0 {
|
||||
log::info!(
|
||||
"audio: opus={count} pcm_frames={} underruns={} ring={} peak={window_peak:.3}",
|
||||
counters.pcm_written.load(Ordering::Relaxed),
|
||||
counters.underruns.load(Ordering::Relaxed),
|
||||
counters.ring_depth.load(Ordering::Relaxed),
|
||||
);
|
||||
window_peak = 0.0;
|
||||
if let Ok(samples) = dec.decode_float(&[], &mut pcm[..plc], false) {
|
||||
let mut buf = free_rx
|
||||
.try_recv()
|
||||
.unwrap_or_else(|_| Vec::with_capacity(pcm_scratch));
|
||||
buf.clear();
|
||||
buf.extend_from_slice(&pcm[..samples * channels]);
|
||||
match tx.try_send(buf) {
|
||||
Ok(()) | Err(TrySendError::Full(_)) => {}
|
||||
Err(TrySendError::Disconnected(_)) => break 'pump,
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => log::debug!("audio: opus decode: {e}"),
|
||||
},
|
||||
match dec.decode_float(&pkt.data, &mut pcm, false) {
|
||||
Ok(samples) => {
|
||||
frame_samples = samples;
|
||||
let n = samples * channels;
|
||||
for &s in &pcm[..n] {
|
||||
window_peak = window_peak.max(s.abs());
|
||||
}
|
||||
// The ring's pre-reservation in `start` assumes the protocol's 5 ms (≤480-f32/ch)
|
||||
// frames; a larger frame would force a one-time realloc on the RT thread. Catch a
|
||||
// future host frame-size change here in debug, not as a silent audio glitch.
|
||||
debug_assert!(
|
||||
n <= 5 * ms,
|
||||
"audio frame {n} f32 exceeds the 5 ms ring reserve"
|
||||
);
|
||||
let count = counters.opus_decoded.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
// Reuse a recycled buffer if the callback handed one back; only allocate when the
|
||||
// free-list is momentarily empty (startup / after a backpressure drop).
|
||||
let mut buf = free_rx
|
||||
.try_recv()
|
||||
.unwrap_or_else(|_| Vec::with_capacity(pcm_scratch));
|
||||
buf.clear();
|
||||
buf.extend_from_slice(&pcm[..n]);
|
||||
match tx.try_send(buf) {
|
||||
Ok(()) | Err(TrySendError::Full(_)) => {} // drop-newest under backpressure
|
||||
Err(TrySendError::Disconnected(_)) => break,
|
||||
}
|
||||
if count % 600 == 0 {
|
||||
log::info!(
|
||||
"audio: opus={count} pcm_frames={} underruns={} ring={} peak={window_peak:.3}",
|
||||
counters.pcm_written.load(Ordering::Relaxed),
|
||||
counters.underruns.load(Ordering::Relaxed),
|
||||
counters.ring_depth.load(Ordering::Relaxed),
|
||||
);
|
||||
window_peak = 0.0;
|
||||
}
|
||||
}
|
||||
Err(e) => log::debug!("audio: opus decode: {e}"),
|
||||
}
|
||||
}
|
||||
Err(PunktfunkError::NoFrame) => {} // timeout
|
||||
Err(_) => break, // session closed
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user