Skip to main content

rlx_core/diag/
mod.rs

1//! Runtime diagnostics: rolling frame-time statistics, core-tracked GPU-byte
2//! accounting, and the flags that gate the on-screen overlay (Plan 0011).
3//!
4//! Two pieces live here, deliberately split so the math is testable without a
5//! clock (NFR 6 determinism):
6//!
7//! - [`FrameStats`] is a **pure** accumulator: it is fed explicit frame deltas
8//!   and computes fps / average / p99 from a fixed-capacity ring. No clock, no
9//!   allocation — its unit tests carry no wall-clock read.
10//! - [`Diag`] wraps it with the single **gated monotonic clock read** on the
11//!   render path. That read is the one place `core` touches the wall clock; it
12//!   is quarantined here (it never feeds DSP or scene animation, which stay a
13//!   pure function of the input window + fixed step) and only runs while
14//!   collection is enabled. See the risk note in Plan 0011.
15
16// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to `diag` by Plan
17// 0011). Runs every displayed frame; a panic here is a visible crash mid-show.
18#![deny(
19    clippy::unwrap_used,
20    clippy::expect_used,
21    clippy::indexing_slicing,
22    clippy::panic,
23    clippy::unreachable
24)]
25
26use std::time::Instant;
27
28use crate::dsp::AnalysisFrame;
29
30/// The downbeat estimator's per-beat decomposition, re-exported here because
31/// **this** is the module a native shell reaches diagnostics through (Plan 0086
32/// Phase 1).
33///
34/// It is defined beside the estimator it describes, and it stays there: nothing
35/// about its shape is a diagnostics concern. What this line adds is the same
36/// boundary statement [`AnalysisMetrics`] makes as a type — analysis diagnostics
37/// are **native-only** (ADR-0052), so a consumer that finds them here has already
38/// been told they do not cross the C ABI. Read it with
39/// [`Analyzer::downbeat_terms`](crate::dsp::Analyzer::downbeat_terms).
40pub use crate::dsp::downbeat::DownbeatTerms;
41
42/// Recent frame durations retained for the rolling stats. 240 samples is ~4 s
43/// at 60 fps — long enough for a stable p99, short enough to react to a stall.
44///
45/// **Lowering this is not a free tuning.** [`samples`](FrameStats::samples) is
46/// what feeds the quality governor, so this value is also the longest series
47/// [`sustained_miss`](crate::render::tier::sustained_miss) can ever be handed —
48/// and below its
49/// [`MIN_SAMPLES`](crate::render::tier::MIN_SAMPLES) the governor would stop
50/// demoting entirely, silently. `pub(crate)` so that coupling is checked by a
51/// compile-time assertion over there rather than left to whoever edits this line.
52pub(crate) const RING: usize = 240;
53
54/// A snapshot of the current diagnostics, mirroring the C ABI `RlxMetrics`
55/// struct (ADR-0008) on the native side so both frontends surface identical
56/// numbers from one computation.
57#[derive(Clone, Copy, Debug, Default, PartialEq)]
58pub struct Metrics {
59    /// Frames per second over the retained window.
60    pub fps: f32,
61    /// Mean frame time over the window, in milliseconds.
62    pub frame_ms_avg: f32,
63    /// 99th-percentile frame time over the window, in milliseconds.
64    pub frame_ms_p99: f32,
65    /// Frames recorded since creation (monotonic).
66    pub frames_total: u64,
67    /// Frames the renderer skipped (surface acquire returned nothing).
68    pub frames_dropped: u64,
69    /// Core-tracked GPU resource bytes — an approximation (wgpu does not report
70    /// driver device memory), dominated by the swapchain. A trend indicator.
71    pub gpu_bytes: u64,
72    /// Draw/render-pass calls issued on the last frame.
73    pub draw_calls: u32,
74}
75
76/// A snapshot of the **analysis** side of diagnostics: what the audio is doing,
77/// as against [`Metrics`]'s what the renderer is doing.
78///
79/// # Why this is a second struct rather than six more fields on `Metrics`
80///
81/// [`Metrics`] claims, in its doc comment, to mirror the C ABI's `RlxMetrics`.
82/// Growing it would either widen the C ABI or quietly make that claim false, and
83/// ADR-0052 chose neither: these values stay **native-only**, so the split makes
84/// "does not cross the ABI" a property of the type instead of a comment on a
85/// field that a later plan has no reason to read. It follows the standing line
86/// rather than inventing one — no analysis value has ever crossed the boundary
87/// (`novelty` is already marked native-API-only on the frame itself).
88///
89/// The named cost, from the same ADR: the foobar plugin gets no analysis
90/// diagnostics **programmatically** — no `rlx_get_metrics` counterpart, so
91/// nothing on that path can compute a lock rate — and it is the one frontend
92/// that never touches loopback capture. It does get them **on screen**: this
93/// overlay is core-drawn, so a host setting `RLX_DEBUG_OVERLAY` paints the same
94/// six rows the standalone's `F3` does (Plan 0049 close review corrected
95/// ADR-0052 on that point; see its Outcome section). If the estimator ever needs
96/// the machine-readable half on that path, `RlxMetrics` still leads with
97/// `struct_size`, so the growth is available — behind a superseding ADR.
98///
99/// Exactly the six values Plan 0048 Phase 6 needs to make its two judgements:
100/// whether the normalized levels ride the music, and whether the downbeat
101/// estimator locks. Nothing speculative — the `*_raw` twins, `bpm`, `bar` and
102/// `novelty` stay off until something asks.
103#[derive(Clone, Copy, Debug, Default, PartialEq)]
104pub struct AnalysisMetrics {
105    /// Bass level, normalized against its recent peak (ADR-0049).
106    pub bass: f32,
107    /// Mid level, normalized against its recent peak.
108    pub mid: f32,
109    /// Treble level, normalized against its recent peak.
110    pub treb: f32,
111    /// Onset envelope, normalized against its recent peak.
112    pub onset: f32,
113    /// The downbeat estimator's confidence in `0..1` (ADR-0050).
114    pub downbeat_confidence: f32,
115    /// Whether the bar position came from the estimator rather than the counter
116    /// fallback. **This is the value ADR-0050's stopping condition needs:** from
117    /// outside the app a wrong lock and the fallback look identical.
118    pub downbeat_locked: bool,
119}
120
121impl From<&AnalysisFrame> for AnalysisMetrics {
122    fn from(frame: &AnalysisFrame) -> Self {
123        Self {
124            bass: frame.bass,
125            mid: frame.mid,
126            treb: frame.treb,
127            onset: frame.onset,
128            downbeat_confidence: frame.downbeat_confidence,
129            downbeat_locked: frame.downbeat_locked,
130        }
131    }
132}
133
134/// Pure rolling frame-time accumulator. Fed explicit deltas (seconds); holds no
135/// clock, so it is fully unit-testable. Fixed capacity — no per-frame alloc.
136pub struct FrameStats {
137    ring: [f32; RING],
138    /// Next write position. Entries are written sequentially and wrap; the
139    /// valid set is the first `len` slots (before wrap) or all of them (after),
140    /// so `ring.iter().take(len)` always yields exactly the retained samples.
141    head: usize,
142    len: usize,
143    frames_total: u64,
144    frames_dropped: u64,
145}
146
147impl Default for FrameStats {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153impl FrameStats {
154    /// An empty accumulator.
155    pub fn new() -> Self {
156        Self {
157            ring: [0.0; RING],
158            head: 0,
159            len: 0,
160            frames_total: 0,
161            frames_dropped: 0,
162        }
163    }
164
165    /// Record one frame's duration (seconds) into the ring and bump the total.
166    pub fn record(&mut self, dt_secs: f32) {
167        if let Some(slot) = self.ring.get_mut(self.head) {
168            *slot = dt_secs;
169        }
170        self.head = (self.head + 1) % RING;
171        if self.len < RING {
172            self.len += 1;
173        }
174        self.frames_total = self.frames_total.saturating_add(1);
175    }
176
177    /// Count a frame the renderer could not present (surface acquire skip).
178    pub fn record_dropped(&mut self) {
179        self.frames_dropped = self.frames_dropped.saturating_add(1);
180    }
181
182    /// Total sum of the retained durations (seconds).
183    fn sum(&self) -> f32 {
184        self.ring.iter().take(self.len).sum()
185    }
186
187    /// Frames per second over the retained window (0 until the first sample).
188    pub fn fps(&self) -> f32 {
189        let sum = self.sum();
190        if self.len == 0 || sum <= 0.0 {
191            return 0.0;
192        }
193        self.len as f32 / sum
194    }
195
196    /// Mean frame time over the window, in milliseconds.
197    pub fn frame_ms_avg(&self) -> f32 {
198        if self.len == 0 {
199            return 0.0;
200        }
201        self.sum() / self.len as f32 * 1000.0
202    }
203
204    /// 99th-percentile frame time over the window, in milliseconds.
205    pub fn frame_ms_p99(&self) -> f32 {
206        self.frame_ms_percentile(0.99)
207    }
208
209    /// **Median** frame time over the window, in milliseconds.
210    ///
211    /// The pair with [`frame_ms_p99`](Self::frame_ms_p99) is what makes a
212    /// frame-time reading legible: the median says what a typical frame costs and
213    /// the p99 says what the worst ones do, and a mean sits between them saying
214    /// neither. Native-only, like the analysis snapshot beside it — it is not on
215    /// [`Metrics`], which mirrors the C ABI's `RlxMetrics` and cannot grow a
216    /// field without widening that surface (ADR-0052's split).
217    pub fn frame_ms_p50(&self) -> f32 {
218        self.frame_ms_percentile(0.5)
219    }
220
221    /// The frame time at quantile `q` over the window, in milliseconds. Copies
222    /// the retained samples into a fixed local buffer and sorts — no allocation.
223    ///
224    /// **Nearest-rank**, `index = round(q * (n - 1))`: with a window of a few
225    /// hundred samples an interpolating definition moves the answer by less than
226    /// the measurement's own noise, and this one always returns a sample that
227    /// actually occurred.
228    fn frame_ms_percentile(&self, q: f32) -> f32 {
229        if self.len == 0 {
230            return 0.0;
231        }
232        let mut buf = [0.0f32; RING];
233        let mut n = 0usize;
234        for (dst, src) in buf.iter_mut().zip(self.ring.iter().take(self.len)) {
235            *dst = *src;
236            n += 1;
237        }
238        let Some(slice) = buf.get_mut(..n) else {
239            return 0.0;
240        };
241        slice.sort_by(f32::total_cmp);
242        let idx = ((n - 1) as f32 * q.clamp(0.0, 1.0)).round() as usize;
243        slice.get(idx).copied().unwrap_or(0.0) * 1000.0
244    }
245
246    /// Frames recorded since creation.
247    pub fn frames_total(&self) -> u64 {
248        self.frames_total
249    }
250
251    /// Frames the renderer skipped.
252    pub fn frames_dropped(&self) -> u64 {
253        self.frames_dropped
254    }
255}
256
257/// The render-side diagnostics state: the pure [`FrameStats`], the gated clock,
258/// the GPU-byte / draw-call figures, and the two flags that control collection
259/// and the on-screen overlay.
260pub struct Diag {
261    /// While true, [`Diag::record_frame`] reads the monotonic clock and feeds
262    /// the delta to `stats`. The standalone leaves this on so the title always
263    /// shows live fps/p99; a host can turn it off to stay fully clock-free.
264    collecting: bool,
265    /// While true, the renderer paints the debug overlay as a final pass. Off by
266    /// default — a live show is clean until a key/menu/env turns it on.
267    overlay: bool,
268    stats: FrameStats,
269    /// Timestamp of the last presented frame; `None` after a toggle or a drop so
270    /// the next delta is not inflated by the gap.
271    last: Option<Instant>,
272    gpu_bytes: u64,
273    draw_calls: u32,
274    /// The latest frame's analysis snapshot. Held here rather than recomputed on
275    /// read so the overlay and the 1 Hz logger see the same six values, and so
276    /// the logger's sampling stays a plain field read on the frames a line is
277    /// due. Not gated by `collecting`: it holds no clock, and the overlay is
278    /// independently toggled.
279    analysis: AnalysisMetrics,
280}
281
282impl Default for Diag {
283    fn default() -> Self {
284        Self::new()
285    }
286}
287
288impl Diag {
289    /// A fresh diagnostics state: not collecting, overlay off.
290    pub fn new() -> Self {
291        Self {
292            collecting: false,
293            overlay: false,
294            stats: FrameStats::new(),
295            last: None,
296            gpu_bytes: 0,
297            draw_calls: 0,
298            analysis: AnalysisMetrics::default(),
299        }
300    }
301
302    /// Enable or disable rolling frame-time collection (the gated clock read).
303    /// Toggling resets the delta chain so a re-enable does not record a spurious
304    /// long frame across the disabled gap.
305    pub fn set_collecting(&mut self, on: bool) {
306        self.collecting = on;
307        self.last = None;
308    }
309
310    /// Whether frame-time collection is currently enabled.
311    pub fn collecting(&self) -> bool {
312        self.collecting
313    }
314
315    /// Enable or disable painting the on-screen overlay.
316    pub fn set_overlay(&mut self, on: bool) {
317        self.overlay = on;
318    }
319
320    /// Whether the overlay should be painted this frame.
321    pub fn overlay_enabled(&self) -> bool {
322        self.overlay
323    }
324
325    /// Record a presented frame. Reads the monotonic clock (only while
326    /// collecting) and feeds the delta since the previous present to the stats.
327    pub fn record_frame(&mut self) {
328        if !self.collecting {
329            return;
330        }
331        // The one wall-clock read in `core`. Quarantined to diagnostics: it never
332        // feeds DSP or scene animation (those stay a pure function of the input
333        // window + fixed step), so NFR 6 determinism holds. See Plan 0011 risks.
334        #[allow(
335            clippy::disallowed_methods,
336            reason = "diagnostics-only monotonic read, gated behind `collecting`, never feeds analysis or visual output (NFR 6 carve-out)"
337        )]
338        let now = Instant::now();
339        if let Some(last) = self.last {
340            self.stats
341                .record(now.saturating_duration_since(last).as_secs_f32());
342        }
343        self.last = Some(now);
344    }
345
346    /// Count a frame the renderer could not present. Breaks the delta chain so
347    /// the next present's delta excludes the skipped interval.
348    pub fn record_dropped(&mut self) {
349        if !self.collecting {
350            return;
351        }
352        self.stats.record_dropped();
353        self.last = None;
354    }
355
356    /// Set the core-tracked GPU resource byte estimate (fed from the render
357    /// context each frame — dominated by the swapchain).
358    pub fn set_gpu_bytes(&mut self, bytes: u64) {
359        self.gpu_bytes = bytes;
360    }
361
362    /// Set the draw/render-pass count issued on the last frame.
363    pub fn set_draw_calls(&mut self, n: u32) {
364        self.draw_calls = n;
365    }
366
367    /// The current rolling snapshot.
368    /// Median frame time over the window, in milliseconds — the native-only
369    /// half of the frame-time pair (see [`FrameStats::frame_ms_p50`]).
370    pub fn frame_ms_p50(&self) -> f32 {
371        self.stats.frame_ms_p50()
372    }
373
374    /// The current rolling snapshot.
375    pub fn metrics(&self) -> Metrics {
376        Metrics {
377            fps: self.stats.fps(),
378            frame_ms_avg: self.stats.frame_ms_avg(),
379            frame_ms_p99: self.stats.frame_ms_p99(),
380            frames_total: self.stats.frames_total(),
381            frames_dropped: self.stats.frames_dropped(),
382            gpu_bytes: self.gpu_bytes,
383            draw_calls: self.draw_calls,
384        }
385    }
386
387    /// Take this frame's analysis snapshot. Called once per drawn frame from the
388    /// render seam, which is the one place that holds the [`AnalysisFrame`].
389    pub fn set_analysis(&mut self, frame: &AnalysisFrame) {
390        self.analysis = AnalysisMetrics::from(frame);
391    }
392
393    /// The latest analysis snapshot — the native-only companion to
394    /// [`metrics`](Diag::metrics). See [`AnalysisMetrics`] for why it is separate.
395    pub fn analysis(&self) -> AnalysisMetrics {
396        self.analysis
397    }
398
399    /// Read-only view of the rolling stats (the overlay reads this to draw the
400    /// frame-time sparkline).
401    pub fn stats(&self) -> &FrameStats {
402        &self.stats
403    }
404}
405
406impl FrameStats {
407    /// The retained frame durations (seconds), oldest first, for the overlay's
408    /// sparkline. Borrows the ring in chronological order without allocating.
409    pub fn samples(&self) -> impl Iterator<Item = f32> + '_ {
410        // Before wrap the valid slots are 0..len; after wrap they are the whole
411        // ring starting at `head` (the oldest). Chain the two halves so the
412        // iterator is chronological in both cases.
413        let (full, head, len) = (self.len == RING, self.head, self.len);
414        let (a, b) = if full {
415            (self.ring.get(head..), self.ring.get(..head))
416        } else {
417            (self.ring.get(..len), None)
418        };
419        a.into_iter()
420            .flatten()
421            .chain(b.into_iter().flatten())
422            .copied()
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    /// The pure accumulator computes fps, average, and p99 from a known
431    /// sequence with no clock in the loop (NFR 6 determinism).
432    #[test]
433    fn frame_stats_computes_known_sequence() {
434        let mut stats = FrameStats::new();
435        // 100 frames of 1..=100 ms. Sum = 5050 ms = 5.05 s.
436        for ms in 1..=100u32 {
437            stats.record(ms as f32 / 1000.0);
438        }
439        assert_eq!(stats.frames_total(), 100);
440
441        // fps = 100 frames / 5.05 s = 19.8019...
442        assert!(
443            (stats.fps() - 19.801_98).abs() < 1e-2,
444            "fps was {}",
445            stats.fps()
446        );
447        // avg = mean(1..=100) ms = 50.5 ms.
448        assert!(
449            (stats.frame_ms_avg() - 50.5).abs() < 1e-3,
450            "avg was {}",
451            stats.frame_ms_avg()
452        );
453        // Sorted [1..=100]; nearest-rank index = round(0.99 * 99) = 98 → 99 ms.
454        assert!(
455            (stats.frame_ms_p99() - 99.0).abs() < 1e-3,
456            "p99 was {}",
457            stats.frame_ms_p99()
458        );
459    }
460
461    /// An empty accumulator reports zeros, not NaN or a panic.
462    #[test]
463    fn frame_stats_empty_is_zero() {
464        let stats = FrameStats::new();
465        assert_eq!(stats.fps(), 0.0);
466        assert_eq!(stats.frame_ms_avg(), 0.0);
467        assert_eq!(stats.frame_ms_p99(), 0.0);
468    }
469
470    /// The ring retains only the most recent RING samples once it wraps.
471    #[test]
472    fn frame_stats_ring_wraps_to_recent() {
473        let mut stats = FrameStats::new();
474        // Fill with 5 ms, then overwrite the whole ring with 20 ms.
475        for _ in 0..RING {
476            stats.record(0.005);
477        }
478        for _ in 0..RING {
479            stats.record(0.020);
480        }
481        assert_eq!(stats.frames_total(), (RING * 2) as u64);
482        // Only the 20 ms samples remain → avg 20 ms, fps 50.
483        assert!((stats.frame_ms_avg() - 20.0).abs() < 1e-3);
484        assert!((stats.fps() - 50.0).abs() < 1e-2);
485    }
486
487    /// The six values are the frame's own, taken from a synthesized frame rather
488    /// than eyeballed on screen — and the destructure is exhaustive, so a field
489    /// added to `AnalysisMetrics` fails to compile until it is populated here.
490    #[test]
491    fn analysis_metrics_are_the_frames_own_values() {
492        let frame = AnalysisFrame {
493            bass: 0.25,
494            mid: 0.5,
495            treb: 0.75,
496            onset: 0.125,
497            downbeat_confidence: 0.875,
498            downbeat_locked: true,
499            // Values that must NOT leak into the six — the raw twins carry
500            // deliberately different magnitudes so a mixed-up field is visible.
501            bass_raw: 9.0,
502            mid_raw: 9.0,
503            treb_raw: 9.0,
504            onset_raw: 9.0,
505            ..Default::default()
506        };
507
508        let mut diag = Diag::new();
509        assert_eq!(
510            diag.analysis(),
511            AnalysisMetrics::default(),
512            "a fresh Diag reports zeros, not stale or uninitialized analysis"
513        );
514        diag.set_analysis(&frame);
515
516        let AnalysisMetrics {
517            bass,
518            mid,
519            treb,
520            onset,
521            downbeat_confidence,
522            downbeat_locked,
523        } = diag.analysis();
524        assert_eq!(bass, 0.25);
525        assert_eq!(mid, 0.5);
526        assert_eq!(treb, 0.75);
527        assert_eq!(onset, 0.125);
528        assert_eq!(downbeat_confidence, 0.875);
529        assert!(downbeat_locked);
530    }
531
532    /// ADR-0052 keeps `Metrics`'s "mirrors `RlxMetrics`" claim literally true by
533    /// putting the analysis values on their own type. This exhaustive destructure
534    /// is what enforces it: adding a field to `Metrics` stops compiling here, so a
535    /// later plan has to come and read this comment before widening the mirror.
536    /// `core-cabi/tests/ffi.rs` holds the other half: it asserts `RlxMetrics`'s
537    /// size and that the runtime version equals the `RLX_ABI_VERSION` constant.
538    /// Neither figure is restated here — a number written into prose is
539    /// falsified by every ABI change and nothing gates one.
540    #[test]
541    fn metrics_still_carries_exactly_the_mirrored_fields() {
542        let Metrics {
543            fps,
544            frame_ms_avg,
545            frame_ms_p99,
546            frames_total,
547            frames_dropped,
548            gpu_bytes,
549            draw_calls,
550        } = Diag::new().metrics();
551        assert_eq!(
552            (fps, frame_ms_avg, frame_ms_p99),
553            (0.0, 0.0, 0.0),
554            "a fresh Diag reports zero frame-time statistics"
555        );
556        assert_eq!((frames_total, frames_dropped, gpu_bytes), (0, 0, 0));
557        assert_eq!(draw_calls, 0);
558    }
559
560    /// `samples()` yields the retained durations chronologically.
561    #[test]
562    fn samples_are_chronological_after_wrap() {
563        let mut stats = FrameStats::new();
564        for i in 0..(RING + 10) {
565            stats.record(i as f32);
566        }
567        let got: Vec<f32> = stats.samples().collect();
568        assert_eq!(got.len(), RING);
569        // The oldest retained sample is (RING + 10) - RING = 10; last is RING+9.
570        assert_eq!(got.first().copied(), Some(10.0));
571        assert_eq!(got.last().copied(), Some((RING + 9) as f32));
572    }
573}