Skip to main content

sva_samples/measure/
pitch.rs

1// Concern: names each frame's spectral peaks as notes and flags likely harmonics | Non-concern: computing the spectrum (spectrum.rs), chord or key identity | IO: (&[f64], sample rate) -> frames
2
3use crate::measure::spectrum::{Peak, magnitudes, peaks};
4
5const NAMES: [&str; 12] = [
6    "C", "Cs", "D", "Ds", "E", "F", "Fs", "G", "Gs", "A", "As", "B",
7];
8pub const LOWEST_HZ: f64 = 25.0;
9pub const HIGHEST_HZ: f64 = 5000.0;
10const HARMONIC_TOLERANCE: f64 = 0.04;
11const HIGHEST_HARMONIC: f64 = 8.0;
12const COMPRESSION: f64 = 0.84;
13
14#[derive(Clone, Debug, PartialEq)]
15pub struct Note {
16    pub hz: f64,
17    pub name: String,
18    pub cents: f64,
19    pub db: f64,
20    pub harmonic_of: Option<f64>,
21}
22
23#[derive(Clone, Debug, PartialEq)]
24pub struct PitchFrame {
25    pub t_secs: f64,
26    pub notes: Vec<Note>,
27}
28
29pub fn track(
30    samples: &[f64],
31    sample_rate: f64,
32    start_secs: f64,
33    frame_secs: f64,
34    max_notes: usize,
35) -> Vec<PitchFrame> {
36    let stride = ((frame_secs * sample_rate).round() as usize).max(1);
37    samples
38        .chunks(stride)
39        .enumerate()
40        .map(|(n, chunk)| {
41            let (mags, bin_hz, ..) = magnitudes(chunk, sample_rate, None);
42            let found = peaks(&mags, bin_hz, max_notes * 4);
43            PitchFrame {
44                t_secs: start_secs + (n * stride) as f64 / sample_rate,
45                notes: name_all(&found, max_notes),
46            }
47        })
48        .collect()
49}
50
51/// The same naming over peaks a reading already holds exactly, loudest first.
52pub fn name_peaks(found: &[Peak], max_notes: usize) -> Vec<Note> {
53    name_all(found, max_notes)
54}
55
56/// A note at a near-whole multiple of a lower one in its series is reported as its harmonic.
57fn name_all(found: &[Peak], max_notes: usize) -> Vec<Note> {
58    let listed: Vec<&Peak> = found
59        .iter()
60        .filter(|p| (LOWEST_HZ..=HIGHEST_HZ).contains(&p.hz))
61        .take(max_notes)
62        .collect();
63    let roots = series_roots(&listed);
64
65    listed
66        .iter()
67        .zip(roots)
68        .map(|(p, harmonic_of)| {
69            let midi = midi_of(p.hz);
70            let nearest = midi.round();
71            Note {
72                hz: p.hz,
73                name: note_name(nearest as i64),
74                cents: (midi - nearest) * 100.0,
75                db: p.db,
76                harmonic_of,
77            }
78        })
79        .collect()
80}
81
82pub fn midi_of(hz: f64) -> f64 {
83    69.0 + 12.0 * (hz / 440.0).log2()
84}
85
86pub fn note_name(midi: i64) -> String {
87    format!("{}{}", class_name(midi), midi.div_euclid(12) - 1)
88}
89
90pub fn class_name(midi: i64) -> &'static str {
91    NAMES[midi.rem_euclid(12) as usize]
92}
93
94/// Klapuri's iterative cancellation (ISMIR 2006): the note with the greatest subharmonic sum
95/// (Hermes, JASA 83(1), 1988) roots a series, and the rest go round again. A note at 2 to 8
96/// times a louder member joins it, or the root itself if their pair's sum at the root is at
97/// least the note's own amplitude: so a weak fundamental heads its octave.
98fn series_roots(notes: &[&Peak]) -> Vec<Option<f64>> {
99    let mut joined = vec![None; notes.len()];
100    let mut left: Vec<usize> = (0..notes.len()).collect();
101    while let Some(root) = most_salient(notes, &left) {
102        left.retain(|&i| i != root);
103        let joins = |m: usize, i: usize| {
104            multiple(notes[i].hz, notes[m].hz).is_some_and(|k| {
105                let (under, over) = (amp(notes[m].db), amp(notes[i].db));
106                k >= 2
107                    && (under > over
108                        || m == root && under + COMPRESSION.powi(k as i32 - 1) * over >= over)
109            })
110        };
111        let mut series = vec![root];
112        while let Some((at, m)) = left
113            .iter()
114            .enumerate()
115            .find_map(|(at, &i)| series.iter().find(|&&m| joins(m, i)).map(|&m| (at, m)))
116        {
117            let i = left.remove(at);
118            joined[i] = Some(notes[m].hz);
119            series.push(i);
120        }
121    }
122    joined
123}
124
125fn amp(db: f64) -> f64 {
126    10f64.powf(db / 20.0)
127}
128
129fn most_salient(notes: &[&Peak], left: &[usize]) -> Option<usize> {
130    let salience = |c: usize| {
131        let mut strongest = [0.0f64; HIGHEST_HARMONIC as usize];
132        for &i in left {
133            if let Some(k) = multiple(notes[i].hz, notes[c].hz) {
134                let at = &mut strongest[k as usize - 1];
135                *at = at.max(amp(notes[i].db));
136            }
137        }
138        strongest
139            .iter()
140            .enumerate()
141            .map(|(k, a)| COMPRESSION.powi(k as i32) * a)
142            .sum::<f64>()
143    };
144    left.iter()
145        .map(|&c| (c, salience(c)))
146        .max_by(|(a, sa), (b, sb)| sa.total_cmp(sb).then(notes[*b].hz.total_cmp(&notes[*a].hz)))
147        .map(|(c, _)| c)
148}
149
150fn multiple(hz: f64, root: f64) -> Option<u32> {
151    let n = hz / root;
152    let k = n.round();
153    ((1.0..=HIGHEST_HARMONIC).contains(&k) && (n - k).abs() < HARMONIC_TOLERANCE)
154        .then_some(k as u32)
155}