1use acorde_core::{ChordSymbol, KeySignature, NoteAddr, Score, detect_chord, roman_numeral};
4use serde::{Deserialize, Serialize};
5
6pub const ANALYSIS_SCHEMA_VERSION: u32 = 3;
8
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub struct ChordLabel {
12 pub address: NoteAddr,
13 pub chord: ChordSymbol,
14 #[serde(default, skip_serializing_if = "Option::is_none")]
15 pub roman_numeral: Option<String>,
16 pub confidence: u8,
17 pub rule_id: String,
18 pub evidence: Vec<NoteAddr>,
19}
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct AnalysisResult {
24 pub schema_version: u32,
25 pub chords: Vec<ChordLabel>,
26 pub intervals: Vec<IntervalObservation>,
27 #[serde(default)]
28 pub key_estimates: Vec<KeyEstimate>,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct KeyEstimate {
34 pub key: KeySignature,
35 pub covered_pitches: usize,
36 pub total_pitches: usize,
37 pub confidence: u8,
38 pub rule_id: String,
39 pub evidence: Vec<NoteAddr>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct IntervalObservation {
45 pub from: NoteAddr,
46 pub to: NoteAddr,
47 pub semitones: u8,
48 pub diatonic_steps: i8,
49 pub rule_id: String,
50 pub evidence: Vec<NoteAddr>,
51}
52
53pub fn analyze_chords(score: &Score) -> AnalysisResult {
55 let mut chords = Vec::new();
56 for (part_index, part) in score.parts.iter().enumerate() {
57 for (staff_index, staff) in part.staves.iter().enumerate() {
58 for (measure_index, measure) in staff.measures.iter().enumerate() {
59 let key = measure
60 .key_sig
61 .as_ref()
62 .unwrap_or(&score.settings.key_signature);
63 for (voice_index, voice) in measure.voices.iter().enumerate() {
64 let pitched: Vec<_> = voice
65 .iter()
66 .enumerate()
67 .filter(|(_, note)| !note.is_rest && !note.pitches.is_empty())
68 .collect();
69 let pitches: Vec<_> = pitched
70 .iter()
71 .flat_map(|(_, note)| note.pitches.iter().cloned())
72 .collect();
73 let Some(chord) = detect_chord(&pitches) else {
74 continue;
75 };
76 let evidence = pitched
77 .iter()
78 .map(|(note_index, _)| NoteAddr {
79 part: part_index,
80 staff: staff_index,
81 measure: measure_index,
82 voice: voice_index,
83 note: *note_index,
84 })
85 .collect();
86 chords.push(ChordLabel {
87 address: NoteAddr {
88 part: part_index,
89 staff: staff_index,
90 measure: measure_index,
91 voice: voice_index,
92 note: pitched[0].0,
93 },
94 roman_numeral: roman_numeral(&chord, key),
95 chord,
96 confidence: 100,
97 rule_id: "pitch-class-template".to_string(),
98 evidence,
99 });
100 }
101 }
102 }
103 }
104 let intervals = analyze_intervals(score);
105 let key_estimates = estimate_keys(score);
106 AnalysisResult {
107 schema_version: ANALYSIS_SCHEMA_VERSION,
108 chords,
109 intervals,
110 key_estimates,
111 }
112}
113
114pub fn estimate_keys(score: &Score) -> Vec<KeyEstimate> {
116 let mut pitches = Vec::new();
117 let mut evidence = Vec::new();
118 for (part_index, part) in score.parts.iter().enumerate() {
119 for (staff_index, staff) in part.staves.iter().enumerate() {
120 for (measure_index, measure) in staff.measures.iter().enumerate() {
121 for (voice_index, voice) in measure.voices.iter().enumerate() {
122 for (note_index, note) in voice.iter().enumerate() {
123 if note.is_rest {
124 continue;
125 }
126 pitches.extend(note.pitches.iter());
127 if !note.pitches.is_empty() {
128 evidence.push(NoteAddr {
129 part: part_index,
130 staff: staff_index,
131 measure: measure_index,
132 voice: voice_index,
133 note: note_index,
134 });
135 }
136 }
137 }
138 }
139 }
140 }
141 if pitches.is_empty() {
142 return Vec::new();
143 }
144 let total_pitches = pitches.len();
145 let mut candidates = Vec::with_capacity(30);
146 for fifths in -7..=7 {
147 for mode in ["major", "minor"] {
148 let key = KeySignature {
149 fifths,
150 mode: mode.to_string(),
151 };
152 let covered = pitches
153 .iter()
154 .filter(|pitch| key.contains_pitch(pitch))
155 .count();
156 candidates.push((key, covered));
157 }
158 }
159 candidates.sort_by(|(left_key, left_score), (right_key, right_score)| {
160 right_score
161 .cmp(left_score)
162 .then_with(|| left_key.fifths.abs().cmp(&right_key.fifths.abs()))
163 .then_with(|| left_key.fifths.cmp(&right_key.fifths))
164 .then_with(|| left_key.mode.cmp(&right_key.mode))
165 });
166 let best = candidates[0].1;
167 candidates
168 .into_iter()
169 .take_while(|(_, covered)| *covered == best)
170 .map(|(key, covered_pitches)| KeyEstimate {
171 key,
172 covered_pitches,
173 total_pitches,
174 confidence: ((covered_pitches * 100) / total_pitches) as u8,
175 rule_id: "diatonic-pitch-coverage".to_string(),
176 evidence: evidence.clone(),
177 })
178 .collect()
179}
180
181pub fn analyze_intervals(score: &Score) -> Vec<IntervalObservation> {
183 let mut observations = Vec::new();
184 for (part_index, part) in score.parts.iter().enumerate() {
185 for (staff_index, staff) in part.staves.iter().enumerate() {
186 for (measure_index, measure) in staff.measures.iter().enumerate() {
187 for (voice_index, voice) in measure.voices.iter().enumerate() {
188 let notes: Vec<_> = voice
189 .iter()
190 .enumerate()
191 .filter_map(|(note_index, note)| {
192 if note.is_rest {
193 None
194 } else {
195 note.pitches.first().map(|pitch| (note_index, pitch))
196 }
197 })
198 .collect();
199 for pair in notes.windows(2) {
200 let (from_index, from) = pair[0];
201 let (to_index, to) = pair[1];
202 let from_addr = NoteAddr {
203 part: part_index,
204 staff: staff_index,
205 measure: measure_index,
206 voice: voice_index,
207 note: from_index,
208 };
209 let to_addr = NoteAddr {
210 part: part_index,
211 staff: staff_index,
212 measure: measure_index,
213 voice: voice_index,
214 note: to_index,
215 };
216 observations.push(IntervalObservation {
217 from: from_addr.clone(),
218 to: to_addr.clone(),
219 semitones: (to.to_midi() - from.to_midi()).unsigned_abs() as u8,
220 diatonic_steps: diatonic_distance(from, to),
221 rule_id: "adjacent-melodic-interval".to_string(),
222 evidence: vec![from_addr, to_addr],
223 });
224 }
225 }
226 }
227 }
228 }
229 observations
230}
231
232fn diatonic_distance(from: &acorde_core::Pitch, to: &acorde_core::Pitch) -> i8 {
233 let step_index = |step: &acorde_core::Step| match step {
234 acorde_core::Step::C => 0i16,
235 acorde_core::Step::D => 1,
236 acorde_core::Step::E => 2,
237 acorde_core::Step::F => 3,
238 acorde_core::Step::G => 4,
239 acorde_core::Step::A => 5,
240 acorde_core::Step::B => 6,
241 };
242 (i16::from(to.octave) * 7 + step_index(&to.step)
243 - (i16::from(from.octave) * 7 + step_index(&from.step))) as i8
244}
245
246pub fn chord_name(chord: &ChordSymbol) -> String {
248 let suffix = match chord.kind.as_str() {
249 "major" => "",
250 "minor" => "m",
251 "dominant" => "7",
252 "major-seventh" => "maj7",
253 "minor-seventh" => "m7",
254 "diminished" => "dim",
255 "diminished-seventh" => "dim7",
256 "half-diminished" => "ΓΈ7",
257 "augmented" => "+",
258 _ => chord.kind.as_str(),
259 };
260 let bass = chord
261 .bass
262 .as_deref()
263 .map_or(String::new(), |bass| format!("/{bass}"));
264 format!("{}{suffix}{bass}", chord.root)
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use acorde_core::{Duration, Note, Pitch, Score, Step};
271
272 #[test]
273 fn labels_chord_with_note_addresses_and_roman_numeral() {
274 let mut score = Score::default();
275 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
276 voice.clear();
277 for step in [Step::C, Step::E, Step::G] {
278 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
279 }
280 let result = analyze_chords(&score);
281 assert_eq!(result.schema_version, ANALYSIS_SCHEMA_VERSION);
282 assert_eq!(result.chords.len(), 1);
283 assert_eq!(result.intervals.len(), 2);
284 assert!(!result.key_estimates.is_empty());
285 assert_eq!(result.chords[0].address.note, 0);
286 assert_eq!(result.chords[0].evidence.len(), 3);
287 assert_eq!(result.chords[0].roman_numeral.as_deref(), Some("I"));
288 assert_eq!(chord_name(&result.chords[0].chord), "C");
289 }
290
291 #[test]
292 fn interval_observation_preserves_direction_and_evidence() {
293 let mut score = Score::default();
294 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
295 voice.clear();
296 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
297 voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
298 let intervals = analyze_intervals(&score);
299 assert_eq!(intervals.len(), 1);
300 assert_eq!(intervals[0].semitones, 7);
301 assert_eq!(intervals[0].diatonic_steps, 4);
302 assert_eq!(intervals[0].evidence.len(), 2);
303 }
304
305 #[test]
306 fn does_not_invent_label_for_unknown_pitch_set() {
307 let mut score = Score::default();
308 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
309 voice.clear();
310 for step in [Step::C, Step::C] {
311 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
312 }
313 assert!(analyze_chords(&score).chords.is_empty());
314 }
315
316 #[test]
317 fn preserves_relative_major_minor_key_ambiguity() {
318 let mut score = Score::default();
319 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
320 voice.clear();
321 for step in [
322 Step::C,
323 Step::D,
324 Step::E,
325 Step::F,
326 Step::G,
327 Step::A,
328 Step::B,
329 ] {
330 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
331 }
332 let estimates = estimate_keys(&score);
333 assert!(
334 estimates
335 .iter()
336 .any(|estimate| estimate.key.display_name() == "C major")
337 );
338 assert!(
339 estimates
340 .iter()
341 .any(|estimate| estimate.key.display_name() == "A minor")
342 );
343 assert!(estimates.iter().all(|estimate| estimate.confidence == 100));
344 }
345
346 #[test]
347 fn returns_no_key_for_empty_score() {
348 assert!(estimate_keys(&Score::default()).is_empty());
349 }
350}