1use crate::chroma::ChromaVector;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub enum PitchClass {
16 C,
18 Cs,
20 D,
22 Ds,
24 E,
26 F,
28 Fs,
30 G,
32 Gs,
34 A,
36 As,
38 B,
40}
41
42impl PitchClass {
43 #[must_use]
45 pub fn name(self) -> &'static str {
46 match self {
47 PitchClass::C => "C",
48 PitchClass::Cs => "C#",
49 PitchClass::D => "D",
50 PitchClass::Ds => "D#",
51 PitchClass::E => "E",
52 PitchClass::F => "F",
53 PitchClass::Fs => "F#",
54 PitchClass::G => "G",
55 PitchClass::Gs => "G#",
56 PitchClass::A => "A",
57 PitchClass::As => "A#",
58 PitchClass::B => "B",
59 }
60 }
61
62 fn from_index(i: usize) -> Self {
63 match i % 12 {
64 0 => PitchClass::C,
65 1 => PitchClass::Cs,
66 2 => PitchClass::D,
67 3 => PitchClass::Ds,
68 4 => PitchClass::E,
69 5 => PitchClass::F,
70 6 => PitchClass::Fs,
71 7 => PitchClass::G,
72 8 => PitchClass::Gs,
73 9 => PitchClass::A,
74 10 => PitchClass::As,
75 _ => PitchClass::B,
76 }
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
83pub enum Mode {
84 Major,
86 Minor,
88}
89
90#[derive(Debug, Clone)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
93pub struct KeyEstimate {
94 pub tonic: PitchClass,
96 pub mode: Mode,
98 pub confidence: f32,
101}
102
103pub struct KeyDetector;
121
122impl KeyDetector {
123 #[must_use]
125 pub fn new() -> Self {
126 KeyDetector
127 }
128
129 #[must_use]
133 pub fn detect(&self, chroma_frames: &[ChromaVector]) -> Option<KeyEstimate> {
134 if chroma_frames.is_empty() {
135 return None;
136 }
137
138 let mut profile = [0.0_f32; 12];
140 for frame in chroma_frames {
141 for (i, &v) in frame.bins.iter().enumerate() {
142 profile[i] += v;
143 }
144 }
145 let n = chroma_frames.len() as f32;
146 for v in &mut profile {
147 *v /= n;
148 }
149
150 let mut best_r = f32::NEG_INFINITY;
152 let mut best_tonic = 0usize;
153 let mut best_mode = Mode::Major;
154
155 for tonic in 0..12 {
156 let r_major = pearson(&profile, &rotate(KS_MAJOR, tonic));
157 let r_minor = pearson(&profile, &rotate(KS_MINOR, tonic));
158
159 if r_major > best_r {
160 best_r = r_major;
161 best_tonic = tonic;
162 best_mode = Mode::Major;
163 }
164 if r_minor > best_r {
165 best_r = r_minor;
166 best_tonic = tonic;
167 best_mode = Mode::Minor;
168 }
169 }
170
171 Some(KeyEstimate {
172 tonic: PitchClass::from_index(best_tonic),
173 mode: best_mode,
174 confidence: best_r.clamp(0.0, 1.0),
175 })
176 }
177}
178
179impl Default for KeyDetector {
180 fn default() -> Self {
181 Self::new()
182 }
183}
184
185const KS_MAJOR: [f32; 12] = [
191 6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88,
192];
193
194const KS_MINOR: [f32; 12] = [
195 6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17,
196];
197
198fn rotate(profile: [f32; 12], n: usize) -> [f32; 12] {
201 let mut out = [0.0_f32; 12];
202 for i in 0..12 {
203 out[(i + n) % 12] = profile[i];
204 }
205 out
206}
207
208fn pearson(x: &[f32; 12], y: &[f32; 12]) -> f32 {
212 let mean_x = x.iter().sum::<f32>() / 12.0;
213 let mean_y = y.iter().sum::<f32>() / 12.0;
214
215 let mut num = 0.0_f32;
216 let mut ss_x = 0.0_f32;
217 let mut ss_y = 0.0_f32;
218
219 for i in 0..12 {
220 let dx = x[i] - mean_x;
221 let dy = y[i] - mean_y;
222 num += dx * dy;
223 ss_x += dx * dx;
224 ss_y += dy * dy;
225 }
226
227 let denom = (ss_x * ss_y).sqrt();
228 if denom < f32::EPSILON {
229 0.0
230 } else {
231 num / denom
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 fn make_chroma(bins: [f32; 12]) -> ChromaVector {
240 ChromaVector { bins }
241 }
242
243 fn major_triad_chroma(root: usize) -> ChromaVector {
245 let mut bins = [0.0_f32; 12];
246 bins[root % 12] = 1.0; bins[(root + 4) % 12] = 0.8; bins[(root + 7) % 12] = 0.6; ChromaVector { bins }
250 }
251
252 fn minor_triad_chroma(root: usize) -> ChromaVector {
253 let mut bins = [0.0_f32; 12];
254 bins[root % 12] = 1.0; bins[(root + 3) % 12] = 0.8; bins[(root + 7) % 12] = 0.6; ChromaVector { bins }
258 }
259
260 #[test]
261 fn empty_input_returns_none() {
262 assert!(KeyDetector::new().detect(&[]).is_none());
263 }
264
265 #[test]
266 fn c_major_triad_detects_c_major() {
267 let cv = major_triad_chroma(0);
269 let est = KeyDetector::new().detect(&[cv]).unwrap();
270 assert_eq!(est.tonic, PitchClass::C);
271 assert_eq!(est.mode, Mode::Major);
272 }
273
274 #[test]
275 fn a_major_triad_detects_a_major() {
276 let cv = major_triad_chroma(9);
278 let est = KeyDetector::new().detect(&[cv]).unwrap();
279 assert_eq!(est.tonic, PitchClass::A);
280 assert_eq!(est.mode, Mode::Major);
281 }
282
283 #[test]
284 fn a_major_confidence_above_threshold() {
285 let mut bins = [0.0_f32; 12];
286 bins[9] = 1.0; bins[1] = 0.8; bins[4] = 0.6; let est = KeyDetector::new().detect(&[make_chroma(bins)]).unwrap();
290 assert!(
291 est.confidence > 0.8,
292 "A major confidence = {:.3}, expected > 0.8",
293 est.confidence
294 );
295 }
296
297 #[test]
298 fn c_minor_triad_detects_c_minor() {
299 let cv = minor_triad_chroma(0);
301 let est = KeyDetector::new().detect(&[cv]).unwrap();
302 assert_eq!(est.tonic, PitchClass::C);
303 assert_eq!(est.mode, Mode::Minor);
304 }
305
306 #[test]
307 fn atonal_chroma_low_confidence() {
308 let cv = make_chroma([1.0; 12]);
310 let est = KeyDetector::new().detect(&[cv]).unwrap();
311 assert!(
312 est.confidence < 0.3,
313 "flat chroma confidence = {:.3}, expected < 0.3",
314 est.confidence
315 );
316 }
317
318 #[test]
319 fn averages_multiple_frames() {
320 let cv = major_triad_chroma(9);
322 let est = KeyDetector::new().detect(&[cv, cv]).unwrap();
323 assert_eq!(est.tonic, PitchClass::A);
324 assert_eq!(est.mode, Mode::Major);
325 }
326
327 #[test]
328 fn pitch_class_names() {
329 assert_eq!(PitchClass::C.name(), "C");
330 assert_eq!(PitchClass::Cs.name(), "C#");
331 assert_eq!(PitchClass::Fs.name(), "F#");
332 assert_eq!(PitchClass::B.name(), "B");
333 }
334
335 #[test]
336 fn rotate_wraps_correctly() {
337 let profile = [
339 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
340 ];
341 let rotated = rotate(profile, 1);
342 assert_eq!(rotated[1], 1.0); assert_eq!(rotated[0], 12.0); }
345
346 #[test]
347 fn pearson_identical_arrays_is_one() {
348 let x = KS_MAJOR;
349 assert!((pearson(&x, &x) - 1.0).abs() < 1e-5);
350 }
351
352 #[test]
353 fn pearson_flat_array_is_zero() {
354 let flat = [1.0_f32; 12];
355 assert_eq!(pearson(&KS_MAJOR, &flat), 0.0);
356 }
357
358 #[cfg(feature = "serde")]
359 #[test]
360 fn key_estimate_serde_roundtrip() {
361 let k = KeyEstimate {
362 tonic: PitchClass::A,
363 mode: Mode::Major,
364 confidence: 0.92,
365 };
366 let json =
367 serde_json::to_string(&k).unwrap_or_else(|e| panic!("serialize KeyEstimate: {e}"));
368 let back: KeyEstimate =
369 serde_json::from_str(&json).unwrap_or_else(|e| panic!("deserialize KeyEstimate: {e}"));
370 assert_eq!(back.tonic, k.tonic);
371 assert_eq!(back.mode, k.mode);
372 assert!((back.confidence - k.confidence).abs() < 1e-6);
373 }
374
375 #[cfg(feature = "serde")]
376 #[test]
377 fn pitch_class_serde_roundtrip() {
378 for pc in [PitchClass::C, PitchClass::Fs, PitchClass::B] {
379 let json =
380 serde_json::to_string(&pc).unwrap_or_else(|e| panic!("serialize PitchClass: {e}"));
381 let back: PitchClass = serde_json::from_str(&json)
382 .unwrap_or_else(|e| panic!("deserialize PitchClass: {e}"));
383 assert_eq!(pc, back);
384 }
385 }
386
387 #[cfg(feature = "serde")]
388 #[test]
389 fn mode_serde_roundtrip() {
390 for mode in [Mode::Major, Mode::Minor] {
391 let json =
392 serde_json::to_string(&mode).unwrap_or_else(|e| panic!("serialize Mode: {e}"));
393 let back: Mode =
394 serde_json::from_str(&json).unwrap_or_else(|e| panic!("deserialize Mode: {e}"));
395 assert_eq!(mode, back);
396 }
397 }
398}