Skip to main content

beat_this/
postprocessing.rs

1use anyhow::{ensure, Result};
2
3/// Default frames per second for the beat model.
4const FPS: f32 = 50.0;
5
6/// Decodes raw beat/downbeat logits into timestamped events.
7///
8/// Applies max-pool peak picking, thresholding, deduplication, and
9/// downbeat-to-beat alignment to convert per-frame logit vectors
10/// into discrete beat and downbeat timestamps.
11pub struct PeakPicker {
12    fps: f32,
13}
14
15impl Default for PeakPicker {
16    fn default() -> Self {
17        Self { fps: FPS }
18    }
19}
20
21impl PeakPicker {
22    /// Create a new peak picker with the given frame rate.
23    #[cfg(test)]
24    pub(crate) fn new(fps: f32) -> Self {
25        Self { fps }
26    }
27
28    /// Decode beat and downbeat logits into `(beats, downbeats)` timestamps in seconds.
29    ///
30    /// Both input slices must have the same length (one value per spectrogram frame).
31    pub fn decode(
32        &self,
33        beat_logits: &[f32],
34        downbeat_logits: &[f32],
35    ) -> Result<(Vec<f32>, Vec<f32>)> {
36        ensure!(
37            beat_logits.len() == downbeat_logits.len(),
38            "beat_logits length ({}) != downbeat_logits length ({})",
39            beat_logits.len(),
40            downbeat_logits.len()
41        );
42
43        let beat_frames = find_peaks(beat_logits);
44        let downbeat_frames = find_peaks(downbeat_logits);
45
46        let beats: Vec<f32> = beat_frames
47            .iter()
48            .map(|&f| (f / self.fps as f64) as f32)
49            .collect();
50        let mut downbeats: Vec<f32> = downbeat_frames
51            .iter()
52            .map(|&f| (f / self.fps as f64) as f32)
53            .collect();
54
55        snap_downbeats_to_beats(&beats, &mut downbeats);
56
57        Ok((beats, downbeats))
58    }
59}
60
61/// Identify local maxima that exceed the logit threshold (> 0.0).
62///
63/// Uses a max-pool window of 7 frames (±3) with stride 1.
64/// A frame is a peak if its value equals the local maximum and is positive.
65fn find_peaks(logits: &[f32]) -> Vec<f64> {
66    let len = logits.len();
67    let mut peaks = Vec::new();
68
69    for i in 0..len {
70        // Threshold: logit > 0 corresponds to probability > 0.5 after sigmoid.
71        if logits[i] <= 0.0 {
72            continue;
73        }
74
75        // Max-pool: check if this frame is the maximum in a 7-frame window.
76        let start = i.saturating_sub(3);
77        let end = (i + 4).min(len);
78
79        let mut is_max = true;
80        for j in start..end {
81            if logits[j] > logits[i] {
82                is_max = false;
83                break;
84            }
85        }
86
87        if is_max {
88            peaks.push(i);
89        }
90    }
91
92    deduplicate_peaks(&peaks, 1)
93}
94
95/// Merge adjacent peak frame indices using a running mean.
96///
97/// Groups of consecutive peaks where each pair is at most `width` frames apart
98/// are replaced by a single peak at their mean position (kept fractional, not
99/// rounded), matching the Python reference's `deduplicate_peaks`.
100fn deduplicate_peaks(peaks: &[usize], width: usize) -> Vec<f64> {
101    if peaks.is_empty() {
102        return Vec::new();
103    }
104
105    let mut result = Vec::new();
106    let mut p = peaks[0] as f64;
107    let mut c = 1.0_f64;
108
109    for &p2_usize in &peaks[1..] {
110        let p2 = p2_usize as f64;
111        if p2 - p <= width as f64 {
112            c += 1.0;
113            p += (p2 - p) / c;
114        } else {
115            result.push(p);
116            p = p2;
117            c = 1.0;
118        }
119    }
120    result.push(p);
121
122    result
123}
124
125/// Snap each downbeat to the nearest beat time, then deduplicate.
126fn snap_downbeats_to_beats(beat_times: &[f32], downbeat_times: &mut Vec<f32>) {
127    if beat_times.is_empty() || downbeat_times.is_empty() {
128        return;
129    }
130
131    for d_time in downbeat_times.iter_mut() {
132        // Binary search for the closest beat.
133        let pos = beat_times.partition_point(|&b| b < *d_time);
134
135        let best = match (pos.checked_sub(1), beat_times.get(pos)) {
136            (Some(before), Some(&after)) => {
137                if (*d_time - beat_times[before]).abs() <= (after - *d_time).abs() {
138                    beat_times[before]
139                } else {
140                    after
141                }
142            }
143            (Some(before), None) => beat_times[before],
144            (None, Some(&after)) => after,
145            (None, None) => continue,
146        };
147
148        *d_time = best;
149    }
150
151    // Sort and deduplicate after snapping (multiple downbeats may map to the same beat).
152    downbeat_times.sort_by(|a, b| a.total_cmp(b));
153    downbeat_times.dedup();
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn test_find_peaks_single_peak() {
162        let logits = [0.0, 0.0, 0.5, 1.0, 0.5, 0.0, 0.0];
163        let peaks = find_peaks(&logits);
164        assert_eq!(peaks, vec![3.0]);
165    }
166
167    #[test]
168    fn test_find_peaks_below_threshold() {
169        let logits = [-1.0, -0.5, -2.0, -0.1];
170        let peaks = find_peaks(&logits);
171        assert!(peaks.is_empty());
172    }
173
174    #[test]
175    fn test_find_peaks_multiple_peaks() {
176        // Two peaks separated by more than 3 frames.
177        let mut logits = vec![0.0; 20];
178        logits[3] = 2.0;
179        logits[15] = 1.5;
180        let peaks = find_peaks(&logits);
181        assert_eq!(peaks, vec![3.0, 15.0]);
182    }
183
184    #[test]
185    fn test_find_peaks_adjacent_equal() {
186        // Adjacent frames with equal positive values — should deduplicate.
187        let logits = [0.0, 1.0, 1.0, 0.0];
188        let peaks = find_peaks(&logits);
189        // Both pass max-pool check (tied), dedup merges them.
190        assert_eq!(peaks.len(), 1);
191        // Mean of indices 1 and 2 is 1.5.
192        assert_eq!(peaks[0], 1.5);
193    }
194
195    #[test]
196    fn test_deduplicate_peaks_empty() {
197        let peaks = deduplicate_peaks(&[], 1);
198        assert!(peaks.is_empty());
199    }
200
201    #[test]
202    fn test_deduplicate_peaks_no_adjacent() {
203        let peaks = deduplicate_peaks(&[5, 10, 20], 1);
204        assert_eq!(peaks, vec![5.0, 10.0, 20.0]);
205    }
206
207    #[test]
208    fn test_deduplicate_peaks_merge() {
209        // 10 and 11 merge (gap=1), mean=10.5. 12 is 1.5 from mean, starts new group.
210        let peaks = deduplicate_peaks(&[10, 11, 12, 20], 1);
211        assert_eq!(peaks, vec![10.5, 12.0, 20.0]);
212
213        // Three truly adjacent peaks: 10, 11 merge to 10.5, then 11 is 0.5 from 10.5 → merges.
214        // Running mean of {10, 11, 11} is 32/3 ≈ 10.6667 (kept fractional, not rounded).
215        let peaks = deduplicate_peaks(&[10, 11, 11, 20], 1);
216        assert_eq!(peaks.len(), 2);
217        assert!((peaks[0] - 32.0 / 3.0).abs() < 1e-9);
218        assert_eq!(peaks[1], 20.0);
219    }
220
221    #[test]
222    fn test_deduplicate_peaks_single() {
223        let peaks = deduplicate_peaks(&[42], 1);
224        assert_eq!(peaks, vec![42.0]);
225    }
226
227    #[test]
228    fn test_snap_downbeats() {
229        let beats = vec![1.0, 2.0, 3.0];
230        let mut downbeats = vec![1.1, 2.8];
231        snap_downbeats_to_beats(&beats, &mut downbeats);
232        assert_eq!(downbeats, vec![1.0, 3.0]);
233    }
234
235    #[test]
236    fn test_snap_downbeats_dedup() {
237        let beats = vec![1.0, 2.0, 3.0];
238        // Both downbeats snap to 2.0.
239        let mut downbeats = vec![1.8, 2.1];
240        snap_downbeats_to_beats(&beats, &mut downbeats);
241        assert_eq!(downbeats, vec![2.0]);
242    }
243
244    #[test]
245    fn test_snap_downbeats_empty_beats() {
246        let beats: Vec<f32> = vec![];
247        let mut downbeats = vec![1.0, 2.0];
248        snap_downbeats_to_beats(&beats, &mut downbeats);
249        // Downbeats unchanged when no beats to snap to.
250        assert_eq!(downbeats, vec![1.0, 2.0]);
251    }
252
253    #[test]
254    fn test_snap_downbeats_empty_downbeats() {
255        let beats = vec![1.0, 2.0];
256        let mut downbeats: Vec<f32> = vec![];
257        snap_downbeats_to_beats(&beats, &mut downbeats);
258        assert!(downbeats.is_empty());
259    }
260
261    #[test]
262    fn test_process_full() {
263        // Construct logits with known peaks at specific frames.
264        let mut beat_logits = vec![-5.0; 200];
265        let mut downbeat_logits = vec![-5.0; 200];
266
267        // Place beat peaks at frames 50, 100, 150.
268        beat_logits[50] = 3.0;
269        beat_logits[100] = 2.5;
270        beat_logits[150] = 4.0;
271
272        // Place downbeat peak at frame 51 (should snap to beat at frame 50).
273        downbeat_logits[51] = 2.0;
274
275        let pp = PeakPicker::new(50.0);
276        let (beats, downbeats) = pp.decode(&beat_logits, &downbeat_logits).unwrap();
277
278        assert_eq!(beats, vec![1.0, 2.0, 3.0]); // 50/50, 100/50, 150/50
279        assert_eq!(downbeats, vec![1.0]); // 51/50=1.02 snaps to 1.0
280    }
281
282    #[test]
283    fn test_process_empty_logits() {
284        let pp = PeakPicker::default();
285        let (beats, downbeats) = pp.decode(&[], &[]).unwrap();
286        assert!(beats.is_empty());
287        assert!(downbeats.is_empty());
288    }
289
290    #[test]
291    fn test_process_mismatched_lengths() {
292        let pp = PeakPicker::default();
293        let err = pp.decode(&[1.0, 2.0], &[1.0]);
294        assert!(err.is_err());
295    }
296}