use acorde_core::{ChordSymbol, NoteAddr, Score, detect_chord, roman_numeral};
use serde::{Deserialize, Serialize};
pub const ANALYSIS_SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChordLabel {
pub address: NoteAddr,
pub chord: ChordSymbol,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub roman_numeral: Option<String>,
pub confidence: u8,
pub rule_id: String,
pub evidence: Vec<NoteAddr>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnalysisResult {
pub schema_version: u32,
pub chords: Vec<ChordLabel>,
pub intervals: Vec<IntervalObservation>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IntervalObservation {
pub from: NoteAddr,
pub to: NoteAddr,
pub semitones: u8,
pub diatonic_steps: i8,
pub rule_id: String,
pub evidence: Vec<NoteAddr>,
}
pub fn analyze_chords(score: &Score) -> AnalysisResult {
let mut chords = Vec::new();
for (part_index, part) in score.parts.iter().enumerate() {
for (staff_index, staff) in part.staves.iter().enumerate() {
for (measure_index, measure) in staff.measures.iter().enumerate() {
let key = measure
.key_sig
.as_ref()
.unwrap_or(&score.settings.key_signature);
for (voice_index, voice) in measure.voices.iter().enumerate() {
let pitched: Vec<_> = voice
.iter()
.enumerate()
.filter(|(_, note)| !note.is_rest && !note.pitches.is_empty())
.collect();
let pitches: Vec<_> = pitched
.iter()
.flat_map(|(_, note)| note.pitches.iter().cloned())
.collect();
let Some(chord) = detect_chord(&pitches) else {
continue;
};
let evidence = pitched
.iter()
.map(|(note_index, _)| NoteAddr {
part: part_index,
staff: staff_index,
measure: measure_index,
voice: voice_index,
note: *note_index,
})
.collect();
chords.push(ChordLabel {
address: NoteAddr {
part: part_index,
staff: staff_index,
measure: measure_index,
voice: voice_index,
note: pitched[0].0,
},
roman_numeral: roman_numeral(&chord, key),
chord,
confidence: 100,
rule_id: "pitch-class-template".to_string(),
evidence,
});
}
}
}
}
let intervals = analyze_intervals(score);
AnalysisResult {
schema_version: ANALYSIS_SCHEMA_VERSION,
chords,
intervals,
}
}
pub fn analyze_intervals(score: &Score) -> Vec<IntervalObservation> {
let mut observations = Vec::new();
for (part_index, part) in score.parts.iter().enumerate() {
for (staff_index, staff) in part.staves.iter().enumerate() {
for (measure_index, measure) in staff.measures.iter().enumerate() {
for (voice_index, voice) in measure.voices.iter().enumerate() {
let notes: Vec<_> = voice
.iter()
.enumerate()
.filter_map(|(note_index, note)| {
if note.is_rest {
None
} else {
note.pitches.first().map(|pitch| (note_index, pitch))
}
})
.collect();
for pair in notes.windows(2) {
let (from_index, from) = pair[0];
let (to_index, to) = pair[1];
let from_addr = NoteAddr {
part: part_index,
staff: staff_index,
measure: measure_index,
voice: voice_index,
note: from_index,
};
let to_addr = NoteAddr {
part: part_index,
staff: staff_index,
measure: measure_index,
voice: voice_index,
note: to_index,
};
observations.push(IntervalObservation {
from: from_addr.clone(),
to: to_addr.clone(),
semitones: (to.to_midi() - from.to_midi()).unsigned_abs() as u8,
diatonic_steps: diatonic_distance(from, to),
rule_id: "adjacent-melodic-interval".to_string(),
evidence: vec![from_addr, to_addr],
});
}
}
}
}
}
observations
}
fn diatonic_distance(from: &acorde_core::Pitch, to: &acorde_core::Pitch) -> i8 {
let step_index = |step: &acorde_core::Step| match step {
acorde_core::Step::C => 0i16,
acorde_core::Step::D => 1,
acorde_core::Step::E => 2,
acorde_core::Step::F => 3,
acorde_core::Step::G => 4,
acorde_core::Step::A => 5,
acorde_core::Step::B => 6,
};
(i16::from(to.octave) * 7 + step_index(&to.step)
- (i16::from(from.octave) * 7 + step_index(&from.step))) as i8
}
pub fn chord_name(chord: &ChordSymbol) -> String {
let suffix = match chord.kind.as_str() {
"major" => "",
"minor" => "m",
"dominant" => "7",
"major-seventh" => "maj7",
"minor-seventh" => "m7",
"diminished" => "dim",
"diminished-seventh" => "dim7",
"half-diminished" => "ΓΈ7",
"augmented" => "+",
_ => chord.kind.as_str(),
};
let bass = chord
.bass
.as_deref()
.map_or(String::new(), |bass| format!("/{bass}"));
format!("{}{suffix}{bass}", chord.root)
}
#[cfg(test)]
mod tests {
use super::*;
use acorde_core::{Duration, Note, Pitch, Score, Step};
#[test]
fn labels_chord_with_note_addresses_and_roman_numeral() {
let mut score = Score::default();
let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
voice.clear();
for step in [Step::C, Step::E, Step::G] {
voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
}
let result = analyze_chords(&score);
assert_eq!(result.schema_version, ANALYSIS_SCHEMA_VERSION);
assert_eq!(result.chords.len(), 1);
assert_eq!(result.intervals.len(), 2);
assert_eq!(result.chords[0].address.note, 0);
assert_eq!(result.chords[0].evidence.len(), 3);
assert_eq!(result.chords[0].roman_numeral.as_deref(), Some("I"));
assert_eq!(chord_name(&result.chords[0].chord), "C");
}
#[test]
fn interval_observation_preserves_direction_and_evidence() {
let mut score = Score::default();
let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
voice.clear();
voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
let intervals = analyze_intervals(&score);
assert_eq!(intervals.len(), 1);
assert_eq!(intervals[0].semitones, 7);
assert_eq!(intervals[0].diatonic_steps, 4);
assert_eq!(intervals[0].evidence.len(), 2);
}
#[test]
fn does_not_invent_label_for_unknown_pitch_set() {
let mut score = Score::default();
let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
voice.clear();
for step in [Step::C, Step::C] {
voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
}
assert!(analyze_chords(&score).chords.is_empty());
}
}