rosa 0.1.0

Audio feature extraction library matching librosa output
Documentation
/// Convert multi-channel audio to mono by averaging channels.
///
/// Matches `librosa.to_mono(y)` where `y` has shape `(channels, samples)`.
pub fn to_mono(channels: &[Vec<f64>]) -> Vec<f64> {
    if channels.is_empty() {
        return Vec::new();
    }
    if channels.len() == 1 {
        return channels[0].clone();
    }

    let n_samples = channels[0].len();
    let n_channels = channels.len() as f64;

    (0..n_samples)
        .map(|i| channels.iter().map(|ch| ch[i]).sum::<f64>() / n_channels)
        .collect()
}

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

    fn assert_approx_eq(actual: &[f64], expected: &[f64], tol: f64, name: &str) {
        assert_eq!(
            actual.len(),
            expected.len(),
            "{name}: length mismatch: got {} expected {}",
            actual.len(),
            expected.len()
        );
        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}"
            );
        }
    }

    #[derive(Deserialize)]
    struct MonoTestCase {
        name: String,
        channels: Vec<Vec<f64>>,
        expected: Vec<f64>,
    }

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

    #[test]
    fn test_to_mono_matches_librosa() {
        for case in load_references() {
            let result = to_mono(&case.channels);
            assert_approx_eq(&result, &case.expected, 1e-12, &case.name);
        }
    }
}