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