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: (planes, sample rate) -> frames
2
3use crate::measure::spectrum::{Peak, magnitudes_of, 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    planes: &[&[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    let len = crate::measure::held(planes);
38    (0..len)
39        .step_by(stride)
40        .enumerate()
41        .map(|(n, from)| {
42            let chunk: Vec<&[f64]> = planes
43                .iter()
44                .map(|p| &p[from..len.min(from + stride)])
45                .collect();
46            let (mags, bin_hz, ..) = magnitudes_of(&chunk, sample_rate, None);
47            let found = peaks(&mags, bin_hz, max_notes * 4);
48            PitchFrame {
49                t_secs: start_secs + (n * stride) as f64 / sample_rate,
50                notes: name_all(&found, max_notes),
51            }
52        })
53        .collect()
54}
55
56/// The same naming over peaks a reading already holds exactly, loudest first.
57pub fn name_peaks(found: &[Peak], max_notes: usize) -> Vec<Note> {
58    name_all(found, max_notes)
59}
60
61/// A note at a near-whole multiple of a lower one in its series is reported as its harmonic.
62fn name_all(found: &[Peak], max_notes: usize) -> Vec<Note> {
63    let listed: Vec<&Peak> = found
64        .iter()
65        .filter(|p| (LOWEST_HZ..=HIGHEST_HZ).contains(&p.hz))
66        .take(max_notes)
67        .collect();
68    let roots = series_roots(&listed);
69
70    listed
71        .iter()
72        .zip(roots)
73        .map(|(p, harmonic_of)| {
74            let midi = midi_of(p.hz);
75            let nearest = midi.round();
76            Note {
77                hz: p.hz,
78                name: note_name(nearest as i64),
79                cents: (midi - nearest) * 100.0,
80                db: p.db,
81                harmonic_of,
82            }
83        })
84        .collect()
85}
86
87pub fn midi_of(hz: f64) -> f64 {
88    69.0 + 12.0 * (hz / 440.0).log2()
89}
90
91pub fn note_name(midi: i64) -> String {
92    format!("{}{}", class_name(midi), midi.div_euclid(12) - 1)
93}
94
95pub fn class_name(midi: i64) -> &'static str {
96    NAMES[midi.rem_euclid(12) as usize]
97}
98
99/// Klapuri's iterative cancellation (ISMIR 2006): the note with the greatest subharmonic sum
100/// (Hermes, JASA 83(1), 1988) roots a series, and the rest go round again. A note at 2 to 8
101/// times a louder member joins it, or the root itself if their pair's sum at the root is at
102/// least the note's own amplitude: so a weak fundamental heads its octave.
103fn series_roots(notes: &[&Peak]) -> Vec<Option<f64>> {
104    let mut joined = vec![None; notes.len()];
105    let mut left: Vec<usize> = (0..notes.len()).collect();
106    while let Some(root) = most_salient(notes, &left) {
107        left.retain(|&i| i != root);
108        let joins = |m: usize, i: usize| {
109            multiple(notes[i].hz, notes[m].hz).is_some_and(|k| {
110                let (under, over) = (amp(notes[m].db), amp(notes[i].db));
111                k >= 2
112                    && (under > over
113                        || m == root && under + COMPRESSION.powi(k as i32 - 1) * over >= over)
114            })
115        };
116        let mut series = vec![root];
117        while let Some((at, m)) = left
118            .iter()
119            .enumerate()
120            .find_map(|(at, &i)| series.iter().find(|&&m| joins(m, i)).map(|&m| (at, m)))
121        {
122            let i = left.remove(at);
123            joined[i] = Some(notes[m].hz);
124            series.push(i);
125        }
126    }
127    joined
128}
129
130fn amp(db: f64) -> f64 {
131    10f64.powf(db / 20.0)
132}
133
134fn most_salient(notes: &[&Peak], left: &[usize]) -> Option<usize> {
135    let salience = |c: usize| {
136        let mut strongest = [0.0f64; HIGHEST_HARMONIC as usize];
137        for &i in left {
138            if let Some(k) = multiple(notes[i].hz, notes[c].hz) {
139                let at = &mut strongest[k as usize - 1];
140                *at = at.max(amp(notes[i].db));
141            }
142        }
143        strongest
144            .iter()
145            .enumerate()
146            .map(|(k, a)| COMPRESSION.powi(k as i32) * a)
147            .sum::<f64>()
148    };
149    left.iter()
150        .map(|&c| (c, salience(c)))
151        .max_by(|(a, sa), (b, sb)| sa.total_cmp(sb).then(notes[*b].hz.total_cmp(&notes[*a].hz)))
152        .map(|(c, _)| c)
153}
154
155fn multiple(hz: f64, root: f64) -> Option<u32> {
156    let n = hz / root;
157    let k = n.round();
158    ((1.0..=HIGHEST_HARMONIC).contains(&k) && (n - k).abs() < HARMONIC_TOLERANCE)
159        .then_some(k as u32)
160}