Files
punktfunk/crates/pf-frame/src/metronome.rs
T
enricobuehler b168790e0a refactor(host/W6.2): extract the shared frame/format vocabulary into the pf-frame leaf crate
The captured-frame types both capture (producer) and encode (consumer) speak —
PixelFormat, OutputFormat, CursorOverlay, CapturedFrame, FramePayload,
DmabufFrame, drm_fourcc — move into crates/pf-frame, alongside the small pure
helpers that ride the same seam: hdr (HDR static metadata / in-band SEI),
metronome (the metronomic-stall detector), thread_qos (per-thread scheduling
QoS), session_tuning (Windows process tuning), and the Windows DXGI capture
IDENTITY (WinCaptureTarget, D3d11Frame, pack_luid, make_device + the GPU
scheduling-priority hardening it applies) (plan §W6).

This is the crate that breaks the capture<->encode cycle: FramePayload's GPU
variants own their backends from BELOW (Cuda -> pf_zerocopy::DeviceBuffer,
D3d11 -> dxgi::D3d11Frame), so encode can speak the vocabulary without a path to
capture, and vice versa. The Windows DXGI identity moving here lets capture,
encode, and pf-vdisplay share ONE WinCaptureTarget/device factory instead of the
old capture<->encode<->vdisplay reach-in.

The host keeps thin facades: capture.rs re-exports the vocabulary
(crate::capture::{PixelFormat,…} unchanged); capture/windows/dxgi.rs keeps the
win32u GPU-preference hook + HDR/video-engine converters + self-test and
re-exports the identity; native.rs re-exports boost_thread_priority from
pf_frame. crate::hdr/metronome/session_tuning callers rewired to pf_frame::*.
metronome's Metronome::new gained a Default impl (new_without_default fires once
the type is public across the crate boundary).

Verified: Linux clippy -D warnings (pf-frame --all-targets + host
nvenc,vulkan-encode,pyrowave --all-targets) + 9/9 pf-frame tests; Windows clippy
nvenc,amf-qsv --all-targets Finished exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:03:56 +02:00

153 lines
5.5 KiB
Rust

//! Detector for METRONOMIC event cycles — evenly-spaced disturbances repeating every few seconds.
//!
//! The "periodic double-jolt" symptom class field reports keep describing is a host/display-side
//! disturbance on a stable multi-second period (display-topology churn, display-poller software,
//! virtual-display present timing). Random network loss is bursty and irregular; a stable period is
//! a machine, and saying so in the host log turns a "nothing in the logs :/" report into a
//! self-diagnosis. Two feeds today: served client-recovery IDRs (`native`) and IDD-push capture
//! stalls (`capture::windows::idd_push`).
use std::collections::VecDeque;
use std::time::{Duration, Instant};
/// Pure evenly-spaced-events detector (unit-tested below).
///
/// Events within [`Self::COALESCE`] count as ONE (a double-jolt's paired disturbances — e.g. the
/// cooldown re-issue of a lost keyframe ~0.7 s after the first — are one user-visible cycle). When
/// the gaps between the last [`Self::STREAK`] events are all within ±[`Self::TOLERANCE`] of their
/// mean, [`Self::note`] returns the mean period for the caller to warn with, then stays quiet for
/// [`Self::REWARN`] while the cycle persists.
#[derive(Default)]
pub struct Metronome {
events: VecDeque<Instant>,
last_warn: Option<Instant>,
}
impl Metronome {
/// Events closer together than this are the same user-visible disturbance.
const COALESCE: Duration = Duration::from_millis(1500);
/// Consecutive evenly-spaced events before the cycle counts as metronomic.
const STREAK: usize = 4;
/// "Evenly spaced" = every gap within this fraction of the mean gap.
const TOLERANCE: f64 = 0.2;
/// Once warned, re-warn at most this often while the cycle persists.
const REWARN: Duration = Duration::from_secs(30);
pub fn new() -> Self {
Self {
events: VecDeque::new(),
last_warn: None,
}
}
/// Record a disturbance at `now`; `Some(mean period)` exactly when the metronomic-cycle
/// warning should fire.
pub fn note(&mut self, now: Instant) -> Option<Duration> {
if self
.events
.back()
.is_some_and(|last| now.duration_since(*last) < Self::COALESCE)
{
return None;
}
self.events.push_back(now);
if self.events.len() > Self::STREAK {
self.events.pop_front();
}
if self.events.len() < Self::STREAK {
return None;
}
let gaps: Vec<f64> = self
.events
.iter()
.zip(self.events.iter().skip(1))
.map(|(a, b)| b.duration_since(*a).as_secs_f64())
.collect();
let mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
if mean <= 0.0
|| gaps
.iter()
.any(|g| (g - mean).abs() > mean * Self::TOLERANCE)
{
return None;
}
if self
.last_warn
.is_some_and(|t| now.duration_since(t) < Self::REWARN)
{
return None;
}
self.last_warn = Some(now);
Some(Duration::from_secs_f64(mean))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Feed a [`Metronome`] a schedule of event offsets (ms from a common origin) and return
/// what each `note` produced.
fn cadence_run(offsets_ms: &[u64]) -> Vec<Option<Duration>> {
let base = Instant::now();
let mut c = Metronome::new();
offsets_ms
.iter()
.map(|ms| c.note(base + Duration::from_millis(*ms)))
.collect()
}
#[test]
fn cadence_detects_metronomic_events() {
// Four events ~4 s apart (±5%) → the fourth trips the detector at ~4 s.
let out = cadence_run(&[0, 4_000, 8_100, 11_950]);
assert_eq!(out[..3], [None, None, None]);
let period = out[3].expect("metronomic series must be detected");
assert!(
(period.as_secs_f64() - 3.98).abs() < 0.2,
"period={period:?}"
);
}
#[test]
fn cadence_coalesces_double_jolt_pairs() {
// The field signature: a jolt pair (second event ~0.7 s after the first, e.g. the IDR
// cooldown re-issue) every ~4 s. Each pair is ONE event; detection still lands on the
// ~4 s cycle.
let out = cadence_run(&[
0, 700, // pair 1
4_000, 4_700, // pair 2
8_000, 8_650, // pair 3
12_000, // pair 4 (first event trips it)
]);
assert!(out[..6].iter().all(Option::is_none));
let period = out[6].expect("coalesced pairs must still read as a 4 s cycle");
assert!(
(period.as_secs_f64() - 4.0).abs() < 0.2,
"period={period:?}"
);
}
#[test]
fn cadence_ignores_irregular_bursts() {
// Genuine Wi-Fi-style loss: irregular gaps → never flagged.
assert!(cadence_run(&[0, 2_000, 9_000, 12_500, 21_000])
.iter()
.all(Option::is_none));
}
#[test]
fn cadence_rewarns_at_most_every_30s() {
// A persisting 4 s cycle: warn on the 4th event (t=12 s), then stay quiet until ≥30 s
// past the warn — the t=44 s event (index 11) is the first at or beyond t=42 s.
let offsets: Vec<u64> = (0..12).map(|i| i * 4_000).collect();
let out = cadence_run(&offsets);
let warned: Vec<usize> = out
.iter()
.enumerate()
.filter_map(|(i, o)| o.map(|_| i))
.collect();
assert_eq!(warned, vec![3, 11], "warn indices");
}
}