Skip to main content

sva_samples/measure/
pitch.rs

1// Concern: names each frame's spectral peaks as notes and flags likely harmonics | Non-concern: computing the spectrum (spectrum.rs), chord or key identity | IO: (&[f64], sample rate) -> frames
2
3use crate::measure::spectrum::{Peak, magnitudes, peaks};
4
5const NAMES: [&str; 12] = [
6    "C", "Cs", "D", "Ds", "E", "F", "Fs", "G", "Gs", "A", "As", "B",
7];
8const LOWEST_HZ: f64 = 25.0;
9const HIGHEST_HZ: f64 = 5000.0;
10const HARMONIC_TOLERANCE: f64 = 0.04;
11const HIGHEST_HARMONIC: f64 = 8.0;
12
13#[derive(Clone, Debug, PartialEq)]
14pub struct Note {
15    pub hz: f64,
16    pub name: String,
17    pub cents: f64,
18    pub db: f64,
19    pub harmonic_of: Option<f64>,
20}
21
22#[derive(Clone, Debug, PartialEq)]
23pub struct PitchFrame {
24    pub t_secs: f64,
25    pub notes: Vec<Note>,
26}
27
28pub fn track(
29    samples: &[f64],
30    sample_rate: f64,
31    start_secs: f64,
32    frame_secs: f64,
33    max_notes: usize,
34) -> Vec<PitchFrame> {
35    let stride = ((frame_secs * sample_rate).round() as usize).max(1);
36    samples
37        .chunks(stride)
38        .enumerate()
39        .map(|(n, chunk)| {
40            let (mags, bin_hz, ..) = magnitudes(chunk, sample_rate, None);
41            let found = peaks(&mags, bin_hz, max_notes * 4);
42            PitchFrame {
43                t_secs: start_secs + (n * stride) as f64 / sample_rate,
44                notes: name_all(&found, max_notes),
45            }
46        })
47        .collect()
48}
49
50/// The same naming over peaks a reading already holds exactly, loudest first.
51pub fn name_peaks(found: &[Peak], max_notes: usize) -> Vec<Note> {
52    name_all(found, max_notes)
53}
54
55/// A peak sitting at a near-integer multiple of a LOUDER peak is reported as that peak's
56/// harmonic, so a saw's partials never read as a chord of their own.
57fn name_all(found: &[Peak], max_notes: usize) -> Vec<Note> {
58    let audible: Vec<&Peak> = found
59        .iter()
60        .filter(|p| (LOWEST_HZ..=HIGHEST_HZ).contains(&p.hz))
61        .collect();
62
63    audible
64        .iter()
65        .take(max_notes)
66        .map(|p| {
67            let midi = 69.0 + 12.0 * (p.hz / 440.0).log2();
68            let nearest = midi.round();
69            Note {
70                hz: p.hz,
71                name: format!(
72                    "{}{}",
73                    NAMES[(nearest as i64).rem_euclid(12) as usize],
74                    (nearest as i64) / 12 - 1
75                ),
76                cents: (midi - nearest) * 100.0,
77                db: p.db,
78                harmonic_of: harmonic_of(p, &audible),
79            }
80        })
81        .collect()
82}
83
84fn harmonic_of(peak: &Peak, louder_first: &[&Peak]) -> Option<f64> {
85    louder_first
86        .iter()
87        .take_while(|other| other.db > peak.db)
88        .find(|other| {
89            let n = peak.hz / other.hz;
90            (1.5..=HIGHEST_HARMONIC).contains(&n) && (n - n.round()).abs() < HARMONIC_TOLERANCE
91        })
92        .map(|other| other.hz)
93}