Skip to main content

acorde_analysis/
lib.rs

1//! Deterministic, explainable music analysis over [`acorde_core::Score`].
2
3use acorde_core::{ChordSymbol, 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 = 1;
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}
27
28/// Analyze every voice that contains at least two pitched notes in a measure.
29pub fn analyze_chords(score: &Score) -> AnalysisResult {
30    let mut chords = Vec::new();
31    for (part_index, part) in score.parts.iter().enumerate() {
32        for (staff_index, staff) in part.staves.iter().enumerate() {
33            for (measure_index, measure) in staff.measures.iter().enumerate() {
34                let key = measure
35                    .key_sig
36                    .as_ref()
37                    .unwrap_or(&score.settings.key_signature);
38                for (voice_index, voice) in measure.voices.iter().enumerate() {
39                    let pitched: Vec<_> = voice
40                        .iter()
41                        .enumerate()
42                        .filter(|(_, note)| !note.is_rest && !note.pitches.is_empty())
43                        .collect();
44                    let pitches: Vec<_> = pitched
45                        .iter()
46                        .flat_map(|(_, note)| note.pitches.iter().cloned())
47                        .collect();
48                    let Some(chord) = detect_chord(&pitches) else {
49                        continue;
50                    };
51                    let evidence = pitched
52                        .iter()
53                        .map(|(note_index, _)| NoteAddr {
54                            part: part_index,
55                            staff: staff_index,
56                            measure: measure_index,
57                            voice: voice_index,
58                            note: *note_index,
59                        })
60                        .collect();
61                    chords.push(ChordLabel {
62                        address: NoteAddr {
63                            part: part_index,
64                            staff: staff_index,
65                            measure: measure_index,
66                            voice: voice_index,
67                            note: pitched[0].0,
68                        },
69                        roman_numeral: roman_numeral(&chord, key),
70                        chord,
71                        confidence: 100,
72                        rule_id: "pitch-class-template".to_string(),
73                        evidence,
74                    });
75                }
76            }
77        }
78    }
79    AnalysisResult {
80        schema_version: ANALYSIS_SCHEMA_VERSION,
81        chords,
82    }
83}
84
85/// Return the stable chord spelling as a compact human-readable label.
86pub fn chord_name(chord: &ChordSymbol) -> String {
87    let suffix = match chord.kind.as_str() {
88        "major" => "",
89        "minor" => "m",
90        "dominant" => "7",
91        "major-seventh" => "maj7",
92        "minor-seventh" => "m7",
93        "diminished" => "dim",
94        "diminished-seventh" => "dim7",
95        "half-diminished" => "ΓΈ7",
96        "augmented" => "+",
97        _ => chord.kind.as_str(),
98    };
99    let bass = chord
100        .bass
101        .as_deref()
102        .map_or(String::new(), |bass| format!("/{bass}"));
103    format!("{}{suffix}{bass}", chord.root)
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use acorde_core::{Duration, Note, Pitch, Score, Step};
110
111    #[test]
112    fn labels_chord_with_note_addresses_and_roman_numeral() {
113        let mut score = Score::default();
114        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
115        voice.clear();
116        for step in [Step::C, Step::E, Step::G] {
117            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
118        }
119        let result = analyze_chords(&score);
120        assert_eq!(result.schema_version, ANALYSIS_SCHEMA_VERSION);
121        assert_eq!(result.chords.len(), 1);
122        assert_eq!(result.chords[0].address.note, 0);
123        assert_eq!(result.chords[0].evidence.len(), 3);
124        assert_eq!(result.chords[0].roman_numeral.as_deref(), Some("I"));
125        assert_eq!(chord_name(&result.chords[0].chord), "C");
126    }
127
128    #[test]
129    fn does_not_invent_label_for_unknown_pitch_set() {
130        let mut score = Score::default();
131        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
132        voice.clear();
133        for step in [Step::C, Step::C] {
134            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
135        }
136        assert!(analyze_chords(&score).chords.is_empty());
137    }
138}