rosa 0.1.0

Audio feature extraction library matching librosa output
Documentation
use crate::matrix::Matrix;
use num_complex::Complex;
use realfft::RealFftPlanner;

/// STFT parameters with librosa-compatible defaults.
pub struct StftParams {
    /// Number of FFT points.
    pub n_fft: usize,
    /// Number of samples between successive frames.
    pub hop_length: usize,
    /// Length of the windowed signal before zero-padding.
    pub win_length: usize,
    /// If true, pad the signal on both sides so frames are centered.
    pub center: bool,
    /// Custom window. None = default periodic hann window.
    pub window: Option<Vec<f64>>,
}

impl StftParams {
    /// Create `StftParams` with the given FFT size and librosa-compatible defaults.
    pub fn new(n_fft: usize) -> Self {
        Self {
            n_fft,
            hop_length: n_fft / 4,
            win_length: n_fft,
            center: true,
            window: None,
        }
    }
}

impl Default for StftParams {
    fn default() -> Self {
        Self::new(2048)
    }
}

/// Compute the complex STFT (internal).
///
/// Returns n_frames vectors of (1 + n_fft/2) complex values.
fn stft_complex_inner(y: &[f64], params: &StftParams) -> Vec<Vec<Complex<f64>>> {
    let n_fft = params.n_fft;
    let hop_length = params.hop_length;

    // Build window: use custom window if provided, otherwise periodic hann
    let win = match &params.window {
        Some(w) => w.clone(),
        None => crate::windows::hann(params.win_length, true),
    };
    let win = crate::dsp::util::pad_center(&win, n_fft);

    // Pad signal if center=true
    let padded: Vec<f64>;
    let signal = if params.center {
        let pad = n_fft / 2;
        padded = std::iter::repeat_n(0.0, pad)
            .chain(y.iter().copied())
            .chain(std::iter::repeat_n(0.0, pad))
            .collect();
        &padded
    } else {
        y
    };

    // Compute frame count
    if signal.len() < n_fft {
        return Vec::new();
    }
    let n_frames = 1 + (signal.len() - n_fft) / hop_length;
    let n_bins = 1 + n_fft / 2;

    // Setup FFT
    let mut planner = RealFftPlanner::<f64>::new();
    let fft = planner.plan_fft_forward(n_fft);
    let mut scratch = fft.make_scratch_vec();
    let mut input_buf = fft.make_input_vec();
    let mut output_buf = fft.make_output_vec();

    let mut result = Vec::with_capacity(n_frames);

    for i in 0..n_frames {
        let start = i * hop_length;
        let frame = &signal[start..start + n_fft];

        // Apply window
        for (j, sample) in frame.iter().enumerate() {
            input_buf[j] = sample * win[j];
        }

        // FFT
        fft.process_with_scratch(&mut input_buf, &mut output_buf, &mut scratch)
            .expect("FFT failed");

        // Copy output (realfft returns n_fft/2+1 complex values)
        let frame_out = output_buf[..n_bins].to_vec();
        result.push(frame_out);
    }

    result
}

/// Compute a power/magnitude spectrogram.
///
/// Returns a Matrix of shape `(1 + n_fft/2, n_frames)` containing `|STFT|^power`.
///
/// # Examples
///
/// ```
/// use rosa::stft::{spectrogram, StftParams};
///
/// let signal = vec![0.0; 4096];
/// let params = StftParams::default();
/// let s = spectrogram(&signal, &params, 2.0);
/// assert_eq!(s.rows(), 1 + params.n_fft / 2);
/// ```
pub fn spectrogram(y: &[f64], params: &StftParams, power: f64) -> Matrix {
    let complex = stft_complex_inner(y, params);
    let n_frames = complex.len();
    if n_frames == 0 {
        return Matrix::zeros(1 + params.n_fft / 2, 0);
    }
    let n_bins = complex[0].len();

    // Build matrix: rows = freq bins, cols = frames
    let mut data = vec![0.0; n_bins * n_frames];
    for (frame_idx, frame) in complex.iter().enumerate() {
        for (bin_idx, &c) in frame.iter().enumerate() {
            let mag = c.norm(); // |X|
            data[bin_idx * n_frames + frame_idx] = mag.powf(power);
        }
    }

    Matrix::from_vec(data, n_bins, n_frames)
}

/// Compute the STFT, returning magnitude and phase as separate matrices.
///
/// Both matrices have shape `(1 + n_fft/2, n_frames)`.
///
/// # Examples
///
/// ```
/// use rosa::stft::{stft, StftParams};
///
/// let signal = vec![0.0; 4096];
/// let (magnitude, phase) = stft(&signal, &StftParams::default());
/// assert_eq!(magnitude.shape(), phase.shape());
/// ```
pub fn stft(y: &[f64], params: &StftParams) -> (Matrix, Matrix) {
    let complex = stft_complex_inner(y, params);
    let n_frames = complex.len();
    if n_frames == 0 {
        let rows = 1 + params.n_fft / 2;
        return (Matrix::zeros(rows, 0), Matrix::zeros(rows, 0));
    }
    let n_bins = complex[0].len();

    let mut mag_data = vec![0.0; n_bins * n_frames];
    let mut phase_data = vec![0.0; n_bins * n_frames];

    for (frame_idx, frame) in complex.iter().enumerate() {
        for (bin_idx, &c) in frame.iter().enumerate() {
            let idx = bin_idx * n_frames + frame_idx;
            mag_data[idx] = c.norm();
            phase_data[idx] = c.arg();
        }
    }

    (
        Matrix::from_vec(mag_data, n_bins, n_frames),
        Matrix::from_vec(phase_data, n_bins, n_frames),
    )
}

