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 grantedioBufferDuration/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.
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`.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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_aftercounted CALLBACKS, and a callback is not a unit of timeThe same
4was ~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
COREAUDIOpreset'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 its5was also ~20 ms.Now
deprime_ms, measured in starved audio, with aMIN_DEPRIME_CALLBACKSfloor 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:
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
installMicTapinstalls withbufferSize: 480and the encoder consumes whole 10 msframesPerPacketchunks, 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_trimrather than by the fade length.crossfade_droplost itsVecin the process — it blends in place in one ascending pass (safe: the fade-out sources are all strictly belowdrop), which it must, now that it runs on every trim inside a realtime callback.Effect
Same link, 10 minutes:
Scope
Fixes 1 and 3 are in the shared
JitterPolicy, so Windows, Linux and Android get them with no changes — all three already passstep.crossfadeintocrossfade_drop. The Swift mirror inAudioRingis kept in step, including the generaliseddropFront(_:)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 macOSswift buildnever 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)
JitterTuningis not#[non_exhaustive]andpunktfunk-webosdefines its ownWEBOS_TUNINGliteral. It pins core at tagv0.22.3, so nothing breaks today — but whoever bumps that pin owesdeprime_after: 5→deprime_ms: 60. Loud compile error, not silent.JitterStepcan 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 inpunktfunk-planning/design/audio-clock-drift-and-rate-adaptation.md.