diff --git a/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift b/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift index f139d82e..222d4be0 100644 --- a/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift +++ b/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift @@ -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, 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..= 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) + } } } diff --git a/clients/apple/Tests/PunktfunkKitTests/AudioRingDriftTests.swift b/clients/apple/Tests/PunktfunkKitTests/AudioRingDriftTests.swift index a86949dd..7fb85be1 100644 --- a/clients/apple/Tests/PunktfunkKitTests/AudioRingDriftTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/AudioRingDriftTests.swift @@ -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..= 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