use std::f64::consts::PI;
#[derive(Debug, Clone)]
pub struct SpectralBin {
pub frequency_hz: f64,
pub magnitude: f64,
pub phase: f64,
}
pub fn fft_dft(samples: &[f64]) -> Vec<SpectralBin> {
let n = samples.len();
if n == 0 {
return Vec::new();
}
let num_bins = n / 2 + 1;
let mut bins = Vec::with_capacity(num_bins);
for k in 0..num_bins {
let mut re = 0.0_f64;
let mut im = 0.0_f64;
for (t, &x) in samples.iter().enumerate() {
let angle = -2.0 * PI * k as f64 * t as f64 / n as f64;
re += x * angle.cos();
im += x * angle.sin();
}
let magnitude = (re * re + im * im).sqrt();
let phase = im.atan2(re);
bins.push(SpectralBin {
frequency_hz: k as f64, magnitude,
phase,
});
}
bins
}
pub fn spectral_centroid(bins: &[SpectralBin]) -> f64 {
let total_mag: f64 = bins.iter().map(|b| b.magnitude).sum();
if total_mag == 0.0 {
return 0.0;
}
bins.iter().map(|b| b.frequency_hz * b.magnitude).sum::<f64>() / total_mag
}
pub fn spectral_rolloff(bins: &[SpectralBin], threshold: f64) -> f64 {
let total_energy: f64 = bins.iter().map(|b| b.magnitude * b.magnitude).sum();
if total_energy == 0.0 {
return 0.0;
}
let target = total_energy * threshold;
let mut cumulative = 0.0;
for bin in bins {
cumulative += bin.magnitude * bin.magnitude;
if cumulative >= target {
return bin.frequency_hz;
}
}
bins.last().map(|b| b.frequency_hz).unwrap_or(0.0)
}
pub fn spectral_flux(current: &[SpectralBin], previous: &[SpectralBin]) -> f64 {
let len = current.len().min(previous.len());
let mut flux = 0.0;
for i in 0..len {
let diff = current[i].magnitude - previous[i].magnitude;
if diff > 0.0 {
flux += diff;
}
}
flux
}
pub const KEY_PROFILES: [[f64; 12]; 24] = [
[6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88], [2.88, 6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29], [2.29, 2.88, 6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66], [3.66, 2.29, 2.88, 6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39], [2.39, 3.66, 2.29, 2.88, 6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19], [5.19, 2.39, 3.66, 2.29, 2.88, 6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52], [2.52, 5.19, 2.39, 3.66, 2.29, 2.88, 6.35, 2.23, 3.48, 2.33, 4.38, 4.09], [4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88, 6.35, 2.23, 3.48, 2.33, 4.38], [4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88, 6.35, 2.23, 3.48, 2.33], [2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88, 6.35, 2.23, 3.48], [3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88, 6.35, 2.23], [2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88, 6.35], [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17], [3.17, 6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34], [3.34, 3.17, 6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69], [2.69, 3.34, 3.17, 6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98], [3.98, 2.69, 3.34, 3.17, 6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75], [4.75, 3.98, 2.69, 3.34, 3.17, 6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54], [2.54, 4.75, 3.98, 2.69, 3.34, 3.17, 6.33, 2.68, 3.52, 5.38, 2.60, 3.53], [3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17, 6.33, 2.68, 3.52, 5.38, 2.60], [2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17, 6.33, 2.68, 3.52, 5.38], [5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17, 6.33, 2.68, 3.52], [3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17, 6.33, 2.68], [2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17, 6.33], ];
const NOTE_NAMES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
pub fn chroma_vector(bins: &[SpectralBin], sample_rate: f64) -> [f64; 12] {
let mut chroma = [0.0_f64; 12];
let n = (bins.len().saturating_sub(1)) * 2; if n == 0 || sample_rate <= 0.0 {
return chroma;
}
for bin in bins {
if bin.magnitude == 0.0 || bin.frequency_hz <= 0.0 {
continue;
}
let freq_hz = bin.frequency_hz * sample_rate / n as f64;
if freq_hz < 20.0 || freq_hz > 20000.0 {
continue;
}
let midi = 69.0 + 12.0 * (freq_hz / 440.0).log2();
let pitch_class = ((midi.round() as i32).rem_euclid(12)) as usize;
chroma[pitch_class] += bin.magnitude * bin.magnitude;
}
chroma
}
pub fn detect_key(chroma: &[f64; 12]) -> (String, bool) {
let chroma_mean = chroma.iter().sum::<f64>() / 12.0;
let chroma_std = {
let var = chroma.iter().map(|&v| (v - chroma_mean).powi(2)).sum::<f64>() / 12.0;
var.sqrt()
};
let mut best_corr = f64::NEG_INFINITY;
let mut best_idx = 0;
for (i, profile) in KEY_PROFILES.iter().enumerate() {
let profile_mean = profile.iter().sum::<f64>() / 12.0;
let profile_std = {
let var = profile.iter().map(|&v| (v - profile_mean).powi(2)).sum::<f64>() / 12.0;
var.sqrt()
};
let denom = chroma_std * profile_std * 12.0;
let corr = if denom < 1e-10 {
0.0
} else {
profile
.iter()
.zip(chroma.iter())
.map(|(&p, &c)| (p - profile_mean) * (c - chroma_mean))
.sum::<f64>()
/ denom
};
if corr > best_corr {
best_corr = corr;
best_idx = i;
}
}
let is_major = best_idx < 12;
let note_idx = best_idx % 12;
(NOTE_NAMES[note_idx].to_string(), is_major)
}
pub fn tempo_from_onsets(onset_times_ms: &[f64]) -> f64 {
if onset_times_ms.len() < 2 {
return 0.0;
}
let iois: Vec<f64> = onset_times_ms
.windows(2)
.map(|w| w[1] - w[0])
.filter(|&ioi| ioi > 0.0)
.collect();
if iois.is_empty() {
return 0.0;
}
let bpm_min = 60.0_f64;
let bpm_max = 180.0_f64;
let num_bins = 121_usize; let mut histogram = vec![0.0_f64; num_bins];
for &ioi in &iois {
let bpm = 60_000.0 / ioi;
if bpm >= bpm_min && bpm <= bpm_max {
let bin = ((bpm - bpm_min).round() as usize).min(num_bins - 1);
histogram[bin] += 1.0;
}
}
let (peak_bin, _) = histogram
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or((0, &0.0));
bpm_min + peak_bin as f64
}
#[derive(Debug, Clone)]
pub struct AudioAnalysis {
pub key: String,
pub is_major: bool,
pub tempo_bpm: f64,
pub spectral_centroid: f64,
pub spectral_rolloff: f64,
}
pub fn analyze(samples: &[f64], sample_rate: f64) -> AudioAnalysis {
let bins = fft_dft(samples);
let n = samples.len();
let scaled_bins: Vec<SpectralBin> = bins
.iter()
.map(|b| SpectralBin {
frequency_hz: b.frequency_hz * sample_rate / n as f64,
magnitude: b.magnitude,
phase: b.phase,
})
.collect();
let centroid = spectral_centroid(&scaled_bins);
let rolloff = spectral_rolloff(&scaled_bins, 0.85);
let chroma = chroma_vector(&bins, sample_rate);
let (key, is_major) = detect_key(&chroma);
AudioAnalysis {
key,
is_major,
tempo_bpm: 0.0, spectral_centroid: centroid,
spectral_rolloff: rolloff,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_sine(freq: f64, sample_rate: f64, num_samples: usize) -> Vec<f64> {
(0..num_samples)
.map(|i| (2.0 * PI * freq * i as f64 / sample_rate).sin())
.collect()
}
#[test]
fn dft_pure_tone_peak_at_correct_bin() {
let n = 64_usize;
let k_target = 4_usize;
let samples: Vec<f64> = (0..n)
.map(|t| (2.0 * PI * k_target as f64 * t as f64 / n as f64).cos())
.collect();
let bins = fft_dft(&samples);
let peak = bins
.iter()
.enumerate()
.max_by(|a, b| a.1.magnitude.partial_cmp(&b.1.magnitude).unwrap())
.map(|(i, _)| i)
.unwrap();
assert_eq!(peak, k_target, "Peak bin should be at k={}", k_target);
}
#[test]
fn spectral_centroid_between_min_max() {
let sample_rate = 44100.0;
let samples = make_sine(440.0, sample_rate, 512);
let bins = fft_dft(&samples);
if bins.is_empty() {
return;
}
let centroid = spectral_centroid(&bins);
let min_freq = bins.iter().map(|b| b.frequency_hz).fold(f64::INFINITY, f64::min);
let max_freq = bins.iter().map(|b| b.frequency_hz).fold(f64::NEG_INFINITY, f64::max);
assert!(centroid >= min_freq && centroid <= max_freq);
}
#[test]
fn chroma_vector_sums_positive() {
let sample_rate = 44100.0;
let samples = make_sine(440.0, sample_rate, 2048);
let bins = fft_dft(&samples);
let chroma = chroma_vector(&bins, sample_rate);
let sum: f64 = chroma.iter().sum();
assert!(sum > 0.0, "Chroma vector should have positive energy");
}
#[test]
fn detect_key_returns_valid_note_name() {
let chroma = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88];
let (key, is_major) = detect_key(&chroma);
assert!(NOTE_NAMES.contains(&key.as_str()), "Key '{}' should be a valid note name", key);
assert!(is_major, "C major profile should detect as major");
}
#[test]
fn tempo_from_onsets_120bpm() {
let onsets: Vec<f64> = (0..20).map(|i| i as f64 * 500.0).collect();
let bpm = tempo_from_onsets(&onsets);
assert!((bpm - 120.0).abs() < 2.0, "Expected ~120 BPM, got {}", bpm);
}
#[test]
fn spectral_rolloff_in_range() {
let n = 64_usize;
let samples: Vec<f64> = (0..n)
.map(|t| (2.0 * PI * 4.0 * t as f64 / n as f64).cos())
.collect();
let bins = fft_dft(&samples);
let rolloff = spectral_rolloff(&bins, 0.85);
let max_freq = bins.iter().map(|b| b.frequency_hz).fold(0.0_f64, f64::max);
assert!(rolloff <= max_freq + 0.001);
}
}