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