Skip to main content

sva_samples/measure/harmony/
inharmonicity.rs

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