The de-prime fuse was a callback count, so an iPad gave up 3x sooner than a Mac #200

Merged
enricobuehler merged 1 commits from worktree-apple-audio-jitter-probe into main 2026-08-13 15:19:57 +00:00
1 Commits
Author SHA1 Message Date
enricobuehler 8f6eb1494d fix(audio): the de-prime fuse was a callback count, so an iPad gave up 3x sooner than a Mac
apple / swift (pull_request) Successful in 2m2s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 42s
ci / docs-site (pull_request) Successful in 1m47s
ci / rust-arm64 (pull_request) Successful in 7m19s
ci / web (pull_request) Successful in 7m26s
ci / rust (pull_request) Successful in 10m23s
android / android (pull_request) Successful in 12m59s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m34s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m17s
Three defects behind the residual Apple audio jitter, found while chasing a field
report that survived both the PLC fix (#82) and the jitter-policy fix (#111).

1. `JitterTuning::deprime_after` counted CALLBACKS, and a callback is not a unit of
   time. The same `4` was ~44 ms of starvation slack on a Mac's ~11 ms quantum and
   20 ms on iOS, whose session asks for a short IO buffer — the shortest fuse of any
   client, on the one with the burstiest transport. A 100 ms Wi-Fi delivery stall
   therefore de-primed the Apple ring on every bunching cycle while the identical
   policy rode it out everywhere else. It is now `deprime_ms`, measured in starved
   audio, with a `MIN_DEPRIME_CALLBACKS` floor so a large-quantum device keeps real
   hysteresis instead of de-priming on the first short read. Android was latently
   exposed too (AAudio's low-latency burst is ~4-5 ms, so its `5` was also ~20 ms).

   Driving the real policy through a simulated link (100 ms stall / 5 s, -30 ppm,
   10 min) at a 5 ms quantum: 120 audible gaps and 690 ms of dead air before, 2 gaps
   and 60 ms after.

2. iOS asked for a 5 ms IO buffer that bought the uplink nothing. The mic tap
   installs with `bufferSize: 480` and the encoder consumes whole 10 ms
   `framesPerPacket` chunks, so at 5 ms the tap simply fired twice per packet for the
   same packet latency — while halving the render callback's deadline and, through
   (1), the ring's starvation hysteresis. Now 10 ms, matching the framing we already
   use. On the harsh link above that takes the residual from 2 gaps to 1.

   The granted `ioBufferDuration`/sample rate/route are now logged at activation:
   both asks are best-effort, and without the granted value an audio-jitter report
   arrives with no way to tell a 10 ms session from a 5 ms one.

3. The hard-cap trim spliced RAW, on the reasoning that a ring which blew its ceiling
   "is already a discontinuity". That describes the arrivals, not the samples either
   side of the seam, which are ordinary continuous audio — and it is the drop that
   actually fires: the same link above trims 120 times per 10 minutes where drift
   sheds a handful. The gentle path that almost never runs was the one being faded.
   Both kinds fade now, told apart by a new `JitterStep::hard_trim` rather than by
   the fade length. `crossfade_drop` lost its `Vec` in the process — it blends in
   place in one ascending pass, which it must, now that it runs on every trim inside
   a realtime callback.

Fixes 1 and 3 live in the shared `JitterPolicy`, so Windows, Linux and Android get
them without change (all three already pass `step.crossfade` into `crossfade_drop`).
The Swift mirror in `AudioRing` is kept in step, including the generalised
`dropFront(_:)` the cap trim now shares with the drift shed.

Gates: 210 core tests, 288 Swift tests, clippy --all-features --all-targets, fmt,
plus an iOS-triple typecheck for the `#if os(iOS)` session change. Both new fuse
tests were plant-the-defect verified: restoring a fixed count reproduces
20/32/40/64/84 ms across the quanta (a 4.2x spread) and fails them loudly.

Not fixed here: drift correction is still one-directional, so a host clock running
SLOW is corrected only by starving and re-priming. That is the remaining periodic
gap on a clean link and it needs rate adaptation — designed separately.
2026-08-13 17:14:47 +02:00