fix(audio): the de-prime fuse was a callback count, so an iPad gave up 3x sooner than a Mac
apple / swift (pull_request) Successful in 2m2s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 42s
ci / docs-site (pull_request) Successful in 1m47s
ci / rust-arm64 (pull_request) Successful in 7m19s
ci / web (pull_request) Successful in 7m26s
ci / rust (pull_request) Successful in 10m23s
android / android (pull_request) Successful in 12m59s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m34s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m17s
apple / swift (pull_request) Successful in 2m2s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 42s
ci / docs-site (pull_request) Successful in 1m47s
ci / rust-arm64 (pull_request) Successful in 7m19s
ci / web (pull_request) Successful in 7m26s
ci / rust (pull_request) Successful in 10m23s
android / android (pull_request) Successful in 12m59s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m34s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m17s
Three defects behind the residual Apple audio jitter, found while chasing a field report that survived both the PLC fix (#82) and the jitter-policy fix (#111). 1. `JitterTuning::deprime_after` counted CALLBACKS, and a callback is not a unit of time. The same `4` was ~44 ms of starvation slack on a Mac's ~11 ms quantum and 20 ms on iOS, whose session asks for a short IO buffer — the shortest fuse of any client, on the one with the burstiest transport. A 100 ms Wi-Fi delivery stall therefore de-primed the Apple ring on every bunching cycle while the identical policy rode it out everywhere else. It is now `deprime_ms`, measured in starved audio, with a `MIN_DEPRIME_CALLBACKS` floor so a large-quantum device keeps real hysteresis instead of de-priming on the first short read. Android was latently exposed too (AAudio's low-latency burst is ~4-5 ms, so its `5` was also ~20 ms). Driving the real policy through a simulated link (100 ms stall / 5 s, -30 ppm, 10 min) at a 5 ms quantum: 120 audible gaps and 690 ms of dead air before, 2 gaps and 60 ms after. 2. iOS asked for a 5 ms IO buffer that bought the uplink nothing. The mic tap installs with `bufferSize: 480` and the encoder consumes whole 10 ms `framesPerPacket` chunks, so at 5 ms the tap simply fired twice per packet for the same packet latency — while halving the render callback's deadline and, through (1), the ring's starvation hysteresis. Now 10 ms, matching the framing we already use. On the harsh link above that takes the residual from 2 gaps to 1. The granted `ioBufferDuration`/sample rate/route are now logged at activation: both asks are best-effort, and without the granted value an audio-jitter report arrives with no way to tell a 10 ms session from a 5 ms one. 3. The hard-cap trim spliced RAW, on the reasoning that a ring which blew its ceiling "is already a discontinuity". That describes the arrivals, not the samples either side of the seam, which are ordinary continuous audio — and it is the drop that actually fires: the same link above trims 120 times per 10 minutes where drift sheds a handful. The gentle path that almost never runs was the one being faded. Both kinds fade now, told apart by a new `JitterStep::hard_trim` rather than by the fade length. `crossfade_drop` lost its `Vec` in the process — it blends in place in one ascending pass, which it must, now that it runs on every trim inside a realtime callback. Fixes 1 and 3 live in the shared `JitterPolicy`, so Windows, Linux and Android get them without change (all three already pass `step.crossfade` into `crossfade_drop`). The Swift mirror in `AudioRing` is kept in step, including the generalised `dropFront(_:)` the cap trim now shares with the drift shed. Gates: 210 core tests, 288 Swift tests, clippy --all-features --all-targets, fmt, plus an iOS-triple typecheck for the `#if os(iOS)` session change. Both new fuse tests were plant-the-defect verified: restoring a fixed count reproduces 20/32/40/64/84 ms across the quanta (a 4.2x spread) and fails them loudly. Not fixed here: drift correction is still one-directional, so a host clock running SLOW is corrected only by starving and re-priming. That is the remaining periodic gap on a clean link and it needs rate adaptation — designed separately.
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