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
9/// A set of key-finding weights for the Krumhansl-Schmuckler algorithm.
10///
11/// These are the profiles music21's `analysis.discrete` ships, with the
12/// characterisations Craig Sapp gives them in the Humdrum `keycor` manual.
13#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub enum KeyProfile {
16    /// Krumhansl and Kessler's 1982 probe-tone ratings. Strong tendency to
17    /// name the dominant as the tonic.
18    KrumhanslSchmuckler,
19    /// Aarden's 2003 profile from the Essen folksong collection, music21's
20    /// default. Weak tendency to name the subdominant as the tonic.
21    AardenEssen,
22    /// Sapp's simple weights, most consistent over long stretches of music.
23    SimpleWeights,
24    /// Bellman and Budge's profile, with no particular neighbouring-key bias.
25    BellmanBudge,
26    /// Temperley's Kostka-Payne corpus profile. Strong tendency to name the
27    /// relative major in minor keys.
28    TemperleyKostkaPayne,
29}
30
31impl KeyProfile {
32    /// Every profile, in music21's order.
33    pub const ALL: [KeyProfile; 5] = [
34        Self::KrumhanslSchmuckler,
35        Self::AardenEssen,
36        Self::SimpleWeights,
37        Self::BellmanBudge,
38        Self::TemperleyKostkaPayne,
39    ];
40
41    /// The weights for the twelve pitch classes above a major tonic.
42    pub fn major_weights(self) -> [FloatType; 12] {
43        match self {
44            Self::KrumhanslSchmuckler => [
45                6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88,
46            ],
47            Self::AardenEssen => [
48                17.7661, 0.145624, 14.9265, 0.160186, 19.8049, 11.3587, 0.291248, 22.062, 0.145624,
49                8.15494, 0.232998, 4.95122,
50            ],
51            Self::SimpleWeights => [2.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 2.0, 0.0, 1.0, 0.0, 1.0],
52            Self::BellmanBudge => [
53                16.80, 0.86, 12.95, 1.41, 13.49, 11.93, 1.25, 20.28, 1.80, 8.04, 0.62, 10.57,
54            ],
55            Self::TemperleyKostkaPayne => [
56                0.748, 0.060, 0.488, 0.082, 0.670, 0.460, 0.096, 0.715, 0.104, 0.366, 0.057, 0.400,
57            ],
58        }
59    }
60
61    /// The weights for the twelve pitch classes above a minor tonic.
62    pub fn minor_weights(self) -> [FloatType; 12] {
63        match self {
64            Self::KrumhanslSchmuckler => [
65                6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17,
66            ],
67            Self::AardenEssen => [
68                18.2648, 0.737619, 14.0499, 16.8599, 0.702494, 14.4362, 0.702494, 18.6161, 4.56621,
69                1.93186, 7.37619, 1.75623,
70            ],
71            Self::SimpleWeights => [2.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 2.0, 1.0, 0.0, 0.5, 0.5],
72            Self::BellmanBudge => [
73                18.16, 0.69, 12.99, 13.34, 1.07, 11.15, 1.38, 21.07, 7.49, 1.53, 0.92, 10.21,
74            ],
75            Self::TemperleyKostkaPayne => [
76                0.712, 0.084, 0.474, 0.618, 0.049, 0.460, 0.105, 0.747, 0.404, 0.067, 0.133, 0.330,
77            ],
78        }
79    }
80
81    /// The name of the music21 class carrying these weights.
82    pub fn music21_class_name(self) -> &'static str {
83        match self {
84            Self::KrumhanslSchmuckler => "KrumhanslSchmuckler",
85            Self::AardenEssen => "AardenEssen",
86            Self::SimpleWeights => "SimpleWeights",
87            Self::BellmanBudge => "BellmanBudge",
88            Self::TemperleyKostkaPayne => "TemperleyKostkaPayne",
89        }
90    }
91}
92
93const TONICS: [&str; 12] = [
94    "C", "C#", "D", "E-", "E", "F", "F#", "G", "A-", "A", "B-", "B",
95];
96
97/// A ranked key estimate.
98#[derive(Clone, Debug)]
99#[must_use]
100pub struct KeyEstimate {
101    key: Key,
102    score: FloatType,
103}
104
105impl KeyEstimate {
106    /// Returns the estimated key.
107    pub fn key(&self) -> &Key {
108        &self.key
109    }
110
111    /// Returns the correlation score. Higher is a better fit.
112    pub fn score(&self) -> FloatType {
113        self.score
114    }
115}
116
117/// Estimates likely keys from pitches using the Krumhansl-Schmuckler weights.
118pub fn estimate_key_from_pitches(pitches: &[Pitch]) -> Result<Vec<KeyEstimate>> {
119    estimate_key_from_pitches_with(KeyProfile::KrumhanslSchmuckler, pitches)
120}
121
122/// Estimates likely keys from pitches using the given weights.
123pub fn estimate_key_from_pitches_with(
124    profile: KeyProfile,
125    pitches: &[Pitch],
126) -> Result<Vec<KeyEstimate>> {
127    if pitches.is_empty() {
128        return Err(Error::Analysis(
129            "key estimation needs at least one pitch".to_string(),
130        ));
131    }
132
133    let mut histogram = [0.0; 12];
134    for pitch in pitches {
135        let pc = (pitch.ps().round() as IntegerType).rem_euclid(12) as usize;
136        histogram[pc] += 1.0;
137    }
138
139    estimate_key_from_histogram(profile, &histogram)
140}
141
142/// Estimates likely keys from chords using the Krumhansl-Schmuckler weights.
143pub fn estimate_key_from_chords(chords: &[Chord]) -> Result<Vec<KeyEstimate>> {
144    estimate_key_from_chords_with(KeyProfile::KrumhanslSchmuckler, chords)
145}
146
147/// Estimates likely keys from chords using the given weights.
148pub fn estimate_key_from_chords_with(
149    profile: KeyProfile,
150    chords: &[Chord],
151) -> Result<Vec<KeyEstimate>> {
152    let pitches = chords.iter().flat_map(Chord::pitches).collect::<Vec<_>>();
153    estimate_key_from_pitches_with(profile, &pitches)
154}
155
156fn estimate_key_from_histogram(
157    profile: KeyProfile,
158    histogram: &[FloatType; 12],
159) -> Result<Vec<KeyEstimate>> {
160    let mut estimates = Vec::new();
161    for (tonic_pc, tonic) in TONICS.iter().enumerate() {
162        for (mode, weights) in [
163            ("major", profile.major_weights()),
164            ("minor", profile.minor_weights()),
165        ] {
166            let key = Key::from_tonic_mode(tonic, mode)?;
167            let rotated = rotate_profile(&weights, tonic_pc);
168            estimates.push(KeyEstimate {
169                key,
170                score: correlation(histogram, &rotated),
171            });
172        }
173    }
174
175    estimates.sort_by(|left, right| {
176        right
177            .score
178            .partial_cmp(&left.score)
179            .unwrap_or(std::cmp::Ordering::Equal)
180    });
181    Ok(estimates)
182}
183
184fn rotate_profile(profile: &[FloatType; 12], tonic_pc: usize) -> [FloatType; 12] {
185    let mut rotated = [0.0; 12];
186    for pc in 0..12 {
187        rotated[pc] = profile[(pc + 12 - tonic_pc) % 12];
188    }
189    rotated
190}
191
192fn correlation(left: &[FloatType; 12], right: &[FloatType; 12]) -> FloatType {
193    let left_mean = left.iter().sum::<FloatType>() / 12.0;
194    let right_mean = right.iter().sum::<FloatType>() / 12.0;
195    let mut numerator = 0.0;
196    let mut left_sum = 0.0;
197    let mut right_sum = 0.0;
198
199    for (left_value, right_value) in left.iter().zip(right) {
200        let left_centered = left_value - left_mean;
201        let right_centered = right_value - right_mean;
202        numerator += left_centered * right_centered;
203        left_sum += left_centered.powi(2);
204        right_sum += right_centered.powi(2);
205    }
206
207    let denominator = left_sum.sqrt() * right_sum.sqrt();
208    if denominator == 0.0 {
209        0.0
210    } else {
211        numerator / denominator
212    }
213}
214
215/// How decisively the first of a ranked list of key estimates wins: music21's
216/// `Key.tonalCertainty` for a key that came out of analysis. It is the
217/// leader's score plus twice its lead over the next positive score; with no
218/// positive runner-up it is the leader's score, floored at zero.
219pub fn tonal_certainty(estimates: &[KeyEstimate]) -> FloatType {
220    let scores: Vec<FloatType> = estimates.iter().map(KeyEstimate::score).collect();
221    tonal_certainty_from_scores(&scores)
222}
223
224/// The same measure over the scores alone, for a ranking that came from
225/// somewhere else — music21 hands its own analysis results around as keys
226/// carrying a `correlationCoefficient` rather than as estimates.
227pub fn tonal_certainty_from_scores(scores: &[FloatType]) -> FloatType {
228    let Some(leader) = scores.first().copied() else {
229        return 0.0;
230    };
231    match scores[1..].iter().copied().find(|score| *score > 0.0) {
232        Some(second) => leader + 2.0 * (leader - second),
233        None => leader.max(0.0),
234    }
235}
236
237#[cfg(test)]
238mod tests {
239
240    #[test]
241    fn a_profile_names_its_music21_class() {
242        assert_eq!(KeyProfile::AardenEssen.music21_class_name(), "AardenEssen");
243        assert_eq!(
244            KeyProfile::KrumhanslSchmuckler.music21_class_name(),
245            "KrumhanslSchmuckler"
246        );
247    }
248
249    #[test]
250    fn tonal_certainty_rewards_a_clear_leader() {
251        let scale: Vec<Pitch> = ["C4", "D4", "E4", "F4", "G4", "A4", "B4", "C5"]
252            .iter()
253            .map(|name| Pitch::from_name(*name).unwrap())
254            .collect();
255        let ranked = estimate_key_from_pitches(&scale).unwrap();
256        let leader = ranked[0].score();
257        let second = ranked[1].score();
258        assert!(second > 0.0);
259        assert!((tonal_certainty(&ranked) - (leader + 2.0 * (leader - second))).abs() < 1e-12);
260        assert_eq!(tonal_certainty(&ranked[..1]), leader.max(0.0));
261        assert_eq!(tonal_certainty(&[]), 0.0);
262    }
263    use super::*;
264
265    #[test]
266    fn estimates_c_major_from_tonic_triad_material() {
267        let pitches = ["C4", "E4", "G4", "C5", "E5", "G5"]
268            .into_iter()
269            .map(Pitch::from_name)
270            .collect::<Result<Vec<_>>>()
271            .unwrap();
272        let estimates = estimate_key_from_pitches(&pitches).unwrap();
273        assert_eq!(estimates[0].key().tonic().name(), "C");
274        assert_eq!(estimates[0].key().mode(), "major");
275    }
276
277    #[test]
278    fn estimates_from_chords() {
279        let chords = [Chord::new("C E G").unwrap(), Chord::new("F A C").unwrap()];
280        let estimates = estimate_key_from_chords(&chords).unwrap();
281        assert!(!estimates.is_empty());
282    }
283
284    #[test]
285    fn every_profile_agrees_on_unambiguous_material() {
286        let pitches = ["C4", "D4", "E4", "F4", "G4", "A4", "B4", "C5", "G4", "C4"]
287            .into_iter()
288            .map(Pitch::from_name)
289            .collect::<Result<Vec<_>>>()
290            .unwrap();
291        for profile in KeyProfile::ALL {
292            let estimates = estimate_key_from_pitches_with(profile, &pitches).unwrap();
293            assert_eq!(estimates[0].key().tonic().name(), "C", "{profile:?}");
294            assert_eq!(estimates[0].key().mode(), "major", "{profile:?}");
295        }
296    }
297}