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
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.
121 lines
6.5 KiB
Swift
121 lines
6.5 KiB
Swift
// Per-frame latency-stage sampler for the live HUD: records one interval per frame (an end
|
||
// instant minus a start instant, both CLOCK_REALTIME ns) and drains percentiles on demand.
|
||
// NSLock rather than an actor — the writers are the non-async pump/decode/present paths (same
|
||
// pattern as the app's FrameMeter).
|
||
|
||
import Foundation
|
||
|
||
/// Samples one **latency stage** per frame and reports percentiles. One instance per stage of the
|
||
/// unified stats model (design/stats-unification.md):
|
||
///
|
||
/// - `host+network` = capture→received: `record(ptsNs:offsetNs:)` at AU receipt.
|
||
/// - `decode` = received→decoded and `display` = decoded→displayed: client-local single-clock
|
||
/// stages — `record(ptsNs:atNs:offsetNs:)` with the start instant as `ptsNs` and `offsetNs: 0`.
|
||
/// - `end-to-end` = capture→displayed, measured directly (never summed from the stages):
|
||
/// `record(ptsNs:atNs:offsetNs:)` at present.
|
||
///
|
||
/// For the host-anchored intervals (capture→…) the sample is `end + offset - pts_ns`, where
|
||
/// `pts_ns` is the host's capture wall clock (the AU's pts) and the connect-time **clock-skew
|
||
/// offset** (`PunktfunkConnection.clockOffsetNs`, host minus client) makes the difference valid
|
||
/// across machines. `offsetNs == 0` means an old host that didn't answer the skew handshake (or
|
||
/// genuinely synced clocks) — the number is then only meaningful same-host, and the HUD tags the
|
||
/// end-to-end line `(same-host clock)`.
|
||
public final class LatencyMeter: @unchecked Sendable {
|
||
private let lock = NSLock()
|
||
private var samplesUs: [Int64] = []
|
||
private var skewCorrected = false
|
||
/// The most recent sample and the instant it ended, for `latestSample(asOfNs:maxAgeMs:)` —
|
||
/// a LEVEL, not a window, so `drain` deliberately leaves both alone.
|
||
private var latestNs: Int64 = 0
|
||
private var latestAtNs: Int64 = 0
|
||
|
||
public init() {}
|
||
|
||
/// Record one frame at receipt (now). `ptsNs` is the host capture clock (the AU's pts);
|
||
/// `offsetNs` is the host-client clock offset from the skew handshake (0 = uncorrected).
|
||
public func record(ptsNs: UInt64, offsetNs: Int64) {
|
||
var ts = timespec()
|
||
clock_gettime(CLOCK_REALTIME, &ts)
|
||
let nowNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||
record(ptsNs: ptsNs, atNs: nowNs, offsetNs: offsetNs)
|
||
}
|
||
|
||
/// Record one frame whose sample is `atNs + offsetNs - ptsNs` — an EXPLICIT end instant
|
||
/// rather than now. `ptsNs` is the stage's start point: the AU pts for the host-anchored
|
||
/// intervals, or a client stamp (receivedNs / decodedNs, with `offsetNs: 0`) for the local
|
||
/// decode/display stages. The stage-2 presenter stamps its present-side samples at the
|
||
/// display link's target present time (not the moment the present call ran). All in
|
||
/// `CLOCK_REALTIME`.
|
||
public func record(ptsNs: UInt64, atNs: Int64, offsetNs: Int64) {
|
||
let latNs = atNs &+ offsetNs &- Int64(bitPattern: ptsNs)
|
||
// Drop absurd values (a clock step, a wildly wrong offset, garbage pts, or a stage whose
|
||
// start stamp is missing/after its end) — samples are clamped to (0, 10 s).
|
||
guard latNs > 0, latNs < 10_000_000_000 else { return }
|
||
lock.lock()
|
||
samplesUs.append(latNs / 1000)
|
||
latestNs = latNs
|
||
latestAtNs = atNs
|
||
if offsetNs != 0 { skewCorrected = true }
|
||
lock.unlock()
|
||
}
|
||
|
||
/// The most recent single sample in ns, or `nil` if none has landed or the last one ended more
|
||
/// than `maxAgeMs` before `nowNs` (both `CLOCK_REALTIME`). Unlike `drain`, this reports a level
|
||
/// rather than a window, and reading it consumes nothing.
|
||
///
|
||
/// **What it is for.** Read off the END-TO-END meter, this is the video plane's live
|
||
/// glass-to-glass figure — `displayed + clockOffset − pts`, exactly the shape `AvSync` compares
|
||
/// audio against — and it is the reference the A/V sync loop needs. It is published from
|
||
/// `record`, so BOTH present paths (arrival and deadline) feed it without either knowing that
|
||
/// audio exists.
|
||
///
|
||
/// **Why staleness is not optional.** The number is a level, so absent an age check it would
|
||
/// simply keep its last value forever. This client has a state where that matters: the
|
||
/// backgrounded keep-alive keeps audio playing and DROPS video decode entirely, so the loop
|
||
/// would go on steering the ring against a reference minutes old and frozen. Expiring it
|
||
/// returns `nil`, which is the same "no reference yet" case as session start — the loop holds
|
||
/// its last correction and stops chasing. `nowNs` is caller-supplied rather than read fresh so
|
||
/// the audio side compares against exactly the instant it timestamped its own frame at.
|
||
///
|
||
/// Only the PAST is bounded. A present stamp can legitimately sit a hair ahead of the reader's
|
||
/// clock (the deadline presenter stamps at the link's target present time), and discarding the
|
||
/// only reference we have over a fraction of a refresh would make it flap in and out; a stamp
|
||
/// wildly in the future instead yields a huge offset, which `AvSync` refuses on its own terms.
|
||
public func latestSample(asOfNs nowNs: Int64, maxAgeMs: Int) -> Int64? {
|
||
lock.lock()
|
||
defer { lock.unlock() }
|
||
guard latestNs > 0 else { return nil }
|
||
guard (nowNs &- latestAtNs) <= Int64(maxAgeMs) * 1_000_000 else { return nil }
|
||
return latestNs
|
||
}
|
||
|
||
public struct Stats: Sendable {
|
||
public let p50Ms: Double
|
||
public let p95Ms: Double
|
||
public let p99Ms: Double
|
||
public let count: Int
|
||
/// True if the skew offset was applied (a host that answered the handshake) — i.e. the
|
||
/// numbers are cross-machine valid, not just same-host.
|
||
public let skewCorrected: Bool
|
||
}
|
||
|
||
/// Percentiles over the samples accumulated since the last drain, then reset the window. `nil`
|
||
/// when no samples arrived in the interval.
|
||
public func drain() -> Stats? {
|
||
lock.lock()
|
||
let sorted = samplesUs.sorted()
|
||
let corrected = skewCorrected
|
||
samplesUs.removeAll(keepingCapacity: true)
|
||
skewCorrected = false
|
||
lock.unlock()
|
||
guard !sorted.isEmpty else { return nil }
|
||
func pct(_ p: Double) -> Double {
|
||
let i = min(Int(Double(sorted.count) * p), sorted.count - 1)
|
||
return Double(sorted[i]) / 1000.0 // us -> ms
|
||
}
|
||
return Stats(
|
||
p50Ms: pct(0.50), p95Ms: pct(0.95), p99Ms: pct(0.99),
|
||
count: sorted.count, skewCorrected: corrected)
|
||
}
|
||
}
|