Skip to main content

rlx_core/dsp/
downbeat.rs

1//! Downbeat estimation: which beat is beat 1 (ADR-0050 Layer 2).
2//!
3//! No preset can build an 8-bar arc or land a drop without knowing where the bar
4//! starts. Estimating that on real music has genuine failure modes —
5//! syncopation, half-time feel, non-4/4 — and **a wrong downbeat is worse than
6//! none**: a preset accenting beat 1 on beat 3 reads as broken in a way a plain
7//! counter never does. So the aggressive capability is kept and its failure mode
8//! is made the conservative one.
9//!
10//! The method: accumulate a bass-weighted accent per detected beat, fold that
11//! history over the four candidate beat-1 alignments of a 4/4 hypothesis, and
12//! take the strongest. **What the four alignments are alignments *of* changed in
13//! Plan 0095**: the fold buckets by the [`grid`](super::grid)'s tempo-driven beat
14//! count, not by `beat_index`, which counts transients at 1.35x-2.10x per musical
15//! beat and so gave the fold a unit that was not a beat and a "bar" that was not
16//! a bar (ADR-0109). Confidence is **how much of the accent variation the
17//! alignment explains**, corrected for the share noise alone would explain, so a
18//! pattern with no accent structure scores near zero rather than picking a winner
19//! by coincidence. Below a threshold the estimator does not publish, and switching
20//! alignment mid-stream is hysteretic so the bar line cannot hop beat to beat.
21//!
22//! **The gate chooses an alignment, nothing else.** Locked and unlocked output
23//! come from one formula, differing only in whether the alignment is the
24//! estimate or `0` — so the fallback is counters derived from whatever beat
25//! count it was handed, by construction rather than by a parallel code path that
26//! could drift. That is what makes "worst case, it behaves exactly like the
27//! counters" a structural claim.
28//!
29//! 4/4 is assumed and documented. Pure and allocation-free after construction:
30//! state is fixed arrays and the only clock is the beat stream (NFR section 6).
31
32// Hot-path panic-denial pragma (Plan 0002 Phase 2). Runs every analysis hop.
33#![deny(
34    clippy::unwrap_used,
35    clippy::expect_used,
36    clippy::indexing_slicing,
37    clippy::panic,
38    clippy::unreachable
39)]
40
41/// Beats per bar. 4/4 is assumed (ADR-0050); a different meter falls back to the
42/// counters rather than mis-accenting.
43pub const BEATS_PER_BAR: u32 = 4;
44
45/// Beats of accent history the fold runs over — eight bars, so each of the four
46/// alignments averages eight observations. Long enough to be evidence, short
47/// enough that "recent past" still means something.
48const ACCENT_HISTORY: usize = 32;
49
50/// Beats of evidence required before the estimator will publish at all. Two
51/// bars: below this every alignment has one or two samples. The confidence
52/// measure already discounts small-sample coincidence, so this is a floor rather
53/// than the main defence.
54const MIN_BEATS: usize = 8;
55
56/// Confidence the effect size must clear to publish. Measured against the
57/// synthesized patterns in this module's tests and against real accented audio in
58/// `core/tests/dsp.rs`: a clean kick every fourth beat scores well above this, an
59/// unaccented click train scores near zero.
60const CONFIDENCE_THRESHOLD: f32 = 0.25;
61
62/// A challenger must lead the incumbent by this fraction before it even starts
63/// counting toward a switch, so ordinary beat-to-beat wobble never begins one.
64const SWITCH_MARGIN: f32 = 0.15;
65
66/// Consecutive beats a leading challenger must hold before the alignment moves —
67/// three bars. This is the "flips take bars, not frames" requirement.
68const HYSTERESIS_BEATS: u32 = 12;
69
70/// How much the bass band contributes to an accent relative to broadband flux.
71///
72/// Both inputs are ADR-0049-normalized to 0..1, which is what makes a weighted
73/// blend of them meaningful at all — on raw magnitudes bass outweighs flux about
74/// twentyfold and the weight would be doing nothing. Bass-dominant because the
75/// kick is the downbeat cue in most material this targets.
76const BASS_WEIGHT: f32 = 0.7;
77
78/// Bar-position output for one hop.
79#[derive(Debug, Clone, Copy, Default, PartialEq)]
80pub struct BarClock {
81    /// Which beat of the bar this is, `0..BEATS_PER_BAR`.
82    pub beat_in_bar: u32,
83    /// Bar counter — **monotone except across an alignment change**, since it is
84    /// `(beat_count - alignment) / BEATS_PER_BAR` and `alignment` moves when the
85    /// estimator locks, drops back, or is overtaken by a challenger. See
86    /// `bar_index_steps_back_across_an_alignment_change`, which pins the size of
87    /// that step at one bar.
88    pub bar_index: u32,
89    /// Position across the bar in `[0, 1)`, including the fraction through the
90    /// current beat — the true "bar phase" the shipped `bar` variable is not.
91    pub bar_phase: f32,
92    /// Alignment confidence in `0..1` — the noise-corrected share of accent
93    /// variation the alignment explains (see `effect_size`). **Diagnostics only**,
94    /// deliberately not a grammar variable: authors get behavior, not homework.
95    pub confidence: f32,
96    /// Whether the published position came from the estimator (`true`) or the
97    /// deterministic counter fallback (`false`).
98    pub locked: bool,
99}
100
101/// A read-only decomposition of what the estimator currently believes
102/// (Plan 0068's instrument).
103///
104/// The 1 Hz diagnostic column publishes the *outcome* — locked or not, and one
105/// confidence number — which is why "the accent feature is weak", "eight bars is
106/// the wrong window" and "the confidence measure under-reports" are three
107/// stories that fit the same reading. This is the decomposition that tells them
108/// apart.
109///
110/// **Diagnostics only.** It is not a grammar variable, not on the C ABI, and
111/// nothing on the analysis path reads it — [`DownbeatTracker::terms`] recomputes
112/// the fold from state that already exists rather than caching it, so the
113/// estimator behaves identically whether or not anyone is looking. The two
114/// exceptions are [`Self::fold_beat`] and [`Self::grid_bar_phase`], which
115/// `process` stores because they are its *input* and nothing downstream retains
116/// them; the store is unconditional and branchless, so it is as invisible to the
117/// estimate as the recomputation is.
118#[derive(Debug, Clone, Copy, Default, PartialEq)]
119pub struct DownbeatTerms {
120    /// Mean accent per candidate beat-1 alignment — the fold's own output.
121    pub scores: [f32; BEATS_PER_BAR as usize],
122    /// The alignment the fold currently favours (first `argmax` of `scores`).
123    pub best: u32,
124    /// The alignment actually held, which lags `best` by the hysteresis.
125    pub held: u32,
126    /// Between-group share of accent variance, **before** the noise correction.
127    pub effect_raw: f32,
128    /// The share four groups would explain by chance alone at this history
129    /// length — what `effect_raw` is discounted by.
130    pub null_share: f32,
131    /// What the gate compares: `effect_raw` corrected for `null_share`.
132    pub effect_corrected: f32,
133    /// Accents recorded, against `MIN_BEATS` and saturating at `ACCENT_HISTORY`.
134    pub beats_seen: u32,
135    /// Whether these terms publish — the evidence floor **and** the gate.
136    pub locked: bool,
137    /// **The counter the fold buckets by**, as of the most recent
138    /// [`DownbeatTracker::process`] — the [`grid`](super::grid)'s beat count,
139    /// and `beat_index` only while the grid warms up (ADR-0109). [`Self::scores`], [`Self::best`] and [`Self::held`] are all
140    /// indexed in *this* counter's space, and it is reported here because
141    /// nothing downstream retains it: the shell's log had no way to name the
142    /// quantity its own alignment columns were expressed in, which is what let
143    /// the two drift apart while every column stayed plausible.
144    pub fold_beat: u32,
145    /// Where that counter sits across the bar, `[0, 1)`, **ungated** — no
146    /// `alignment` is subtracted, because the question this exists to answer is
147    /// where the *grid* is, not where the estimator thinks beat 1 is.
148    ///
149    /// Honest only once the grid is running. Before that the fold is handed the
150    /// tempo tracker's onset-reset phase, so a warmup row's reading is not a
151    /// grid reading; `bpm` marks the handover.
152    pub grid_bar_phase: f32,
153}
154
155/// The three numbers behind one confidence reading. Split out so the probe can
156/// see the correction rather than only its result; `effect_size` is still the
157/// one caller on the analysis path and still returns a single `f32`.
158#[derive(Debug, Clone, Copy)]
159struct Effect {
160    raw: f32,
161    null: f32,
162    corrected: f32,
163}
164
165/// Folds per-beat accents over the four 4/4 alignments and publishes a bar
166/// position when the winner is convincing enough.
167pub struct DownbeatTracker {
168    /// `beat_count % BEATS_PER_BAR` for each recorded accent.
169    phases: [u32; ACCENT_HISTORY],
170    /// Accent strength for each recorded accent, same order.
171    values: [f32; ACCENT_HISTORY],
172    /// Accents recorded, saturating at `ACCENT_HISTORY`.
173    filled: usize,
174    /// Write cursor into the ring.
175    cursor: usize,
176    /// The alignment currently held: which `beat_count % BEATS_PER_BAR` is beat 1.
177    alignment: u32,
178    /// Latest confidence in `alignment` — see `effect_size`.
179    confidence: f32,
180    /// A challenging alignment and how many consecutive beats it has led for.
181    challenger: Option<(u32, u32)>,
182    /// The `beat_count` and clamped `beat_phase` of the most recent `process`
183    /// call, for [`DownbeatTerms::fold_beat`] / [`DownbeatTerms::grid_bar_phase`].
184    /// Recorded on **every** hop, not only on beats: the fold buckets on beats,
185    /// but the position across the bar keeps moving between them.
186    last_beat_count: u32,
187    last_beat_phase: f32,
188}
189
190impl DownbeatTracker {
191    /// A tracker with no evidence: unlocked, aligned to 0, so it reproduces the
192    /// plain counters until it has reason not to.
193    pub fn new() -> Self {
194        Self {
195            phases: [0; ACCENT_HISTORY],
196            values: [0.0; ACCENT_HISTORY],
197            filled: 0,
198            cursor: 0,
199            alignment: 0,
200            confidence: 0.0,
201            challenger: None,
202            last_beat_count: 0,
203            last_beat_phase: 0.0,
204        }
205    }
206
207    /// Advance one hop.
208    ///
209    /// `beat` flags a transient this hop; `bass` and `onset` are the
210    /// **normalized** levels; `beat_phase` is the `0..1` position through the
211    /// current beat, which becomes the sub-beat part of `bar_phase`.
212    ///
213    /// **`beat_count` is the counter this folds over, and it is not
214    /// `beat_index`.** It is the [`grid`](super::grid)'s beat count, which is
215    /// driven by the tempo estimate; `beat_index` counts transients, at 1.35x to
216    /// 2.10x per musical beat, so folding over it spanned well under a bar and a
217    /// bar-locked accent precessed across all four alignments instead of
218    /// accumulating in one (ADR-0109). The analyzer still passes `beat_index`
219    /// while the grid warms up, which is the fallback ADR-0050 specifies and the
220    /// behaviour this module had throughout.
221    pub fn process(
222        &mut self,
223        beat: bool,
224        beat_count: u32,
225        bass: f32,
226        onset: f32,
227        beat_phase: f32,
228    ) -> BarClock {
229        let phase = beat_phase.clamp(0.0, 1.0);
230        // Every hop, beat or not — see the field docs.
231        self.last_beat_count = beat_count;
232        self.last_beat_phase = phase;
233
234        if beat {
235            self.record(beat_count, accent(bass, onset));
236            self.reconsider();
237        }
238
239        let locked = self.filled >= MIN_BEATS && self.confidence >= CONFIDENCE_THRESHOLD;
240        // One formula for both paths: the gate only decides whether the alignment
241        // is the estimate or zero.
242        let alignment = if locked { self.alignment } else { 0 };
243        let shifted = beat_count.saturating_sub(alignment);
244        let beat_in_bar = shifted % BEATS_PER_BAR;
245
246        BarClock {
247            beat_in_bar,
248            bar_index: shifted / BEATS_PER_BAR,
249            bar_phase: (beat_in_bar as f32 + phase) / BEATS_PER_BAR as f32,
250            confidence: self.confidence,
251            locked,
252        }
253    }
254
255    /// Read the terms behind the current estimate — Plan 0068's instrument.
256    ///
257    /// **Changes nothing.** It takes `&self`, recomputes the fold and the effect
258    /// size from state `process` already keeps, and returns them by value in
259    /// fixed-size arrays: no heap allocation, no clock, no field written, and no
260    /// branch inside `process` that differs depending on whether anyone calls
261    /// this. The recomputation is a pure function of the same state that
262    /// produced the last published `BarClock::confidence`, so between hops the
263    /// two agree bit for bit — which is what makes this a reading of the gate
264    /// rather than a second opinion about it.
265    pub fn terms(&self) -> DownbeatTerms {
266        let scores = self.scores();
267        let mut best = 0u32;
268        let mut best_score = f32::NEG_INFINITY;
269        for (a, &s) in scores.iter().enumerate() {
270            if s > best_score {
271                best_score = s;
272                best = a as u32;
273            }
274        }
275        let effect = self.effect(&scores);
276        DownbeatTerms {
277            scores,
278            best,
279            held: self.alignment,
280            effect_raw: effect.raw,
281            null_share: effect.null,
282            effect_corrected: effect.corrected,
283            beats_seen: self.filled as u32,
284            locked: self.filled >= MIN_BEATS && effect.corrected >= CONFIDENCE_THRESHOLD,
285            fold_beat: self.last_beat_count,
286            // Ungated: `alignment` is deliberately not subtracted.
287            grid_bar_phase: ((self.last_beat_count % BEATS_PER_BAR) as f32 + self.last_beat_phase)
288                / BEATS_PER_BAR as f32,
289        }
290    }
291
292    /// Store one beat's accent in the ring.
293    #[allow(
294        clippy::indexing_slicing,
295        reason = "cursor is kept modulo ACCENT_HISTORY, a valid index into both fixed arrays"
296    )]
297    fn record(&mut self, beat_count: u32, value: f32) {
298        let value = if value.is_finite() {
299            value.max(0.0)
300        } else {
301            0.0
302        };
303        self.phases[self.cursor] = beat_count % BEATS_PER_BAR;
304        self.values[self.cursor] = value;
305        self.cursor = (self.cursor + 1) % ACCENT_HISTORY;
306        self.filled = (self.filled + 1).min(ACCENT_HISTORY);
307    }
308
309    /// Recompute the fold, the confidence, and any pending switch.
310    fn reconsider(&mut self) {
311        let scores = self.scores();
312        let mut best = 0u32;
313        let mut best_score = f32::NEG_INFINITY;
314        for (a, &s) in scores.iter().enumerate() {
315            if s > best_score {
316                best_score = s;
317                best = a as u32;
318            }
319        }
320        self.confidence = self.effect_size(&scores);
321
322        if best == self.alignment {
323            self.challenger = None;
324            return;
325        }
326
327        // A challenger has to lead by a real margin before it starts counting,
328        // and then hold that lead for bars rather than beats.
329        let incumbent = scores.get(self.alignment as usize).copied().unwrap_or(0.0);
330        if best_score <= incumbent * (1.0 + SWITCH_MARGIN) {
331            self.challenger = None;
332            return;
333        }
334        let held = match self.challenger {
335            Some((who, n)) if who == best => n + 1,
336            _ => 1,
337        };
338        if held >= HYSTERESIS_BEATS {
339            self.alignment = best;
340            self.challenger = None;
341        } else {
342            self.challenger = Some((best, held));
343        }
344    }
345
346    /// How much of the accent variation the alignment actually explains, `0..1`.
347    ///
348    /// **Not the best-versus-runner-up margin**, which was the first thing tried
349    /// and is unusable: with four groups over `n` observations, pure noise already
350    /// produces an expected margin around `(k-1)/(n-1)`, so an unaccented click
351    /// train scored 0.257 against a 0.20 gate and locked onto nothing. A
352    /// confidently wrong downbeat is the one failure ADR-0050 says an author
353    /// cannot work around, so the measure has to know the difference between a
354    /// pattern and a coincidence.
355    ///
356    /// This is the between-group share of variance (eta-squared), **corrected for
357    /// the share noise alone would explain**. The correction is what makes the
358    /// threshold mean the same thing at every history length: with little evidence
359    /// the null share is large, so a weak pattern cannot clear the gate; as
360    /// evidence accumulates the same effect size becomes significant. No separate
361    /// "minimum evidence" tuning is doing that work.
362    fn effect_size(&self, means: &[f32; BEATS_PER_BAR as usize]) -> f32 {
363        self.effect(means).corrected
364    }
365
366    /// `effect_size` with its working shown — the raw between-group share, the
367    /// null share it is discounted by, and the corrected value the gate reads.
368    /// Same arithmetic on the same inputs; the split exists so [`Self::terms`]
369    /// can report the correction separately from its result.
370    fn effect(&self, means: &[f32; BEATS_PER_BAR as usize]) -> Effect {
371        let n = self.filled;
372        // Share four groups would explain by chance alone over n observations.
373        let null = (BEATS_PER_BAR as f32 - 1.0) / (n as f32 - 1.0).max(1.0);
374        let nothing = Effect {
375            raw: 0.0,
376            null,
377            corrected: 0.0,
378        };
379        if n <= BEATS_PER_BAR as usize {
380            return nothing;
381        }
382        let values = self.values.iter().take(n);
383        let grand = values.clone().sum::<f32>() / n as f32;
384
385        let mut between = 0.0f32;
386        let mut within = 0.0f32;
387        for (phase, value) in self.phases.iter().zip(self.values.iter()).take(n) {
388            let slot = (*phase % BEATS_PER_BAR) as usize;
389            let group = means.get(slot).copied().unwrap_or(0.0);
390            let d = value - group;
391            within += d * d;
392        }
393        for (a, &m) in means.iter().enumerate() {
394            let count = self
395                .phases
396                .iter()
397                .take(n)
398                .filter(|p| (**p % BEATS_PER_BAR) as usize == a)
399                .count();
400            let d = m - grand;
401            between += count as f32 * d * d;
402        }
403
404        let total = between + within;
405        if total <= f32::EPSILON {
406            return nothing;
407        }
408        let eta_sq = between / total;
409        if null >= 1.0 {
410            return Effect {
411                raw: eta_sq,
412                null,
413                corrected: 0.0,
414            };
415        }
416        Effect {
417            raw: eta_sq,
418            null,
419            corrected: ((eta_sq - null) / (1.0 - null)).clamp(0.0, 1.0),
420        }
421    }
422
423    /// Mean accent for each of the four alignments over the recorded history.
424    fn scores(&self) -> [f32; BEATS_PER_BAR as usize] {
425        let mut sums = [0.0f32; BEATS_PER_BAR as usize];
426        let mut counts = [0u32; BEATS_PER_BAR as usize];
427        for (phase, value) in self.phases.iter().zip(self.values.iter()).take(self.filled) {
428            let slot = (*phase % BEATS_PER_BAR) as usize;
429            if let (Some(sum), Some(count)) = (sums.get_mut(slot), counts.get_mut(slot)) {
430                *sum += *value;
431                *count += 1;
432            }
433        }
434        std::array::from_fn(|a| {
435            let n = counts.get(a).copied().unwrap_or(0);
436            if n == 0 {
437                0.0
438            } else {
439                sums.get(a).copied().unwrap_or(0.0) / n as f32
440            }
441        })
442    }
443}
444
445impl Default for DownbeatTracker {
446    fn default() -> Self {
447        Self::new()
448    }
449}
450
451/// One beat's accent strength from the normalized bass and flux levels.
452fn accent(bass: f32, onset: f32) -> f32 {
453    BASS_WEIGHT * bass + (1.0 - BASS_WEIGHT) * onset
454}
455
456#[cfg(test)]
457#[allow(
458    clippy::indexing_slicing,
459    reason = "the module's hot-path pragma also covers the tests; indices here are literals or loop bounds, and a panic is the intended failure"
460)]
461mod tests {
462    use super::*;
463
464    /// Drive `beats` beats with `accent_at(beat_index) -> (bass, onset)`, one hop
465    /// per beat, and return the last reading.
466    fn drive(
467        tracker: &mut DownbeatTracker,
468        beats: u32,
469        start_index: u32,
470        accent_at: impl Fn(u32) -> (f32, f32),
471    ) -> BarClock {
472        let mut last = BarClock::default();
473        for i in 0..beats {
474            let index = start_index + i;
475            let (bass, onset) = accent_at(index);
476            last = tracker.process(true, index, bass, onset, 0.0);
477        }
478        last
479    }
480
481    /// An accent every fourth beat, with beat 1 at `offset`.
482    fn accented(offset: u32) -> impl Fn(u32) -> (f32, f32) {
483        move |index| {
484            if index % BEATS_PER_BAR == offset {
485                (0.9, 0.8)
486            } else {
487                (0.25, 0.2)
488            }
489        }
490    }
491
492    /// ADR-0050's first pinned test: an accented click locks to the accented
493    /// alignment, **in all four rotations**. One rotation passing would be
494    /// consistent with the tracker simply always answering 0.
495    #[test]
496    fn an_accented_pattern_locks_in_every_rotation() {
497        for offset in 0..BEATS_PER_BAR {
498            let mut t = DownbeatTracker::new();
499            let out = drive(&mut t, 32, 0, accented(offset));
500            assert!(
501                out.locked,
502                "offset {offset} should lock (confidence {:.3})",
503                out.confidence
504            );
505            assert_eq!(
506                t.alignment, offset,
507                "offset {offset} should be identified as beat 1, got {}",
508                t.alignment
509            );
510            // And the published position agrees: the accented beat is beat 0 of
511            // the bar. Checked on a beat whose index is the accented phase.
512            let on_accent = t.process(false, offset + 4 * BEATS_PER_BAR, 0.0, 0.0, 0.0);
513            assert_eq!(
514                on_accent.beat_in_bar, 0,
515                "offset {offset}: the accented beat should read beat_in_bar 0"
516            );
517        }
518    }
519
520    /// **`bar_index` is not monotone across an alignment change, and this is the
521    /// size of the step.**
522    ///
523    /// Three places documented it as monotone and it is not: `bar_index` is
524    /// `(beat count - alignment) / BEATS_PER_BAR` — the [`grid`](super::grid)'s
525    /// beat count, `beat_index` only while the grid warms up — so
526    /// the beat the estimator locks
527    /// onto a non-zero alignment subtracts up to three beats and the counter can
528    /// step back by one bar. Plan 0049 chose to soften those docs rather than
529    /// publish a second never-decreasing counter — the counter would need
530    /// history-dependent state on the determinism-sensitive path and would give up
531    /// the "one formula for both paths" property that makes the lock and the
532    /// fallback auditable against each other, all to buy immunity from a rare
533    /// one-bar repeat that is already the soft failure the gate exists to prefer.
534    ///
535    /// Softening a doc is only honest if the behaviour it now describes is pinned,
536    /// so: the step happens, and it is **exactly one bar**.
537    #[test]
538    fn bar_index_steps_back_across_an_alignment_change() {
539        // Accent beat 2 of every bar, so the estimator's alignment is 2 rather
540        // than the fallback's 0 — the case where locking shifts the counter.
541        const OFFSET: u32 = 2;
542        let mut t = DownbeatTracker::new();
543
544        // Feed one beat short of the lock, then read the counter at a known beat
545        // while still in fallback.
546        let mut before = None;
547        let mut after = None;
548        for i in 0..40u32 {
549            let (bass, onset) = accented(OFFSET)(i);
550            let out = t.process(true, i, bass, onset, 0.0);
551            if !out.locked {
552                before = Some((i, out.bar_index));
553            } else if after.is_none() {
554                after = Some((i, out.bar_index));
555            }
556        }
557        let (last_free_beat, free_bar) = before.expect("the tracker starts in fallback");
558        let (first_locked_beat, locked_bar) =
559            after.expect("an accented pattern eventually locks (see the test above)");
560        assert_eq!(
561            first_locked_beat,
562            last_free_beat + 1,
563            "the two readings must be consecutive beats for the step to be the lock's doing"
564        );
565
566        // In fallback the counter is beat_index / 4; locked it is
567        // (beat_index - 2) / 4. Across the lock it therefore repeats a bar
568        // whenever the beat index has already passed the alignment within its bar.
569        assert_eq!(
570            locked_bar,
571            first_locked_beat.saturating_sub(OFFSET) / BEATS_PER_BAR,
572            "the locked reading is the alignment-shifted counter"
573        );
574        assert_eq!(
575            free_bar,
576            last_free_beat / BEATS_PER_BAR,
577            "the fallback reading is the plain counter"
578        );
579        // The claim the docs now make: it can fail to advance, and never by more
580        // than one bar in either direction.
581        let step = locked_bar as i64 - free_bar as i64;
582        assert!(
583            (-1..=1).contains(&step),
584            "bar_index moved {step} bars across the lock at beat {first_locked_beat}"
585        );
586        assert!(
587            step <= 0,
588            "this fixture is the repeat case: locking to alignment {OFFSET} must not \
589             advance the counter (free {free_bar} -> locked {locked_bar})"
590        );
591    }
592
593    /// ADR-0050's second pinned test: an unaccented pattern must stay in
594    /// fallback rather than crowning an alignment by noise.
595    ///
596    /// Run twice, and the **noisy** run is the one that matters. Perfectly equal
597    /// accents are the easy case: between-group variance is exactly zero, so any
598    /// measure reports nothing. Real material is never equal, and it was
599    /// unstructured *variation* that made the first confidence measure — the
600    /// best-versus-runner-up margin — lock onto an unaccented click train at
601    /// 0.257. This is that regression.
602    #[test]
603    fn an_unaccented_pattern_stays_in_fallback() {
604        for (label, jitter) in [("flat", 0.0f32), ("noisy", 0.35)] {
605            let mut t = DownbeatTracker::new();
606            let out = drive(&mut t, 32, 0, |index| {
607                // Deterministic, unstructured, and deliberately NOT periodic in 4:
608                // an irrational-ish step means no alignment is systematically
609                // favoured, which is what "no downbeat" looks like.
610                let wobble = jitter * (index as f32 * 2.399_963).sin();
611                (
612                    (0.5 + wobble).clamp(0.0, 1.0),
613                    (0.5 - wobble).clamp(0.0, 1.0),
614                )
615            });
616            assert!(
617                !out.locked,
618                "the {label} pattern must not lock (confidence {:.3})",
619                out.confidence
620            );
621            assert!(
622                out.confidence < CONFIDENCE_THRESHOLD,
623                "{label}: confidence {:.3} should sit under the {CONFIDENCE_THRESHOLD} gate",
624                out.confidence
625            );
626            // Fallback is the plain counter, exactly.
627            assert_eq!(out.beat_in_bar, 31 % BEATS_PER_BAR, "{label}");
628            assert_eq!(out.bar_index, 31 / BEATS_PER_BAR, "{label}");
629        }
630    }
631
632    /// ADR-0050's third pinned test: a mid-stream alignment flip takes several
633    /// bars, not one beat.
634    #[test]
635    fn an_alignment_flip_takes_bars_not_beats() {
636        let mut t = DownbeatTracker::new();
637        drive(&mut t, 32, 0, accented(0));
638        assert_eq!(t.alignment, 0, "locked on the original alignment first");
639
640        // The accent moves to beat 2. Count how many beats pass before the
641        // tracker follows.
642        let mut moved_after = None;
643        for i in 0..40u32 {
644            let index = 32 + i;
645            let (bass, onset) = accented(2)(index);
646            t.process(true, index, bass, onset, 0.0);
647            if t.alignment == 2 && moved_after.is_none() {
648                moved_after = Some(i + 1);
649            }
650        }
651        let beats = moved_after.expect("the tracker should eventually follow the new accent");
652        assert!(
653            beats >= HYSTERESIS_BEATS,
654            "the flip took {beats} beats, which is under the {HYSTERESIS_BEATS}-beat hysteresis"
655        );
656        assert!(
657            beats <= 40,
658            "the flip should still happen within a reasonable span, took {beats}"
659        );
660    }
661
662    /// **The gate did not move.** Plan 0095 changed what the fold folds over and
663    /// nothing about what it takes to publish: ADR-0082's reason for the
664    /// threshold is untouched by the unit underneath it, and an estimator that
665    /// starts locking more often because its bar is right is the outcome that
666    /// was wanted — an estimator that starts locking more often because the bar
667    /// is easier to clear would be the same repair silently undone.
668    ///
669    /// Asserted as constants rather than read off the diff, because the diff is
670    /// exactly where a threshold change hides in a plan that touches this file.
671    #[test]
672    fn the_gate_constants_have_not_moved() {
673        assert_eq!(
674            CONFIDENCE_THRESHOLD, 0.25,
675            "the publish gate is ADR-0082's and this plan does not move it"
676        );
677        assert_eq!(SWITCH_MARGIN, 0.15, "the challenger margin does not move");
678        assert_eq!(HYSTERESIS_BEATS, 12, "the switch hysteresis does not move");
679        assert_eq!(BEATS_PER_BAR, 4, "4/4 is still assumed (ADR-0050)");
680    }
681
682    /// **The grid handover does not step the bar backwards** (Plan 0095 Phase
683    /// 7a). The fold counts `beat_index` until the tempo tracker warms up and
684    /// the grid's own count — which starts at zero — from then on, several
685    /// seconds into *every* stream. Before the whole-bar offset that seam walked
686    /// `bar_index` back one bar on a 120 BPM click train and three on
687    /// `dynamic_groove(124)`, once per stream, in the first few seconds.
688    ///
689    /// This has to drive PCM through the analyzer rather than the tracker: the
690    /// handover exists only where the two counters meet, and no test of this
691    /// module alone can reach it. The stimuli are chosen for onset density,
692    /// which is what sets the size of the step — the denser the detections, the
693    /// further `beat_index` has run by the time the grid starts.
694    ///
695    /// The alignment change stays the *one* place `bar_index` may step back, and
696    /// `bar_index_steps_back_across_an_alignment_change` still pins that at
697    /// exactly one bar. So this asserts monotonicity over the warmup window
698    /// only, where no alignment can have been chosen yet.
699    #[test]
700    fn the_grid_handover_does_not_step_the_bar_backwards() {
701        use crate::audio::AudioFormat;
702        use crate::dsp::{Analyzer, HOP_SIZE, WARMUP_HOPS};
703
704        let format = AudioFormat {
705            sample_rate: 48_000,
706            channels: 1,
707        };
708        // Enough past the tracker's ~4.1 s envelope history that the handover is
709        // inside the window, and short enough that a lock cannot have settled.
710        let secs = 8.0;
711        let clips: [(&str, Vec<f32>); 4] = [
712            ("click 120", crate::signal::click_track(120.0, secs, format)),
713            ("click 200", crate::signal::click_track(200.0, secs, format)),
714            (
715                "groove 124",
716                crate::signal::dynamic_groove(124.0, secs, format),
717            ),
718            (
719                "off-beat 90 at 0.80",
720                crate::signal::offbeat_click_track(90.0, secs, 0.8, format),
721            ),
722        ];
723
724        for (label, pcm) in clips {
725            let mut analyzer = Analyzer::new(format).expect("valid format");
726            let mut prev = 0u32;
727            let mut saw_the_handover = false;
728            let mut warm = false;
729            for (hop, samples) in pcm
730                .chunks(HOP_SIZE * format.channels as usize)
731                .enumerate()
732                .skip(WARMUP_HOPS)
733            {
734                let _ = hop;
735                analyzer.push_interleaved(samples);
736                let f = analyzer.take_frame();
737                if !warm && f.bpm > 0.0 {
738                    warm = true;
739                    saw_the_handover = true;
740                }
741                assert!(
742                    f.bar_index >= prev,
743                    "{label}: bar_index went backwards, {prev} then {} \
744                     (hop {hop}, bpm {:.1}, beat_index {})",
745                    f.bar_index,
746                    f.bpm,
747                    f.beat_index
748                );
749                prev = f.bar_index;
750            }
751            assert!(
752                saw_the_handover,
753                "{label}: the tracker never warmed up, so this clip never \
754                 exercised the handover it exists to cover"
755            );
756            assert!(
757                prev > 0,
758                "{label}: the clip should have advanced at least one bar"
759            );
760        }
761    }
762
763    /// The whole analysis path is byte-deterministic across the new wiring: one
764    /// buffer through two fresh analyzers, and the published bar trio agrees bit
765    /// for bit. The grid sits on the hot path between the tempo tracker and this
766    /// module, so "the same PCM gives the same bars" is the property that would
767    /// break first if it ever read a clock or carried state across a run.
768    #[test]
769    fn the_analyzer_publishes_the_same_bars_for_the_same_buffer() {
770        use crate::audio::AudioFormat;
771        use crate::dsp::{Analyzer, HOP_SIZE};
772
773        let format = AudioFormat {
774            sample_rate: 48_000,
775            channels: 1,
776        };
777        let pcm = crate::signal::dynamic_groove(124.0, 12.0, format);
778        let run = || {
779            let mut analyzer = Analyzer::new(format).expect("valid format");
780            let mut series = Vec::new();
781            for samples in pcm.chunks(HOP_SIZE * format.channels as usize) {
782                analyzer.push_interleaved(samples);
783                let f = analyzer.take_frame();
784                series.push((
785                    f.beat_in_bar,
786                    f.bar_index,
787                    f.bar_phase.to_bits(),
788                    f.downbeat_confidence.to_bits(),
789                    f.downbeat_locked,
790                ));
791            }
792            series
793        };
794        let first = run();
795        assert_eq!(first, run(), "the same buffer must publish the same bars");
796        assert!(
797            first.iter().any(|r| r.1 > 0),
798            "the clip should have advanced at least one bar"
799        );
800    }
801
802    /// The fallback path is byte-deterministic — the phase's third done-when.
803    #[test]
804    fn the_fallback_path_is_byte_deterministic() {
805        let run = || {
806            let mut t = DownbeatTracker::new();
807            (0..64u32)
808                .map(|i| {
809                    let c = t.process(i % 2 == 0, i, 0.5, 0.5, i as f32 * 0.01);
810                    (
811                        c.beat_in_bar,
812                        c.bar_index,
813                        c.bar_phase.to_bits(),
814                        c.confidence.to_bits(),
815                        c.locked,
816                    )
817                })
818                .collect::<Vec<_>>()
819        };
820        assert_eq!(run(), run());
821    }
822
823    #[test]
824    fn bar_phase_spans_the_bar_and_beat_in_bar_stays_in_range() {
825        let mut t = DownbeatTracker::new();
826        let mut seen_low = false;
827        let mut seen_high = false;
828        for i in 0..32u32 {
829            // Sweep the sub-beat phase so bar_phase covers the continuum, not
830            // just the four beat boundaries.
831            for step in 0..4 {
832                let c = t.process(step == 0, i, 0.5, 0.5, step as f32 * 0.25);
833                assert!(
834                    c.beat_in_bar < BEATS_PER_BAR,
835                    "beat_in_bar {} out of range",
836                    c.beat_in_bar
837                );
838                assert!(
839                    (0.0..1.0).contains(&c.bar_phase),
840                    "bar_phase {} out of range",
841                    c.bar_phase
842                );
843                if c.bar_phase < 0.1 {
844                    seen_low = true;
845                }
846                if c.bar_phase > 0.9 {
847                    seen_high = true;
848                }
849            }
850        }
851        assert!(
852            seen_low && seen_high,
853            "bar_phase should traverse the whole bar, saw low {seen_low} high {seen_high}"
854        );
855    }
856
857    #[test]
858    fn evidence_is_required_before_locking() {
859        // A perfectly accented pattern still must not publish on one bar of
860        // evidence: with two samples per alignment the margin is not a
861        // measurement.
862        let mut t = DownbeatTracker::new();
863        let early = drive(&mut t, MIN_BEATS as u32 - 1, 0, accented(0));
864        assert!(
865            !early.locked,
866            "{} beats is under the {MIN_BEATS}-beat evidence floor",
867            MIN_BEATS - 1
868        );
869        let later = drive(&mut t, 24, MIN_BEATS as u32 - 1, accented(0));
870        assert!(later.locked, "it should lock once the evidence arrives");
871    }
872
873    #[test]
874    fn a_non_finite_accent_cannot_poison_the_fold() {
875        let mut t = DownbeatTracker::new();
876        drive(&mut t, 16, 0, accented(0));
877        let before = t.confidence;
878        t.process(true, 16, f32::NAN, f32::INFINITY, 0.0);
879        assert!(
880            t.confidence.is_finite(),
881            "confidence went non-finite after a NaN accent (was {before})"
882        );
883        let after = drive(&mut t, 16, 17, accented(0));
884        assert!(after.locked, "the tracker should recover and still lock");
885    }
886
887    /// **The terms report the counter the fold was handed, not one derived from
888    /// it.** Plan 0095 repointed that counter and the shell's log kept writing
889    /// `beat_index` beside the alignment columns for want of anywhere to read
890    /// the real one; this is that reading, so the test is on whether it tracks
891    /// the *input* through cases where an inference would go wrong.
892    ///
893    /// Three of them: an arbitrary count (not a small loop index), a hop with
894    /// `beat == false` — where the fold records nothing but the position still
895    /// moves — and a count that jumps, which is what the grid handover does.
896    #[test]
897    fn the_terms_report_the_count_and_phase_the_fold_was_handed() {
898        let mut t = DownbeatTracker::new();
899
900        // (count, phase, beat) in sequence; every one must be readable back.
901        let hops: [(u32, f32, bool); 5] = [
902            (0, 0.0, true),
903            (41, 0.25, true),
904            // No beat: the fold does not record, the position still advances.
905            (41, 0.80, false),
906            (42, 0.10, true),
907            // The grid handover's jump — a whole-bar offset latched at once.
908            (108, 0.50, false),
909        ];
910        for (count, phase, beat) in hops {
911            t.process(beat, count, 0.6, 0.4, phase);
912            let terms = t.terms();
913            assert_eq!(
914                terms.fold_beat, count,
915                "fold_beat should be the count just handed in (beat = {beat})"
916            );
917            let expected = ((count % BEATS_PER_BAR) as f32 + phase) / BEATS_PER_BAR as f32;
918            assert!(
919                (terms.grid_bar_phase - expected).abs() < 1e-6,
920                "grid_bar_phase {} for count {count} phase {phase}, expected {expected}",
921                terms.grid_bar_phase
922            );
923            assert!(
924                (0.0..1.0).contains(&terms.grid_bar_phase),
925                "grid_bar_phase {} left [0, 1)",
926                terms.grid_bar_phase
927            );
928        }
929
930        // Ungated by construction: locking onto a non-zero alignment moves
931        // `held` and leaves `grid_bar_phase` exactly where the grid is.
932        let mut t = DownbeatTracker::new();
933        const OFFSET: u32 = 2;
934        let out = drive(&mut t, 40, 0, accented(OFFSET));
935        assert!(
936            out.locked,
937            "the fixture must lock for this half to mean anything"
938        );
939        let terms = t.terms();
940        assert_eq!(
941            terms.held, OFFSET,
942            "the fixture should hold alignment {OFFSET}"
943        );
944        t.process(false, 400, 0.0, 0.0, 0.0);
945        assert_eq!(
946            t.terms().grid_bar_phase,
947            0.0,
948            "count 400 is bar-aligned in grid space; a phase that subtracted the \
949             held alignment would read {}/4 instead",
950            BEATS_PER_BAR - OFFSET
951        );
952
953        // The clamp `process` applies to `beat_phase` is the one reported, at
954        // both ends: unclamped these would read 2.75 and -0.5.
955        t.process(false, 2, 0.0, 0.0, 9.0);
956        assert_eq!(
957            t.terms().grid_bar_phase,
958            0.75,
959            "the phase should clamp to 1, not wrap"
960        );
961        t.process(false, 3, 0.0, 0.0, -5.0);
962        assert_eq!(
963            t.terms().grid_bar_phase,
964            0.75,
965            "the phase should clamp to 0, not go negative"
966        );
967    }
968}