1use crate::{
2 chord::Chord,
3 defaults::{FloatType, IntegerType},
4 error::{Error, Result},
5 key::Key,
6 pitch::Pitch,
7};
8
9#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub enum KeyProfile {
16 KrumhanslSchmuckler,
19 AardenEssen,
22 SimpleWeights,
24 BellmanBudge,
26 TemperleyKostkaPayne,
29}
30
31impl KeyProfile {
32 pub const ALL: [KeyProfile; 5] = [
34 Self::KrumhanslSchmuckler,
35 Self::AardenEssen,
36 Self::SimpleWeights,
37 Self::BellmanBudge,
38 Self::TemperleyKostkaPayne,
39 ];
40
41 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 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 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#[derive(Clone, Debug)]
99#[must_use]
100pub struct KeyEstimate {
101 key: Key,
102 score: FloatType,
103}
104
105impl KeyEstimate {
106 pub fn key(&self) -> &Key {
108 &self.key
109 }
110
111 pub fn score(&self) -> FloatType {
113 self.score
114 }
115}
116
117pub fn estimate_key_from_pitches(pitches: &[Pitch]) -> Result<Vec<KeyEstimate>> {
119 estimate_key_from_pitches_with(KeyProfile::KrumhanslSchmuckler, pitches)
120}
121
122pub 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
142pub fn estimate_key_from_chords(chords: &[Chord]) -> Result<Vec<KeyEstimate>> {
144 estimate_key_from_chords_with(KeyProfile::KrumhanslSchmuckler, chords)
145}
146
147pub 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
215pub 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
224pub 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}