Skip to main content

music21_rs/
analysis.rs

1use crate::{
2    chord::Chord,
3    defaults::{FloatType, IntegerType},
4    error::{Error, Result},
5    key::Key,
6    pitch::Pitch,
7};
8
9const MAJOR_PROFILE: [FloatType; 12] = [
10    6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88,
11];
12const MINOR_PROFILE: [FloatType; 12] = [
13    6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17,
14];
15const TONICS: [&str; 12] = [
16    "C", "C#", "D", "E-", "E", "F", "F#", "G", "A-", "A", "B-", "B",
17];
18
19/// A ranked key estimate.
20#[derive(Clone, Debug)]
21pub struct KeyEstimate {
22    key: Key,
23    score: FloatType,
24}
25
26impl KeyEstimate {
27    /// Returns the estimated key.
28    pub fn key(&self) -> &Key {
29        &self.key
30    }
31
32    /// Returns the correlation score. Higher is a better fit.
33    pub fn score(&self) -> FloatType {
34        self.score
35    }
36}
37
38/// Estimates likely keys from pitches using Krumhansl-Schmuckler profiles.
39pub fn estimate_key_from_pitches(pitches: &[Pitch]) -> Result<Vec<KeyEstimate>> {
40    if pitches.is_empty() {
41        return Err(Error::Analysis(
42            "key estimation needs at least one pitch".to_string(),
43        ));
44    }
45
46    let mut histogram = [0.0; 12];
47    for pitch in pitches {
48        let pc = (pitch.ps().round() as IntegerType).rem_euclid(12) as usize;
49        histogram[pc] += 1.0;
50    }
51
52    estimate_key_from_histogram(&histogram)
53}
54
55/// Estimates likely keys from chords using Krumhansl-Schmuckler profiles.
56pub fn estimate_key_from_chords(chords: &[Chord]) -> Result<Vec<KeyEstimate>> {
57    let pitches = chords.iter().flat_map(Chord::pitches).collect::<Vec<_>>();
58    estimate_key_from_pitches(&pitches)
59}
60
61fn estimate_key_from_histogram(histogram: &[FloatType; 12]) -> Result<Vec<KeyEstimate>> {
62    let mut estimates = Vec::new();
63    for (tonic_pc, tonic) in TONICS.iter().enumerate() {
64        for (mode, profile) in [("major", MAJOR_PROFILE), ("minor", MINOR_PROFILE)] {
65            let key = Key::from_tonic_mode(tonic, mode)?;
66            let rotated = rotate_profile(&profile, tonic_pc);
67            estimates.push(KeyEstimate {
68                key,
69                score: correlation(histogram, &rotated),
70            });
71        }
72    }
73
74    estimates.sort_by(|left, right| {
75        right
76            .score
77            .partial_cmp(&left.score)
78            .unwrap_or(std::cmp::Ordering::Equal)
79    });
80    Ok(estimates)
81}
82
83fn rotate_profile(profile: &[FloatType; 12], tonic_pc: usize) -> [FloatType; 12] {
84    let mut rotated = [0.0; 12];
85    for pc in 0..12 {
86        rotated[pc] = profile[(pc + 12 - tonic_pc) % 12];
87    }
88    rotated
89}
90
91fn correlation(left: &[FloatType; 12], right: &[FloatType; 12]) -> FloatType {
92    let left_mean = left.iter().sum::<FloatType>() / 12.0;
93    let right_mean = right.iter().sum::<FloatType>() / 12.0;
94    let mut numerator = 0.0;
95    let mut left_sum = 0.0;
96    let mut right_sum = 0.0;
97
98    for (left_value, right_value) in left.iter().zip(right) {
99        let left_centered = left_value - left_mean;
100        let right_centered = right_value - right_mean;
101        numerator += left_centered * right_centered;
102        left_sum += left_centered.powi(2);
103        right_sum += right_centered.powi(2);
104    }
105
106    let denominator = left_sum.sqrt() * right_sum.sqrt();
107    if denominator == 0.0 {
108        0.0
109    } else {
110        numerator / denominator
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn estimates_c_major_from_tonic_triad_material() {
120        let pitches = ["C4", "E4", "G4", "C5", "E5", "G5"]
121            .into_iter()
122            .map(Pitch::from_name)
123            .collect::<Result<Vec<_>>>()
124            .unwrap();
125        let estimates = estimate_key_from_pitches(&pitches).unwrap();
126        assert_eq!(estimates[0].key().tonic().name(), "C");
127        assert_eq!(estimates[0].key().mode(), "major");
128    }
129
130    #[test]
131    fn estimates_from_chords() {
132        let chords = [Chord::new("C E G").unwrap(), Chord::new("F A C").unwrap()];
133        let estimates = estimate_key_from_chords(&chords).unwrap();
134        assert!(!estimates.is_empty());
135    }
136}