mecomp_analysis/
temporal.rs

1//! Temporal feature extraction module.
2//!
3//! Contains functions to extract & summarize the temporal aspects
4//! of a given Song.
5
6use crate::Feature;
7
8use super::errors::{AnalysisError, AnalysisResult};
9use super::utils::Normalize;
10use bliss_audio_aubio_rs::{OnsetMode, Tempo};
11use likely_stable::{LikelyResult, unlikely};
12use log::warn;
13use ndarray::arr1;
14use ndarray_stats::Quantile1dExt;
15use ndarray_stats::interpolate::Midpoint;
16use noisy_float::prelude::*;
17
18/**
19 * Beats per minutes ([BPM](https://en.wikipedia.org/wiki/Tempo#Measurement))
20 * detection object.
21 *
22 * It indicates the (subjective) "speed" of a music piece. The higher the BPM,
23 * the "quicker" the song will feel.
24 *
25 * It uses `SpecFlux`, a phase-deviation onset detection function to perform
26 * onset detection; it proved to be the best for finding out the BPM of a panel
27 * of songs I had, but it could very well be replaced by something better in the
28 * future.
29 *
30 * Ranges from 0 (theoretically...) to 206 BPM. (Even though aubio apparently
31 * has trouble to identify tempo > 190 BPM - did not investigate too much)
32 *
33 */
34pub struct BPMDesc {
35    aubio_obj: Tempo,
36    bpms: Vec<f32>,
37}
38
39// TODO>1.0 use the confidence value to discard this descriptor if confidence
40// is too low.
41impl BPMDesc {
42    pub const WINDOW_SIZE: usize = 512;
43    pub const HOP_SIZE: usize = Self::WINDOW_SIZE / 2;
44
45    #[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
46    #[inline]
47    pub fn new(sample_rate: u32) -> AnalysisResult<Self> {
48        Ok(Self {
49            aubio_obj: Tempo::new(
50                OnsetMode::SpecFlux,
51                Self::WINDOW_SIZE,
52                Self::HOP_SIZE,
53                sample_rate,
54            )
55            .map_err_unlikely(|e| {
56                AnalysisError::AnalysisError(format!("error while loading aubio tempo object: {e}"))
57            })?,
58            bpms: Vec::new(),
59        })
60    }
61
62    #[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
63    #[inline]
64    pub fn do_(&mut self, chunk: &[f32]) -> AnalysisResult<()> {
65        let result = self.aubio_obj.do_result(chunk).map_err_unlikely(|e| {
66            AnalysisError::AnalysisError(format!("aubio error while computing tempo {e}"))
67        })?;
68
69        if result > 0. {
70            self.bpms.push(self.aubio_obj.get_bpm());
71        }
72        Ok(())
73    }
74
75    /**
76     * Compute score related to tempo.
77     * Right now, basically returns the song's BPM.
78     *
79     * - `song` Song to compute score from
80     */
81    #[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
82    #[inline]
83    pub fn get_value(&mut self) -> Feature {
84        if unlikely(self.bpms.is_empty()) {
85            warn!("Set tempo value to zero because no beats were found.");
86            return -1.;
87        }
88        let median = arr1(&self.bpms)
89            .mapv(n32)
90            .quantile_mut(n64(0.5), &Midpoint)
91            .unwrap();
92        self.normalize(median.into())
93    }
94}
95
96impl Normalize for BPMDesc {
97    // See aubio/src/tempo/beattracking.c:387
98    // Should really be 413, needs testing
99    const MAX_VALUE: Feature = 206.;
100    const MIN_VALUE: Feature = 0.;
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::{
107        SAMPLE_RATE,
108        decoder::{Decoder as DecoderTrait, MecompDecoder as Decoder},
109    };
110    use std::path::Path;
111
112    #[test]
113    fn test_tempo_real() {
114        let song = Decoder::new()
115            .unwrap()
116            .decode(Path::new("data/s16_mono_22_5kHz.flac"))
117            .unwrap();
118        let mut tempo_desc = BPMDesc::new(SAMPLE_RATE).unwrap();
119        for chunk in song.samples.chunks_exact(BPMDesc::HOP_SIZE) {
120            tempo_desc.do_(chunk).unwrap();
121        }
122        assert!(
123            0.01 > (0.378_605 - tempo_desc.get_value()).abs(),
124            "{} !~= 0.378605",
125            tempo_desc.get_value()
126        );
127    }
128
129    #[test]
130    fn test_tempo_artificial() {
131        let mut tempo_desc = BPMDesc::new(22050).unwrap();
132        // This gives one beat every second, so 60 BPM
133        let mut one_chunk = vec![0.; 22000];
134        one_chunk.append(&mut vec![1.; 100]);
135        let chunks = std::iter::repeat_n(one_chunk.iter(), 100)
136            .flatten()
137            .copied()
138            .collect::<Vec<f32>>();
139        for chunk in chunks.chunks_exact(BPMDesc::HOP_SIZE) {
140            tempo_desc.do_(chunk).unwrap();
141        }
142
143        // -0.41 is 60 BPM normalized
144        assert!(
145            0.01 > (-0.416_853 - tempo_desc.get_value()).abs(),
146            "{} !~= -0.416853",
147            tempo_desc.get_value()
148        );
149    }
150
151    #[test]
152    fn test_tempo_boundaries() {
153        let mut tempo_desc = BPMDesc::new(10).unwrap();
154        let silence_chunk = vec![0.; 1024];
155        tempo_desc.do_(&silence_chunk).unwrap();
156        let value = tempo_desc.get_value();
157        assert!(f64::EPSILON > (-1. - value).abs(), "{value} !~= -1");
158
159        let mut tempo_desc = BPMDesc::new(22050).unwrap();
160        // The highest value I could obtain was with these params, even though
161        // apparently the higher bound is 206 BPM, but here I found ~189 BPM.
162        let mut one_chunk = vec![0.; 6989];
163        one_chunk.append(&mut vec![1.; 20]);
164        let chunks = std::iter::repeat_n(one_chunk.iter(), 500)
165            .flatten()
166            .copied()
167            .collect::<Vec<f32>>();
168        for chunk in chunks.chunks_exact(BPMDesc::HOP_SIZE) {
169            tempo_desc.do_(chunk).unwrap();
170        }
171        // 0.86 is 192BPM normalized
172        assert!(
173            0.01 > (0.86 - tempo_desc.get_value()).abs(),
174            "{} !~= 0.86",
175            tempo_desc.get_value()
176        );
177    }
178}