Skip to main content

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}