use crate::chroma::ChromaVector;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum PitchClass {
C,
Cs,
D,
Ds,
E,
F,
Fs,
G,
Gs,
A,
As,
B,
}
impl PitchClass {
#[must_use]
pub fn name(self) -> &'static str {
match self {
PitchClass::C => "C",
PitchClass::Cs => "C#",
PitchClass::D => "D",
PitchClass::Ds => "D#",
PitchClass::E => "E",
PitchClass::F => "F",
PitchClass::Fs => "F#",
PitchClass::G => "G",
PitchClass::Gs => "G#",
PitchClass::A => "A",
PitchClass::As => "A#",
PitchClass::B => "B",
}
}
fn from_index(i: usize) -> Self {
match i % 12 {
0 => PitchClass::C,
1 => PitchClass::Cs,
2 => PitchClass::D,
3 => PitchClass::Ds,
4 => PitchClass::E,
5 => PitchClass::F,
6 => PitchClass::Fs,
7 => PitchClass::G,
8 => PitchClass::Gs,
9 => PitchClass::A,
10 => PitchClass::As,
_ => PitchClass::B,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Mode {
Major,
Minor,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct KeyEstimate {
pub tonic: PitchClass,
pub mode: Mode,
pub confidence: f32,
}
pub struct KeyDetector;
impl KeyDetector {
#[must_use]
pub fn new() -> Self {
KeyDetector
}
#[must_use]
pub fn detect(&self, chroma_frames: &[ChromaVector]) -> Option<KeyEstimate> {
if chroma_frames.is_empty() {
return None;
}
let mut profile = [0.0_f32; 12];
for frame in chroma_frames {
for (i, &v) in frame.bins.iter().enumerate() {
profile[i] += v;
}
}
let n = chroma_frames.len() as f32;
for v in &mut profile {
*v /= n;
}
let mut best_r = f32::NEG_INFINITY;
let mut best_tonic = 0usize;
let mut best_mode = Mode::Major;
for tonic in 0..12 {
let r_major = pearson(&profile, &rotate(KS_MAJOR, tonic));
let r_minor = pearson(&profile, &rotate(KS_MINOR, tonic));
if r_major > best_r {
best_r = r_major;
best_tonic = tonic;
best_mode = Mode::Major;
}
if r_minor > best_r {
best_r = r_minor;
best_tonic = tonic;
best_mode = Mode::Minor;
}
}
Some(KeyEstimate {
tonic: PitchClass::from_index(best_tonic),
mode: best_mode,
confidence: best_r.clamp(0.0, 1.0),
})
}
}
impl Default for KeyDetector {
fn default() -> Self {
Self::new()
}
}
const KS_MAJOR: [f32; 12] = [
6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88,
];
const KS_MINOR: [f32; 12] = [
6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17,
];
fn rotate(profile: [f32; 12], n: usize) -> [f32; 12] {
let mut out = [0.0_f32; 12];
for i in 0..12 {
out[(i + n) % 12] = profile[i];
}
out
}
fn pearson(x: &[f32; 12], y: &[f32; 12]) -> f32 {
let mean_x = x.iter().sum::<f32>() / 12.0;
let mean_y = y.iter().sum::<f32>() / 12.0;
let mut num = 0.0_f32;
let mut ss_x = 0.0_f32;
let mut ss_y = 0.0_f32;
for i in 0..12 {
let dx = x[i] - mean_x;
let dy = y[i] - mean_y;
num += dx * dy;
ss_x += dx * dx;
ss_y += dy * dy;
}
let denom = (ss_x * ss_y).sqrt();
if denom < f32::EPSILON {
0.0
} else {
num / denom
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_chroma(bins: [f32; 12]) -> ChromaVector {
ChromaVector { bins }
}
fn major_triad_chroma(root: usize) -> ChromaVector {
let mut bins = [0.0_f32; 12];
bins[root % 12] = 1.0; bins[(root + 4) % 12] = 0.8; bins[(root + 7) % 12] = 0.6; ChromaVector { bins }
}
fn minor_triad_chroma(root: usize) -> ChromaVector {
let mut bins = [0.0_f32; 12];
bins[root % 12] = 1.0; bins[(root + 3) % 12] = 0.8; bins[(root + 7) % 12] = 0.6; ChromaVector { bins }
}
#[test]
fn empty_input_returns_none() {
assert!(KeyDetector::new().detect(&[]).is_none());
}
#[test]
fn c_major_triad_detects_c_major() {
let cv = major_triad_chroma(0);
let est = KeyDetector::new().detect(&[cv]).unwrap();
assert_eq!(est.tonic, PitchClass::C);
assert_eq!(est.mode, Mode::Major);
}
#[test]
fn a_major_triad_detects_a_major() {
let cv = major_triad_chroma(9);
let est = KeyDetector::new().detect(&[cv]).unwrap();
assert_eq!(est.tonic, PitchClass::A);
assert_eq!(est.mode, Mode::Major);
}
#[test]
fn a_major_confidence_above_threshold() {
let mut bins = [0.0_f32; 12];
bins[9] = 1.0; bins[1] = 0.8; bins[4] = 0.6; let est = KeyDetector::new().detect(&[make_chroma(bins)]).unwrap();
assert!(
est.confidence > 0.8,
"A major confidence = {:.3}, expected > 0.8",
est.confidence
);
}
#[test]
fn c_minor_triad_detects_c_minor() {
let cv = minor_triad_chroma(0);
let est = KeyDetector::new().detect(&[cv]).unwrap();
assert_eq!(est.tonic, PitchClass::C);
assert_eq!(est.mode, Mode::Minor);
}
#[test]
fn atonal_chroma_low_confidence() {
let cv = make_chroma([1.0; 12]);
let est = KeyDetector::new().detect(&[cv]).unwrap();
assert!(
est.confidence < 0.3,
"flat chroma confidence = {:.3}, expected < 0.3",
est.confidence
);
}
#[test]
fn averages_multiple_frames() {
let cv = major_triad_chroma(9);
let est = KeyDetector::new().detect(&[cv, cv]).unwrap();
assert_eq!(est.tonic, PitchClass::A);
assert_eq!(est.mode, Mode::Major);
}
#[test]
fn pitch_class_names() {
assert_eq!(PitchClass::C.name(), "C");
assert_eq!(PitchClass::Cs.name(), "C#");
assert_eq!(PitchClass::Fs.name(), "F#");
assert_eq!(PitchClass::B.name(), "B");
}
#[test]
fn rotate_wraps_correctly() {
let profile = [
1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
];
let rotated = rotate(profile, 1);
assert_eq!(rotated[1], 1.0); assert_eq!(rotated[0], 12.0); }
#[test]
fn pearson_identical_arrays_is_one() {
let x = KS_MAJOR;
assert!((pearson(&x, &x) - 1.0).abs() < 1e-5);
}
#[test]
fn pearson_flat_array_is_zero() {
let flat = [1.0_f32; 12];
assert_eq!(pearson(&KS_MAJOR, &flat), 0.0);
}
#[cfg(feature = "serde")]
#[test]
fn key_estimate_serde_roundtrip() {
let k = KeyEstimate {
tonic: PitchClass::A,
mode: Mode::Major,
confidence: 0.92,
};
let json =
serde_json::to_string(&k).unwrap_or_else(|e| panic!("serialize KeyEstimate: {e}"));
let back: KeyEstimate =
serde_json::from_str(&json).unwrap_or_else(|e| panic!("deserialize KeyEstimate: {e}"));
assert_eq!(back.tonic, k.tonic);
assert_eq!(back.mode, k.mode);
assert!((back.confidence - k.confidence).abs() < 1e-6);
}
#[cfg(feature = "serde")]
#[test]
fn pitch_class_serde_roundtrip() {
for pc in [PitchClass::C, PitchClass::Fs, PitchClass::B] {
let json =
serde_json::to_string(&pc).unwrap_or_else(|e| panic!("serialize PitchClass: {e}"));
let back: PitchClass = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("deserialize PitchClass: {e}"));
assert_eq!(pc, back);
}
}
#[cfg(feature = "serde")]
#[test]
fn mode_serde_roundtrip() {
for mode in [Mode::Major, Mode::Minor] {
let json =
serde_json::to_string(&mode).unwrap_or_else(|e| panic!("serialize Mode: {e}"));
let back: Mode =
serde_json::from_str(&json).unwrap_or_else(|e| panic!("deserialize Mode: {e}"));
assert_eq!(mode, back);
}
}
}