Merge pull request 'The de-prime fuse was a callback count, so an iPad gave up 3x sooner than a Mac' (#200) from worktree-apple-audio-jitter-probe into main
apple / swift (push) Successful in 1m57s
ci / web (push) Successful in 2m4s
apple / distribute (push) Successful in 10m44s
ci / docs-site (push) Successful in 2m2s
ci / bun-nix (push) Successful in 34s
apple / screenshots (push) Successful in 6m56s
android / android (push) Successful in 14m35s
ci / rust-arm64 (push) Successful in 13m22s
deb / build-publish-gamescope (push) Successful in 1m20s
deb / build-publish (push) Successful in 9m36s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m4s
deb / build-publish-host (push) Successful in 9m46s
deb / build-publish-client-arm64 (push) Successful in 3m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 20s
arch / build-publish (push) Failing after 1m54s
deb / smoke-install (push) Successful in 3m30s
ci / rust (push) Successful in 27m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 12m35s
windows-host / package (push) Canceled after 14m12s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m1s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 1m40s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 55s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m10s
flatpak / build-publish (push) Successful in 20m21s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / deploy-docs (push) Successful in 41s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m58s
docker / builders-arm64cross (push) Canceled after 0s

Reviewed-on: #200
This commit was merged in pull request #200.
This commit is contained in:
2026-08-13 15:19:54 +00:00
4 changed files with 313 additions and 49 deletions
@@ -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 {
@@ -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.
@@ -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
+170 -36
View File
@@ -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<f32>` 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<f32>` 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<f32>` 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<f32>` rings; the Apple
/// ring is index-based and mirrors this in Swift.
pub fn crossfade_drop(ring: &mut std::collections::VecDeque<f32>, drop: usize, fade: usize) {
if drop == 0 || ring.len() < drop {
return;
@@ -876,17 +940,19 @@ pub fn crossfade_drop(ring: &mut std::collections::VecDeque<f32>, 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]