diff --git a/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift b/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift index e5f030e7..5400e3c1 100644 --- a/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift +++ b/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift @@ -39,7 +39,18 @@ final class AudioRing: @unchecked Sendable { private static let maxTargetMS = 70 private static let headroomMS = 30 private static let hardCapMS = 90 - private static let deprimeAfter = 4 + /// How long the ring may run short before it goes back to priming, in MILLISECONDS of + /// starvation — not a count of callbacks. As a count (it was 4) the hysteresis meant a + /// different span of time on every device, because a callback is not a unit of time: 4 of them + /// is ~44 ms on a Mac's ~11 ms quantum and **20 ms on iOS**, whose session asks for a short IO + /// buffer. A Wi-Fi delivery stall therefore de-primed this ring on every bunching cycle where + /// the same policy rode it out elsewhere — measured on the shared Rust policy at 120 audible + /// gaps per 10 minutes at a 5 ms quantum, against 3 at 8 ms and 1 at 16 ms on an identical + /// link. Mirrors `JitterTuning::COREAUDIO.deprime_ms`. + private static let deprimeMS = 60 + /// Floor in callbacks under `deprimeMS`, so a large-quantum device keeps real hysteresis + /// instead of de-priming on the first short read. Mirrors `MIN_DEPRIME_CALLBACKS`. + private static let minDeprimeCallbacks = 2 /// The protocol's frame: the shed unit, and the slack added over a large device quantum. private static let frameMS = 5 /// Depth average must exceed target by this before drift correction fires — the middle of the @@ -93,7 +104,12 @@ final class AudioRing: @unchecked Sendable { private var writeIdx = 0 private var primed = false private var renderQuantum = 0 + /// Consecutive short reads, and the audio they starved for in interleaved samples. BOTH gate + /// the de-prime (see `deprimeMS`): the run must be at least that long AND at least + /// `minDeprimeCallbacks` callbacks, so the fuse is the same span of time whatever the device's + /// quantum without collapsing to a hair trigger on a large-quantum device. private var emptyReads = 0 + private var emptyRun = 0 private var depthAvg: Double = 0 private var overRun = 0 /// The live target in interleaved samples — `targetMS` grown by underrun pressure @@ -240,8 +256,10 @@ final class AudioRing: @unchecked Sendable { min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS), target + renderQuantum) if writeIdx - readIdx > cap { - readIdx = writeIdx - cap - depthAvg = Double(cap) + // Crossfaded, like the smooth shed — see `dropFront`. This is the correction a + // bunching link actually pays, so it is the one that most needs not to click. + dropFront(writeIdx - readIdx - cap) + depthAvg = Double(writeIdx - readIdx) overRun = 0 } } @@ -262,6 +280,7 @@ final class AudioRing: @unchecked Sendable { if available >= target { primed = true emptyReads = 0 + emptyRun = 0 // The refill just banked this much: seed the average with it rather than letting // it climb from wherever the drought left it — a freshly-primed ring would // otherwise read as hollow for the EWMA's whole settling time, and the FIRST @@ -348,15 +367,23 @@ final class AudioRing: @unchecked Sendable { if ranShort { quietRun = 0 emptyReads += 1 + emptyRun += count underrunCount += 1 - if emptyReads >= Self.deprimeAfter || hollow { - // The consecutive-empties hysteresis protects a FULL ring from one late packet. + // Starved for `deprimeMS` of audio, over at least `minDeprimeCallbacks` callbacks. + // Both, because either alone is wrong at one end of the quantum range: time alone is a + // hair trigger on a device whose single quantum already exceeds the window, and a + // callback count alone is the device-dependent fuse this replaced. + let starved = emptyRun >= Self.deprimeMS * perMS + && emptyReads >= Self.minDeprimeCallbacks + if starved || hollow { + // The starvation hysteresis protects a FULL ring from one late packet. // A hollow ring is the opposite case: the target has been raised but the depth // never re-banked (growth is a promise; only a re-prime cashes it), and riding // that out is a click per bunching period, forever. The click just heard has // already paid for the refill — take it now. primed = false emptyReads = 0 + emptyRun = 0 } if !restored { underrunsInWindow += 1 @@ -375,12 +402,14 @@ final class AudioRing: @unchecked Sendable { // the path above takes over. A near-miss is pressure, not quiet. quietRun = 0 emptyReads = 0 + emptyRun = 0 if !nearMissGrown, !restored { nearMissGrown = true targetLive = min(targetLive + Self.growStepMS * perMS, Self.maxTargetMS * perMS) } } else { emptyReads = 0 + emptyRun = 0 quietRun += count // Without a sync request, time is the only evidence that hard-won slack is no longer // needed, so a grown target waits out the long window. A request for less IS evidence, @@ -402,13 +431,21 @@ final class AudioRing: @unchecked Sendable { } } - /// Drop one protocol frame from the front, linearly crossfading the seam so the correction is - /// inaudible rather than a click. Mirrors `punktfunk_core::audio::crossfade_drop`; caller holds - /// the lock. - private func shedOneFrame() { - let drop = Self.frameMS * perMS + /// Drop one protocol frame from the front — the smooth drift correction. + private func shedOneFrame() { dropFront(Self.frameMS * perMS) } + + /// Drop `drop` interleaved samples from the front, linearly crossfading the seam so the + /// correction is inaudible rather than a click. Mirrors `punktfunk_core::audio::crossfade_drop`; + /// caller holds the lock. + /// + /// Used by BOTH corrections. The hard-cap trim in `write` used to splice raw, on the reasoning + /// that a ring which blew its ceiling is already a discontinuity — but 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 here: a bunching Wi-Fi link trims far more often than + /// drift sheds, so the one path left unfaded was the audible one. + private func dropFront(_ drop: Int) { let available = writeIdx - readIdx - guard available > drop else { return } + guard drop > 0, available > drop else { return } let fade = min(Self.crossfadeMS * perMS, min(drop, available - drop)) let capacity = buf.count if fade > 0 { diff --git a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift index 7798aaa9..b9491208 100644 --- a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift +++ b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift @@ -234,11 +234,23 @@ public final class SessionAudio { try session.setCategory( .playAndRecord, mode: .default, options: [.allowBluetoothA2DP, .mixWithOthers]) - // Uplink latency: ask for 5 ms IO quanta at the wire rate (the default ~10-23 ms + // Uplink latency: ask for 10 ms IO quanta at the wire rate (the default ~23 ms // quantum is most of the mic path's burst latency). Best-effort — the hardware // has the final word (a Bluetooth route will ignore both), and whatever quantum // is actually granted, the capture tap handles the buffers it gets. - try? session.setPreferredIOBufferDuration(0.005) + // + // 10 ms, NOT the 5 ms this used to ask for. The IO buffer duration is a property + // of the whole IO unit, so a shorter quantum is not free to the PLAYBACK side — + // and it bought the uplink nothing, because the encoder frames at 10 ms + // (`installMicTap` installs with `bufferSize: 480` and `OpusEncoder` consumes + // whole `framesPerPacket` chunks): at a 5 ms quantum the tap simply fired twice + // per packet, for the same packet latency. What it did buy was a halved deadline + // for the render callback and — because the de-prime fuse used to be a callback + // COUNT — half the starvation hysteresis in the jitter ring, on the one platform + // whose transport bunches hardest. Both ends of that are fixed now (`AudioRing` + // measures the fuse in ms), but there is still no reason to ask for a quantum + // finer than the packets we send. + try? session.setPreferredIOBufferDuration(0.010) try? session.setPreferredSampleRate(48_000) } else { try session.setCategory(.playback, mode: .default, options: [.mixWithOthers]) @@ -247,6 +259,16 @@ public final class SessionAudio { try session.setCategory(.playback, mode: .default, options: [.mixWithOthers]) #endif try session.setActive(true) + // What we were actually GRANTED, not what we asked for. Both are best-effort, and the + // ring's behaviour depends on the quantum it really gets — without this, a report of + // audio jitter arrives with no way to tell a 10 ms session from a 5 ms or a 23 ms one, + // which is exactly the gap that made the last round of this take a simulation to close. + log.info(""" + AVAudioSession active: io_buffer_ms=\ + \(session.ioBufferDuration * 1000, format: .fixed(precision: 2)) \ + sample_rate=\(Int(session.sampleRate)) \ + route=\(session.currentRoute.outputs.first?.portType.rawValue ?? "none") + """) #if os(iOS) // Only the `.playAndRecord` session can land on the earpiece, and only it accepts an // output override — so the mic-off (`.playback`) path deliberately does neither. diff --git a/clients/apple/Tests/PunktfunkKitTests/AudioRingDriftTests.swift b/clients/apple/Tests/PunktfunkKitTests/AudioRingDriftTests.swift index 19c9456b..76566868 100644 --- a/clients/apple/Tests/PunktfunkKitTests/AudioRingDriftTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/AudioRingDriftTests.swift @@ -106,6 +106,77 @@ final class AudioRingDriftTests: XCTestCase { "a single short read must not force a full re-prime") } + /// THE regression that made an iPad crackle where a Mac did not: the de-prime fuse must be the + /// same SPAN OF TIME whatever the device's IO quantum. It used to be a callback COUNT (4), and + /// a callback is not a unit of time — the same 4 was ~44 ms on a Mac's ~11 ms quantum and 20 ms + /// on iOS, whose session asked for a 5 ms IO buffer. A Wi-Fi delivery stall therefore de-primed + /// this ring on every bunching cycle where the identical policy rode it out elsewhere (measured + /// on the shared Rust policy: 120 audible gaps per 10 min at a 5 ms quantum against 3 at 8 ms). + /// Plant the defect by restoring a fixed count and the quanta below stop agreeing. + /// + /// Mirrors `deprime_fuse_is_a_duration_not_a_callback_count` in `punktfunk_core::audio`. + func testDeprimeFuseIsADurationNotACallbackCount() { + let deprimeMS = 60 // AudioRing.deprimeMS / JitterTuning::COREAUDIO.deprime_ms + let quanta = [5, 8, 10, 16, 21] + var deprimedAt: [Int: Int] = [:] + for quantumMS in quanta { + let ring = AudioRing(capacity: 48_000 * channels, channels: channels) + let want = quantumMS * perMS + var scratch = [Float](repeating: 0, count: want) + // Prime DEEP: the depth average is seeded with the refill, so `hollow` stays false for + // the EWMA's whole settling second and the starvation fuse — not the hollow shortcut — + // is what this measures. + let big = [Float](repeating: 0.5, count: 80 * perMS) + big.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: big.count) } + scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) } + XCTAssertTrue( + scratch.contains { $0 != 0 }, "q=\(quantumMS)ms: must play after priming") + + // Starve on a trickle far under what the device takes: every read runs short but still + // carries audio, so an all-zero read can only mean the ring gave up and re-primed. + let trickle = [Float](repeating: 0.5, count: max(perMS, want / 4)) + var starvedMS = 0 + var deprimedAfterMS: Int? + for _ in 0..<2_000 { + trickle.withUnsafeBufferPointer { + ring.write($0.baseAddress!, count: trickle.count) + } + let short = ring.bufferedSamples < want + scratch.withUnsafeMutableBufferPointer { + ring.read(into: $0.baseAddress!, count: want) + } + if scratch.allSatisfy({ $0 == 0 }) { + deprimedAfterMS = starvedMS + break + } + if short { starvedMS += quantumMS } + } + guard let deprimedAfterMS else { + return XCTFail("q=\(quantumMS)ms: never de-primed at all") + } + deprimedAt[quantumMS] = deprimedAfterMS + } + + // Each quantum must give up somewhere around the fuse. The band is wide on purpose: at a + // short quantum the HOLLOW shortcut legitimately fires a little before the fuse does (the + // target has grown, the depth was never re-banked, so the click is taken early and spent + // on a full refill — see `deprimeDebtMS`), and that is the policy working, not drift. + for (q, ms) in deprimedAt.sorted(by: { $0.key < $1.key }) { + XCTAssertTrue( + (deprimeMS - 20...deprimeMS + 25).contains(ms), + "q=\(q)ms de-primed after \(ms) ms, nowhere near the \(deprimeMS) ms fuse — " + + "\(deprimedAt.sorted { $0.key < $1.key })") + } + // ...and THE property: the fuse must not SCALE with the quantum. As a callback count these + // same devices de-primed after 20/32/40/64/84 ms — a 4.2x spread, which is exactly why an + // iPad crackled where a Mac did not. Measured in time the spread collapses to ~1.3x. + let spread = Double(deprimedAt.values.max()!) / Double(deprimedAt.values.min()!) + XCTAssertLessThan( + spread, 1.6, + "de-prime time still scales with the IO quantum (\(String(format: "%.2f", spread))x " + + "across \(deprimedAt.sorted { $0.key < $1.key })) — the fuse is a count again") + } + /// Mirror of the Rust `target_grows_on_underruns_and_relaxes_when_quiet`, updated for /// near-miss growth: the drain's LAST full read (less than a frame left over) already grows /// the floor before anything was audible, clustered genuine underruns raise it further, and diff --git a/crates/punktfunk-core/src/audio.rs b/crates/punktfunk-core/src/audio.rs index 46c40302..b0bf4d12 100644 --- a/crates/punktfunk-core/src/audio.rs +++ b/crates/punktfunk-core/src/audio.rs @@ -407,10 +407,23 @@ pub struct JitterTuning { pub headroom_ms: u32, /// Absolute bound on buffered audio — the only hard guarantee on added latency. pub hard_cap_ms: u32, - /// Consecutive short reads before the ring goes back to priming. `1` reproduces the old - /// `if ring.is_empty() { primed = false }`, where a single transient drain manufactured a - /// whole target's worth of fresh silence; every platform now uses hysteresis. - pub deprime_after: u32, + /// How long the ring may run short before it gives up and goes back to priming, in + /// MILLISECONDS of starvation — not a count of callbacks. + /// + /// It used to be a callback count, and that made the hysteresis mean something different on + /// every platform, because a callback is not a unit of time: the same `4` was ~40 ms of slack + /// on a 10 ms WASAPI quantum and **20 ms on iOS**, whose session asks for a 5 ms IO buffer — + /// the shortest fuse of any client, on the one with the burstiest transport. A 100 ms Wi-Fi + /// delivery stall then de-primed the Apple ring on every single bunching cycle (measured: 120 + /// audible gaps in 10 minutes at a 5 ms quantum, versus 3 at 8 ms and 1 at 16 ms, on an + /// otherwise identical link) while the same policy rode it out everywhere else. Expressed in + /// time, one number means one thing on all four clients and a device's buffer size stops + /// silently re-tuning the de-prime behaviour. + /// + /// A floor of `MIN_DEPRIME_CALLBACKS` callbacks still applies, so a large-quantum device + /// keeps real hysteresis: `1` reproduces the old `if ring.is_empty() { primed = false }`, where + /// a single transient drain manufactured a whole target's worth of fresh silence. + pub deprime_ms: u32, } impl JitterTuning { @@ -421,7 +434,7 @@ impl JitterTuning { max_target_ms: 60, headroom_ms: 25, hard_cap_ms: 80, - deprime_after: 4, + deprime_ms: 40, }; /// WASAPI shared-mode event-driven render: the engine buffers for us, but nothing rate-matches. pub const WASAPI: JitterTuning = JitterTuning { @@ -429,15 +442,21 @@ impl JitterTuning { max_target_ms: 70, headroom_ms: 30, hard_cap_ms: 90, - deprime_after: 4, + deprime_ms: 50, }; - /// CoreAudio via AVAudioEngine — comparable to WASAPI; the iOS IO buffer is already 5 ms. + /// CoreAudio via AVAudioEngine — comparable to WASAPI, but the transport is not: this is the + /// preset an iPad on Wi-Fi runs, so it gets the longer fuse for the same reason [`AAUDIO`] + /// does. (The old comment here read "the iOS IO buffer is already 5 ms" as grounds for using + /// WASAPI's callback count unchanged; that quantum is precisely why a count was the wrong unit + /// — see [`JitterTuning::deprime_ms`].) + /// + /// [`AAUDIO`]: JitterTuning::AAUDIO pub const COREAUDIO: JitterTuning = JitterTuning { base_target_ms: 20, max_target_ms: 70, headroom_ms: 30, hard_cap_ms: 90, - deprime_after: 4, + deprime_ms: 60, }; /// AAudio hands us a raw realtime callback and makes us own the buffer, and Wi-Fi power-save /// bunching lands as underruns = crackle. Android therefore starts DEEPER — but at 25 ms, not @@ -448,7 +467,7 @@ impl JitterTuning { max_target_ms: 90, headroom_ms: 40, hard_cap_ms: 120, - deprime_after: 5, + deprime_ms: 60, }; /// How far above the live target the depth average must sit before drift correction sheds: @@ -471,11 +490,21 @@ impl JitterTuning { pub struct JitterStep { /// Interleaved samples to discard from the FRONT of the ring before reading. pub drop_front: usize, - /// When non-zero, `drop_front` is a smooth drift correction and this many interleaved samples - /// of linear crossfade should be applied across the seam ([`crossfade_drop`] does it for a - /// `VecDeque` ring). Zero means discard hard — either nothing is being dropped, or the - /// ring blew the hard cap and is already a discontinuity. + /// Interleaved samples of linear crossfade to apply across the seam left by `drop_front` + /// ([`crossfade_drop`] does it for a `VecDeque` ring). Zero only when nothing is dropped. + /// + /// BOTH kinds of drop are faded. The hard-cap trim used to splice raw, on the reasoning that a + /// ring which blew its ceiling "is already a discontinuity" — but that is a statement about the + /// ARRIVALS, not about the samples either side of the seam, which are ordinary continuous + /// audio. It is also the drop that actually fires in the field: a bunching Wi-Fi link trimmed + /// 120 times in 10 simulated minutes where the smooth shed fired for drift a handful of times. + /// The gentle path that almost never runs was the one being faded. pub crossfade: usize, + /// `drop_front` was the hard-cap backstop (a burst blew the ceiling) rather than the smooth + /// drift shed. Both fade now, so the fade length no longer distinguishes them — and the two + /// mean very different things to anyone reading logs or a test: sheds are the policy working, + /// trims are the link outrunning the headroom. + pub hard_trim: bool, /// Emit silence this callback: still priming, or re-priming after a sustained drain. pub silence: bool, } @@ -515,6 +544,19 @@ const SHRINK_PROBE_MS: u32 = 5_000; /// consecutive-empties hysteresis alone converges to. A full ring's underrun (one packet a few /// ms late) is nowhere near hollow and keeps the hysteresis. const DEPRIME_DEBT_MS: u32 = GROW_STEP_MS; +/// Floor, in callbacks, under `JitterTuning::deprime_ms`: however short the starvation window works +/// out to in time, a de-prime always needs at least this many consecutive short reads. A device +/// with a quantum at or above `deprime_ms` would otherwise de-prime on the FIRST short read — +/// exactly the "a single transient drain manufactures a whole target of fresh silence" defect the +/// hysteresis exists to prevent, reintroduced at the other end of the quantum range. +/// +/// Deliberately NOT `pub`: it is an internal detail of the policy, and cbindgen exports every +/// public const into the C header, where this one would land unprefixed next to +/// `PUNKTFUNK_AUDIO_*` and pollute every embedder's macro namespace. +const MIN_DEPRIME_CALLBACKS: u32 = 2; +// A de-prime on the FIRST short read is the defect the hysteresis exists to prevent, so hold the +// floor at build time rather than in a test: tuning it to 1 should not compile. +const _: () = assert!(MIN_DEPRIME_CALLBACKS >= 2); /// How long a failed probe keeps the sync loop from driving another shrink. Without this the /// loop pays an audible starvation event every [`SHRINK_QUIET_SYNC_MS`] on any link whose jitter /// genuinely needs the depth — sync asks for less, the ring shrinks, the link answers, the ring @@ -545,8 +587,12 @@ pub struct JitterPolicy { /// The live target, in interleaved samples — `base_target_ms` grown by underrun pressure. target: usize, primed: bool, - /// Consecutive short reads (de-prime hysteresis). + /// Consecutive short reads, and the audio they starved for in interleaved samples. BOTH gate + /// the de-prime: the run must be at least [`JitterTuning::deprime_ms`] long AND at least + /// [`MIN_DEPRIME_CALLBACKS`] callbacks, so the hysteresis means the same span of time whatever + /// the device's quantum, without collapsing to a hair trigger on a large-quantum device. empties: u32, + empties_run: usize, /// EWMA of ring depth, interleaved samples. depth_avg: f32, /// Consumed samples for which the EWMA has stayed above the shed threshold. @@ -594,6 +640,7 @@ impl JitterPolicy { target: tuning.base_target_ms as usize * per_ms, primed: false, empties: 0, + empties_run: 0, depth_avg: 0.0, over_run: 0, underruns: 0, @@ -693,9 +740,14 @@ impl JitterPolicy { let mut out = JitterStep::default(); if depth > cap { - // Blew the ceiling: a burst arrived, or we were wedged. Already a discontinuity — - // discard hard, and reset the drift timer so the trim isn't double-counted as drift. + // Blew the ceiling: a burst arrived, or we were wedged. Discard down to the cap and + // reset the drift timer so the trim isn't double-counted as drift. Faded like any + // other drop — see `JitterStep::crossfade` for why this used to splice raw and why + // that was backwards. out.drop_front = depth - cap; + out.hard_trim = true; + out.crossfade = (SHED_CROSSFADE_MS as usize * self.per_ms) + .min(depth.saturating_sub(out.drop_front)); self.over_run = 0; } else if self.depth_avg > (target + self.tuning.shed_excess_ms() as usize * self.per_ms) as f32 @@ -717,6 +769,7 @@ impl JitterPolicy { if !self.primed && depth.saturating_sub(out.drop_front) >= target { self.primed = true; self.empties = 0; + self.empties_run = 0; // The refill just banked this much: seed the average with it rather than letting it // climb from wherever the drought left it — a freshly-primed ring would otherwise // read as hollow for the EWMA's whole settling time, and the FIRST late packet @@ -784,14 +837,22 @@ impl JitterPolicy { if ran_short { self.quiet_run = 0; self.empties += 1; - if self.empties >= self.tuning.deprime_after || self.hollow { - // The consecutive-empties hysteresis protects a FULL ring from one late packet. - // A hollow ring is the opposite case: the target has been raised but the depth - // never re-banked (growth is a promise; only a re-prime cashes it), and riding - // that out is a click per bunching period, forever. The click just heard has - // already paid for the refill — take it now. + self.empties_run += want; + // Starved for `deprime_ms` of audio, over at least MIN_DEPRIME_CALLBACKS callbacks. + // Both, because either alone is wrong at one end of the quantum range: time alone is a + // hair trigger on a device whose single quantum already exceeds the window, and a + // callback count alone is the platform-dependent fuse this replaced. + let starved = self.empties_run >= self.tuning.deprime_ms as usize * self.per_ms + && self.empties >= MIN_DEPRIME_CALLBACKS; + if starved || self.hollow { + // The starvation hysteresis protects a FULL ring from one late packet. A hollow + // ring is the opposite case: the target has been raised but the depth never + // re-banked (growth is a promise; only a re-prime cashes it), and riding that out + // is a click per bunching period, forever. The click just heard has already paid + // for the refill — take it now. self.primed = false; self.empties = 0; + self.empties_run = 0; } if !restored { self.underruns += 1; @@ -814,6 +875,7 @@ impl JitterPolicy { // the path above takes over. A near-miss is pressure, not quiet. self.quiet_run = 0; self.empties = 0; + self.empties_run = 0; if !self.near_miss_grown && !restored { self.near_miss_grown = true; let grown = self.target + GROW_STEP_MS as usize * self.per_ms; @@ -821,6 +883,7 @@ impl JitterPolicy { } } else { self.empties = 0; + self.empties_run = 0; self.quiet_run += want; // A grown target normally relaxes only after a long quiet spell, because without other // evidence the only thing that can justify giving up hard-won slack is time. When the @@ -862,9 +925,10 @@ pub const SAMPLE_RATE_HZ: u32 = 48_000; /// `fade` samples so a drift correction is inaudible rather than a click. /// /// The dropped region's tail fades out while the surviving head fades in, so the waveform is -/// continuous across the splice. `fade == 0` discards hard (what a hard-cap trim wants — that -/// backlog is already a discontinuity). Shared by the three `VecDeque` rings; the Apple ring -/// is index-based and mirrors this in Swift. +/// continuous across the splice. `fade == 0` discards hard; no caller in the policy asks for that +/// any more (see [`JitterStep::crossfade`]), but it stays honoured for callers that splice at a +/// point they know is already discontinuous. Shared by the three `VecDeque` rings; the Apple +/// ring is index-based and mirrors this in Swift. pub fn crossfade_drop(ring: &mut std::collections::VecDeque, drop: usize, fade: usize) { if drop == 0 || ring.len() < drop { return; @@ -876,17 +940,19 @@ pub fn crossfade_drop(ring: &mut std::collections::VecDeque, drop: usize, f } // The last `fade` samples of what we are about to discard are the fade-OUT source; they blend // into the first `fade` samples of what survives. - let mut faded = Vec::with_capacity(fade); + // + // Blended in place and BEFORE the drain, with no scratch buffer: a value written at `drop + i` + // can never be read again as a fade-OUT source, because those sources are `drop - fade + j` for + // `j < fade`, i.e. strictly below `drop`. One ascending pass is therefore safe — and this runs + // inside realtime audio callbacks, where the `Vec` this used to allocate had no business being. + // It now runs on every hard-cap trim too, which is the common case on a bunching link. for i in 0..fade { let old = ring[drop - fade + i]; let new = ring[drop + i]; let t = (i + 1) as f32 / (fade + 1) as f32; - faded.push(old * (1.0 - t) + new * t); + ring[drop + i] = old * (1.0 - t) + new * t; } ring.drain(..drop); - for (i, v) in faded.into_iter().enumerate() { - ring[i] = v; - } } // ---- per-platform channel-layout helpers (pure data; no platform deps) -------------------- @@ -1460,11 +1526,17 @@ mod tests { let s = p.step(depth, want); if s.drop_front > 0 { - if s.crossfade > 0 { - out.soft_sheds += 1; - } else { + // Told apart by `hard_trim`, not by the fade length — both kinds fade now. + if s.hard_trim { out.hard_trims += 1; + } else { + out.soft_sheds += 1; } + assert!( + s.crossfade > 0, + "every drop must be faded: dropped {} with no crossfade", + s.drop_front + ); depth -= s.drop_front.min(depth); } if s.silence { @@ -1508,7 +1580,22 @@ mod tests { "{name}: the headroom band is cut short by the hard cap" ); assert!(t.max_target_ms >= t.base_target_ms, "{name}"); - assert!(t.deprime_after >= 2, "{name}: needs real hysteresis"); + // Real hysteresis, in time: a drought has to outlast several protocol frames before + // the ring gives up, or one late packet manufactures a whole target of fresh silence. + assert!( + t.deprime_ms >= 4 * FRAME_MS, + "{name}: de-primes after {} ms — a single late packet would trip it", + t.deprime_ms + ); + // ...and never longer than the deepest buffer this preset would ever hold: past that + // point the drought has already cost more than the re-prime it is trying to avoid, and + // every callback in between is dribbling partial reads at the listener. + assert!( + t.deprime_ms <= t.max_target_ms, + "{name}: waits {} ms to de-prime but never buffers more than {} ms", + t.deprime_ms, + t.max_target_ms + ); } } @@ -1619,7 +1706,20 @@ mod tests { s.drop_front > 0, "a 500 ms backlog must be trimmed on the spot" ); - assert_eq!(s.crossfade, 0, "a blown cap is already a discontinuity"); + assert!(s.hard_trim, "a cap trim must announce itself as one"); + // ...and it is FADED. This used to assert the opposite ("a blown cap is already a + // discontinuity"), which confused the arrivals with the audio: the samples either side of + // the splice are ordinary continuous sound, and a raw seam through them is a click. It is + // also the drop that actually fires in the field — a bunching Wi-Fi link trims far more + // often than drift sheds — so the one path that was left unfaded was the audible one. + assert!( + s.crossfade > 0, + "a cap trim splices real audio and must be faded" + ); + assert!( + s.crossfade <= s.drop_front, + "the fade cannot outrun what is being dropped" + ); let left = 500 * pm - s.drop_front; assert!( left <= JitterTuning::AAUDIO.hard_cap_ms as usize * pm, @@ -1647,12 +1747,46 @@ mod tests { assert!(p.is_primed()); p.note_read(true); // one short read assert!(p.is_primed(), "a single short read must not de-prime"); - for _ in 1..JitterTuning::PIPEWIRE.deprime_after { + let deprime = JitterTuning::PIPEWIRE.deprime_ms as usize; + for _ in 1..(deprime / 5) { p.note_read(true); } assert!(!p.is_primed(), "a sustained drain must re-prime"); } + /// THE regression this replaced a callback count for: the de-prime fuse must be the same + /// SPAN OF TIME whatever the device's IO quantum. As a count it was not — the same `4` was + /// ~40 ms on a 10 ms WASAPI quantum and 20 ms on iOS, whose session asks for a 5 ms IO buffer. + /// A Wi-Fi delivery stall therefore de-primed the Apple ring on every bunching cycle while the + /// identical policy rode it out everywhere else. Plant the defect by restoring a fixed count + /// and the two quanta below stop agreeing. + #[test] + fn deprime_fuse_is_a_duration_not_a_callback_count() { + for quantum_ms in [5usize, 8, 10, 16, 21] { + let t = JitterTuning::COREAUDIO; + let pm = per_ms(2); + let want = quantum_ms * pm; + let mut p = JitterPolicy::new(t, 2); + // Prime well above target so the hysteresis path is what we measure, not `hollow`. + assert!(!p.step(80 * pm, want).silence); + assert!(p.is_primed()); + let mut starved_ms = 0; + while p.is_primed() && starved_ms < 10 * t.deprime_ms as usize { + p.note_read(true); + starved_ms += quantum_ms; + } + assert!(!p.is_primed(), "q={quantum_ms}ms: never de-primed at all"); + // One quantum of granularity either side — the fuse can only be checked per callback. + let floor = (t.deprime_ms as usize).min(quantum_ms * MIN_DEPRIME_CALLBACKS as usize); + assert!( + starved_ms >= floor && starved_ms < t.deprime_ms as usize + quantum_ms, + "q={quantum_ms}ms de-primed after {starved_ms} ms, not ~{} ms — the fuse is still \ + scaling with the quantum", + t.deprime_ms + ); + } + } + /// A device that pulls a big quantum cannot sustain a target below it: the effective target /// must lift, or the ring oscillates prime → dropout → re-prime forever. #[test]