Compare commits

...
Author SHA1 Message Date
enricobuehler 3c25ff1a80 feat(client/android): per-AU attribution for latency excursions
The e2e spikes WP2 surfaced (79-106 ms against a 9-16 ms p50) could not be
diagnosed from what we log. Window maxima cannot say WHERE a 90 ms frame spent
its time, because the per-stage maxima in a window are generally different
frames. Attribution has to be captured per AU, for the offending AU.

`debug.punktfunk.spike_ms` arms a watch: every AU whose capture->reassembled
latency crosses the threshold is logged to `pf.spike` with the host's own
pipeline time for that same AU (from the 0xCF timing plane), the remainder on
the wire, the AU's size, and the gap since the previous AU was reassembled.
That last field is what separates "this frame was slow" from "the stream
stalled and then burst" — indistinguishable in any percentile. Off by default,
so it costs nothing until someone asks.

Also moves the 0xCF drain OUT of the HUD gate. It carries the host's per-AU
pipeline time and the phase-lock ACK, neither of which had any business being
invisible with the HUD down — the same HUD-gating trap this client has now hit
three times.

What it found immediately, on .173 over Wi-Fi:

  idx=2383 hostnetMs=72.8 hostMs=4.6 netMs=68.2 gapMs=69.5
  idx=2384 hostnetMs=66.4 hostMs=4.9 netMs=61.6 gapMs=1.8
  idx=2385 hostnetMs=62.2 hostMs=4.9 netMs=57.3 gapMs=3.8
  ...
  idx=2420 hostnetMs=69.2 hostMs=4.3 netMs=65.0 gapMs=69.3

A ~69 ms window where nothing is delivered, then a burst that drains the
backlog, recurring every ~305 ms. Across 112 attributed frames the host
pipeline is 2.0-8.0 ms (mean 3.9) and never spikes: the excursion is entirely
wire time. Our Wi-Fi low-latency lock is confirmed active throughout
(`dumpsys wifi`: both locks held, uid in the low-latency watchlist,
operation mode 4), so this is NOT the power-save trap StreamScreen already
defends against — that defence is working and is not sufficient.

Gates: cargo ndk check + clippy arm64 clean (the 5 warnings are pre-existing
and in other files); fmt clean.
2026-08-06 00:15:11 +02:00
enricobuehler 1099c94ca3 fix(client/present): one bogus present stamp no longer wedges the cadence run
Caught on glass during the WP2 baseline, on the first stream the metric ever
measured: judder=0 mode=0 cadN=0 disorder=119, every second, on a perfectly
healthy 118 fps stream with the panel period correctly learned at 8.33 ms.

Every single present was scoring as disordered. The cause was the vendor quirk
decode/display.rs already documents — Android's render callback can deliver a
garbage far-future system_nano on a session's first frames. The rule "hold the
later instant so one reordered delivery cannot corrupt the following spacings"
then latched onto that stamp permanently: every real timestamp afterwards was
behind it, so nothing was ever scored again for the rest of the session.

The rule was right for what it was written for and wrong past a bound. A step
backwards of a few refreshes IS a reordered delivery and the later instant
should win; a step backwards of an hour is a bogus stamp and the run must
re-anchor onto the new sample. One bad sample now costs one sample.

Both implementations get the bound and the regression test, since the two must
agree; the Swift port would otherwise have shipped the same latch-up.

Also makes the statistic self-diagnosing, which is what turned a puzzling
result into a five-minute diagnosis: summary() returning None was
indistinguishable from a window of perfectly smooth zeros in a log line, so
"no cadence is being scored at all" looked exactly like "no judder". The
pf.present line now carries the raw sample/stall/disorder counts and the period
the run is quantising against, whatever the evidence bar.

Gates: punktfunk-core 190 tests green (22 in phase, incl. the new
a_garbage_far_future_stamp_does_not_wedge_the_run); swift test
--filter PresentIntervalsTests 11/11 green; fmt clean; on-glass re-run against
.173 now reports judder 8-36permille with cadN ~119/s.
2026-08-05 23:45:06 +02:00
enricobuehler e5ba78ea66 fix(apple): build opus from source so the xcframework stops depending on brew
The xcframework build failed its own deployment-target guard on any Mac with
a Homebrew libopus installed:

    ERROR: .../libpunktfunk_core.a contains objects built for macOS 26.0 (> 14.0)

The guard was right; its advice was not. This is not a stale cache, and the
suggested `rm -rf target/{aarch64,x86_64}-apple-darwin` never fixes it —
the objects come back on every clean rebuild.

audiopus_sys probes pkg-config before falling back to its vendored copy, and
on this machine it found /opt/homebrew/Cellar/opus/1.6.1 and linked it
statically. Homebrew compiles for the HOST macOS, so 143 SILK objects
(wrappers_FLP.o, VAD.o, stereo_*.o, resampler.o …) entered our staticlib
carrying minos 26 while everything we compiled carried 11 or 14.

That made a locally-built framework's validity depend on whether the
developer happens to have run `brew install opus` — for an artifact every
Apple build consumes and no one commits, which is exactly the kind of
environmental coupling that produces "works on my machine". OPUS_NO_PKG_CONFIG
forces the vendored build unconditionally; CMAKE_POLICY_VERSION_MINIMUM is
what that vendored copy needs to configure under CMake 4, which removed
support for the pre-3.5 minimum its CMakeLists still declares.

The guard's error message now names both causes and shows how to identify the
offending objects, since the misleading half cost real time.

Verified: from a fully clean target dir and with no environment variables set,
`bash scripts/build-xcframework.sh` completes and signs. `swift build` then
compiles PunktfunkKit, and `swift test --filter PresentIntervalsTests` runs
10/10 green — which pays off the Apple typecheck owed by d0d23994.
2026-08-05 23:22:37 +02:00
enricobuehler d0d2399476 feat(client/present): Apple reads the cadence statistic — WP1 complete
The third and last leg of WP1. All three clients now publish the same
judder number, which was the point: one ruler, so a smoothness A/B can be
compared across platforms instead of argued about.

A verbatim Swift port of punktfunk_core::phase::PresentIntervals, in the
same spirit as PhaseReporter.circularLatch alongside it, with a test file
that runs the SAME vectors as the Rust unit tests. A hand-written port is
exactly where "all three emit the same numbers" quietly stops being true,
so it is pinned rather than trusted.

Porting it found a real cross-client hazard. The modal spacing was read
with max_by_key, which returns the LAST maximum, while Swift's max(by:)
returns the FIRST — so a 50/50 window (the classic 1-and-3 sawtooth) would
have reported the same judder but a different mode on Android and Apple.
Both sides now spell the rule out: ties resolve to the smallest spacing.
The Rust test that previously accepted either answer now pins it.

Two Apple-specific decisions:

  - the stats object is built for EVERY session, not just under the debug
    env var or deadline pacing. A smoothness defect produces no drops and
    healthy percentiles, so gating the one statistic that could see it
    behind an env var means it is off exactly when it matters. A `verbose`
    flag preserves the old behaviour for the wordy counters line; the
    cadence line always emits.
  - the panel period comes from the link's own reported period (glass
    pacing) or is learned from the link's target instants (deadline
    pacing). Those tick at the panel rate whether or not WE present, which
    is what makes the window minimum the true period — the same reasoning
    PhaseReporter already documents. Learning it from on-glass spacings
    instead would read a 60-on-120 stream as a 60 Hz panel and mislabel the
    cadence mode.

A dropped drawable splits the run rather than scoring the gap: it never
reached glass, so it is not a cadence event, and the next present does not
continue the previous interval either.

Gates: punktfunk-core 21 phase tests green; the Swift port verified against
all 11 Rust vectors via a standalone harness (identical mode/judder/samples/
stalls/disordered on every case, incl. the tie-break); both edited Swift
files parse clean; fmt clean.

⚠ The Swift INTEGRATION is not compiler-verified locally: building
PunktfunkCore.xcframework on this machine fails a pre-existing deployment-
target guard (objects at minos 26 survive a cache wipe and an exported
MACOSX_DEPLOYMENT_TARGET). Source-only change, so it cannot be the cause.
CI's Apple leg owns that check — treat it as owed, not passed.
2026-08-05 23:14:28 +02:00
enricobuehler 53278c6f5f feat(client/present): desktop reads the cadence statistic too
WP1 continued — the Linux/Windows session client joins Android on the shared
PresentIntervals. Apple is the remaining leg.

