Files
punktfunk/clients/apple/Tests/PunktfunkKitTests/LatencyMeterTests.swift
T
enricobuehler c43769282a
ci / bun-nix (pull_request) Successful in 41s
ci / web (pull_request) Successful in 1m25s
apple / swift (pull_request) Successful in 1m35s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m51s
ci / rust-arm64 (pull_request) Successful in 2m24s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m55s
ci / rust (pull_request) Successful in 3m31s
android / android (pull_request) Successful in 3m54s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m18s
fix(apple): place audio with the picture instead of wherever the ring settles
The Apple half of the A/V sync overhaul; the Rust half is 12a53183 and this
mirrors its policy rather than re-deriving one.

The host stamps `pts_ns` on every audio datagram and the client decoded it into
`AudioPCM` — and then never read it. Video's `pts_ns` is used end to end (the
end-to-end meter computes a true glass-to-glass `displayed + clockOffset − pts`
per presented frame), so audio free-ran at whatever depth its jitter ring
happened to reach, video was presented on an independent path, and nothing ever
compared them. The A/V offset was an accident of buffer depths: it moved
whenever the ring ratcheted under underrun pressure, and it got WORSE every time
video got faster, because a quicker decoder lowers the video leg and leaves
audio's exactly where it was.

Video is the master:

  audio_e2e = (now + buffered_ahead + clock_offset) − pts_ns
  av_offset = audio_e2e − video_e2e        (> 0 ⇒ audio behind the picture)

`AvSync` smooths that with an EWMA, ignores what sits inside a deadband no
listener can detect, refuses the implausible outright rather than clamping it (a
wall-clock step must not steer the ring), and proposes a depth. Swift refuses
one thing Rust does not have to: the arithmetic itself. The Rust controller
works in i128, while Swift has no Int128 at this tools version, so the terms are
combined with overflow-REPORTING arithmetic instead of the `&-` the latency
meters use. That is not defensive padding — `ptsNs = 1 << 63` reads as
`Int64.min`, the difference lands on exactly `Int64.min`, and `abs()` of that
has no representable result, so checking the overflow flags AFTER the sanity
limit does not mis-measure the stream, it aborts the process from the audio
drain thread. The guard's short-circuit ordering is what makes the sanity check
safe to run at all.

Continuity outranks sync, always. `AudioRing.setSyncTarget` only ever takes a
REQUEST, clamped between the existing underrun-driven floor and the hard cap. A
link whose jitter genuinely needs more buffer than the picture is away keeps its
buffer and the residual is reported. `nil` is the default and reproduces the
previous behaviour exactly. The clamp raises its ceiling to the floor rather
than using it as-is: a device whose callback quantum alone exceeds the hard cap
makes floor > cap, and a plain `min(max(s, floor), cap)` would then hand back
the CAP — quietly below the continuity floor, inverting the exact ordering this
exists to guarantee, on the awkward hardware it exists to survive. (Rust's
`Ord::clamp` announces that condition by panicking; Swift would just get it
wrong, which is worse.)

The reference is the other half, and without it the loop is inert — which is why
this was split out rather than shipped alongside the Rust side. `LatencyMeter`
now publishes its most recent sample as a LEVEL, so the end-to-end meter the
presenter already writes per presented frame becomes the video figure the audio
plane reads. Both present paths (arrival and deadline) feed it without either
knowing audio exists, and the stage-1 fallback presenter — which stamps no
present at all — offers nothing, so the loop correctly declines to correct. The
level EXPIRES, unlike the Rust atomic: this client has a backgrounded keep-alive
that keeps audio playing and drops video decode entirely, and a reference with
no expiry would go on steering the ring against a figure minutes old and frozen.

And the reason none of this was visible: `bufferedMS`/`targetMS` existed only in
a periodic log line, absent from anything a surface could render. The HUD's
detailed tier now carries `audio buffer N ms · a/v ±N ms` and the 1 Hz stats log
gains the same pair, appended last so existing parsers are unaffected — both
numbers, because a deep ring on a jittery link is correct and only the offset
separates that from audio held late.

