1extern crate noisy_float;
7
8use crate::Feature;
9
10use super::errors::{AnalysisError, AnalysisResult};
11use super::utils::{hz_to_octs_inplace, stft, Normalize};
12use ndarray::{arr1, arr2, concatenate, s, Array, Array1, Array2, Axis, Zip};
13use ndarray_stats::interpolate::Midpoint;
14use ndarray_stats::QuantileExt;
15use noisy_float::prelude::*;
16
17#[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 #[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 #[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#[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#[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(); 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])).mapv(|x| {
194 if x <= 1. {
195 1.
196 } else {
197 x
198 }
199 }),
200 );
201
202 let mut d: Array2<f64> = Array::zeros((n_chroma as usize, (freq_bins).len()));
203 for (idx, mut row) in d.rows_mut().into_iter().enumerate() {
204 #[allow(clippy::cast_precision_loss)]
205 row.fill(idx as f64);
206 }
207 d = -d + &freq_bins;
208
209 d.mapv_inplace(|x| 10f64.mul_add(n_chroma_float, x + n_chroma2) % n_chroma_float - n_chroma2);
210 d = d / binwidth_bins;
211 d.mapv_inplace(|x| (-0.5 * (2. * x) * (2. * x)).exp());
212
213 let mut wts = d;
214 for mut col in wts.columns_mut() {
216 let mut sum = col.mapv(|x| x * x).sum().sqrt();
217 if sum < f64::MIN_POSITIVE {
218 sum = 1.;
219 }
220 col /= sum;
221 }
222
223 freq_bins.mapv_inplace(|x| (-0.5 * ((x / n_chroma_float - ctroct) / octwidth).powi(2)).exp());
224
225 wts *= &freq_bins;
226
227 let mut uninit: Vec<f64> = vec![0.; (wts).len()];
229 unsafe {
230 uninit.set_len(wts.len());
231 }
232 let mut b = Array::from(uninit)
233 .to_shape(wts.dim())
234 .map_err(|e| AnalysisError::AnalysisError(format!("in chroma: {e}")))?
235 .to_owned();
236 b.slice_mut(s![-3.., ..]).assign(&wts.slice(s![..3, ..]));
237 b.slice_mut(s![..-3, ..]).assign(&wts.slice(s![3.., ..]));
238
239 wts = b;
240 let non_aliased = 1 + n_fft / 2;
241 Ok(wts.slice_move(s![.., ..non_aliased]))
242}
243
244#[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
245#[allow(clippy::missing_inline_in_public_items)]
246pub fn pip_track(
247 sample_rate: u32,
248 spectrum: &Array2<f64>,
249 n_fft: usize,
250) -> AnalysisResult<(Vec<f64>, Vec<f64>)> {
251 let sample_rate_float = f64::from(sample_rate);
252 let fmin = 150.0_f64;
253 let fmax = 4000.0_f64.min(sample_rate_float / 2.0);
254 let threshold = 0.1;
255
256 let fft_freqs = Array::linspace(0., sample_rate_float / 2., 1 + n_fft / 2);
257
258 let length = spectrum.len_of(Axis(0));
259
260 let freq_mask = fft_freqs
262 .iter()
263 .map(|&f| (fmin <= f) && (f < fmax))
264 .collect::<Vec<bool>>();
265
266 let ref_value = spectrum.map_axis(Axis(0), |x| {
267 let first: f64 = *x.first().expect("empty spectrum axis");
268 let max = x.fold(first, |acc, &elem| if acc > elem { acc } else { elem });
269 threshold * max
270 });
271
272 let taken_columns = freq_mask
274 .iter()
275 .fold(0, |acc, &x| if x { acc + 1 } else { acc });
276 let mut pitches = Vec::with_capacity(taken_columns * length);
277 let mut mags = Vec::with_capacity(taken_columns * length);
278
279 let beginning = freq_mask
280 .iter()
281 .position(|&b| b)
282 .ok_or_else(|| AnalysisError::AnalysisError(String::from("in chroma")))?;
283 let end = freq_mask
284 .iter()
285 .rposition(|&b| b)
286 .ok_or_else(|| AnalysisError::AnalysisError(String::from("in chroma")))?;
287
288 let zipped = Zip::indexed(spectrum.slice(s![beginning..end - 3, ..]))
289 .and(spectrum.slice(s![beginning + 1..end - 2, ..]))
290 .and(spectrum.slice(s![beginning + 2..end - 1, ..]));
291
292 zipped.for_each(|(i, j), &before_elem, &elem, &after_elem| {
295 if elem > ref_value[j] && after_elem <= elem && before_elem < elem {
296 let avg = 0.5 * (after_elem - before_elem);
297 let mut shift = 2f64.mul_add(elem, -after_elem) - before_elem;
298 if shift.abs() < f64::MIN_POSITIVE {
299 shift += 1.;
300 }
301 shift = avg / shift;
302 #[allow(clippy::cast_precision_loss)]
303 pitches.push(((i + beginning + 1) as f64 + shift) * sample_rate_float / n_fft as f64);
304 mags.push((0.5 * avg).mul_add(shift, elem));
305 }
306 });
307
308 Ok((pitches, mags))
309}
310
311#[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
313#[inline]
314pub fn pitch_tuning(
315 frequencies: &mut Array1<f64>,
316 resolution: f64,
317 bins_per_octave: u32,
318) -> AnalysisResult<f64> {
319 if frequencies.is_empty() {
320 return Ok(0.0);
321 }
322 hz_to_octs_inplace(frequencies, 0.0, 12);
323 frequencies.mapv_inplace(|x| f64::from(bins_per_octave) * x % 1.0);
324
325 frequencies.mapv_inplace(|x| if x >= 0.5 { x - 1. } else { x });
327
328 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
329 let indexes = ((frequencies.to_owned() - -0.5) / resolution).mapv(|x| x as usize);
330 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
331 let mut counts: Array1<usize> = Array::zeros(((0.5 - -0.5) / resolution) as usize);
332 for &idx in &indexes {
333 counts[idx] += 1;
334 }
335 let max_index = counts
336 .argmax()
337 .map_err(|e| AnalysisError::AnalysisError(format!("in chroma: {e}")))?;
338
339 #[allow(clippy::cast_precision_loss)]
341 Ok((100. * resolution).mul_add(max_index as f64, -50.) / 100.)
342}
343
344#[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
345#[inline]
346pub fn estimate_tuning(
347 sample_rate: u32,
348 spectrum: &Array2<f64>,
349 n_fft: usize,
350 resolution: f64,
351 bins_per_octave: u32,
352) -> AnalysisResult<f64> {
353 let (pitch, mag) = pip_track(sample_rate, spectrum, n_fft)?;
354
355 let (filtered_pitch, filtered_mag): (Vec<N64>, Vec<N64>) = pitch
356 .iter()
357 .zip(&mag)
358 .filter(|(&p, _)| p > 0.)
359 .map(|(x, y)| (n64(*x), n64(*y)))
360 .unzip();
361
362 if pitch.is_empty() {
363 return Ok(0.);
364 }
365
366 let threshold: N64 = Array::from(filtered_mag.clone())
367 .quantile_axis_mut(Axis(0), n64(0.5), &Midpoint)
368 .map_err(|e| AnalysisError::AnalysisError(format!("in chroma: {e}")))?
369 .into_scalar();
370 let mut pitch = filtered_pitch
371 .iter()
372 .zip(&filtered_mag)
373 .filter_map(|(&p, &m)| if m >= threshold { Some(p.into()) } else { None })
374 .collect::<Array1<f64>>();
375 pitch_tuning(&mut pitch, resolution, bins_per_octave)
376}
377
378#[allow(
379 clippy::missing_errors_doc,
380 clippy::missing_panics_doc,
381 clippy::module_name_repetitions
382)]
383#[inline]
384pub fn chroma_stft(
385 sample_rate: u32,
386 spectrum: &mut Array2<f64>,
387 n_fft: usize,
388 n_chroma: u32,
389 tuning: f64,
390) -> AnalysisResult<Array2<f64>> {
391 spectrum.mapv_inplace(|x| x * x);
392 let mut raw_chroma = chroma_filter(sample_rate, n_fft, n_chroma, tuning)?;
393
394 raw_chroma = raw_chroma.dot(spectrum);
395 for mut row in raw_chroma.columns_mut() {
396 let mut sum = row.mapv(f64::abs).sum();
397 if sum < f64::MIN_POSITIVE {
398 sum = 1.;
399 }
400 row /= sum;
401 }
402 Ok(raw_chroma)
403}
404
405#[cfg(test)]
406mod test {
407 use super::*;
408 use crate::{
409 decoder::{Decoder as _, MecompDecoder as Decoder},
410 utils::stft,
411 SAMPLE_RATE,
412 };
413 use ndarray::{arr1, arr2, Array2};
414 use ndarray_npy::ReadNpyExt as _;
415 use std::{fs::File, path::Path};
416
417 #[test]
418 fn test_chroma_interval_features() {
419 let file = File::open("data/chroma.npy").unwrap();
420 let chroma = Array2::<f64>::read_npy(file).unwrap();
421 let features = chroma_interval_features(&chroma);
422 let expected_features = arr1(&[
423 0.038_602_84,
424 0.021_852_81,
425 0.042_243_79,
426 0.063_852_78,
427 0.073_111_48,
428 0.025_125_66,
429 0.003_198_99,
430 0.003_113_08,
431 0.001_074_33,
432 0.002_418_61,
433 ]);
434 for (expected, actual) in expected_features.iter().zip(&features) {
435 assert!(
436 0.000_000_01 > (expected - actual.abs()),
437 "{expected} !~= {actual}"
438 );
439 }
440 }
441
442 #[test]
443 fn test_extract_interval_features() {
444 let file = File::open("data/chroma-interval.npy").unwrap();
445 let chroma = Array2::<f64>::read_npy(file).unwrap();
446 let templates = arr2(&[
447 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
448 [1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
449 [0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
450 [0, 0, 1, 0, 0, 0, 0, 1, 1, 0],
451 [0, 0, 0, 1, 0, 0, 1, 0, 0, 1],
452 [0, 0, 0, 0, 1, 0, 0, 0, 0, 0],
453 [0, 0, 0, 0, 0, 1, 0, 0, 1, 0],
454 [0, 0, 0, 0, 0, 0, 1, 1, 0, 0],
455 [0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
456 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
457 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
458 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
459 ]);
460
461 let file = File::open("data/interval-feature-matrix.npy").unwrap();
462 let expected_interval_features = Array2::<f64>::read_npy(file).unwrap();
463
464 let interval_features = extract_interval_features(&chroma, &templates);
465 for (expected, actual) in expected_interval_features
466 .iter()
467 .zip(interval_features.iter())
468 {
469 assert!(
470 0.000_000_1 > (expected - actual).abs(),
471 "{expected} !~= {actual}"
472 );
473 }
474 }
475
476 #[test]
477 fn test_normalize_feature_sequence() {
478 let array = arr2(&[[0.1, 0.3, 0.4], [1.1, 0.53, 1.01]]);
479 let expected_array = arr2(&[
480 [0.083_333_33, 0.361_445_78, 0.283_687_94],
481 [0.916_666_67, 0.638_554_22, 0.716_312_06],
482 ]);
483
484 let normalized_array = normalize_feature_sequence(&array);
485
486 assert!(!array.is_empty() && !expected_array.is_empty());
487
488 for (expected, actual) in normalized_array.iter().zip(expected_array.iter()) {
489 assert!(
490 0.000_000_1 > (expected - actual).abs(),
491 "{expected} !~= {actual}"
492 );
493 }
494 }
495
496 #[test]
497 fn test_chroma_desc() {
498 let song = Decoder::new()
499 .unwrap()
500 .decode(Path::new("data/s16_mono_22_5kHz.flac"))
501 .unwrap();
502 let mut chroma_desc = ChromaDesc::new(SAMPLE_RATE, 12);
503 chroma_desc.do_(&song.samples).unwrap();
504 let expected_values = [
505 -0.356_619_36,
506 -0.635_786_53,
507 -0.295_936_82,
508 0.064_213_04,
509 0.218_524_58,
510 -0.581_239,
511 -0.946_683_5,
512 -0.948_115_3,
513 -0.982_094_5,
514 -0.959_689_74,
515 ];
516 for (expected, actual) in expected_values.iter().zip(chroma_desc.get_value().iter()) {
517 let relative_error = (expected - actual).abs() / expected.abs();
519 assert!(
520 relative_error < 0.01,
521 "relative error: {relative_error}, expected: {expected}, actual: {actual}"
522 );
523 }
524 }
525
526 #[test]
527 fn test_chroma_stft_decode() {
528 let signal = Decoder::new()
529 .unwrap()
530 .decode(Path::new("data/s16_mono_22_5kHz.flac"))
531 .unwrap()
532 .samples;
533 let mut stft = stft(&signal, 8192, 2205);
534
535 let file = File::open("data/chroma.npy").unwrap();
536 let expected_chroma = Array2::<f64>::read_npy(file).unwrap();
537
538 let chroma = chroma_stft(22050, &mut stft, 8192, 12, -0.049_999_999_999_999_99).unwrap();
539
540 assert!(!chroma.is_empty() && !expected_chroma.is_empty());
541
542 for (expected, actual) in expected_chroma.iter().zip(chroma.iter()) {
543 let relative_error = (expected - actual).abs() / expected.abs();
545 assert!(
546 relative_error < 0.01,
547 "relative error: {relative_error}, expected: {expected}, actual: {actual}"
548 );
549 }
550 }
551
552 #[test]
553 fn test_estimate_tuning() {
554 let file = File::open("data/spectrum-chroma.npy").unwrap();
555 let arr = Array2::<f64>::read_npy(file).unwrap();
556
557 let tuning = estimate_tuning(22050, &arr, 2048, 0.01, 12).unwrap();
558 assert!(
559 0.000_001 > (-0.099_999_999_999_999_98 - tuning).abs(),
560 "{tuning} !~= -0.09999999999999998"
561 );
562 }
563
564 #[test]
565 fn test_chroma_estimate_tuning_empty_fix() {
566 assert!(0. == estimate_tuning(22050, &Array2::zeros((8192, 1)), 8192, 0.01, 12).unwrap());
567 }
568
569 #[test]
570 fn test_estimate_tuning_decode() {
571 let signal = Decoder::new()
572 .unwrap()
573 .decode(Path::new("data/s16_mono_22_5kHz.flac"))
574 .unwrap()
575 .samples;
576 let stft = stft(&signal, 8192, 2205);
577
578 let tuning = estimate_tuning(22050, &stft, 8192, 0.01, 12).unwrap();
579 assert!(
580 0.000_001 > (-0.049_999_999_999_999_99 - tuning).abs(),
581 "{tuning} !~= -0.04999999999999999"
582 );
583 }
584
585 #[test]
586 fn test_pitch_tuning() {
587 let file = File::open("data/pitch-tuning.npy").unwrap();
588 let mut pitch = Array1::<f64>::read_npy(file).unwrap();
589 let tuned = pitch_tuning(&mut pitch, 0.05, 12).unwrap();
590 assert!(f64::EPSILON > (tuned + 0.1).abs(), "{tuned} != -0.1");
591 }
592
593 #[test]
594 fn test_pitch_tuning_no_frequencies() {
595 let mut frequencies = arr1(&[]);
596 let tuned = pitch_tuning(&mut frequencies, 0.05, 12).unwrap();
597 assert!(f64::EPSILON > tuned.abs(), "{tuned} != 0");
598 }
599
600 #[test]
601 fn test_pip_track() {
602 let file = File::open("data/spectrum-chroma.npy").unwrap();
603 let spectrum = Array2::<f64>::read_npy(file).unwrap();
604
605 let mags_file = File::open("data/spectrum-chroma-mags.npy").unwrap();
606 let expected_mags = Array1::<f64>::read_npy(mags_file).unwrap();
607
608 let pitches_file = File::open("data/spectrum-chroma-pitches.npy").unwrap();
609 let expected_pitches = Array1::<f64>::read_npy(pitches_file).unwrap();
610
611 let (mut pitches, mut mags) = pip_track(22050, &spectrum, 2048).unwrap();
612 pitches.sort_by(|a, b| a.partial_cmp(b).unwrap());
613 mags.sort_by(|a, b| a.partial_cmp(b).unwrap());
614
615 for (expected_pitches, actual_pitches) in expected_pitches.iter().zip(pitches.iter()) {
616 assert!(
617 0.000_000_01 > (expected_pitches - actual_pitches).abs(),
618 "{expected_pitches} !~= {actual_pitches}"
619 );
620 }
621 for (expected_mags, actual_mags) in expected_mags.iter().zip(mags.iter()) {
622 assert!(
623 0.000_000_01 > (expected_mags - actual_mags).abs(),
624 "{expected_mags} !~= {actual_mags}"
625 );
626 }
627 }
628
629 #[test]
630 fn test_chroma_filter() {
631 let file = File::open("data/chroma-filter.npy").unwrap();
632 let expected_filter = Array2::<f64>::read_npy(file).unwrap();
633
634 let filter = chroma_filter(22050, 2048, 12, -0.1).unwrap();
635
636 for (expected, actual) in expected_filter.iter().zip(filter.iter()) {
637 assert!(
638 0.000_000_001 > (expected - actual).abs(),
639 "{expected} !~= {actual}"
640 );
641 }
642 }
643}