1use acorde_core::{ChordSymbol, NoteAddr, Score, detect_chord, roman_numeral};
4use serde::{Deserialize, Serialize};
5
6pub const ANALYSIS_SCHEMA_VERSION: u32 = 1;
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}
27
28pub 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
85pub 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}