rosa 0.1.0

Audio feature extraction library matching librosa output
Documentation
//! Tuning estimation matching `librosa.pitch_tuning` and `librosa.estimate_tuning`.

use crate::convert::hz_to_octs;
use crate::matrix::Matrix;

use super::piptrack::{PiptrackParams, piptrack};

/// Estimate tuning deviation from a collection of detected pitches.
///
/// Matches `librosa.pitch_tuning(frequencies, resolution, bins_per_octave)`.
///
/// Returns tuning deviation in fractional bins, in the range [-0.5, 0.5).
pub fn pitch_tuning(frequencies: &[f64], resolution: f64, bins_per_octave: usize) -> f64 {
    // Filter out zero/negative frequencies
    let positive: Vec<f64> = frequencies.iter().copied().filter(|&f| f > 0.0).collect();

    if positive.is_empty() {
        return 0.0;
    }

    // Convert to fractional semitone residual
    let residuals: Vec<f64> = positive
        .iter()
        .map(|&freq| {
            let mut r = (bins_per_octave as f64 * hz_to_octs(freq)) % 1.0;
            // Handle negative modulo
            if r < 0.0 {
                r += 1.0;
            }
            // Map to [-0.5, 0.5)
            if r >= 0.5 {
                r -= 1.0;
            }
            r
        })
        .collect();

    // Build histogram over [-0.5, 0.5) with bin width = resolution
    let n_bins = (1.0 / resolution).ceil() as usize + 1;
    let mut counts = vec![0usize; n_bins];

    for &r in &residuals {
        // Map r in [-0.5, 0.5) to bin index
        let bin = ((r + 0.5) / resolution).floor() as usize;
        let bin = bin.min(n_bins - 1);
        counts[bin] += 1;
    }

    // Return the center of the highest bin
    let best_bin = counts
        .iter()
        .enumerate()
        .max_by_key(|&(_, c)| *c)
        .map(|(i, _)| i)
        .unwrap_or(0);

    -0.5 + (best_bin as f64) * resolution
}

/// Estimate tuning from audio or spectrogram.
///
/// Matches `librosa.estimate_tuning(y=y, sr=sr, S=S, ...)`.
///
/// Returns tuning deviation in fractional bins, in the range [-0.5, 0.5).
pub fn estimate_tuning(
    y: Option<&[f64]>,
    sr: f64,
    s: Option<&Matrix>,
    n_fft: usize,
    resolution: f64,
    bins_per_octave: usize,
) -> f64 {
    let params = PiptrackParams {
        sr,
        n_fft,
        ..Default::default()
    };

    let (pitches, mags) = piptrack(y, s, &params);

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

    // Collect all (pitch, magnitude) pairs where pitch > 0
    let mut voiced_pitches = Vec::new();
    let mut voiced_mags = Vec::new();

    for t in 0..n_frames {
        for k in 0..n_bins {
            let p = pitches.get(k, t);
            if p > 0.0 {
                voiced_pitches.push(p);
                voiced_mags.push(mags.get(k, t));
            }
        }
    }

    if voiced_pitches.is_empty() {
        return 0.0;
    }

    // Filter to above-median magnitude
    let mut sorted_mags = voiced_mags.clone();
    sorted_mags.sort_by(|a, b| a.total_cmp(b));
    let median = sorted_mags[sorted_mags.len() / 2];

    let high_conf: Vec<f64> = voiced_pitches
        .iter()
        .zip(voiced_mags.iter())
        .filter(|(_, m)| **m >= median)
        .map(|(p, _)| *p)
        .collect();

    pitch_tuning(&high_conf, resolution, bins_per_octave)
}