use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq)]
pub struct PitchTrack {
pub f0: Vec<Option<f64>>,
pub hop_s: f64,
pub mean_hz: Option<f64>,
pub std_hz: Option<f64>,
pub voiced_fraction: f64,
pub aperiodicity: Vec<Option<f64>>,
pub mean_hnr_db: Option<f64>,
}
fn hnr_db(aperiodicity: f64) -> f64 {
const MIN_A: f64 = 1e-4;
let a = aperiodicity.clamp(MIN_A, 1.0 - MIN_A);
10.0 * ((1.0 - a) / a).log10()
}
#[derive(Debug, Clone, Copy)]
pub struct PitchOptions {
pub min_hz: f64,
pub max_hz: f64,
pub window_s: f64,
pub hop_s: f64,
pub threshold: f64,
}
impl Default for PitchOptions {
fn default() -> Self {
Self {
min_hz: 60.0,
max_hz: 400.0,
window_s: 0.045,
hop_s: 0.010,
threshold: 0.15,
}
}
}
fn parabolic_offset(a: f64, b: f64, c: f64) -> f64 {
let denom = 2.0 * (a - 2.0 * b + c);
if denom <= 0.0 || !denom.is_finite() {
return 0.0;
}
((a - c) / denom).clamp(-1.0, 1.0)
}
fn frame_f0(
frame: &[f32],
scratch: &mut [f64],
sample_rate: f64,
integration: usize,
tau_min: usize,
tau_max: usize,
threshold: f64,
) -> Option<(f64, f64)> {
if let Some(slot) = scratch.get_mut(0) {
*slot = 1.0;
}
let mut running = 0.0f64;
for tau in 1..=tau_max {
let mut d = 0.0f64;
if let (Some(a), Some(b)) = (frame.get(..integration), frame.get(tau..tau + integration)) {
d = a
.iter()
.zip(b.iter())
.map(|(&x, &y)| {
let delta = x as f64 - y as f64;
delta * delta
})
.sum();
}
running += d;
let normalised = if running > 0.0 {
d * tau as f64 / running
} else {
1.0
};
if let Some(slot) = scratch.get_mut(tau) {
*slot = normalised;
}
}
let at = |i: usize| scratch.get(i).copied().unwrap_or(1.0);
let mut tau = tau_min;
let mut chosen = None;
while tau <= tau_max {
if at(tau) < threshold {
while tau < tau_max && at(tau + 1) < at(tau) {
tau += 1;
}
chosen = Some(tau);
break;
}
tau += 1;
}
let tau = chosen?;
let refined = if tau > tau_min && tau < tau_max {
tau as f64 + parabolic_offset(at(tau - 1), at(tau), at(tau + 1))
} else {
tau as f64
};
if refined > 0.0 {
Some((sample_rate / refined, at(tau)))
} else {
None
}
}
fn require_positive(arg: &'static str, value: f64) -> Result<()> {
if value.is_finite() && value > 0.0 {
Ok(())
} else {
Err(Error::InvalidArgument {
arg,
reason: format!("must be > 0, got {value}"),
})
}
}
pub fn estimate_pitch(samples: &[f32], sample_rate: u32, opts: PitchOptions) -> Result<PitchTrack> {
if sample_rate == 0 {
return Err(Error::InvalidArgument {
arg: "sample_rate",
reason: "must be > 0, got 0".into(),
});
}
if samples.is_empty() {
return Err(Error::InvalidArgument {
arg: "samples",
reason: "must be non-empty, got 0 samples".into(),
});
}
require_positive("opts.min_hz", opts.min_hz)?;
require_positive("opts.window_s", opts.window_s)?;
require_positive("opts.hop_s", opts.hop_s)?;
if !opts.max_hz.is_finite() || opts.min_hz >= opts.max_hz {
return Err(Error::InvalidArgument {
arg: "opts.max_hz",
reason: format!("must be > min_hz {}, got {}", opts.min_hz, opts.max_hz),
});
}
let rate = sample_rate as f64;
if opts.max_hz >= rate / 2.0 {
return Err(Error::InvalidArgument {
arg: "opts.max_hz",
reason: format!("must be below Nyquist {}, got {}", rate / 2.0, opts.max_hz),
});
}
let min_window_s = 2.0 / opts.min_hz;
if opts.window_s < min_window_s {
return Err(Error::InvalidArgument {
arg: "opts.window_s",
reason: format!(
"must span two periods of min_hz {} (>= {min_window_s} s), got {}",
opts.min_hz, opts.window_s
),
});
}
let tau_max = (rate / opts.min_hz).floor() as usize;
let tau_min = ((rate / opts.max_hz).floor() as usize).max(2);
let window = (opts.window_s * rate).round() as usize;
let hop = ((opts.hop_s * rate).round() as usize).max(1);
if tau_min + 1 >= tau_max || window <= tau_max {
return Err(Error::InvalidArgument {
arg: "opts.min_hz",
reason: format!(
"search range {}-{} Hz at {sample_rate} Hz gives lags {tau_min}-{tau_max} in a \
{window}-sample window, which leaves no lag to search",
opts.min_hz, opts.max_hz
),
});
}
let integration = window - tau_max;
let mut scratch = vec![0.0f64; tau_max + 1];
let mut f0 = Vec::new();
let mut aperiodicity = Vec::new();
let mut start = 0usize;
while let Some(frame) = samples.get(start..start + window) {
let measured = frame_f0(
frame,
&mut scratch,
rate,
integration,
tau_min,
tau_max,
opts.threshold,
);
f0.push(measured.map(|(hz, _)| hz));
aperiodicity.push(measured.map(|(_, a)| a));
start += hop;
}
let voiced: Vec<f64> = f0.iter().filter_map(|&v| v).collect();
let voiced_aperiodicity: Vec<f64> = aperiodicity.iter().filter_map(|&v| v).collect();
let mean_hnr_db = if voiced_aperiodicity.is_empty() {
None
} else {
Some(
voiced_aperiodicity.iter().copied().map(hnr_db).sum::<f64>()
/ voiced_aperiodicity.len() as f64,
)
};
let voiced_fraction = if f0.is_empty() {
0.0
} else {
voiced.len() as f64 / f0.len() as f64
};
let mean_hz = if voiced.is_empty() {
None
} else {
Some(voiced.iter().sum::<f64>() / voiced.len() as f64)
};
let std_hz = match mean_hz {
Some(mean) if voiced.len() >= 2 => {
let var = voiced
.iter()
.map(|v| {
let d = v - mean;
d * d
})
.sum::<f64>()
/ voiced.len() as f64;
Some(var.sqrt())
}
_ => None,
};
Ok(PitchTrack {
f0,
hop_s: hop as f64 / rate,
mean_hz,
std_hz,
voiced_fraction,
aperiodicity,
mean_hnr_db,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hnr_is_monotonic_in_aperiodicity_and_finite_at_the_ends() {
assert!(hnr_db(0.0).is_finite());
assert!(hnr_db(1.0).is_finite());
assert!(hnr_db(0.0) > hnr_db(0.1));
assert!(hnr_db(0.1) > hnr_db(0.5));
assert!(hnr_db(0.5) > hnr_db(0.9));
assert!(
hnr_db(0.5).abs() < 1e-9,
"a=0.5 must be 0 dB, got {}",
hnr_db(0.5)
);
}
}