1use crate::measure::spectrum::{Peak, magnitudes_of, peaks};
4use crate::measure::stiff::{fit, stretch};
5
6const NAMES: [&str; 12] = [
7 "C", "Cs", "D", "Ds", "E", "F", "Fs", "G", "Gs", "A", "As", "B",
8];
9pub const LOWEST_HZ: f64 = 25.0;
10pub const HIGHEST_HZ: f64 = 5000.0;
11const HARMONIC_TOLERANCE: f64 = 0.04;
12const HIGHEST_HARMONIC: f64 = 8.0;
13const COMPRESSION: f64 = 0.84;
14
15#[derive(Clone, Debug, PartialEq)]
16pub struct Note {
17 pub hz: f64,
18 pub name: String,
19 pub cents: f64,
20 pub db: f64,
21 pub harmonic_of: Option<f64>,
22}
23
24#[derive(Clone, Debug, PartialEq)]
25pub struct PitchFrame {
26 pub t_secs: f64,
27 pub notes: Vec<Note>,
28}
29
30pub fn track(
31 planes: &[&[f64]],
32 sample_rate: f64,
33 start_secs: f64,
34 frame_secs: f64,
35 max_notes: usize,
36) -> Vec<PitchFrame> {
37 let stride = ((frame_secs * sample_rate).round() as usize).max(1);
38 let len = crate::measure::held(planes);
39 (0..len)
40 .step_by(stride)
41 .enumerate()
42 .map(|(n, from)| {
43 let chunk: Vec<&[f64]> = planes
44 .iter()
45 .map(|p| &p[from..len.min(from + stride)])
46 .collect();
47 let (mags, bin_hz, ..) = magnitudes_of(&chunk, sample_rate, None);
48 let found = peaks(&mags, bin_hz, max_notes * 4);
49 let resolution_hz = sample_rate / chunk[0].len().max(1) as f64;
50 PitchFrame {
51 t_secs: start_secs + (n * stride) as f64 / sample_rate,
52 notes: name_all(&found, max_notes, resolution_hz / 2.0),
53 }
54 })
55 .collect()
56}
57
58pub fn name_peaks(found: &[Peak], max_notes: usize) -> Vec<Note> {
60 name_all(found, max_notes, 0.0)
61}
62
63fn name_all(found: &[Peak], max_notes: usize, spread_hz: f64) -> Vec<Note> {
65 let listed: Vec<&Peak> = found
66 .iter()
67 .filter(|p| (LOWEST_HZ..=HIGHEST_HZ).contains(&p.hz))
68 .take(max_notes)
69 .collect();
70 let roots = series_roots(&listed, spread_hz);
71
72 listed
73 .iter()
74 .zip(roots)
75 .map(|(p, harmonic_of)| {
76 let midi = midi_of(p.hz);
77 let nearest = midi.round();
78 Note {
79 hz: p.hz,
80 name: note_name(nearest as i64),
81 cents: (midi - nearest) * 100.0,
82 db: p.db,
83 harmonic_of,
84 }
85 })
86 .collect()
87}
88
89pub fn midi_of(hz: f64) -> f64 {
90 69.0 + 12.0 * (hz / 440.0).log2()
91}
92
93pub fn note_name(midi: i64) -> String {
94 format!("{}{}", class_name(midi), midi.div_euclid(12) - 1)
95}
96
97pub fn class_name(midi: i64) -> &'static str {
98 NAMES[midi.rem_euclid(12) as usize]
99}
100
101fn series_roots(notes: &[&Peak], spread_hz: f64) -> Vec<Option<f64>> {
105 let mut joined = vec![None; notes.len()];
106 let mut left: Vec<usize> = (0..notes.len()).collect();
107 while let Some((root, f0)) = most_salient(notes, &left, spread_hz) {
108 left.retain(|&i| i != root);
109 let mut series = vec![Member {
110 at: root,
111 n: 1,
112 hz: f0,
113 }];
114 loop {
115 let points: Vec<(u32, f64)> = series.iter().map(|m| (m.n, m.hz)).collect();
116 let b = match spread_hz > 0.0 {
117 true => 0.0,
118 false => fit(&points).unwrap_or(0.0).max(0.0),
120 };
121 let joins = |m: &Member, i: usize| {
122 over(notes[i].hz, m, b).filter(|&q| {
123 let (under, over) = (amp(notes[m.at].db), amp(notes[i].db));
124 under > over
125 || m.at == root && under + COMPRESSION.powi(q as i32 - 1) * over >= over
126 })
127 };
128 let Some((at, m, q)) = left
129 .iter()
130 .enumerate()
131 .find_map(|(at, &i)| series.iter().find_map(|m| joins(m, i).map(|q| (at, *m, q))))
132 else {
133 break;
134 };
135 let i = left.remove(at);
136 joined[i] = Some(notes[m.at].hz);
137 series.push(Member {
138 at: i,
139 n: q * m.n,
140 hz: notes[i].hz,
141 });
142 }
143 }
144 joined
145}
146
147#[derive(Clone, Copy)]
148struct Member {
149 at: usize,
150 n: u32,
151 hz: f64,
152}
153
154fn over(hz: f64, m: &Member, b: f64) -> Option<u32> {
156 let ratio = hz / m.hz;
157 let q = ratio.round();
158 let (n, k) = (f64::from(m.n), q * f64::from(m.n));
159 let sharpest = q * stretch(k, b) / stretch(n, b);
160 ((2.0..=HIGHEST_HARMONIC).contains(&q)
161 && ratio > q - HARMONIC_TOLERANCE
162 && ratio < sharpest.max(q) + HARMONIC_TOLERANCE)
163 .then_some(q as u32)
164}
165
166fn amp(db: f64) -> f64 {
167 10f64.powf(db / 20.0)
168}
169
170fn salience(notes: &[&Peak], among: &[usize], hz: f64) -> f64 {
171 let mut strongest = [0.0f64; HIGHEST_HARMONIC as usize];
172 for &i in among {
173 if let Some(k) = multiple(notes[i].hz, hz) {
174 let at = &mut strongest[k as usize - 1];
175 *at = at.max(amp(notes[i].db));
176 }
177 }
178 strongest
179 .iter()
180 .enumerate()
181 .map(|(k, a)| COMPRESSION.powi(k as i32) * a)
182 .sum::<f64>()
183}
184
185fn most_salient(notes: &[&Peak], left: &[usize], spread_hz: f64) -> Option<(usize, f64)> {
186 left.iter()
187 .map(|&c| {
188 let f0 = fundamental(notes, left, c, spread_hz);
189 (c, f0, salience(notes, left, f0))
190 })
191 .max_by(|(a, _, sa), (b, _, sb)| {
192 sa.total_cmp(sb).then(notes[*b].hz.total_cmp(¬es[*a].hz))
193 })
194 .map(|(c, f0, _)| (c, f0))
195}
196
197fn fundamental(notes: &[&Peak], left: &[usize], root: usize, spread_hz: f64) -> f64 {
200 let at = notes[root].hz;
201 if spread_hz <= 0.0 {
202 return at;
203 }
204 let explained = |f: f64| -> Vec<bool> {
205 left.iter()
206 .map(|&i| multiple(notes[i].hz, f).is_some())
207 .collect()
208 };
209 let own = explained(at);
210 let keeps = |f: f64| explained(f).iter().zip(&own).all(|(e, o)| *e || !o);
211 let near = |f: f64| (f - at).abs() <= spread_hz && (at / f - 1.0).abs() < HARMONIC_TOLERANCE;
212 left.iter()
213 .flat_map(|&i| (1..=HIGHEST_HARMONIC as u32).map(move |k| notes[i].hz / f64::from(k)))
214 .filter(|f| near(*f) && keeps(*f))
215 .map(|f| (f, salience(notes, left, f)))
216 .fold((at, salience(notes, left, at)), |held, next| {
217 let nearer = (held.0 - at).abs().total_cmp(&(next.0 - at).abs());
218 match next.1.total_cmp(&held.1).then(nearer) {
219 std::cmp::Ordering::Greater => next,
220 _ => held,
221 }
222 })
223 .0
224}
225
226fn multiple(hz: f64, root: f64) -> Option<u32> {
227 let n = hz / root;
228 let k = n.round();
229 ((1.0..=HIGHEST_HARMONIC).contains(&k) && (n - k).abs() < HARMONIC_TOLERANCE)
230 .then_some(k as u32)
231}