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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user