Skip to main content

acorde_analysis/
lib.rs

1//! Deterministic, explainable music analysis over [`acorde_core::Score`].
2
3use acorde_core::{ChordSymbol, KeySignature, NoteAddr, Score, detect_chord, roman_numeral};
4use serde::{Deserialize, Serialize};
5
6/// Version of the serialized analysis result contract.
7pub const ANALYSIS_SCHEMA_VERSION: u32 = 3;
8
9/// A chord label with source evidence and the rule that produced it.
10#[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/// Deterministic output of the chord-analysis pass.
22#[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/// A deterministic key candidate ranked by diatonic pitch coverage.
32#[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/// A consecutive melodic interval with addresses for both source notes.
43#[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
53/// Analyze every voice that contains at least two pitched notes in a measure.
54pub 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
114/// Estimate major/minor keys from pitch coverage, preserving tied candidates.
115pub 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
181/// Analyze adjacent pitched notes in every voice without inferring missing events.
182pub 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
246/// Return the stable chord spelling as a compact human-readable label.
247pub 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}