/// Compute the complex STFT, returning real and imaginary parts as separate matrices.
///
/// Both matrices have shape `(1 + n_fft/2, n_frames)`.
/// This preserves full complex information for reconstruction via `istft`.
pub fn stft_as_real_imag(y: &[f64], params: &StftParams) -> (Matrix, Matrix) {
    let complex = stft_complex_inner(y, params);
    let n_frames = complex.len();
    if n_frames == 0 {
        let rows = 1 + params.n_fft / 2;
        return (Matrix::zeros(rows, 0), Matrix::zeros(rows, 0));
    }
    let n_bins = complex[0].len();

    let mut real_data = vec![0.0; n_bins * n_frames];
    let mut imag_data = vec![0.0; n_bins * n_frames];

    for (frame_idx, frame) in complex.iter().enumerate() {
        for (bin_idx, &c) in frame.iter().enumerate() {
            let idx = bin_idx * n_frames + frame_idx;
            real_data[idx] = c.re;
            imag_data[idx] = c.im;
        }
    }

    (
        Matrix::from_vec(real_data, n_bins, n_frames),
        Matrix::from_vec(imag_data, n_bins, n_frames),
    )
}

/// ISTFT parameters with librosa-compatible defaults.
pub struct IstftParams {
    /// Number of FFT points.
    pub n_fft: usize,
    /// Number of samples between successive frames.
    pub hop_length: usize,
    /// Length of the windowed signal before zero-padding.
    pub win_length: usize,
    /// If true, remove center-padding from the reconstructed signal.
    pub center: bool,
    /// If set, trim or pad the output to this length.
    pub length: Option<usize>,
}

impl IstftParams {
    /// Create `IstftParams` with the given FFT size and librosa-compatible defaults.
    pub fn new(n_fft: usize) -> Self {
        Self {
            n_fft,
            hop_length: n_fft / 4,
            win_length: n_fft,
            center: true,
            length: None,
        }
    }
}

/// Reconstruct a signal from its complex STFT (given as real + imaginary matrices).
///
/// Both matrices have shape `(n_bins, n_frames)` where `n_bins = 1 + n_fft/2`.
/// Matches `librosa.istft`.
///
/// # Panics
///
/// Panics if `stft_real` and `stft_imag` have different shapes, or if
/// `stft_real.rows() != 1 + n_fft / 2`.
pub fn istft(stft_real: &Matrix, stft_imag: &Matrix, params: &IstftParams) -> Vec<f64> {
    let n_bins = stft_real.rows();
    let n_frames = stft_real.cols();
    let n_fft = params.n_fft;
    let hop_length = params.hop_length;

    assert_eq!(n_bins, 1 + n_fft / 2, "istft: n_bins mismatch");
    assert_eq!(stft_imag.rows(), n_bins);
    assert_eq!(stft_imag.cols(), n_frames);

    if n_frames == 0 {
        return Vec::new();
    }

    // Build window
    let win = crate::windows::hann(params.win_length, true);
    let win = crate::dsp::util::pad_center(&win, n_fft);

    // Expected output length
    let expected_len = n_fft + hop_length * (n_frames - 1);
    let mut output = vec![0.0; expected_len];
    let mut window_sum = vec![0.0; expected_len];

    // Setup inverse FFT
    let mut planner = RealFftPlanner::<f64>::new();
    let ifft = planner.plan_fft_inverse(n_fft);
    let mut scratch = ifft.make_scratch_vec();
    let mut spectrum = ifft.make_input_vec();
    let mut time_buf = ifft.make_output_vec();

    let norm = 1.0 / n_fft as f64;

    for frame_idx in 0..n_frames {
        // Build complex spectrum for this frame
        for (bin_idx, spec) in spectrum.iter_mut().enumerate().take(n_bins) {
            *spec = Complex::new(
                stft_real.get(bin_idx, frame_idx),
                stft_imag.get(bin_idx, frame_idx),
            );
        }

        // Inverse FFT
        ifft.process_with_scratch(&mut spectrum, &mut time_buf, &mut scratch)
            .expect("Inverse FFT failed");

        // Overlap-add with window
        let start = frame_idx * hop_length;
        for j in 0..n_fft {
            output[start + j] += time_buf[j] * norm * win[j];
            window_sum[start + j] += win[j] * win[j];
        }
    }

    // Normalize by window sum-of-squares (COLA condition)
    let tiny = crate::dsp::util::tiny();
    for i in 0..expected_len {
        if window_sum[i] > tiny {
            output[i] /= window_sum[i];
        }
    }

    // If center, trim padding
    if params.center {
        let pad = n_fft / 2;
        let end = expected_len.saturating_sub(pad);
        output = output[pad.min(expected_len)..end].to_vec();
    }

    // If length specified, fix to that length
    if let Some(length) = params.length {
        output = crate::dsp::util::fix_length(&output, length);
    }

    output
}