rosa 0.1.0

Audio feature extraction library matching librosa output
Documentation
//! Parabolic interpolation pitch tracking matching `librosa.piptrack`.

use crate::matrix::Matrix;
use crate::stft::{StftParams, fft_frequencies};

/// Parameters for piptrack.
pub struct PiptrackParams {
    /// Sampling rate in Hz. Default: 22050.0
    pub sr: f64,
    /// FFT size. Default: 2048
    pub n_fft: usize,
    /// Hop length in samples. Default: n_fft / 4
    pub hop_length: Option<usize>,
    /// Lower frequency cutoff. Default: 150.0
    pub fmin: f64,
    /// Upper frequency cutoff. Default: 4000.0
    pub fmax: f64,
    /// Magnitude threshold (relative). Default: 0.1
    pub threshold: f64,
}

impl Default for PiptrackParams {
    fn default() -> Self {
        Self {
            sr: 22050.0,
            n_fft: 2048,
            hop_length: None,
            fmin: 150.0,
            fmax: 4000.0,
            threshold: 0.1,
        }
    }
}

/// Pitch tracking via parabolic interpolation on STFT peaks.
///
/// Matches `librosa.piptrack(y=y, sr=sr, S=S, ...)`.
///
/// Returns (pitches, magnitudes) both of shape [n_fft/2+1, n_frames].
/// Non-peak bins contain 0.
pub fn piptrack(
    y: Option<&[f64]>,
    s: Option<&Matrix>,
    params: &PiptrackParams,
) -> (Matrix, Matrix) {
    let n_fft = params.n_fft;
    let sr = params.sr;

    // Get magnitude spectrogram
    let owned_s;
    let mag = match s {
        Some(s) => s,
        None => {
            let y = y.expect("piptrack: either y or S must be provided");
            let stft_params = StftParams {
                n_fft,
                hop_length: params.hop_length.unwrap_or(n_fft / 4),
                win_length: n_fft,
                center: true,
                window: None,
            };
            // spectrogram with power=1 gives magnitude
            owned_s = crate::stft::spectrogram(y, &stft_params, 1.0);
            &owned_s
        }
    };

    let n_bins = mag.rows();
    let n_frames = mag.cols();

    if n_frames == 0 || n_bins < 3 {
        return (
            Matrix::zeros(n_bins, n_frames),
            Matrix::zeros(n_bins, n_frames),
        );
    }

    // Compute FFT frequencies
    let fft_freqs = fft_frequencies(sr, n_fft);

    // Frequency mask
    let fmin = params.fmin.max(0.0);
    let fmax = params.fmax.min(sr / 2.0);

    // Compute parabolic shifts and gradient per column
    let mut pitches = Matrix::zeros(n_bins, n_frames);
    let mut magnitudes = Matrix::zeros(n_bins, n_frames);

    for t in 0..n_frames {
        // Per-frame maximum for threshold
        let mut frame_max = f64::NEG_INFINITY;
        for k in 0..n_bins {
            let v = mag.get(k, t);
            if v > frame_max {
                frame_max = v;
            }
        }
        let ref_value = params.threshold * frame_max;

        // For each bin, check if it's a local max above threshold in [fmin, fmax]
        for k in 1..n_bins - 1 {
            let freq = fft_freqs[k];
            if freq < fmin || freq >= fmax {
                continue;
            }

            let v = mag.get(k, t);
            let v_prev = mag.get(k - 1, t);
            let v_next = mag.get(k + 1, t);

            // Must be above threshold
            if v < ref_value {
                continue;
            }

            // Local maximum: v > v_prev and v >= v_next
            if !(v > v_prev && v >= v_next) {
                continue;
            }

            // Parabolic interpolation
            let a = v_prev + v_next - 2.0 * v;
            let b = (v_next - v_prev) / 2.0;
            let shift = if b.abs() < a.abs() { -b / a } else { 0.0 };

            // Gradient for amplitude correction
            let avg = (v_next - v_prev) / 2.0;
            let dskew = 0.5 * avg * shift;

            *pitches.get_mut(k, t) = (k as f64 + shift) * sr / n_fft as f64;
            *magnitudes.get_mut(k, t) = v + dskew;
        }
    }

    (pitches, magnitudes)
}