fix(client/audio): the in-core decoder conceals lost packets like every other client

A field report: game audio on a MacBook (M1) crackles over Wi-Fi against a host
that plays clean to other clients. The Apple client is the one client whose
Opus decode lives in core (punktfunk_connection_next_audio_pcm — AudioToolbox
has no multistream path), and that decoder only ever decoded packets that
ARRIVED. The Linux, Windows and Android decode loops all feed an
AudioGapTracker and synthesize libopus packet-loss concealment for every
packet the wire lost; the in-core path had the tracker sitting unused in the
same crate. So on Apple every lost 5 ms datagram — at ~200 packets/s over
Wi-Fi, a steady trickle — landed in the playout ring as a hard time-domain
gap: a click per loss, sustained crackle under real loss. The redundant-plane
recovery (0xD2) hides single losses when the host grants it, which is exactly
why the survivors are the burstier gaps that need concealing most.

The decode now runs through the same accounting as everyone else: concealed
frames land in front of the arriving frame in one contiguous buffer (the
embedder just writes it to its ring), a DTX marker advances the accounting
without being decoded, and the output buffer is pre-sized for a full
concealment run so the borrow-until-next-call pointer can never dangle.
Unit-tested against real libopus: gaps, duplicates, DTX-after-loss, and the
50 ms cap.
This commit is contained in:
2026-08-07 09:23:15 +02:00
parent 9c7409ea40
commit 81d257c7fa
3 changed files with 153 additions and 31 deletions
+7
View File
@@ -2594,6 +2594,13 @@ PunktfunkStatus punktfunk_connection_end_reason(PunktfunkConnection *c, uint8_t
// [`punktfunk_connection_next_audio`] on a given connection, from one dedicated audio thread —
// not both (they share the underlying queue).
//
// **Loss concealment**: packets the wire lost (a gap in the sequence, after the redundant-plane
// recovery has had its chance) are synthesized via libopus packet-loss concealment and returned
// IN FRONT of the arriving frame in the same buffer — `out->frame_count` then covers the
// concealed frames plus the real one (`out->seq`/`out->pts_ns` are the real packet's). The
// embedder just writes the whole buffer to its ring, same as any other frame; gaps arrive
// pre-healed, exactly as they do on the clients that decode outside core.
//
// # Safety
// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio.
PunktfunkStatus punktfunk_connection_next_audio_pcm(PunktfunkConnection *c,