Skip to main content

beat_this/
inference.rs

1use anyhow::{anyhow, ensure, Result};
2
3use crate::runtime::{Model, Tensor};
4
5/// Frames per chunk (30 seconds at 50 fps).
6const CHUNK_SIZE: usize = 1500;
7/// Frames discarded from each edge of predictions.
8const BORDER_SIZE: usize = 6;
9/// Effective step between chunks.
10const STRIDE: usize = CHUNK_SIZE - 2 * BORDER_SIZE;
11
12/// Runs chunked beat/downbeat prediction on a mel spectrogram.
13///
14/// The beat model was trained on 1500-frame segments. For longer audio,
15/// the spectrogram is split into overlapping chunks, each run through
16/// the model, and the results are aggregated using "keep_first" mode
17/// (earlier chunks take priority in overlapping regions).
18pub struct BeatPredictor<M: Model> {
19    model: M,
20}
21
22impl<M: Model> BeatPredictor<M> {
23    /// Wrap an already-loaded model for beat prediction.
24    pub fn new(model: M) -> Self {
25        Self { model }
26    }
27
28    /// Get a mutable reference to the underlying model.
29    pub fn model_mut(&mut self) -> &mut M {
30        &mut self.model
31    }
32
33    /// Predict beats from a full mel spectrogram.
34    ///
35    /// Input: mel spectrogram `Tensor` with shape `[1, T, 128]`.
36    /// Returns: `(beat_logits, downbeat_logits)` — each `Vec<f32>` of length T.
37    pub fn predict(&mut self, mel: &Tensor) -> Result<(Vec<f32>, Vec<f32>)> {
38        ensure!(
39            mel.shape.len() == 3 && mel.shape[0] == 1 && mel.shape[2] == 128,
40            "Expected mel shape [1, T, 128], got {:?}",
41            mel.shape
42        );
43
44        let full_time = mel.shape[1];
45        let starts = generate_starts(full_time);
46
47        // Initialize with sentinel value; every frame will be overwritten.
48        let mut beat_logits = vec![-1000.0f32; full_time];
49        let mut downbeat_logits = vec![-1000.0f32; full_time];
50
51        // Process in reverse order for "keep_first" aggregation:
52        // earlier chunks are written last, so they overwrite later chunks
53        // in overlapping regions.
54        for &start in starts.iter().rev() {
55            let chunk = extract_chunk(mel, start);
56            let chunk_time = chunk.shape[1];
57
58            let mut outputs = self.model.run(&[("spectrogram", &chunk)])?;
59
60            let beat = extract_output(&mut outputs, "beat", "beat_logits")?;
61            let downbeat = extract_output(&mut outputs, "downbeat", "downbeat_logits")?;
62
63            // Strip border frames from both ends of the prediction.
64            let valid_beat = &beat.data[BORDER_SIZE..chunk_time - BORDER_SIZE];
65            let valid_downbeat = &downbeat.data[BORDER_SIZE..chunk_time - BORDER_SIZE];
66
67            // Write valid predictions to output buffers.
68            let write_start = (start + BORDER_SIZE as i32) as usize;
69            for (i, (&b, &d)) in valid_beat.iter().zip(valid_downbeat.iter()).enumerate() {
70                let dest = write_start + i;
71                if dest < full_time {
72                    beat_logits[dest] = b;
73                    downbeat_logits[dest] = d;
74                }
75            }
76        }
77
78        Ok((beat_logits, downbeat_logits))
79    }
80}
81
82/// Extract a named output tensor, trying a fallback name if the primary is missing.
83fn extract_output(
84    outputs: &mut std::collections::HashMap<String, Tensor>,
85    primary: &str,
86    fallback: &str,
87) -> Result<Tensor> {
88    if let Some(t) = outputs.remove(primary) {
89        return Ok(t);
90    }
91    if let Some(t) = outputs.remove(fallback) {
92        return Ok(t);
93    }
94    Err(anyhow!(
95        "Model missing output '{}' (also tried '{}'). Available: {:?}",
96        primary,
97        fallback,
98        outputs.keys().collect::<Vec<_>>()
99    ))
100}
101
102/// Generate chunk start positions for a spectrogram of `full_time` frames.
103///
104/// Starts at `-BORDER_SIZE`, steps by `STRIDE`, and adjusts the last start
105/// to align with the spectrogram end (avoid_short_end).
106fn generate_starts(full_time: usize) -> Vec<i32> {
107    let mut starts = Vec::new();
108    let mut pos = -(BORDER_SIZE as i32);
109    let limit = full_time as i32 - BORDER_SIZE as i32;
110
111    while pos < limit {
112        starts.push(pos);
113        pos += STRIDE as i32;
114    }
115
116    // Avoid short end: shift the last start so the final chunk aligns
117    // with the spectrogram end.
118    if full_time > STRIDE {
119        if let Some(last) = starts.last_mut() {
120            *last = full_time as i32 - (CHUNK_SIZE as i32 - BORDER_SIZE as i32);
121        }
122    }
123
124    starts
125}
126
127/// Extract a single chunk from the mel spectrogram, zero-padding as needed.
128///
129/// Right padding is capped at `BORDER_SIZE`, matching Python's `split_piece`:
130/// `right = max(0, min(border_size, start + chunk_size - len(spect)))`.
131/// For short audio this produces a minimal chunk; for long audio the
132/// `avoid_short_end` adjustment ensures chunks fill to `CHUNK_SIZE`.
133fn extract_chunk(mel: &Tensor, start: i32) -> Tensor {
134    let full_time = mel.shape[1];
135    let n_mels = mel.shape[2]; // 128
136
137    let actual_start = start.max(0) as usize;
138    let actual_end = ((start + CHUNK_SIZE as i32) as usize).min(full_time);
139    let pad_left = (-start).max(0) as usize;
140    let n_frames = actual_end - actual_start;
141
142    // Mirrors Python/C++: max(0, min(border_size, start + chunk_size - len))
143    let pad_right =
144        0.max((start + CHUNK_SIZE as i32 - full_time as i32).min(BORDER_SIZE as i32)) as usize;
145
146    let chunk_time = pad_left + n_frames + pad_right;
147    let mut data = vec![0.0f32; chunk_time * n_mels];
148
149    // Copy mel frames into the chunk at the correct offset.
150    for t in actual_start..actual_end {
151        let src_offset = t * n_mels;
152        let dst_t = pad_left + (t - actual_start);
153        let dst_offset = dst_t * n_mels;
154        data[dst_offset..dst_offset + n_mels]
155            .copy_from_slice(&mel.data[src_offset..src_offset + n_mels]);
156    }
157
158    Tensor {
159        shape: vec![1, chunk_time, n_mels],
160        data,
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_generate_starts_short() {
170        // 100 frames — fits in a single chunk
171        let starts = generate_starts(100);
172        assert_eq!(starts, vec![-6]);
173    }
174
175    #[test]
176    fn test_generate_starts_exact_chunk() {
177        // Exactly 1500 frames — needs 2 chunks because after border trimming
178        // the first chunk only covers frames 0..1488, leaving 12 frames uncovered.
179        let starts = generate_starts(1500);
180        assert_eq!(starts.len(), 2);
181        assert_eq!(starts[0], -6);
182        // Last adjusted: 1500 - (1500 - 6) = 6
183        assert_eq!(starts[1], 6);
184    }
185
186    #[test]
187    fn test_generate_starts_two_chunks() {
188        // 2000 frames — needs two chunks
189        let starts = generate_starts(2000);
190        assert_eq!(starts.len(), 2);
191        assert_eq!(starts[0], -6);
192        // Last start adjusted: 2000 - (1500 - 6) = 506
193        assert_eq!(starts[1], 506);
194    }
195
196    #[test]
197    fn test_generate_starts_long() {
198        // 5000 frames
199        let starts = generate_starts(5000);
200        assert_eq!(starts[0], -6);
201        // Stride = 1488, so: -6, 1482, 2970, and then last adjusted to 5000-1494=3506
202        assert_eq!(starts.len(), 4);
203        assert_eq!(starts[1], 1482);
204        assert_eq!(starts[2], 2970);
205        assert_eq!(starts[3], 3506);
206    }
207
208    #[test]
209    fn test_generate_starts_coverage() {
210        // Verify every frame is covered by at least one chunk (after border trimming).
211        for full_time in [50, 100, 500, 1488, 1500, 2000, 3000, 5000, 7800] {
212            let starts = generate_starts(full_time);
213            let mut covered = vec![false; full_time];
214            for &start in &starts {
215                let pad_left = (-start).max(0) as usize;
216                let actual_end = ((start + CHUNK_SIZE as i32) as usize).min(full_time);
217                let actual_start = start.max(0) as usize;
218                let n_frames = actual_end - actual_start;
219                let pad_right = 0
220                    .max((start + CHUNK_SIZE as i32 - full_time as i32).min(BORDER_SIZE as i32))
221                    as usize;
222                let chunk_time = pad_left + n_frames + pad_right;
223                let write_start = (start + BORDER_SIZE as i32).max(0) as usize;
224                let write_end =
225                    ((start as usize).wrapping_add(chunk_time) - BORDER_SIZE).min(full_time);
226                for i in write_start..write_end {
227                    covered[i] = true;
228                }
229            }
230            assert!(
231                covered.iter().all(|&c| c),
232                "Not all frames covered for full_time={full_time}. First uncovered: {}",
233                covered.iter().position(|&c| !c).unwrap()
234            );
235        }
236    }
237
238    #[test]
239    fn test_extract_chunk_short_audio() {
240        // Short audio (100 frames < STRIDE=1488): minimal padding.
241        // Python behavior: pad_left=6, frames=100, pad_right=6 → 112 total
242        let n_mels = 128;
243        let full_time = 100;
244        let mel = Tensor {
245            shape: vec![1, full_time, n_mels],
246            data: vec![1.0; full_time * n_mels],
247        };
248
249        let chunk = extract_chunk(&mel, -6);
250        // Expected: 6 (left pad) + 100 (data) + 6 (right pad) = 112
251        assert_eq!(chunk.shape, vec![1, 112, n_mels]);
252
253        // First 6 frames should be zero (left padding).
254        for t in 0..6 {
255            assert_eq!(
256                chunk.data[t * n_mels],
257                0.0,
258                "Expected zero padding at t={t}"
259            );
260        }
261        // Frame at t=6 should be 1.0 (from mel frame 0).
262        assert_eq!(chunk.data[6 * n_mels], 1.0);
263        // Last data frame at t=105 should be 1.0.
264        assert_eq!(chunk.data[105 * n_mels], 1.0);
265        // Last 6 frames should be zero (right padding).
266        for t in 106..112 {
267            assert_eq!(
268                chunk.data[t * n_mels],
269                0.0,
270                "Expected zero padding at t={t}"
271            );
272        }
273    }
274
275    #[test]
276    fn test_extract_chunk_long_audio_first() {
277        // Long audio, first chunk (start = -6): padded to CHUNK_SIZE.
278        let n_mels = 128;
279        let full_time = 5000;
280        let mel = Tensor {
281            shape: vec![1, full_time, n_mels],
282            data: vec![1.0; full_time * n_mels],
283        };
284
285        let chunk = extract_chunk(&mel, -6);
286        assert_eq!(chunk.shape, vec![1, CHUNK_SIZE, n_mels]);
287
288        // First 6 frames should be zero (padding).
289        for t in 0..6 {
290            assert_eq!(chunk.data[t * n_mels], 0.0);
291        }
292        // Frame at t=6 should be 1.0 (from mel frame 0).
293        assert_eq!(chunk.data[6 * n_mels], 1.0);
294    }
295
296    #[test]
297    fn test_extract_chunk_long_audio_middle() {
298        // Middle chunk (start = 100, full_time = 5000): no padding, full CHUNK_SIZE.
299        let n_mels = 128;
300        let full_time = 5000;
301        let mel = Tensor {
302            shape: vec![1, full_time, n_mels],
303            data: vec![1.0; full_time * n_mels],
304        };
305
306        let chunk = extract_chunk(&mel, 100);
307        assert_eq!(chunk.shape, vec![1, CHUNK_SIZE, n_mels]);
308
309        // All frames should be 1.0 (no padding needed).
310        assert!(chunk.data.iter().all(|&v| v == 1.0));
311    }
312}