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 = 2;
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}
28
29/// A consecutive melodic interval with addresses for both source notes.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct IntervalObservation {
32    pub from: NoteAddr,
33    pub to: NoteAddr,
34    pub semitones: u8,
35    pub diatonic_steps: i8,
36    pub rule_id: String,
37    pub evidence: Vec<NoteAddr>,
38}
39
40/// Analyze every voice that contains at least two pitched notes in a measure.
41pub fn analyze_chords(score: &Score) -> AnalysisResult {
42    let mut chords = Vec::new();
43    for (part_index, part) in score.parts.iter().enumerate() {
44        for (staff_index, staff) in part.staves.iter().enumerate() {
45            for (measure_index, measure) in staff.measures.iter().enumerate() {
46                let key = measure
47                    .key_sig
48                    .as_ref()
49                    .unwrap_or(&score.settings.key_signature);
50                for (voice_index, voice) in measure.voices.iter().enumerate() {
51                    let pitched: Vec<_> = voice
52                        .iter()
53                        .enumerate()
54                        .filter(|(_, note)| !note.is_rest && !note.pitches.is_empty())
55                        .collect();
56                    let pitches: Vec<_> = pitched
57                        .iter()
58                        .flat_map(|(_, note)| note.pitches.iter().cloned())
59                        .collect();
60                    let Some(chord) = detect_chord(&pitches) else {
61                        continue;
62                    };
63                    let evidence = pitched
64                        .iter()
65                        .map(|(note_index, _)| NoteAddr {
66                            part: part_index,
67                            staff: staff_index,
68                            measure: measure_index,
69                            voice: voice_index,
70                            note: *note_index,
71                        })
72                        .collect();
73                    chords.push(ChordLabel {
74                        address: NoteAddr {
75                            part: part_index,
76                            staff: staff_index,
77                            measure: measure_index,
78                            voice: voice_index,
79                            note: pitched[0].0,
80                        },
81                        roman_numeral: roman_numeral(&chord, key),
82                        chord,
83                        confidence: 100,
84                        rule_id: "pitch-class-template".to_string(),
85                        evidence,
86                    });
87                }
88            }
89        }
90    }
91    let intervals = analyze_intervals(score);
92    AnalysisResult {
93        schema_version: ANALYSIS_SCHEMA_VERSION,
94        chords,
95        intervals,
96    }
97}
98
99/// Analyze adjacent pitched notes in every voice without inferring missing events.
100pub fn analyze_intervals(score: &Score) -> Vec<IntervalObservation> {
101    let mut observations = Vec::new();
102    for (part_index, part) in score.parts.iter().enumerate() {
103        for (staff_index, staff) in part.staves.iter().enumerate() {
104            for (measure_index, measure) in staff.measures.iter().enumerate() {
105                for (voice_index, voice) in measure.voices.iter().enumerate() {
106                    let notes: Vec<_> = voice
107                        .iter()
108                        .enumerate()
109                        .filter_map(|(note_index, note)| {
110                            if note.is_rest {
111                                None
112                            } else {
113                                note.pitches.first().map(|pitch| (note_index, pitch))
114                            }
115                        })
116                        .collect();
117                    for pair in notes.windows(2) {
118                        let (from_index, from) = pair[0];
119                        let (to_index, to) = pair[1];
120                        let from_addr = NoteAddr {
121                            part: part_index,
122                            staff: staff_index,
123                            measure: measure_index,
124                            voice: voice_index,
125                            note: from_index,
126                        };
127                        let to_addr = NoteAddr {
128                            part: part_index,
129                            staff: staff_index,
130                            measure: measure_index,
131                            voice: voice_index,
132                            note: to_index,
133                        };
134                        observations.push(IntervalObservation {
135                            from: from_addr.clone(),
136                            to: to_addr.clone(),
137                            semitones: (to.to_midi() - from.to_midi()).unsigned_abs() as u8,
138                            diatonic_steps: diatonic_distance(from, to),
139                            rule_id: "adjacent-melodic-interval".to_string(),
140                            evidence: vec![from_addr, to_addr],
141                        });
142                    }
143                }
144            }
145        }
146    }
147    observations
148}
149
150fn diatonic_distance(from: &acorde_core::Pitch, to: &acorde_core::Pitch) -> i8 {
151    let step_index = |step: &acorde_core::Step| match step {
152        acorde_core::Step::C => 0i16,
153        acorde_core::Step::D => 1,
154        acorde_core::Step::E => 2,
155        acorde_core::Step::F => 3,
156        acorde_core::Step::G => 4,
157        acorde_core::Step::A => 5,
158        acorde_core::Step::B => 6,
159    };
160    (i16::from(to.octave) * 7 + step_index(&to.step)
161        - (i16::from(from.octave) * 7 + step_index(&from.step))) as i8
162}
163
164/// Return the stable chord spelling as a compact human-readable label.
165pub fn chord_name(chord: &ChordSymbol) -> String {
166    let suffix = match chord.kind.as_str() {
167        "major" => "",
168        "minor" => "m",
169        "dominant" => "7",
170        "major-seventh" => "maj7",
171        "minor-seventh" => "m7",
172        "diminished" => "dim",
173        "diminished-seventh" => "dim7",
174        "half-diminished" => "ΓΈ7",
175        "augmented" => "+",
176        _ => chord.kind.as_str(),
177    };
178    let bass = chord
179        .bass
180        .as_deref()
181        .map_or(String::new(), |bass| format!("/{bass}"));
182    format!("{}{suffix}{bass}", chord.root)
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use acorde_core::{Duration, Note, Pitch, Score, Step};
189
190    #[test]
191    fn labels_chord_with_note_addresses_and_roman_numeral() {
192        let mut score = Score::default();
193        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
194        voice.clear();
195        for step in [Step::C, Step::E, Step::G] {
196            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
197        }
198        let result = analyze_chords(&score);
199        assert_eq!(result.schema_version, ANALYSIS_SCHEMA_VERSION);
200        assert_eq!(result.chords.len(), 1);
201        assert_eq!(result.intervals.len(), 2);
202        assert_eq!(result.chords[0].address.note, 0);
203        assert_eq!(result.chords[0].evidence.len(), 3);
204        assert_eq!(result.chords[0].roman_numeral.as_deref(), Some("I"));
205        assert_eq!(chord_name(&result.chords[0].chord), "C");
206    }
207
208    #[test]
209    fn interval_observation_preserves_direction_and_evidence() {
210        let mut score = Score::default();
211        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
212        voice.clear();
213        voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
214        voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
215        let intervals = analyze_intervals(&score);
216        assert_eq!(intervals.len(), 1);
217        assert_eq!(intervals[0].semitones, 7);
218        assert_eq!(intervals[0].diatonic_steps, 4);
219        assert_eq!(intervals[0].evidence.len(), 2);
220    }
221
222    #[test]
223    fn does_not_invent_label_for_unknown_pitch_set() {
224        let mut score = Score::default();
225        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
226        voice.clear();
227        for step in [Step::C, Step::C] {
228            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
229        }
230        assert!(analyze_chords(&score).chords.is_empty());
231    }
232}