Skip to main content

resonant_analysis/
key.rs

1//! Key detection via Krumhansl-Schmuckler key profiles.
2//!
3//! Classifies a sequence of chroma frames as one of 24 musical keys
4//! (12 tonics × major/minor) using Pearson correlation against the
5//! Krumhansl-Schmuckler (1990) key profiles.
6//!
7//! Reference: Krumhansl, C. L. (1990). *Cognitive Foundations of Musical Pitch*.
8//! Oxford University Press.
9
10use crate::chroma::ChromaVector;
11
12/// One of the 12 chromatic pitch classes, from C upward.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub enum PitchClass {
16    /// C
17    C,
18    /// C# / D♭
19    Cs,
20    /// D
21    D,
22    /// D# / E♭
23    Ds,
24    /// E
25    E,
26    /// F
27    F,
28    /// F# / G♭
29    Fs,
30    /// G
31    G,
32    /// G# / A♭
33    Gs,
34    /// A
35    A,
36    /// A# / B♭
37    As,
38    /// B
39    B,
40}
41
42impl PitchClass {
43    /// Short name used in standard notation.
44    #[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/// Musical mode.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
83pub enum Mode {
84    /// Major scale.
85    Major,
86    /// Natural minor scale.
87    Minor,
88}
89
90/// Result of a key classification.
91#[derive(Debug, Clone)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
93pub struct KeyEstimate {
94    /// Detected tonic pitch class.
95    pub tonic: PitchClass,
96    /// Detected mode.
97    pub mode: Mode,
98    /// Pearson correlation with the best-matching profile (0..1).
99    /// Values below ~0.3 indicate low confidence (near-atonal input).
100    pub confidence: f32,
101}
102
103/// Key detector using Krumhansl-Schmuckler profiles.
104///
105/// # Examples
106///
107/// ```
108/// use resonant_analysis::key::KeyDetector;
109/// use resonant_analysis::chroma::ChromaVector;
110///
111/// // All-A chroma (strong A, C#, E — A major triad)
112/// let mut cv = ChromaVector { bins: [0.0; 12] };
113/// cv.bins[9]  = 1.0; // A
114/// cv.bins[1]  = 0.8; // C#
115/// cv.bins[4]  = 0.6; // E
116///
117/// let result = KeyDetector::new().detect(&[cv]);
118/// assert!(result.is_some());
119/// ```
120pub struct KeyDetector;
121
122impl KeyDetector {
123    /// Creates a new detector.
124    #[must_use]
125    pub fn new() -> Self {
126        KeyDetector
127    }
128
129    /// Classifies a sequence of chroma frames and returns the best-matching key.
130    ///
131    /// Returns `None` if `chroma_frames` is empty.
132    #[must_use]
133    pub fn detect(&self, chroma_frames: &[ChromaVector]) -> Option<KeyEstimate> {
134        if chroma_frames.is_empty() {
135            return None;
136        }
137
138        // Average frames to a single 12-bin profile.
139        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        // Test all 24 key templates, track the best Pearson correlation.
151        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
185//
186// Each array is indexed from C (0) to B (11) and represents the expected
187// tonal salience of each pitch class relative to a C-rooted key.
188// These values are from Table 4.1 of Krumhansl (1990).
189
190const 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
198/// Rotates `profile` right by `n` positions so that `profile[0]` (root
199/// salience) lands at chroma bin `n` (the tonic).
200fn 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
208/// Pearson correlation coefficient between two 12-element arrays.
209///
210/// Returns a value in [−1, 1]; returns 0.0 if either array has zero variance.
211fn 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    // Build a chroma where the notes of a major triad are strong.
244    fn major_triad_chroma(root: usize) -> ChromaVector {
245        let mut bins = [0.0_f32; 12];
246        bins[root % 12] = 1.0; // root
247        bins[(root + 4) % 12] = 0.8; // major third
248        bins[(root + 7) % 12] = 0.6; // perfect fifth
249        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; // root
255        bins[(root + 3) % 12] = 0.8; // minor third
256        bins[(root + 7) % 12] = 0.6; // perfect fifth
257        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        // C=0, E=4, G=7
268        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        // A=9, C#=1, E=4
277        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; // A
287        bins[1] = 0.8; // C#
288        bins[4] = 0.6; // E
289        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        // C=0, Eb=3, G=7
300        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        // All equal bins → no tonal centre
309        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        // Two frames both suggesting A major
321        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        // Right-rotate by 1: profile[0]=1.0 moves to out[1], profile[11]=12.0 moves to out[0].
338        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); // root salience lands at tonic bin 1
343        assert_eq!(rotated[0], 12.0); // B salience wraps to position 0
344    }
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}