#![allow(dead_code)]
const CHROMA_BINS: usize = 12;
const A4_FREQ: f64 = 440.0;
const A4_MIDI: f64 = 69.0;
const PITCH_NAMES: [&str; 12] = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
#[derive(Debug, Clone)]
pub struct ChromaVector {
pub bins: [f64; CHROMA_BINS],
}
impl ChromaVector {
#[must_use]
pub fn zero() -> Self {
Self {
bins: [0.0; CHROMA_BINS],
}
}
#[must_use]
pub fn from_slice(data: &[f64]) -> Self {
assert_eq!(data.len(), CHROMA_BINS, "Chroma vector must have 12 bins");
let mut bins = [0.0; CHROMA_BINS];
bins.copy_from_slice(data);
Self { bins }
}
#[must_use]
pub fn dominant_pitch_class(&self) -> usize {
self.bins
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map_or(0, |(i, _)| i)
}
#[must_use]
pub fn dominant_pitch_name(&self) -> &'static str {
PITCH_NAMES[self.dominant_pitch_class()]
}
#[must_use]
pub fn normalized(&self) -> Self {
let sum: f64 = self.bins.iter().sum();
if sum < 1e-12 {
return self.clone();
}
let mut bins = [0.0; CHROMA_BINS];
for (i, &v) in self.bins.iter().enumerate() {
bins[i] = v / sum;
}
Self { bins }
}
#[must_use]
pub fn cosine_similarity(&self, other: &Self) -> f64 {
let dot: f64 = self
.bins
.iter()
.zip(other.bins.iter())
.map(|(a, b)| a * b)
.sum();
let mag_a: f64 = self.bins.iter().map(|v| v * v).sum::<f64>().sqrt();
let mag_b: f64 = other.bins.iter().map(|v| v * v).sum::<f64>().sqrt();
if mag_a < 1e-12 || mag_b < 1e-12 {
return 0.0;
}
dot / (mag_a * mag_b)
}
#[must_use]
pub fn transposed(&self, semitones: i32) -> Self {
let mut bins = [0.0; CHROMA_BINS];
for i in 0..CHROMA_BINS {
let target = ((i as i32 + semitones).rem_euclid(CHROMA_BINS as i32)) as usize;
bins[target] = self.bins[i];
}
Self { bins }
}
}
#[derive(Debug, Clone)]
pub struct ChromagramConfig {
pub sample_rate: f32,
pub window_size: usize,
pub hop_size: usize,
pub tuning_ref: f64,
pub min_freq: f64,
pub max_freq: f64,
}
impl Default for ChromagramConfig {
fn default() -> Self {
Self {
sample_rate: 44100.0,
window_size: 4096,
hop_size: 512,
tuning_ref: A4_FREQ,
min_freq: 65.0, max_freq: 2093.0, }
}
}
#[derive(Debug)]
pub struct ChromagramAnalyzer {
config: ChromagramConfig,
}
impl ChromagramAnalyzer {
#[must_use]
pub fn new(config: ChromagramConfig) -> Self {
Self { config }
}
#[must_use]
pub fn with_sample_rate(sample_rate: f32) -> Self {
Self::new(ChromagramConfig {
sample_rate,
..ChromagramConfig::default()
})
}
#[must_use]
pub fn freq_to_chroma(&self, freq: f64) -> usize {
if freq <= 0.0 {
return 0;
}
let midi = 12.0 * (freq / self.config.tuning_ref).log2() + A4_MIDI;
let chroma = midi.round() as i64 % 12;
if chroma < 0 {
(chroma + 12) as usize
} else {
chroma as usize
}
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn compute(&self, samples: &[f32]) -> Vec<ChromaVector> {
let win = self.config.window_size.max(1);
let hop = self.config.hop_size.max(1);
let sr = f64::from(self.config.sample_rate);
if samples.len() < win {
return vec![self.compute_frame(samples, sr)];
}
let n_frames = (samples.len() - win) / hop + 1;
let mut chromagram = Vec::with_capacity(n_frames);
for i in 0..n_frames {
let start = i * hop;
let end = start + win;
let frame = &samples[start..end];
chromagram.push(self.compute_frame(frame, sr));
}
chromagram
}
#[allow(clippy::cast_precision_loss)]
fn compute_frame(&self, frame: &[f32], sr: f64) -> ChromaVector {
let n = frame.len();
if n == 0 {
return ChromaVector::zero();
}
let n_bins = n / 2 + 1;
let mut chroma = [0.0f64; CHROMA_BINS];
for k in 1..n_bins {
let freq = k as f64 * sr / n as f64;
if freq < self.config.min_freq || freq > self.config.max_freq {
continue;
}
let omega = 2.0 * std::f64::consts::PI * k as f64 / n as f64;
let coeff = 2.0 * omega.cos();
let mut s_prev2 = 0.0f64;
let mut s_prev = 0.0f64;
for sample in frame {
let s_cur = f64::from(*sample) + coeff * s_prev - s_prev2;
s_prev2 = s_prev;
s_prev = s_cur;
}
let magnitude = (s_prev * s_prev + s_prev2 * s_prev2 - coeff * s_prev * s_prev2)
.abs()
.sqrt();
let pitch_class = self.freq_to_chroma(freq);
chroma[pitch_class] += magnitude;
}
ChromaVector { bins: chroma }
}
#[must_use]
pub fn mean_chroma(&self, samples: &[f32]) -> ChromaVector {
let chromagram = self.compute(samples);
if chromagram.is_empty() {
return ChromaVector::zero();
}
let mut sum = [0.0f64; CHROMA_BINS];
for cv in &chromagram {
for (i, &v) in cv.bins.iter().enumerate() {
sum[i] += v;
}
}
#[allow(clippy::cast_precision_loss)]
let n = chromagram.len() as f64;
for v in &mut sum {
*v /= n;
}
ChromaVector { bins: sum }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chroma_vector_zero() {
let cv = ChromaVector::zero();
for &v in &cv.bins {
assert!((v - 0.0).abs() < f64::EPSILON);
}
}
#[test]
fn test_chroma_vector_from_slice() {
let data: Vec<f64> = (0..12).map(|i| i as f64).collect();
let cv = ChromaVector::from_slice(&data);
assert_eq!(cv.bins[0], 0.0);
assert_eq!(cv.bins[11], 11.0);
}
#[test]
#[should_panic(expected = "Chroma vector must have 12 bins")]
fn test_chroma_vector_from_slice_wrong_size() {
let _ = ChromaVector::from_slice(&[1.0, 2.0]);
}
#[test]
fn test_dominant_pitch_class() {
let mut cv = ChromaVector::zero();
cv.bins[9] = 10.0; assert_eq!(cv.dominant_pitch_class(), 9);
assert_eq!(cv.dominant_pitch_name(), "A");
}
#[test]
fn test_normalized() {
let mut cv = ChromaVector::zero();
cv.bins[0] = 4.0;
cv.bins[1] = 6.0;
let norm = cv.normalized();
let sum: f64 = norm.bins.iter().sum();
assert!((sum - 1.0).abs() < 1e-9);
}
#[test]
fn test_normalized_zero() {
let cv = ChromaVector::zero();
let norm = cv.normalized();
let sum: f64 = norm.bins.iter().sum();
assert!((sum - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_cosine_similarity_identical() {
let mut cv = ChromaVector::zero();
cv.bins[0] = 1.0;
cv.bins[4] = 1.0;
let sim = cv.cosine_similarity(&cv);
assert!((sim - 1.0).abs() < 1e-9);
}
#[test]
fn test_cosine_similarity_orthogonal() {
let mut a = ChromaVector::zero();
a.bins[0] = 1.0;
let mut b = ChromaVector::zero();
b.bins[6] = 1.0;
let sim = a.cosine_similarity(&b);
assert!(sim.abs() < 1e-9);
}
#[test]
fn test_transposed() {
let mut cv = ChromaVector::zero();
cv.bins[0] = 1.0; let transposed = cv.transposed(4); assert!((transposed.bins[4] - 1.0).abs() < f64::EPSILON);
assert!((transposed.bins[0] - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_transposed_negative() {
let mut cv = ChromaVector::zero();
cv.bins[2] = 1.0; let transposed = cv.transposed(-3); assert!((transposed.bins[11] - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_freq_to_chroma_a440() {
let analyzer = ChromagramAnalyzer::with_sample_rate(44100.0);
let chroma = analyzer.freq_to_chroma(440.0);
assert_eq!(chroma, 9); }
#[test]
fn test_freq_to_chroma_c261() {
let analyzer = ChromagramAnalyzer::with_sample_rate(44100.0);
let chroma = analyzer.freq_to_chroma(261.63); assert_eq!(chroma, 0); }
#[test]
fn test_compute_silence() {
let analyzer = ChromagramAnalyzer::with_sample_rate(44100.0);
let silence = vec![0.0f32; 44100];
let chromagram = analyzer.compute(&silence);
assert!(!chromagram.is_empty());
for cv in &chromagram {
for &v in &cv.bins {
assert!(v.abs() < 1e-6);
}
}
}
#[test]
fn test_compute_short_signal() {
let analyzer = ChromagramAnalyzer::with_sample_rate(8000.0);
let signal = vec![0.5f32; 100];
let chromagram = analyzer.compute(&signal);
assert!(!chromagram.is_empty());
}
#[test]
fn test_mean_chroma() {
let analyzer = ChromagramAnalyzer::with_sample_rate(44100.0);
let silence = vec![0.0f32; 44100];
let mean = analyzer.mean_chroma(&silence);
for &v in &mean.bins {
assert!(v.abs() < 1e-6);
}
}
#[test]
fn test_config_default() {
let cfg = ChromagramConfig::default();
assert!((cfg.sample_rate - 44100.0).abs() < f32::EPSILON);
assert_eq!(cfg.window_size, 4096);
assert_eq!(cfg.hop_size, 512);
}
#[test]
fn test_chromagram_a4_440hz_pitch_class_a() {
let sample_rate = 44100_u32;
let freq = 440.0_f32; let samples: Vec<f32> = (0..sample_rate)
.map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate as f32).sin())
.collect();
let analyzer = ChromagramAnalyzer::with_sample_rate(sample_rate as f32);
let mean = analyzer.mean_chroma(&samples);
let dominant = mean.dominant_pitch_class();
assert_eq!(
dominant, 9,
"440 Hz sine wave should produce dominant pitch class A (9), got {dominant}"
);
}
}