The metric lives on LatchClock because that is where the on-glass stamps and
the learned panel grid it quantises against already meet, so no new plumbing
and no second source of truth about the grid. Every stamp is scored,
including the sub-millisecond pairs the grid learner deliberately skips: two
presents inside one refresh is not a grid step, but it is very much a
cadence event.

The period is taken seeded-or-learned rather than learned-only, so cadence is
scored from the first window instead of waiting for the learner to converge.

Judder also becomes a trigger for the 1 Hz presenter line. That line only
fired on drops, gate holds or the debug env var — and a cadence defect
produces none of those: no drops, no holds, healthy percentiles, visibly
broken motion. Without this a desktop stream could judder for an entire
session and never emit a line, which is the same blind spot the metric exists
to close.

One honest asymmetry, recorded in the code: these stamps are CLOCK_REALTIME
(this module's domain), so a wall-clock step would forge a hitch that never
happened. It lands in the stall/disordered counters rather than the judder
ratio, which is part of why that split is worth having. Android feeds the
metric a raw monotonic stamp and has no such exposure.

Gates: cargo check + clippy --all-targets on pf-presenter clean; punktfunk-core
clippy -D warnings clean; fmt clean.
2026-08-05 22:44:35 +02:00
enricobuehler fc5b6296e3 feat(client/present): the cadence statistic — measure judder, not just latency
WP1 of design/presenter-cadence-rework-implementation-plan.md. Shared core
type plus the Android binding; desktop and Apple follow.

Every stat we publish is a latency — a difference between two points on one
frame. No latency can see judder, because judder is a property of the
SEQUENCE. A stream that shows each frame one refresh early and the next one
late has excellent percentiles and looks broken; a stream whose every
interval is exactly two refreshes has worse latency than one alternating 1
and 3, and looks perfect. That blind spot is why a smoothness complaint
could not be confirmed or refuted from our own telemetry.

PresentIntervals quantises the spacing between consecutive on-glass instants
onto the learned panel grid and reports the modal spacing plus the fraction
of intervals that miss it — the judder number, in permille to match the
phase coherence already next to it. Scale-free: the mode absorbs the cadence
ratio, so 60-on-120 and 120-on-120 are both "one tall bucket" and directly
comparable. That is what makes it usable as one ruler across clients,
refresh rates and stream rates, and for a feature-on/off A/B.

Deliberate choices, each with a test:

  - fed the MEASURED on-glass instant, never the requested present time,
    which would measure our own intent and always look perfect
  - fed SurfaceFlinger's raw CLOCK_MONOTONIC render stamp, not the
    realtime-rebased one the latency stats use: cadence is about spacing,
    and a realtime clock step would forge a hitch that never happened
  - stalls (>8 refreshes) and out-of-order callbacks counted apart from the
    ratio, so a window that looks smooth because the stream was PAUSED
    cannot be mistaken for a good one
  - sub-refresh jitter is not judder: the display quantises it away, so the
    metric must too
  - the predecessor survives a window drain, else one interval per window
    would go unscored forever

Always-on via the 1 Hz pf.present line, so the HUD-off wireless A/B the
baseline measurement needs is readable from logcat. HUD surfacing waits on
the stats-unification spec amendment (plan S3) and on the in-flight HUD work.

Gates: punktfunk-core 189 tests green (10 new); cargo ndk check + clippy
arm64 clean — the 5 remaining warnings are pre-existing and in other files.
2026-08-05 22:40:43 +02:00
9 changed files with 980 additions and 31 deletions
+155 -20
View File
@@ -28,6 +28,76 @@ use super::{
NO_VIDEO_RETRY, PENDING_SPLIT_CAP,
};
/// How long a flagged AU waits for the host's 0xCF timing before being logged unattributed.
/// Comfortably longer than the round the host takes to report, short enough that the line still
/// lands near the event in the log.
const SPIKE_ATTRIBUTE_WAIT_NS: i64 = 500_000_000;
/// Bound on AUs awaiting attribution — a stream that spikes constantly must not grow this.
const SPIKE_WATCH_CAP: usize = 64;
/// One receipt-latency excursion, held until the host's own timing for the same AU arrives.
///
/// The point of this record is attribution. A window maximum cannot say WHERE a 90 ms frame
/// spent its time — the per-stage maxima in a window are generally different frames — so the
/// stage split has to be captured per AU, for the offending AU.
struct SpikeWatch {
pts_ns: u64,
/// Capture → reassembled, skew-corrected: the host pipeline plus the wire.
hostnet_us: u64,
au_len: usize,
/// Since the previous AU was reassembled — separates "this frame was slow" from "the
/// stream stalled and then burst", which look identical in a latency percentile.
gap_us: u64,
idx: u32,
seen_mono: i64,
}
impl SpikeWatch {
/// `host_us` = the host's own capture→submit time for this AU (0xCF), or `None` when the
/// host never reported it. `net` is the remainder: wire + reassembly.
fn log(&self, host_us: Option<u64>) {
log::warn!(
target: "pf.spike",
"idx={} hostnetMs={:.1} hostMs={} netMs={} gapMs={:.1} bytes={}",
self.idx,
self.hostnet_us as f64 / 1000.0,
host_us.map_or("?".into(), |h| format!("{:.1}", h as f64 / 1000.0)),
host_us.map_or("?".into(), |h| format!(
"{:.1}",
self.hostnet_us.saturating_sub(h) as f64 / 1000.0
)),
self.gap_us as f64 / 1000.0,
self.au_len,
);
}
}
/// `debug.punktfunk.spike_ms` (1..=2000): log a per-AU stage breakdown for every receipt latency
/// at or above this. Unset = off, so the instrument costs nothing until someone asks for it.
fn spike_threshold_us() -> Option<u64> {
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
let n = unsafe {
libc::__system_property_get(
c"debug.punktfunk.spike_ms".as_ptr(),
buf.as_mut_ptr().cast(),
)
};
if n > 0 {
if let Ok(ms) = std::str::from_utf8(&buf[..n as usize])
.unwrap_or("")
.trim()
.parse::<u64>()
{
if (1..=2_000).contains(&ms) {
return Some(ms * 1_000);
}
}
}
None
}
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the
/// wall-clock instant the output callback fired — the spec's `decoded` point ("decoder output
@@ -584,6 +654,14 @@ fn feeder_loop(
// Last logged phase-lock ACK (the host's applied capture hold, from the 0xCF tail) — logged
// on change so `adb logcat -s pf.phase` shows the closed loop working (or not) at a glance.
let mut last_phase_ack: Option<i32> = None;
// Latency-excursion watch (`debug.punktfunk.spike_ms`). Read once per stream: this is a
// field instrument, armed by setprop + reconnect, and off by default.
let spike_thresh_us = spike_threshold_us();
if let Some(t) = spike_thresh_us {
log::info!("decode: spike watch armed at {} ms (pf.spike)", t / 1000);
}
let mut spike_watch: VecDeque<SpikeWatch> = VecDeque::new();
let mut last_recv_mono: Option<i64> = None;
while !shutdown.load(Ordering::Relaxed) {
match client.next_frame(Duration::from_millis(5)) {
Ok(frame) => {
@@ -599,6 +677,44 @@ fn feeder_loop(
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
// HUD-only `received` point + host/network split stay gated on the overlay.
// The receipt latency is needed by the always-on spike watch below, so it is
// computed for every complete AU rather than only when the HUD is up.
let spike_lat_us = if frame.complete {
let received_ns = if frame.received_ns > 0 {
frame.received_ns as i128
} else {
now_realtime_ns()
};
let off = clock_offset.load(Ordering::Relaxed) as i128;
let lat_ns = received_ns + off - frame.pts_ns as i128;
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64)
} else {
None
};
if let (Some(thresh_us), Some(lat_us)) = (spike_thresh_us, spike_lat_us) {
let now_mono = now_monotonic_ns();
let gap_us = last_recv_mono
.map(|p| ((now_mono - p) / 1000) as u64)
.unwrap_or(0);
last_recv_mono = Some(now_mono);
if lat_us >= thresh_us {
let au_len = frame.part.map_or(0, |p| p.offset as usize) + frame.data.len();
// Held for the host's 0xCF timing for this pts, which is what splits the
// excursion into host pipeline vs wire — the whole point. Emitted
// unattributed if that never arrives (see the drain below).
spike_watch.push_back(SpikeWatch {
pts_ns: frame.pts_ns,
hostnet_us: lat_us,
au_len,
gap_us,
idx: frame.frame_index,
seen_mono: now_mono,
});
if spike_watch.len() > SPIKE_WATCH_CAP {
spike_watch.pop_front();
}
}
}
if (stats.enabled() || measure_decode) && frame.complete {
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping
// here would fold the hand-off queue wait into the network latency figure
@@ -632,28 +748,47 @@ fn feeder_loop(
pending_split.pop_front();
}
}
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
// Phase-lock closed-loop readout: the host's applied hold rides the
// 0xCF tail; log transitions (~1 Hz worst case — the host updates it
// once a second). None = a host without the tail (pre-phase-lock).
if t.applied_phase_ns != last_phase_ack {
log::info!(
target: "pf.phase",
"host applied_phase={:?}us",
t.applied_phase_ns.map(|n| n / 1000)
);
last_phase_ack = t.applied_phase_ns;
}
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
{
let (_, hostnet_us) = pending_split.remove(i).unwrap();
stats.note_host_split(
t.host_us as u64,
hostnet_us.saturating_sub(t.host_us as u64),
);
}
}
}
// The 0xCF drain is OUTSIDE the HUD gate: it carries the host's own pipeline time
// per AU, which is what attributes a latency excursion to the host or the wire,
// and the phase-lock ack, which had no business being invisible with the HUD down.
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
// Phase-lock closed-loop readout: the host's applied hold rides the
// 0xCF tail; log transitions (~1 Hz worst case — the host updates it
// once a second). None = a host without the tail (pre-phase-lock).
if t.applied_phase_ns != last_phase_ack {
log::info!(
target: "pf.phase",
"host applied_phase={:?}us",
t.applied_phase_ns.map(|n| n / 1000)
);
last_phase_ack = t.applied_phase_ns;
}
if stats.enabled() {
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns) {
let (_, hostnet_us) = pending_split.remove(i).unwrap();
stats.note_host_split(
t.host_us as u64,
hostnet_us.saturating_sub(t.host_us as u64),
);
}
}
if let Some(i) = spike_watch.iter().position(|w| w.pts_ns == t.pts_ns) {
let w = spike_watch.remove(i).unwrap();
w.log(Some(t.host_us as u64));
}
}
// Anything the host never reported on still gets logged, unattributed, rather
// than silently dropped — an old host has no 0xCF tail at all.
let now_mono = now_monotonic_ns();
while spike_watch
.front()
.is_some_and(|w| now_mono - w.seen_mono > SPIKE_ATTRIBUTE_WAIT_NS)
{
if let Some(w) = spike_watch.pop_front() {
w.log(None);
}
}
if ev_tx.send(DecodeEvent::Au(frame, gap)).is_err() {
break; // the decode loop is gone
+1 -1
View File
@@ -185,7 +185,7 @@ unsafe extern "C" fn on_frame_rendered(
let display_us = paired.and_then(|(d, _)| clamp(displayed_ns - d));
let latch_us = paired.and_then(|(_, r)| clamp(displayed_ns - r));
// Always-on half: the presenter's pf-present line reads these with the HUD off.
t.meter.note_latch(latch_us);
t.meter.note_latch(latch_us, system_nano);
if !t.stats.enabled() {
return; // HUD hidden — skip the skew math + the stats lock
}
+56 -6
View File
@@ -22,7 +22,7 @@
use ndk::media::media_codec::MediaCodec;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicI64, Ordering};
use std::sync::Mutex;
use std::time::Instant;
@@ -152,6 +152,10 @@ pub(super) struct PresentMeter {
/// This device delivers render callbacks at all (API ≥ 33 and the platform accepted the
/// registration). Until one arrives, `undisplayed` is meaningless and the rail stays down.
confirms: AtomicBool,
/// The learned panel period the cadence statistic quantises against, republished by
/// [`Presenter::pump`] (the callback thread has no access to the vsync clock). 0 until the
/// grid is known, which simply means cadence is not scored yet.
panel_period_ns: AtomicI64,
}
struct PresentMeterInner {
@@ -166,6 +170,9 @@ struct PresentMeterInner {
/// Capture→decoded end-to-end µs (skew-corrected, clamped) — always on for the same reason:
/// the wireless A/B's headline without having to reach the on-screen HUD.
e2e_us: Vec<u64>,
/// The cadence (judder) statistic — the only stat here that is not a latency, and the only
/// one that can see a pacing defect. See [`punktfunk_core::phase::PresentIntervals`].
intervals: punktfunk_core::phase::PresentIntervals,
}
impl PresentMeter {
@@ -177,19 +184,33 @@ impl PresentMeter {
feed_us: Vec::with_capacity(256),
codec_us: Vec::with_capacity(256),
e2e_us: Vec::with_capacity(256),
intervals: punktfunk_core::phase::PresentIntervals::new(),
}),
undisplayed: AtomicI32::new(0),
confirms: AtomicBool::new(false),
panel_period_ns: AtomicI64::new(0),
}
}
/// Republish the learned panel period for the cadence statistic (presenter thread).
pub(super) fn set_panel_period(&self, period_ns: i64) {
self.panel_period_ns.store(period_ns, Ordering::Relaxed);
}
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
///
/// Also the glass budget's CONFIRM: this frame left the BufferQueue, so one outstanding
/// release is settled. Clamped at zero — the legacy `arrival` path renders without going
/// through [`Presenter::pump`], so confirms can outnumber counted releases.
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
///
/// `present_mono_ns` is SurfaceFlinger's own render timestamp, raw on `CLOCK_MONOTONIC` —
/// deliberately not the realtime-rebased instant the latency stats use. Cadence is a
/// statistic about *spacing*, and a realtime clock step (NTP) would forge a hitch that never
/// happened. Garbage stamps need no special handling here: an implausible one lands in the
/// stall or disordered counters rather than the judder ratio.
pub(super) fn note_latch(&self, latch_us: Option<u64>, present_mono_ns: i64) {
self.confirms.store(true, Ordering::Relaxed);
let period_ns = self.panel_period_ns.load(Ordering::Relaxed);
let _ = self
.undisplayed
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
@@ -200,6 +221,7 @@ impl PresentMeter {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.displays += 1;
g.intervals.record(present_mono_ns, period_ns);
if let Some(l) = latch_us {
if g.latch_us.len() < 4096 {
g.latch_us.push(l);
@@ -258,7 +280,17 @@ impl PresentMeter {
}
#[allow(clippy::type_complexity)] // one caller unpacks it in place; a struct would be noise
fn drain(&self) -> (Vec<u64>, u64, Vec<u64>, Vec<u64>, Vec<u64>) {
fn drain(
&self,
) -> (
Vec<u64>,
u64,
Vec<u64>,
Vec<u64>,
Vec<u64>,
(u32, u32, u32),
Option<punktfunk_core::phase::PresentCadence>,
) {
let mut g = self
.inner
.lock()
@@ -271,6 +303,8 @@ impl PresentMeter {
std::mem::take(&mut g.feed_us),
std::mem::take(&mut g.codec_us),
std::mem::take(&mut g.e2e_us),
g.intervals.pending(),
g.intervals.take(),
)
}
}
@@ -410,6 +444,11 @@ impl Presenter {
stats: &crate::stats::VideoStats,
now_mono_ns: i64,
) -> bool {
// The callback thread scores cadence but cannot see the vsync clock — republish the grid
// it quantises against. Relaxed: a period change is rare and one stale sample is noise.
if let Some(c) = clock {
meter.set_panel_period(c.panel_period_ns().max(c.period_ns()));
}
// Budget bookkeeping first: reopen on the predicted latch, force-open on the backstop.
if let Some(f) = &self.inflight {
if now_mono_ns >= f.reopen_at_ns {
@@ -547,7 +586,11 @@ impl Presenter {
/// `pace` (decoded→release) / `latch` (release→displayed) /
/// `feed`+`codec` (the decode stage split: received→queued hand-off/slot wait + the
/// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless
/// A/B headline) / `vsync` (the measured panel period).
/// A/B headline) / `vsync` (the measured panel period) /
/// `judder` (‰ of present intervals off the modal spacing — the cadence statistic, and the
/// only number here that can see a pacing defect) / `mode` (the modal spacing in refreshes:
/// 1 at panel rate, 2 for 60-on-120) / `stalls` + `disorder` (excluded from the ratio; see
/// [`punktfunk_core::phase::PresentIntervals`]).
///
/// Returns this window's CIRCULAR latch statistics `(vector-mean latch ns mod panel period,
/// coherence ‰)` when a window actually flushed — the phase-lock reporter's v2 error signal
@@ -561,7 +604,7 @@ impl Presenter {
return None;
}
self.last_flush = Instant::now();
let (latch, displays, feed, codec, e2e) = meter.drain();
let (latch, displays, feed, codec, e2e, cad_raw, cadence) = meter.drain();
if self.released == 0 && displays == 0 {
return None; // idle stream — nothing worth a line
}
@@ -584,7 +627,8 @@ impl Presenter {
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \
feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \
e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
vsyncMs={:.2} panelMs={:.2}",
vsyncMs={:.2} panelMs={:.2} \
judder={}permille mode={}vsync cadN={} stalls={} disorder={} cadPeriodMs={:.2}",
self.released,
displays,
self.paced_drops,
@@ -607,6 +651,12 @@ impl Presenter {
circ.map(|(_, c)| c).unwrap_or(0),
period_ms,
panel_ns as f64 / 1e6,
cadence.map(|c| c.judder_permille).unwrap_or(0),
cadence.map(|c| c.mode_units).unwrap_or(0),
cad_raw.0,
cad_raw.1,
cad_raw.2,
meter.panel_period_ns.load(Ordering::Relaxed) as f64 / 1e6,
);
self.released = 0;
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past
@@ -474,6 +474,7 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
// The link's own pipeline depth, measured: how far ahead of glass this vend runs.
let leadS = update.targetPresentationTimestamp - CACurrentMediaTime()
stats?.vendLead(ms: leadS * 1000)
stats?.notePanelTarget(mediaTime: update.targetPresentationTimestamp)
// Same measurement into the floor meter (as a LatencyMeter sample: end = now, start =
// now lead) its 1 s p50 is the OS present floor SessionModel shaves off.
if leadS > 0, let floorMeter {
@@ -562,6 +563,112 @@ final class PresentGate: @unchecked Sendable {
}
}
/// One window's present-cadence summary (see `PresentIntervals`).
struct PresentCadence: Equatable {
/// The most common spacing, in whole panel refreshes: 1 at panel rate, 2 for 60-on-120.
let modeUnits: Int
/// Fraction of intervals that were NOT the mode, in . **The judder number.**
let judderPermille: Int
let samples: Int
/// Spacings wider than `maxUnits` stalls, not judder.
let stalls: Int
/// Present instants that did not advance (duplicate/out-of-order callbacks).
let disordered: Int
}
/// Present-interval distribution in whole panel refreshes the cadence (judder) statistic.
///
/// A **verbatim port of `punktfunk_core::phase::PresentIntervals`**, in the same spirit as
/// `PhaseReporter.circularLatch` above: the three clients must publish the SAME statistic, so the
/// numbers can be compared across platforms and so a feature-on/off A/B uses one ruler. Any change
/// here belongs in the Rust original first including the tie-break, which is spelled out on both
/// sides precisely because the two languages' `max` disagree about which equal element wins.
///
/// Every other stat we publish is a latency: a difference between two points on one frame. No
/// latency can see judder, because judder is a property of the *sequence*. A stream that shows each
/// frame one refresh early and the next one late has excellent percentiles and looks broken.
///
/// Fed the MEASURED on-glass instant, never the requested present time the latter would measure
/// our own intent and report a perfect cadence no matter what the display did.
struct PresentIntervals {
/// Largest spacing still treated as cadence; wider is a stall, counted apart.
private static let maxUnits = 8
/// Minimum intervals before a summary means anything (matches `circularLatch`'s bar).
private static let minSamples = 8
/// A backwards step larger than this is a bogus timestamp, not a reordered delivery, so the
/// run re-anchors rather than holding the old instant. Without it, one garbage far-future
/// stamp latches the statistic and every later present scores as disordered for the whole
/// session observed on glass on Android, 2026-08-05.
private static let reanchorNs: Int64 = 100_000_000
private var lastPresentNs: Int64 = 0
private var hist = [Int](repeating: 0, count: PresentIntervals.maxUnits + 1)
private var samples = 0
private var stalls = 0
private var disordered = 0
/// Forget the previous instant without discarding the window's counts a discontinuity where
/// the next present does not continue this cadence.
mutating func split() { lastPresentNs = 0 }
/// Fold one on-glass instant. A non-positive `periodNs` means the grid is not known yet and
/// the sample is held as the new predecessor without being scored.
mutating func record(presentNs: Int64, periodNs: Int64) {
let prev = lastPresentNs
lastPresentNs = presentNs
guard prev > 0, periodNs > 0 else { return }
let spacing = presentNs - prev
if spacing <= 0 {
// Hold the LATER instant so one reordered delivery cannot corrupt every following
// spacing but only when the step back is small enough to BE a reordering. Beyond
// that the old instant is the bogus one (see `reanchorNs`) and the run re-anchors
// onto the new sample, which `lastPresentNs` already holds.
disordered += 1
if prev - presentNs < PresentIntervals.reanchorNs {
lastPresentNs = prev
}
return
}
// Nearest whole refresh: a present is "on the grid" if it is closer to this vblank than
// the next, which is exactly what the display did with it.
let units = Int((spacing * 2 + periodNs) / (periodNs * 2))
if units > PresentIntervals.maxUnits {
stalls += 1
return
}
hist[units] += 1
samples += 1
}
/// This window's summary, or nil under `minSamples`.
func summary() -> PresentCadence? {
guard samples >= PresentIntervals.minSamples else { return nil }
// Ties resolve to the SMALLEST spacing see the Rust original: `max_by_key` takes the
// last maximum and Swift's `max(by:)` the first, so this is written out on both sides.
var modeUnits = 0
var modeCount = 0
for (i, c) in hist.enumerated() where c > modeCount {
modeCount = c
modeUnits = i
}
return PresentCadence(
modeUnits: modeUnits,
judderPermille: (samples - modeCount) * 1000 / samples,
samples: samples, stalls: stalls, disordered: disordered)
}
/// Drain the window. The previous instant SURVIVES the cadence continues across a window
/// boundary, and dropping it would manufacture one unscored interval per window.
mutating func take() -> PresentCadence? {
let out = summary()
hist = [Int](repeating: 0, count: PresentIntervals.maxUnits + 1)
samples = 0
stalls = 0
disordered = 0
return out
}
}
/// PUNKTFUNK_PRESENT_DEBUG=1 aggregation: one printed line per second from the render thread with
/// the decode rate, render outcomes, the slowest render call ( nextDrawable wait) and the deltas
/// between system-reported on-glass times (vsync-aligned presents show clean refresh-period
@@ -588,6 +695,50 @@ private final class PresentDebugStats: @unchecked Sendable {
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 pegs it at the gate depth).
private var inFlight = 0
private var maxInFlight = 0
/// The cadence (judder) statistic the only number here that is not a latency, and the only
/// one that can see a pacing defect. `glassDeltasMs` above is the same raw material reported
/// as a percentile, which cannot distinguish a steady 2-refresh cadence from an alternating
/// 1-and-3 one: same mean, same median, one of them visibly broken.
private var intervals = PresentIntervals()
/// The panel period cadence quantises against: seeded from the display mode and refined from
/// the link's own reported period, mirroring `punktfunk_core::phase::PanelGrid`'s seed-then-
/// correct design. 0 until known, which simply means cadence is not scored yet.
private var panelPeriodNs: Int64 = 0
/// Deadline-pacing period learner state (see `notePanelTarget`). Re-armed each window so a
/// mode or VRR rate change is tracked both ways rather than latching the first value seen.
private var lastTargetS: CFTimeInterval = 0
private var minTargetSpacingS: CFTimeInterval = 0
/// Whether the verbose per-second line prints. The cadence line always does: a smoothness
/// defect must not be invisible until someone thinks to set an env var.
private let verbose: Bool
init(verbose: Bool) { self.verbose = verbose }
/// Seed or refine the panel period (render/link thread).
func setPanelPeriod(ns: Int64) {
guard ns > 0 else { return }
lock.lock()
panelPeriodNs = ns
lock.unlock()
}
/// Deadline pacing has no reported period, so learn it from the link's own target instants.
/// Those tick at the panel rate whether or not WE present, which is what makes the window
/// minimum the true period the same reasoning (and the same guard band) `PhaseReporter`
/// uses above. Learning it from on-glass spacings instead would read a 60-on-120 stream as a
/// 60 Hz panel and mislabel the cadence mode.
func notePanelTarget(mediaTime t: CFTimeInterval) {
lock.lock()
defer { lock.unlock() }
defer { lastTargetS = t }
guard lastTargetS > 0 else { return }
let d = t - lastTargetS
guard d > 0.0005, d < 0.1 else { return }
if minTargetSpacingS == 0 || d < minTargetSpacingS {
minTargetSpacingS = d
panelPeriodNs = Int64(d * 1_000_000_000)
}
}
func emptyWake() { lock.lock(); empty += 1; lock.unlock() }
@@ -624,8 +775,13 @@ private final class PresentDebugStats: @unchecked Sendable {
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
lastGlassNs = atNs
latchMs.append(Double(atNs - issuedNs) / 1e6)
intervals.record(presentNs: atNs, periodNs: panelPeriodNs)
} else {
// A dropped drawable never reached glass, so it is not a cadence event but the
// NEXT one does not continue the previous interval either. Split rather than let
// the gap read as judder.
dropped += 1
intervals.split()
}
lock.unlock()
}
@@ -656,6 +812,9 @@ private final class PresentDebugStats: @unchecked Sendable {
smoothing.overflowDrops, smoothing.underflows, maxRenderMs, inflightMax,
gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax,
vendP50, vendMax)
let cadence = intervals.take()
let verbose = self.verbose
minTargetSpacingS = 0 // re-arm the period learner for the next window
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0; noDrawable = 0
maxRenderMs = 0
maxInFlight = inFlight // the window peak restarts from the live depth
@@ -663,6 +822,21 @@ private final class PresentDebugStats: @unchecked Sendable {
latchMs.removeAll(keepingCapacity: true)
vendLeadMs.removeAll(keepingCapacity: true)
lock.unlock()
// The cadence line is ALWAYS emitted (when the window had evidence): it is the ruler the
// smoothness A/B reads, and it must not depend on an env var the field never sets. The
// verbose counters line stays behind its existing lever.
if let cadence {
let cadenceLine = String(
format: "pf-present judderPermille=%d modeVsync=%d n=%d stalls=%d disorder=%d",
cadence.judderPermille, cadence.modeUnits, cadence.samples,
cadence.stalls, cadence.disordered)
presentLog.info("\(cadenceLine, privacy: .public)")
if presentDebug {
print(cadenceLine)
fflush(stdout)
}
}
guard verbose else { return }
// Console.app first (the on-device readout see presentLog); stdout only under the env
// lever (the CLI client's capture channel).
presentLog.info("\(line, privacy: .public)")
@@ -746,6 +920,10 @@ public final class Stage2Pipeline {
/// mirror the pump's bounded join.
private let renderSignal = DispatchSemaphore(value: 0)
private let vsyncClock = VsyncClock()
/// The per-session present statistics, retained so the clock-bearing threads can republish
/// the panel period the cadence statistic quantises against. Assigned once in `start`, read
/// from the render/link threads; the object is itself lock-guarded.
private var presentStats: PresentDebugStats?
private let renderStopped = DispatchSemaphore(value: 0)
private var renderJoinable = false
/// Deadline pacing's staged CAMetalDisplayLink frame-rate hint (see `FrameRateHint`).
@@ -967,7 +1145,14 @@ public final class Stage2Pipeline {
// startDeadlinePresenter. The V-Sync policy below doesn't apply there (the link deadline-
// times every present). Deadline sessions ALWAYS carry the stats (their pf-present line
// streams to Console.app via presentLog the on-device pacing decomposition).
let debugStats = (presentDebug || pacing == .deadline) ? PresentDebugStats() : nil
//
// The stats object is now built for EVERY session, because the cadence statistic inside
// it has to be: a smoothness defect produces no drops and healthy percentiles, so gating
// it behind an env var means the one number that could see it is off exactly when it
// matters. `verbose` preserves the old behaviour for the wordy counters line.
let debugStats: PresentDebugStats? = PresentDebugStats(
verbose: presentDebug || pacing == .deadline)
presentStats = debugStats
if pacing == .deadline {
startDeadlinePresenter(debugStats: debugStats)
return
@@ -1241,6 +1426,9 @@ public final class Stage2Pipeline {
/// (their CAMetalDisplayLink's updates are both clock and retry).
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
vsyncClock.set(target: targetMediaTime, period: period)
// The link's own reported period is the authoritative grid for the cadence statistic
// it tracks VRR rate changes, which a mode-derived seed cannot.
presentStats?.setPanelPeriod(ns: Int64(period * 1_000_000_000))
renderSignal.signal()
}
@@ -0,0 +1,170 @@
// Parity tests for the Swift `PresentIntervals` port (Video/Stage2Pipeline.swift) against
// `punktfunk_core::phase::PresentIntervals` the cadence (judder) statistic of
// design/presenter-cadence-rework.md WP1.
//
// These are deliberately the SAME cases and the SAME vectors as the Rust unit tests in
// crates/punktfunk-core/src/phase.rs (module `cadence_tests`). WP1's acceptance criterion is that
// all three clients emit the same numbers for the same synthetic input, and a hand-written port is
// exactly where that quietly stops being true so the port is pinned here rather than trusted.
//
// If you change one side, change both, and keep the vectors identical.
import Foundation
import XCTest
@testable import PunktfunkKit
final class PresentIntervalsTests: XCTestCase {
/// 120 Hz in ns the Rust tests' `P`.
private static let P: Int64 = 8_333_333
/// Fold `n` presents spaced by `spacings` in rotation, starting at an arbitrary instant.
/// Mirrors the Rust helper of the same shape.
private func cadence(_ spacings: [Int64], _ n: Int, period: Int64 = P) -> PresentIntervals {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
pi.record(presentNs: t, periodNs: period)
for i in 0..<n {
t += spacings[i % spacings.count]
pi.record(presentNs: t, periodNs: period)
}
return pi
}
func testARegularCadenceHasNoJudder() {
let s = cadence([Self.P], 60).summary()
XCTAssertEqual(s?.modeUnits, 1)
XCTAssertEqual(s?.judderPermille, 0)
XCTAssertEqual(s?.samples, 60)
}
/// The property that makes this one ruler across rates: a stream at half (or a quarter of)
/// the panel rate is SMOOTH, not judder the mode absorbs the cadence ratio.
func testSixtyOnOneTwentyReadsSmooth() {
for (mult, expected) in [(Int64(2), 2), (Int64(4), 4)] {
let s = cadence([Self.P * mult], 40).summary()
XCTAssertEqual(s?.modeUnits, expected)
XCTAssertEqual(s?.judderPermille, 0)
}
}
/// D3's signature: the same mean spacing as a steady 2, delivered as alternating 1 and 3.
/// Identical average frame rate, identical latency percentiles this is the broken-looking one.
///
/// Also pins the TIE-BREAK. The histogram is 50/50 here, and Rust's `max_by_key` takes the
/// last maximum while Swift's `max(by:)` takes the first, so both sides spell the rule out:
/// ties resolve to the smallest spacing.
func testTheSawtoothThatLatencyStatsCannotSee() {
let s = cadence([Self.P, Self.P * 3], 40).summary()
XCTAssertEqual(s?.judderPermille, 500)
XCTAssertEqual(s?.modeUnits, 1, "a tied mode resolves to the smallest spacing")
}
/// Sub-refresh jitter is not judder: the display quantises it away, so the metric must too.
func testJitterInsideARefreshIsNotJudder() {
let s = cadence([Self.P + Self.P * 2 / 5, Self.P - Self.P * 2 / 5], 40).summary()
XCTAssertEqual(s?.modeUnits, 1)
XCTAssertEqual(s?.judderPermille, 0)
}
func testAStallIsCountedApartFromJudder() {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
pi.record(presentNs: t, periodNs: Self.P)
for _ in 0..<20 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
t += Self.P * 400 // a pause, not a pacing defect
pi.record(presentNs: t, periodNs: Self.P)
let s = pi.summary()
XCTAssertEqual(s?.judderPermille, 0)
XCTAssertEqual(s?.stalls, 1)
XCTAssertEqual(s?.samples, 20)
}
func testOutOfOrderCallbacksDoNotCorruptTheRun() {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
pi.record(presentNs: t, periodNs: Self.P)
for _ in 0..<10 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
pi.record(presentNs: t - Self.P * 3, periodNs: Self.P) // a late/duplicate delivery
for _ in 0..<10 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
let s = pi.summary()
XCTAssertEqual(s?.disordered, 1)
XCTAssertEqual(
s?.judderPermille, 0,
"keeping the later instant means the following spacings stay on the grid")
}
/// The on-glass failure of 2026-08-05, pinned on both sides. A render callback can deliver a
/// garbage far-future timestamp on a session's first frames; holding "the later instant"
/// unconditionally latched onto it and scored EVERY subsequent present as disordered for the
/// whole session. One bad sample must cost one sample.
func testAGarbageFarFutureStampDoesNotWedgeTheRun() {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
pi.record(presentNs: t, periodNs: Self.P)
pi.record(presentNs: t + 60 * 60 * 1_000_000_000, periodNs: Self.P)
for _ in 0..<20 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
let s = pi.summary()
XCTAssertEqual(s?.disordered, 1, "the garbage stamp cost exactly one sample")
XCTAssertEqual(s?.modeUnits, 1)
XCTAssertEqual(s?.judderPermille, 0)
XCTAssertEqual(s?.samples, 19, "every present after the re-anchor scored")
}
func testAnUnknownGridScoresNothing() {
var pi = PresentIntervals()
var t: Int64 = 1_000_000_000
for _ in 0..<60 {
t += Self.P
pi.record(presentNs: t, periodNs: 0) // no learned period yet
}
XCTAssertNil(pi.summary())
XCTAssertNotNil(cadence([Self.P], 60).summary(), "control")
}
func testAShortWindowPublishesNothing() {
XCTAssertNil(cadence([Self.P], 5).summary())
}
/// The cadence continues across a window boundary dropping the predecessor on drain would
/// silently discard one interval per window, every window.
func testTakeResetsTheCountsButNotTheCadence() {
var pi = cadence([Self.P], 20)
XCTAssertNotNil(pi.take())
XCTAssertNil(pi.summary(), "counts cleared")
var t: Int64 = 1_000_000_000 + Self.P * 20
for _ in 0..<10 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
XCTAssertEqual(
pi.summary()?.samples, 10,
"the first post-drain present scored against the pre-drain one")
}
func testSplitForgetsThePredecessor() {
var pi = cadence([Self.P], 20)
_ = pi.take()
pi.split()
var t: Int64 = 5_000_000_000 // a discontinuity: the gap across it is meaningless
for _ in 0..<10 {
t += Self.P
pi.record(presentNs: t, periodNs: Self.P)
}
let s = pi.summary()
XCTAssertEqual(s?.samples, 9)
XCTAssertEqual(s?.stalls, 0, "the gap was not scored at all")
}
}
+30
View File
@@ -30,6 +30,12 @@ const STALE_REOPEN_NS: u64 = 100_000_000;
pub(crate) const MARGIN_STEP_NS: u64 = 500_000;
pub(crate) const MARGIN_MAX_NS: u64 = 2_500_000;
/// Judder (‰ of present intervals off the modal spacing) that on its own justifies a 1 Hz
/// presenter line. A cadence defect produces no drops, no gate holds and healthy latency
/// percentiles, so it would otherwise stay silent until someone set the debug env var.
/// Occasional single-frame slips are normal; a twentieth of a window is not.
pub(crate) const JUDDER_LOG_PERMILLE: u16 = 50;
/// The decoded-frame store between the wake channel and the present call.
///
/// `capacity == 0` = newest-wins (latency intent): `submit` replaces, `take` clears.
@@ -183,6 +189,15 @@ pub(crate) struct LatchClock {
pending_count: u32,
grid: punktfunk_core::phase::PanelGrid,
fallback_period_ns: u64,
/// The cadence (judder) statistic — the only stat we publish that is not a latency,
/// and the only one that can see a pacing defect. Lives here because this is where
/// the on-glass stamps and the learned grid it quantises against already meet.
///
/// ⚠ These stamps are `CLOCK_REALTIME` (the module's domain), so a wall-clock step
/// would forge one hitch that never happened. It lands in the stall/disordered
/// counters rather than the judder ratio, which is why that split is worth having.
/// Android feeds the metric a raw monotonic stamp and has no such exposure.
intervals: punktfunk_core::phase::PresentIntervals,
}
/// Spacings per handoff to [`punktfunk_core::phase::PanelGrid`]. Small enough that a real
@@ -198,14 +213,29 @@ impl LatchClock {
pending_count: 0,
grid: punktfunk_core::phase::PanelGrid::seeded(refresh_hz as i32),
fallback_period_ns: 1_000_000_000 / u64::from(refresh_hz.max(1)),
intervals: punktfunk_core::phase::PresentIntervals::new(),
}
}
/// Drain the window's cadence summary — the 1 Hz stat boundary, beside the store and
/// gate counters.
pub(crate) fn take_cadence(&mut self) -> Option<punktfunk_core::phase::PresentCadence> {
self.intervals.take()
}
/// Fold on-glass stamps (ascending). Spacings are measured against the previous
/// stamp whatever the batching, so the loop's one-sample-per-pass drain still feeds
/// the learner.
pub(crate) fn note_batch(&mut self, stamps: &[u64]) {
// Seeded-or-learned, so cadence is scored from the first window rather than only
// once the learner has converged. Held for the batch: a mid-batch period change
// would requantise a handful of samples for no benefit.
let period_ns = self.period_ns() as i64;
for &s in stamps {
// Cadence sees EVERY stamp, including the sub-millisecond pairs the grid
// learner skips below: two presents inside one refresh is not a grid step,
// but it is very much a cadence event (it scores as a zero-refresh interval).
self.intervals.record(s as i64, period_ns);
if self.last_ns != 0 && s > self.last_ns {
let d = s - self.last_ns;
// < 1 ms apart = a queued pair, not a grid step.
+23 -2
View File
@@ -19,7 +19,8 @@
use crate::input::{Capture, FingerPhase};
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
use crate::present_pace::{
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, MARGIN_MAX_NS, MARGIN_STEP_NS,
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, JUDDER_LOG_PERMILLE, MARGIN_MAX_NS,
MARGIN_STEP_NS,
};
use crate::touch::Abs;
use crate::vk::{FrameInput, Presenter};
@@ -1821,6 +1822,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// a second `take_counters` would read zeros.
let (replaced, q_drop, q_dry) = st.store.take_counters();
let (gated, forced) = st.gate.take_counters();
let cadence = st.clock.take_cadence();
st.presented = PresentedWindow {
e2e_p50_ms: e2e_p50 as f32 / 1000.0,
e2e_p95_ms: e2e_p95 as f32 / 1000.0,
@@ -1834,6 +1836,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
q_dry,
gated,
forced,
judder_permille: cadence.map(|c| c.judder_permille).unwrap_or(0),
cadence_mode: cadence.map(|c| c.mode_units).unwrap_or(0),
};
st.win_e2e_us.clear();
st.win_disp_us.clear();
@@ -1855,7 +1859,14 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// The 1 Hz presenter line (the Apple `pf-present` analogue): emitted
// when anything moved, or always under PUNKTFUNK_PRESENT_DEBUG=1 —
// the field-triage instrument for the intent engine.
if pacing_active && (present_debug || q_drop + q_dry + gated + forced > 0) {
// Judder joins the "something moved" triggers deliberately: a cadence
// defect shows NO drops, NO gate holds and healthy percentiles, so
// without this a stream can judder visibly and never emit a line.
if pacing_active
&& (present_debug
|| q_drop + q_dry + gated + forced > 0
|| st.presented.judder_permille >= JUDDER_LOG_PERMILLE)
{
tracing::info!(
smoothing = st.presented.smoothing,
mode = st.presented.mode,
@@ -1871,6 +1882,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
latch_ms = st.presented.latch_ms,
period_us = st.clock.period_ns() / 1000,
margin_us = st.margin_ns / 1000,
judder_permille = st.presented.judder_permille,
cadence_mode = st.presented.cadence_mode,
"presenter window"
);
}
@@ -2368,6 +2381,14 @@ struct PresentedWindow {
q_dry: u32,
gated: u32,
forced: u32,
/// The cadence (judder) statistic — the fraction of present intervals (‰) that missed
/// the modal spacing, and that modal spacing in whole refreshes. Every other number
/// here is a latency and none of them can see a pacing defect: alternating 1 and 3
/// refreshes has the same mean rate as a steady 2, better latency percentiles, and
/// looks broken. `mode 0` = not enough evidence this window.
/// See [`punktfunk_core::phase::PresentIntervals`].
judder_permille: u16,
cadence_mode: u8,
}
/// The capture hints (`ui_stream` parity — the words the user reads while released).
+336
View File
@@ -129,6 +129,167 @@ pub fn circular_latch(samples_us: &[u64], period_ns: i64) -> Option<(u64, u16)>
Some((mean_ns, (r * 1000.0) as u16))
}
/// Largest present spacing still treated as cadence. Anything wider is a stall (a stream pause,
/// an occluded window, a codec rebuild) and is counted separately: folding a 5-second gap in as
/// "one irregular interval" would be true but useless, and folding it in as several would make a
/// single hitch dominate the window.
const CADENCE_MAX_UNITS: usize = 8;
/// A backwards step larger than this is not a reordered delivery, it is a bogus timestamp, and
/// the run re-anchors onto the new instant instead of holding the old one. Android's render
/// callback is documented to carry a garbage far-future stamp on a session's first frames;
/// without this bound, holding "the later instant" latches onto that stamp and every subsequent
/// present scores as disordered for the rest of the session (observed on glass, 2026-08-05).
const CADENCE_REANCHOR_NS: i64 = 100_000_000;
/// Minimum intervals before a cadence summary means anything — same evidence bar as
/// [`circular_latch`]. At any sane frame rate a 1 s window clears this many times over; it is
/// there so a window truncated by a reanchor does not publish a judder figure off three samples.
const CADENCE_MIN_SAMPLES: u32 = 8;
/// One window's present-cadence summary (see [`PresentIntervals`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PresentCadence {
/// The most common spacing, in whole panel refreshes. This is the stream's cadence ratio:
/// 1 when stream rate matches the panel, 2 for 60-on-120, 4 for 30-on-120.
pub mode_units: u8,
/// Fraction of intervals that were NOT the mode, in ‰ (same unit as the phase coherence).
/// **This is the judder number.** 0 = a perfectly regular cadence at any ratio.
pub judder_permille: u16,
/// Intervals folded into the histogram (excludes stalls and disordered samples).
pub samples: u32,
/// Spacings wider than [`CADENCE_MAX_UNITS`] — stalls, not judder. Reported so a window that
/// looks smooth *because the stream was paused* cannot be mistaken for a good one.
pub stalls: u32,
/// Present instants that did not advance (duplicate or out-of-order callbacks). A platform
/// bookkeeping signal, not a display defect — kept out of the judder ratio deliberately.
pub disordered: u32,
}
/// Present-interval distribution in whole panel refreshes — the cadence (judder) statistic.
///
/// Every other stat we publish is a latency: a difference between two points on one frame. No
/// latency can see judder, because judder is a property of the *sequence*. A stream that shows
/// each frame one refresh early and the next one late has excellent percentiles and looks
/// broken; a stream whose every interval is exactly two refreshes has worse latency than one
/// that alternates 1 and 3, and looks perfect. Quantising the spacing between consecutive
/// on-glass instants onto the panel grid measures the thing the eye actually reacts to.
///
/// Scale-free by construction: it needs no reference clock, and the *mode* absorbs the cadence
/// ratio, so 60-on-120 and 120-on-120 are both "smooth = one tall bucket" and comparable to each
/// other. That is what makes it usable as one ruler across clients, refresh rates and stream
/// rates — including for a feature-on/feature-off A/B on the same device.
///
/// Feed it the **measured on-glass instant**, never the instant a present was *requested*:
/// requested times would measure our own intent and report a perfect cadence no matter what the
/// display did with it. Every client has the real one (Android's `OnFrameRendered` system time,
/// the desktop's `VK_KHR_present_wait` stamp, Apple's drawable `presentedTime`).
///
/// Pure state and arithmetic — no clock, no allocation. The caller owns the window: drain with
/// [`take`](Self::take) on its own 1 s tumbling boundary, per `design/stats-unification.md`.
#[derive(Debug, Clone, Default)]
pub struct PresentIntervals {
last_present_ns: i64,
/// Counts indexed by whole refreshes, `0..=CADENCE_MAX_UNITS`.
hist: [u32; CADENCE_MAX_UNITS + 1],
samples: u32,
stalls: u32,
disordered: u32,
}
impl PresentIntervals {
pub fn new() -> PresentIntervals {
PresentIntervals::default()
}
/// Forget the previous instant without discarding the window's counts. Call on any
/// discontinuity where the next present is not a continuation of this cadence (reanchor,
/// codec rebuild, surface recreate) so the gap across it is not scored as a stall.
pub fn split(&mut self) {
self.last_present_ns = 0;
}
/// Fold one on-glass instant. `period_ns` is the learned panel period
/// ([`PanelGrid::period_ns`]); a non-positive one means the grid is not known yet and the
/// sample is held as the new predecessor without being scored.
pub fn record(&mut self, present_ns: i64, period_ns: i64) {
let prev = std::mem::replace(&mut self.last_present_ns, present_ns);
if prev <= 0 || period_ns <= 0 {
return; // first sample of a run, or no grid to quantise against
}
let spacing = present_ns - prev;
if spacing <= 0 {
// A repeated or out-of-order callback. Hold the LATER instant so one reordered
// delivery cannot corrupt every following spacing — but only when the step back is
// small enough to BE a reordering. Beyond that the old instant is the bogus one
// (see [`CADENCE_REANCHOR_NS`]) and the run re-anchors onto the new sample, which
// `last_present_ns` already holds.
self.disordered += 1;
if prev - present_ns < CADENCE_REANCHOR_NS {
self.last_present_ns = prev;
}
return;
}
// Round to the nearest whole refresh: a present is "on the grid" if it is closer to this
// vblank than the next, which is exactly what the display did with it.
let units = (spacing * 2 + period_ns) / (period_ns * 2);
if units as usize > CADENCE_MAX_UNITS {
self.stalls += 1;
return;
}
self.hist[units as usize] += 1;
self.samples += 1;
}
/// The window's raw counts `(samples, stalls, disordered)`, whatever the evidence bar.
///
/// [`summary`](Self::summary) returning `None` is otherwise indistinguishable from a window
/// of perfectly smooth zeros in a log line, which makes "no cadence is being scored at all"
/// invisible — the exact failure this exists to diagnose.
pub fn pending(&self) -> (u32, u32, u32) {
(self.samples, self.stalls, self.disordered)
}
/// This window's summary, or `None` under [`CADENCE_MIN_SAMPLES`].
pub fn summary(&self) -> Option<PresentCadence> {
if self.samples < CADENCE_MIN_SAMPLES {
return None;
}
// Ties resolve to the SMALLEST spacing, spelled out rather than left to a library:
// `max_by_key` would take the last maximum and Swift's `max(by:)` the first, so a
// 50/50 window (the classic 1-and-3 sawtooth) would label its mode differently on
// Android and Apple while reporting the same judder. The clients have to agree.
let mut mode_units = 0u8;
let mut mode_count = 0u32;
for (i, &c) in self.hist.iter().enumerate() {
if c > mode_count {
mode_count = c;
mode_units = i as u8;
}
}
Some(PresentCadence {
mode_units,
judder_permille: (u64::from(self.samples - mode_count) * 1000 / u64::from(self.samples))
as u16,
samples: self.samples,
stalls: self.stalls,
disordered: self.disordered,
})
}
/// Drain the window: the summary (if it clears the evidence bar) and a reset of the counts.
/// The previous instant SURVIVES the drain — the cadence continues across a window boundary,
/// and dropping it would manufacture one unscored interval per window.
pub fn take(&mut self) -> Option<PresentCadence> {
let out = self.summary();
self.hist = [0; CADENCE_MAX_UNITS + 1];
self.samples = 0;
self.stalls = 0;
self.disordered = 0;
out
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -294,3 +455,178 @@ mod panel_grid_tests {
assert_eq!(g.period_ns(), P120, "and the real grid wins it back");
}
}
#[cfg(test)]
mod cadence_tests {
use super::*;
const P: i64 = 8_333_333; // 120 Hz in ns
/// Fold `n` presents spaced by `spacings` in rotation, starting at an arbitrary instant.
fn cadence(spacings: &[i64], n: usize) -> PresentIntervals {
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
pi.record(t, P);
for i in 0..n {
t += spacings[i % spacings.len()];
pi.record(t, P);
}
pi
}
#[test]
fn a_regular_cadence_has_no_judder() {
let s = cadence(&[P], 60).summary().unwrap();
assert_eq!((s.mode_units, s.judder_permille), (1, 0));
assert_eq!(s.samples, 60);
}
/// The property that makes this one ruler across rates: a stream at half the panel rate is
/// SMOOTH, not judder — the mode absorbs the cadence ratio.
fn ratio_is_absorbed_not_penalised(mult: i64, expect_units: u8) {
let s = cadence(&[P * mult], 40).summary().unwrap();
assert_eq!((s.mode_units, s.judder_permille), (expect_units, 0));
}
#[test]
fn sixty_on_onetwenty_reads_smooth() {
ratio_is_absorbed_not_penalised(2, 2); // 60 fps on a 120 Hz panel
ratio_is_absorbed_not_penalised(4, 4); // 30 fps on a 120 Hz panel
}
/// D3's signature: the same mean spacing as `sixty_on_onetwenty_reads_smooth`, delivered as
/// alternating 1 and 3 refreshes. Identical average frame rate, identical latency
/// percentiles — and this is the one that looks broken.
#[test]
fn the_sawtooth_that_latency_stats_cannot_see() {
let s = cadence(&[P, P * 3], 40).summary().unwrap();
assert_eq!(s.judder_permille, 500);
assert_eq!(
s.mode_units, 1,
"a tied mode resolves to the smallest spacing — pinned so the Swift port agrees"
);
}
/// Sub-refresh jitter is not judder: the display quantises it away, so the metric must too.
/// Only a spacing that crosses the half-refresh boundary changes which vblank was used.
#[test]
fn jitter_inside_a_refresh_is_not_judder() {
let s = cadence(&[P + P * 2 / 5, P - P * 2 / 5], 40)
.summary()
.unwrap();
assert_eq!((s.mode_units, s.judder_permille), (1, 0));
}
#[test]
fn a_stall_is_counted_apart_from_judder() {
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
pi.record(t, P);
for _ in 0..20 {
t += P;
pi.record(t, P);
}
t += P * 400; // a pause, not a pacing defect
pi.record(t, P);
let s = pi.summary().unwrap();
assert_eq!((s.judder_permille, s.stalls, s.samples), (0, 1, 20));
}
#[test]
fn out_of_order_callbacks_do_not_corrupt_the_run() {
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
pi.record(t, P);
for _ in 0..10 {
t += P;
pi.record(t, P);
}
pi.record(t - P * 3, P); // a late/duplicate delivery
for _ in 0..10 {
t += P;
pi.record(t, P);
}
let s = pi.summary().unwrap();
assert_eq!(s.disordered, 1);
assert_eq!(
s.judder_permille, 0,
"keeping the later instant means the following spacings stay on the grid"
);
}
/// The on-glass failure of 2026-08-05, pinned. Android's render callback can deliver a
/// garbage far-future timestamp on a session's first frames. Holding "the later instant"
/// unconditionally latched onto it and scored EVERY subsequent present as disordered —
/// `cadN=0 disorder=119` per second, for the whole session, with the period known and the
/// stream perfectly healthy. One bad sample must cost one sample, not the session.
#[test]
fn a_garbage_far_future_stamp_does_not_wedge_the_run() {
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
pi.record(t, P);
pi.record(t + 60 * 60 * 1_000_000_000, P); // a vendor's epoch-sized first stamp
for _ in 0..20 {
t += P;
pi.record(t, P);
}
let s = pi.summary().expect("the run recovers instead of wedging");
assert_eq!(s.disordered, 1, "the garbage stamp cost exactly one sample");
assert_eq!((s.mode_units, s.judder_permille), (1, 0));
assert_eq!(s.samples, 19, "every present after the re-anchor scored");
}
#[test]
fn an_unknown_grid_scores_nothing() {
let s = cadence(&[P], 60);
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
for _ in 0..60 {
t += P;
pi.record(t, 0); // PanelGrid has not learned a period yet
}
assert!(pi.summary().is_none());
assert!(s.summary().is_some(), "control");
}
#[test]
fn a_short_window_publishes_nothing() {
assert!(cadence(&[P], 5).summary().is_none());
}
/// The cadence continues across a window boundary — dropping the predecessor on drain would
/// silently discard one interval per window, every window.
#[test]
fn take_resets_the_counts_but_not_the_cadence() {
let mut pi = cadence(&[P], 20);
assert!(pi.take().is_some());
assert!(pi.summary().is_none(), "counts cleared");
let mut t = 1_000_000_000 + P * 20;
for _ in 0..10 {
t += P;
pi.record(t, P);
}
let s = pi.summary().unwrap();
assert_eq!(
s.samples, 10,
"the first post-drain present scored against the pre-drain one"
);
}
#[test]
fn split_forgets_the_predecessor() {
let mut pi = cadence(&[P], 20);
pi.take();
pi.split();
let mut t = 5_000_000_000i64; // a reanchor: the gap across it is meaningless
for _ in 0..10 {
t += P;
pi.record(t, P);
}
let s = pi.summary().unwrap();
assert_eq!(
(s.samples, s.stalls),
(9, 0),
"the gap was not scored at all"
);
}
}
+20 -1
View File
@@ -10,6 +10,18 @@
set -euo pipefail
cd "$(dirname "$0")/.."
# Opus must be built FROM SOURCE, never picked up from the machine. `audiopus_sys` probes
# pkg-config first, and a Homebrew libopus is compiled for the HOST macOS — its objects land
# inside our staticlib carrying that minos (the deployment-target check at the end of this
# script then fails with 143 SILK objects at the host's version). Whether the bundle is
# usable would otherwise depend on whether the developer happens to have `brew install opus`,
# which is exactly the kind of thing an artifact consumed by every Apple build must not
# depend on. `OPUS_NO_PKG_CONFIG` forces the vendored build; the CMake policy floor is for
# that vendored copy, whose CMakeLists still declares a pre-3.5 minimum that CMake 4 removed
# support for.
export OPUS_NO_PKG_CONFIG=1
export CMAKE_POLICY_VERSION_MINIMUM="${CMAKE_POLICY_VERSION_MINIMUM:-3.5}"
TARGETS_MAC=(aarch64-apple-darwin x86_64-apple-darwin)
BUILD_IOS="${BUILD_IOS:-0}" # BUILD_IOS=1 adds iOS device + simulator slices (rustup targets aarch64-apple-ios{,-sim})
BUILD_TVOS="${BUILD_TVOS:-0}" # BUILD_TVOS=1 adds tvOS slices — TIER-3 Rust targets: needs `rustup toolchain install nightly` + `rustup component add rust-src --toolchain nightly`
@@ -125,7 +137,14 @@ for obj in "$STAGE"/macos/libpunktfunk_core.a; do
bad=$(otool -l "$obj" 2>/dev/null | awk '/minos/ {print $2}' | sort -uV | awk -F. '$1 > 14' | head -1)
if [[ -n "$bad" ]]; then
echo "ERROR: $obj contains objects built for macOS $bad (> 14.0)." >&2
echo "Stale cache — rm -rf target/{aarch64,x86_64}-apple-darwin and rebuild." >&2
echo "Two known causes:" >&2
echo " 1. A system libopus linked instead of the vendored one (check the build" >&2
echo " script output for a /opt/homebrew or /usr/local link-search path). This" >&2
echo " script exports OPUS_NO_PKG_CONFIG=1 to prevent it — if you see it anyway," >&2
echo " something overrode that." >&2
echo " 2. A stale cache: cargo does not fingerprint MACOSX_DEPLOYMENT_TARGET." >&2
echo " rm -rf target/{aarch64,x86_64}-apple-darwin and rebuild." >&2
echo "Identify the offenders with: ar x $obj && otool -l *.o | grep -B1 minos" >&2
exit 1
fi
done