1use super::chords::named;
4use super::dissonance::Extreme;
5use super::{Framing, Moment, Partial};
6use crate::measure::stiff::{Weights, dot, weights};
7
8pub const MODEL: &str = "per moment, the series `pitch` names: the partial with the greatest \
9 subharmonic sum (Hermes, D. J. (1988), Measurement of pitch by \
10 subharmonic summation, J. Acoust. Soc. Am. 83(1) 257-264, the k-th \
11 multiple's amplitude weighted 0.84^(k-1)) roots a series, a partial left \
12 at a ratio over a louder member from 0.04 under a whole number q from 2 \
13 to 8 to 0.04 over where the stiff string the series' members so far fit \
14 (Fletcher, below, B floored at 0) puts its partial q n over its partial \
15 n joins it, or over the root if the pair's sum at the root is at \
16 least that partial's amplitude, and the rest go round again (Klapuri, A. (2006), Multiple \
17 fundamental frequency estimation by summing harmonic amplitudes, ISMIR); \
18 a member's harmonic number n is q times that of the member it \
19 joined, 1 for the root, the q n the fit puts nearest it where several \
20 members admit it; only \
21 partials from 25 Hz to 5 kHz at or above the floor are read. That \
22 window bounds how stretched a member may be, and a partial stretched \
23 past it, or a harmonic past the 8th with no member at a divisor (a saw's \
24 11th), roots a series of its own; a fundamental quieter than its 2nd \
25 harmonic roots their series if its subharmonic sum is the greater and \
26 it is within 16 dB of that harmonic, else the two split; and \
27 a noise's many peaks fall within the tolerance by chance, so noise reads \
28 partly grouped, with many series. \
29 cents = 1200 log2(f / (n f1)); a series' and the moment's `cents` are \
30 the amplitude-weighted mean of |cents| over members n >= 2, null with \
31 none. fit: Fletcher, H. (1964), Normal vibration frequencies of a stiff \
32 piano string, J. Acoust. Soc. Am. 36(1) 203-209, f_n = n f0 sqrt(1 + B \
33 n^2), by unweighted least squares of (f_n / n)^2 = f0^2 + f0^2 B n^2 \
34 over a series' members, null under two distinct n or with no positive \
35 f0^2; B is the model's coefficient for a stiff string and only a fitted \
36 slope for any other body. unexplained: the share of the read partials' \
37 energy, sum of amplitude^2, in series of one partial, which hold no \
38 evidence of a harmonic series, so a lone sinusoid reads 1. Every bound \
39 holds over the box of the partials' frequency and amplitude bounds, \
40 with the series and each n as named at the estimates; b_bounds is null \
41 where that box admits no positive f0^2";
42
43#[derive(Clone, Copy, Debug, PartialEq)]
44pub struct Member {
45 pub n: u32,
46 pub partial: Partial,
47 pub cents: f64,
48 pub cents_bounds: (f64, f64),
49}
50
51#[derive(Clone, Copy, Debug, PartialEq)]
52pub struct Fit {
53 pub b: f64,
54 pub f0_hz: f64,
55 pub b_bounds: Option<(f64, f64)>,
57 pub f0_bounds: Option<(f64, f64)>,
58}
59
60#[derive(Clone, Debug, PartialEq)]
62pub struct Series {
63 pub members: Vec<Member>,
64 pub cents: Option<Weighted>,
65 pub fit: Option<Fit>,
66}
67
68#[derive(Clone, Copy, Debug, PartialEq)]
69pub struct Weighted {
70 pub value: f64,
71 pub bounds: (f64, f64),
72}
73
74#[derive(Clone, Debug, PartialEq)]
75pub struct InharmonicFrame {
76 pub t_secs: f64,
77 pub series: Vec<Series>,
78 pub cents: Option<Weighted>,
79 pub unexplained: Option<Weighted>,
81}
82
83#[derive(Clone, Debug, PartialEq)]
84pub struct Inharmonicity {
85 pub framing: Framing,
86 pub floor_db: f64,
87 pub frames: Vec<InharmonicFrame>,
88 pub cents_mean: Option<f64>,
90 pub cents_max: Option<Extreme>,
91 pub unexplained_mean: Option<f64>,
92}
93
94fn cents(ratio: f64) -> f64 {
95 1200.0 * ratio.log2()
96}
97
98fn member(p: &Partial, n: u32, root: &Partial) -> Member {
100 let k = f64::from(n);
101 let cents_bounds = match n {
102 1 => (0.0, 0.0),
103 _ => (
104 cents(p.hz_bounds.0 / (k * root.hz_bounds.1)),
105 cents(p.hz_bounds.1 / (k * root.hz_bounds.0)),
106 ),
107 };
108 Member {
109 n,
110 partial: *p,
111 cents: cents(p.hz / (k * root.hz)),
112 cents_bounds,
113 }
114}
115
116#[derive(Clone, Copy)]
118struct Term {
119 value: f64,
120 value_bounds: (f64, f64),
121 weight: f64,
122 weight_bounds: (f64, f64),
123}
124
125fn weighted(terms: &[Term]) -> Option<Weighted> {
130 let weight: f64 = terms.iter().map(|t| t.weight).sum();
131 if weight <= 0.0 {
132 return None;
133 }
134 let value = terms.iter().map(|t| t.weight * t.value).sum::<f64>() / weight;
135 let extreme = |least: bool| {
136 let mut held: Vec<(f64, (f64, f64))> = terms
137 .iter()
138 .map(|t| match least {
139 true => (t.value_bounds.0, t.weight_bounds),
140 false => (-t.value_bounds.1, t.weight_bounds),
141 })
142 .collect();
143 held.sort_by(|a, b| a.0.total_cmp(&b.0));
144 let lowest = (0..=held.len())
145 .filter_map(|split| {
146 let (num, den) = held
147 .iter()
148 .enumerate()
149 .fold((0.0, 0.0), |(n, d), (i, (v, w))| {
150 let w = if i < split { w.1 } else { w.0 };
151 (n + w * v, d + w)
152 });
153 (den > 0.0).then_some(num / den)
154 })
155 .fold(f64::INFINITY, f64::min);
156 match least {
157 true => lowest,
158 false => -lowest,
159 }
160 };
161 Some(Weighted {
163 value,
164 bounds: (extreme(true).min(value), extreme(false).max(value)),
165 })
166}
167
168fn magnitude(bounds: (f64, f64)) -> (f64, f64) {
170 let (lo, hi) = bounds;
171 let least = match lo <= 0.0 && hi >= 0.0 {
172 true => 0.0,
173 false => lo.abs().min(hi.abs()),
174 };
175 (least, lo.abs().max(hi.abs()))
176}
177
178fn fit(points: &[(u32, Partial)]) -> Option<Fit> {
181 let ns: Vec<u32> = points.iter().map(|(n, _)| *n).collect();
182 let Weights { slope, intercept } = weights(&ns)?;
183 let y = |at: fn(&Partial) -> f64| -> Vec<f64> {
184 points
185 .iter()
186 .map(|(n, p)| (at(p) / f64::from(*n)).powi(2))
187 .collect()
188 };
189 let (y_lo, y_hi, y_at) = (y(|p| p.hz_bounds.0), y(|p| p.hz_bounds.1), y(|p| p.hz));
190 let a = dot(&intercept, &y_at);
191 if a <= 0.0 {
192 return None;
193 }
194 let b = dot(&slope, &y_at) / a;
195 let a_end = |pick: fn(f64, f64) -> f64| -> f64 {
196 intercept
197 .iter()
198 .zip(y_lo.iter().zip(&y_hi))
199 .map(|(w, (lo, hi))| pick(w * lo, w * hi))
200 .sum()
201 };
202 let (least_a, most_a) = (a_end(f64::min), a_end(f64::max));
203 let ratio_bound = |sign: f64| {
205 let mut l = b;
206 loop {
207 let y: Vec<f64> = slope
208 .iter()
209 .zip(&intercept)
210 .enumerate()
211 .map(|(i, (s, w))| match sign * (s - l * w) > 0.0 {
212 true => y_hi[i],
213 false => y_lo[i],
214 })
215 .collect();
216 let next = dot(&slope, &y) / dot(&intercept, &y);
217 if sign * (next - l) <= 0.0 {
218 return l;
219 }
220 l = next;
221 }
222 };
223 Some(Fit {
224 b,
225 f0_hz: a.sqrt(),
226 b_bounds: (least_a > 0.0).then(|| (ratio_bound(-1.0), ratio_bound(1.0))),
227 f0_bounds: (least_a > 0.0).then(|| (least_a.min(a).sqrt(), most_a.max(a).sqrt())),
228 })
229}
230
231fn series_of(moment: &Moment, floor_db: f64) -> Vec<(Partial, Vec<(u32, Partial)>)> {
232 let named = named(moment, floor_db);
233 let mut roots: Vec<(Partial, Vec<(u32, Partial)>)> = named
234 .iter()
235 .filter(|(_, note)| note.harmonic_of.is_none())
236 .map(|(partial, _)| (*partial, vec![(1, *partial)]))
237 .collect();
238 let mut by_hz: Vec<_> = named.iter().collect();
239 by_hz.sort_by(|a, b| a.0.hz.total_cmp(&b.0.hz));
240 let mut root_of: Vec<(f64, usize)> = Vec::with_capacity(named.len());
241 for (partial, note) in by_hz {
242 let at = match note.harmonic_of {
243 None => roots
244 .iter()
245 .position(|(root, _)| root.hz == partial.hz)
246 .expect("a note naming no harmonic heads a series"),
247 Some(hz) => {
248 let at = root_of
249 .iter()
250 .find(|(lower, _)| *lower == hz)
251 .map(|(_, at)| *at)
252 .expect("a harmonic names a lower note of its series");
253 roots[at].1.push((note.n, *partial));
254 at
255 }
256 };
257 root_of.push((partial.hz, at));
258 }
259 for (_, members) in &mut roots {
260 members.sort_by(|a, b| a.1.hz.total_cmp(&b.1.hz));
261 }
262 roots
263}
264
265fn energy(p: &Partial) -> (f64, (f64, f64)) {
266 (
267 p.amplitude.powi(2),
268 (p.amplitude_bounds.0.powi(2), p.amplitude_bounds.1.powi(2)),
269 )
270}
271
272fn unexplained(lone: (f64, (f64, f64)), grouped: (f64, (f64, f64))) -> Option<Weighted> {
274 let share = |u: f64, e: f64| match u + e > 0.0 {
275 true => u / (u + e),
276 false => 0.0,
277 };
278 (lone.0 + grouped.0 > 0.0).then(|| Weighted {
279 value: share(lone.0, grouped.0),
280 bounds: (share(lone.1.0, grouped.1.1), share(lone.1.1, grouped.1.0)),
281 })
282}
283
284fn frame(moment: &Moment, floor_db: f64) -> InharmonicFrame {
285 let add =
286 |a: (f64, (f64, f64)), b: (f64, (f64, f64))| (a.0 + b.0, (a.1.0 + b.1.0, a.1.1 + b.1.1));
287 let none = (0.0, (0.0, 0.0));
288 let (mut lone, mut grouped) = (none, none);
289 let mut every = Vec::new();
290 let mut series = Vec::new();
291 for (root, points) in series_of(moment, floor_db) {
292 let held = points.iter().map(|(_, p)| energy(p)).fold(none, add);
293 match points.len() {
294 1 => lone = add(lone, held),
295 _ => grouped = add(grouped, held),
296 }
297 let members: Vec<Member> = points.iter().map(|(n, p)| member(p, *n, &root)).collect();
298 let deviations: Vec<Term> = members
299 .iter()
300 .zip(&points)
301 .filter(|(m, _)| m.n >= 2)
302 .map(|(m, (_, p))| Term {
303 value: m.cents.abs(),
304 value_bounds: magnitude(m.cents_bounds),
305 weight: p.amplitude,
306 weight_bounds: p.amplitude_bounds,
307 })
308 .collect();
309 every.extend(&deviations);
310 series.push(Series {
311 cents: weighted(&deviations),
312 fit: fit(&points),
313 members,
314 });
315 }
316 InharmonicFrame {
317 t_secs: moment.t_secs,
318 series,
319 cents: weighted(&every),
320 unexplained: unexplained(lone, grouped),
321 }
322}
323
324pub fn measure(moments: &[Moment], framing: Framing, floor_db: f64) -> Inharmonicity {
325 let frames: Vec<InharmonicFrame> = moments.iter().map(|m| frame(m, floor_db)).collect();
326 let mean = |of: fn(&InharmonicFrame) -> Option<f64>| {
327 let held: Vec<f64> = frames.iter().filter_map(of).collect();
328 (!held.is_empty()).then(|| held.iter().sum::<f64>() / held.len() as f64)
329 };
330 let cents_max = frames
331 .iter()
332 .filter_map(|f| f.cents.map(|c| (c.value, f.t_secs)))
333 .fold(None, |held: Option<Extreme>, (value, t_secs)| match held {
334 Some(e) if e.value >= value => Some(e),
335 _ => Some(Extreme { value, t_secs }),
336 });
337 Inharmonicity {
338 framing,
339 floor_db,
340 cents_mean: mean(|f| f.cents.map(|c| c.value)),
341 cents_max,
342 unexplained_mean: mean(|f| f.unexplained.map(|u| u.value)),
343 frames,
344 }
345}