Skip to main content

sound_to_patch/estimate/pitch/
vibrato.rs

1//! Vibrato: periodic pitch motion of 3 to 12 Hz, read from the f0 track.
2
3use super::fit::golden_section;
4use crate::cast;
5use crate::units::{Cents, Hz, Seconds};
6
7/// The slowest vibrato that counts.
8pub(crate) const VIBRATO_RATE_MIN: Hz = Hz::from_const(3.0);
9/// The fastest vibrato that counts.
10pub(crate) const VIBRATO_RATE_MAX: Hz = Hz::from_const(12.0);
11/// The share by which a rate may read outside 3 to 12 Hz and still count.
12///
13/// Through the f0 track the generated rates read within 0.8 % of the
14/// truth, so a vibrato at either edge stays in.
15pub(crate) const RATE_SLACK: f64 = 0.01;
16/// The smallest extent that counts, after the correction.
17pub(crate) const VIBRATO_EXTENT_MIN: Cents = Cents::from_const(10.0);
18/// The shortest voiced span the search reads.
19pub const VIBRATO_SPAN_MIN: Seconds = Seconds::from_const(0.5);
20/// The smallest share of the detrended contour's power that the sine
21/// must explain, so jitter does not read as a vibrato.
22pub(crate) const VIBRATO_SHARE_MIN: f64 = 0.5;
23/// The fewest cycles of the sine over the span.
24///
25/// Checked against an independent f0 on real stems, every vibrato of 2.5 cycles or more
26/// matched it; the shorter ones, 1.6 to 2.4 cycles at 3 to 4.7 Hz, did not
27/// or held under 2 cycles of it.
28pub(crate) const VIBRATO_CYCLES_MIN: f64 = 2.5;
29/// The search runs from this rate, or from [`SEARCH_CYCLES`] over the
30/// span.
31const SEARCH_LO: f64 = 1.0;
32/// The fewest cycles over the span the search reads.
33const SEARCH_CYCLES: f64 = 1.5;
34/// The search runs up to this rate.
35const SEARCH_HI: f64 = 20.0;
36/// The step of the search grid before the refinement.
37const SEARCH_STEP: f64 = 0.01;
38
39/// The convention of a vibrato, as `layers.json` names it.
40pub const VIBRATO_CONVENTION: &str = "the voiced f0 frames of a steady span, a voiced run of \
41the track split where two stable pitches lie 150 cents or more apart, without 50 ms at each end, \
42without the frames where a glide's fitted curve lies more than 10 cents from both its pitches \
43and without a drop: the span before it ends 50 ms before its onset, and the span after starts \
44once it has settled, five time constants and at least 100 ms after the onset; in cents; start_s and end_s are its first and last frame in audio time; a least-squares line and sine fitted \
45together, at the rate whose sine explains the most power beyond the line, searched from 1 Hz or \
461.5 cycles over the span up to 20 Hz in 0.01 Hz steps and refined by golden-section search; extent_raw_ct is that sine's amplitude, half the peak-to-peak swing; extent_ct is \
47extent_raw_ct times extent_factor, which undoes the smoothing of the f0 track at that rate; a \
48vibrato counts at 2.97 to 12.12 Hz, 3 to 12 Hz with 1 % slack, with extent_ct of 10 or more and a share, the power the sine \
49explains beyond the line, the line's residual less that of the line and sine together, over the \
50power left after the line alone, of 0.5 or more, over 2.5 cycles or more";
51
52/// A vibrato read from one span.
53#[derive(Clone, Copy, Debug, PartialEq)]
54pub struct VibratoFit {
55    /// The time of the first frame read: track time from [`fit_vibrato`],
56    /// audio time in the pitch layer, which subtracts the track's lag.
57    pub start: Seconds,
58    /// The time of the last frame read, as `start`.
59    pub end: Seconds,
60    /// The rate.
61    pub rate: Hz,
62    /// The amplitude of the fitted sine.
63    pub extent_raw: Cents,
64    /// The factor that turns the raw extent into the extent.
65    pub factor: f64,
66    /// The share of the detrended contour's power the sine explains.
67    pub share: f64,
68    /// The kurtosis of the contour after the line: 1.5 for a sine, 1.0
69    /// for a square wave.
70    pub kurtosis: f64,
71    /// The mean pitch of the span.
72    pub pitch: Hz,
73}
74
75impl VibratoFit {
76    /// The corrected extent, `extent_raw * factor`.
77    #[must_use]
78    pub fn extent(&self) -> Cents {
79        Cents::clamped(self.extent_raw.get() * self.factor)
80    }
81
82    /// The cycles of the sine over the span.
83    #[must_use]
84    pub fn cycles(&self) -> f64 {
85        self.rate.get() * (self.end.get() - self.start.get())
86    }
87
88    /// Whether the fit counts as a vibrato.
89    #[must_use]
90    pub fn accepted(&self) -> bool {
91        let rate = self.rate.get();
92        (VIBRATO_RATE_MIN.get() * (1.0 - RATE_SLACK)..=VIBRATO_RATE_MAX.get() * (1.0 + RATE_SLACK))
93            .contains(&rate)
94            && self.extent() >= VIBRATO_EXTENT_MIN
95            && self.share >= VIBRATO_SHARE_MIN
96            && self.cycles() >= VIBRATO_CYCLES_MIN
97    }
98}
99
100/// The raw extent the f0 track reads, as a share of the true extent, by
101/// rate.
102///
103/// Measured on one generated 1 s mono saw at 220 Hz with ±35 cents
104/// per rate, through the transcriber's notes and the track. The track's
105/// frame of 93 ms smooths a fast swing, so the share falls with the rate.
106/// Between the rates the share is interpolated linearly; outside them it
107/// holds the end value.
108pub const EXTENT_SHARE: [(f64, f64); 9] = [
109    (3.0, 1.003),
110    (4.0, 0.973),
111    (5.0, 0.944),
112    (5.5, 0.939),
113    (6.0, 0.909),
114    (7.0, 0.812),
115    (8.0, 0.817),
116    (10.0, 0.800),
117    (12.0, 0.732),
118];
119
120/// The factor that undoes the track's smoothing at `rate`: one over the
121/// interpolated [`EXTENT_SHARE`].
122#[must_use]
123pub(crate) fn extent_factor(rate: Hz) -> f64 {
124    let r = rate.get();
125    let share = match EXTENT_SHARE.iter().position(|&(at, _)| at >= r) {
126        None => EXTENT_SHARE[EXTENT_SHARE.len() - 1].1,
127        Some(0) => EXTENT_SHARE[0].1,
128        Some(k) => {
129            let ((r0, s0), (r1, s1)) = (EXTENT_SHARE[k - 1], EXTENT_SHARE[k]);
130            s0 + (s1 - s0) * (r - r0) / (r1 - r0)
131        }
132    };
133    1.0 / share
134}
135
136/// Fits a vibrato to voiced frames `(time, f0)` in time order. `None` when
137/// the frames span less than [`VIBRATO_SPAN_MIN`], hold no motion, or are
138/// too few for the line and the sine together.
139#[must_use]
140pub fn fit_vibrato(frames: &[(Seconds, Hz)]) -> Option<VibratoFit> {
141    let (first, last) = (frames.first()?, frames.last()?);
142    let span = last.0.get() - first.0.get();
143    if span < VIBRATO_SPAN_MIN.get() {
144        return None;
145    }
146    let mut cents: Vec<f64> = frames
147        .iter()
148        .filter(|(_, f0)| f0.get() > 0.0)
149        .map(|(_, f0)| 1200.0 * f0.get().log2())
150        .collect();
151    let times: Vec<f64> = frames
152        .iter()
153        .filter(|(_, f0)| f0.get() > 0.0)
154        .map(|(t, _)| t.get() - first.0.get())
155        .collect();
156    let mean = cents.iter().sum::<f64>() / cast::usize_to_f64(cents.len());
157    for c in &mut cents {
158        *c -= mean;
159    }
160    let residuals = detrend(&times, &cents);
161    let power: f64 = residuals.iter().map(|r| r * r).sum();
162    if power <= 0.0 {
163        return None;
164    }
165    let explained = |rate: f64| {
166        sine_fit(&times, &cents, rate).map_or(f64::NEG_INFINITY, |(left, _)| power - left)
167    };
168    let lo = SEARCH_LO.max(SEARCH_CYCLES / span);
169    let steps = cast::floor_to_usize((SEARCH_HI - lo) / SEARCH_STEP)?;
170    let (best, _) = (0..=steps)
171        .map(|k| lo + SEARCH_STEP * cast::usize_to_f64(k))
172        .map(|rate| (rate, explained(rate)))
173        .max_by(|a, b| a.1.total_cmp(&b.1))?;
174    let (rate, _) = golden_section(
175        |rate| -explained(rate),
176        (best - SEARCH_STEP).max(lo),
177        (best + SEARCH_STEP).min(SEARCH_HI),
178        1e-6,
179    );
180    let (left, amplitude) = sine_fit(&times, &cents, rate)?;
181    let explained_power = power - left;
182    let kurtosis = kurtosis(&residuals);
183    let rate = Hz::new(rate).ok()?;
184    Some(VibratoFit {
185        start: first.0,
186        end: last.0,
187        rate,
188        extent_raw: Cents::new(amplitude).ok()?,
189        factor: extent_factor(rate),
190        share: (explained_power / power).min(1.0),
191        kurtosis,
192        pitch: Hz::new(2_f64.powf(mean / 1200.0)).ok()?,
193    })
194}
195
196/// The least-squares fit of `a + b t + c cos(2 pi f t) + d sin(2 pi f t)`
197/// to `y`: the power left after the joint fit, and the sine's amplitude
198/// `sqrt(c^2 + d^2)`. Fitting the line and the sine together keeps a sine
199/// over a fraction of a cycle from leaking into the line. The sine term
200/// can share power with the line, so the power it explains beyond the line
201/// is the line's residual less the joint one, not the term's own power.
202/// `None` when the joint fit is singular.
203fn sine_fit(times: &[f64], values: &[f64], rate: f64) -> Option<(f64, f64)> {
204    let mut normal = [[0.0; 5]; 4];
205    for (&time, &value) in times.iter().zip(values) {
206        let (sine, cosine) = (std::f64::consts::TAU * rate * time).sin_cos();
207        let basis = [1.0, time, cosine, sine];
208        for (row, &u) in normal.iter_mut().zip(&basis) {
209            for (cell, &v) in row.iter_mut().zip(&basis) {
210                *cell += u * v;
211            }
212            row[4] += u * value;
213        }
214    }
215    let [offset, slope, along_cos, along_sin] = solve4(normal)?;
216    let left = times
217        .iter()
218        .zip(values)
219        .map(|(&time, &value)| {
220            let (sine, cosine) = (std::f64::consts::TAU * rate * time).sin_cos();
221            (value - offset - slope * time - along_cos * cosine - along_sin * sine).powi(2)
222        })
223        .sum();
224    Some((left, along_cos.hypot(along_sin)))
225}
226
227/// `y` less its least-squares line over `t`; a flat line when `t` holds a
228/// single time.
229fn detrend(t: &[f64], y: &[f64]) -> Vec<f64> {
230    let n = cast::usize_to_f64(t.len());
231    let (st, sy) = (t.iter().sum::<f64>(), y.iter().sum::<f64>());
232    let stt: f64 = t.iter().map(|t| t * t).sum();
233    let sty: f64 = t.iter().zip(y).map(|(t, y)| t * y).sum();
234    let det = n * stt - st * st;
235    let slope = if det > 0.0 {
236        (n * sty - st * sy) / det
237    } else {
238        0.0
239    };
240    let intercept = (sy - slope * st) / n;
241    t.iter()
242        .zip(y)
243        .map(|(t, y)| y - intercept - slope * t)
244        .collect()
245}
246
247/// The kurtosis of `residuals`, the fourth moment over the squared second,
248/// or 0 when nothing is left.
249fn kurtosis(residuals: &[f64]) -> f64 {
250    let n = cast::usize_to_f64(residuals.len());
251    let (mut m2, mut m4) = (0.0, 0.0);
252    for r in residuals {
253        m2 += r * r;
254        m4 += r.powi(4);
255    }
256    if m2 > 0.0 { n * m4 / (m2 * m2) } else { 0.0 }
257}
258
259/// Solves four linear equations, the augmented rows of `m`, by Gaussian
260/// elimination with partial pivoting. `None` for a singular system.
261fn solve4(mut m: [[f64; 5]; 4]) -> Option<[f64; 4]> {
262    for col in 0..4 {
263        let pivot = (col..4).max_by(|&a, &b| m[a][col].abs().total_cmp(&m[b][col].abs()))?;
264        if m[pivot][col].abs() < 1e-12 {
265            return None;
266        }
267        m.swap(col, pivot);
268        for row in col + 1..4 {
269            let factor = m[row][col] / m[col][col];
270            for k in col..5 {
271                m[row][k] -= factor * m[col][k];
272            }
273        }
274    }
275    let mut x = [0.0; 4];
276    for row in (0..4).rev() {
277        let tail: f64 = (row + 1..4).map(|k| m[row][k] * x[k]).sum();
278        x[row] = (m[row][4] - tail) / m[row][row];
279    }
280    Some(x)
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn the_share_counts_only_the_power_beyond_the_line() {
289        // Three cycles of a 20-cent sine from phase 0 under uniform noise.
290        // Such a sine leans with time, so its term shares power with the
291        // line's slope. Its own power read a share of 0.524 at 20.6 cents of
292        // noise and passed; the power it adds beyond the line reads 0.490.
293        // At 18 cents both pass.
294        let reads = |noise: f64| fit_vibrato(&noisy(0.0, noise)).unwrap();
295        let clear = reads(18.0);
296        assert!(
297            clear.accepted() && (0.55..0.59).contains(&clear.share),
298            "{clear:?}"
299        );
300        let borderline = reads(20.6);
301        assert!((0.48..0.5).contains(&borderline.share), "{borderline:?}");
302        assert!(!borderline.accepted(), "{borderline:?}");
303    }
304
305    /// Three cycles of a 20-cent sine at 5.5 Hz starting at `phase`, plus
306    /// uniform noise of up to `noise` cents from a fixed seed.
307    fn noisy(phase: f64, noise: f64) -> Vec<(Seconds, Hz)> {
308        let hop = 128.0 / 22_050.0;
309        let mut seed: u64 = 7;
310        let count = cast::floor_to_usize(3.0 / 5.5 / hop).unwrap();
311        (0..=count)
312            .map(|k| {
313                seed = seed
314                    .wrapping_mul(6_364_136_223_846_793_005)
315                    .wrapping_add(1_442_695_040_888_963_407);
316                let u = cast::u64_to_f64(seed >> 11) / cast::u64_to_f64(1 << 53) * 2.0 - 1.0;
317                let t = cast::usize_to_f64(k) * hop;
318                let cents = 20.0 * (std::f64::consts::TAU * 5.5 * t + phase).sin() + noise * u;
319                (
320                    Seconds::new(0.2 + t).unwrap(),
321                    Hz::new(220.0 * 2f64.powf(cents / 1200.0)).unwrap(),
322                )
323            })
324            .collect()
325    }
326
327    #[test]
328    fn the_rate_search_maximises_the_power_beyond_the_line() {
329        // A 10-cent sine at 6 Hz over 0.8 s on a bowed contour. Below 2 Hz
330        // a sine term bends with the bow and shares its power with the
331        // line: maximising the term's own power read 1.89 Hz. The power it
332        // adds beyond the line reads near 6 Hz.
333        let hop = 128.0 / 22_050.0;
334        let count = cast::floor_to_usize(0.8 / hop).unwrap();
335        let frames: Vec<(Seconds, Hz)> = (0..=count)
336            .map(|k| {
337                let t = cast::usize_to_f64(k) * hop;
338                let cents =
339                    300.0 * (t - 0.4).powi(2) + 10.0 * (std::f64::consts::TAU * 6.0 * t).sin();
340                (
341                    Seconds::new(0.2 + t).unwrap(),
342                    Hz::new(220.0 * 2f64.powf(cents / 1200.0)).unwrap(),
343                )
344            })
345            .collect();
346        let fit = fit_vibrato(&frames).unwrap();
347        assert!((fit.rate.get() - 6.0).abs() < 0.3, "{fit:?}");
348    }
349
350    #[test]
351    fn a_singular_joint_fit_reads_no_vibrato() {
352        // Three frames over 0.6 s leave the line and the sine underdetermined.
353        let frames: Vec<(Seconds, Hz)> = [0.2, 0.5, 0.8]
354            .iter()
355            .zip([220.0, 221.0, 219.0])
356            .map(|(&t, f)| (Seconds::new(t).unwrap(), Hz::new(f).unwrap()))
357            .collect();
358        assert_eq!(fit_vibrato(&frames), None);
359    }
360
361    #[test]
362    fn the_convention_states_the_constants() {
363        use super::super::glide::STEADY_SPLIT;
364        use super::super::{RUN_TRIM, SETTLE_MIN, num, says};
365        let ms = |s: f64| num(1000.0 * s);
366        says(
367            VIBRATO_CONVENTION,
368            &[
369                format!("lie {} cents or more apart", num(STEADY_SPLIT.get())),
370                format!("without {} ms at each end", ms(RUN_TRIM.get())),
371                format!(
372                    "a glide's fitted curve lies more than {} cents from both its pitches",
373                    num(VIBRATO_EXTENT_MIN.get())
374                ),
375                format!("five time constants and at least {} ms", ms(SETTLE_MIN)),
376                format!(
377                    "searched from {} Hz or {} cycles over the span up to {} Hz in {} Hz steps",
378                    num(SEARCH_LO),
379                    num(SEARCH_CYCLES),
380                    num(SEARCH_HI),
381                    num(SEARCH_STEP)
382                ),
383                format!(
384                    "counts at {} to {} Hz, {} to {} Hz with {} % slack",
385                    num(VIBRATO_RATE_MIN.get() * (1.0 - RATE_SLACK)),
386                    num(VIBRATO_RATE_MAX.get() * (1.0 + RATE_SLACK)),
387                    num(VIBRATO_RATE_MIN.get()),
388                    num(VIBRATO_RATE_MAX.get()),
389                    num(100.0 * RATE_SLACK)
390                ),
391                format!("extent_ct of {} or more", num(VIBRATO_EXTENT_MIN.get())),
392                format!("of {} or more, over", num(VIBRATO_SHARE_MIN)),
393                format!("over {} cycles or more", num(VIBRATO_CYCLES_MIN)),
394            ],
395        );
396    }
397
398    fn frames(rate: f64, extent: f64, span: f64) -> Vec<(Seconds, Hz)> {
399        let hop = 128.0 / 22_050.0;
400        let count = cast::floor_to_usize(span / hop).unwrap();
401        (0..=count)
402            .map(|k| {
403                let t = 0.2 + cast::usize_to_f64(k) * hop;
404                let cents = 0.3 * t + extent * (std::f64::consts::TAU * rate * t).sin();
405                (
406                    Seconds::new(t).unwrap(),
407                    Hz::new(220.0 * 2f64.powf(cents / 1200.0)).unwrap(),
408                )
409            })
410            .collect()
411    }
412
413    #[test]
414    fn an_exact_sine_contour_reads_its_rate_and_extent() {
415        let fit = fit_vibrato(&frames(5.5, 35.0, 1.0)).unwrap();
416        assert!((fit.rate.get() - 5.5).abs() < 1e-3, "{fit:?}");
417        assert!((fit.extent_raw.get() - 35.0).abs() < 0.1, "{fit:?}");
418        assert!((fit.factor - 1.0 / 0.939).abs() < 1e-3, "{fit:?}");
419        assert!(fit.share > 0.99 && fit.accepted(), "{fit:?}");
420        assert!((fit.kurtosis - 1.5).abs() < 0.02, "{fit:?}");
421    }
422
423    #[test]
424    fn a_vibrato_needs_two_and_a_half_cycles() {
425        // 3 Hz over 0.6 s holds 1.8 cycles, over 1 s 3.
426        let short = fit_vibrato(&frames(3.0, 35.0, 0.6)).unwrap();
427        assert!(
428            short.cycles() < VIBRATO_CYCLES_MIN && !short.accepted(),
429            "{short:?}"
430        );
431        let long = fit_vibrato(&frames(3.0, 35.0, 1.0)).unwrap();
432        assert!(long.accepted(), "{long:?}");
433    }
434
435    #[test]
436    fn a_contour_that_jumps_between_two_levels_reads_a_kurtosis_near_1() {
437        let square: Vec<(Seconds, Hz)> = frames(5.5, 35.0, 1.0)
438            .into_iter()
439            .map(|(t, _)| {
440                let up = (2.0 * 5.5 * t.get()).floor() % 2.0;
441                (t, Hz::new(220.0 * 2f64.powf(up / 12.0)).unwrap())
442            })
443            .collect();
444        let fit = fit_vibrato(&square).unwrap();
445        assert!(fit.kurtosis < 1.1, "{fit:?}");
446    }
447
448    #[test]
449    fn the_factor_interpolates_between_rates_and_holds_at_the_ends() {
450        let factor = |rate: f64| extent_factor(Hz::new(rate).unwrap());
451        assert!((factor(5.25) - 1.0 / 0.9415).abs() < 1e-12);
452        assert!((factor(2.0) - 1.0 / 1.003).abs() < 1e-12);
453        assert!((factor(15.0) - 1.0 / 0.732).abs() < 1e-12);
454    }
455
456    #[test]
457    fn slow_fast_shallow_and_short_motion_is_not_a_vibrato() {
458        assert!(!fit_vibrato(&frames(2.0, 35.0, 1.0)).unwrap().accepted());
459        assert!(!fit_vibrato(&frames(15.0, 35.0, 1.0)).unwrap().accepted());
460        assert!(!fit_vibrato(&frames(5.5, 5.0, 1.0)).unwrap().accepted());
461        assert_eq!(fit_vibrato(&frames(5.5, 35.0, 0.4)), None);
462        assert_eq!(fit_vibrato(&[]), None);
463    }
464}