rlx_core/dsp/grid.rs
1//! The bar-scale grid Layer 2 folds over (ADR-0109, Plan 0095 Phase 3).
2//!
3//! [`beat_index`](super::AnalysisFrame::beat_index) counts **onset events**, not
4//! musical beats — 1.73x / 1.35-2.10x / 1.76x of them per beat on three genres,
5//! against a synthesized control that reads exactly 1.00. Folding an accent
6//! history over `beat_index % 4` therefore spans well under a bar at a ratio
7//! that is not even a stable integer within one track, so a bar-locked accent
8//! precesses across all four alignments instead of accumulating in one. This
9//! module is the unit that repairs it: a beat clock driven by the **tempo
10//! estimate** rather than by the transient stream.
11//!
12//! Two things it deliberately is not:
13//!
14//! - **Not a second `beat_index`.** Nothing outside the analysis path reads it.
15//! `beat`, `beat_index` and `time_since_beat` keep their present behaviour bit
16//! for bit, so no preset's flash timing moves (ADR-0109's Alternatives A and B
17//! record why that was chosen over repairing them in place).
18//! - **Not a downbeat estimate.** It says where the beat *grid* is, not which of
19//! its beats is beat 1. Which one that is stays [`downbeat`](super::downbeat)'s
20//! job, and its four-alignment fold is what absorbs this grid's arbitrary bar
21//! offset.
22//!
23//! **The phase is locked, not free-running**, which is the design question the
24//! plan flagged as most likely to need a second attempt. A free accumulator
25//! walks off the music at any tempo error, and — worse than drifting — it can
26//! settle with its beat boundary sitting *on* the music's transients, which
27//! splits every accent across two adjacent cells and smears the fold it exists
28//! to sharpen. The lock is a phase-locked loop over the onset **envelope**, not
29//! the beat flag: two exponentially-decayed quadrature accumulators give the
30//! energy-weighted mean phase of the recent envelope relative to the grid, and
31//! the grid is nudged toward it by a small per-hop gain. It corrects toward the
32//! onset stream's aggregate phase over a window rather than snapping to each
33//! event, which is what keeps the over-firing detector from yanking the grid
34//! the way it yanks [`tempo`](super::tempo)'s `bar` phase.
35//!
36//! Pure and allocation-free after construction: state is five scalars, the only
37//! clock is the hop counter, and the same `(bpm, onset)` sequence always yields
38//! the same positions (NFR section 6).
39
40// Hot-path panic-denial pragma (Plan 0002 Phase 2). Runs every analysis hop.
41#![deny(
42 clippy::unwrap_used,
43 clippy::expect_used,
44 clippy::indexing_slicing,
45 clippy::panic,
46 clippy::unreachable
47)]
48
49use std::f32::consts::TAU;
50
51use super::HOP_SIZE;
52use super::downbeat::BEATS_PER_BAR;
53
54/// Time constant of the phase lock's window, in seconds. Two seconds is a few
55/// bars at any tempo in the search range: long enough that one loud transient in
56/// the wrong place cannot move the grid, short enough to follow a real shift
57/// within a phrase.
58const LOCK_TAU_SECS: f32 = 2.0;
59
60/// Fraction of the measured phase error corrected per hop.
61///
62/// Sized so the correction can never exceed the advance: at [`MIN_BPM`] the grid
63/// advances 0.0107 beats per hop and the largest possible correction is
64/// `0.02 * 0.5 = 0.01`, so the grid cannot run backwards even at the slowest
65/// tempo with the worst error. The step is clamped non-negative anyway — a
66/// structural guarantee is worth more here than an arithmetic one, because
67/// `bar_index` going backwards would be visible in a preset as a repeated bar.
68///
69/// [`MIN_BPM`]: super::tempo
70const LOCK_GAIN: f32 = 0.02;
71
72/// Where in the beat the lock parks the envelope's energy, in beats.
73///
74/// **Not zero, and this is the whole difference between a grid that sharpens the
75/// fold and one that shreds it.** Aiming the energy at the beat's start is the
76/// obvious choice and it puts the grid's cell boundary exactly on the music's
77/// transients — so the very samples the fold reads land on a knife edge, and
78/// which cell each one falls into is decided by where the 10.7 ms hop lattice
79/// happens to sit. Measured on a 120 BPM kick pattern before this constant
80/// existed: the grid's beat count skipped one and repeated another across
81/// successive musical beats, while its average rate was exactly right.
82///
83/// 0.12 of a beat is 60 ms at 120 BPM — several hops of margin on either side of
84/// the transient, and small enough that `bar_phase` still reads near zero on the
85/// downbeat. The energy is parked *after* the beat starts, which is also the
86/// physically honest place for it: the reading is a centroid of a decaying
87/// transient, so the attack that produced it sits earlier still.
88const LOCK_TARGET: f32 = 0.12;
89
90/// Where the grid stands this hop.
91#[derive(Debug, Clone, Copy, Default, PartialEq)]
92pub struct GridPosition {
93 /// Which beat of the bar this is, `0..BEATS_PER_BAR`. **Which one is beat 1
94 /// is not decided here** — [`downbeat`](super::downbeat) folds over this and
95 /// finds that.
96 pub beat_in_bar: u32,
97 /// Monotone bar counter since the grid started running.
98 pub bar_index: u32,
99 /// Position across the bar in `[0, 1)`, including the fraction through the
100 /// current beat.
101 pub bar_phase: f32,
102 /// Position across the current beat in `[0, 1)`.
103 pub beat_phase: f32,
104 /// Whether the grid is advancing. False until the tempo tracker warms up,
105 /// and the position is held rather than reset while it is false.
106 pub running: bool,
107}
108
109/// A beat clock driven by the tempo estimate, phase-locked to the onset
110/// envelope.
111pub struct BarGrid {
112 /// Seconds per hop — the fixed conversion between BPM and phase per hop.
113 hop_sec: f32,
114 /// Per-hop decay of the quadrature accumulators, derived from
115 /// [`LOCK_TAU_SECS`] at construction so nothing on the hot path calls `exp`.
116 decay: f32,
117 /// Position within the current grid beat, `[0, 1)`.
118 beat_phase: f32,
119 /// Grid beats completed. Split from the phase rather than accumulated as one
120 /// float, so an hour-long session loses no sub-beat resolution.
121 beats: u32,
122 /// Quadrature accumulators: the envelope's energy projected onto the grid's
123 /// own phase, exponentially weighted. Their argument is the mean phase the
124 /// lock steers toward.
125 cos_acc: f32,
126 sin_acc: f32,
127}
128
129impl BarGrid {
130 /// A grid for `sample_rate`, stopped until the first positive tempo.
131 pub fn new(sample_rate: u32) -> Self {
132 let hop_sec = HOP_SIZE as f32 / sample_rate as f32;
133 Self {
134 hop_sec,
135 decay: (-hop_sec / LOCK_TAU_SECS).exp(),
136 beat_phase: 0.0,
137 beats: 0,
138 cos_acc: 0.0,
139 sin_acc: 0.0,
140 }
141 }
142
143 /// Advance one hop against the current tempo estimate and onset envelope.
144 ///
145 /// `bpm` is the tempo tracker's estimate and `onset` the **raw** envelope —
146 /// raw for the same reason the tempo tracker reads it raw (see
147 /// [`gain`](super::gain)): peak normalization is a slow AGC that would
148 /// reweight the history the lock averages over.
149 pub fn process(&mut self, bpm: f32, onset: f32) -> GridPosition {
150 if !bpm.is_finite() || bpm <= 0.0 || !onset.is_finite() {
151 return self.position(false);
152 }
153
154 // Where the recent envelope's energy sits relative to this grid, as a
155 // phase in [-0.5, 0.5) beats. Accumulated before the advance so the
156 // reading and the projection use the same phase.
157 let angle = TAU * self.beat_phase;
158 self.cos_acc = self.cos_acc * self.decay + onset.max(0.0) * angle.cos();
159 self.sin_acc = self.sin_acc * self.decay + onset.max(0.0) * angle.sin();
160 let mut error = self.sin_acc.atan2(self.cos_acc) / TAU - LOCK_TARGET;
161 if error < -0.5 {
162 error += 1.0;
163 }
164
165 // Advance by the tempo, pulled toward that energy. Clamped non-negative
166 // so the grid can only ever stall, never reverse.
167 let advance = bpm * self.hop_sec / 60.0;
168 let step = (advance - LOCK_GAIN * error).max(0.0);
169 self.beat_phase += step;
170 while self.beat_phase >= 1.0 {
171 self.beat_phase -= 1.0;
172 self.beats = self.beats.saturating_add(1);
173 }
174
175 self.position(true)
176 }
177
178 /// The current position, without advancing.
179 fn position(&self, running: bool) -> GridPosition {
180 let beat_in_bar = self.beats % BEATS_PER_BAR;
181 GridPosition {
182 beat_in_bar,
183 bar_index: self.beats / BEATS_PER_BAR,
184 bar_phase: (beat_in_bar as f32 + self.beat_phase) / BEATS_PER_BAR as f32,
185 beat_phase: self.beat_phase,
186 running,
187 }
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 const SR: u32 = 48_000;
196
197 /// Hops in `secs` at the analyzer's hop rate.
198 fn hops(secs: f32) -> usize {
199 (secs * SR as f32 / HOP_SIZE as f32).round() as usize
200 }
201
202 /// The envelope every test here drives the grid with: a narrow spike once
203 /// per musical beat, offset by `beat_offset` beats of phase.
204 fn envelope(hop: usize, bpm: f32, beat_offset: f32) -> f32 {
205 let t = hop as f32 * HOP_SIZE as f32 / SR as f32;
206 let beat_secs = 60.0 / bpm;
207 let since = (t / beat_secs - beat_offset).rem_euclid(1.0) * beat_secs;
208 (-since * 40.0).exp()
209 }
210
211 /// Drive `secs` of that envelope from `cursor`, advancing it, so successive
212 /// calls are one continuous stimulus rather than two that both start on a
213 /// beat.
214 fn drive(
215 grid: &mut BarGrid,
216 cursor: &mut usize,
217 bpm: f32,
218 secs: f32,
219 beat_offset: f32,
220 ) -> GridPosition {
221 let mut last = GridPosition::default();
222 for _ in 0..hops(secs) {
223 last = grid.process(bpm, envelope(*cursor, bpm, beat_offset));
224 *cursor += 1;
225 }
226 last
227 }
228
229 /// The rate claim: one bar per four musical beats, counted against the
230 /// clip's own construction rather than against any detector's output.
231 #[test]
232 fn the_grid_advances_one_bar_per_four_musical_beats() {
233 for bpm in [60.0f32, 90.0, 120.0, 175.0] {
234 let mut grid = BarGrid::new(SR);
235 // Measured as a rate across a window *after* the lock has pulled the
236 // phase in, so the lock-in transient is not charged to the rate.
237 let settle = 8.0;
238 let window = 24.0;
239 let mut cursor = 0usize;
240 let start = drive(&mut grid, &mut cursor, bpm, settle, 0.0);
241 let end = drive(&mut grid, &mut cursor, bpm, window, 0.0);
242 let counted =
243 (end.bar_index as f32 + end.bar_phase) - (start.bar_index as f32 + start.bar_phase);
244 let expected = window / (60.0 / bpm) / BEATS_PER_BAR as f32;
245 assert!(
246 (counted - expected).abs() <= 0.05,
247 "{bpm} BPM over {window} s is {expected:.3} bars by construction; \
248 the grid counted {counted:.3}"
249 );
250 assert!(end.running, "{bpm} BPM: the grid should be running");
251 }
252 }
253
254 /// **The grid tracks the estimate it is handed, including a wrong one**
255 /// (Plan 0095 Phase 7b).
256 ///
257 /// Every other test in this file — and the end-to-end
258 /// `the_downbeat_estimator_locks_onto_a_kick_pattern_in_real_audio` — drives
259 /// exactly one transient per musical beat, which is the single configuration
260 /// where the grid, the tempo estimate and `beat_index` all agree. No test at
261 /// that configuration can say which of the three the grid actually followed.
262 /// This one splits them: on material with strong off-beat energy the
263 /// estimator reads the octave above (Phase 1's table, and Phase 2's
264 /// `the_octave_ambiguity_is_one_sided` shows that direction has no
265 /// discriminating evidence in the autocorrelation), so the grid's "bar"
266 /// spans two musical beats rather than four.
267 ///
268 /// **The property asserted is deliberately not "it finds a bar" — it
269 /// provably cannot here.** It is that the grid advances at the rate it was
270 /// given, and monotonically. That is the reading which separates *the grid
271 /// tracks and the accent feature is weak* from *the grid does not track on
272 /// this material* — the pair Phase 5's captures could not tell apart,
273 /// because no log column carries the grid.
274 ///
275 /// The envelope is the analyzer's own, not this file's synthetic spike: the
276 /// coupling under test is `(bpm, onset_raw)` as the analyzer actually
277 /// produces them.
278 #[test]
279 fn the_grid_tracks_a_wrong_octave_estimate() {
280 use crate::audio::AudioFormat;
281 use crate::dsp::Analyzer;
282
283 let format = AudioFormat {
284 sample_rate: SR,
285 channels: 1,
286 };
287 let truth = 90.0f32;
288 for offbeat in [0.5f32, 0.8] {
289 let pcm = crate::signal::offbeat_click_track(truth, 24.0, offbeat, format);
290 let mut analyzer = Analyzer::new(format).expect("valid format");
291 let mut grid = BarGrid::new(SR);
292
293 // Settle past the tracker's warmup and the lock's pull-in, then
294 // measure the rate against the estimate published over that window.
295 let settle = hops(10.0);
296 let mut start = GridPosition::default();
297 let mut bpm_sum = 0.0f64;
298 let mut measured = 0usize;
299 let mut prev_bar = 0u32;
300 for (hop, samples) in pcm.chunks(HOP_SIZE * format.channels as usize).enumerate() {
301 analyzer.push_interleaved(samples);
302 let f = analyzer.take_frame();
303 let pos = grid.process(f.bpm, f.onset_raw);
304 if hop == settle {
305 start = pos;
306 }
307 if hop > settle {
308 bpm_sum += f.bpm as f64;
309 measured += 1;
310 assert!(
311 pos.bar_index >= prev_bar,
312 "off-beat {offbeat}: the grid went backwards at hop {hop}, \
313 {prev_bar} then {}",
314 pos.bar_index
315 );
316 }
317 prev_bar = pos.bar_index;
318 }
319 let mean_bpm = (bpm_sum / measured.max(1) as f64) as f32;
320 let end = grid.process(mean_bpm, 0.0);
321
322 // Entry requirement, asserted rather than assumed: this case is only
323 // the case it claims to be while the estimate really is an octave
324 // high. If the estimator is ever repaired, this fires and says so
325 // rather than quietly testing the easy configuration.
326 assert!(
327 mean_bpm > truth * 1.5,
328 "off-beat {offbeat}: this test covers the wrong-octave case and the \
329 estimate is no longer wrong ({mean_bpm:.1} against a {truth:.0} truth) \
330 — the octave case needs a new stimulus, or this test needs retiring"
331 );
332
333 let window = measured as f32 * HOP_SIZE as f32 / SR as f32;
334 let counted =
335 (end.bar_index as f32 + end.bar_phase) - (start.bar_index as f32 + start.bar_phase);
336 let expected = window / (60.0 / mean_bpm) / BEATS_PER_BAR as f32;
337 // The same 0.05 bars `the_grid_advances_one_bar_per_four_musical
338 // _beats` states, and it is not tight here by accident: the estimate
339 // moves within the window and `counted` is compared against its
340 // mean, yet the residual measures 0.02 bars over 14 s.
341 assert!(
342 (counted - expected).abs() <= 0.05,
343 "off-beat {offbeat}: the estimate averaged {mean_bpm:.1} BPM over \
344 {window:.1} s, which is {expected:.3} bars of the grid's own beat; \
345 it counted {counted:.3} — the grid is not tracking what it was handed"
346 );
347 assert!(
348 end.running,
349 "off-beat {offbeat}: the grid should be running"
350 );
351 }
352 }
353
354 /// The lock claim: wherever the grid's phase starts, the envelope's spikes
355 /// end up near the start of a grid beat rather than straddling one.
356 ///
357 /// The offsets are the cases that matter — 0.5 is the one a free-running
358 /// accumulator can settle into, where every transient lands exactly on a
359 /// cell boundary and the fold downstream splits each accent in two.
360 #[test]
361 fn the_phase_locks_onto_the_envelope_wherever_it_starts() {
362 for offset in [0.0f32, 0.25, 0.5, 0.75] {
363 let mut grid = BarGrid::new(SR);
364 let mut cursor = 0usize;
365 drive(&mut grid, &mut cursor, 120.0, 20.0, offset);
366 // Then read where the grid says the loudest spike of the next few
367 // seconds landed.
368 let mut at_spike = 0.0f32;
369 let mut peak = f32::NEG_INFINITY;
370 for _ in 0..hops(4.0) {
371 let onset = envelope(cursor, 120.0, offset);
372 let pos = grid.process(120.0, onset);
373 cursor += 1;
374 if onset > peak {
375 peak = onset;
376 at_spike = pos.beat_phase;
377 }
378 }
379 // Distance from the start of a grid beat, wrapped: a spike at 0.98
380 // is as well aligned as one at 0.02.
381 let from_beat = at_spike.min(1.0 - at_spike);
382 assert!(
383 from_beat <= 0.15,
384 "starting {offset} beats off, the envelope's spike should sit near a grid \
385 beat, not {from_beat:.3} of a beat away (phase {at_spike:.3})"
386 );
387 }
388 }
389
390 /// The grid stalls rather than reversing, at every tempo in the search
391 /// range and against an envelope actively pulling its phase backwards.
392 #[test]
393 fn the_bar_counter_never_runs_backwards() {
394 for bpm in [60.0f32, 120.0, 200.0] {
395 let mut grid = BarGrid::new(SR);
396 let mut prev = (0u32, 0.0f32);
397 let hop_sec = HOP_SIZE as f32 / SR as f32;
398 for hop in 0..hops(20.0) {
399 // Deliberately adversarial: a spike wherever the grid is not.
400 let t = hop as f32 * hop_sec;
401 let onset = (t * 7.3).sin().max(0.0);
402 let pos = grid.process(bpm, onset);
403 let now = (pos.bar_index, pos.bar_phase);
404 assert!(
405 now.0 > prev.0 || (now.0 == prev.0 && now.1 >= prev.1),
406 "{bpm} BPM, hop {hop}: the grid went backwards, {prev:?} then {now:?}"
407 );
408 prev = now;
409 }
410 }
411 }
412
413 /// Before the tempo tracker warms up there is no grid, and the position is
414 /// held rather than reset when it stops.
415 #[test]
416 fn a_stopped_grid_holds_its_position() {
417 let mut grid = BarGrid::new(SR);
418 for _ in 0..hops(1.0) {
419 let pos = grid.process(0.0, 0.5);
420 assert!(!pos.running, "no tempo means no grid");
421 assert_eq!(pos.bar_index, 0);
422 assert_eq!(pos.beat_phase, 0.0);
423 }
424 let mut cursor = 0usize;
425 drive(&mut grid, &mut cursor, 120.0, 8.0, 0.0);
426 let running = grid.process(120.0, 0.5);
427 let stopped = grid.process(0.0, 0.5);
428 assert!(!stopped.running);
429 assert_eq!(
430 stopped.bar_index, running.bar_index,
431 "a stopped grid holds its bar rather than resetting it"
432 );
433 assert_eq!(stopped.beat_phase.to_bits(), running.beat_phase.to_bits());
434 }
435
436 /// **Layer 1 does not move.** The property every shipped preset's timing
437 /// rests on (ADR-0109): `beat`, `beat_index` and `time_since_beat` are
438 /// exactly what they were before this module existed.
439 ///
440 /// Asserted two ways, because they fail differently. The first is a
441 /// same-run comparison: one analyzer with a grid driven off its own output
442 /// every hop, one with no grid at all, and the three series must match bit
443 /// for bit — which catches any feedback from the grid into Layer 1. The
444 /// second is structural and outlives Phase 3, where the grid moves inside
445 /// the analyzer and the first arm cannot be built at all: the three are the
446 /// counter and the timer derived from the beat-flag stream, so anything
447 /// that re-times them shows up as a broken derivation rather than as a
448 /// number nobody has a reference for.
449 #[test]
450 fn the_grid_does_not_move_layer_1() {
451 use crate::audio::AudioFormat;
452 use crate::dsp::Analyzer;
453
454 let format = AudioFormat {
455 sample_rate: SR,
456 channels: 1,
457 };
458 let pcm = crate::signal::click_track(120.0, 12.0, format);
459 let hop_samples = HOP_SIZE * format.channels as usize;
460
461 let run = |with_grid: bool| {
462 let mut analyzer = Analyzer::new(format).expect("valid format");
463 let mut grid = BarGrid::new(SR);
464 let mut series = Vec::new();
465 for samples in pcm.chunks(hop_samples) {
466 analyzer.push_interleaved(samples);
467 let f = analyzer.take_frame();
468 if with_grid {
469 grid.process(f.bpm, f.onset_raw);
470 }
471 series.push((f.beat, f.beat_index, f.time_since_beat));
472 }
473 series
474 };
475
476 let with = run(true);
477 let without = run(false);
478 assert_eq!(with.len(), without.len());
479 for (hop, (a, b)) in with.iter().zip(without.iter()).enumerate() {
480 assert_eq!(a.0, b.0, "hop {hop}: the beat flag moved");
481 assert_eq!(a.1, b.1, "hop {hop}: beat_index moved");
482 assert_eq!(
483 a.2.to_bits(),
484 b.2.to_bits(),
485 "hop {hop}: time_since_beat moved ({} then {})",
486 a.2,
487 b.2
488 );
489 }
490
491 // The derivation, restated as an assertion. `beat_index` is one less
492 // than the number of flags seen, and `time_since_beat` is hops since the
493 // last flag on the hop clock — no wall clock, no grid.
494 //
495 // Read from the first *analyzed* hop: until the low window is full the
496 // analyzer publishes its default frame, which is not a reading of
497 // anything (`filled` reaches `LOW_WINDOW_SIZE` on hop `WARMUP_HOPS - 1`).
498 let hop_sec = HOP_SIZE as f32 / SR as f32;
499 let mut flags = 0u32;
500 let mut since = 0u32;
501 let mut saw_a_beat = false;
502 for (hop, &(beat, index, time)) in with
503 .iter()
504 .enumerate()
505 .skip(crate::dsp::WARMUP_HOPS.saturating_sub(1))
506 {
507 if beat {
508 flags += 1;
509 since = 0;
510 saw_a_beat = true;
511 } else {
512 since += 1;
513 }
514 assert_eq!(
515 index,
516 flags.saturating_sub(1),
517 "hop {hop}: beat_index must be the flag count less one"
518 );
519 assert_eq!(
520 time.to_bits(),
521 (since as f32 * hop_sec).to_bits(),
522 "hop {hop}: time_since_beat must be hops-since-flag on the hop clock"
523 );
524 }
525 assert!(saw_a_beat, "the clip should have produced beats at all");
526 }
527
528 /// Determinism: the same `(bpm, onset)` sequence twice, bit for bit.
529 #[test]
530 fn the_grid_is_deterministic() {
531 let run = || {
532 let mut grid = BarGrid::new(SR);
533 let mut series = Vec::new();
534 for hop in 0..hops(10.0) {
535 let onset = ((hop as f32) * 0.37).sin().abs();
536 let pos = grid.process(128.0, onset);
537 series.push((pos.bar_index, pos.bar_phase.to_bits()));
538 }
539 series
540 };
541 assert_eq!(
542 run(),
543 run(),
544 "the grid must be a pure function of its input"
545 );
546 }
547}