The jitter ring only ever learned from clicks — it now grows on near-misses, un-does refused shrinks, and cashes growth on the click it already paid #111

Merged
enricobuehler merged 1 commits from worktree-audio-jitter-lowwater into main 2026-08-08 10:11:11 +00:00
Owner

The field report

0.25.0 on a MacBook Pro M1 over Wi-Fi: audio jitter "at certain points", confirmed by a second listener. 0.25.0 shipped both the Apple PLC/adaptive-ring work (#82) and A/V sync (#101), so this is the first field verdict on the two interacting — and the verdict was correct.

What was actually wrong

A ten-minute simulation of the Wi-Fi power-save pattern (25 ms delivery gaps every 300 ms, −50 ppm host-vs-DAC skew) measured ~2000 audible events under the shipped policy. Three compounding causes, and notably the biggest one does not involve the sync loop at all:

  1. Growth is a promise; only a re-prime pays it. Growing the target raises a threshold — nothing deepens the ring, because inflow always equals outflow. The only mechanism that re-banks depth is a de-prime refill, which required 4 consecutive short reads. A bunching link produces isolated clicks, so the ring rode the knife edge clicking once per period while its "grown" target sat inert. (This is why PUNKTFUNK_NO_AV_SYNC=1 would not have cleaned the report.)
  2. Growth needed three audible underruns before deepening — the user heard two before the policy did anything.
  3. The sync loop re-probed a refused depth every five quiet seconds. Each wrong guess was an audible starvation event, and it retried forever — the "wrong guesses are cheap" comment in note_read was wrong on exactly this link.

The fix — three mechanisms, JitterPolicy + the Swift AudioRing mirror

  • Near-miss: a read served in full but with less than one protocol frame left over is the same evidence as an underrun, heard by no one. It grows the target one step per window, before the first click. On a healthy link the post-read depth hovers a whole target above the margin, so this cannot fire on normal swing.
  • Shrink probes: every shrink is armed for five seconds. Answered by an underrun or a near-miss, it is undone on the spot — no re-learning three clicks at a time — and a failed sync-driven shrink is not retried for a doubling backoff (60 s → 8 min; a probe that survives resets it). Continuity outranks sync, now with a memory.
  • Hollow re-prime: an underrun while the depth average runs more than one growth step below the target re-primes immediately — the click already happened, so it buys the whole refill instead of a click per period. The average (seeded on prime, lifted by the current callback's quantum) is what separates a hollow ring from one late packet, which keeps the existing hysteresis.

Same simulation after: 9 audible events, tail clean except the clock-skew re-anchor — a genuinely slow host must re-bank every few minutes, and only rate adaptation (which no client has) would remove that.

Verification

  • New Rust convergence tests drive the bunching pattern for ten simulated minutes with and without sync pressure; neutralising the three new constants reproduces the ~2000 events and fails them (plant-the-defect, run by hand).
  • ⚠ A drift-free bunching sim passes vacuously — with inflow exactly equal to outflow the depth freezes wherever priming left it and a wrong target is never punished. The sims carry ppm skew for that reason; worth knowing before writing the next one.
  • Two Swift tests were updated for semantics this change makes deliberate: a knife-edge refill (write exactly what each read takes) now reads as pressure, not quiet — so quiet-relax harnesses refill deep first — and testNegativeDriftKeepsPlaying became testNegativeDriftBanksRarelyInsteadOfChattering, because "silence-free" was hiding permanent sub-frame chatter: every callback fractionally short, none fully silent, all audible.
  • Gates: 203 punktfunk-core tests, 254 Swift tests (0 failures), clippy -D warnings --all-features on punktfunk-core, cargo fmt --all --check.

Still owed

An on-glass listen on the reporting MacBook. The vacuous-sim episode above is this branch's own reminder that only ears settle it — the stats overlay (audio buffer / a/v ±) and the ~10 s audio: log line (log stream --predicate 'subsystem == "io.unom.punktfunk"') now exist to put numbers next to what they hear.

## The field report 0.25.0 on a MacBook Pro M1 over Wi-Fi: audio jitter "at certain points", confirmed by a second listener. 0.25.0 shipped both the Apple PLC/adaptive-ring work (#82) and A/V sync (#101), so this is the first field verdict on the two interacting — and the verdict was correct. ## What was actually wrong A ten-minute simulation of the Wi-Fi power-save pattern (25 ms delivery gaps every 300 ms, −50 ppm host-vs-DAC skew) measured **~2000 audible events under the shipped policy**. Three compounding causes, and notably the biggest one does not involve the sync loop at all: 1. **Growth is a promise; only a re-prime pays it.** Growing the target raises a threshold — nothing deepens the ring, because inflow always equals outflow. The only mechanism that re-banks depth is a de-prime refill, which required **4 consecutive short reads**. A bunching link produces *isolated* clicks, so the ring rode the knife edge clicking once per period while its "grown" target sat inert. (This is why `PUNKTFUNK_NO_AV_SYNC=1` would not have cleaned the report.) 2. **Growth needed three audible underruns** before deepening — the user heard two before the policy did anything. 3. **The sync loop re-probed a refused depth every five quiet seconds.** Each wrong guess was an audible starvation event, and it retried forever — the "wrong guesses are cheap" comment in `note_read` was wrong on exactly this link. ## The fix — three mechanisms, `JitterPolicy` + the Swift `AudioRing` mirror - **Near-miss**: a read served in full but with less than one protocol frame left over is the same evidence as an underrun, *heard by no one*. It grows the target one step per window, before the first click. On a healthy link the post-read depth hovers a whole target above the margin, so this cannot fire on normal swing. - **Shrink probes**: every shrink is armed for five seconds. Answered by an underrun **or a near-miss**, it is undone on the spot — no re-learning three clicks at a time — and a failed *sync-driven* shrink is not retried for a doubling backoff (60 s → 8 min; a probe that survives resets it). Continuity outranks sync, now with a memory. - **Hollow re-prime**: an underrun while the depth *average* runs more than one growth step below the target re-primes immediately — the click already happened, so it buys the whole refill instead of a click per period. The average (seeded on prime, lifted by the current callback's quantum) is what separates a hollow ring from one late packet, which keeps the existing hysteresis. Same simulation after: **9 audible events, tail clean** except the clock-skew re-anchor — a genuinely slow host must re-bank every few minutes, and only rate adaptation (which no client has) would remove that. ## Verification - New Rust convergence tests drive the bunching pattern for ten simulated minutes with and without sync pressure; **neutralising the three new constants reproduces the ~2000 events and fails them** (plant-the-defect, run by hand). - ⚠ A drift-free bunching sim passes *vacuously* — with inflow exactly equal to outflow the depth freezes wherever priming left it and a wrong target is never punished. The sims carry ppm skew for that reason; worth knowing before writing the next one. - Two Swift tests were updated for semantics this change makes deliberate: a knife-edge refill (write exactly what each read takes) now reads as *pressure*, not quiet — so quiet-relax harnesses refill deep first — and `testNegativeDriftKeepsPlaying` became `testNegativeDriftBanksRarelyInsteadOfChattering`, because "silence-free" was hiding permanent sub-frame chatter: every callback fractionally short, none fully silent, all audible. - Gates: 203 punktfunk-core tests, 254 Swift tests (0 failures), `clippy -D warnings --all-features` on punktfunk-core, `cargo fmt --all --check`. ## Still owed An on-glass listen on the reporting MacBook. The vacuous-sim episode above is this branch's own reminder that only ears settle it — the stats overlay (`audio buffer / a/v ±`) and the ~10 s `audio:` log line (`log stream --predicate 'subsystem == "io.unom.punktfunk"'`) now exist to put numbers next to what they hear.
enricobuehler added 1 commit 2026-08-08 09:11:01 +00:00
fix(audio): detect jitter before it is audible, and stop re-probing a depth the link just refused
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 2m1s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m44s
ci / web (pull_request) Successful in 1m38s
android / android (pull_request) Successful in 4m52s
ci / docs-site (pull_request) Successful in 1m33s
ci / rust-arm64 (pull_request) Successful in 4m10s
ci / bun-nix (pull_request) Successful in 28s
ci / rust (pull_request) Successful in 9m26s
3bb87d260e
The 0.25.0 MacBook field report — audio jitter 'at certain points' — is the
jitter policy learning exclusively from audible failures, on both of its
sides. Growth needed THREE audible underruns before deepening the ring; the
A/V sync loop re-tested a shallower ring every five quiet seconds and paid an
audible starvation event every time it was wrong, forever; and a grown target
was never re-banked — growth raises a threshold, only a re-prime deepens the
ring — so a bunching link rode the knife edge, clicking once per bunching
period with the 'grown' target sitting inert. A ten-minute simulation of the
Wi-Fi power-save pattern (25 ms gaps / 300 ms, −50 ppm skew) measured ~2000
audible events under the shipped policy.

Three mechanisms, in JitterPolicy (Linux/Windows/Android) and mirrored in the
Swift AudioRing:

- NEAR-MISS: a read served with less than one protocol frame left over is the
  same evidence as an underrun, heard by no one. It grows the target one step
  per window, BEFORE the click — waiting for the third audible underrun means
  the user heard two.
- SHRINK PROBES: every shrink is armed for five seconds; answered by an
  underrun or near-miss it is undone on the spot, and a failed sync-driven
  shrink is not retried for a doubling backoff (60 s → 8 min). A probe that
  survives resets the backoff. Continuity outranks sync, now with a memory.
- HOLLOW RE-PRIME: an underrun while the depth AVERAGE runs more than a step
  below the target re-primes immediately, spending the click it already cost
  on the whole refill instead of limping. The average, not the instant, is
  what separates a hollow ring from one late packet, and it is seeded on
  prime so a fresh ring is never spuriously hollow.

Same simulation after: 9 audible events, tail clean but for the clock-skew
re-anchor (a genuinely slow host must re-bank every few minutes; only rate
adaptation would remove that, and no client has it). Neutralising the three
constants reproduces the ~2000 — the convergence tests fail against the old
behaviour.

Verified: 203 punktfunk-core tests, 254 Swift tests (5 skipped), clippy -D
warnings on punktfunk-core --all-features, cargo fmt --all --check.
enricobuehler merged commit 9089651406 into main 2026-08-08 10:11:11 +00:00
enricobuehler deleted branch worktree-audio-jitter-lowwater 2026-08-08 10:11:16 +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#111