fix(client/apple): audio stops crackling on lossy, bunching Wi-Fi #82

Merged
enricobuehler merged 3 commits from worktree-audio-wifi-distortion into main 2026-08-07 07:37:26 +00:00
Owner

Field report: game audio distorts over Wi-Fi on a MacBook Pro M1, while a patched Moonlight (tunable audio buffer) plays clean against the same host. The Apple client turned out to be the only one of the four missing both protections the others already have — this PR closes both gaps and fixes the build-script trap found on the way.

1. The in-core decoder never concealed lost packets (81d257c7)

The Apple client is the only client whose Opus decode lives in core (punktfunk_connection_next_audio_pcm — AudioToolbox has no multistream path), and that decoder only decoded packets that arrived. Linux, Windows and Android all feed an AudioGapTracker and synthesize libopus packet-loss concealment for every lost packet; the tracker sat unused in the very crate the Apple decode runs in. Every lost 5 ms datagram (~200 packets/s) therefore hit the playout ring as a hard time-domain gap — a click per loss, sustained crackle under real Wi-Fi loss. The redundant 0xD2 plane only heals single losses (and only when the host grants it), which is exactly why the survivors are the burstier gaps that need concealing most.

Now the decode runs 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 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.

2. The Swift jitter ring never deepened (64c92da3)

The shared JitterPolicy grew an adaptive target floor (3 underruns in 5 s → +10 ms up to max_target_ms; 30 s quiet → a step back), and the three Rust rings run it via note_read. The Apple ring is the one hand-written mirror — and it mirrored the drift-shed half but not the growth half, so its target stayed pinned at the 20 ms base forever. On Wi-Fi that bunches deliveries (power-save is the classic), 20 ms is shorter than one stall, so the ring re-primed through every stall for the whole session: crackle that never got better, on exactly the client where a deeper-buffered Moonlight sounds fine.

The ring now carries the full note_read mirror (grow, relax, cap at COREAUDIO's 70 ms, plus the target + quantum hard-trim guard the mirror also lacked). New tests pin the mirror to the Rust suite's expectations, plus the field scenario end to end: bunched 60 ms deliveries with every fourth burst 30 ms late must converge to a silence-free tail. No user-facing buffer setting on purpose — the ring self-tunes instead of making users hunt for a slider.

3. The xcframework absorbed a Homebrew libopus (b2713530)

On a Mac with brew's opus installed, audiopus_sys pkg-configs the arm64-only Homebrew static lib into one slice (built for the running macOS, tripping the script's own minos guard) while the other slice silently falls back to the vendored build — two slices, two different libopus builds. The script now forces the hermetic vendored CMake build for every slice.

Verification

  • punktfunk-core: 348 tests green (incl. the new concealment test), fmt + clippy clean, C-ABI harness links and passes.
  • Apple package: 208/208 Swift tests green, incl. 4 new adaptive-ring tests and the existing drift suite against the modified ring.
  • xcframework rebuilds clean from the fixed script, version guard green.
  • Still owed: an on-glass listen over real Wi-Fi. The fix reaches the app only after bash scripts/build-xcframework.sh (note: prebuilt frameworks older than ABI v17 no longer build against main anyway).
