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}