fix(client/apple): the jitter ring deepens on the sessions that actually starve

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.
This commit is contained in:
2026-08-07 09:23:29 +02:00
parent 81d257c7fa
commit 64c92da356
2 changed files with 176 additions and 9 deletions
@@ -15,10 +15,16 @@ import os
/// audible blip". It is now the same two-stage scheme the Rust clients share
/// (`punktfunk_core::audio::JitterPolicy`): a slow depth average that sits above target for a
/// sustained window sheds ONE 5 ms frame with a crossfade, and the hard cap is only a backstop.
/// Keep the constants here in step with `JitterTuning.COREAUDIO`.
///
/// **Adaptive depth.** The target is a floor, not a constant: repeated genuine underruns grow it
/// a step at a time (`noteRead`, mirroring `JitterPolicy::note_read`) up to `maxTargetMS`, and a
/// long quiet spell relaxes it back toward the base so a session on Wi-Fi that bunches arrivals
/// deepens until it stops crackling, while a clean LAN keeps the tight base latency. Keep the
/// constants here in step with `JitterTuning.COREAUDIO`.
final class AudioRing: @unchecked Sendable {
/// Mirrors `JitterTuning::COREAUDIO` see that type for the rationale.
private static let targetMS = 20
private static let maxTargetMS = 70
private static let headroomMS = 30
private static let hardCapMS = 90
private static let deprimeAfter = 4
@@ -33,6 +39,15 @@ final class AudioRing: @unchecked Sendable {
private static let crossfadeMS = 2
/// Time constant of the depth average.
private static let ewmaTauMS = 1_000
/// Adaptive target floor, mirroring `JitterPolicy::note_read`: this many genuine underruns
/// inside one window grow the live target a step (up to `maxTargetMS`), and a long quiet
/// spell relaxes it a step back toward the base so only the sessions that actually starve
/// (Wi-Fi power-save bunching is the classic) pay for extra depth, and only while they need
/// it. All spans are measured in consumed samples, like the Rust policy.
private static let growUnderruns = 3
private static let growWindowMS = 5_000
private static let growStepMS = 10
private static let shrinkQuietMS = 30_000
private var buf: [Float]
private var readIdx = 0
@@ -42,6 +57,14 @@ final class AudioRing: @unchecked Sendable {
private var emptyReads = 0
private var depthAvg: Double = 0
private var overRun = 0
/// The live target in interleaved samples `targetMS` grown by underrun pressure
/// (`noteRead`), never below the base. Set in `init` (needs `perMS`).
private var targetLive = 0
/// Underruns seen in the current growth window, and the window's consumed-sample count.
private var underrunsInWindow = 0
private var windowRun = 0
/// Consumed samples since the last underrun (drives the relax-back-down step).
private var quietRun = 0
/// Reported, not acted on: short reads that actually starved the callback, and smooth drift
/// corrections. A rising underrun count means the ring is being starved (network or CPU),
/// which is a different problem from the depth being wrong.
@@ -57,12 +80,14 @@ final class AudioRing: @unchecked Sendable {
buf = [Float](repeating: 0, count: capacity)
self.channels = channels
perMS = 48 * channels
targetLive = Self.targetMS * perMS
}
/// Live target depth in interleaved samples, lifted so it can always serve one device quantum
/// plus a packet (a large-buffer device cannot sustain a target below its own quantum).
/// Effective target depth in interleaved samples: the (adaptively grown) live target, lifted
/// so it can always serve one device quantum plus a packet (a large-buffer device cannot
/// sustain a target below its own quantum).
private var target: Int {
max(Self.targetMS * perMS, renderQuantum + Self.frameMS * perMS)
max(targetLive, renderQuantum + Self.frameMS * perMS)
}
func write(_ samples: UnsafePointer<Float>, count: Int) {
@@ -80,8 +105,13 @@ final class AudioRing: @unchecked Sendable {
buf[(writeIdx + i) % capacity] = samples[i]
}
writeIdx += count
// Backstop only: the smooth shed in `read` is what normally holds the depth down.
let cap = min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS)
// Backstop only: the smooth shed in `read` is what normally holds the depth down. The
// hard cap must always leave room for one device quantum past the target (mirrors the
// Rust policy's `.max(target + want)`) or a large-quantum device would trim itself into
// a permanent underrun.
let cap = max(
min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS),
target + renderQuantum)
if writeIdx - readIdx > cap {
readIdx = writeIdx - cap
depthAvg = Double(cap)
@@ -133,13 +163,43 @@ final class AudioRing: @unchecked Sendable {
readIdx += n
if n < count {
for i in n..<count { out[i] = 0 }
// De-prime only after a RUN of short reads: a single transient drain must not
// manufacture a whole target's worth of fresh silence.
}
noteRead(ranShort: n < count, count: count)
}
/// The outcome accounting of one primed read the Swift mirror of
/// `JitterPolicy::note_read`. A short read drives both the de-prime hysteresis (a single
/// transient drain must not manufacture a whole target's worth of fresh silence) and the
/// adaptive target floor: a device that genuinely keeps starving gets more slack, one step
/// per window, capped and gives it back after a long quiet spell, so one bad minute
/// doesn't cost latency for the rest of the session. Caller holds the lock.
private func noteRead(ranShort: Bool, count: Int) {
windowRun += count
if windowRun >= Self.growWindowMS * perMS {
windowRun = 0
underrunsInWindow = 0
}
if ranShort {
quietRun = 0
emptyReads += 1
underrunCount += 1
if emptyReads >= Self.deprimeAfter { primed = false }
if emptyReads >= Self.deprimeAfter {
primed = false
emptyReads = 0
}
underrunsInWindow += 1
if underrunsInWindow >= Self.growUnderruns {
underrunsInWindow = 0
windowRun = 0
targetLive = min(targetLive + Self.growStepMS * perMS, Self.maxTargetMS * perMS)
}
} else {
emptyReads = 0
quietRun += count
if quietRun >= Self.shrinkQuietMS * perMS {
quietRun = 0
targetLive = max(targetLive - Self.growStepMS * perMS, Self.targetMS * perMS)
}
}
}
@@ -91,5 +91,112 @@ final class AudioRingDriftTests: XCTestCase {
scratch.contains { $0 != 0 },
"a single short read must not force a full re-prime")
}
/// Mirror of the Rust `target_grows_on_underruns_and_relaxes_when_quiet`: clustered genuine
/// underruns raise the target floor (that session needs the slack), a long quiet spell gives
/// it back and the floor never dips below the base.
func testTargetGrowsOnUnderrunsAndRelaxesWhenQuiet() {
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
let want = 5 * perMS
var scratch = [Float](repeating: 0, count: want)
let feed = [Float](repeating: 0.5, count: 25 * perMS)
func write(ms: Int) {
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
}
func read() {
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
}
XCTAssertEqual(ring.stats.targetMS, 20, "base target must match JitterTuning.COREAUDIO")
// Prime, drain dry, then alternate starve/refill: each dry read is a genuine underrun,
// each full read in between keeps the de-prime hysteresis from tripping.
write(ms: 25)
for _ in 0..<5 { read() } // drains to zero
read() // short underrun 1
write(ms: 5); read() // full hysteresis reset
read() // short underrun 2
write(ms: 5); read() // full
read() // short underrun 3 the floor grows one step
XCTAssertEqual(ring.stats.targetMS, 30, "3 clustered underruns must grow the target 10 ms")
XCTAssertEqual(ring.stats.underruns, 3)
// A long clean run (30 s of consumed audio) relaxes the growth back to the base
for _ in 0..<(30_000 / 5 + 10) {
write(ms: 5)
read()
}
XCTAssertEqual(ring.stats.targetMS, 20, "a quiet spell must give the growth back")
// and stays there: quiet forever never dips below the base.
for _ in 0..<(30_000 / 5 + 10) {
write(ms: 5)
read()
}
XCTAssertEqual(ring.stats.targetMS, 20, "the floor must never go below the base target")
}
/// Growth is capped at `maxTargetMS`, exactly like `JitterPolicy` respects
/// `JitterTuning.max_target_ms`.
func testTargetGrowthRespectsTheCap() {
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
let want = 5 * perMS
var scratch = [Float](repeating: 0, count: want)
let feed = [Float](repeating: 0.5, count: 25 * perMS)
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: 25 * perMS) }
// Starve it far past what six growth steps (20 70) would need.
for _ in 0..<40 {
for _ in 0..<5 {
scratch.withUnsafeMutableBufferPointer {
ring.read(into: $0.baseAddress!, count: want)
}
}
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: 25 * perMS) }
}
XCTAssertLessThanOrEqual(ring.stats.targetMS, 70, "growth must respect maxTargetMS")
}
/// THE field scenario: Wi-Fi power-save bunches arrivals audio is produced steadily but
/// delivered in bursts, some of them late. A fixed 20 ms target crackles on every late burst
/// forever; the adaptive floor must deepen until the bunching rides through, and the tail of
/// the session must be silence-free.
func testWifiBunchingConvergesToSilenceFree() {
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
let want = 5 * perMS
var scratch = [Float](repeating: 0, count: want)
var pending = 0 // ms produced by the host but still "in flight"
var burst = 0
var silentTail = 0
let steps = 4000 // 20 s in 5 ms callbacks
let feed = [Float](repeating: 0.5, count: 200 * perMS)
for step in 0..<steps {
pending += 5 // the host encodes 5 ms per 5 ms of wall clock, stall or not
// Delivery bunches into ~60 ms bursts; every 4th burst arrives a further 30 ms late.
if step % 12 == 11 {
if burst % 4 == 3 {
// Hold this burst 30 ms: it is flushed 6 callbacks later instead.
burst += 1
} else {
feed.withUnsafeBufferPointer {
ring.write($0.baseAddress!, count: pending * perMS)
}
pending = 0
burst += 1
}
} else if step % 12 == 5, pending >= 60 {
// The held burst lands, together with everything produced since.
feed.withUnsafeBufferPointer {
ring.write($0.baseAddress!, count: pending * perMS)
}
pending = 0
}
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
if step >= steps - 600, scratch.allSatisfy({ $0 == 0 }) { silentTail += 1 }
}
XCTAssertGreaterThanOrEqual(
ring.stats.targetMS, 30,
"bunched delivery must have grown the target floor")
XCTAssertEqual(
silentTail, 0,
"after adapting, the last 3 s must play through the bunching without a dropout")
}
}
#endif