rosa 0.1.0

Audio feature extraction library matching librosa output
Documentation
/// Convert Hz to mel scale.
///
/// Uses Slaney formula by default (htk=false), HTK formula when htk=true.
pub fn hz_to_mel(hz: f64, htk: bool) -> f64 {
    if htk {
        2595.0 * (1.0 + hz / 700.0).log10()
    } else {
        // Slaney: linear below 1000 Hz, logarithmic above
        if hz < 1000.0 {
            hz * 3.0 / 200.0
        } else {
            15.0 + 27.0 * (hz / 1000.0).ln() / f64::ln(6.4)
        }
    }
}

/// Convert mel scale to Hz.
///
/// Uses Slaney formula by default (htk=false), HTK formula when htk=true.
pub fn mel_to_hz(mel: f64, htk: bool) -> f64 {
    if htk {
        700.0 * (10.0_f64.powf(mel / 2595.0) - 1.0)
    } else {
        // Slaney: linear below mel 15, logarithmic above
        if mel < 15.0 {
            mel * 200.0 / 3.0
        } else {
            1000.0 * (f64::ln(6.4) * (mel - 15.0) / 27.0).exp()
        }
    }
}

/// Compute FFT bin center frequencies.
///
/// Returns `1 + n_fft/2` linearly spaced frequencies from 0 to sr/2.
/// Equivalent to `np.fft.rfftfreq(n_fft, d=1.0/sr)`.
pub fn fft_frequencies(sr: f64, n_fft: usize) -> Vec<f64> {
    let n_bins = 1 + n_fft / 2;
    (0..n_bins).map(|i| i as f64 * sr / n_fft as f64).collect()
}

/// Compute mel-spaced frequencies.
///
/// Returns `n_mels` frequencies equally spaced in mel scale between fmin and fmax.
/// For filterbank construction, callers typically pass `n_mels + 2`.
pub fn mel_frequencies(n_mels: usize, fmin: f64, fmax: f64, htk: bool) -> Vec<f64> {
    let min_mel = hz_to_mel(fmin, htk);
    let max_mel = hz_to_mel(fmax, htk);

    if n_mels <= 1 {
        return vec![mel_to_hz(min_mel, htk)];
    }

    let step = (max_mel - min_mel) / (n_mels - 1) as f64;
    (0..n_mels)
        .map(|i| mel_to_hz(min_mel + i as f64 * step, htk))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde::Deserialize;

    #[derive(Deserialize)]
    struct ConvTestCase {
        name: String,
        function: String,
        #[serde(default)]
        hz: Option<f64>,
        #[serde(default)]
        mel: Option<f64>,
        #[serde(default)]
        htk: Option<bool>,
        #[serde(default)]
        sr: Option<f64>,
        #[serde(default)]
        n_fft: Option<usize>,
        #[serde(default)]
        n_mels: Option<usize>,
        #[serde(default)]
        fmin: Option<f64>,
        #[serde(default)]
        fmax: Option<f64>,
        #[serde(default)]
        expected: serde_json::Value,
    }

    fn load_refs() -> Vec<ConvTestCase> {
        let data = include_str!("tests/references/mel_conversions.json");
        serde_json::from_str(data).expect("Failed to parse mel_conversions.json")
    }

    fn assert_approx(actual: f64, expected: f64, tol: f64, name: &str) {
        let diff = (actual - expected).abs();
        assert!(
            diff < tol,
            "{name}: got {actual}, expected {expected}, diff {diff} > tol {tol}"
        );
    }

    fn assert_vec_approx(actual: &[f64], expected: &[f64], tol: f64, name: &str) {
        assert_eq!(actual.len(), expected.len(), "{name}: length mismatch");
        for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
            let diff = (a - e).abs();
            assert!(
                diff < tol,
                "{name}[{i}]: got {a}, expected {e}, diff {diff} > tol {tol}"
            );
        }
    }

    #[test]
    fn test_hz_to_mel_matches_librosa() {
        let refs = load_refs();
        let mut count = 0;
        for case in refs.iter().filter(|c| c.function == "hz_to_mel") {
            let hz = case.hz.unwrap();
            let htk = case.htk.unwrap();
            let expected = case.expected.as_f64().unwrap();
            let actual = hz_to_mel(hz, htk);
            assert_approx(actual, expected, 1e-12, &case.name);
            count += 1;
        }
        assert!(count > 0, "No hz_to_mel test cases found");
    }

    #[test]
    fn test_mel_to_hz_matches_librosa() {
        let refs = load_refs();
        let mut count = 0;
        for case in refs.iter().filter(|c| c.function == "mel_to_hz") {
            let mel = case.mel.unwrap();
            let htk = case.htk.unwrap();
            let expected = case.expected.as_f64().unwrap();
            let actual = mel_to_hz(mel, htk);
            assert_approx(actual, expected, 1e-10, &case.name);
            count += 1;
        }
        assert!(count > 0, "No mel_to_hz test cases found");
    }

    #[test]
    fn test_fft_frequencies_matches_librosa() {
        let refs = load_refs();
        let mut count = 0;
        for case in refs.iter().filter(|c| c.function == "fft_frequencies") {
            let sr = case.sr.unwrap();
            let n_fft = case.n_fft.unwrap();
            let expected: Vec<f64> = case
                .expected
                .as_array()
                .unwrap()
                .iter()
                .map(|v| v.as_f64().unwrap())
                .collect();
            let actual = fft_frequencies(sr, n_fft);
            assert_vec_approx(&actual, &expected, 1e-12, &case.name);
            count += 1;
        }
        assert!(count > 0, "No fft_frequencies test cases found");
    }

    #[test]
    fn test_mel_frequencies_matches_librosa() {
        let refs = load_refs();
        let mut count = 0;
        for case in refs.iter().filter(|c| c.function == "mel_frequencies") {
            let n_mels = case.n_mels.unwrap();
            let fmin = case.fmin.unwrap();
            let fmax = case.fmax.unwrap();
            let htk = case.htk.unwrap();
            let expected: Vec<f64> = case
                .expected
                .as_array()
                .unwrap()
                .iter()
                .map(|v| v.as_f64().unwrap())
                .collect();
            let actual = mel_frequencies(n_mels, fmin, fmax, htk);
            assert_vec_approx(&actual, &expected, 1e-10, &case.name);
            count += 1;
        }
        assert!(count > 0, "No mel_frequencies test cases found");
    }
}