Skip to main content

beat_this/
output.rs

1use crate::BeatAnalysis;
2
3/// Compute beat counts from beat and downbeat timestamps.
4///
5/// Faithful port of the Python reference `infer_beat_numbers`
6/// (`refs/beat_this/beat_this/utils.py`): each downbeat is numbered `1`, beats within a
7/// measure count upward, and a pickup measure (beats before the first downbeat) is
8/// numbered so it leads *into* the first downbeat. Requires every downbeat to also be a
9/// beat — guaranteed here because postprocessing snaps downbeats onto beat times.
10///
11/// Returns a `Vec<i32>` parallel to `analysis.beats`.
12pub fn beat_counts(analysis: &BeatAnalysis) -> Vec<i32> {
13    infer_beat_numbers(&analysis.beats, &analysis.downbeats)
14}
15
16/// Port of Python `infer_beat_numbers`. Assumes `beats` is sorted and `downbeats` is a
17/// sorted subset of `beats` (the pipeline invariant).
18fn infer_beat_numbers(beats: &[f32], downbeats: &[f32]) -> Vec<i32> {
19    // Derive where to start counting, handling a pickup measure.
20    let start_counter: i32 = if downbeats.len() >= 2 {
21        // Index of each downbeat among the sorted beats (np.searchsorted / lower_bound).
22        let first = beats.partition_point(|&b| b < downbeats[0]) as i32;
23        let second = beats.partition_point(|&b| b < downbeats[1]) as i32;
24        let beats_in_first_measure = second - first;
25        let pickup_beats = first;
26        if pickup_beats < beats_in_first_measure {
27            beats_in_first_measure - pickup_beats
28        } else {
29            // More pickup beats than a full measure: don't try to estimate (Python warns).
30            1
31        }
32    } else {
33        // Fewer than two downbeats: can't estimate the pickup (Python warns).
34        1
35    };
36
37    // Assemble the beat numbers. The increment/reset happens *before* the push, so a
38    // non-downbeat first beat becomes `start_counter + 1` (matches Python exactly).
39    let mut numbers = Vec::with_capacity(beats.len());
40    let mut counter = start_counter;
41    let mut db_idx = 0usize;
42    for &beat in beats {
43        match downbeats.get(db_idx) {
44            Some(&db) if (beat - db).abs() < 0.001 => {
45                counter = 1;
46                db_idx += 1;
47            }
48            _ => counter += 1,
49        }
50        numbers.push(counter);
51    }
52    numbers
53}
54
55/// Calculate BPM from beat timestamps using median inter-beat interval.
56///
57/// Filters out unrealistic intervals (<0.1s or >3.0s, i.e. outside 20–600 BPM).
58/// Returns `None` if fewer than 2 valid intervals exist.
59pub fn calculate_bpm(analysis: &BeatAnalysis) -> Option<f32> {
60    if analysis.beats.len() < 2 {
61        return None;
62    }
63
64    let mut intervals: Vec<f32> = analysis
65        .beats
66        .windows(2)
67        .map(|w| w[1] - w[0])
68        .filter(|&iv| iv > 0.1 && iv < 3.0)
69        .collect();
70
71    if intervals.is_empty() {
72        return None;
73    }
74
75    intervals.sort_by(|a, b| a.total_cmp(b));
76    let n = intervals.len();
77    let median = if n.is_multiple_of(2) {
78        (intervals[n / 2 - 1] + intervals[n / 2]) / 2.0
79    } else {
80        intervals[n / 2]
81    };
82
83    Some(60.0 / median)
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use crate::runtime::Tensor;
90
91    fn make_analysis(beats: Vec<f32>, downbeats: Vec<f32>) -> BeatAnalysis {
92        BeatAnalysis {
93            beats,
94            downbeats,
95            mel: Tensor {
96                shape: vec![1, 0, 128],
97                data: vec![],
98            },
99            beat_logits: vec![],
100            downbeat_logits: vec![],
101        }
102    }
103
104    #[test]
105    fn test_beat_counts_basic() {
106        let analysis = make_analysis(vec![0.5, 1.0, 1.5, 2.0], vec![0.5]);
107        let counts = beat_counts(&analysis);
108        assert_eq!(counts, vec![1, 2, 3, 4]);
109    }
110
111    #[test]
112    fn test_beat_counts_multiple_downbeats() {
113        let analysis = make_analysis(vec![0.5, 1.0, 1.5, 2.0, 2.5, 3.0], vec![0.5, 2.0]);
114        let counts = beat_counts(&analysis);
115        assert_eq!(counts, vec![1, 2, 3, 1, 2, 3]);
116    }
117
118    #[test]
119    fn test_beat_counts_no_downbeats() {
120        // Fewer than two downbeats: Python can't estimate the pickup, so start_counter = 1
121        // and the increment-before-append loop yields 2, 3, 4 (its warning says "start from 2").
122        let analysis = make_analysis(vec![0.5, 1.0, 1.5], vec![]);
123        let counts = beat_counts(&analysis);
124        assert_eq!(counts, vec![2, 3, 4]);
125    }
126
127    #[test]
128    fn test_beat_counts_beats_before_first_downbeat() {
129        // Single downbeat with leading pickup beats: start_counter = 1, so the two pickup
130        // beats are 2, 3, the downbeat resets to 1, and the next beat is 2.
131        let analysis = make_analysis(vec![0.5, 1.0, 1.5, 2.0], vec![1.5]);
132        let counts = beat_counts(&analysis);
133        assert_eq!(counts, vec![2, 3, 1, 2]);
134    }
135
136    #[test]
137    fn test_beat_counts_pickup_measure() {
138        // 4/4 with two pickup beats: downbeats at index 2 and 6.
139        let analysis = make_analysis(vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], vec![1.0, 3.0]);
140        // beats_in_first_measure = 4, pickup_beats = 2 -> start_counter = 2 -> 3,4,1,2,...
141        assert_eq!(beat_counts(&analysis), vec![3, 4, 1, 2, 3, 4, 1, 2]);
142    }
143
144    #[test]
145    fn test_beat_counts_pickup_longer_than_first_measure() {
146        // 3 pickup beats but only a 2-beat first measure -> start_counter falls back to 1.
147        let analysis = make_analysis(vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0], vec![1.5, 2.5]);
148        // first=3, second=5 -> beats_in_first_measure=2, pickup_beats=3 (>=2) -> start_counter=1
149        assert_eq!(beat_counts(&analysis), vec![2, 3, 4, 1, 2, 1, 2]);
150    }
151
152    #[test]
153    fn test_beat_counts_clean_start_single_downbeat() {
154        // Clean start (first beat is the downbeat): pickup_beats = 0, plain count up.
155        let analysis = make_analysis(vec![0.5, 1.0, 1.5, 2.0], vec![0.5]);
156        assert_eq!(beat_counts(&analysis), vec![1, 2, 3, 4]);
157    }
158
159    #[test]
160    fn test_calculate_bpm_120() {
161        let analysis = make_analysis(vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0], vec![0.0]);
162        let bpm = calculate_bpm(&analysis).unwrap();
163        assert!((bpm - 120.0).abs() < 0.1, "Expected ~120 BPM, got {}", bpm);
164    }
165
166    #[test]
167    fn test_calculate_bpm_too_few_beats() {
168        let analysis = make_analysis(vec![0.5], vec![]);
169        assert!(calculate_bpm(&analysis).is_none());
170    }
171
172    #[test]
173    fn test_calculate_bpm_empty() {
174        let analysis = make_analysis(vec![], vec![]);
175        assert!(calculate_bpm(&analysis).is_none());
176    }
177}