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}