diff --git a/crates/pf-presenter/src/present_pace.rs b/crates/pf-presenter/src/present_pace.rs index a853551c..31a6621b 100644 --- a/crates/pf-presenter/src/present_pace.rs +++ b/crates/pf-presenter/src/present_pace.rs @@ -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 { + 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. diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index b91127ab..c4576f4b 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -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 // 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 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 // 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 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).