1use acorde_core::{ChordSymbol, KeySignature, NoteAddr, Score, detect_chord, roman_numeral};
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, HashMap};
6use thiserror::Error;
7
8pub const ANALYSIS_SCHEMA_VERSION: u32 = 7;
10
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct ChordLabel {
14 pub address: NoteAddr,
15 pub chord: ChordSymbol,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub roman_numeral: Option<String>,
18 pub confidence: u8,
19 pub rule_id: String,
20 pub evidence: Vec<NoteAddr>,
21}
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct AnalysisResult {
26 pub schema_version: u32,
27 #[serde(default)]
29 pub score_fingerprint: String,
30 pub chords: Vec<ChordLabel>,
31 pub intervals: Vec<IntervalObservation>,
32 #[serde(default)]
33 pub key_estimates: Vec<KeyEstimate>,
34 #[serde(default)]
35 pub cadence_candidates: Vec<CadenceCandidate>,
36 #[serde(default)]
37 pub voice_leading: Vec<VoiceLeadingObservation>,
38 #[serde(default)]
39 pub satb_diagnostics: Vec<SatbDiagnostic>,
40 #[serde(default)]
41 pub motifs: Vec<MotifPattern>,
42 #[serde(default)]
43 pub phrase_boundaries: Vec<PhraseBoundary>,
44}
45
46impl AnalysisResult {
47 pub fn cache_key(&self) -> String {
49 format!(
50 "analysis-v{}-{}",
51 self.schema_version, self.score_fingerprint
52 )
53 }
54
55 pub fn matches_score(&self, score: &Score) -> bool {
57 self.score_fingerprint == score_fingerprint(score)
58 }
59}
60
61pub trait AnalysisPass {
63 fn id(&self) -> &str;
65
66 fn run(&self, score: &Score) -> serde_json::Value;
68}
69
70#[derive(Debug, Error, Clone, PartialEq, Eq)]
72pub enum AnalysisPassError {
73 #[error("analysis pass ID must not be empty")]
74 EmptyId,
75 #[error("duplicate analysis pass ID: {0}")]
76 DuplicateId(String),
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct AnalysisPassResult {
82 pub pass_id: String,
83 pub output: serde_json::Value,
84}
85
86pub fn run_analysis_passes(
88 score: &Score,
89 passes: &[&dyn AnalysisPass],
90) -> Result<Vec<AnalysisPassResult>, AnalysisPassError> {
91 let mut ordered: Vec<&dyn AnalysisPass> = passes.to_vec();
92 for pass in &ordered {
93 if pass.id().is_empty() {
94 return Err(AnalysisPassError::EmptyId);
95 }
96 }
97 ordered.sort_by(|left, right| left.id().cmp(right.id()));
98 for pair in ordered.windows(2) {
99 if pair[0].id() == pair[1].id() {
100 return Err(AnalysisPassError::DuplicateId(pair[0].id().to_string()));
101 }
102 }
103 Ok(ordered
104 .into_iter()
105 .map(|pass| AnalysisPassResult {
106 pass_id: pass.id().to_string(),
107 output: pass.run(score),
108 })
109 .collect())
110}
111
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct KeyEstimate {
115 pub key: KeySignature,
116 pub covered_pitches: usize,
117 pub total_pitches: usize,
118 pub confidence: u8,
119 pub rule_id: String,
120 pub evidence: Vec<NoteAddr>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct CadenceCandidate {
126 pub from: NoteAddr,
127 pub to: NoteAddr,
128 pub kind: CadenceKind,
129 pub confidence: u8,
130 pub rule_id: String,
131 pub evidence: Vec<NoteAddr>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub enum CadenceKind {
137 Authentic,
138 Plagal,
139 Deceptive,
140 Half,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct VoiceLeadingObservation {
146 pub upper: NoteAddr,
147 pub lower: NoteAddr,
148 pub upper_motion: i16,
149 pub lower_motion: i16,
150 pub parallel_perfect: bool,
151 pub confidence: u8,
152 pub rule_id: String,
153 pub evidence: Vec<NoteAddr>,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct SatbDiagnostic {
159 pub upper: NoteAddr,
160 pub lower: NoteAddr,
161 pub kind: SatbDiagnosticKind,
162 pub severity: SatbSeverity,
163 pub confidence: u8,
164 pub rule_id: String,
165 pub evidence: Vec<NoteAddr>,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub enum SatbDiagnosticKind {
171 VoiceCrossing,
172 WideSpacing,
173 ParallelPerfect,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub enum SatbSeverity {
179 Error,
180 Warning,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185pub struct MotifPattern {
186 pub signature: Vec<i8>,
187 pub occurrences: Vec<MotifOccurrence>,
188 pub confidence: u8,
189 pub rule_id: String,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194pub struct MotifOccurrence {
195 pub start: NoteAddr,
196 pub end: NoteAddr,
197 pub evidence: Vec<NoteAddr>,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct PhraseBoundary {
203 pub address: NoteAddr,
204 pub reason: PhraseBoundaryReason,
205 pub confidence: u8,
206 pub rule_id: String,
207 pub evidence: Vec<NoteAddr>,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub enum PhraseBoundaryReason {
213 RestTermination,
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
218pub struct BenchmarkExpectation {
219 pub chords: usize,
220 pub intervals: usize,
221 pub key_estimates: usize,
222 pub cadence_candidates: usize,
223 pub voice_leading: usize,
224 pub satb_diagnostics: usize,
225 pub motifs: usize,
226 pub phrase_boundaries: usize,
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
231pub struct AnalysisCounts {
232 pub chords: usize,
233 pub intervals: usize,
234 pub key_estimates: usize,
235 pub cadence_candidates: usize,
236 pub voice_leading: usize,
237 pub satb_diagnostics: usize,
238 pub motifs: usize,
239 pub phrase_boundaries: usize,
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
244pub enum BenchmarkCategory {
245 Chords,
246 Intervals,
247 KeyEstimates,
248 CadenceCandidates,
249 VoiceLeading,
250 SatbDiagnostics,
251 Motifs,
252 PhraseBoundaries,
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
257pub struct BenchmarkFailure {
258 pub category: BenchmarkCategory,
259 pub expected: usize,
260 pub predicted: usize,
261 pub missing: usize,
262 pub excess: usize,
263}
264
265#[derive(Debug, Clone)]
267pub struct BenchmarkCase<'a> {
268 pub name: &'a str,
269 pub score: &'a Score,
270 pub expected: BenchmarkExpectation,
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub struct BenchmarkCaseReport {
276 pub name: String,
277 pub predicted: AnalysisCounts,
278 pub expected: BenchmarkExpectation,
279 pub precision_percent: u8,
280 pub recall_percent: u8,
281 pub explanation_completeness_percent: u8,
282 pub failures: Vec<BenchmarkFailure>,
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287pub struct BenchmarkSuiteReport {
288 pub cases: Vec<BenchmarkCaseReport>,
289 pub case_count: usize,
290 pub passed_case_count: usize,
291 pub failed_case_count: usize,
292 pub precision_percent: u8,
293 pub recall_percent: u8,
294 pub explanation_completeness_percent: u8,
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299pub struct IntervalObservation {
300 pub from: NoteAddr,
301 pub to: NoteAddr,
302 pub semitones: u8,
303 pub diatonic_steps: i8,
304 pub rule_id: String,
305 pub evidence: Vec<NoteAddr>,
306}
307
308pub fn analyze_score(score: &Score) -> AnalysisResult {
310 let mut chords = Vec::new();
311 for (part_index, part) in score.parts.iter().enumerate() {
312 for (staff_index, staff) in part.staves.iter().enumerate() {
313 for (measure_index, measure) in staff.measures.iter().enumerate() {
314 let key = measure
315 .key_sig
316 .as_ref()
317 .unwrap_or(&score.settings.key_signature);
318 for (voice_index, voice) in measure.voices.iter().enumerate() {
319 let pitched: Vec<_> = voice
320 .iter()
321 .enumerate()
322 .filter(|(_, note)| !note.is_rest && !note.pitches.is_empty())
323 .collect();
324 let pitches: Vec<_> = pitched
325 .iter()
326 .flat_map(|(_, note)| note.pitches.iter().cloned())
327 .collect();
328 let Some(chord) = detect_chord(&pitches) else {
329 continue;
330 };
331 let evidence = pitched
332 .iter()
333 .map(|(note_index, _)| NoteAddr {
334 part: part_index,
335 staff: staff_index,
336 measure: measure_index,
337 voice: voice_index,
338 note: *note_index,
339 })
340 .collect();
341 chords.push(ChordLabel {
342 address: NoteAddr {
343 part: part_index,
344 staff: staff_index,
345 measure: measure_index,
346 voice: voice_index,
347 note: pitched[0].0,
348 },
349 roman_numeral: roman_numeral(&chord, key),
350 chord,
351 confidence: 100,
352 rule_id: "pitch-class-template".to_string(),
353 evidence,
354 });
355 }
356 }
357 }
358 }
359 let intervals = analyze_intervals(score);
360 let key_estimates = estimate_keys(score);
361 let cadence_candidates = analyze_cadences(&chords);
362 let voice_leading = analyze_voice_leading(score);
363 let satb_diagnostics = analyze_satb_with_voice_leading(score, &voice_leading);
364 let motifs = analyze_motifs(score);
365 let phrase_boundaries = analyze_phrase_boundaries(score);
366 AnalysisResult {
367 schema_version: ANALYSIS_SCHEMA_VERSION,
368 score_fingerprint: score_fingerprint(score),
369 chords,
370 intervals,
371 key_estimates,
372 cadence_candidates,
373 voice_leading,
374 satb_diagnostics,
375 motifs,
376 phrase_boundaries,
377 }
378}
379
380pub fn score_fingerprint(score: &Score) -> String {
382 let mut value = serde_json::to_value(score).unwrap_or_default();
383 remove_generated_ids(&mut value);
384 let bytes = serde_json::to_vec(&value).unwrap_or_default();
385 let hash = fnv1a64(&bytes);
386 format!("fnv1a64-{hash:016x}")
387}
388
389fn remove_generated_ids(value: &mut serde_json::Value) {
390 match value {
391 serde_json::Value::Object(object) => {
392 object.remove("id");
393 for child in object.values_mut() {
394 remove_generated_ids(child);
395 }
396 }
397 serde_json::Value::Array(values) => {
398 for child in values {
399 remove_generated_ids(child);
400 }
401 }
402 _ => {}
403 }
404}
405
406pub fn analysis_cache_key(score: &Score) -> String {
408 format!(
409 "analysis-v{}-{}",
410 ANALYSIS_SCHEMA_VERSION,
411 score_fingerprint(score)
412 )
413}
414
415fn fnv1a64(bytes: &[u8]) -> u64 {
416 bytes.iter().fold(0xcbf29ce484222325u64, |hash, byte| {
417 (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
418 })
419}
420
421impl AnalysisCounts {
422 fn from_result(result: &AnalysisResult) -> Self {
423 Self {
424 chords: result.chords.len(),
425 intervals: result.intervals.len(),
426 key_estimates: result.key_estimates.len(),
427 cadence_candidates: result.cadence_candidates.len(),
428 voice_leading: result.voice_leading.len(),
429 satb_diagnostics: result.satb_diagnostics.len(),
430 motifs: result.motifs.len(),
431 phrase_boundaries: result.phrase_boundaries.len(),
432 }
433 }
434
435 fn total(self) -> usize {
436 self.chords
437 + self.intervals
438 + self.key_estimates
439 + self.cadence_candidates
440 + self.voice_leading
441 + self.satb_diagnostics
442 + self.motifs
443 + self.phrase_boundaries
444 }
445
446 fn explained(self, result: &AnalysisResult) -> usize {
447 result
448 .chords
449 .iter()
450 .filter(|item| !item.evidence.is_empty())
451 .count()
452 + result
453 .intervals
454 .iter()
455 .filter(|item| !item.evidence.is_empty())
456 .count()
457 + result
458 .key_estimates
459 .iter()
460 .filter(|item| !item.evidence.is_empty())
461 .count()
462 + result
463 .cadence_candidates
464 .iter()
465 .filter(|item| !item.evidence.is_empty())
466 .count()
467 + result
468 .voice_leading
469 .iter()
470 .filter(|item| !item.evidence.is_empty())
471 .count()
472 + result
473 .satb_diagnostics
474 .iter()
475 .filter(|item| !item.evidence.is_empty())
476 .count()
477 + result
478 .motifs
479 .iter()
480 .map(|item| {
481 item.occurrences
482 .iter()
483 .filter(|occurrence| !occurrence.evidence.is_empty())
484 .count()
485 })
486 .sum::<usize>()
487 + result
488 .phrase_boundaries
489 .iter()
490 .filter(|item| !item.evidence.is_empty())
491 .count()
492 }
493}
494
495pub fn benchmark_case(case: &BenchmarkCase<'_>) -> BenchmarkCaseReport {
497 let result = analyze_score(case.score);
498 let predicted = AnalysisCounts::from_result(&result);
499 let expected = case.expected;
500 let matched = predicted.chords.min(expected.chords)
501 + predicted.intervals.min(expected.intervals)
502 + predicted.key_estimates.min(expected.key_estimates)
503 + predicted
504 .cadence_candidates
505 .min(expected.cadence_candidates)
506 + predicted.voice_leading.min(expected.voice_leading)
507 + predicted.satb_diagnostics.min(expected.satb_diagnostics)
508 + predicted.motifs.min(expected.motifs)
509 + predicted.phrase_boundaries.min(expected.phrase_boundaries);
510 let expected_total = AnalysisCounts {
511 chords: expected.chords,
512 intervals: expected.intervals,
513 key_estimates: expected.key_estimates,
514 cadence_candidates: expected.cadence_candidates,
515 voice_leading: expected.voice_leading,
516 satb_diagnostics: expected.satb_diagnostics,
517 motifs: expected.motifs,
518 phrase_boundaries: expected.phrase_boundaries,
519 }
520 .total();
521 let predicted_total = predicted.total();
522 let failures = [
523 (BenchmarkCategory::Chords, expected.chords, predicted.chords),
524 (
525 BenchmarkCategory::Intervals,
526 expected.intervals,
527 predicted.intervals,
528 ),
529 (
530 BenchmarkCategory::KeyEstimates,
531 expected.key_estimates,
532 predicted.key_estimates,
533 ),
534 (
535 BenchmarkCategory::CadenceCandidates,
536 expected.cadence_candidates,
537 predicted.cadence_candidates,
538 ),
539 (
540 BenchmarkCategory::VoiceLeading,
541 expected.voice_leading,
542 predicted.voice_leading,
543 ),
544 (
545 BenchmarkCategory::SatbDiagnostics,
546 expected.satb_diagnostics,
547 predicted.satb_diagnostics,
548 ),
549 (BenchmarkCategory::Motifs, expected.motifs, predicted.motifs),
550 (
551 BenchmarkCategory::PhraseBoundaries,
552 expected.phrase_boundaries,
553 predicted.phrase_boundaries,
554 ),
555 ]
556 .into_iter()
557 .filter_map(|(category, expected, predicted)| {
558 if expected == predicted {
559 return None;
560 }
561 Some(BenchmarkFailure {
562 category,
563 expected,
564 predicted,
565 missing: expected.saturating_sub(predicted),
566 excess: predicted.saturating_sub(expected),
567 })
568 })
569 .collect();
570 BenchmarkCaseReport {
571 name: case.name.to_string(),
572 predicted,
573 expected,
574 precision_percent: percentage(matched, predicted_total),
575 recall_percent: percentage(matched, expected_total),
576 explanation_completeness_percent: percentage(predicted.explained(&result), predicted_total),
577 failures,
578 }
579}
580
581pub fn run_benchmark(cases: &[BenchmarkCase<'_>]) -> Vec<BenchmarkCaseReport> {
583 cases.iter().map(benchmark_case).collect()
584}
585
586pub fn run_benchmark_suite(cases: &[BenchmarkCase<'_>]) -> BenchmarkSuiteReport {
588 let reports = run_benchmark(cases);
589 let case_count = reports.len();
590 let passed_case_count = reports
591 .iter()
592 .filter(|report| report.failures.is_empty())
593 .count();
594 let failed_case_count = case_count.saturating_sub(passed_case_count);
595 let precision_total: usize = reports
596 .iter()
597 .map(|report| usize::from(report.precision_percent))
598 .sum();
599 let recall_total: usize = reports
600 .iter()
601 .map(|report| usize::from(report.recall_percent))
602 .sum();
603 let explanation_total: usize = reports
604 .iter()
605 .map(|report| usize::from(report.explanation_completeness_percent))
606 .sum();
607 let metric_denominator = case_count.saturating_mul(100);
608 let aggregate_metric = |total: usize| {
609 if case_count == 0 {
610 0
611 } else {
612 percentage(total, metric_denominator)
613 }
614 };
615 BenchmarkSuiteReport {
616 cases: reports,
617 case_count,
618 passed_case_count,
619 failed_case_count,
620 precision_percent: aggregate_metric(precision_total),
621 recall_percent: aggregate_metric(recall_total),
622 explanation_completeness_percent: aggregate_metric(explanation_total),
623 }
624}
625
626fn percentage(numerator: usize, denominator: usize) -> u8 {
627 match numerator.saturating_mul(100).checked_div(denominator) {
628 Some(value) => value.min(100) as u8,
629 None => 100,
630 }
631}
632
633pub fn analyze_satb(score: &Score) -> Vec<SatbDiagnostic> {
635 let voice_leading = analyze_voice_leading(score);
636 analyze_satb_with_voice_leading(score, &voice_leading)
637}
638
639fn analyze_satb_with_voice_leading(
640 score: &Score,
641 voice_leading: &[VoiceLeadingObservation],
642) -> Vec<SatbDiagnostic> {
643 let mut diagnostics = Vec::new();
644 for (part_index, part) in score.parts.iter().enumerate() {
645 for (staff_index, staff) in part.staves.iter().enumerate() {
646 for (measure_index, measure) in staff.measures.iter().enumerate() {
647 for (upper_index, upper_voice) in measure.voices.iter().enumerate() {
648 let Some(lower_voice) = measure.voices.get(upper_index + 1) else {
649 continue;
650 };
651 for note_index in 0..upper_voice.len().min(lower_voice.len()) {
652 let Some(upper) = upper_voice[note_index].pitches.first() else {
653 continue;
654 };
655 let Some(lower) = lower_voice[note_index].pitches.first() else {
656 continue;
657 };
658 let upper_addr = NoteAddr {
659 part: part_index,
660 staff: staff_index,
661 measure: measure_index,
662 voice: upper_index,
663 note: note_index,
664 };
665 let lower_addr = NoteAddr {
666 part: part_index,
667 staff: staff_index,
668 measure: measure_index,
669 voice: upper_index + 1,
670 note: note_index,
671 };
672 let distance = upper.to_midi() - lower.to_midi();
673 if distance < 0 {
674 diagnostics.push(satb_diagnostic(
675 upper_addr.clone(),
676 lower_addr.clone(),
677 SatbDiagnosticKind::VoiceCrossing,
678 SatbSeverity::Error,
679 "satb-voice-crossing",
680 ));
681 } else if distance > 24 {
682 diagnostics.push(satb_diagnostic(
683 upper_addr.clone(),
684 lower_addr.clone(),
685 SatbDiagnosticKind::WideSpacing,
686 SatbSeverity::Warning,
687 "satb-wide-spacing",
688 ));
689 }
690 }
691 }
692 }
693 }
694 }
695 for observation in voice_leading {
696 if observation.parallel_perfect {
697 diagnostics.push(satb_diagnostic(
698 observation.upper.clone(),
699 observation.lower.clone(),
700 SatbDiagnosticKind::ParallelPerfect,
701 SatbSeverity::Warning,
702 "satb-parallel-perfect",
703 ));
704 }
705 }
706 diagnostics
707}
708
709fn satb_diagnostic(
710 upper: NoteAddr,
711 lower: NoteAddr,
712 kind: SatbDiagnosticKind,
713 severity: SatbSeverity,
714 rule_id: &str,
715) -> SatbDiagnostic {
716 SatbDiagnostic {
717 evidence: vec![upper.clone(), lower.clone()],
718 upper,
719 lower,
720 kind,
721 severity,
722 confidence: 100,
723 rule_id: rule_id.to_string(),
724 }
725}
726
727pub fn analyze_motifs(score: &Score) -> Vec<MotifPattern> {
729 let mut groups: BTreeMap<(usize, usize, usize, Vec<i8>), Vec<MotifOccurrence>> =
730 BTreeMap::new();
731 for (part_index, part) in score.parts.iter().enumerate() {
732 for (staff_index, staff) in part.staves.iter().enumerate() {
733 let Some(first_measure) = staff.measures.first() else {
734 continue;
735 };
736 for (voice_index, _) in first_measure.voices.iter().enumerate() {
737 let mut segment = Vec::new();
738 let mut segments = Vec::new();
739 for (measure_index, measure) in staff.measures.iter().enumerate() {
740 for (note_index, note) in measure.voices[voice_index].iter().enumerate() {
741 let Some(pitch) = note.pitches.first() else {
742 if segment.len() >= 3 {
743 segments.push(std::mem::take(&mut segment));
744 } else {
745 segment.clear();
746 }
747 continue;
748 };
749 if note.is_rest {
750 if segment.len() >= 3 {
751 segments.push(std::mem::take(&mut segment));
752 } else {
753 segment.clear();
754 }
755 continue;
756 }
757 segment.push((
758 NoteAddr {
759 part: part_index,
760 staff: staff_index,
761 measure: measure_index,
762 voice: voice_index,
763 note: note_index,
764 },
765 pitch.to_midi(),
766 ));
767 }
768 }
769 if segment.len() >= 3 {
770 segments.push(segment);
771 }
772 for segment in segments {
773 for window in segment.windows(3) {
774 let signature = vec![
775 (window[1].1 - window[0].1) as i8,
776 (window[2].1 - window[1].1) as i8,
777 ];
778 let occurrence = MotifOccurrence {
779 start: window[0].0.clone(),
780 end: window[2].0.clone(),
781 evidence: window.iter().map(|(address, _)| address.clone()).collect(),
782 };
783 groups
784 .entry((part_index, staff_index, voice_index, signature))
785 .or_default()
786 .push(occurrence);
787 }
788 }
789 }
790 }
791 }
792 groups
793 .into_iter()
794 .filter(|(_, occurrences)| occurrences.len() >= 2)
795 .map(|((_, _, _, signature), occurrences)| MotifPattern {
796 signature,
797 occurrences,
798 confidence: 100,
799 rule_id: "repeated-three-note-interval-pattern".to_string(),
800 })
801 .collect()
802}
803
804pub fn analyze_phrase_boundaries(score: &Score) -> Vec<PhraseBoundary> {
806 let mut boundaries = Vec::new();
807 for (part_index, part) in score.parts.iter().enumerate() {
808 for (staff_index, staff) in part.staves.iter().enumerate() {
809 for (measure_index, measure) in staff.measures.iter().enumerate() {
810 for (voice_index, voice) in measure.voices.iter().enumerate() {
811 let Some((note_index, note)) = voice.iter().enumerate().next_back() else {
812 continue;
813 };
814 if !note.is_rest {
815 continue;
816 }
817 let address = NoteAddr {
818 part: part_index,
819 staff: staff_index,
820 measure: measure_index,
821 voice: voice_index,
822 note: note_index,
823 };
824 boundaries.push(PhraseBoundary {
825 address: address.clone(),
826 reason: PhraseBoundaryReason::RestTermination,
827 confidence: 100,
828 rule_id: "measure-ending-rest".to_string(),
829 evidence: vec![address],
830 });
831 }
832 }
833 }
834 }
835 boundaries
836}
837
838pub fn analyze_cadences(chords: &[ChordLabel]) -> Vec<CadenceCandidate> {
840 let mut candidates = Vec::new();
841 let mut previous: HashMap<(usize, usize, usize), &ChordLabel> = HashMap::new();
842 for chord in chords {
843 let key = (chord.address.part, chord.address.staff, chord.address.voice);
844 let Some(previous_chord) = previous.insert(key, chord) else {
845 continue;
846 };
847 let Some(from_roman) = previous_chord.roman_numeral.as_deref() else {
848 continue;
849 };
850 let Some(to_roman) = chord.roman_numeral.as_deref() else {
851 continue;
852 };
853 let from_figure = roman_figure(from_roman);
854 let to_figure = roman_figure(to_roman);
855 let kind = match (from_figure, to_figure) {
856 ("V" | "V7", "I") => CadenceKind::Authentic,
857 ("IV", "I") => CadenceKind::Plagal,
858 ("V" | "V7", "vi") => CadenceKind::Deceptive,
859 (_, "V" | "V7") => CadenceKind::Half,
860 _ => continue,
861 };
862 let evidence = vec![previous_chord.address.clone(), chord.address.clone()];
863 candidates.push(CadenceCandidate {
864 from: previous_chord.address.clone(),
865 to: chord.address.clone(),
866 kind,
867 confidence: 100,
868 rule_id: "roman-numeral-cadence-transition".to_string(),
869 evidence,
870 });
871 }
872 candidates
873}
874
875fn roman_figure(roman: &str) -> &str {
876 roman.find('/').map_or(roman, |index| &roman[..index])
877}
878
879pub fn analyze_voice_leading(score: &Score) -> Vec<VoiceLeadingObservation> {
881 let mut observations = Vec::new();
882 for (part_index, part) in score.parts.iter().enumerate() {
883 for (staff_index, staff) in part.staves.iter().enumerate() {
884 for (measure_index, measure) in staff.measures.iter().enumerate() {
885 for (upper_index, upper_voice) in measure.voices.iter().enumerate() {
886 let Some(lower_voice) = measure.voices.get(upper_index + 1) else {
887 continue;
888 };
889 let count = upper_voice.len().min(lower_voice.len());
890 for note_index in 0..count {
891 let Some(upper) = upper_voice[note_index].pitches.first() else {
892 continue;
893 };
894 let Some(lower) = lower_voice[note_index].pitches.first() else {
895 continue;
896 };
897 let next_upper = upper_voice[note_index + 1..]
898 .iter()
899 .find_map(|note| note.pitches.first());
900 let next_lower = lower_voice[note_index + 1..]
901 .iter()
902 .find_map(|note| note.pitches.first());
903 let (Some(next_upper), Some(next_lower)) = (next_upper, next_lower) else {
904 continue;
905 };
906 let upper_addr = NoteAddr {
907 part: part_index,
908 staff: staff_index,
909 measure: measure_index,
910 voice: upper_index,
911 note: note_index,
912 };
913 let lower_addr = NoteAddr {
914 part: part_index,
915 staff: staff_index,
916 measure: measure_index,
917 voice: upper_index + 1,
918 note: note_index,
919 };
920 let upper_next_midi = next_upper.to_midi();
921 let lower_next_midi = next_lower.to_midi();
922 let upper_motion = upper_next_midi - upper.to_midi();
923 let lower_motion = lower_next_midi - lower.to_midi();
924 let initial = (upper.to_midi() - lower.to_midi()).unsigned_abs() % 12;
925 let next = (upper_next_midi - lower_next_midi).unsigned_abs() % 12;
926 observations.push(VoiceLeadingObservation {
927 upper: upper_addr.clone(),
928 lower: lower_addr.clone(),
929 upper_motion,
930 lower_motion,
931 parallel_perfect: matches!(initial, 0 | 7)
932 && initial == next
933 && upper_motion != 0
934 && upper_motion.signum() == lower_motion.signum(),
935 confidence: 100,
936 rule_id: "aligned-adjacent-voice-leading".to_string(),
937 evidence: vec![upper_addr, lower_addr],
938 });
939 }
940 }
941 }
942 }
943 }
944 observations
945}
946
947pub fn analyze_chords(score: &Score) -> AnalysisResult {
949 analyze_score(score)
950}
951
952pub fn analyze_batch(scores: &[Score]) -> Vec<AnalysisResult> {
954 scores.iter().map(analyze_score).collect()
955}
956
957pub fn analyze_stream<I>(scores: I) -> impl Iterator<Item = AnalysisResult>
959where
960 I: IntoIterator<Item = Score>,
961{
962 scores.into_iter().map(|score| analyze_score(&score))
963}
964
965pub fn estimate_keys(score: &Score) -> Vec<KeyEstimate> {
967 let mut pitches = Vec::new();
968 let mut evidence = Vec::new();
969 for (part_index, part) in score.parts.iter().enumerate() {
970 for (staff_index, staff) in part.staves.iter().enumerate() {
971 for (measure_index, measure) in staff.measures.iter().enumerate() {
972 for (voice_index, voice) in measure.voices.iter().enumerate() {
973 for (note_index, note) in voice.iter().enumerate() {
974 if note.is_rest {
975 continue;
976 }
977 pitches.extend(note.pitches.iter());
978 if !note.pitches.is_empty() {
979 evidence.push(NoteAddr {
980 part: part_index,
981 staff: staff_index,
982 measure: measure_index,
983 voice: voice_index,
984 note: note_index,
985 });
986 }
987 }
988 }
989 }
990 }
991 }
992 if pitches.is_empty() {
993 return Vec::new();
994 }
995 let total_pitches = pitches.len();
996 let mut candidates = Vec::with_capacity(30);
997 for fifths in -7..=7 {
998 for mode in ["major", "minor"] {
999 let key = KeySignature {
1000 fifths,
1001 mode: mode.to_string(),
1002 };
1003 let covered = pitches
1004 .iter()
1005 .filter(|pitch| key.contains_pitch(pitch))
1006 .count();
1007 candidates.push((key, covered));
1008 }
1009 }
1010 candidates.sort_by(|(left_key, left_score), (right_key, right_score)| {
1011 right_score
1012 .cmp(left_score)
1013 .then_with(|| left_key.fifths.abs().cmp(&right_key.fifths.abs()))
1014 .then_with(|| left_key.fifths.cmp(&right_key.fifths))
1015 .then_with(|| left_key.mode.cmp(&right_key.mode))
1016 });
1017 let best = candidates[0].1;
1018 candidates
1019 .into_iter()
1020 .take_while(|(_, covered)| *covered == best)
1021 .map(|(key, covered_pitches)| KeyEstimate {
1022 key,
1023 covered_pitches,
1024 total_pitches,
1025 confidence: ((covered_pitches * 100) / total_pitches) as u8,
1026 rule_id: "diatonic-pitch-coverage".to_string(),
1027 evidence: evidence.clone(),
1028 })
1029 .collect()
1030}
1031
1032pub fn analyze_intervals(score: &Score) -> Vec<IntervalObservation> {
1034 let mut observations = Vec::new();
1035 for (part_index, part) in score.parts.iter().enumerate() {
1036 for (staff_index, staff) in part.staves.iter().enumerate() {
1037 for (measure_index, measure) in staff.measures.iter().enumerate() {
1038 for (voice_index, voice) in measure.voices.iter().enumerate() {
1039 let notes: Vec<_> = voice
1040 .iter()
1041 .enumerate()
1042 .filter_map(|(note_index, note)| {
1043 if note.is_rest {
1044 None
1045 } else {
1046 note.pitches.first().map(|pitch| (note_index, pitch))
1047 }
1048 })
1049 .collect();
1050 for pair in notes.windows(2) {
1051 let (from_index, from) = pair[0];
1052 let (to_index, to) = pair[1];
1053 let from_addr = NoteAddr {
1054 part: part_index,
1055 staff: staff_index,
1056 measure: measure_index,
1057 voice: voice_index,
1058 note: from_index,
1059 };
1060 let to_addr = NoteAddr {
1061 part: part_index,
1062 staff: staff_index,
1063 measure: measure_index,
1064 voice: voice_index,
1065 note: to_index,
1066 };
1067 observations.push(IntervalObservation {
1068 from: from_addr.clone(),
1069 to: to_addr.clone(),
1070 semitones: (to.to_midi() - from.to_midi()).unsigned_abs() as u8,
1071 diatonic_steps: diatonic_distance(from, to),
1072 rule_id: "adjacent-melodic-interval".to_string(),
1073 evidence: vec![from_addr, to_addr],
1074 });
1075 }
1076 }
1077 }
1078 }
1079 }
1080 observations
1081}
1082
1083fn diatonic_distance(from: &acorde_core::Pitch, to: &acorde_core::Pitch) -> i8 {
1084 let step_index = |step: &acorde_core::Step| match step {
1085 acorde_core::Step::C => 0i16,
1086 acorde_core::Step::D => 1,
1087 acorde_core::Step::E => 2,
1088 acorde_core::Step::F => 3,
1089 acorde_core::Step::G => 4,
1090 acorde_core::Step::A => 5,
1091 acorde_core::Step::B => 6,
1092 };
1093 (i16::from(to.octave) * 7 + step_index(&to.step)
1094 - (i16::from(from.octave) * 7 + step_index(&from.step))) as i8
1095}
1096
1097pub fn chord_name(chord: &ChordSymbol) -> String {
1099 let suffix = match chord.kind.as_str() {
1100 "major" => "",
1101 "minor" => "m",
1102 "dominant" => "7",
1103 "major-seventh" => "maj7",
1104 "minor-seventh" => "m7",
1105 "diminished" => "dim",
1106 "diminished-seventh" => "dim7",
1107 "half-diminished" => "ΓΈ7",
1108 "augmented" => "+",
1109 _ => chord.kind.as_str(),
1110 };
1111 let bass = chord
1112 .bass
1113 .as_deref()
1114 .map_or(String::new(), |bass| format!("/{bass}"));
1115 format!("{}{suffix}{bass}", chord.root)
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120 use super::*;
1121 use acorde_core::{Duration, Note, Pitch, Score, Step};
1122
1123 #[test]
1124 fn labels_chord_with_note_addresses_and_roman_numeral() {
1125 let mut score = Score::default();
1126 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
1127 voice.clear();
1128 for step in [Step::C, Step::E, Step::G] {
1129 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
1130 }
1131 let result = analyze_chords(&score);
1132 assert_eq!(result.schema_version, ANALYSIS_SCHEMA_VERSION);
1133 assert_eq!(result.chords.len(), 1);
1134 assert_eq!(result.intervals.len(), 2);
1135 assert!(!result.key_estimates.is_empty());
1136 assert_eq!(result.chords[0].address.note, 0);
1137 assert_eq!(result.chords[0].evidence.len(), 3);
1138 assert_eq!(result.chords[0].roman_numeral.as_deref(), Some("I"));
1139 assert_eq!(chord_name(&result.chords[0].chord), "C");
1140 }
1141
1142 #[test]
1143 fn analysis_has_stable_score_fingerprint() {
1144 let score = Score::default();
1145 let repeated = analyze_score(&score);
1146 assert_eq!(repeated.score_fingerprint, score_fingerprint(&score));
1147 assert_eq!(
1148 repeated.score_fingerprint,
1149 analyze_score(&score).score_fingerprint
1150 );
1151
1152 let mut changed = score.clone();
1153 changed.metadata.title = "Changed".to_string();
1154 assert_ne!(repeated.score_fingerprint, score_fingerprint(&changed));
1155 }
1156
1157 #[test]
1158 fn fingerprint_uses_fnv1a_byte_order() {
1159 assert_eq!(fnv1a64(b"hello"), 0xa430d84680aabd0b);
1160 }
1161
1162 #[test]
1163 fn cache_key_includes_schema_and_score_identity() {
1164 let result = analyze_score(&Score::default());
1165 assert!(result.cache_key().starts_with("analysis-v7-fnv1a64-"));
1166 assert_eq!(result.cache_key(), analysis_cache_key(&Score::default()));
1167 let mut changed = result.clone();
1168 changed.schema_version = 8;
1169 assert_ne!(result.cache_key(), changed.cache_key());
1170 }
1171
1172 #[test]
1173 fn analysis_result_rejects_a_different_score() {
1174 let score = Score::default();
1175 let result = analyze_score(&score);
1176 assert!(result.matches_score(&score));
1177 let mut changed = score.clone();
1178 changed.metadata.title = "Changed".to_string();
1179 assert!(!result.matches_score(&changed));
1180 }
1181
1182 #[test]
1183 fn interval_observation_preserves_direction_and_evidence() {
1184 let mut score = Score::default();
1185 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
1186 voice.clear();
1187 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
1188 voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
1189 let intervals = analyze_intervals(&score);
1190 assert_eq!(intervals.len(), 1);
1191 assert_eq!(intervals[0].semitones, 7);
1192 assert_eq!(intervals[0].diatonic_steps, 4);
1193 assert_eq!(intervals[0].evidence.len(), 2);
1194 }
1195
1196 #[test]
1197 fn does_not_invent_label_for_unknown_pitch_set() {
1198 let mut score = Score::default();
1199 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
1200 voice.clear();
1201 for step in [Step::C, Step::C] {
1202 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
1203 }
1204 assert!(analyze_chords(&score).chords.is_empty());
1205 }
1206
1207 #[test]
1208 fn preserves_relative_major_minor_key_ambiguity() {
1209 let mut score = Score::default();
1210 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
1211 voice.clear();
1212 for step in [
1213 Step::C,
1214 Step::D,
1215 Step::E,
1216 Step::F,
1217 Step::G,
1218 Step::A,
1219 Step::B,
1220 ] {
1221 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
1222 }
1223 let estimates = estimate_keys(&score);
1224 assert!(
1225 estimates
1226 .iter()
1227 .any(|estimate| estimate.key.display_name() == "C major")
1228 );
1229 assert!(
1230 estimates
1231 .iter()
1232 .any(|estimate| estimate.key.display_name() == "A minor")
1233 );
1234 assert!(estimates.iter().all(|estimate| estimate.confidence == 100));
1235 }
1236
1237 #[test]
1238 fn returns_no_key_for_empty_score() {
1239 assert!(estimate_keys(&Score::default()).is_empty());
1240 }
1241
1242 #[test]
1243 fn batch_and_stream_preserve_score_order() {
1244 let scores = vec![Score::default(), Score::default()];
1245 let batch = analyze_batch(&scores);
1246 let streamed: Vec<_> = analyze_stream(scores.clone()).collect();
1247 assert_eq!(batch, streamed);
1248 assert_eq!(batch.len(), scores.len());
1249 }
1250
1251 #[test]
1252 fn detects_authentic_cadence_from_adjacent_measures() {
1253 let mut score = Score::default();
1254 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
1255 voice.clear();
1256 for (step, octave) in [(Step::G, 3), (Step::B, 3), (Step::D, 4)] {
1257 voice.push(Note::new(Pitch::new(step, octave), Duration::Quarter));
1258 }
1259 let voice = &mut score.parts[0].staves[0].measures[1].voices[0];
1260 voice.clear();
1261 for step in [Step::C, Step::E, Step::G] {
1262 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
1263 }
1264 let result = analyze_score(&score);
1265 assert_eq!(result.cadence_candidates.len(), 1);
1266 assert_eq!(result.cadence_candidates[0].kind, CadenceKind::Authentic);
1267 assert_eq!(result.cadence_candidates[0].evidence.len(), 2);
1268 }
1269
1270 #[test]
1271 fn flags_parallel_octaves_between_aligned_voices() {
1272 let mut score = Score::default();
1273 let measure = &mut score.parts[0].staves[0].measures[0];
1274 measure.voices[0].clear();
1275 measure.voices[1].clear();
1276 for (upper, lower) in [(Step::C, Step::C), (Step::D, Step::D)] {
1277 measure.voices[0].push(Note::new(Pitch::new(upper, 4), Duration::Quarter));
1278 measure.voices[1].push(Note::new(Pitch::new(lower, 3), Duration::Quarter));
1279 }
1280 let observations = analyze_voice_leading(&score);
1281 assert_eq!(observations.len(), 1);
1282 assert!(observations[0].parallel_perfect);
1283 assert_eq!(observations[0].evidence.len(), 2);
1284 let diagnostics = analyze_satb(&score);
1285 assert_eq!(diagnostics.len(), 1);
1286 assert_eq!(diagnostics[0].kind, SatbDiagnosticKind::ParallelPerfect);
1287 assert_eq!(diagnostics[0].severity, SatbSeverity::Warning);
1288 }
1289
1290 #[test]
1291 fn reports_voice_crossing_as_error() {
1292 let mut score = Score::default();
1293 let measure = &mut score.parts[0].staves[0].measures[0];
1294 measure.voices[0].clear();
1295 measure.voices[1].clear();
1296 measure.voices[0].push(Note::new(Pitch::new(Step::C, 3), Duration::Quarter));
1297 measure.voices[1].push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
1298 let diagnostics = analyze_satb(&score);
1299 assert_eq!(diagnostics.len(), 1);
1300 assert_eq!(diagnostics[0].kind, SatbDiagnosticKind::VoiceCrossing);
1301 assert_eq!(diagnostics[0].severity, SatbSeverity::Error);
1302 }
1303
1304 #[test]
1305 fn finds_repeated_melodic_motif_with_source_spans() {
1306 let mut score = Score::default();
1307 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
1308 voice.clear();
1309 for step in [Step::C, Step::D, Step::E, Step::G, Step::A, Step::B] {
1310 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
1311 }
1312 let motifs = analyze_motifs(&score);
1313 assert_eq!(motifs.len(), 1);
1314 assert_eq!(motifs[0].signature, vec![2, 2]);
1315 assert_eq!(motifs[0].occurrences.len(), 2);
1316 assert_eq!(motifs[0].occurrences[0].evidence.len(), 3);
1317 }
1318
1319 #[test]
1320 fn reports_measure_ending_rest_as_phrase_boundary() {
1321 let mut score = Score::default();
1322 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
1323 voice.clear();
1324 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
1325 voice.push(Note::rest(Duration::Quarter));
1326 let boundaries = analyze_phrase_boundaries(&score);
1327 assert!(boundaries.iter().any(|boundary| {
1328 boundary.reason == PhraseBoundaryReason::RestTermination
1329 && boundary.address.measure == 0
1330 && boundary.address.note == 1
1331 }));
1332 }
1333
1334 #[test]
1335 fn benchmark_reports_perfect_scores_for_hand_verified_empty_fixture() {
1336 let score = Score::default();
1337 let cases = [BenchmarkCase {
1338 name: "empty-score",
1339 score: &score,
1340 expected: BenchmarkExpectation {
1341 phrase_boundaries: 4,
1342 ..BenchmarkExpectation::default()
1343 },
1344 }];
1345 let reports = run_benchmark(&cases);
1346 assert_eq!(reports.len(), 1);
1347 assert_eq!(reports[0].precision_percent, 100);
1348 assert_eq!(reports[0].recall_percent, 100);
1349 assert_eq!(reports[0].explanation_completeness_percent, 100);
1350 assert!(reports[0].failures.is_empty());
1351 }
1352
1353 #[test]
1354 fn benchmark_reports_category_level_failure_details() {
1355 let score = Score::default();
1356 let cases = [BenchmarkCase {
1357 name: "under-annotated-score",
1358 score: &score,
1359 expected: BenchmarkExpectation {
1360 phrase_boundaries: 5,
1361 ..BenchmarkExpectation::default()
1362 },
1363 }];
1364 let reports = run_benchmark(&cases);
1365 assert_eq!(
1366 reports[0].failures,
1367 vec![BenchmarkFailure {
1368 category: BenchmarkCategory::PhraseBoundaries,
1369 expected: 5,
1370 predicted: 4,
1371 missing: 1,
1372 excess: 0,
1373 }]
1374 );
1375 }
1376
1377 #[test]
1378 fn benchmark_suite_aggregates_case_status_and_metrics() {
1379 let score = Score::default();
1380 let cases = [
1381 BenchmarkCase {
1382 name: "passing",
1383 score: &score,
1384 expected: BenchmarkExpectation {
1385 phrase_boundaries: 4,
1386 ..BenchmarkExpectation::default()
1387 },
1388 },
1389 BenchmarkCase {
1390 name: "failing",
1391 score: &score,
1392 expected: BenchmarkExpectation {
1393 phrase_boundaries: 5,
1394 ..BenchmarkExpectation::default()
1395 },
1396 },
1397 ];
1398 let suite = run_benchmark_suite(&cases);
1399 assert_eq!(suite.case_count, 2);
1400 assert_eq!(suite.passed_case_count, 1);
1401 assert_eq!(suite.failed_case_count, 1);
1402 assert_eq!(suite.cases.len(), 2);
1403 assert_eq!(suite.precision_percent, 100);
1404 assert_eq!(suite.recall_percent, 90);
1405 assert_eq!(suite.explanation_completeness_percent, 100);
1406 }
1407
1408 struct TestPass(&'static str);
1409
1410 impl AnalysisPass for TestPass {
1411 fn id(&self) -> &str {
1412 self.0
1413 }
1414
1415 fn run(&self, _score: &Score) -> serde_json::Value {
1416 serde_json::json!({ "pass": self.0 })
1417 }
1418 }
1419
1420 #[test]
1421 fn extension_passes_run_in_stable_id_order() {
1422 let score = Score::default();
1423 let beta = TestPass("beta");
1424 let alpha = TestPass("alpha");
1425 let results = run_analysis_passes(&score, &[&beta, &alpha]).unwrap();
1426 assert_eq!(
1427 results
1428 .iter()
1429 .map(|result| result.pass_id.as_str())
1430 .collect::<Vec<_>>(),
1431 ["alpha", "beta"]
1432 );
1433 assert_eq!(results[0].output["pass"], "alpha");
1434 }
1435
1436 #[test]
1437 fn extension_passes_reject_empty_and_duplicate_ids() {
1438 let score = Score::default();
1439 let empty = TestPass("");
1440 assert_eq!(
1441 run_analysis_passes(&score, &[&empty]),
1442 Err(AnalysisPassError::EmptyId)
1443 );
1444 let first = TestPass("same");
1445 let second = TestPass("same");
1446 assert_eq!(
1447 run_analysis_passes(&score, &[&first, &second]),
1448 Err(AnalysisPassError::DuplicateId("same".to_string()))
1449 );
1450 }
1451}