rosa 0.1.0

Audio feature extraction library matching librosa output
Documentation
//! FFT-based autocorrelation matching librosa.autocorrelate.

use num_complex::Complex;
use realfft::RealFftPlanner;

/// Compute bounded-lag autocorrelation using FFT.
///
/// Matches `librosa.autocorrelate(y, max_size=max_size)`.
///
/// If `max_size` is None, returns autocorrelation for all lags up to `y.len()`.
pub fn autocorrelate(y: &[f64], max_size: Option<usize>) -> Vec<f64> {
    let n = y.len();
    if n == 0 {
        return Vec::new();
    }

    let max_size = max_size.unwrap_or(n).min(n);

    // Pad to efficient FFT length (at least 2*n - 1)
    let padded_len = (2 * n - 1).next_power_of_two();

    let mut planner = RealFftPlanner::<f64>::new();

    // Forward FFT
    let fft_forward = planner.plan_fft_forward(padded_len);
    let mut scratch = fft_forward.make_scratch_vec();
    let mut input_buf = fft_forward.make_input_vec();
    let mut spectrum = fft_forward.make_output_vec();

    // Copy y into input buffer (rest is zero-padded by make_input_vec)
    input_buf[..n].copy_from_slice(y);

    fft_forward
        .process_with_scratch(&mut input_buf, &mut spectrum, &mut scratch)
        .expect("Forward FFT failed");

    // Compute power spectrum: |Y|^2 element-wise
    for val in spectrum.iter_mut() {
        *val = Complex::new(val.norm_sqr(), 0.0);
    }

    // Inverse FFT
    let fft_inverse = planner.plan_fft_inverse(padded_len);
    let mut scratch_inv = fft_inverse.make_scratch_vec();
    let mut output_buf = fft_inverse.make_output_vec();

    fft_inverse
        .process_with_scratch(&mut spectrum, &mut output_buf, &mut scratch_inv)
        .expect("Inverse FFT failed");

    // realfft's inverse is unnormalized, divide by padded_len
    let norm = 1.0 / padded_len as f64;

    output_buf[..max_size].iter().map(|&v| v * norm).collect()
}

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

    #[derive(Deserialize)]
    struct AutocorrelateTestCase {
        name: String,
        input: Vec<f64>,
        max_size: Option<usize>,
        expected: Vec<f64>,
    }

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

    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();
            // Use relative tolerance for large values, absolute for small
            let threshold = tol * e.abs().max(1.0);
            assert!(
                diff < threshold,
                "{name}[{i}]: got {a}, expected {e}, diff {diff} > tol {threshold}"
            );
        }
    }

    #[test]
    fn test_autocorrelate_matches_librosa() {
        for case in load_references() {
            let result = autocorrelate(&case.input, case.max_size);
            assert_approx_eq(&result, &case.expected, 1e-10, &case.name);
        }
    }
}