Skip to main content

rlx_core/dsp/
onset.rs

1//! Onset envelope (spectral flux) and beat flagging with an adaptive
2//! threshold. Deterministic: state depends only on the magnitude sequence.
3
4// Hot-path panic-denial pragma (Plan 0002 Phase 2).
5#![deny(
6    clippy::unwrap_used,
7    clippy::expect_used,
8    clippy::indexing_slicing,
9    clippy::panic,
10    clippy::unreachable
11)]
12
13use super::WINDOW_SIZE;
14
15const MAG_BINS: usize = WINDOW_SIZE / 2;
16/// ~0.46 s of flux history at a 10.7 ms hop — enough context for an adaptive
17/// threshold without smearing across musical phrases.
18const HISTORY: usize = 43;
19/// Minimum gap between beats: ~96 ms, i.e. faster than any plausible beat.
20const REFRACTORY_HOPS: u32 = 9;
21/// Beat when flux exceeds mean + K * std of recent history...
22const THRESHOLD_K: f32 = 1.5;
23/// ...and is at least this absolute level, so numeric dust in silence never
24/// registers.
25const ABS_FLOOR: f32 = 1e-6;
26
27/// Spectral-flux onset envelope with an adaptive-threshold beat flag.
28pub struct OnsetDetector {
29    prev: [f32; MAG_BINS],
30    have_prev: bool,
31    history: [f32; HISTORY],
32    hist_pos: usize,
33    hist_len: usize,
34    refractory: u32,
35}
36
37impl OnsetDetector {
38    /// A detector with empty history (no beats until it warms up).
39    pub fn new() -> Self {
40        Self {
41            prev: [0.0; MAG_BINS],
42            have_prev: false,
43            history: [0.0; HISTORY],
44            hist_pos: 0,
45            hist_len: 0,
46            refractory: 0,
47        }
48    }
49
50    /// One hop: returns (onset envelope value, beat flag).
51    #[allow(
52        clippy::indexing_slicing,
53        reason = "hist_pos < HISTORY (kept modulo HISTORY), a valid index into the ring history"
54    )]
55    pub fn process(&mut self, mags: &[f32; MAG_BINS]) -> (f32, bool) {
56        // Spectral flux: mean positive magnitude increase per bin.
57        let mut flux = 0.0f32;
58        if self.have_prev {
59            for (m, p) in mags.iter().zip(self.prev.iter()) {
60                flux += (m - p).max(0.0);
61            }
62            flux /= MAG_BINS as f32;
63        }
64        self.prev.copy_from_slice(mags);
65        self.have_prev = true;
66
67        // Threshold from history *before* this hop is added, so a spike
68        // cannot raise the bar against itself.
69        let (mean, std) = self.history_stats();
70        let over_threshold = flux > mean + THRESHOLD_K * std && flux > ABS_FLOOR;
71        let beat = self.refractory == 0 && over_threshold;
72        if beat {
73            self.refractory = REFRACTORY_HOPS;
74        } else {
75            self.refractory = self.refractory.saturating_sub(1);
76        }
77
78        self.history[self.hist_pos] = flux;
79        self.hist_pos = (self.hist_pos + 1) % HISTORY;
80        self.hist_len = (self.hist_len + 1).min(HISTORY);
81
82        (flux, beat)
83    }
84
85    #[allow(
86        clippy::indexing_slicing,
87        reason = "hist_len <= HISTORY, so history[..hist_len] is always in range"
88    )]
89    fn history_stats(&self) -> (f32, f32) {
90        if self.hist_len == 0 {
91            return (0.0, 0.0);
92        }
93        let n = self.hist_len as f32;
94        let slice = &self.history[..self.hist_len];
95        let mean = slice.iter().sum::<f32>() / n;
96        let var = slice.iter().map(|f| (f - mean) * (f - mean)).sum::<f32>() / n;
97        (mean, var.sqrt())
98    }
99}
100
101impl Default for OnsetDetector {
102    fn default() -> Self {
103        Self::new()
104    }
105}