Skip to main content

rlx_core/dsp/
tempo.rs

1//! Deterministic tempo (BPM) and beat-phase (`bar`) from the onset envelope.
2//!
3//! The time base is the analyzer's hop count, never the wall clock: BPM is the
4//! lag of the strongest mean-subtracted autocorrelation of the recent onset
5//! envelope (parabolically refined for sub-hop precision, and **held against
6//! challengers** so two near-tied peaks cannot trade it hop by hop — Plan 0095
7//! Phase 2, see `TempoTracker::hold`), and `bar` is a 0..1
8//! phase advanced each hop by the current BPM and snapped to 0 on every
9//! detected beat. Pure and allocation-free after construction — the envelope
10//! history is a fixed array and every pass is iterator-based, so the same
11//! `(onset, beat)` sequence always yields the same `(bpm, bar)` sequence
12//! (NFR 6, hot-path discipline §5).
13
14// Hot-path panic-denial pragma (Plan 0002 Phase 2). Runs every analysis hop.
15#![deny(
16    clippy::unwrap_used,
17    clippy::expect_used,
18    clippy::indexing_slicing,
19    clippy::panic,
20    clippy::unreachable
21)]
22
23use super::HOP_SIZE;
24
25/// Onset-envelope history (~4.1 s at a 10.7 ms hop): long enough to resolve
26/// tempos down to `MIN_BPM` with several beat periods of overlap.
27const ENV_HISTORY: usize = 384;
28/// Tempo search range. Sub/‑super-harmonics outside this are ignored.
29const MIN_BPM: f32 = 60.0;
30const MAX_BPM: f32 = 200.0;
31
32/// A challenging lag must lead the held one's correlation by this fraction
33/// before it even starts counting toward a switch, so two near-tied peaks
34/// cannot trade the estimate back and forth hop by hop. Named and sized after
35/// [`downbeat`](super::downbeat)'s alignment switch, which solves the same
36/// problem one layer up.
37const SWITCH_MARGIN: f32 = 0.15;
38
39/// Consecutive hops a leading challenger must hold before the estimate moves —
40/// ~0.5 s at a 10.7 ms hop. The envelope history is 4.1 s, so a real tempo
41/// change arrives gradually as the window fills and leads for far longer than
42/// this; what it excludes is the flicker at a tie.
43const SWITCH_HOPS: u32 = 48;
44
45/// Rolling onset-envelope autocorrelator producing a BPM estimate and a beat
46/// phase.
47pub struct TempoTracker {
48    /// Seconds per hop — the fixed conversion between lag (hops) and BPM.
49    hop_sec: f32,
50    /// Envelope history, oldest at index 0, newest at the end.
51    env: [f32; ENV_HISTORY],
52    /// Hops seen so far, saturating at `ENV_HISTORY` (estimation waits until
53    /// the buffer is full so the autocorrelation has full context).
54    filled: usize,
55    /// Lag search bounds (hops) derived from `MAX_BPM`/`MIN_BPM`.
56    min_lag: usize,
57    max_lag: usize,
58    /// Latest BPM estimate (0 until warm / when no periodicity is found).
59    bpm: f32,
60    /// The lag the estimate is currently published at; `0` until the first
61    /// positive periodicity is found. See [`TempoTracker::hold`].
62    held_lag: usize,
63    /// A challenging lag and how many consecutive hops it has led for.
64    challenger_lag: usize,
65    challenger_hops: u32,
66    /// Beat phase in [0, 1): 0 at each beat, ramping toward the next.
67    phase: f32,
68    /// Beats detected so far. `beat_index` publishes this less one, so the first
69    /// detected beat reads 0 and [`Layer 2`](super::downbeat)'s counter fallback
70    /// starts its bar on a beat rather than a beat and a bit.
71    beats_seen: u32,
72    /// Hops since the last detected beat, the integer `time_since_beat` is
73    /// derived from. Counted in hops rather than accumulated in seconds so it
74    /// cannot drift: the hop clock is the only clock here (NFR section 6).
75    hops_since_beat: u32,
76}
77
78/// One hop's beat-clock reading (ADR-0050 Layer 1 plus the pre-existing tempo
79/// pair), returned together because they all derive from the same beat stream.
80#[derive(Debug, Clone, Copy, Default, PartialEq)]
81pub struct BeatClock {
82    /// Tempo estimate in BPM; 0 until the tracker warms.
83    pub bpm: f32,
84    /// Beat phase in [0, 1) — the shipped `bar` variable, whose name is a
85    /// documented misnomer (ADR-0050).
86    pub bar: f32,
87    /// Monotone count of beats seen, starting at 0 on the first one.
88    pub beat_index: u32,
89    /// Seconds since the last detected beat; exactly 0 on a beat hop.
90    pub time_since_beat: f32,
91}
92
93impl TempoTracker {
94    /// Build a tracker for `sample_rate`, precomputing the lag search bounds.
95    pub fn new(sample_rate: u32) -> Self {
96        let hop_sec = HOP_SIZE as f32 / sample_rate as f32;
97        // lag_hops = 60 / (bpm * hop_sec); faster tempo => shorter lag.
98        let min_lag = (60.0 / (MAX_BPM * hop_sec)).floor() as usize;
99        let max_lag = ((60.0 / (MIN_BPM * hop_sec)).ceil() as usize).min(ENV_HISTORY - 1);
100        Self {
101            hop_sec,
102            env: [0.0; ENV_HISTORY],
103            filled: 0,
104            min_lag: min_lag.max(1),
105            max_lag,
106            bpm: 0.0,
107            held_lag: 0,
108            challenger_lag: 0,
109            challenger_hops: 0,
110            phase: 0.0,
111            beats_seen: 0,
112            hops_since_beat: 0,
113        }
114    }
115
116    /// Advance one hop and return the whole beat clock.
117    pub fn process(&mut self, onset: f32, beat: bool) -> BeatClock {
118        // Slide the newest onset into the tail (oldest falls off the front).
119        self.env.copy_within(1.., 0);
120        if let Some(last) = self.env.last_mut() {
121            *last = onset;
122        }
123        self.filled = (self.filled + 1).min(ENV_HISTORY);
124
125        if self.filled >= ENV_HISTORY {
126            self.bpm = self.estimate_bpm();
127        }
128
129        // Beat phase: hard-reset on a detected beat so the ramp stays locked
130        // to the music; otherwise advance by the current tempo.
131        if beat {
132            self.phase = 0.0;
133            self.beats_seen = self.beats_seen.saturating_add(1);
134            self.hops_since_beat = 0;
135        } else {
136            if self.bpm > 0.0 {
137                self.phase += self.bpm * self.hop_sec / 60.0;
138                self.phase -= self.phase.floor(); // wrap into [0, 1)
139            }
140            self.hops_since_beat = self.hops_since_beat.saturating_add(1);
141        }
142
143        BeatClock {
144            bpm: self.bpm,
145            bar: self.phase,
146            beat_index: self.beats_seen.saturating_sub(1),
147            time_since_beat: self.hops_since_beat as f32 * self.hop_sec,
148        }
149    }
150
151    /// Lag of the strongest mean-subtracted autocorrelation peak in the search
152    /// range, held against beat-to-beat challengers, refined to sub-hop
153    /// precision and converted to BPM. Keeps the last estimate if no positive
154    /// periodicity is present.
155    fn estimate_bpm(&mut self) -> f32 {
156        let mean = self.env.iter().sum::<f32>() / ENV_HISTORY as f32;
157
158        let mut best_lag = self.min_lag;
159        let mut best = f32::NEG_INFINITY;
160        for lag in self.min_lag..=self.max_lag {
161            let c = self.corr_at(lag, mean);
162            if c > best {
163                best = c;
164                best_lag = lag;
165            }
166        }
167        if best <= 0.0 {
168            return self.bpm;
169        }
170
171        let lag = self.hold(best_lag, best, mean);
172        60.0 / (self.refine(lag, mean) * self.hop_sec)
173    }
174
175    /// Which lag the estimate actually publishes: the argmax once it has led the
176    /// incumbent by a margin for long enough, and the incumbent until then.
177    ///
178    /// **This does not settle the octave, and it is not trying to** (Plan 0095
179    /// Phase 1). The probe measured both directions of the ambiguity on
180    /// synthesized clips with known truth — the numbers below are what
181    /// `the_octave_ambiguity_is_one_sided` prints, in `core/tests/tempo_probe.rs`,
182    /// which is where to re-read them rather than trusting this comment: a clean
183    /// click train's correlation at *twice* the winning lag reads 80.0-88.5 % of
184    /// the peak — a plain property of any periodic signal — against 75.2-90.7 %
185    /// for material whose accent period really is twice the click period, so the
186    /// two overlap and no threshold separates them, and a rule that preferred the
187    /// slower reading dragged the 140, 165 and 200 BPM rungs down an octave. That
188    /// overlap is asserted there, not just printed. What is separable is
189    /// *stability*:
190    /// the estimator recomputes an argmax from scratch every hop and has no
191    /// memory, so two near-tied peaks make it flicker hop to hop (measured at
192    /// 15 % of the window on the off-beat rung where the two peaks cross). A
193    /// margin plus a hold turns a flickering answer into a stable one, which is
194    /// the property a bar grid needs from it (ADR-0109).
195    fn hold(&mut self, best_lag: usize, best: f32, mean: f32) -> usize {
196        // Cold start, or a held lag left stale by a rebuild of the bounds.
197        if self.held_lag < self.min_lag || self.held_lag > self.max_lag {
198            self.held_lag = best_lag;
199            self.clear_challenger();
200            return best_lag;
201        }
202        // Adjacent lags are the same answer drifting, not a challenger: follow
203        // them, so a slowly-moving tempo is tracked rather than resisted.
204        if best_lag.abs_diff(self.held_lag) <= 1 {
205            self.held_lag = best_lag;
206            self.clear_challenger();
207            return best_lag;
208        }
209        // A challenger has to lead by a real margin before it starts counting,
210        // and then hold that lead for a stretch rather than a hop.
211        let incumbent = self.corr_at(self.held_lag, mean);
212        if best <= incumbent * (1.0 + SWITCH_MARGIN) {
213            self.clear_challenger();
214            return self.held_lag;
215        }
216        let hops = if self.challenger_lag.abs_diff(best_lag) <= 1 {
217            self.challenger_hops.saturating_add(1)
218        } else {
219            1
220        };
221        self.challenger_lag = best_lag;
222        if hops >= SWITCH_HOPS {
223            self.held_lag = best_lag;
224            self.clear_challenger();
225            best_lag
226        } else {
227            self.challenger_hops = hops;
228            self.held_lag
229        }
230    }
231
232    fn clear_challenger(&mut self) {
233        self.challenger_lag = 0;
234        self.challenger_hops = 0;
235    }
236
237    /// Parabolic interpolation across `lag`'s neighbors for sub-hop precision
238    /// (keeps the estimate off the coarse integer-lag grid).
239    fn refine(&self, lag: usize, mean: f32) -> f32 {
240        if lag > self.min_lag && lag < self.max_lag {
241            let y = self.corr_at(lag, mean);
242            let yl = self.corr_at(lag - 1, mean);
243            let yr = self.corr_at(lag + 1, mean);
244            let denom = yl - 2.0 * y + yr;
245            let delta = if denom.abs() > f32::EPSILON {
246                (0.5 * (yl - yr) / denom).clamp(-0.5, 0.5)
247            } else {
248                0.0
249            };
250            lag as f32 + delta
251        } else {
252            lag as f32
253        }
254    }
255
256    /// Mean-subtracted autocorrelation at `lag`, iterator-based (no indexing).
257    fn corr_at(&self, lag: usize, mean: f32) -> f32 {
258        self.env
259            .iter()
260            .zip(self.env.iter().skip(lag))
261            .map(|(x, y)| (x - mean) * (y - mean))
262            .sum()
263    }
264}