Skip to main content

beat_this/
inference.rs

1use anyhow::{anyhow, ensure, Result};
2
3use crate::runtime::{InferenceSession, 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 inference 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 BeatInference<S: InferenceSession> {
19    session: S,
20}
21
22impl<S: InferenceSession> BeatInference<S> {
23    /// Wrap an already-loaded inference session for the beat model.
24    pub fn new(session: S) -> Self {
25        Self { session }
26    }
27
28    /// Get a mutable reference to the underlying session.
29    pub fn session_mut(&mut self) -> &mut S {
30        &mut self.session
31    }
32
33    /// Run inference on 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 process(&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
57            let mut outputs = self.session.run(&[("spectrogram", &chunk)])?;
58
59            let beat = extract_output(&mut outputs, "beat", "beat_logits")?;
60            let downbeat = extract_output(&mut outputs, "downbeat", "downbeat_logits")?;
61
62            // Strip border frames from both ends of the prediction.
63            let valid_beat = &beat.data[BORDER_SIZE..CHUNK_SIZE - BORDER_SIZE];
64            let valid_downbeat = &downbeat.data[BORDER_SIZE..CHUNK_SIZE - BORDER_SIZE];
65
66            // Write valid predictions to output buffers.
67            let write_start = (start + BORDER_SIZE as i32) as usize;
68            for i in 0..STRIDE {
69                let dest = write_start + i;
70                if dest < full_time {
71                    beat_logits[dest] = valid_beat[i];
72                    downbeat_logits[dest] = valid_downbeat[i];
73                }
74            }
75        }
76
77        Ok((beat_logits, downbeat_logits))
78    }
79}
80
81/// Extract a named output tensor, trying a fallback name if the primary is missing.
82fn extract_output(
83    outputs: &mut std::collections::HashMap<String, Tensor>,
84    primary: &str,
85    fallback: &str,
86) -> Result<Tensor> {
87    if let Some(t) = outputs.remove(primary) {
88        return Ok(t);
89    }
90    if let Some(t) = outputs.remove(fallback) {
91        return Ok(t);
92    }
93    Err(anyhow!(
94        "Model missing output '{}' (also tried '{}'). Available: {:?}",
95        primary,
96        fallback,
97        outputs.keys().collect::<Vec<_>>()
98    ))
99}
100
101/// Generate chunk start positions for a spectrogram of `full_time` frames.
102///
103/// Starts at `-BORDER_SIZE`, steps by `STRIDE`, and adjusts the last start
104/// to align with the spectrogram end (avoid_short_end).
105fn generate_starts(full_time: usize) -> Vec<i32> {
106    let mut starts = Vec::new();
107    let mut pos = -(BORDER_SIZE as i32);
108    let limit = full_time as i32 - BORDER_SIZE as i32;
109
110    while pos < limit {
111        starts.push(pos);
112        pos += STRIDE as i32;
113    }
114
115    // Avoid short end: shift the last start so the final chunk aligns
116    // with the spectrogram end.
117    if full_time > STRIDE {
118        if let Some(last) = starts.last_mut() {
119            *last = full_time as i32 - (CHUNK_SIZE as i32 - BORDER_SIZE as i32);
120        }
121    }
122
123    starts
124}
125
126/// Extract a single chunk from the mel spectrogram, zero-padding as needed.
127///
128/// Returns a `Tensor` of shape `[1, CHUNK_SIZE, 128]`.
129fn extract_chunk(mel: &Tensor, start: i32) -> Tensor {
130    let full_time = mel.shape[1];
131    let n_mels = mel.shape[2]; // 128
132
133    let actual_start = start.max(0) as usize;
134    let actual_end = ((start + CHUNK_SIZE as i32) as usize).min(full_time);
135    let pad_left = (-start).max(0) as usize;
136
137    let mut data = vec![0.0f32; CHUNK_SIZE * n_mels];
138
139    // Copy mel frames into the chunk at the correct offset.
140    for t in actual_start..actual_end {
141        let src_offset = t * n_mels;
142        let dst_t = pad_left + (t - actual_start);
143        let dst_offset = dst_t * n_mels;
144        data[dst_offset..dst_offset + n_mels]
145            .copy_from_slice(&mel.data[src_offset..src_offset + n_mels]);
146    }
147
148    Tensor {
149        shape: vec![1, CHUNK_SIZE, n_mels],
150        data,
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_generate_starts_short() {
160        // 100 frames — fits in a single chunk
161        let starts = generate_starts(100);
162        assert_eq!(starts, vec![-6]);
163    }
164
165    #[test]
166    fn test_generate_starts_exact_chunk() {
167        // Exactly 1500 frames — needs 2 chunks because after border trimming
168        // the first chunk only covers frames 0..1488, leaving 12 frames uncovered.
169        let starts = generate_starts(1500);
170        assert_eq!(starts.len(), 2);
171        assert_eq!(starts[0], -6);
172        // Last adjusted: 1500 - (1500 - 6) = 6
173        assert_eq!(starts[1], 6);
174    }
175
176    #[test]
177    fn test_generate_starts_two_chunks() {
178        // 2000 frames — needs two chunks
179        let starts = generate_starts(2000);
180        assert_eq!(starts.len(), 2);
181        assert_eq!(starts[0], -6);
182        // Last start adjusted: 2000 - (1500 - 6) = 506
183        assert_eq!(starts[1], 506);
184    }
185
186    #[test]
187    fn test_generate_starts_long() {
188        // 5000 frames
189        let starts = generate_starts(5000);
190        assert_eq!(starts[0], -6);
191        // Stride = 1488, so: -6, 1482, 2970, and then last adjusted to 5000-1494=3506
192        assert_eq!(starts.len(), 4);
193        assert_eq!(starts[1], 1482);
194        assert_eq!(starts[2], 2970);
195        assert_eq!(starts[3], 3506);
196    }
197
198    #[test]
199    fn test_generate_starts_coverage() {
200        // Verify every frame is covered by at least one chunk (after border trimming).
201        for full_time in [50, 100, 500, 1488, 1500, 2000, 3000, 5000, 7800] {
202            let starts = generate_starts(full_time);
203            let mut covered = vec![false; full_time];
204            for &start in &starts {
205                let write_start = (start + BORDER_SIZE as i32).max(0) as usize;
206                let write_end =
207                    ((start + CHUNK_SIZE as i32 - BORDER_SIZE as i32) as usize).min(full_time);
208                for i in write_start..write_end {
209                    covered[i] = true;
210                }
211            }
212            assert!(
213                covered.iter().all(|&c| c),
214                "Not all frames covered for full_time={full_time}. First uncovered: {}",
215                covered.iter().position(|&c| !c).unwrap()
216            );
217        }
218    }
219
220    #[test]
221    fn test_extract_chunk_first() {
222        // First chunk (start = -6): should have 6 frames of left padding.
223        let n_mels = 128;
224        let full_time = 100;
225        let mel = Tensor {
226            shape: vec![1, full_time, n_mels],
227            data: vec![1.0; full_time * n_mels],
228        };
229
230        let chunk = extract_chunk(&mel, -6);
231        assert_eq!(chunk.shape, vec![1, CHUNK_SIZE, n_mels]);
232
233        // First 6 frames should be zero (padding).
234        for t in 0..6 {
235            for f in 0..n_mels {
236                assert_eq!(
237                    chunk.data[t * n_mels + f],
238                    0.0,
239                    "Expected zero padding at t={t}, f={f}"
240                );
241            }
242        }
243        // Frame at t=6 should be 1.0 (from mel frame 0).
244        assert_eq!(chunk.data[6 * n_mels], 1.0);
245    }
246
247    #[test]
248    fn test_extract_chunk_middle() {
249        // Middle chunk (start = 100, full_time = 5000): no padding.
250        let n_mels = 128;
251        let full_time = 5000;
252        let mel = Tensor {
253            shape: vec![1, full_time, n_mels],
254            data: vec![1.0; full_time * n_mels],
255        };
256
257        let chunk = extract_chunk(&mel, 100);
258        assert_eq!(chunk.shape, vec![1, CHUNK_SIZE, n_mels]);
259
260        // All frames should be 1.0 (no padding needed).
261        assert!(chunk.data.iter().all(|&v| v == 1.0));
262    }
263}