Skip to main content

resonant_analysis/
onset.rs

1//! Onset detection via spectral flux and adaptive thresholding.
2//!
3//! An onset marks the beginning of a new musical event (note, drum hit, etc.).
4//! This module computes the onset strength envelope using half-wave rectified
5//! spectral flux, then picks peaks above an adaptive threshold.
6
7extern crate alloc;
8
9use alloc::vec;
10use alloc::vec::Vec;
11
12use resonant_core::signal::Signal;
13use resonant_core::window;
14use resonant_fft::stft::Stft;
15use resonant_fft::SignalFreqExt;
16
17use crate::error::AnalysisError;
18
19/// A detected onset event.
20///
21/// # Examples
22///
23/// ```
24/// use resonant_analysis::onset::Onset;
25///
26/// let o = Onset { sample_index: 22050, time_secs: 0.5, strength: 1.2 };
27/// assert_eq!(o.time_secs, 0.5);
28/// ```
29#[derive(Debug, Clone, Copy, PartialEq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub struct Onset {
32    /// Sample index of the onset.
33    pub sample_index: usize,
34    /// Time in seconds.
35    pub time_secs: f32,
36    /// Strength of the onset (spectral flux value).
37    pub strength: f32,
38}
39
40/// Onset detector with configurable STFT and threshold parameters.
41///
42/// # Examples
43///
44/// ```
45/// use resonant_analysis::onset::OnsetDetector;
46///
47/// let detector = OnsetDetector::new(44100.0);
48/// // Silence → no onsets
49/// let onsets = detector.detect(&vec![0.0_f32; 4096]).unwrap();
50/// assert!(onsets.is_empty());
51/// ```
52#[derive(Debug, Clone)]
53pub struct OnsetDetector {
54    sample_rate: f32,
55    window_size: usize,
56    hop_size: usize,
57    threshold_multiplier: f32,
58    median_window: usize,
59}
60
61impl OnsetDetector {
62    /// Creates a detector with default parameters.
63    ///
64    /// Defaults: window 1024, hop 512, threshold multiplier 1.5, median window 10.
65    #[must_use]
66    pub fn new(sample_rate: f32) -> Self {
67        Self {
68            sample_rate,
69            window_size: 1024,
70            hop_size: 512,
71            threshold_multiplier: 1.5,
72            median_window: 10,
73        }
74    }
75
76    /// Sets the STFT window size (must be a power of two).
77    #[must_use]
78    pub fn with_window_size(mut self, size: usize) -> Self {
79        self.window_size = size;
80        self
81    }
82
83    /// Returns the configured hop size in samples.
84    #[must_use]
85    #[inline]
86    pub fn hop_size(&self) -> usize {
87        self.hop_size
88    }
89
90    /// Sets the STFT hop size.
91    #[must_use]
92    pub fn with_hop_size(mut self, hop: usize) -> Self {
93        self.hop_size = hop;
94        self
95    }
96
97    /// Sets the adaptive threshold multiplier.
98    ///
99    /// Higher values → fewer onsets (stricter). Lower → more onsets.
100    #[must_use]
101    pub fn with_threshold_multiplier(mut self, m: f32) -> Self {
102        self.threshold_multiplier = m;
103        self
104    }
105
106    /// Sets the median filter window size for adaptive thresholding.
107    #[must_use]
108    pub fn with_median_window(mut self, w: usize) -> Self {
109        self.median_window = w;
110        self
111    }
112
113    /// Detects onsets in mono audio samples.
114    ///
115    /// Returns a list of [`Onset`] events sorted by time.
116    ///
117    /// # Errors
118    ///
119    /// Returns [`AnalysisError::EmptyInput`] if `samples` is empty, or
120    /// an FFT error if the window size is invalid.
121    pub fn detect(&self, samples: &[f32]) -> Result<Vec<Onset>, AnalysisError> {
122        let envelope = self.onset_strength(samples)?;
123        let threshold =
124            adaptive_threshold(&envelope, self.median_window, self.threshold_multiplier);
125        let peaks = pick_peaks(&envelope, &threshold);
126
127        let onsets = peaks
128            .into_iter()
129            .map(|(frame_idx, strength)| {
130                let sample_index = frame_idx * self.hop_size;
131                Onset {
132                    sample_index,
133                    time_secs: sample_index as f32 / self.sample_rate,
134                    strength,
135                }
136            })
137            .collect();
138
139        Ok(onsets)
140    }
141
142    /// Computes the onset strength envelope (spectral flux per STFT frame).
143    ///
144    /// Returns one value per frame. The first frame always has strength 0.
145    ///
146    /// # Errors
147    ///
148    /// Returns [`AnalysisError::EmptyInput`] if `samples` is empty.
149    pub fn onset_strength(&self, samples: &[f32]) -> Result<Vec<f32>, AnalysisError> {
150        if samples.is_empty() {
151            return Err(AnalysisError::EmptyInput);
152        }
153
154        // If the signal is shorter than one window, no frames can be extracted
155        if samples.len() < self.window_size {
156            return Ok(vec![0.0]);
157        }
158
159        let signal = Signal::from_samples(samples.to_vec());
160        let stft = Stft::builder(self.window_size, self.hop_size)
161            .window_fn(window::hann)
162            .build();
163
164        let frames = stft.analyze(&signal)?;
165        if frames.is_empty() {
166            return Ok(vec![0.0]);
167        }
168
169        let magnitudes: Vec<Vec<f32>> = frames.iter().map(|f| f.magnitude()).collect();
170
171        let mut envelope = Vec::with_capacity(magnitudes.len());
172        envelope.push(0.0); // first frame has no previous frame
173
174        for i in 1..magnitudes.len() {
175            envelope.push(spectral_flux(&magnitudes[i - 1], &magnitudes[i]));
176        }
177
178        Ok(envelope)
179    }
180}
181
182/// Half-wave rectified spectral flux between consecutive magnitude frames.
183fn spectral_flux(prev: &[f32], curr: &[f32]) -> f32 {
184    prev.iter()
185        .zip(curr)
186        .map(|(prev_mag, curr_mag)| (curr_mag - prev_mag).max(0.0))
187        .sum()
188}
189
190/// Adaptive threshold: local median + multiplier × local mean absolute deviation.
191fn adaptive_threshold(envelope: &[f32], window: usize, multiplier: f32) -> Vec<f32> {
192    let envelope_len = envelope.len();
193    let mut thresholds = Vec::with_capacity(envelope_len);
194    let half_window = window / 2;
195
196    for i in 0..envelope_len {
197        let start = i.saturating_sub(half_window);
198        let end = (i + half_window + 1).min(envelope_len);
199        let local = &envelope[start..end];
200
201        let median = local_median(local);
202        let mad = local_mad(local, median);
203        thresholds.push(median + multiplier * mad);
204    }
205
206    thresholds
207}
208
209/// Median of a small slice (copies and sorts).
210fn local_median(values: &[f32]) -> f32 {
211    if values.is_empty() {
212        return 0.0;
213    }
214    let mut sorted: Vec<f32> = values.to_vec();
215    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
216    let median_idx = sorted.len() / 2;
217    if sorted.len() % 2 == 0 {
218        (sorted[median_idx - 1] + sorted[median_idx]) / 2.0
219    } else {
220        sorted[median_idx]
221    }
222}
223
224/// Mean absolute deviation from the median.
225fn local_mad(values: &[f32], median: f32) -> f32 {
226    if values.is_empty() {
227        return 0.0;
228    }
229    let sum: f32 = values.iter().map(|sample| (sample - median).abs()).sum();
230    sum / values.len() as f32
231}
232
233/// Picks local maxima in the envelope that exceed the adaptive threshold.
234fn pick_peaks(envelope: &[f32], threshold: &[f32]) -> Vec<(usize, f32)> {
235    let mut peaks = Vec::new();
236    for i in 1..envelope.len().saturating_sub(1) {
237        let flux_value = envelope[i];
238        if flux_value > threshold[i]
239            && flux_value >= envelope[i - 1]
240            && flux_value >= envelope[i + 1]
241        {
242            peaks.push((i, flux_value));
243        }
244    }
245    peaks
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use core::f32::consts::PI;
252
253    const SR: f32 = 44100.0;
254
255    #[test]
256    fn silence_no_onsets() {
257        let detector = OnsetDetector::new(SR);
258        let samples = vec![0.0_f32; 8192];
259        let onsets = detector.detect(&samples).ok();
260        assert_eq!(onsets.as_ref().map(|o| o.len()), Some(0));
261    }
262
263    #[test]
264    fn empty_input_returns_error() {
265        let detector = OnsetDetector::new(SR);
266        let result = detector.detect(&[]);
267        assert_eq!(result, Err(AnalysisError::EmptyInput));
268    }
269
270    #[test]
271    fn onset_strength_empty_error() {
272        let detector = OnsetDetector::new(SR);
273        let result = detector.onset_strength(&[]);
274        assert_eq!(result, Err(AnalysisError::EmptyInput));
275    }
276
277    #[test]
278    fn onset_strength_nonnegative() {
279        let detector = OnsetDetector::new(SR);
280        let samples: Vec<f32> = (0..8192)
281            .map(|i| (2.0 * PI * 440.0 * i as f32 / SR).sin())
282            .collect();
283        let envelope = detector.onset_strength(&samples).ok();
284        if let Some(env) = &envelope {
285            for &v in env {
286                assert!(v >= 0.0, "negative onset strength: {v}");
287            }
288        }
289    }
290
291    #[test]
292    fn onset_strength_first_frame_zero() {
293        let detector = OnsetDetector::new(SR);
294        let samples = vec![1.0_f32; 4096];
295        let envelope = detector.onset_strength(&samples).ok();
296        assert!(envelope.as_ref().is_some_and(|e| e[0] == 0.0));
297    }
298
299    #[test]
300    fn click_track_detects_onsets() {
301        // Create a click track: silence with impulses at known positions
302        // 1 second of silence; place clicks at 0.2s, 0.5s, 0.8s
303        let mut samples = vec![0.0_f32; 44100];
304        let click_positions = [8820_usize, 22050, 35280];
305        for &pos in &click_positions {
306            // Short burst (64 samples of sine at 1kHz)
307            for j in 0..64 {
308                if pos + j < samples.len() {
309                    samples[pos + j] = (2.0 * PI * 1000.0 * j as f32 / SR).sin();
310                }
311            }
312        }
313
314        let detector = OnsetDetector::new(SR)
315            .with_window_size(1024)
316            .with_hop_size(512)
317            .with_threshold_multiplier(1.0);
318
319        let onsets = detector.detect(&samples).ok();
320        let onsets = onsets.as_ref();
321
322        // Should detect at least 2 of the 3 clicks (the first may be missed
323        // due to edge effects)
324        assert!(
325            onsets.is_some_and(|o| o.len() >= 2),
326            "expected >=2 onsets, got {:?}",
327            onsets.map(|o| o.len())
328        );
329
330        // Each detected onset should be near one of the click positions
331        if let Some(onsets) = onsets {
332            for onset in onsets {
333                let near_click = click_positions.iter().any(|&pos| {
334                    let diff = (onset.sample_index as i64 - pos as i64).unsigned_abs() as usize;
335                    diff < 2048 // within ~2 hops
336                });
337                assert!(
338                    near_click,
339                    "onset at sample {} not near any click",
340                    onset.sample_index
341                );
342            }
343        }
344    }
345
346    #[test]
347    fn sine_onset_detected() {
348        // Silence then sine: onset at the transition
349        let mut samples = vec![0.0_f32; 8192];
350        let onset_pos = 4096;
351        for i in onset_pos..8192 {
352            samples[i] = (2.0 * PI * 440.0 * (i - onset_pos) as f32 / SR).sin();
353        }
354
355        let detector = OnsetDetector::new(SR)
356            .with_window_size(1024)
357            .with_hop_size(512)
358            .with_threshold_multiplier(1.0);
359
360        let onsets = detector.detect(&samples).ok();
361        assert!(
362            onsets.as_ref().is_some_and(|o| !o.is_empty()),
363            "should detect onset at silence→sine transition"
364        );
365
366        // The detected onset should be near the actual transition
367        if let Some(onsets) = &onsets {
368            let nearest = onsets
369                .iter()
370                .min_by_key(|o| (o.sample_index as i64 - onset_pos as i64).unsigned_abs())
371                .map(|o| o.sample_index);
372            assert!(
373                nearest.is_some_and(|n| (n as i64 - onset_pos as i64).unsigned_abs() < 2048),
374                "onset too far from transition: {nearest:?}"
375            );
376        }
377    }
378
379    #[test]
380    fn short_signal_returns_single_zero() {
381        let detector = OnsetDetector::new(SR).with_window_size(1024);
382        let samples = vec![1.0_f32; 512]; // shorter than window
383        let env = detector.onset_strength(&samples).ok();
384        assert_eq!(env.as_deref(), Some([0.0].as_slice()));
385    }
386
387    #[test]
388    fn builder_methods() {
389        let d = OnsetDetector::new(SR)
390            .with_window_size(2048)
391            .with_hop_size(1024)
392            .with_threshold_multiplier(2.0)
393            .with_median_window(20);
394        assert_eq!(d.window_size, 2048);
395        assert_eq!(d.hop_size, 1024);
396        assert_eq!(d.threshold_multiplier, 2.0);
397        assert_eq!(d.median_window, 20);
398    }
399
400    #[test]
401    fn onset_time_matches_sample_index() {
402        // Check that time_secs = sample_index / sample_rate
403        let mut samples = vec![0.0_f32; 8192];
404        for i in 4096..8192 {
405            samples[i] = (2.0 * PI * 1000.0 * i as f32 / SR).sin();
406        }
407
408        let detector = OnsetDetector::new(SR)
409            .with_window_size(1024)
410            .with_hop_size(512)
411            .with_threshold_multiplier(1.0);
412
413        let onsets = detector.detect(&samples).ok();
414        if let Some(onsets) = &onsets {
415            for o in onsets {
416                let expected_time = o.sample_index as f32 / SR;
417                assert!(
418                    (o.time_secs - expected_time).abs() < 1e-6,
419                    "time mismatch: {} vs {expected_time}",
420                    o.time_secs
421                );
422            }
423        }
424    }
425
426    // --- internal helper tests ---
427
428    #[test]
429    fn spectral_flux_identical_is_zero() {
430        let a = [1.0, 2.0, 3.0];
431        assert_eq!(spectral_flux(&a, &a), 0.0);
432    }
433
434    #[test]
435    fn spectral_flux_increase_only() {
436        let prev = [0.0, 0.0, 0.0];
437        let curr = [1.0, 2.0, 3.0];
438        assert_eq!(spectral_flux(&prev, &curr), 6.0);
439    }
440
441    #[test]
442    fn spectral_flux_decrease_ignored() {
443        let prev = [3.0, 2.0, 1.0];
444        let curr = [0.0, 0.0, 0.0];
445        assert_eq!(spectral_flux(&prev, &curr), 0.0);
446    }
447
448    #[test]
449    fn local_median_odd() {
450        assert_eq!(local_median(&[3.0, 1.0, 2.0]), 2.0);
451    }
452
453    #[test]
454    fn local_median_even() {
455        assert_eq!(local_median(&[1.0, 3.0, 2.0, 4.0]), 2.5);
456    }
457
458    #[test]
459    fn local_median_empty() {
460        assert_eq!(local_median(&[]), 0.0);
461    }
462
463    #[test]
464    fn local_mad_uniform() {
465        assert_eq!(local_mad(&[5.0, 5.0, 5.0], 5.0), 0.0);
466    }
467
468    #[cfg(feature = "serde")]
469    #[test]
470    fn onset_serde_roundtrip() {
471        let o = Onset {
472            sample_index: 22050,
473            time_secs: 0.5,
474            strength: 1.2,
475        };
476        let json = serde_json::to_string(&o).unwrap_or_else(|e| panic!("serialize Onset: {e}"));
477        let back: Onset =
478            serde_json::from_str(&json).unwrap_or_else(|e| panic!("deserialize Onset: {e}"));
479        assert_eq!(o, back);
480    }
481}