Field report: game audio distorts over Wi-Fi on a MacBook Pro M1, while a patched Moonlight (tunable audio buffer) plays clean against the same host. The Apple client turned out to be the only one of the four missing **both** protections the others already have — this PR closes both gaps and fixes the build-script trap found on the way. ## 1. The in-core decoder never concealed lost packets (`81d257c7`) The Apple client is the only client whose Opus decode lives in core (`punktfunk_connection_next_audio_pcm` — AudioToolbox has no multistream path), and that decoder only decoded packets that *arrived*. Linux, Windows and Android all feed an `AudioGapTracker` and synthesize libopus packet-loss concealment for every lost packet; the tracker sat unused in the very crate the Apple decode runs in. Every lost 5 ms datagram (~200 packets/s) therefore hit the playout ring as a hard time-domain gap — a click per loss, sustained crackle under real Wi-Fi loss. The redundant `0xD2` plane only heals *single* losses (and only when the host grants it), which is exactly why the survivors are the burstier gaps that need concealing most. Now the decode runs 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 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. ## 2. The Swift jitter ring never deepened (`64c92da3`) The shared `JitterPolicy` grew an adaptive target floor (3 underruns in 5 s → +10 ms up to `max_target_ms`; 30 s quiet → a step back), and the three Rust rings run it via `note_read`. The Apple ring is the one hand-written mirror — and it mirrored the drift-shed half but not the growth half, so its target stayed pinned at the 20 ms base forever. On Wi-Fi that bunches deliveries (power-save is the classic), 20 ms is shorter than one stall, so the ring re-primed through every stall for the whole session: crackle that never got better, on exactly the client where a deeper-buffered Moonlight sounds fine. The ring now carries the full `note_read` mirror (grow, relax, cap at COREAUDIO's 70 ms, plus the `target + quantum` hard-trim guard the mirror also lacked). New tests pin the mirror to the Rust suite's expectations, plus the field scenario end to end: bunched 60 ms deliveries with every fourth burst 30 ms late must converge to a silence-free tail. No user-facing buffer setting on purpose — the ring self-tunes instead of making users hunt for a slider. ## 3. The xcframework absorbed a Homebrew libopus (`b2713530`) On a Mac with brew's opus installed, `audiopus_sys` pkg-configs the arm64-only Homebrew static lib into one slice (built for the *running* macOS, tripping the script's own minos guard) while the other slice silently falls back to the vendored build — two slices, two different libopus builds. The script now forces the hermetic vendored CMake build for every slice. ## Verification - `punktfunk-core`: 348 tests green (incl. the new concealment test), fmt + clippy clean, C-ABI harness links and passes. - Apple package: 208/208 Swift tests green, incl. 4 new adaptive-ring tests and the existing drift suite against the modified ring. - xcframework rebuilds clean from the fixed script, version guard green. - Still owed: an on-glass listen over real Wi-Fi. The fix reaches the app only after `bash scripts/build-xcframework.sh` (note: prebuilt frameworks older than ABI v17 no longer build against main anyway).
enricobuehler added 3 commits 2026-08-07 07:33:43 +00:00
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.
The shared JitterPolicy grew an adaptive target floor — clustered genuine
underruns raise the live target a step at a time up to max_target_ms, a long
quiet spell relaxes it back — and the three Rust rings all run it via
note_read. The Apple ring is the one hand-written mirror, and it mirrored the
shed half but not the growth half: its target was pinned at the 20 ms base
forever. On Wi-Fi that bunches arrivals (power-save is the classic; the field
MacBook report is the symptom), 20 ms is regularly shorter than one delivery
stall, so the ring re-primed through every stall for the whole session —
crackle that never got better, on exactly the client where a Moonlight with a
deeper buffer sounds fine on the same host and network.

The ring now carries the full mirror of note_read: 3 underruns inside a 5 s
window grow the target 10 ms (capped at COREAUDIO's 70), 30 s of quiet gives a
step back, and the write-side hard trim follows the grown target (including
the Rust policy's target+quantum guard, which the mirror also lacked). New
tests pin the mirror to the Rust suite's expectations — growth, relax, the
cap — plus the field scenario end to end: bunched 60 ms deliveries with every
fourth burst 30 ms late converge to a silence-free tail instead of crackling
forever.
fix(scripts): the xcframework never absorbs a Homebrew libopus
ci / docs-site (pull_request) Failing after 2s
ci / web (pull_request) Failing after 5s
android / android (pull_request) Failing after 14s
ci / bun-nix (pull_request) Successful in 22s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m52s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 5m45s
ci / rust (pull_request) Failing after 7m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m22s
b27135308f
On a Mac with brew's opus installed, audiopus_sys found it via pkg-config and
statically linked it into the aarch64 slice — a lib built for the RUNNING
macOS (minos 26.0, tripping the script's own version guard) and existing only
for the host arch, so the x86_64 slice silently fell back to the vendored
build and the two slices shipped different libopus builds. Force the vendored
CMake build for every slice (OPUS_NO_PKG_CONFIG=1), with the CMake policy
floor modern CMake (>=4) needs to accept libopus's old cmake_minimum_required.
enricobuehler merged commit b5205fef52 into main 2026-08-07 07:37:25 +00:00
enricobuehler deleted branch worktree-audio-wifi-distortion 2026-08-07 07:37:36 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#82