mecomp_analysis/
chroma.rs

1//! Chroma feature extraction module.
2//!
3//! Contains functions to compute the chromagram of a song, and
4//! then from this chromagram extract the song's tone and mode
5//! (minor / major).
6extern crate noisy_float;
7
8use crate::Feature;
9
10use super::errors::{AnalysisError, AnalysisResult};
11use super::utils::{Normalize, hz_to_octs_inplace, stft};
12use ndarray::{Array, Array1, Array2, Axis, Zip, arr1, arr2, concatenate, s};
13use ndarray_stats::QuantileExt;
14use ndarray_stats::interpolate::Midpoint;
15use noisy_float::prelude::*;
16
17/**
18 * General object holding the chroma descriptor.
19 *
20 * Current chroma descriptors are interval features (see
21 * <https://speech.di.uoa.gr/ICMC-SMC-2014/images/VOL_2/1461.pdf>).
22 *
23 * Contrary to the other descriptors that can be used with streaming
24 * without consequences, this one performs better if the full song is used at
25 * once.
26 */
27#[derive(Debug, Clone)]
28#[allow(clippy::module_name_repetitions)]
29pub struct ChromaDesc {
30    sample_rate: u32,
31    n_chroma: u32,
32    values_chroma: Array2<f64>,
33}
34
35impl Normalize for ChromaDesc {
36    const MAX_VALUE: Feature = 0.12;
37    const MIN_VALUE: Feature = 0.;
38}
39
40impl ChromaDesc {
41    pub const WINDOW_SIZE: usize = 8192;
42
43    #[must_use]
44    #[inline]
45    pub fn new(sample_rate: u32, n_chroma: u32) -> Self {
46        Self {
47            sample_rate,
48            n_chroma,
49            values_chroma: Array2::zeros((n_chroma as usize, 0)),
50        }
51    }
52
53    /**
54     * Compute and store the chroma of a signal.
55     *
56     * Passing a full song here once instead of streaming smaller parts of the
57     * song will greatly improve accuracy.
58     */
59    #[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
60    #[inline]
61    pub fn do_(&mut self, signal: &[f32]) -> AnalysisResult<()> {
62        let mut stft = stft(signal, Self::WINDOW_SIZE, 2205);
63        let tuning = estimate_tuning(self.sample_rate, &stft, Self::WINDOW_SIZE, 0.01, 12)?;
64        let chroma = chroma_stft(
65            self.sample_rate,
66            &mut stft,
67            Self::WINDOW_SIZE,
68            self.n_chroma,
69            tuning,
70        )?;
71        self.values_chroma = concatenate![Axis(1), self.values_chroma, chroma];
72        Ok(())
73    }
74
75    /**
76     * Get the song's interval features.
77     *
78     * Return the 6 pitch class set categories, as well as the major, minor,
79     * diminished and augmented triads.
80     *
81     * See this paper <https://speech.di.uoa.gr/ICMC-SMC-2014/images/VOL_2/1461.pdf>
82     * for more information ("Timbre-invariant Audio Features for Style Analysis of Classical
83     * Music").
84     */
85    #[inline]
86    pub fn get_value(&mut self) -> Vec<Feature> {
87        #[allow(clippy::cast_possible_truncation)]
88        chroma_interval_features(&self.values_chroma)
89            .mapv(|x| self.normalize(x as Feature))
90            .to_vec()
91    }
92}
93
94// Functions below are Rust versions of python notebooks by AudioLabs Erlang
95// (<https://www.audiolabs-erlangen.de/resources/MIR/FMP/C0/C0.html>)
96#[allow(
97    clippy::missing_errors_doc,
98    clippy::missing_panics_doc,
99    clippy::module_name_repetitions
100)]
101#[must_use]
102#[inline]
103pub fn chroma_interval_features(chroma: &Array2<f64>) -> Array1<f64> {
104    let chroma = normalize_feature_sequence(&chroma.mapv(|x| (x * 15.).exp()));
105    let templates = arr2(&[
106        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
107        [1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
108        [0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
109        [0, 0, 1, 0, 0, 0, 0, 1, 1, 0],
110        [0, 0, 0, 1, 0, 0, 1, 0, 0, 1],
111        [0, 0, 0, 0, 1, 0, 0, 0, 0, 0],
112        [0, 0, 0, 0, 0, 1, 0, 0, 1, 0],
113        [0, 0, 0, 0, 0, 0, 1, 1, 0, 0],
114        [0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
115        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
116        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
117        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
118    ]);
119    let interval_feature_matrix = extract_interval_features(&chroma, &templates);
120    interval_feature_matrix.mean_axis(Axis(1)).unwrap()
121}
122
123#[must_use]
124#[inline]
125pub fn extract_interval_features(chroma: &Array2<f64>, templates: &Array2<i32>) -> Array2<f64> {
126    let mut f_intervals: Array2<f64> = Array::zeros((chroma.shape()[1], templates.shape()[1]));
127    for (template, mut f_interval) in templates
128        .axis_iter(Axis(1))
129        .zip(f_intervals.axis_iter_mut(Axis(1)))
130    {
131        for shift in 0..12 {
132            let mut vec: Vec<i32> = template.to_vec();
133            vec.rotate_right(shift);
134            let rolled = arr1(&vec);
135            let power = Zip::from(chroma.t())
136                .and_broadcast(&rolled)
137                .map_collect(|&f, &s| f.powi(s))
138                .map_axis_mut(Axis(1), |x| x.product());
139            f_interval += &power;
140        }
141    }
142    f_intervals.t().to_owned()
143}
144
145#[inline]
146pub fn normalize_feature_sequence(feature: &Array2<f64>) -> Array2<f64> {
147    let mut normalized_sequence = feature.to_owned();
148    for mut column in normalized_sequence.columns_mut() {
149        let mut sum = column.mapv(f64::abs).sum();
150        if sum < 0.0001 {
151            sum = 1.;
152        }
153        column /= sum;
154    }
155
156    normalized_sequence
157}
158
159// All the functions below are more than heavily inspired from
160// librosa"s code: https://github.com/librosa/librosa/blob/main/librosa/feature/spectral.py#L1165
161// chroma(22050, n_fft=5, n_chroma=12)
162//
163// Could be precomputed, but it takes very little time to compute it
164// on the fly compared to the rest of the functions, and we'd lose the
165// possibility to tweak parameters.
166#[allow(
167    clippy::missing_errors_doc,
168    clippy::missing_panics_doc,
169    clippy::module_name_repetitions,
170    clippy::missing_inline_in_public_items
171)]
172pub fn chroma_filter(
173    sample_rate: u32,
174    n_fft: usize,
175    n_chroma: u32,
176    tuning: f64,
177) -> AnalysisResult<Array2<f64>> {
178    let ctroct = 5.0;
179    let octwidth = 2.;
180    let n_chroma_float = f64::from(n_chroma);
181    #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
182    let n_chroma2 = (n_chroma_float / 2.0).round(); // NOTE: used to be f64::from((n_chroma_float / 2.0).round() as usize)
183
184    let frequencies = Array::linspace(0., f64::from(sample_rate), n_fft + 1);
185
186    let mut freq_bins = frequencies;
187    hz_to_octs_inplace(&mut freq_bins, tuning, n_chroma);
188    freq_bins.mapv_inplace(|x| x * n_chroma_float);
189    freq_bins[0] = 1.5f64.mul_add(-n_chroma_float, freq_bins[1]);
190
191    let mut binwidth_bins = Array::ones(freq_bins.raw_dim());
192    binwidth_bins.slice_mut(s![0..freq_bins.len() - 1]).assign(
193        &(&freq_bins.slice(s![1..]) - &freq_bins.slice(s![..-1]))
194            .mapv(|x| if x <= 1. { 1. } else { x }),
195    );
196
197    let mut d: Array2<f64> = Array::zeros((n_chroma as usize, (freq_bins).len()));
198    for (idx, mut row) in d.rows_mut().into_iter().enumerate() {
199        #[allow(clippy::cast_precision_loss)]
200        row.fill(idx as f64);
201    }
202    d = -d + &freq_bins;
203
204    d.mapv_inplace(|x| 10f64.mul_add(n_chroma_float, x + n_chroma2) % n_chroma_float - n_chroma2);
205    d = d / binwidth_bins;
206    d.mapv_inplace(|x| (-0.5 * (2. * x) * (2. * x)).exp());
207
208    let mut wts = d;
209    // Normalize by computing the l2-norm over the columns
210    for mut col in wts.columns_mut() {
211        let mut sum = col.mapv(|x| x * x).sum().sqrt();
212        if sum < f64::MIN_POSITIVE {
213            sum = 1.;
214        }
215        col /= sum;
216    }
217
218    freq_bins.mapv_inplace(|x| (-0.5 * ((x / n_chroma_float - ctroct) / octwidth).powi(2)).exp());
219
220    wts *= &freq_bins;
221
222    // np.roll(), np bro
223    let mut uninit: Vec<f64> = vec![0.; (wts).len()];
224    unsafe {
225        uninit.set_len(wts.len());
226    }
227    let mut b = Array::from(uninit)
228        .to_shape(wts.dim())
229        .map_err(|e| AnalysisError::AnalysisError(format!("in chroma: {e}")))?
230        .to_owned();
231    b.slice_mut(s![-3.., ..]).assign(&wts.slice(s![..3, ..]));
232    b.slice_mut(s![..-3, ..]).assign(&wts.slice(s![3.., ..]));
233
234    wts = b;
235    let non_aliased = 1 + n_fft / 2;
236    Ok(wts.slice_move(s![.., ..non_aliased]))
237}
238
239#[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
240#[allow(clippy::missing_inline_in_public_items)]
241pub fn pip_track(
242    sample_rate: u32,
243    spectrum: &Array2<f64>,
244    n_fft: usize,
245) -> AnalysisResult<(Vec<f64>, Vec<f64>)> {
246    let sample_rate_float = f64::from(sample_rate);
247    let fmin = 150.0_f64;
248    let fmax = 4000.0_f64.min(sample_rate_float / 2.0);
249    let threshold = 0.1;
250
251    let fft_freqs = Array::linspace(0., sample_rate_float / 2., 1 + n_fft / 2);
252
253    let length = spectrum.len_of(Axis(0));
254
255    // TODO>1.0 Make this a bitvec when that won't mean depending on a crate
256    let freq_mask = fft_freqs
257        .iter()
258        .map(|&f| (fmin <= f) && (f < fmax))
259        .collect::<Vec<bool>>();
260
261    let ref_value = spectrum.map_axis(Axis(0), |x| {
262        let first: f64 = *x.first().expect("empty spectrum axis");
263        let max = x.fold(first, |acc, &elem| if acc > elem { acc } else { elem });
264        threshold * max
265    });
266
267    // There will be at most taken_columns * length elements in pitches / mags
268    let taken_columns = freq_mask
269        .iter()
270        .fold(0, |acc, &x| if x { acc + 1 } else { acc });
271    let mut pitches = Vec::with_capacity(taken_columns * length);
272    let mut mags = Vec::with_capacity(taken_columns * length);
273
274    let beginning = freq_mask
275        .iter()
276        .position(|&b| b)
277        .ok_or_else(|| AnalysisError::AnalysisError(String::from("in chroma")))?;
278    let end = freq_mask
279        .iter()
280        .rposition(|&b| b)
281        .ok_or_else(|| AnalysisError::AnalysisError(String::from("in chroma")))?;
282
283    let zipped = Zip::indexed(spectrum.slice(s![beginning..end - 3, ..]))
284        .and(spectrum.slice(s![beginning + 1..end - 2, ..]))
285        .and(spectrum.slice(s![beginning + 2..end - 1, ..]));
286
287    // No need to handle the last column, since freq_mask[length - 1] is
288    // always going to be `false` for 22.5kHz
289    zipped.for_each(|(i, j), &before_elem, &elem, &after_elem| {
290        if elem > ref_value[j] && after_elem <= elem && before_elem < elem {
291            let avg = 0.5 * (after_elem - before_elem);
292            let mut shift = 2f64.mul_add(elem, -after_elem) - before_elem;
293            if shift.abs() < f64::MIN_POSITIVE {
294                shift += 1.;
295            }
296            shift = avg / shift;
297            #[allow(clippy::cast_precision_loss)]
298            pitches.push(((i + beginning + 1) as f64 + shift) * sample_rate_float / n_fft as f64);
299            mags.push((0.5 * avg).mul_add(shift, elem));
300        }
301    });
302
303    Ok((pitches, mags))
304}
305
306// Only use this with strictly positive `frequencies`.
307#[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
308#[inline]
309pub fn pitch_tuning(
310    frequencies: &mut Array1<f64>,
311    resolution: f64,
312    bins_per_octave: u32,
313) -> AnalysisResult<f64> {
314    if frequencies.is_empty() {
315        return Ok(0.0);
316    }
317    hz_to_octs_inplace(frequencies, 0.0, 12);
318    frequencies.mapv_inplace(|x| f64::from(bins_per_octave) * x % 1.0);
319
320    // Put everything between -0.5 and 0.5.
321    frequencies.mapv_inplace(|x| if x >= 0.5 { x - 1. } else { x });
322
323    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
324    let indexes = ((frequencies.to_owned() - -0.5) / resolution).mapv(|x| x as usize);
325    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
326    let mut counts: Array1<usize> = Array::zeros(((0.5 - -0.5) / resolution) as usize);
327    for &idx in &indexes {
328        counts[idx] += 1;
329    }
330    let max_index = counts
331        .argmax()
332        .map_err(|e| AnalysisError::AnalysisError(format!("in chroma: {e}")))?;
333
334    // Return the bin with the most reoccurring frequency.
335    #[allow(clippy::cast_precision_loss)]
336    Ok((100. * resolution).mul_add(max_index as f64, -50.) / 100.)
337}
338
339#[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
340#[inline]
341pub fn estimate_tuning(
342    sample_rate: u32,
343    spectrum: &Array2<f64>,
344    n_fft: usize,
345    resolution: f64,
346    bins_per_octave: u32,
347) -> AnalysisResult<f64> {
348    let (pitch, mag) = pip_track(sample_rate, spectrum, n_fft)?;
349
350    let (filtered_pitch, filtered_mag): (Vec<N64>, Vec<N64>) = pitch
351        .iter()
352        .zip(&mag)
353        .filter(|&(&p, _)| p > 0.)
354        .map(|(x, y)| (n64(*x), n64(*y)))
355        .unzip();
356
357    if pitch.is_empty() {
358        return Ok(0.);
359    }
360
361    let threshold: N64 = Array::from(filtered_mag.clone())
362        .quantile_axis_mut(Axis(0), n64(0.5), &Midpoint)
363        .map_err(|e| AnalysisError::AnalysisError(format!("in chroma: {e}")))?
364        .into_scalar();
365    let mut pitch = filtered_pitch
366        .iter()
367        .zip(&filtered_mag)
368        .filter_map(|(&p, &m)| if m >= threshold { Some(p.into()) } else { None })
369        .collect::<Array1<f64>>();
370    pitch_tuning(&mut pitch, resolution, bins_per_octave)
371}
372
373#[allow(
374    clippy::missing_errors_doc,
375    clippy::missing_panics_doc,
376    clippy::module_name_repetitions
377)]
378#[inline]
379pub fn chroma_stft(
380    sample_rate: u32,
381    spectrum: &mut Array2<f64>,
382    n_fft: usize,
383    n_chroma: u32,
384    tuning: f64,
385) -> AnalysisResult<Array2<f64>> {
386    spectrum.mapv_inplace(|x| x * x);
387    let mut raw_chroma = chroma_filter(sample_rate, n_fft, n_chroma, tuning)?;
388
389    raw_chroma = raw_chroma.dot(spectrum);
390    for mut row in raw_chroma.columns_mut() {
391        let mut sum = row.mapv(f64::abs).sum();
392        if sum < f64::MIN_POSITIVE {
393            sum = 1.;
394        }
395        row /= sum;
396    }
397    Ok(raw_chroma)
398}
399
400#[cfg(test)]
401mod test {
402    use super::*;
403    use crate::{
404        SAMPLE_RATE,
405        decoder::{Decoder as _, MecompDecoder as Decoder},
406        utils::stft,
407    };
408    use ndarray::{Array2, arr1, arr2};
409    use ndarray_npy::ReadNpyExt as _;
410    use std::{fs::File, path::Path};
411
412    #[test]
413    fn test_chroma_interval_features() {
414        let file = File::open("data/chroma.npy").unwrap();
415        let chroma = Array2::<f64>::read_npy(file).unwrap();
416        let features = chroma_interval_features(&chroma);
417        let expected_features = arr1(&[
418            0.038_602_84,
419            0.021_852_81,
420            0.042_243_79,
421            0.063_852_78,
422            0.073_111_48,
423            0.025_125_66,
424            0.003_198_99,
425            0.003_113_08,
426            0.001_074_33,
427            0.002_418_61,
428        ]);
429        for (expected, actual) in expected_features.iter().zip(&features) {
430            assert!(
431                0.000_000_01 > (expected - actual.abs()),
432                "{expected} !~= {actual}"
433            );
434        }
435    }
436
437    #[test]
438    fn test_extract_interval_features() {
439        let file = File::open("data/chroma-interval.npy").unwrap();
440        let chroma = Array2::<f64>::read_npy(file).unwrap();
441        let templates = arr2(&[
442            [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
443            [1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
444            [0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
445            [0, 0, 1, 0, 0, 0, 0, 1, 1, 0],
446            [0, 0, 0, 1, 0, 0, 1, 0, 0, 1],
447            [0, 0, 0, 0, 1, 0, 0, 0, 0, 0],
448            [0, 0, 0, 0, 0, 1, 0, 0, 1, 0],
449            [0, 0, 0, 0, 0, 0, 1, 1, 0, 0],
450            [0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
451            [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
452            [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
453            [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
454        ]);
455
456        let file = File::open("data/interval-feature-matrix.npy").unwrap();
457        let expected_interval_features = Array2::<f64>::read_npy(file).unwrap();
458
459        let interval_features = extract_interval_features(&chroma, &templates);
460        for (expected, actual) in expected_interval_features
461            .iter()
462            .zip(interval_features.iter())
463        {
464            assert!(
465                0.000_000_1 > (expected - actual).abs(),
466                "{expected} !~= {actual}"
467            );
468        }
469    }
470
471    #[test]
472    fn test_normalize_feature_sequence() {
473        let array = arr2(&[[0.1, 0.3, 0.4], [1.1, 0.53, 1.01]]);
474        let expected_array = arr2(&[
475            [0.083_333_33, 0.361_445_78, 0.283_687_94],
476            [0.916_666_67, 0.638_554_22, 0.716_312_06],
477        ]);
478
479        let normalized_array = normalize_feature_sequence(&array);
480
481        assert!(!array.is_empty() && !expected_array.is_empty());
482
483        for (expected, actual) in normalized_array.iter().zip(expected_array.iter()) {
484            assert!(
485                0.000_000_1 > (expected - actual).abs(),
486                "{expected} !~= {actual}"
487            );
488        }
489    }
490
491    #[test]
492    fn test_chroma_desc() {
493        let song = Decoder::new()
494            .unwrap()
495            .decode(Path::new("data/s16_mono_22_5kHz.flac"))
496            .unwrap();
497        let mut chroma_desc = ChromaDesc::new(SAMPLE_RATE, 12);
498        chroma_desc.do_(&song.samples).unwrap();
499        let expected_values = [
500            -0.356_619_36,
501            -0.635_786_53,
502            -0.295_936_82,
503            0.064_213_04,
504            0.218_524_58,
505            -0.581_239,
506            -0.946_683_5,
507            -0.948_115_3,
508            -0.982_094_5,
509            -0.959_689_74,
510        ];
511        for (expected, actual) in expected_values.iter().zip(chroma_desc.get_value().iter()) {
512            // original test wanted absolute error < 0.0000001
513            let relative_error = (expected - actual).abs() / expected.abs();
514            assert!(
515                relative_error < 0.01,
516                "relative error: {relative_error}, expected: {expected}, actual: {actual}"
517            );
518        }
519    }
520
521    #[test]
522    fn test_chroma_stft_decode() {
523        let signal = Decoder::new()
524            .unwrap()
525            .decode(Path::new("data/s16_mono_22_5kHz.flac"))
526            .unwrap()
527            .samples;
528        let mut stft = stft(&signal, 8192, 2205);
529
530        let file = File::open("data/chroma.npy").unwrap();
531        let expected_chroma = Array2::<f64>::read_npy(file).unwrap();
532
533        let chroma = chroma_stft(22050, &mut stft, 8192, 12, -0.049_999_999_999_999_99).unwrap();
534
535        assert!(!chroma.is_empty() && !expected_chroma.is_empty());
536
537        for (expected, actual) in expected_chroma.iter().zip(chroma.iter()) {
538            // original test wanted absolute error < 0.0000001
539            let relative_error = (expected - actual).abs() / expected.abs();
540            assert!(
541                relative_error < 0.01,
542                "relative error: {relative_error}, expected: {expected}, actual: {actual}"
543            );
544        }
545    }
546
547    #[test]
548    fn test_estimate_tuning() {
549        let file = File::open("data/spectrum-chroma.npy").unwrap();
550        let arr = Array2::<f64>::read_npy(file).unwrap();
551
552        let tuning = estimate_tuning(22050, &arr, 2048, 0.01, 12).unwrap();
553        assert!(
554            0.000_001 > (-0.099_999_999_999_999_98 - tuning).abs(),
555            "{tuning} !~= -0.09999999999999998"
556        );
557    }
558
559    #[test]
560    fn test_chroma_estimate_tuning_empty_fix() {
561        assert!(0. == estimate_tuning(22050, &Array2::zeros((8192, 1)), 8192, 0.01, 12).unwrap());
562    }
563
564    #[test]
565    fn test_estimate_tuning_decode() {
566        let signal = Decoder::new()
567            .unwrap()
568            .decode(Path::new("data/s16_mono_22_5kHz.flac"))
569            .unwrap()
570            .samples;
571        let stft = stft(&signal, 8192, 2205);
572
573        let tuning = estimate_tuning(22050, &stft, 8192, 0.01, 12).unwrap();
574        assert!(
575            0.000_001 > (-0.049_999_999_999_999_99 - tuning).abs(),
576            "{tuning} !~= -0.04999999999999999"
577        );
578    }
579
580    #[test]
581    fn test_pitch_tuning() {
582        let file = File::open("data/pitch-tuning.npy").unwrap();
583        let mut pitch = Array1::<f64>::read_npy(file).unwrap();
584        let tuned = pitch_tuning(&mut pitch, 0.05, 12).unwrap();
585        assert!(f64::EPSILON > (tuned + 0.1).abs(), "{tuned} != -0.1");
586    }
587
588    #[test]
589    fn test_pitch_tuning_no_frequencies() {
590        let mut frequencies = arr1(&[]);
591        let tuned = pitch_tuning(&mut frequencies, 0.05, 12).unwrap();
592        assert!(f64::EPSILON > tuned.abs(), "{tuned} != 0");
593    }
594
595    #[test]
596    fn test_pip_track() {
597        let file = File::open("data/spectrum-chroma.npy").unwrap();
598        let spectrum = Array2::<f64>::read_npy(file).unwrap();
599
600        let mags_file = File::open("data/spectrum-chroma-mags.npy").unwrap();
601        let expected_mags = Array1::<f64>::read_npy(mags_file).unwrap();
602
603        let pitches_file = File::open("data/spectrum-chroma-pitches.npy").unwrap();
604        let expected_pitches = Array1::<f64>::read_npy(pitches_file).unwrap();
605
606        let (mut pitches, mut mags) = pip_track(22050, &spectrum, 2048).unwrap();
607        pitches.sort_by(|a, b| a.partial_cmp(b).unwrap());
608        mags.sort_by(|a, b| a.partial_cmp(b).unwrap());
609
610        for (expected_pitches, actual_pitches) in expected_pitches.iter().zip(pitches.iter()) {
611            assert!(
612                0.000_000_01 > (expected_pitches - actual_pitches).abs(),
613                "{expected_pitches} !~= {actual_pitches}"
614            );
615        }
616        for (expected_mags, actual_mags) in expected_mags.iter().zip(mags.iter()) {
617            assert!(
618                0.000_000_01 > (expected_mags - actual_mags).abs(),
619                "{expected_mags} !~= {actual_mags}"
620            );
621        }
622    }
623
624    #[test]
625    fn test_chroma_filter() {
626        let file = File::open("data/chroma-filter.npy").unwrap();
627        let expected_filter = Array2::<f64>::read_npy(file).unwrap();
628
629        let filter = chroma_filter(22050, 2048, 12, -0.1).unwrap();
630
631        for (expected, actual) in expected_filter.iter().zip(filter.iter()) {
632            assert!(
633                0.000_000_001 > (expected - actual).abs(),
634                "{expected} !~= {actual}"
635            );
636        }
637    }
638}