rosa 0.1.0

Audio feature extraction library matching librosa output
Documentation
use std::f64::consts::PI;

/// Generate a pure tone (cosine) signal.
///
/// Matches `librosa.tone(frequency, sr=sr, length=length)`.
/// Uses `phi = -π/2` by default (producing a sine wave), matching librosa's default.
pub fn tone(frequency: f64, sr: u32, length: usize) -> Vec<f64> {
    let phi = -PI * 0.5;
    let sr_f = sr as f64;
    (0..length)
        .map(|n| (2.0 * PI * frequency * n as f64 / sr_f + phi).cos())
        .collect()
}

/// Generate a chirp (frequency sweep) signal.
///
/// Matches `librosa.chirp(fmin=fmin, fmax=fmax, sr=sr, length=length, linear=linear)`.
/// Reimplements `scipy.signal.chirp` with `method='logarithmic'` or `method='linear'`.
///
/// - `linear=false` (default in librosa): exponential/logarithmic sweep
/// - `linear=true`: linear frequency sweep
pub fn chirp(fmin: f64, fmax: f64, sr: u32, length: usize, linear: bool) -> Vec<f64> {
    // librosa passes phi=-pi/2 converted to degrees: phi / pi * 180 = -90
    // scipy then converts back: phi_rad = phi_deg * pi / 180 = -pi/2
    let phi = -PI * 0.5;
    let sr_f = sr as f64;
    // librosa: duration = (1/sr) * length = length/sr (NOT (length-1)/sr)
    let t1 = length as f64 / sr_f;

    if linear {
        // scipy: beta = (f1 - f0) / t1; phase = 2*pi*(f0*t + 0.5*beta*t^2)
        let beta = (fmax - fmin) / t1;
        (0..length)
            .map(|n| {
                let t = n as f64 / sr_f;
                let phase = 2.0 * PI * (fmin * t + 0.5 * beta * t * t);
                (phase + phi).cos()
            })
            .collect()
    } else {
        // scipy: beta = t1 / log(f1/f0); phase = 2*pi*f0*beta*(pow(f1/f0, t/t1) - 1)
        let log_ratio = (fmax / fmin).ln();
        let beta = t1 / log_ratio;
        (0..length)
            .map(|n| {
                let t = n as f64 / sr_f;
                // Use exp(log_ratio * t / t1) instead of powf for stability
                let phase = 2.0 * PI * fmin * beta * ((log_ratio * t / t1).exp() - 1.0);
                (phase + phi).cos()
            })
            .collect()
    }
}

/// Generate a click track signal.
///
/// Matches `librosa.clicks(times=times, sr=sr, click_freq=click_freq,
///                          click_duration=click_duration, length=length)`.
///
/// Each click is an exponentially decaying sine wave at `click_freq`.
pub fn clicks(
    times: &[f64],
    sr: u32,
    click_freq: f64,
    click_duration: f64,
    length: Option<usize>,
) -> Vec<f64> {
    let sr_f = sr as f64;
    let click_len = (sr_f * click_duration) as usize;
    let angular_freq = 2.0 * PI * click_freq / sr_f;

    // Generate default click: exponential envelope * sine wave
    // librosa uses: np.logspace(0, -10, num=click_len, base=2.0)
    // which is 2^linspace(0, -10, click_len)
    let click: Vec<f64> = (0..click_len)
        .map(|i| {
            let t = if click_len > 1 {
                i as f64 / (click_len - 1) as f64
            } else {
                0.0
            };
            let envelope = 2.0_f64.powf(-10.0 * t);
            let wave = (angular_freq * i as f64).sin();
            envelope * wave
        })
        .collect();

    // Convert times to sample positions
    let positions: Vec<usize> = times.iter().map(|&t| (t * sr_f) as usize).collect();

    // Determine output length
    let total_len =
        length.unwrap_or_else(|| positions.iter().map(|&p| p + click_len).max().unwrap_or(0));

    // Place clicks
    let mut output = vec![0.0; total_len];
    for &start in &positions {
        if start >= total_len {
            continue;
        }
        let end = (start + click_len).min(total_len);
        for (i, sample) in click[..end - start].iter().enumerate() {
            output[start + i] += sample;
        }
    }

    output
}

#[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 ToneTestCase {
        name: String,
        frequency: f64,
        sr: u32,
        length: usize,
        expected: Vec<f64>,
    }

    #[test]
    fn test_tone_matches_librosa() {
        let data = include_str!("tests/references/tone.json");
        let cases: Vec<ToneTestCase> = serde_json::from_str(data).unwrap();
        for case in cases {
            let result = tone(case.frequency, case.sr, case.length);
            assert_approx_eq(&result, &case.expected, 1e-10, &case.name);
        }
    }

    #[derive(Deserialize)]
    struct ChirpTestCase {
        name: String,
        fmin: f64,
        fmax: f64,
        sr: u32,
        length: usize,
        linear: bool,
        expected: Vec<f64>,
    }

    #[test]
    fn test_chirp_matches_librosa() {
        let data = include_str!("tests/references/chirp.json");
        let cases: Vec<ChirpTestCase> = serde_json::from_str(data).unwrap();
        for case in cases {
            let result = chirp(case.fmin, case.fmax, case.sr, case.length, case.linear);
            assert_approx_eq(&result, &case.expected, 1e-10, &case.name);
        }
    }

    #[derive(Deserialize)]
    struct ClicksTestCase {
        name: String,
        times: Vec<f64>,
        sr: u32,
        click_freq: f64,
        click_duration: f64,
        length: usize,
        expected: Vec<f64>,
    }

    #[test]
    fn test_clicks_matches_librosa() {
        let data = include_str!("tests/references/clicks.json");
        let cases: Vec<ClicksTestCase> = serde_json::from_str(data).unwrap();
        for case in cases {
            let result = clicks(
                &case.times,
                case.sr,
                case.click_freq,
                case.click_duration,
                Some(case.length),
            );
            assert_approx_eq(&result, &case.expected, 1e-6, &case.name);
        }
    }
}