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