pub fn roughness_weight(modulation_freq_hz: f64) -> f64 {
if !(15.0..=300.0).contains(&modulation_freq_hz) {
return 0.0;
}
let log_f = modulation_freq_hz.log2();
let log_center = 70.0_f64.log2();
let sigma = 0.8; (-0.5 * ((log_f - log_center) / sigma).powi(2)).exp()
}
pub fn roughness_from_modes(modes: &[(f64, f64)]) -> f64 {
let mut roughness = 0.0;
for i in 0..modes.len() {
for j in (i + 1)..modes.len() {
let (fi, li) = modes[i];
let (fj, lj) = modes[j];
let mod_freq = (fi - fj).abs();
let w = roughness_weight(mod_freq);
if w > 0.0 {
let depth = li.min(lj).max(0.0);
roughness += w * depth * depth;
}
}
}
roughness / (70.0 * 70.0)
}
fn find_spectral_peaks(freqs: &[f64], spl_db: &[f64], min_prominence_db: f64) -> Vec<(f64, f64)> {
let mut peaks = Vec::new();
if spl_db.len() < 3 {
return peaks;
}
for i in 1..(spl_db.len() - 1) {
let left = spl_db[i - 1];
let right = spl_db[i + 1];
let center = spl_db[i];
let prominence = center - left.max(right);
if prominence >= min_prominence_db {
peaks.push((freqs[i], center));
}
}
peaks
}
pub fn roughness_from_spectrum(freqs: &[f64], spl_db: &[f64]) -> f64 {
let peaks = find_spectral_peaks(freqs, spl_db, 3.0);
roughness_from_modes(&peaks)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_roughness_weight_peak_at_70hz() {
let w70 = roughness_weight(70.0);
assert!(
(w70 - 1.0).abs() < 0.01,
"Weight at 70 Hz = {w70}, expected ~1.0"
);
assert!(w70 > roughness_weight(30.0));
assert!(w70 > roughness_weight(200.0));
}
#[test]
fn test_roughness_weight_zero_outside_range() {
assert_eq!(roughness_weight(10.0), 0.0, "Below 15 Hz should be 0");
assert_eq!(roughness_weight(500.0), 0.0, "Above 300 Hz should be 0");
assert_eq!(roughness_weight(0.0), 0.0, "0 Hz should be 0");
assert_eq!(roughness_weight(-5.0), 0.0, "Negative freq should be 0");
}
#[test]
fn test_roughness_close_modes_high() {
let modes = vec![(200.0, 80.0), (250.0, 80.0)];
let r = roughness_from_modes(&modes);
assert!(
r > 0.1,
"Close loud modes should produce significant roughness, got {r}"
);
}
#[test]
fn test_roughness_far_modes_low() {
let modes = vec![(200.0, 80.0), (1200.0, 80.0)];
let r = roughness_from_modes(&modes);
assert!(
r < 1e-10,
"Far-apart modes should produce zero roughness, got {r}"
);
}
#[test]
fn test_roughness_from_flat_spectrum() {
let n = 500;
let freqs: Vec<f64> = (0..n)
.map(|i| 20.0 + (16000.0 - 20.0) * i as f64 / n as f64)
.collect();
let spl: Vec<f64> = vec![70.0; n];
let r = roughness_from_spectrum(&freqs, &spl);
assert!(
r < 1e-10,
"Flat spectrum should have near-zero roughness, got {r}"
);
}
#[test]
fn test_roughness_from_spectrum_with_peaks() {
let n = 500;
let freqs: Vec<f64> = (0..n)
.map(|i| 20.0 + (16000.0 - 20.0) * i as f64 / n as f64)
.collect();
let mut spl: Vec<f64> = vec![60.0; n];
let idx_200 = freqs
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| (*a - 200.0).abs().partial_cmp(&(*b - 200.0).abs()).unwrap())
.unwrap()
.0;
let idx_260 = freqs
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| (*a - 260.0).abs().partial_cmp(&(*b - 260.0).abs()).unwrap())
.unwrap()
.0;
spl[idx_200] = 75.0; spl[idx_260] = 75.0;
let r = roughness_from_spectrum(&freqs, &spl);
assert!(
r > 0.0,
"Spectrum with close peaks should have nonzero roughness, got {r}"
);
}
#[test]
fn test_roughness_weight_monotonic_toward_peak() {
let w20 = roughness_weight(20.0);
let w40 = roughness_weight(40.0);
let w60 = roughness_weight(60.0);
assert!(
w60 > w40 && w40 > w20,
"Weight should increase toward 70 Hz: w20={w20}, w40={w40}, w60={w60}"
);
}
}