`PUNKTFUNK_NO_AV_SYNC=1` disarms the loop without a rebuild, as on the Rust
clients.

Verified: swift build + 225 tests (5 skipped) green. Every new gate was proven
non-vacuous by planting its own defect and confirming the gate caught it — the
continuity invariant, the clamp inversion, the deadband, both refusal paths, the
evidence threshold, the sync-pressure relax, the reference's staleness and its
survival of a drain, and `setSyncTarget` being live at all rather than dead
code, which is how the previous pass in this area shipped a correction that was
structurally unreachable with a green test. Two gates came back VACUOUS on the
first sweep and are the reason their inputs look so specific: the overflow test
was being caught by the sanity limit instead of the overflow guard, and the
refused-reference test was being caught by `latestSample`'s own `> 0` check
rather than by where the publish sits.
2026-08-08 00:10:48 +02:00

136 lines
6.7 KiB
Swift

// Unit tests for LatencyMeter (one instance per unified-stats stage — see
// design/stats-unification.md): percentiles, the skew-corrected flag, reset-on-drain, the
// absurd-value guard, and the explicit-instant stage form (record(ptsNs:atNs:offsetNs:), used for
// the client-local decode/display stages and the at-present end-to-end stamp). Receipt-path
// latencies are constructed by stamping a pts a known interval in the past, so the result is that
// interval plus the (tiny) clock advance between reads — asserted with tolerance; the explicit
// form is exact.
import Foundation
import XCTest
@testable import PunktfunkKit
final class LatencyMeterTests: XCTestCase {
private func nowRealtimeNs() -> UInt64 {
var ts = timespec()
clock_gettime(CLOCK_REALTIME, &ts)
return UInt64(ts.tv_sec) * 1_000_000_000 + UInt64(ts.tv_nsec)
}
func testEmptyDrainIsNil() {
XCTAssertNil(LatencyMeter().drain())
}
func testRecordsPercentilesAndResets() {
let m = LatencyMeter()
let now = nowRealtimeNs()
// Each frame "captured" 5 ms ago, no skew offset → latency ≈ 5 ms.
for _ in 0..<50 { m.record(ptsNs: now - 5_000_000, offsetNs: 0) }
guard let s = m.drain() else { return XCTFail("expected samples") }
XCTAssertEqual(s.count, 50)
XCTAssertFalse(s.skewCorrected, "offset 0 ⇒ not skew-corrected")
XCTAssertEqual(s.p50Ms, 5.0, accuracy: 2.0)
XCTAssertGreaterThanOrEqual(s.p99Ms, s.p50Ms)
XCTAssertNil(m.drain(), "drain resets the window")
}
func testSkewCorrectedFlagSetByNonZeroOffset() {
let m = LatencyMeter()
let now = nowRealtimeNs()
m.record(ptsNs: now - 1_000_000, offsetNs: 250_000) // 1 ms ago, +0.25 ms offset
XCTAssertEqual(m.drain()?.skewCorrected, true)
}
func testExplicitStageRecordIsExact() {
let m = LatencyMeter()
// A client-local stage (decode: received→decoded) — start instant as ptsNs, offset 0.
let receivedNs: Int64 = 1_000_000_000_000
m.record(ptsNs: UInt64(receivedNs), atNs: receivedNs + 3_000_000, offsetNs: 0)
guard let s = m.drain() else { return XCTFail("expected a sample") }
XCTAssertEqual(s.count, 1)
XCTAssertEqual(s.p50Ms, 3.0, "explicit instants make the sample exact")
XCTAssertFalse(s.skewCorrected, "local stages record with offset 0")
}
func testExplicitStageDropsNonPositiveInterval() {
let m = LatencyMeter()
// A stage whose start stamp is missing (0) or after its end must not pollute the window.
let decodedNs: Int64 = 1_000_000_000_000
m.record(ptsNs: 0, atNs: decodedNs, offsetNs: 0) // "start unknown" → > 10 s → dropped
m.record(ptsNs: UInt64(decodedNs + 1), atNs: decodedNs, offsetNs: 0) // negative → dropped
XCTAssertNil(m.drain())
}
func testDropsAbsurdValues() {
let m = LatencyMeter()
let now = nowRealtimeNs()
// pts 1 s in the future → negative latency → dropped.
m.record(ptsNs: now + 1_000_000_000, offsetNs: 0)
// pts absurdly far in the past → > 10 s latency → dropped.
m.record(ptsNs: now - 20_000_000_000, offsetNs: 0)
XCTAssertNil(m.drain())
}
// MARK: - latestSample: the A/V sync loop's video reference
/// The end-to-end meter doubles as the reference the audio ring steers against, so its most
/// recent sample must be readable as a LEVEL — without consuming it, and independently of the
/// 1 Hz percentile window the HUD drains.
func testLatestSampleSurvivesDrainAndIsNotAWindow() {
let m = LatencyMeter()
let atNs: Int64 = 1_000_000_000_000
m.record(ptsNs: UInt64(atNs - 12_000_000), atNs: atNs, offsetNs: 0) // 12 ms
XCTAssertEqual(m.latestSample(asOfNs: atNs, maxAgeMs: 500), 12_000_000)
_ = m.drain()
XCTAssertEqual(
m.latestSample(asOfNs: atNs, maxAgeMs: 500), 12_000_000,
"the reference is a level — draining the percentile window must not clear it")
// …and it tracks the newest frame.
m.record(ptsNs: UInt64(atNs - 20_000_000), atNs: atNs, offsetNs: 0)
XCTAssertEqual(m.latestSample(asOfNs: atNs, maxAgeMs: 500), 20_000_000)
}
/// No frame yet ⇒ no reference. This is what keeps the sync loop inert at session start and
/// under the stage-1 presenter, which stamps no present at all.
func testLatestSampleIsNilBeforeAnyFrame() {
XCTAssertNil(LatencyMeter().latestSample(asOfNs: 1_000_000_000_000, maxAgeMs: 500))
}
/// THE staleness gate: video can stop while audio keeps playing (the backgrounded keep-alive
/// drops decode entirely). A level with no expiry would go on offering a minutes-old figure as
/// though it were live, and the ring would be steered against a frozen reference.
func testLatestSampleExpires() {
let m = LatencyMeter()
let atNs: Int64 = 1_000_000_000_000
m.record(ptsNs: UInt64(atNs - 12_000_000), atNs: atNs, offsetNs: 0)
XCTAssertNotNil(m.latestSample(asOfNs: atNs + 499_000_000, maxAgeMs: 500))
XCTAssertNil(
m.latestSample(asOfNs: atNs + 501_000_000, maxAgeMs: 500),
"a stale reference must read as NO reference, not as a live one")
// A stamp marginally ahead of the reader's clock is normal (the deadline presenter stamps
// at the link's TARGET present time) and must not drop the only reference we have.
XCTAssertNotNil(m.latestSample(asOfNs: atNs - 8_000_000, maxAgeMs: 500))
}
/// A sample the meter refused must not become a reference either — the sync loop would then be
/// steered by a value the percentile window itself judged absurd.
///
/// The ABSURDLY LARGE case is the load-bearing one: a negative interval would also be stopped
/// by `latestSample`'s own `> 0` check, so on its own it proves nothing about where the publish
/// sits relative to the guard.
func testRefusedSampleIsNotPublishedAsAReference() {
let m = LatencyMeter()
let atNs: Int64 = 1_000_000_000_000
m.record(ptsNs: UInt64(atNs - 20_000_000_000), atNs: atNs, offsetNs: 0) // 20 s → refused
XCTAssertNil(
m.latestSample(asOfNs: atNs, maxAgeMs: 500),
"a sample too absurd for the window is too absurd to steer the ring")
m.record(ptsNs: UInt64(atNs + 1), atNs: atNs, offsetNs: 0) // negative interval
XCTAssertNil(m.latestSample(asOfNs: atNs, maxAgeMs: 500))
// …and a good sample after them still lands, so the refusals cost nothing.
m.record(ptsNs: UInt64(atNs - 9_000_000), atNs: atNs, offsetNs: 0)
XCTAssertEqual(m.latestSample(asOfNs: atNs, maxAgeMs: 500), 9_000_000)
}
}