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
Owner

Chasing a field report of slight, intermittent audio jitter on iPad that survived both the PLC fix (#82) and the near-miss/probe/hollow work (#111) — both confirmed merged and present in what the device ran. The residual was not in the adaptive logic at all.

1. 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.

The COREAUDIO preset's own comment ("the iOS IO buffer is already 5 ms") cited that quantum as grounds for copying WASAPI's count. It is precisely why a count was the wrong unit. Android was latently exposed too — AAudio's low-latency burst is ~4–5 ms, so its 5 was also ~20 ms.

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.

Driving the real policy through a simulated link (100 ms stall / 5 s, −30 ppm, 10 min), sweeping only the IO quantum:

quantum de-prime after audible gaps
5 ms (old iOS ask) 20 ms 120 — one every 5 s
8 ms 32 ms 3
11 ms (macOS) 44 ms 2
16 ms 64 ms 1

A cliff between 5 and 8 ms. That is the iPad-vs-Mac difference in one number.

2. The iOS 5 ms IO ask bought the uplink nothing

installMicTap installs with bufferSize: 480 and the encoder consumes whole 10 ms framesPerPacket chunks, so at a 5 ms quantum the tap simply fired twice per packet for identical packet latency. The two asks contradicted each other in the same file. What it did buy was a halved render-callback deadline and, via (1), half the ring's starvation hysteresis — on the platform that could least afford it. Now 10 ms.

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 or a 23 ms one — the gap that made this round take a simulation to close.

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. It is also the drop that actually fires: the 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 (safe: the fade-out sources are all strictly below drop), which it must, now that it runs on every trim inside a realtime callback.

Effect

Same link, 10 minutes:

gaps dead air unfaded trims
before 120 690 ms 120
+ fix 1 2 60 ms 0
+ fixes 1&2 1 20 ms 0

Scope

Fixes 1 and 3 are in the shared JitterPolicy, so Windows, Linux and Android get them with no changes — 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 — fix 2 lives in #if os(iOS), which a macOS swift build never compiles, so it would otherwise have merged untypechecked.

Both new fuse tests are plant-the-defect verified: restoring a fixed count reproduces 20/32/40/64/84 ms across the quanta (a 4.2× spread) and fails them loudly.

Follow-ups (not in this PR)

  • webOS owes a one-line change. JitterTuning is not #[non_exhaustive] and punktfunk-webos defines its own WEBOS_TUNING literal. It pins core at tag v0.22.3, so nothing breaks today — but whoever bumps that pin owes deprime_after: 5deprime_ms: 60. Loud compile error, not silent.
  • Drift correction is still one-directional. JitterStep can only drop; there is no insert/stretch path anywhere. A host clock running slow is corrected only by starving and re-priming — a gap of one whole target depth every ~30 min at −20 ppm, ~12 min at −50, ~7.5 min at −100, independent of Wi-Fi quality. That is the remaining periodic artefact on a clean link and it needs rate adaptation; designed separately in punktfunk-planning/design/audio-clock-drift-and-rate-adaptation.md.
Chasing a field report of slight, intermittent audio jitter on iPad that survived **both** the PLC fix (#82) and the near-miss/probe/hollow work (#111) — both confirmed merged and present in what the device ran. The residual was not in the adaptive logic at all. ## 1. `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. The `COREAUDIO` preset's own comment ("the iOS IO buffer is already 5 ms") cited that quantum as grounds for copying WASAPI's count. It is precisely why a count was the wrong unit. Android was latently exposed too — AAudio's low-latency burst is ~4–5 ms, so its `5` was also ~20 ms. 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. Driving the real policy through a simulated link (100 ms stall / 5 s, −30 ppm, 10 min), sweeping only the IO quantum: | quantum | de-prime after | audible gaps | |---|---|---| | **5 ms (old iOS ask)** | 20 ms | **120** — one every 5 s | | 8 ms | 32 ms | 3 | | 11 ms (macOS) | 44 ms | 2 | | 16 ms | 64 ms | 1 | A cliff between 5 and 8 ms. That is the iPad-vs-Mac difference in one number. ## 2. The iOS 5 ms IO ask bought the uplink nothing `installMicTap` installs with `bufferSize: 480` and the encoder consumes whole 10 ms `framesPerPacket` chunks, so at a 5 ms quantum the tap simply fired twice per packet for identical packet latency. The two asks contradicted each other in the same file. What it did buy was a halved render-callback deadline and, via (1), half the ring's starvation hysteresis — on the platform that could least afford it. Now 10 ms. 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 or a 23 ms one — the gap that made this round take a simulation to close. ## 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. It is also the drop that actually fires: the 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 (safe: the fade-out sources are all strictly below `drop`), which it must, now that it runs on every trim inside a realtime callback. ## Effect Same link, 10 minutes: | | gaps | dead air | unfaded trims | |---|---|---|---| | before | 120 | 690 ms | 120 | | + fix 1 | 2 | 60 ms | 0 | | + fixes 1&2 | **1** | **20 ms** | **0** | ## Scope Fixes 1 and 3 are in the shared `JitterPolicy`, so Windows, Linux and Android get them with no changes — 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** — fix 2 lives in `#if os(iOS)`, which a macOS `swift build` never compiles, so it would otherwise have merged untypechecked. Both new fuse tests are plant-the-defect verified: restoring a fixed count reproduces 20/32/40/64/84 ms across the quanta (a 4.2× spread) and fails them loudly. ## Follow-ups (not in this PR) - **webOS owes a one-line change.** `JitterTuning` is not `#[non_exhaustive]` and `punktfunk-webos` defines its own `WEBOS_TUNING` literal. It pins core at tag `v0.22.3`, so nothing breaks today — but whoever bumps that pin owes `deprime_after: 5` → `deprime_ms: 60`. Loud compile error, not silent. - **Drift correction is still one-directional.** `JitterStep` can only drop; there is no insert/stretch path anywhere. A host clock running *slow* is corrected only by starving and re-priming — a gap of one whole target depth every ~30 min at −20 ppm, ~12 min at −50, ~7.5 min at −100, independent of Wi-Fi quality. That is the remaining periodic artefact on a clean link and it needs rate adaptation; designed separately in `punktfunk-planning/design/audio-clock-drift-and-rate-adaptation.md`.
enricobuehler added 1 commit 2026-08-13 15:16:12 +00:00
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
8f6eb1494d
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.
enricobuehler merged commit 86cbbea020 into main 2026-08-13 15:19:57 +00:00
enricobuehler deleted branch worktree-apple-audio-jitter-probe 2026-08-13 15:19:59 +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#200