sva_samples/measure/
pitch.rs1use 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
56pub fn name_peaks(found: &[Peak], max_notes: usize) -> Vec<Note> {
58 name_all(found, max_notes)
59}
60
61fn 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
99fn 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(¬es[*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}