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}