1use acorde_core::{
4 ChangeHint, ChordSymbol, KeySignature, NoteAddr, Pitch, Score, detect_chord, roman_numeral,
5 roman_numeral_figured,
6};
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, HashMap, VecDeque};
9use thiserror::Error;
10
11pub const ANALYSIS_SCHEMA_VERSION: u32 = 13;
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct ChordLabel {
17 pub address: NoteAddr,
18 pub chord: ChordSymbol,
19 #[serde(default)]
21 pub name: String,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub roman_numeral: Option<String>,
24 pub confidence: u8,
25 pub rule_id: String,
26 pub evidence: Vec<NoteAddr>,
27}
28
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct AnalysisResult {
32 pub schema_version: u32,
33 #[serde(default)]
35 pub score_fingerprint: String,
36 pub chords: Vec<ChordLabel>,
37 pub intervals: Vec<IntervalObservation>,
38 #[serde(default)]
39 pub key_estimates: Vec<KeyEstimate>,
40 #[serde(default)]
41 pub cadence_candidates: Vec<CadenceCandidate>,
42 #[serde(default)]
43 pub voice_leading: Vec<VoiceLeadingObservation>,
44 #[serde(default)]
45 pub satb_diagnostics: Vec<SatbDiagnostic>,
46 #[serde(default)]
47 pub motifs: Vec<MotifPattern>,
48 #[serde(default)]
49 pub phrase_boundaries: Vec<PhraseBoundary>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54pub enum AnalysisCategory {
55 Chords,
56 Intervals,
57 KeyEstimates,
58 CadenceCandidates,
59 VoiceLeading,
60 SatbDiagnostics,
61 Motifs,
62 PhraseBoundaries,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct AnalysisRegion {
68 pub part: usize,
69 pub staff: usize,
70 pub start_measure: usize,
71 pub end_measure: usize,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct AnalysisRefreshPlan {
77 pub local_categories: Vec<AnalysisCategory>,
78 pub global_categories: Vec<AnalysisCategory>,
79 pub region: Option<AnalysisRegion>,
80 pub context_before: usize,
81 pub context_after: usize,
82}
83
84pub fn analysis_refresh_plan(hint: &ChangeHint) -> AnalysisRefreshPlan {
86 let all = all_analysis_categories();
87 if !hint.layout_dirty && !hint.playback_dirty {
88 return AnalysisRefreshPlan {
89 local_categories: Vec::new(),
90 global_categories: Vec::new(),
91 region: None,
92 context_before: 0,
93 context_after: 0,
94 };
95 }
96 match &hint.scope {
97 acorde_core::ChangeScope::Measures {
98 part,
99 staff,
100 start,
101 end,
102 } => AnalysisRefreshPlan {
103 local_categories: vec![
104 AnalysisCategory::Chords,
105 AnalysisCategory::Intervals,
106 AnalysisCategory::CadenceCandidates,
107 AnalysisCategory::VoiceLeading,
108 AnalysisCategory::SatbDiagnostics,
109 AnalysisCategory::PhraseBoundaries,
110 ],
111 global_categories: vec![AnalysisCategory::KeyEstimates, AnalysisCategory::Motifs],
112 region: Some(AnalysisRegion {
113 part: *part,
114 staff: *staff,
115 start_measure: *start,
116 end_measure: *end,
117 }),
118 context_before: 1,
119 context_after: 1,
120 },
121 acorde_core::ChangeScope::Part(_) | acorde_core::ChangeScope::Global => {
122 AnalysisRefreshPlan {
123 local_categories: Vec::new(),
124 global_categories: all,
125 region: None,
126 context_before: 0,
127 context_after: 0,
128 }
129 }
130 }
131}
132
133fn all_analysis_categories() -> Vec<AnalysisCategory> {
134 vec![
135 AnalysisCategory::Chords,
136 AnalysisCategory::Intervals,
137 AnalysisCategory::KeyEstimates,
138 AnalysisCategory::CadenceCandidates,
139 AnalysisCategory::VoiceLeading,
140 AnalysisCategory::SatbDiagnostics,
141 AnalysisCategory::Motifs,
142 AnalysisCategory::PhraseBoundaries,
143 ]
144}
145
146pub fn affected_categories_for_change_hint(hint: &ChangeHint) -> Vec<AnalysisCategory> {
152 let plan = analysis_refresh_plan(hint);
153 let selected: Vec<_> = plan
154 .local_categories
155 .into_iter()
156 .chain(plan.global_categories)
157 .collect();
158 all_analysis_categories()
159 .into_iter()
160 .filter(|category| selected.contains(category))
161 .collect()
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166pub struct AnalysisDiff {
167 pub previous_score_fingerprint: String,
168 pub current_score_fingerprint: String,
169 pub schema_changed: bool,
170 pub changed_categories: Vec<AnalysisCategory>,
171}
172
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct AnalysisEditResult {
176 pub analysis: AnalysisResult,
177 pub diff: AnalysisDiff,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub struct AnalysisProvenance {
183 pub category: AnalysisCategory,
184 pub rule_id: String,
185 pub confidence: u8,
186 pub evidence: Vec<NoteAddr>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub label: Option<String>,
190}
191
192pub fn analysis_provenance(
194 analysis: &AnalysisResult,
195 address: &NoteAddr,
196) -> Vec<AnalysisProvenance> {
197 let mut findings = Vec::new();
198 let mut add = |category, rule_id: &str, confidence, evidence: &[NoteAddr]| {
199 if evidence.iter().any(|item| item == address) {
200 findings.push(AnalysisProvenance {
201 category,
202 rule_id: rule_id.to_string(),
203 confidence,
204 evidence: evidence.to_vec(),
205 label: None,
206 });
207 }
208 };
209 for item in &analysis.chords {
210 add(
211 AnalysisCategory::Chords,
212 &item.rule_id,
213 item.confidence,
214 &item.evidence,
215 );
216 }
217 for item in &analysis.intervals {
218 add(
219 AnalysisCategory::Intervals,
220 &item.rule_id,
221 100,
222 &item.evidence,
223 );
224 }
225 for item in &analysis.key_estimates {
226 add(
227 AnalysisCategory::KeyEstimates,
228 &item.rule_id,
229 item.confidence,
230 &item.evidence,
231 );
232 }
233 for item in &analysis.cadence_candidates {
234 add(
235 AnalysisCategory::CadenceCandidates,
236 &item.rule_id,
237 item.confidence,
238 &item.evidence,
239 );
240 }
241 for item in &analysis.voice_leading {
242 add(
243 AnalysisCategory::VoiceLeading,
244 &item.rule_id,
245 item.confidence,
246 &item.evidence,
247 );
248 }
249 for item in &analysis.satb_diagnostics {
250 add(
251 AnalysisCategory::SatbDiagnostics,
252 &item.rule_id,
253 item.confidence,
254 &item.evidence,
255 );
256 }
257 for item in &analysis.motifs {
258 for occurrence in &item.occurrences {
259 add(
260 AnalysisCategory::Motifs,
261 &item.rule_id,
262 item.confidence,
263 &occurrence.evidence,
264 );
265 }
266 }
267 for item in &analysis.phrase_boundaries {
268 add(
269 AnalysisCategory::PhraseBoundaries,
270 &item.rule_id,
271 item.confidence,
272 &item.evidence,
273 );
274 }
275 for finding in &mut findings {
276 if finding.category == AnalysisCategory::Chords {
277 finding.label = analysis
278 .chords
279 .iter()
280 .find(|item| item.rule_id == finding.rule_id && item.evidence == finding.evidence)
281 .map(|item| item.name.clone());
282 }
283 }
284 findings.sort_by_key(|item| {
285 (
286 analysis_category_rank(item.category),
287 item.rule_id.clone(),
288 item.evidence
289 .iter()
290 .map(|address| {
291 (
292 address.part,
293 address.staff,
294 address.measure,
295 address.voice,
296 address.note,
297 )
298 })
299 .collect::<Vec<_>>(),
300 )
301 });
302 findings
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307pub struct AnalysisChangeExplanation {
308 pub diff: AnalysisDiff,
309 pub previous: Vec<AnalysisProvenance>,
310 pub current: Vec<AnalysisProvenance>,
311}
312
313pub fn explain_analysis_change(
315 previous: &AnalysisResult,
316 current: &AnalysisResult,
317 address: &NoteAddr,
318) -> AnalysisChangeExplanation {
319 AnalysisChangeExplanation {
320 diff: diff_analysis(previous, current),
321 previous: analysis_provenance(previous, address),
322 current: analysis_provenance(current, address),
323 }
324}
325
326fn analysis_category_rank(category: AnalysisCategory) -> u8 {
327 match category {
328 AnalysisCategory::Chords => 0,
329 AnalysisCategory::Intervals => 1,
330 AnalysisCategory::KeyEstimates => 2,
331 AnalysisCategory::CadenceCandidates => 3,
332 AnalysisCategory::VoiceLeading => 4,
333 AnalysisCategory::SatbDiagnostics => 5,
334 AnalysisCategory::Motifs => 6,
335 AnalysisCategory::PhraseBoundaries => 7,
336 }
337}
338
339impl AnalysisDiff {
340 pub fn is_empty(&self) -> bool {
342 !self.schema_changed && self.changed_categories.is_empty()
343 }
344}
345
346pub fn diff_analysis(previous: &AnalysisResult, current: &AnalysisResult) -> AnalysisDiff {
348 let mut changed_categories = Vec::new();
349 if previous.chords != current.chords {
350 changed_categories.push(AnalysisCategory::Chords);
351 }
352 if previous.intervals != current.intervals {
353 changed_categories.push(AnalysisCategory::Intervals);
354 }
355 if previous.key_estimates != current.key_estimates {
356 changed_categories.push(AnalysisCategory::KeyEstimates);
357 }
358 if previous.cadence_candidates != current.cadence_candidates {
359 changed_categories.push(AnalysisCategory::CadenceCandidates);
360 }
361 if previous.voice_leading != current.voice_leading {
362 changed_categories.push(AnalysisCategory::VoiceLeading);
363 }
364 if previous.satb_diagnostics != current.satb_diagnostics {
365 changed_categories.push(AnalysisCategory::SatbDiagnostics);
366 }
367 if previous.motifs != current.motifs {
368 changed_categories.push(AnalysisCategory::Motifs);
369 }
370 if previous.phrase_boundaries != current.phrase_boundaries {
371 changed_categories.push(AnalysisCategory::PhraseBoundaries);
372 }
373 AnalysisDiff {
374 previous_score_fingerprint: previous.score_fingerprint.clone(),
375 current_score_fingerprint: current.score_fingerprint.clone(),
376 schema_changed: previous.schema_version != current.schema_version,
377 changed_categories,
378 }
379}
380
381impl AnalysisResult {
382 pub fn cache_key(&self) -> String {
384 format!(
385 "analysis-v{}-{}",
386 self.schema_version, self.score_fingerprint
387 )
388 }
389
390 pub fn matches_score(&self, score: &Score) -> bool {
392 self.score_fingerprint == score_fingerprint(score)
393 }
394}
395
396pub trait AnalysisPass {
398 fn id(&self) -> &str;
400
401 fn run(&self, score: &Score) -> serde_json::Value;
403}
404
405#[derive(Debug, Error, Clone, PartialEq, Eq)]
407pub enum AnalysisPassError {
408 #[error("analysis pass ID must not be empty")]
409 EmptyId,
410 #[error("duplicate analysis pass ID: {0}")]
411 DuplicateId(String),
412}
413
414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
416pub struct AnalysisPassResult {
417 pub pass_id: String,
418 pub output: serde_json::Value,
419}
420
421pub fn run_analysis_passes(
423 score: &Score,
424 passes: &[&dyn AnalysisPass],
425) -> Result<Vec<AnalysisPassResult>, AnalysisPassError> {
426 let mut ordered: Vec<&dyn AnalysisPass> = passes.to_vec();
427 for pass in &ordered {
428 if pass.id().is_empty() {
429 return Err(AnalysisPassError::EmptyId);
430 }
431 }
432 ordered.sort_by(|left, right| left.id().cmp(right.id()));
433 for pair in ordered.windows(2) {
434 if pair[0].id() == pair[1].id() {
435 return Err(AnalysisPassError::DuplicateId(pair[0].id().to_string()));
436 }
437 }
438 Ok(ordered
439 .into_iter()
440 .map(|pass| AnalysisPassResult {
441 pass_id: pass.id().to_string(),
442 output: pass.run(score),
443 })
444 .collect())
445}
446
447#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
449pub struct KeyEstimate {
450 pub key: KeySignature,
451 pub covered_pitches: usize,
452 pub total_pitches: usize,
453 #[serde(default)]
455 pub weighted_covered_beats: f64,
456 #[serde(default)]
458 pub total_duration_beats: f64,
459 pub confidence: u8,
460 pub rule_id: String,
461 pub evidence: Vec<NoteAddr>,
462}
463
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
466pub struct CadenceCandidate {
467 pub from: NoteAddr,
468 pub to: NoteAddr,
469 pub kind: CadenceKind,
470 pub confidence: u8,
471 pub rule_id: String,
472 pub evidence: Vec<NoteAddr>,
473}
474
475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
477pub enum CadenceKind {
478 Authentic,
479 Plagal,
480 Deceptive,
481 Half,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
486pub struct VoiceLeadingObservation {
487 pub upper: NoteAddr,
488 pub lower: NoteAddr,
489 pub upper_motion: i16,
490 pub lower_motion: i16,
491 #[serde(default)]
493 pub upper_motion_cents: i32,
494 #[serde(default)]
496 pub lower_motion_cents: i32,
497 pub parallel_perfect: bool,
498 pub confidence: u8,
499 pub rule_id: String,
500 pub evidence: Vec<NoteAddr>,
501}
502
503#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
505pub struct SatbDiagnostic {
506 pub upper: NoteAddr,
507 pub lower: NoteAddr,
508 pub kind: SatbDiagnosticKind,
509 pub severity: SatbSeverity,
510 pub confidence: u8,
511 pub rule_id: String,
512 pub evidence: Vec<NoteAddr>,
513}
514
515#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
517pub enum SatbDiagnosticKind {
518 VoiceCrossing,
519 WideSpacing,
520 ParallelPerfect,
521}
522
523#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
525pub enum SatbSeverity {
526 Error,
527 Warning,
528}
529
530#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
532pub struct MotifPattern {
533 pub signature: Vec<i8>,
534 pub occurrences: Vec<MotifOccurrence>,
535 pub confidence: u8,
536 pub rule_id: String,
537}
538
539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
541pub struct MotifOccurrence {
542 pub start: NoteAddr,
543 pub end: NoteAddr,
544 pub evidence: Vec<NoteAddr>,
545}
546
547#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
549pub struct PhraseBoundary {
550 pub address: NoteAddr,
551 pub reason: PhraseBoundaryReason,
552 pub confidence: u8,
553 pub rule_id: String,
554 pub evidence: Vec<NoteAddr>,
555}
556
557#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
559pub enum PhraseBoundaryReason {
560 RestTermination,
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
565pub struct BenchmarkExpectation {
566 pub chords: usize,
567 pub intervals: usize,
568 pub key_estimates: usize,
569 pub cadence_candidates: usize,
570 pub voice_leading: usize,
571 pub satb_diagnostics: usize,
572 pub motifs: usize,
573 pub phrase_boundaries: usize,
574}
575
576#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
578pub struct AnalysisCounts {
579 pub chords: usize,
580 pub intervals: usize,
581 pub key_estimates: usize,
582 pub cadence_candidates: usize,
583 pub voice_leading: usize,
584 pub satb_diagnostics: usize,
585 pub motifs: usize,
586 pub phrase_boundaries: usize,
587}
588
589#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
591pub enum BenchmarkCategory {
592 Chords,
593 Intervals,
594 KeyEstimates,
595 CadenceCandidates,
596 VoiceLeading,
597 SatbDiagnostics,
598 Motifs,
599 PhraseBoundaries,
600}
601
602#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
604pub struct BenchmarkFailure {
605 pub category: BenchmarkCategory,
606 pub expected: usize,
607 pub predicted: usize,
608 pub missing: usize,
609 pub excess: usize,
610}
611
612#[derive(Debug, Clone)]
614pub struct BenchmarkCase<'a> {
615 pub name: &'a str,
616 pub score: &'a Score,
617 pub expected: BenchmarkExpectation,
618}
619
620#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
622pub struct BenchmarkCaseReport {
623 pub name: String,
624 pub predicted: AnalysisCounts,
625 pub expected: BenchmarkExpectation,
626 pub precision_percent: u8,
627 pub recall_percent: u8,
628 pub explanation_completeness_percent: u8,
629 pub failures: Vec<BenchmarkFailure>,
630}
631
632#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
634pub struct BenchmarkSuiteReport {
635 pub cases: Vec<BenchmarkCaseReport>,
636 pub case_count: usize,
637 pub passed_case_count: usize,
638 pub failed_case_count: usize,
639 pub precision_percent: u8,
640 pub recall_percent: u8,
641 pub explanation_completeness_percent: u8,
642}
643
644#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
646pub struct IntervalObservation {
647 pub from: NoteAddr,
648 pub to: NoteAddr,
649 pub semitones: u8,
650 #[serde(default)]
652 pub cents: i32,
653 pub diatonic_steps: i8,
654 pub rule_id: String,
655 pub evidence: Vec<NoteAddr>,
656}
657
658pub fn analyze_score(score: &Score) -> AnalysisResult {
660 let chords = analyze_chords_only(score);
661 let intervals = analyze_intervals(score);
662 let key_estimates = estimate_keys(score);
663 let cadence_candidates = analyze_cadences(&chords);
664 let voice_leading = analyze_voice_leading(score);
665 let satb_diagnostics = analyze_satb_in_region(score, &voice_leading, None);
666 let motifs = analyze_motifs(score);
667 let phrase_boundaries = analyze_phrase_boundaries(score);
668 AnalysisResult {
669 schema_version: ANALYSIS_SCHEMA_VERSION,
670 score_fingerprint: score_fingerprint(score),
671 chords,
672 intervals,
673 key_estimates,
674 cadence_candidates,
675 voice_leading,
676 satb_diagnostics,
677 motifs,
678 phrase_boundaries,
679 }
680}
681
682fn analyze_chords_only(score: &Score) -> Vec<ChordLabel> {
683 analyze_chords_in_region(score, None)
684}
685
686#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
688pub struct VerticalSonority {
689 pub measure: usize,
690 pub beat: f64,
691 pub label: ChordLabel,
692}
693
694struct SoundingPitchEvent {
695 start: f64,
696 end: f64,
697 address: NoteAddr,
698 pitches: Vec<Pitch>,
699}
700
701pub fn analyze_vertical_sonorities(score: &Score) -> Vec<VerticalSonority> {
708 analyze_vertical_sonorities_with_key(score, None)
709}
710
711pub fn analyze_vertical_sonorities_in_key(
716 score: &Score,
717 key: &KeySignature,
718) -> Vec<VerticalSonority> {
719 analyze_vertical_sonorities_with_key(score, Some(key))
720}
721
722fn analyze_vertical_sonorities_with_key(
723 score: &Score,
724 key_override: Option<&KeySignature>,
725) -> Vec<VerticalSonority> {
726 const EPSILON: f64 = 1e-7;
727 let measure_count = score
728 .parts
729 .iter()
730 .flat_map(|part| part.staves.iter())
731 .map(|staff| staff.measures.len())
732 .max()
733 .unwrap_or(0);
734 let mut key = key_override
735 .cloned()
736 .unwrap_or_else(|| score.settings.key_signature.clone());
737 let mut result = Vec::new();
738
739 for measure_index in 0..measure_count {
740 if key_override.is_none()
741 && let Some(next_key) = score
742 .parts
743 .iter()
744 .flat_map(|part| part.staves.iter())
745 .filter_map(|staff| staff.measures.get(measure_index))
746 .find_map(|measure| measure.key_sig.as_ref())
747 {
748 key = next_key.clone();
749 }
750 let mut sounding = Vec::new();
751 let mut authored = Vec::new();
752 let mut onsets = Vec::new();
753 for (part_index, part) in score.parts.iter().enumerate() {
754 for (staff_index, staff) in part.staves.iter().enumerate() {
755 let Some(measure) = staff.measures.get(measure_index) else {
756 continue;
757 };
758 for (voice_index, voice) in measure.voices.iter().enumerate() {
759 let mut beat = 0.0;
760 for (note_index, note) in voice.iter().enumerate() {
761 let duration = note.beats();
762 let address = NoteAddr {
763 part: part_index,
764 staff: staff_index,
765 measure: measure_index,
766 voice: voice_index,
767 note: note_index,
768 };
769 if let Some(chord) = ¬e.chord_symbol {
770 authored.push((
771 beat,
772 ChordLabel {
773 address: address.clone(),
774 chord: chord.clone(),
775 name: chord_name(chord),
776 roman_numeral: roman_numeral_figured(chord, &key),
777 confidence: 100,
778 rule_id: "authored-chord-symbol".to_string(),
779 evidence: vec![address.clone()],
780 },
781 ));
782 onsets.push(beat);
783 }
784 if !note.is_rest
785 && !note.is_unpitched
786 && !note.pitches.is_empty()
787 && duration > EPSILON
788 {
789 sounding.push(SoundingPitchEvent {
790 start: beat,
791 end: beat + duration,
792 address,
793 pitches: note.pitches.clone(),
794 });
795 onsets.push(beat);
796 }
797 beat += duration;
798 }
799 }
800 }
801 }
802 onsets.sort_by(f64::total_cmp);
803 onsets.dedup_by(|left, right| (*left - *right).abs() < EPSILON);
804 for beat in onsets {
805 let authored_here: Vec<_> = authored
806 .iter()
807 .filter(|(onset, _)| (*onset - beat).abs() < EPSILON)
808 .collect();
809 if !authored_here.is_empty() {
810 result.extend(
811 authored_here
812 .into_iter()
813 .map(|(_, label)| VerticalSonority {
814 measure: measure_index,
815 beat,
816 label: label.clone(),
817 }),
818 );
819 continue;
820 }
821 let active: Vec<_> = sounding
822 .iter()
823 .filter(|event| event.start <= beat + EPSILON && event.end > beat + EPSILON)
824 .collect();
825 let pitches: Vec<_> = active
826 .iter()
827 .flat_map(|event| event.pitches.iter().cloned())
828 .collect();
829 let Some(chord) = detect_chord(&pitches) else {
830 continue;
831 };
832 let Some(anchor) = active
833 .iter()
834 .find(|event| (event.start - beat).abs() < EPSILON)
835 else {
836 continue;
837 };
838 result.push(VerticalSonority {
839 measure: measure_index,
840 beat,
841 label: ChordLabel {
842 address: anchor.address.clone(),
843 name: chord_name(&chord),
844 roman_numeral: roman_numeral_figured(&chord, &key),
845 chord,
846 confidence: 70,
847 rule_id: "vertical-sonority-template".to_string(),
848 evidence: active.iter().map(|event| event.address.clone()).collect(),
849 },
850 });
851 }
852 }
853 result
854}
855
856pub fn analyze_chords_in_region(score: &Score, region: Option<&AnalysisRegion>) -> Vec<ChordLabel> {
858 let mut chords = Vec::new();
859 for (part_index, part) in score.parts.iter().enumerate() {
860 for (staff_index, staff) in part.staves.iter().enumerate() {
861 for (measure_index, measure) in staff.measures.iter().enumerate() {
862 if let Some(region) = region
863 && (part_index != region.part
864 || staff_index != region.staff
865 || measure_index < region.start_measure
866 || measure_index >= region.end_measure)
867 {
868 continue;
869 }
870 let key = measure
871 .key_sig
872 .as_ref()
873 .unwrap_or(&score.settings.key_signature);
874 for (voice_index, voice) in measure.voices.iter().enumerate() {
875 let authored: Vec<_> = voice
876 .iter()
877 .enumerate()
878 .filter_map(|(note_index, note)| {
879 note.chord_symbol.as_ref().map(|chord| (note_index, chord))
880 })
881 .collect();
882 if !authored.is_empty() {
883 for (note_index, chord) in authored {
884 let address = NoteAddr {
885 part: part_index,
886 staff: staff_index,
887 measure: measure_index,
888 voice: voice_index,
889 note: note_index,
890 };
891 chords.push(ChordLabel {
892 address: address.clone(),
893 name: chord_name(chord),
894 roman_numeral: roman_numeral(chord, key),
895 chord: chord.clone(),
896 confidence: 100,
897 rule_id: "authored-chord-symbol".to_string(),
898 evidence: vec![address],
899 });
900 }
901 continue;
902 }
903 let pitched: Vec<_> = voice
904 .iter()
905 .enumerate()
906 .filter(|(_, note)| !note.is_rest && !note.pitches.is_empty())
907 .collect();
908 let pitches: Vec<_> = pitched
909 .iter()
910 .flat_map(|(_, note)| note.pitches.iter().cloned())
911 .collect();
912 let Some(chord) = detect_chord(&pitches) else {
913 continue;
914 };
915 let evidence = pitched
916 .iter()
917 .map(|(note_index, _)| NoteAddr {
918 part: part_index,
919 staff: staff_index,
920 measure: measure_index,
921 voice: voice_index,
922 note: *note_index,
923 })
924 .collect();
925 chords.push(ChordLabel {
926 address: NoteAddr {
927 part: part_index,
928 staff: staff_index,
929 measure: measure_index,
930 voice: voice_index,
931 note: pitched[0].0,
932 },
933 name: chord_name(&chord),
934 roman_numeral: roman_numeral(&chord, key),
935 chord,
936 confidence: 100,
937 rule_id: "pitch-class-template".to_string(),
938 evidence,
939 });
940 }
941 }
942 }
943 }
944 chords
945}
946
947pub fn analyze_selected_categories(
953 score: &Score,
954 previous: &AnalysisResult,
955 categories: &[AnalysisCategory],
956) -> AnalysisResult {
957 analyze_selected_categories_in_region(score, previous, categories, None)
958}
959
960pub fn analyze_selected_categories_in_region(
962 score: &Score,
963 previous: &AnalysisResult,
964 categories: &[AnalysisCategory],
965 region: Option<&AnalysisRegion>,
966) -> AnalysisResult {
967 let selected = |category| categories.contains(&category);
968 let chords =
969 if selected(AnalysisCategory::Chords) || selected(AnalysisCategory::CadenceCandidates) {
970 let refreshed = analyze_chords_in_region(score, region);
971 merge_chord_region(&previous.chords, refreshed, region)
972 } else {
973 previous.chords.clone()
974 };
975 let intervals = if selected(AnalysisCategory::Intervals) {
976 merge_interval_region(
977 &previous.intervals,
978 analyze_intervals_in_region(score, region),
979 region,
980 )
981 } else {
982 previous.intervals.clone()
983 };
984 let key_estimates = if selected(AnalysisCategory::KeyEstimates) {
985 estimate_keys(score)
986 } else {
987 previous.key_estimates.clone()
988 };
989 let cadence_candidates = if selected(AnalysisCategory::CadenceCandidates) {
990 analyze_cadences(&chords)
991 } else {
992 previous.cadence_candidates.clone()
993 };
994 let voice_leading = if selected(AnalysisCategory::VoiceLeading)
995 || selected(AnalysisCategory::SatbDiagnostics)
996 {
997 merge_voice_leading_region(
998 &previous.voice_leading,
999 analyze_voice_leading_in_region(score, region),
1000 region,
1001 )
1002 } else {
1003 previous.voice_leading.clone()
1004 };
1005 let satb_diagnostics = if selected(AnalysisCategory::SatbDiagnostics) {
1006 merge_satb_region(
1007 &previous.satb_diagnostics,
1008 analyze_satb_in_region(score, &voice_leading, region),
1009 region,
1010 )
1011 } else {
1012 previous.satb_diagnostics.clone()
1013 };
1014 let motifs = if selected(AnalysisCategory::Motifs) {
1015 analyze_motifs(score)
1016 } else {
1017 previous.motifs.clone()
1018 };
1019 let phrase_boundaries = if selected(AnalysisCategory::PhraseBoundaries) {
1020 analyze_phrase_boundaries(score)
1021 } else {
1022 previous.phrase_boundaries.clone()
1023 };
1024 AnalysisResult {
1025 schema_version: ANALYSIS_SCHEMA_VERSION,
1026 score_fingerprint: score_fingerprint(score),
1027 chords,
1028 intervals,
1029 key_estimates,
1030 cadence_candidates,
1031 voice_leading,
1032 satb_diagnostics,
1033 motifs,
1034 phrase_boundaries,
1035 }
1036}
1037
1038fn merge_interval_region(
1039 previous: &[IntervalObservation],
1040 refreshed: Vec<IntervalObservation>,
1041 region: Option<&AnalysisRegion>,
1042) -> Vec<IntervalObservation> {
1043 let Some(region) = region else {
1044 return refreshed;
1045 };
1046 let mut merged: Vec<_> = previous
1047 .iter()
1048 .filter(|item| {
1049 !analysis_region_contains(region, &item.from)
1050 && !analysis_region_contains(region, &item.to)
1051 })
1052 .cloned()
1053 .collect();
1054 merged.extend(refreshed);
1055 merged.sort_by_key(|item| {
1056 (
1057 item.from.part,
1058 item.from.staff,
1059 item.from.measure,
1060 item.from.voice,
1061 item.from.note,
1062 item.to.measure,
1063 item.to.note,
1064 )
1065 });
1066 merged
1067}
1068
1069fn merge_voice_leading_region(
1070 previous: &[VoiceLeadingObservation],
1071 refreshed: Vec<VoiceLeadingObservation>,
1072 region: Option<&AnalysisRegion>,
1073) -> Vec<VoiceLeadingObservation> {
1074 let Some(region) = region else {
1075 return refreshed;
1076 };
1077 let mut merged: Vec<_> = previous
1078 .iter()
1079 .filter(|item| {
1080 !analysis_region_contains(region, &item.upper)
1081 && !analysis_region_contains(region, &item.lower)
1082 })
1083 .cloned()
1084 .collect();
1085 merged.extend(refreshed);
1086 merged.sort_by_key(|item| {
1087 (
1088 item.upper.part,
1089 item.upper.staff,
1090 item.upper.measure,
1091 item.upper.voice,
1092 item.upper.note,
1093 item.lower.voice,
1094 )
1095 });
1096 merged
1097}
1098
1099fn merge_chord_region(
1100 previous: &[ChordLabel],
1101 refreshed: Vec<ChordLabel>,
1102 region: Option<&AnalysisRegion>,
1103) -> Vec<ChordLabel> {
1104 let Some(region) = region else {
1105 return refreshed;
1106 };
1107 let mut merged: Vec<_> = previous
1108 .iter()
1109 .filter(|label| !analysis_region_contains(region, &label.address))
1110 .cloned()
1111 .collect();
1112 merged.extend(refreshed);
1113 merged.sort_by_key(|label| {
1114 (
1115 label.address.part,
1116 label.address.staff,
1117 label.address.measure,
1118 label.address.voice,
1119 label.address.note,
1120 )
1121 });
1122 merged
1123}
1124
1125fn merge_satb_region(
1126 previous: &[SatbDiagnostic],
1127 refreshed: Vec<SatbDiagnostic>,
1128 region: Option<&AnalysisRegion>,
1129) -> Vec<SatbDiagnostic> {
1130 let Some(region) = region else {
1131 return refreshed;
1132 };
1133 let mut merged: Vec<_> = previous
1134 .iter()
1135 .filter(|diagnostic| {
1136 !diagnostic
1137 .evidence
1138 .iter()
1139 .any(|address| analysis_region_contains(region, address))
1140 })
1141 .cloned()
1142 .collect();
1143 merged.extend(refreshed);
1144 merged.sort_by_key(|diagnostic| {
1145 (
1146 diagnostic.upper.part,
1147 diagnostic.upper.staff,
1148 diagnostic.upper.measure,
1149 diagnostic.upper.voice,
1150 diagnostic.upper.note,
1151 diagnostic.lower.voice,
1152 diagnostic.rule_id.clone(),
1153 )
1154 });
1155 merged
1156}
1157
1158fn analysis_region_contains(region: &AnalysisRegion, address: &NoteAddr) -> bool {
1159 address.part == region.part
1160 && address.staff == region.staff
1161 && (region.start_measure..region.end_measure).contains(&address.measure)
1162}
1163
1164pub fn score_fingerprint(score: &Score) -> String {
1166 let mut value = serde_json::to_value(score).unwrap_or_default();
1167 remove_generated_ids(&mut value);
1168 let bytes = serde_json::to_vec(&value).unwrap_or_default();
1169 let hash = fnv1a64(&bytes);
1170 format!("fnv1a64-{hash:016x}")
1171}
1172
1173fn remove_generated_ids(value: &mut serde_json::Value) {
1174 match value {
1175 serde_json::Value::Object(object) => {
1176 object.remove("id");
1177 for child in object.values_mut() {
1178 remove_generated_ids(child);
1179 }
1180 }
1181 serde_json::Value::Array(values) => {
1182 for child in values {
1183 remove_generated_ids(child);
1184 }
1185 }
1186 _ => {}
1187 }
1188}
1189
1190pub fn analysis_cache_key(score: &Score) -> String {
1192 format!(
1193 "analysis-v{}-{}",
1194 ANALYSIS_SCHEMA_VERSION,
1195 score_fingerprint(score)
1196 )
1197}
1198
1199#[derive(Debug, Error, Clone, PartialEq, Eq)]
1201pub enum AnalysisCacheError {
1202 #[error("analysis cache capacity must be greater than zero")]
1203 ZeroCapacity,
1204}
1205
1206#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1208pub struct AnalysisCacheStats {
1209 pub hits: usize,
1210 pub misses: usize,
1211}
1212
1213#[derive(Debug, Clone)]
1220pub struct AnalysisCache {
1221 capacity: usize,
1222 entries: BTreeMap<String, AnalysisResult>,
1223 insertion_order: VecDeque<String>,
1224 stats: AnalysisCacheStats,
1225}
1226
1227impl Default for AnalysisCache {
1228 fn default() -> Self {
1229 Self {
1230 capacity: 16,
1231 entries: BTreeMap::new(),
1232 insertion_order: VecDeque::new(),
1233 stats: AnalysisCacheStats::default(),
1234 }
1235 }
1236}
1237
1238impl AnalysisCache {
1239 pub fn with_capacity(capacity: usize) -> Result<Self, AnalysisCacheError> {
1241 if capacity == 0 {
1242 return Err(AnalysisCacheError::ZeroCapacity);
1243 }
1244 Ok(Self {
1245 capacity,
1246 entries: BTreeMap::new(),
1247 insertion_order: VecDeque::new(),
1248 stats: AnalysisCacheStats::default(),
1249 })
1250 }
1251
1252 pub fn capacity(&self) -> usize {
1254 self.capacity
1255 }
1256
1257 pub fn stats(&self) -> AnalysisCacheStats {
1259 self.stats
1260 }
1261
1262 pub fn reset_stats(&mut self) {
1264 self.stats = AnalysisCacheStats::default();
1265 }
1266
1267 pub fn get(&self, score: &Score) -> Option<&AnalysisResult> {
1269 self.entries.get(&analysis_cache_key(score))
1270 }
1271
1272 pub fn analyze(&mut self, score: &Score) -> AnalysisResult {
1274 let key = analysis_cache_key(score);
1275 if let Some(result) = self.entries.get(&key) {
1276 self.stats.hits = self.stats.hits.saturating_add(1);
1277 return result.clone();
1278 }
1279
1280 self.stats.misses = self.stats.misses.saturating_add(1);
1281 let result = analyze_score(score);
1282 self.insert(key, result.clone());
1283 result
1284 }
1285
1286 pub fn analyze_batch(&mut self, scores: &[Score]) -> Vec<AnalysisResult> {
1288 scores.iter().map(|score| self.analyze(score)).collect()
1289 }
1290
1291 pub fn analyze_after_edit(&mut self, previous: &Score, current: &Score) -> AnalysisResult {
1293 if analysis_cache_key(previous) != analysis_cache_key(current) {
1294 self.invalidate(previous);
1295 }
1296 self.analyze(current)
1297 }
1298
1299 pub fn analyze_after_edit_with_diff(
1301 &mut self,
1302 previous_score: &Score,
1303 previous_result: &AnalysisResult,
1304 current: &Score,
1305 ) -> AnalysisEditResult {
1306 let analysis = self.analyze_after_edit(previous_score, current);
1307 let diff = diff_analysis(previous_result, &analysis);
1308 AnalysisEditResult { analysis, diff }
1309 }
1310
1311 pub fn analyze_selected_after_edit(
1313 &mut self,
1314 previous_score: &Score,
1315 previous_result: &AnalysisResult,
1316 current: &Score,
1317 categories: &[AnalysisCategory],
1318 ) -> AnalysisResult {
1319 let key = analysis_cache_key(current);
1320 if let Some(result) = self.entries.get(&key) {
1321 self.stats.hits = self.stats.hits.saturating_add(1);
1322 return result.clone();
1323 }
1324 self.stats.misses = self.stats.misses.saturating_add(1);
1325 if analysis_cache_key(previous_score) != key {
1326 self.invalidate(previous_score);
1327 }
1328 let result = analyze_selected_categories(current, previous_result, categories);
1329 self.insert(key, result.clone());
1330 result
1331 }
1332
1333 pub fn analyze_after_edit_with_plan(
1335 &mut self,
1336 previous_score: &Score,
1337 previous_result: &AnalysisResult,
1338 current: &Score,
1339 plan: &AnalysisRefreshPlan,
1340 ) -> AnalysisEditResult {
1341 let categories: Vec<_> = plan
1342 .local_categories
1343 .iter()
1344 .chain(&plan.global_categories)
1345 .copied()
1346 .collect();
1347 let key = analysis_cache_key(current);
1348 let analysis = if let Some(result) = self.entries.get(&key) {
1349 self.stats.hits = self.stats.hits.saturating_add(1);
1350 result.clone()
1351 } else {
1352 self.stats.misses = self.stats.misses.saturating_add(1);
1353 if analysis_cache_key(previous_score) != key {
1354 self.invalidate(previous_score);
1355 }
1356 let result = analyze_selected_categories_in_region(
1357 current,
1358 previous_result,
1359 &categories,
1360 plan.region.as_ref(),
1361 );
1362 self.insert(key, result.clone());
1363 result
1364 };
1365 AnalysisEditResult {
1366 diff: diff_analysis(previous_result, &analysis),
1367 analysis,
1368 }
1369 }
1370
1371 pub fn analyze_after_edit_with_hint(
1373 &mut self,
1374 previous_score: &Score,
1375 previous_result: &AnalysisResult,
1376 current: &Score,
1377 hint: &ChangeHint,
1378 ) -> AnalysisEditResult {
1379 let plan = analysis_refresh_plan(hint);
1380 self.analyze_after_edit_with_plan(previous_score, previous_result, current, &plan)
1381 }
1382
1383 pub fn insert(&mut self, key: String, result: AnalysisResult) {
1385 if self.entries.contains_key(&key) {
1386 self.entries.insert(key.clone(), result);
1387 self.insertion_order.retain(|existing| existing != &key);
1388 } else {
1389 self.entries.insert(key.clone(), result);
1390 }
1391 self.insertion_order.push_back(key);
1392 while self.entries.len() > self.capacity {
1393 if let Some(oldest) = self.insertion_order.pop_front() {
1394 self.entries.remove(&oldest);
1395 }
1396 }
1397 }
1398
1399 pub fn invalidate(&mut self, score: &Score) -> bool {
1401 let key = analysis_cache_key(score);
1402 let removed = self.entries.remove(&key).is_some();
1403 if removed {
1404 self.insertion_order.retain(|existing| existing != &key);
1405 }
1406 removed
1407 }
1408
1409 pub fn clear(&mut self) {
1411 self.entries.clear();
1412 self.insertion_order.clear();
1413 }
1414
1415 pub fn len(&self) -> usize {
1417 self.entries.len()
1418 }
1419
1420 pub fn is_empty(&self) -> bool {
1422 self.entries.is_empty()
1423 }
1424}
1425
1426fn fnv1a64(bytes: &[u8]) -> u64 {
1427 bytes.iter().fold(0xcbf29ce484222325u64, |hash, byte| {
1428 (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
1429 })
1430}
1431
1432impl AnalysisCounts {
1433 fn from_result(result: &AnalysisResult) -> Self {
1434 Self {
1435 chords: result.chords.len(),
1436 intervals: result.intervals.len(),
1437 key_estimates: result.key_estimates.len(),
1438 cadence_candidates: result.cadence_candidates.len(),
1439 voice_leading: result.voice_leading.len(),
1440 satb_diagnostics: result.satb_diagnostics.len(),
1441 motifs: result.motifs.len(),
1442 phrase_boundaries: result.phrase_boundaries.len(),
1443 }
1444 }
1445
1446 fn total(self) -> usize {
1447 self.chords
1448 + self.intervals
1449 + self.key_estimates
1450 + self.cadence_candidates
1451 + self.voice_leading
1452 + self.satb_diagnostics
1453 + self.motifs
1454 + self.phrase_boundaries
1455 }
1456
1457 fn explained(self, result: &AnalysisResult) -> usize {
1458 result
1459 .chords
1460 .iter()
1461 .filter(|item| !item.evidence.is_empty())
1462 .count()
1463 + result
1464 .intervals
1465 .iter()
1466 .filter(|item| !item.evidence.is_empty())
1467 .count()
1468 + result
1469 .key_estimates
1470 .iter()
1471 .filter(|item| !item.evidence.is_empty())
1472 .count()
1473 + result
1474 .cadence_candidates
1475 .iter()
1476 .filter(|item| !item.evidence.is_empty())
1477 .count()
1478 + result
1479 .voice_leading
1480 .iter()
1481 .filter(|item| !item.evidence.is_empty())
1482 .count()
1483 + result
1484 .satb_diagnostics
1485 .iter()
1486 .filter(|item| !item.evidence.is_empty())
1487 .count()
1488 + result
1489 .motifs
1490 .iter()
1491 .map(|item| {
1492 item.occurrences
1493 .iter()
1494 .filter(|occurrence| !occurrence.evidence.is_empty())
1495 .count()
1496 })
1497 .sum::<usize>()
1498 + result
1499 .phrase_boundaries
1500 .iter()
1501 .filter(|item| !item.evidence.is_empty())
1502 .count()
1503 }
1504}
1505
1506pub fn benchmark_case(case: &BenchmarkCase<'_>) -> BenchmarkCaseReport {
1508 let result = analyze_score(case.score);
1509 let predicted = AnalysisCounts::from_result(&result);
1510 let expected = case.expected;
1511 let matched = predicted.chords.min(expected.chords)
1512 + predicted.intervals.min(expected.intervals)
1513 + predicted.key_estimates.min(expected.key_estimates)
1514 + predicted
1515 .cadence_candidates
1516 .min(expected.cadence_candidates)
1517 + predicted.voice_leading.min(expected.voice_leading)
1518 + predicted.satb_diagnostics.min(expected.satb_diagnostics)
1519 + predicted.motifs.min(expected.motifs)
1520 + predicted.phrase_boundaries.min(expected.phrase_boundaries);
1521 let expected_total = AnalysisCounts {
1522 chords: expected.chords,
1523 intervals: expected.intervals,
1524 key_estimates: expected.key_estimates,
1525 cadence_candidates: expected.cadence_candidates,
1526 voice_leading: expected.voice_leading,
1527 satb_diagnostics: expected.satb_diagnostics,
1528 motifs: expected.motifs,
1529 phrase_boundaries: expected.phrase_boundaries,
1530 }
1531 .total();
1532 let predicted_total = predicted.total();
1533 let failures = [
1534 (BenchmarkCategory::Chords, expected.chords, predicted.chords),
1535 (
1536 BenchmarkCategory::Intervals,
1537 expected.intervals,
1538 predicted.intervals,
1539 ),
1540 (
1541 BenchmarkCategory::KeyEstimates,
1542 expected.key_estimates,
1543 predicted.key_estimates,
1544 ),
1545 (
1546 BenchmarkCategory::CadenceCandidates,
1547 expected.cadence_candidates,
1548 predicted.cadence_candidates,
1549 ),
1550 (
1551 BenchmarkCategory::VoiceLeading,
1552 expected.voice_leading,
1553 predicted.voice_leading,
1554 ),
1555 (
1556 BenchmarkCategory::SatbDiagnostics,
1557 expected.satb_diagnostics,
1558 predicted.satb_diagnostics,
1559 ),
1560 (BenchmarkCategory::Motifs, expected.motifs, predicted.motifs),
1561 (
1562 BenchmarkCategory::PhraseBoundaries,
1563 expected.phrase_boundaries,
1564 predicted.phrase_boundaries,
1565 ),
1566 ]
1567 .into_iter()
1568 .filter_map(|(category, expected, predicted)| {
1569 if expected == predicted {
1570 return None;
1571 }
1572 Some(BenchmarkFailure {
1573 category,
1574 expected,
1575 predicted,
1576 missing: expected.saturating_sub(predicted),
1577 excess: predicted.saturating_sub(expected),
1578 })
1579 })
1580 .collect();
1581 BenchmarkCaseReport {
1582 name: case.name.to_string(),
1583 predicted,
1584 expected,
1585 precision_percent: percentage(matched, predicted_total),
1586 recall_percent: percentage(matched, expected_total),
1587 explanation_completeness_percent: percentage(predicted.explained(&result), predicted_total),
1588 failures,
1589 }
1590}
1591
1592pub fn run_benchmark(cases: &[BenchmarkCase<'_>]) -> Vec<BenchmarkCaseReport> {
1594 cases.iter().map(benchmark_case).collect()
1595}
1596
1597pub fn run_benchmark_suite(cases: &[BenchmarkCase<'_>]) -> BenchmarkSuiteReport {
1599 let reports = run_benchmark(cases);
1600 let case_count = reports.len();
1601 let passed_case_count = reports
1602 .iter()
1603 .filter(|report| report.failures.is_empty())
1604 .count();
1605 let failed_case_count = case_count.saturating_sub(passed_case_count);
1606 let precision_total: usize = reports
1607 .iter()
1608 .map(|report| usize::from(report.precision_percent))
1609 .sum();
1610 let recall_total: usize = reports
1611 .iter()
1612 .map(|report| usize::from(report.recall_percent))
1613 .sum();
1614 let explanation_total: usize = reports
1615 .iter()
1616 .map(|report| usize::from(report.explanation_completeness_percent))
1617 .sum();
1618 let metric_denominator = case_count.saturating_mul(100);
1619 let aggregate_metric = |total: usize| {
1620 if case_count == 0 {
1621 0
1622 } else {
1623 percentage(total, metric_denominator)
1624 }
1625 };
1626 BenchmarkSuiteReport {
1627 cases: reports,
1628 case_count,
1629 passed_case_count,
1630 failed_case_count,
1631 precision_percent: aggregate_metric(precision_total),
1632 recall_percent: aggregate_metric(recall_total),
1633 explanation_completeness_percent: aggregate_metric(explanation_total),
1634 }
1635}
1636
1637fn percentage(numerator: usize, denominator: usize) -> u8 {
1638 match numerator.saturating_mul(100).checked_div(denominator) {
1639 Some(value) => value.min(100) as u8,
1640 None => 100,
1641 }
1642}
1643
1644pub fn analyze_satb(score: &Score) -> Vec<SatbDiagnostic> {
1646 let voice_leading = analyze_voice_leading(score);
1647 analyze_satb_in_region(score, &voice_leading, None)
1648}
1649
1650pub fn analyze_satb_in_region(
1652 score: &Score,
1653 voice_leading: &[VoiceLeadingObservation],
1654 region: Option<&AnalysisRegion>,
1655) -> Vec<SatbDiagnostic> {
1656 let mut diagnostics = Vec::new();
1657 for (part_index, part) in score.parts.iter().enumerate() {
1658 for (staff_index, staff) in part.staves.iter().enumerate() {
1659 for (measure_index, measure) in staff.measures.iter().enumerate() {
1660 if let Some(region) = region
1661 && (part_index != region.part
1662 || staff_index != region.staff
1663 || measure_index < region.start_measure
1664 || measure_index >= region.end_measure)
1665 {
1666 continue;
1667 }
1668 for (upper_index, upper_voice) in measure.voices.iter().enumerate() {
1669 let Some(lower_voice) = measure.voices.get(upper_index + 1) else {
1670 continue;
1671 };
1672 for note_index in 0..upper_voice.len().min(lower_voice.len()) {
1673 let Some(upper) = upper_voice[note_index].pitches.first() else {
1674 continue;
1675 };
1676 let Some(lower) = lower_voice[note_index].pitches.first() else {
1677 continue;
1678 };
1679 let upper_addr = NoteAddr {
1680 part: part_index,
1681 staff: staff_index,
1682 measure: measure_index,
1683 voice: upper_index,
1684 note: note_index,
1685 };
1686 let lower_addr = NoteAddr {
1687 part: part_index,
1688 staff: staff_index,
1689 measure: measure_index,
1690 voice: upper_index + 1,
1691 note: note_index,
1692 };
1693 let distance = upper.to_midi() - lower.to_midi();
1694 if distance < 0 {
1695 diagnostics.push(satb_diagnostic(
1696 upper_addr.clone(),
1697 lower_addr.clone(),
1698 SatbDiagnosticKind::VoiceCrossing,
1699 SatbSeverity::Error,
1700 "satb-voice-crossing",
1701 ));
1702 } else if distance > 24 {
1703 diagnostics.push(satb_diagnostic(
1704 upper_addr.clone(),
1705 lower_addr.clone(),
1706 SatbDiagnosticKind::WideSpacing,
1707 SatbSeverity::Warning,
1708 "satb-wide-spacing",
1709 ));
1710 }
1711 }
1712 }
1713 }
1714 }
1715 }
1716 for observation in voice_leading {
1717 if let Some(region) = region
1718 && !observation
1719 .evidence
1720 .iter()
1721 .any(|address| analysis_region_contains(region, address))
1722 {
1723 continue;
1724 }
1725 if observation.parallel_perfect {
1726 diagnostics.push(satb_diagnostic(
1727 observation.upper.clone(),
1728 observation.lower.clone(),
1729 SatbDiagnosticKind::ParallelPerfect,
1730 SatbSeverity::Warning,
1731 "satb-parallel-perfect",
1732 ));
1733 }
1734 }
1735 diagnostics
1736}
1737
1738fn satb_diagnostic(
1739 upper: NoteAddr,
1740 lower: NoteAddr,
1741 kind: SatbDiagnosticKind,
1742 severity: SatbSeverity,
1743 rule_id: &str,
1744) -> SatbDiagnostic {
1745 SatbDiagnostic {
1746 evidence: vec![upper.clone(), lower.clone()],
1747 upper,
1748 lower,
1749 kind,
1750 severity,
1751 confidence: 100,
1752 rule_id: rule_id.to_string(),
1753 }
1754}
1755
1756pub fn analyze_motifs(score: &Score) -> Vec<MotifPattern> {
1758 let mut groups: BTreeMap<(usize, usize, usize, Vec<i8>), Vec<MotifOccurrence>> =
1759 BTreeMap::new();
1760 for (part_index, part) in score.parts.iter().enumerate() {
1761 for (staff_index, staff) in part.staves.iter().enumerate() {
1762 let Some(first_measure) = staff.measures.first() else {
1763 continue;
1764 };
1765 for (voice_index, _) in first_measure.voices.iter().enumerate() {
1766 let mut segment = Vec::new();
1767 let mut segments = Vec::new();
1768 for (measure_index, measure) in staff.measures.iter().enumerate() {
1769 for (note_index, note) in measure.voices[voice_index].iter().enumerate() {
1770 let Some(pitch) = note.pitches.first() else {
1771 if segment.len() >= 3 {
1772 segments.push(std::mem::take(&mut segment));
1773 } else {
1774 segment.clear();
1775 }
1776 continue;
1777 };
1778 if note.is_rest {
1779 if segment.len() >= 3 {
1780 segments.push(std::mem::take(&mut segment));
1781 } else {
1782 segment.clear();
1783 }
1784 continue;
1785 }
1786 segment.push((
1787 NoteAddr {
1788 part: part_index,
1789 staff: staff_index,
1790 measure: measure_index,
1791 voice: voice_index,
1792 note: note_index,
1793 },
1794 pitch.to_midi(),
1795 ));
1796 }
1797 }
1798 if segment.len() >= 3 {
1799 segments.push(segment);
1800 }
1801 for segment in segments {
1802 for window in segment.windows(3) {
1803 let signature = vec![
1804 (window[1].1 - window[0].1) as i8,
1805 (window[2].1 - window[1].1) as i8,
1806 ];
1807 let occurrence = MotifOccurrence {
1808 start: window[0].0.clone(),
1809 end: window[2].0.clone(),
1810 evidence: window.iter().map(|(address, _)| address.clone()).collect(),
1811 };
1812 groups
1813 .entry((part_index, staff_index, voice_index, signature))
1814 .or_default()
1815 .push(occurrence);
1816 }
1817 }
1818 }
1819 }
1820 }
1821 groups
1822 .into_iter()
1823 .filter(|(_, occurrences)| occurrences.len() >= 2)
1824 .map(|((_, _, _, signature), occurrences)| MotifPattern {
1825 signature,
1826 occurrences,
1827 confidence: 100,
1828 rule_id: "repeated-three-note-interval-pattern".to_string(),
1829 })
1830 .collect()
1831}
1832
1833pub fn analyze_phrase_boundaries(score: &Score) -> Vec<PhraseBoundary> {
1835 let mut boundaries = Vec::new();
1836 for (part_index, part) in score.parts.iter().enumerate() {
1837 for (staff_index, staff) in part.staves.iter().enumerate() {
1838 for (measure_index, measure) in staff.measures.iter().enumerate() {
1839 for (voice_index, voice) in measure.voices.iter().enumerate() {
1840 let Some((note_index, note)) = voice.iter().enumerate().next_back() else {
1841 continue;
1842 };
1843 if !note.is_rest {
1844 continue;
1845 }
1846 let address = NoteAddr {
1847 part: part_index,
1848 staff: staff_index,
1849 measure: measure_index,
1850 voice: voice_index,
1851 note: note_index,
1852 };
1853 boundaries.push(PhraseBoundary {
1854 address: address.clone(),
1855 reason: PhraseBoundaryReason::RestTermination,
1856 confidence: 100,
1857 rule_id: "measure-ending-rest".to_string(),
1858 evidence: vec![address],
1859 });
1860 }
1861 }
1862 }
1863 }
1864 boundaries
1865}
1866
1867pub fn analyze_cadences(chords: &[ChordLabel]) -> Vec<CadenceCandidate> {
1869 let mut candidates = Vec::new();
1870 let mut previous: HashMap<(usize, usize, usize), &ChordLabel> = HashMap::new();
1871 for chord in chords {
1872 let key = (chord.address.part, chord.address.staff, chord.address.voice);
1873 let Some(previous_chord) = previous.insert(key, chord) else {
1874 continue;
1875 };
1876 let Some(from_roman) = previous_chord.roman_numeral.as_deref() else {
1877 continue;
1878 };
1879 let Some(to_roman) = chord.roman_numeral.as_deref() else {
1880 continue;
1881 };
1882 let from_figure = roman_figure(from_roman);
1883 let to_figure = roman_figure(to_roman);
1884 let kind = match (from_figure, to_figure) {
1885 ("V" | "V7", "I") => CadenceKind::Authentic,
1886 ("IV", "I") => CadenceKind::Plagal,
1887 ("V" | "V7", "vi") => CadenceKind::Deceptive,
1888 (_, "V" | "V7") => CadenceKind::Half,
1889 _ => continue,
1890 };
1891 let evidence = vec![previous_chord.address.clone(), chord.address.clone()];
1892 candidates.push(CadenceCandidate {
1893 from: previous_chord.address.clone(),
1894 to: chord.address.clone(),
1895 kind,
1896 confidence: 100,
1897 rule_id: "roman-numeral-cadence-transition".to_string(),
1898 evidence,
1899 });
1900 }
1901 candidates
1902}
1903
1904fn roman_figure(roman: &str) -> &str {
1905 roman.find('/').map_or(roman, |index| &roman[..index])
1906}
1907
1908pub fn analyze_voice_leading(score: &Score) -> Vec<VoiceLeadingObservation> {
1910 analyze_voice_leading_in_region(score, None)
1911}
1912
1913pub fn analyze_voice_leading_in_region(
1915 score: &Score,
1916 region: Option<&AnalysisRegion>,
1917) -> Vec<VoiceLeadingObservation> {
1918 let mut observations = Vec::new();
1919 for (part_index, part) in score.parts.iter().enumerate() {
1920 for (staff_index, staff) in part.staves.iter().enumerate() {
1921 for (measure_index, measure) in staff.measures.iter().enumerate() {
1922 if let Some(region) = region
1923 && (part_index != region.part
1924 || staff_index != region.staff
1925 || measure_index < region.start_measure
1926 || measure_index >= region.end_measure)
1927 {
1928 continue;
1929 }
1930 for (upper_index, upper_voice) in measure.voices.iter().enumerate() {
1931 let Some(lower_voice) = measure.voices.get(upper_index + 1) else {
1932 continue;
1933 };
1934 let count = upper_voice.len().min(lower_voice.len());
1935 for note_index in 0..count {
1936 let Some(upper) = upper_voice[note_index].pitches.first() else {
1937 continue;
1938 };
1939 let Some(lower) = lower_voice[note_index].pitches.first() else {
1940 continue;
1941 };
1942 let Some(next_upper_note) = upper_voice.get(note_index + 1) else {
1943 continue;
1944 };
1945 let Some(next_lower_note) = lower_voice.get(note_index + 1) else {
1946 continue;
1947 };
1948 if next_upper_note.is_rest || next_lower_note.is_rest {
1949 continue;
1950 }
1951 let next_upper = next_upper_note.pitches.first();
1952 let next_lower = next_lower_note.pitches.first();
1953 let (Some(next_upper), Some(next_lower)) = (next_upper, next_lower) else {
1954 continue;
1955 };
1956 let upper_addr = NoteAddr {
1957 part: part_index,
1958 staff: staff_index,
1959 measure: measure_index,
1960 voice: upper_index,
1961 note: note_index,
1962 };
1963 let lower_addr = NoteAddr {
1964 part: part_index,
1965 staff: staff_index,
1966 measure: measure_index,
1967 voice: upper_index + 1,
1968 note: note_index,
1969 };
1970 let upper_next_midi = next_upper.to_midi();
1971 let lower_next_midi = next_lower.to_midi();
1972 let upper_motion = upper_next_midi - upper.to_midi();
1973 let lower_motion = lower_next_midi - lower.to_midi();
1974 let upper_motion_cents = next_upper.to_midi_cents() - upper.to_midi_cents();
1975 let lower_motion_cents = next_lower.to_midi_cents() - lower.to_midi_cents();
1976 let initial =
1977 (upper.to_midi_cents() - lower.to_midi_cents()).unsigned_abs() % 1200;
1978 let next = (next_upper.to_midi_cents() - next_lower.to_midi_cents())
1979 .unsigned_abs()
1980 % 1200;
1981 observations.push(VoiceLeadingObservation {
1982 upper: upper_addr.clone(),
1983 lower: lower_addr.clone(),
1984 upper_motion,
1985 lower_motion,
1986 upper_motion_cents,
1987 lower_motion_cents,
1988 parallel_perfect: matches!(initial, 0 | 700)
1989 && initial == next
1990 && upper_motion_cents != 0
1991 && upper_motion_cents.signum() == lower_motion_cents.signum(),
1992 confidence: 100,
1993 rule_id: "aligned-adjacent-voice-leading".to_string(),
1994 evidence: vec![upper_addr, lower_addr],
1995 });
1996 }
1997 }
1998 }
1999 }
2000 }
2001 observations
2002}
2003
2004pub fn analyze_chords(score: &Score) -> AnalysisResult {
2006 analyze_score(score)
2007}
2008
2009pub fn analyze_batch(scores: &[Score]) -> Vec<AnalysisResult> {
2011 scores.iter().map(analyze_score).collect()
2012}
2013
2014pub fn analyze_stream<I>(scores: I) -> impl Iterator<Item = AnalysisResult>
2016where
2017 I: IntoIterator<Item = Score>,
2018{
2019 scores.into_iter().map(|score| analyze_score(&score))
2020}
2021
2022pub fn estimate_keys(score: &Score) -> Vec<KeyEstimate> {
2024 estimate_key_candidates(score, true)
2025}
2026
2027fn estimate_key_candidates(score: &Score, only_best_coverage: bool) -> Vec<KeyEstimate> {
2028 let mut pitches = Vec::new();
2029 let mut evidence = Vec::new();
2030 for (part_index, part) in score.parts.iter().enumerate() {
2031 for (staff_index, staff) in part.staves.iter().enumerate() {
2032 for (measure_index, measure) in staff.measures.iter().enumerate() {
2033 for (voice_index, voice) in measure.voices.iter().enumerate() {
2034 for (note_index, note) in voice.iter().enumerate() {
2035 if note.is_rest {
2036 continue;
2037 }
2038 let duration_beats = note.beats().max(0.0);
2039 pitches.extend(note.pitches.iter().map(|pitch| (pitch, duration_beats)));
2040 if !note.pitches.is_empty() {
2041 evidence.push(NoteAddr {
2042 part: part_index,
2043 staff: staff_index,
2044 measure: measure_index,
2045 voice: voice_index,
2046 note: note_index,
2047 });
2048 }
2049 }
2050 }
2051 }
2052 }
2053 }
2054 if pitches.is_empty() {
2055 return Vec::new();
2056 }
2057 let total_pitches = pitches.len();
2058 let total_duration_beats: f64 = pitches.iter().map(|(_, beats)| *beats).sum();
2059 let mut candidates = Vec::with_capacity(30);
2060 for fifths in -7..=7 {
2061 for mode in ["major", "minor"] {
2062 let key = KeySignature {
2063 fifths,
2064 mode: mode.to_string(),
2065 };
2066 let covered = pitches
2067 .iter()
2068 .filter(|(pitch, _)| key.contains_pitch(pitch))
2069 .count();
2070 let weighted_covered_beats = pitches
2071 .iter()
2072 .filter(|(pitch, _)| key.contains_pitch(pitch))
2073 .map(|(_, beats)| *beats)
2074 .sum::<f64>();
2075 candidates.push((key, covered, weighted_covered_beats));
2076 }
2077 }
2078 candidates.sort_by(
2079 |(left_key, left_score, left_weight), (right_key, right_score, right_weight)| {
2080 right_weight
2081 .total_cmp(left_weight)
2082 .then_with(|| right_score.cmp(left_score))
2083 .then_with(|| left_key.fifths.abs().cmp(&right_key.fifths.abs()))
2084 .then_with(|| left_key.fifths.cmp(&right_key.fifths))
2085 .then_with(|| left_key.mode.cmp(&right_key.mode))
2086 },
2087 );
2088 let best_weight = candidates[0].2;
2089 candidates
2090 .into_iter()
2091 .take_while(|(_, _, weighted)| {
2092 !only_best_coverage || weighted.total_cmp(&best_weight).is_eq()
2093 })
2094 .map(
2095 |(key, covered_pitches, weighted_covered_beats)| KeyEstimate {
2096 key,
2097 covered_pitches,
2098 total_pitches,
2099 weighted_covered_beats,
2100 total_duration_beats,
2101 confidence: if total_duration_beats > 0.0 {
2102 ((weighted_covered_beats / total_duration_beats * 100.0).round() as u8).min(100)
2103 } else {
2104 0
2105 },
2106 rule_id: "duration-weighted-diatonic-pitch-coverage".to_string(),
2107 evidence: evidence.clone(),
2108 },
2109 )
2110 .collect()
2111}
2112
2113pub fn estimate_keys_with_tonal_profile(score: &Score) -> Vec<KeyEstimate> {
2120 let mut estimates = estimate_keys(score);
2121 rank_key_estimates_by_tonal_profile(
2122 score,
2123 &mut estimates,
2124 &KRUMHANSL_MAJOR,
2125 &KRUMHANSL_MINOR,
2126 "duration-weighted-diatonic-pitch-coverage+krumhansl-profile-ranking",
2127 );
2128 estimates
2129}
2130
2131pub fn estimate_keys_with_aarden_essen_profile(score: &Score) -> Vec<KeyEstimate> {
2139 let mut estimates = estimate_key_candidates(score, false);
2140 if !rank_key_estimates_by_tonal_profile(
2141 score,
2142 &mut estimates,
2143 &AARDEN_ESSEN_MAJOR,
2144 &AARDEN_ESSEN_MINOR,
2145 "duration-weighted-aarden-essen-profile-ranking",
2146 ) {
2147 return estimate_keys(score);
2148 }
2149 estimates
2150}
2151
2152const KRUMHANSL_MAJOR: [f64; 12] = [
2155 6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88,
2156];
2157const KRUMHANSL_MINOR: [f64; 12] = [
2158 6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17,
2159];
2160const AARDEN_ESSEN_MAJOR: [f64; 12] = [
2161 17.7661, 0.145624, 14.9265, 0.160186, 19.8049, 11.3587, 0.291248, 22.062, 0.145624, 8.15494,
2162 0.232998, 4.95122,
2163];
2164const AARDEN_ESSEN_MINOR: [f64; 12] = [
2165 18.2648, 0.737619, 14.0499, 16.8599, 0.702494, 14.4362, 0.702494, 18.6161, 4.56621, 1.93186,
2166 7.37619, 1.75623,
2167];
2168
2169fn rank_key_estimates_by_tonal_profile(
2170 score: &Score,
2171 estimates: &mut [KeyEstimate],
2172 major: &[f64; 12],
2173 minor: &[f64; 12],
2174 rule_id: &str,
2175) -> bool {
2176 if estimates.len() < 2 {
2177 return false;
2178 }
2179
2180 let mut pitch_classes = [0.0_f64; 12];
2181 for part in &score.parts {
2182 for staff in &part.staves {
2183 for measure in &staff.measures {
2184 for voice in &measure.voices {
2185 for note in voice {
2186 if note.is_rest {
2187 continue;
2188 }
2189 let beats = note.beats();
2190 if !beats.is_finite() || beats <= 0.0 {
2191 continue;
2192 }
2193 for pitch in ¬e.pitches {
2194 if pitch.microtone_cents != 0 {
2195 return false;
2196 }
2197 pitch_classes[pitch.to_midi().rem_euclid(12) as usize] += beats;
2198 }
2199 }
2200 }
2201 }
2202 }
2203 }
2204 let observed_mean = pitch_classes.iter().sum::<f64>() / 12.0;
2205 let observed_variance = pitch_classes
2206 .iter()
2207 .map(|value| (value - observed_mean).powi(2))
2208 .sum::<f64>();
2209 if observed_variance <= 0.0 {
2210 return false;
2211 }
2212 let correlation = |estimate: &KeyEstimate| {
2213 let weights = if estimate.key.mode == "minor" {
2214 minor
2215 } else {
2216 major
2217 };
2218 let tonic = (estimate.key.fifths * 7 + if estimate.key.mode == "minor" { 9 } else { 0 })
2219 .rem_euclid(12) as usize;
2220 let profile_mean = weights.iter().sum::<f64>() / 12.0;
2221 let profile_variance = weights
2222 .iter()
2223 .map(|value| (value - profile_mean).powi(2))
2224 .sum::<f64>();
2225 let covariance = pitch_classes
2226 .iter()
2227 .enumerate()
2228 .map(|(class, value)| {
2229 (value - observed_mean) * (weights[(class + 12 - tonic) % 12] - profile_mean)
2230 })
2231 .sum::<f64>();
2232 covariance / (observed_variance * profile_variance).sqrt()
2233 };
2234 estimates.sort_by(|left, right| correlation(right).total_cmp(&correlation(left)));
2235 for estimate in estimates {
2236 estimate.rule_id = rule_id.to_string();
2237 }
2238 true
2239}
2240
2241pub fn analyze_intervals(score: &Score) -> Vec<IntervalObservation> {
2243 analyze_intervals_in_region(score, None)
2244}
2245
2246pub fn analyze_intervals_in_region(
2248 score: &Score,
2249 region: Option<&AnalysisRegion>,
2250) -> Vec<IntervalObservation> {
2251 let mut observations = Vec::new();
2252 for (part_index, part) in score.parts.iter().enumerate() {
2253 for (staff_index, staff) in part.staves.iter().enumerate() {
2254 for (measure_index, measure) in staff.measures.iter().enumerate() {
2255 for (voice_index, voice) in measure.voices.iter().enumerate() {
2256 for pair in voice.iter().enumerate().collect::<Vec<_>>().windows(2) {
2257 let (from_index, from_note) = pair[0];
2258 let (to_index, to_note) = pair[1];
2259 if from_note.is_rest || to_note.is_rest {
2260 continue;
2261 }
2262 let Some(from) = from_note.pitches.first() else {
2263 continue;
2264 };
2265 let Some(to) = to_note.pitches.first() else {
2266 continue;
2267 };
2268 let from_addr = NoteAddr {
2269 part: part_index,
2270 staff: staff_index,
2271 measure: measure_index,
2272 voice: voice_index,
2273 note: from_index,
2274 };
2275 let to_addr = NoteAddr {
2276 part: part_index,
2277 staff: staff_index,
2278 measure: measure_index,
2279 voice: voice_index,
2280 note: to_index,
2281 };
2282 if let Some(region) = region
2283 && !analysis_region_contains(region, &from_addr)
2284 && !analysis_region_contains(region, &to_addr)
2285 {
2286 continue;
2287 }
2288 observations.push(IntervalObservation {
2289 from: from_addr.clone(),
2290 to: to_addr.clone(),
2291 semitones: (to.to_midi() - from.to_midi()).unsigned_abs() as u8,
2292 cents: to.to_midi_cents() - from.to_midi_cents(),
2293 diatonic_steps: diatonic_distance(from, to),
2294 rule_id: "adjacent-melodic-interval".to_string(),
2295 evidence: vec![from_addr, to_addr],
2296 });
2297 }
2298 }
2299 }
2300 }
2301 }
2302 observations
2303}
2304
2305fn diatonic_distance(from: &acorde_core::Pitch, to: &acorde_core::Pitch) -> i8 {
2306 let step_index = |step: &acorde_core::Step| match step {
2307 acorde_core::Step::C => 0i16,
2308 acorde_core::Step::D => 1,
2309 acorde_core::Step::E => 2,
2310 acorde_core::Step::F => 3,
2311 acorde_core::Step::G => 4,
2312 acorde_core::Step::A => 5,
2313 acorde_core::Step::B => 6,
2314 };
2315 (i16::from(to.octave) * 7 + step_index(&to.step)
2316 - (i16::from(from.octave) * 7 + step_index(&from.step))) as i8
2317}
2318
2319pub fn chord_name(chord: &ChordSymbol) -> String {
2321 chord.display_text()
2322}
2323
2324#[cfg(test)]
2325mod tests {
2326 use super::*;
2327 use acorde_core::{Duration, Note, Pitch, Score, Step};
2328
2329 #[test]
2330 fn labels_chord_with_note_addresses_and_roman_numeral() {
2331 let mut score = Score::default();
2332 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2333 voice.clear();
2334 for step in [Step::C, Step::E, Step::G] {
2335 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2336 }
2337 let result = analyze_chords(&score);
2338 assert_eq!(result.schema_version, ANALYSIS_SCHEMA_VERSION);
2339 assert_eq!(result.chords.len(), 1);
2340 assert_eq!(result.intervals.len(), 2);
2341 assert!(!result.key_estimates.is_empty());
2342 assert_eq!(result.chords[0].address.note, 0);
2343 assert_eq!(result.chords[0].evidence.len(), 3);
2344 assert_eq!(result.chords[0].roman_numeral.as_deref(), Some("I"));
2345 assert_eq!(chord_name(&result.chords[0].chord), "C");
2346 assert_eq!(result.chords[0].name, "C");
2347 }
2348
2349 #[test]
2350 fn vertical_sonorities_do_not_turn_a_sequential_melody_into_a_chord() {
2351 let mut score = Score::new("melody", 120, 4, 4, 0, 1);
2352 score.parts[0].staves[0].measures[0].voices[0] = [Step::C, Step::E, Step::G]
2353 .into_iter()
2354 .map(|step| Note::new(Pitch::new(step, 4), Duration::Quarter))
2355 .collect();
2356
2357 assert!(analyze_vertical_sonorities(&score).is_empty());
2358 assert_eq!(analyze_chords(&score).chords.len(), 1);
2360 }
2361
2362 #[test]
2363 fn vertical_sonorities_include_held_notes_across_parts() {
2364 let mut score = Score::new("vertical", 120, 4, 4, 0, 1);
2365 let part = score.parts[0].clone();
2366 score.parts.push(part.clone());
2367 score.parts.push(part);
2368 score.parts[0].staves[0].measures[0].voices[0] =
2369 vec![Note::new(Pitch::new(Step::C, 3), Duration::Whole)];
2370 score.parts[1].staves[0].measures[0].voices[0] = vec![
2371 Note::new(Pitch::new(Step::E, 4), Duration::Half),
2372 Note::new(Pitch::new(Step::F, 4), Duration::Half),
2373 ];
2374 score.parts[2].staves[0].measures[0].voices[0] =
2375 vec![Note::new(Pitch::new(Step::G, 4), Duration::Whole)];
2376
2377 let sonorities = analyze_vertical_sonorities(&score);
2378 assert_eq!(sonorities.len(), 2);
2379 assert_eq!((sonorities[0].measure, sonorities[0].beat), (0, 0.0));
2380 assert_eq!((sonorities[1].measure, sonorities[1].beat), (0, 2.0));
2381 assert_eq!(sonorities[0].label.chord.root, "C");
2382 assert_eq!(sonorities[0].label.chord.kind, "major");
2383 assert_eq!(sonorities[1].label.chord.kind, "suspended-fourth");
2384 assert!(sonorities.iter().all(|item| item.label.evidence.len() == 3));
2385 }
2386
2387 #[test]
2388 fn vertical_sonorities_preserve_authored_chord_at_its_beat() {
2389 let mut score = Score::new("authored", 120, 4, 4, 0, 1);
2390 let part = score.parts[0].clone();
2391 score.parts.push(part.clone());
2392 score.parts.push(part);
2393 let mut bass = Note::new(Pitch::new(Step::C, 3), Duration::Quarter);
2394 bass.chord_symbol = Some(ChordSymbol {
2395 root: "D".to_string(),
2396 kind: "minor".to_string(),
2397 bass: None,
2398 placement: None,
2399 extender: false,
2400 harmonic_degree: None,
2401 harmony_function: None,
2402 harmony_type: None,
2403 chord_ref: None,
2404 range_end: None,
2405 degrees: Vec::new(),
2406 });
2407 score.parts[0].staves[0].measures[0].voices[0] = vec![bass];
2408 score.parts[1].staves[0].measures[0].voices[0] =
2409 vec![Note::new(Pitch::new(Step::E, 4), Duration::Quarter)];
2410 score.parts[2].staves[0].measures[0].voices[0] =
2411 vec![Note::new(Pitch::new(Step::G, 4), Duration::Quarter)];
2412
2413 let sonorities = analyze_vertical_sonorities(&score);
2414 assert_eq!(sonorities.len(), 1);
2415 assert_eq!(sonorities[0].label.rule_id, "authored-chord-symbol");
2416 assert_eq!(sonorities[0].label.chord.root, "D");
2417 assert_eq!(sonorities[0].label.confidence, 100);
2418 }
2419
2420 #[test]
2421 fn vertical_sonorities_can_use_an_independently_estimated_minor_key() {
2422 let mut score = Score::new("minor", 120, 4, 4, -3, 1);
2423 let part = score.parts[0].clone();
2424 score.parts.push(part.clone());
2425 score.parts.push(part);
2426 for (part_index, (step, alter)) in [(Step::C, 0), (Step::E, -1), (Step::G, 0)]
2427 .into_iter()
2428 .enumerate()
2429 {
2430 score.parts[part_index].staves[0].measures[0].voices[0] = vec![Note::new(
2431 Pitch::with_alter(step, 4, alter),
2432 Duration::Quarter,
2433 )];
2434 }
2435 let c_minor = KeySignature {
2436 fifths: -3,
2437 mode: "minor".to_string(),
2438 };
2439 assert_eq!(
2440 analyze_vertical_sonorities(&score)[0]
2441 .label
2442 .roman_numeral
2443 .as_deref(),
2444 Some("vi")
2445 );
2446 assert_eq!(
2447 analyze_vertical_sonorities_in_key(&score, &c_minor)[0]
2448 .label
2449 .roman_numeral
2450 .as_deref(),
2451 Some("i")
2452 );
2453 }
2454
2455 #[test]
2456 fn authored_chord_symbol_wins_with_source_address_provenance() {
2457 use acorde_core::ChordSymbol;
2458
2459 let mut score = Score::default();
2460 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2461 voice.clear();
2462 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2463 note.chord_symbol = Some(ChordSymbol {
2464 root: "F".to_owned(),
2465 kind: "dominant".to_owned(),
2466 bass: Some("A".to_owned()),
2467 placement: None,
2468 extender: false,
2469 harmonic_degree: Some("4".to_owned()),
2470 harmony_function: Some("D".to_owned()),
2471 harmony_type: None,
2472 chord_ref: None,
2473 range_end: None,
2474 degrees: Vec::new(),
2475 });
2476 voice.push(note);
2477
2478 let result = analyze_score(&score);
2479 assert_eq!(result.chords.len(), 1);
2480 assert_eq!(result.chords[0].rule_id, "authored-chord-symbol");
2481 assert_eq!(result.chords[0].address.note, 0);
2482 assert_eq!(
2483 result.chords[0].evidence,
2484 vec![result.chords[0].address.clone()]
2485 );
2486 assert_eq!(chord_name(&result.chords[0].chord), "F7/A");
2487 assert_eq!(result.chords[0].name, "F7/A");
2488 let finding = analysis_provenance(&result, &result.chords[0].address)
2489 .into_iter()
2490 .find(|finding| finding.category == AnalysisCategory::Chords)
2491 .expect("authored chord provenance");
2492 assert_eq!(finding.label.as_deref(), Some("F7/A"));
2493 }
2494
2495 #[test]
2496 fn chord_name_preserves_authored_degree_extensions() {
2497 use acorde_core::{ChordDegree, ChordSymbol};
2498
2499 let chord = ChordSymbol {
2500 root: "C".to_owned(),
2501 kind: "dominant".to_owned(),
2502 bass: None,
2503 placement: None,
2504 extender: false,
2505 harmonic_degree: None,
2506 harmony_function: None,
2507 harmony_type: None,
2508 chord_ref: None,
2509 range_end: None,
2510 degrees: vec![
2511 ChordDegree {
2512 value: 9,
2513 alter: 1,
2514 kind: "add".to_owned(),
2515 },
2516 ChordDegree {
2517 value: 5,
2518 alter: -1,
2519 kind: "alter".to_owned(),
2520 },
2521 ChordDegree {
2522 value: 3,
2523 alter: 0,
2524 kind: "subtract".to_owned(),
2525 },
2526 ],
2527 };
2528
2529 assert_eq!(chord_name(&chord), "C7add#9b5no3");
2530 }
2531
2532 #[test]
2533 fn provenance_returns_stable_explanations_for_a_note() {
2534 let mut score = Score::default();
2535 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2536 voice.clear();
2537 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2538 voice.push(Note::new(Pitch::new(Step::E, 4), Duration::Quarter));
2539 voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
2540 let address = NoteAddr {
2541 part: 0,
2542 staff: 0,
2543 measure: 0,
2544 voice: 0,
2545 note: 0,
2546 };
2547 let findings = analysis_provenance(&analyze_score(&score), &address);
2548 let chord = findings
2549 .iter()
2550 .find(|finding| finding.category == AnalysisCategory::Chords)
2551 .expect("chord provenance");
2552 assert_eq!(chord.rule_id, "pitch-class-template");
2553 assert_eq!(chord.label.as_deref(), Some("C"));
2554 assert_eq!(chord.evidence[0], address);
2555 assert!(findings.windows(2).all(|pair| {
2556 (analysis_category_rank(pair[0].category), &pair[0].rule_id)
2557 <= (analysis_category_rank(pair[1].category), &pair[1].rule_id)
2558 }));
2559 }
2560
2561 #[test]
2562 fn change_explanation_combines_diff_and_before_after_provenance() {
2563 let mut previous_score = Score::default();
2564 let voice = &mut previous_score.parts[0].staves[0].measures[0].voices[0];
2565 voice.clear();
2566 for step in [Step::C, Step::E, Step::G] {
2567 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2568 }
2569 let previous = analyze_score(&previous_score);
2570 let mut current_score = previous_score.clone();
2571 current_score.parts[0].staves[0].measures[0].voices[0][1] =
2572 Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
2573 let current = analyze_score(¤t_score);
2574 let address = NoteAddr {
2575 part: 0,
2576 staff: 0,
2577 measure: 0,
2578 voice: 0,
2579 note: 1,
2580 };
2581
2582 let explanation = explain_analysis_change(&previous, ¤t, &address);
2583 assert!(!explanation.diff.is_empty());
2584 assert!(!explanation.previous.is_empty());
2585 assert!(!explanation.current.is_empty());
2586 }
2587
2588 #[test]
2589 fn analysis_has_stable_score_fingerprint() {
2590 let score = Score::default();
2591 let repeated = analyze_score(&score);
2592 assert_eq!(repeated.score_fingerprint, score_fingerprint(&score));
2593 assert_eq!(
2594 repeated.score_fingerprint,
2595 analyze_score(&score).score_fingerprint
2596 );
2597
2598 let mut changed = score.clone();
2599 changed.metadata.title = "Changed".to_string();
2600 assert_ne!(repeated.score_fingerprint, score_fingerprint(&changed));
2601 }
2602
2603 #[test]
2604 fn fingerprint_uses_fnv1a_byte_order() {
2605 assert_eq!(fnv1a64(b"hello"), 0xa430d84680aabd0b);
2606 }
2607
2608 #[test]
2609 fn cache_key_includes_schema_and_score_identity() {
2610 let result = analyze_score(&Score::default());
2611 assert!(result.cache_key().starts_with("analysis-v13-fnv1a64-"));
2612 assert_eq!(result.cache_key(), analysis_cache_key(&Score::default()));
2613 let mut changed = result.clone();
2614 changed.schema_version = ANALYSIS_SCHEMA_VERSION + 1;
2615 assert_ne!(result.cache_key(), changed.cache_key());
2616 }
2617
2618 #[test]
2619 fn analysis_cache_reuses_identical_score_and_misses_after_edit() {
2620 let score = Score::default();
2621 let mut cache = AnalysisCache::with_capacity(2).unwrap();
2622 let first = cache.analyze(&score);
2623 assert_eq!(cache.len(), 1);
2624 assert!(cache.get(&score).is_some());
2625 let second = cache.analyze(&score);
2626 assert_eq!(first, second);
2627
2628 let mut changed = score.clone();
2629 changed.metadata.title = "changed".to_owned();
2630 assert!(cache.get(&changed).is_none());
2631 let changed_result = cache.analyze(&changed);
2632 assert_ne!(first.score_fingerprint, changed_result.score_fingerprint);
2633 assert_eq!(cache.len(), 2);
2634 }
2635
2636 #[test]
2637 fn analysis_cache_eviction_is_bounded_and_deterministic() {
2638 let mut cache = AnalysisCache::with_capacity(1).unwrap();
2639 let first = Score::default();
2640 let mut second = first.clone();
2641 second.metadata.title = "second".to_owned();
2642 cache.analyze(&first);
2643 cache.analyze(&second);
2644 assert_eq!(cache.len(), 1);
2645 assert!(cache.get(&first).is_none());
2646 assert!(cache.get(&second).is_some());
2647 }
2648
2649 #[test]
2650 fn analysis_cache_batch_preserves_order_and_reuses_duplicate_scores() {
2651 let first = Score::default();
2652 let mut second = first.clone();
2653 second.metadata.title = "second".to_owned();
2654 let mut cache = AnalysisCache::with_capacity(2).unwrap();
2655 let results = cache.analyze_batch(&[second.clone(), first.clone(), second.clone()]);
2656
2657 assert_eq!(results.len(), 3);
2658 assert_eq!(results[0].score_fingerprint, results[2].score_fingerprint);
2659 assert_ne!(results[0].score_fingerprint, results[1].score_fingerprint);
2660 assert_eq!(cache.len(), 2);
2661 assert_eq!(cache.stats(), AnalysisCacheStats { hits: 1, misses: 2 });
2662 cache.reset_stats();
2663 assert_eq!(cache.stats(), AnalysisCacheStats::default());
2664 assert!(cache.get(&first).is_some());
2665 }
2666
2667 #[test]
2668 fn analysis_cache_invalidates_one_score_without_affecting_others() {
2669 let first = Score::default();
2670 let mut second = first.clone();
2671 second.metadata.title = "second".to_owned();
2672 let mut cache = AnalysisCache::with_capacity(2).unwrap();
2673 cache.analyze_batch(&[first.clone(), second.clone()]);
2674
2675 assert!(cache.invalidate(&first));
2676 assert!(!cache.invalidate(&first));
2677 assert!(cache.get(&first).is_none());
2678 assert!(cache.get(&second).is_some());
2679 assert_eq!(cache.len(), 1);
2680 }
2681
2682 #[test]
2683 fn analysis_cache_analyze_after_edit_reclaims_previous_snapshot() {
2684 let previous = Score::default();
2685 let mut current = previous.clone();
2686 current.metadata.title = "edited".to_owned();
2687 let mut cache = AnalysisCache::with_capacity(2).unwrap();
2688 cache.analyze(&previous);
2689
2690 let result = cache.analyze_after_edit(&previous, ¤t);
2691 assert!(result.matches_score(¤t));
2692 assert!(cache.get(&previous).is_none());
2693 assert!(cache.get(¤t).is_some());
2694 assert_eq!(cache.len(), 1);
2695 }
2696
2697 #[test]
2698 fn analysis_cache_analyze_after_noop_edit_reuses_existing_result() {
2699 let score = Score::default();
2700 let mut cache = AnalysisCache::with_capacity(1).unwrap();
2701 cache.analyze(&score);
2702 cache.reset_stats();
2703
2704 let result = cache.analyze_after_edit(&score, &score);
2705 assert!(result.matches_score(&score));
2706 assert_eq!(cache.stats(), AnalysisCacheStats { hits: 1, misses: 0 });
2707 assert_eq!(cache.len(), 1);
2708 }
2709
2710 #[test]
2711 fn analysis_cache_edit_with_diff_returns_result_and_changed_categories() {
2712 let previous = Score::default();
2713 let mut current = previous.clone();
2714 let voice = &mut current.parts[0].staves[0].measures[0].voices[0];
2715 voice.clear();
2716 for step in [Step::C, Step::E, Step::G] {
2717 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2718 }
2719 let previous_result = analyze_score(&previous);
2720 let mut cache = AnalysisCache::with_capacity(2).unwrap();
2721 cache.analyze(&previous);
2722
2723 let edited = cache.analyze_after_edit_with_diff(&previous, &previous_result, ¤t);
2724 assert!(edited.analysis.matches_score(¤t));
2725 assert_eq!(edited.diff.changed_categories[0], AnalysisCategory::Chords);
2726 assert!(
2727 edited
2728 .diff
2729 .changed_categories
2730 .contains(&AnalysisCategory::Intervals)
2731 );
2732 assert!(cache.get(&previous).is_none());
2733 assert!(cache.get(¤t).is_some());
2734
2735 cache.reset_stats();
2736 let no_op = cache.analyze_after_edit_with_diff(¤t, &edited.analysis, ¤t);
2737 assert!(no_op.diff.is_empty());
2738 assert_eq!(cache.stats(), AnalysisCacheStats { hits: 1, misses: 0 });
2739 }
2740
2741 #[test]
2742 fn selected_analysis_reuses_unchanged_categories_and_rebinds_identity() {
2743 let previous = Score::default();
2744 let previous_result = analyze_score(&previous);
2745 let mut current = previous.clone();
2746 current.metadata.title = "edited".to_owned();
2747 let mut cache = AnalysisCache::with_capacity(2).unwrap();
2748 let result = cache.analyze_selected_after_edit(&previous, &previous_result, ¤t, &[]);
2749
2750 assert!(result.matches_score(¤t));
2751 assert_eq!(result.chords, previous_result.chords);
2752 assert_ne!(result.score_fingerprint, previous_result.score_fingerprint);
2753 assert_eq!(cache.stats(), AnalysisCacheStats { hits: 0, misses: 1 });
2754 }
2755
2756 #[test]
2757 fn hinted_analysis_uses_clean_hint_without_recomputing_categories() {
2758 let previous = Score::default();
2759 let previous_result = analyze_score(&previous);
2760 let mut current = previous.clone();
2761 current.metadata.title = "edited".to_owned();
2762 let hint = ChangeHint {
2763 scope: acorde_core::ChangeScope::Global,
2764 layout_dirty: false,
2765 playback_dirty: false,
2766 };
2767 let mut cache = AnalysisCache::with_capacity(2).unwrap();
2768 let edited =
2769 cache.analyze_after_edit_with_hint(&previous, &previous_result, ¤t, &hint);
2770
2771 assert!(edited.diff.is_empty());
2772 assert!(edited.analysis.matches_score(¤t));
2773 assert_eq!(edited.analysis.chords, previous_result.chords);
2774 }
2775
2776 #[test]
2777 fn analysis_cache_rejects_zero_capacity() {
2778 assert!(matches!(
2779 AnalysisCache::with_capacity(0),
2780 Err(AnalysisCacheError::ZeroCapacity)
2781 ));
2782 }
2783
2784 #[test]
2785 fn analysis_result_rejects_a_different_score() {
2786 let score = Score::default();
2787 let result = analyze_score(&score);
2788 assert!(result.matches_score(&score));
2789 let mut changed = score.clone();
2790 changed.metadata.title = "Changed".to_string();
2791 assert!(!result.matches_score(&changed));
2792 }
2793
2794 #[test]
2795 fn analysis_diff_is_empty_for_identical_results() {
2796 let result = analyze_score(&Score::default());
2797 let diff = diff_analysis(&result, &result);
2798 assert!(diff.is_empty());
2799 assert_eq!(diff.previous_score_fingerprint, result.score_fingerprint);
2800 assert_eq!(diff.current_score_fingerprint, result.score_fingerprint);
2801 }
2802
2803 #[test]
2804 fn analysis_diff_keeps_score_identity_separate_from_category_changes() {
2805 let mut first_score = Score::default();
2806 first_score.metadata.title = "first".to_owned();
2807 let mut second_score = first_score.clone();
2808 second_score.metadata.title = "second".to_owned();
2809 let diff = diff_analysis(&analyze_score(&first_score), &analyze_score(&second_score));
2810
2811 assert!(diff.is_empty());
2812 assert!(diff.changed_categories.is_empty());
2813 assert_ne!(
2814 diff.previous_score_fingerprint,
2815 diff.current_score_fingerprint
2816 );
2817 }
2818
2819 #[test]
2820 fn analysis_diff_reports_changed_categories_in_stable_order() {
2821 let previous = analyze_score(&Score::default());
2822 let mut changed_score = Score::default();
2823 let voice = &mut changed_score.parts[0].staves[0].measures[0].voices[0];
2824 voice.clear();
2825 for step in [Step::C, Step::E, Step::G] {
2826 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2827 }
2828 let current = analyze_score(&changed_score);
2829 let diff = diff_analysis(&previous, ¤t);
2830
2831 assert!(!diff.is_empty());
2832 assert_eq!(diff.changed_categories[0], AnalysisCategory::Chords);
2833 assert!(
2834 diff.changed_categories
2835 .contains(&AnalysisCategory::Intervals)
2836 );
2837 }
2838
2839 #[test]
2840 fn affected_categories_are_conservative_and_stable() {
2841 let clean = ChangeHint {
2842 scope: acorde_core::ChangeScope::Global,
2843 layout_dirty: false,
2844 playback_dirty: false,
2845 };
2846 assert!(affected_categories_for_change_hint(&clean).is_empty());
2847
2848 let dirty = ChangeHint {
2849 scope: acorde_core::ChangeScope::Measures {
2850 part: 0,
2851 staff: 0,
2852 start: 1,
2853 end: 2,
2854 },
2855 layout_dirty: false,
2856 playback_dirty: true,
2857 };
2858 assert_eq!(
2859 affected_categories_for_change_hint(&dirty),
2860 vec![
2861 AnalysisCategory::Chords,
2862 AnalysisCategory::Intervals,
2863 AnalysisCategory::KeyEstimates,
2864 AnalysisCategory::CadenceCandidates,
2865 AnalysisCategory::VoiceLeading,
2866 AnalysisCategory::SatbDiagnostics,
2867 AnalysisCategory::Motifs,
2868 AnalysisCategory::PhraseBoundaries,
2869 ]
2870 );
2871 }
2872
2873 #[test]
2874 fn refresh_plan_separates_local_and_global_dependencies() {
2875 let hint = ChangeHint {
2876 scope: acorde_core::ChangeScope::Measures {
2877 part: 1,
2878 staff: 2,
2879 start: 3,
2880 end: 4,
2881 },
2882 layout_dirty: true,
2883 playback_dirty: true,
2884 };
2885 let plan = analysis_refresh_plan(&hint);
2886 assert_eq!(
2887 plan.region,
2888 Some(AnalysisRegion {
2889 part: 1,
2890 staff: 2,
2891 start_measure: 3,
2892 end_measure: 4,
2893 })
2894 );
2895 assert!(plan.local_categories.contains(&AnalysisCategory::Chords));
2896 assert!(
2897 plan.local_categories
2898 .contains(&AnalysisCategory::CadenceCandidates)
2899 );
2900 assert_eq!(
2901 plan.global_categories,
2902 vec![AnalysisCategory::KeyEstimates, AnalysisCategory::Motifs]
2903 );
2904 assert_eq!(plan.context_before, 1);
2905 assert_eq!(plan.context_after, 1);
2906 }
2907
2908 #[test]
2909 fn chord_region_keeps_outside_results_and_refreshes_inside() {
2910 let mut score = Score::default();
2911 for measure_index in 0..2 {
2912 let voice = &mut score.parts[0].staves[0].measures[measure_index].voices[0];
2913 voice.clear();
2914 for step in [Step::C, Step::E, Step::G] {
2915 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2916 }
2917 }
2918 let region = AnalysisRegion {
2919 part: 0,
2920 staff: 0,
2921 start_measure: 0,
2922 end_measure: 1,
2923 };
2924 let chords = analyze_chords_in_region(&score, Some(®ion));
2925 assert_eq!(chords.len(), 1);
2926 assert_eq!(chords[0].address.measure, 0);
2927 }
2928
2929 #[test]
2930 fn local_analysis_refresh_matches_full_pass_after_a_middle_measure_edit() {
2931 let mut score = Score::default();
2932 for measure in &mut score.parts[0].staves[0].measures {
2933 measure.voices[0] = [Step::C, Step::E, Step::G]
2934 .into_iter()
2935 .map(|step| Note::new(Pitch::new(step, 4), Duration::Quarter))
2936 .collect();
2937 measure.voices[1] = [Step::G, Step::A, Step::B]
2938 .into_iter()
2939 .map(|step| Note::new(Pitch::new(step, 3), Duration::Quarter))
2940 .collect();
2941 }
2942 let before = analyze_score(&score);
2943 score.parts[0].staves[0].measures[1].voices[0][1] =
2944 Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
2945 score.parts[0].staves[0].measures[1].voices[1][1] =
2946 Note::new(Pitch::new(Step::F, 3), Duration::Quarter);
2947 let region = AnalysisRegion {
2948 part: 0,
2949 staff: 0,
2950 start_measure: 1,
2951 end_measure: 2,
2952 };
2953 let refreshed = analyze_selected_categories_in_region(
2954 &score,
2955 &before,
2956 &[
2957 AnalysisCategory::Chords,
2958 AnalysisCategory::Intervals,
2959 AnalysisCategory::VoiceLeading,
2960 ],
2961 Some(®ion),
2962 );
2963 let full = analyze_score(&score);
2964 assert_eq!(refreshed.chords, full.chords);
2965 assert_eq!(refreshed.intervals, full.intervals);
2966 assert_eq!(refreshed.voice_leading, full.voice_leading);
2967 assert_eq!(refreshed.key_estimates, before.key_estimates);
2968 }
2969
2970 #[test]
2971 fn interval_region_includes_boundary_observations() {
2972 let mut score = Score::default();
2973 for measure_index in 0..2 {
2974 let voice = &mut score.parts[0].staves[0].measures[measure_index].voices[0];
2975 voice.clear();
2976 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2977 voice.push(Note::new(Pitch::new(Step::D, 4), Duration::Quarter));
2978 }
2979 let region = AnalysisRegion {
2980 part: 0,
2981 staff: 0,
2982 start_measure: 0,
2983 end_measure: 1,
2984 };
2985 let intervals = analyze_intervals_in_region(&score, Some(®ion));
2986 assert_eq!(intervals.len(), 1);
2987 assert_eq!(intervals[0].from.measure, 0);
2988 }
2989
2990 #[test]
2991 fn voice_leading_region_limits_measure_traversal() {
2992 let mut score = Score::default();
2993 for measure_index in 0..2 {
2994 let measure = &mut score.parts[0].staves[0].measures[measure_index];
2995 measure.voices[0].clear();
2996 measure.voices[1].clear();
2997 measure.voices[0].push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2998 measure.voices[1].push(Note::new(Pitch::new(Step::G, 3), Duration::Quarter));
2999 measure.voices[0].push(Note::new(Pitch::new(Step::D, 4), Duration::Quarter));
3000 measure.voices[1].push(Note::new(Pitch::new(Step::A, 3), Duration::Quarter));
3001 }
3002 let region = AnalysisRegion {
3003 part: 0,
3004 staff: 0,
3005 start_measure: 1,
3006 end_measure: 2,
3007 };
3008 let observations = analyze_voice_leading_in_region(&score, Some(®ion));
3009 assert_eq!(observations.len(), 1);
3010 assert_eq!(observations[0].upper.measure, 1);
3011 }
3012
3013 #[test]
3014 fn interval_observation_preserves_direction_and_evidence() {
3015 let mut score = Score::default();
3016 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
3017 voice.clear();
3018 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
3019 voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
3020 let intervals = analyze_intervals(&score);
3021 assert_eq!(intervals.len(), 1);
3022 assert_eq!(intervals[0].semitones, 7);
3023 assert_eq!(intervals[0].diatonic_steps, 4);
3024 assert_eq!(intervals[0].evidence.len(), 2);
3025 }
3026
3027 #[test]
3028 fn interval_analysis_treats_rests_as_melodic_boundaries() {
3029 let mut score = Score::default();
3030 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
3031 voice.clear();
3032 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
3033 voice.push(Note::rest(Duration::Quarter));
3034 voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
3035
3036 assert!(analyze_intervals(&score).is_empty());
3037 }
3038
3039 #[test]
3040 fn interval_observation_preserves_exact_microtonal_distance() {
3041 let mut score = Score::default();
3042 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
3043 voice.clear();
3044 voice.push(Note::new(
3045 Pitch::try_with_microtone(Step::C, 4, 0, 25).expect("valid microtone"),
3046 Duration::Quarter,
3047 ));
3048 voice.push(Note::new(Pitch::new(Step::D, 4), Duration::Quarter));
3049
3050 let intervals = analyze_intervals(&score);
3051 assert_eq!(intervals.len(), 1);
3052 assert_eq!(intervals[0].semitones, 2);
3053 assert_eq!(intervals[0].cents, 175);
3054 }
3055
3056 #[test]
3057 fn key_estimates_report_duration_weighted_coverage() {
3058 let mut score = Score::default();
3059 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
3060 voice.clear();
3061 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Whole));
3062 voice.push(Note::new(Pitch::new(Step::D, 4), Duration::Eighth));
3063
3064 let estimates = estimate_keys(&score);
3065 assert!(!estimates.is_empty());
3066 assert!(estimates.iter().all(|estimate| {
3067 (estimate.total_duration_beats - 4.5).abs() < f64::EPSILON
3068 && estimate.weighted_covered_beats <= estimate.total_duration_beats
3069 && estimate.rule_id == "duration-weighted-diatonic-pitch-coverage"
3070 }));
3071 }
3072
3073 #[test]
3074 fn does_not_invent_label_for_unknown_pitch_set() {
3075 let mut score = Score::default();
3076 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
3077 voice.clear();
3078 for step in [Step::C, Step::C] {
3079 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
3080 }
3081 assert!(analyze_chords(&score).chords.is_empty());
3082 }
3083
3084 #[test]
3085 fn preserves_relative_major_minor_key_ambiguity() {
3086 let mut score = Score::default();
3087 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
3088 voice.clear();
3089 for step in [
3090 Step::C,
3091 Step::D,
3092 Step::E,
3093 Step::F,
3094 Step::G,
3095 Step::A,
3096 Step::B,
3097 ] {
3098 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
3099 }
3100 let estimates = estimate_keys(&score);
3101 assert!(
3102 estimates
3103 .iter()
3104 .any(|estimate| estimate.key.display_name() == "C major")
3105 );
3106 assert!(
3107 estimates
3108 .iter()
3109 .any(|estimate| estimate.key.display_name() == "A minor")
3110 );
3111 assert!(estimates.iter().all(|estimate| estimate.confidence == 100));
3112 }
3113
3114 #[test]
3115 fn tonal_profile_ranks_minor_center_without_discarding_legacy_candidates() {
3116 let mut score = Score::default();
3117 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
3118 voice.clear();
3119 for (step, duration) in [
3120 (Step::A, Duration::Whole),
3121 (Step::C, Duration::Quarter),
3122 (Step::E, Duration::Quarter),
3123 (Step::A, Duration::Whole),
3124 ] {
3125 voice.push(Note::new(Pitch::new(step, 4), duration));
3126 }
3127
3128 let estimates = estimate_keys(&score);
3129 let original_keys: Vec<_> = estimates
3130 .iter()
3131 .map(|estimate| estimate.key.display_name())
3132 .collect();
3133 assert!(original_keys.contains(&"C major".to_string()));
3134 assert!(original_keys.contains(&"A minor".to_string()));
3135 let ranked = estimate_keys_with_tonal_profile(&score);
3136 assert_eq!(ranked[0].key.display_name(), "A minor");
3137 assert_eq!(ranked.len(), original_keys.len());
3138 assert!(
3139 ranked
3140 .iter()
3141 .all(|estimate| estimate.rule_id.ends_with("krumhansl-profile-ranking"))
3142 );
3143 }
3144
3145 #[test]
3146 fn tonal_profile_does_not_round_microtones_into_key_evidence() {
3147 let mut score = Score::default();
3148 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
3149 voice.clear();
3150 voice.push(Note::new(Pitch::new(Step::A, 4), Duration::Quarter));
3151 voice.push(Note::new(
3152 Pitch::with_microtone(Step::C, 5, 0, 25),
3153 Duration::Quarter,
3154 ));
3155 let original = estimate_keys(&score);
3156 assert_eq!(estimate_keys_with_tonal_profile(&score), original);
3157 }
3158
3159 #[test]
3160 fn aarden_essen_profile_keeps_candidates_discarded_by_coverage() {
3161 let mut score = Score::default();
3162 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
3163 voice.clear();
3164 for step in [
3165 Step::A,
3166 Step::B,
3167 Step::C,
3168 Step::D,
3169 Step::E,
3170 Step::F,
3171 Step::G,
3172 ] {
3173 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
3174 }
3175 let coverage = estimate_keys(&score);
3176 let full = estimate_keys_with_aarden_essen_profile(&score);
3177 assert!(coverage.len() < full.len());
3178 assert_eq!(full.len(), 30);
3179 assert!(
3180 full.iter()
3181 .any(|estimate| estimate.key.display_name() == "A major")
3182 );
3183 assert!(full.iter().all(|estimate| {
3184 estimate.rule_id == "duration-weighted-aarden-essen-profile-ranking"
3185 }));
3186 }
3187
3188 #[test]
3189 fn aarden_essen_profile_falls_back_on_microtones() {
3190 let mut score = Score::default();
3191 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
3192 voice.clear();
3193 voice.push(Note::new(Pitch::new(Step::A, 4), Duration::Quarter));
3194 voice.push(Note::new(
3195 Pitch::with_microtone(Step::C, 5, 0, 25),
3196 Duration::Quarter,
3197 ));
3198 assert_eq!(
3199 estimate_keys_with_aarden_essen_profile(&score),
3200 estimate_keys(&score)
3201 );
3202 }
3203
3204 #[test]
3205 fn returns_no_key_for_empty_score() {
3206 assert!(estimate_keys(&Score::default()).is_empty());
3207 }
3208
3209 #[test]
3210 fn batch_and_stream_preserve_score_order() {
3211 let scores = vec![Score::default(), Score::default()];
3212 let batch = analyze_batch(&scores);
3213 let streamed: Vec<_> = analyze_stream(scores.clone()).collect();
3214 assert_eq!(batch, streamed);
3215 assert_eq!(batch.len(), scores.len());
3216 }
3217
3218 #[test]
3219 fn detects_authentic_cadence_from_adjacent_measures() {
3220 let mut score = Score::default();
3221 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
3222 voice.clear();
3223 for (step, octave) in [(Step::G, 3), (Step::B, 3), (Step::D, 4)] {
3224 voice.push(Note::new(Pitch::new(step, octave), Duration::Quarter));
3225 }
3226 let voice = &mut score.parts[0].staves[0].measures[1].voices[0];
3227 voice.clear();
3228 for step in [Step::C, Step::E, Step::G] {
3229 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
3230 }
3231 let result = analyze_score(&score);
3232 assert_eq!(result.cadence_candidates.len(), 1);
3233 assert_eq!(result.cadence_candidates[0].kind, CadenceKind::Authentic);
3234 assert_eq!(result.cadence_candidates[0].evidence.len(), 2);
3235 }
3236
3237 #[test]
3238 fn flags_parallel_octaves_between_aligned_voices() {
3239 let mut score = Score::default();
3240 let measure = &mut score.parts[0].staves[0].measures[0];
3241 measure.voices[0].clear();
3242 measure.voices[1].clear();
3243 for (upper, lower) in [(Step::C, Step::C), (Step::D, Step::D)] {
3244 measure.voices[0].push(Note::new(Pitch::new(upper, 4), Duration::Quarter));
3245 measure.voices[1].push(Note::new(Pitch::new(lower, 3), Duration::Quarter));
3246 }
3247 let observations = analyze_voice_leading(&score);
3248 assert_eq!(observations.len(), 1);
3249 assert!(observations[0].parallel_perfect);
3250 assert_eq!(observations[0].upper_motion_cents, 200);
3251 assert_eq!(observations[0].lower_motion_cents, 200);
3252 assert_eq!(observations[0].evidence.len(), 2);
3253 let diagnostics = analyze_satb(&score);
3254 assert_eq!(diagnostics.len(), 1);
3255 assert_eq!(diagnostics[0].kind, SatbDiagnosticKind::ParallelPerfect);
3256 assert_eq!(diagnostics[0].severity, SatbSeverity::Warning);
3257 }
3258
3259 #[test]
3260 fn voice_leading_does_not_bridge_rests() {
3261 let mut score = Score::default();
3262 let measure = &mut score.parts[0].staves[0].measures[0];
3263 measure.voices[0].clear();
3264 measure.voices[1].clear();
3265 let (upper_voices, lower_voices) = measure.voices.split_at_mut(1);
3266 for voice in [&mut upper_voices[0], &mut lower_voices[0]] {
3267 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
3268 voice.push(Note::rest(Duration::Quarter));
3269 voice.push(Note::new(Pitch::new(Step::D, 4), Duration::Quarter));
3270 }
3271 assert!(analyze_voice_leading(&score).is_empty());
3272 }
3273
3274 #[test]
3275 fn reports_voice_crossing_as_error() {
3276 let mut score = Score::default();
3277 let measure = &mut score.parts[0].staves[0].measures[0];
3278 measure.voices[0].clear();
3279 measure.voices[1].clear();
3280 measure.voices[0].push(Note::new(Pitch::new(Step::C, 3), Duration::Quarter));
3281 measure.voices[1].push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
3282 let diagnostics = analyze_satb(&score);
3283 assert_eq!(diagnostics.len(), 1);
3284 assert_eq!(diagnostics[0].kind, SatbDiagnosticKind::VoiceCrossing);
3285 assert_eq!(diagnostics[0].severity, SatbSeverity::Error);
3286 }
3287
3288 #[test]
3289 fn satb_region_replaces_local_findings_and_preserves_outside_findings() {
3290 let mut previous_score = Score::default();
3291 for measure in &mut previous_score.parts[0].staves[0].measures {
3292 measure.voices[0].clear();
3293 measure.voices[1].clear();
3294 measure.voices[0].push(Note::new(Pitch::new(Step::C, 3), Duration::Quarter));
3295 measure.voices[1].push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
3296 }
3297 let previous = analyze_score(&previous_score);
3298
3299 let mut edited_score = previous_score.clone();
3300 let measure = &mut edited_score.parts[0].staves[0].measures[0];
3301 measure.voices[0][0] = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3302 let region = AnalysisRegion {
3303 part: 0,
3304 staff: 0,
3305 start_measure: 0,
3306 end_measure: 1,
3307 };
3308 let refreshed = analyze_selected_categories_in_region(
3309 &edited_score,
3310 &previous,
3311 &[AnalysisCategory::SatbDiagnostics],
3312 Some(®ion),
3313 );
3314
3315 assert_eq!(refreshed.satb_diagnostics.len(), 3);
3316 assert!(
3317 refreshed
3318 .satb_diagnostics
3319 .iter()
3320 .all(|diagnostic| diagnostic.upper.measure >= 1)
3321 );
3322 }
3323
3324 #[test]
3325 fn finds_repeated_melodic_motif_with_source_spans() {
3326 let mut score = Score::default();
3327 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
3328 voice.clear();
3329 for step in [Step::C, Step::D, Step::E, Step::G, Step::A, Step::B] {
3330 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
3331 }
3332 let motifs = analyze_motifs(&score);
3333 assert_eq!(motifs.len(), 1);
3334 assert_eq!(motifs[0].signature, vec![2, 2]);
3335 assert_eq!(motifs[0].occurrences.len(), 2);
3336 assert_eq!(motifs[0].occurrences[0].evidence.len(), 3);
3337 }
3338
3339 #[test]
3340 fn reports_measure_ending_rest_as_phrase_boundary() {
3341 let mut score = Score::default();
3342 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
3343 voice.clear();
3344 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
3345 voice.push(Note::rest(Duration::Quarter));
3346 let boundaries = analyze_phrase_boundaries(&score);
3347 assert!(boundaries.iter().any(|boundary| {
3348 boundary.reason == PhraseBoundaryReason::RestTermination
3349 && boundary.address.measure == 0
3350 && boundary.address.note == 1
3351 }));
3352 }
3353
3354 #[test]
3355 fn benchmark_reports_perfect_scores_for_hand_verified_empty_fixture() {
3356 let score = Score::default();
3357 let cases = [BenchmarkCase {
3358 name: "empty-score",
3359 score: &score,
3360 expected: BenchmarkExpectation {
3361 phrase_boundaries: 4,
3362 ..BenchmarkExpectation::default()
3363 },
3364 }];
3365 let reports = run_benchmark(&cases);
3366 assert_eq!(reports.len(), 1);
3367 assert_eq!(reports[0].precision_percent, 100);
3368 assert_eq!(reports[0].recall_percent, 100);
3369 assert_eq!(reports[0].explanation_completeness_percent, 100);
3370 assert!(reports[0].failures.is_empty());
3371 }
3372
3373 #[test]
3374 fn benchmark_reports_category_level_failure_details() {
3375 let score = Score::default();
3376 let cases = [BenchmarkCase {
3377 name: "under-annotated-score",
3378 score: &score,
3379 expected: BenchmarkExpectation {
3380 phrase_boundaries: 5,
3381 ..BenchmarkExpectation::default()
3382 },
3383 }];
3384 let reports = run_benchmark(&cases);
3385 assert_eq!(
3386 reports[0].failures,
3387 vec![BenchmarkFailure {
3388 category: BenchmarkCategory::PhraseBoundaries,
3389 expected: 5,
3390 predicted: 4,
3391 missing: 1,
3392 excess: 0,
3393 }]
3394 );
3395 }
3396
3397 #[test]
3398 fn benchmark_suite_aggregates_case_status_and_metrics() {
3399 let score = Score::default();
3400 let cases = [
3401 BenchmarkCase {
3402 name: "passing",
3403 score: &score,
3404 expected: BenchmarkExpectation {
3405 phrase_boundaries: 4,
3406 ..BenchmarkExpectation::default()
3407 },
3408 },
3409 BenchmarkCase {
3410 name: "failing",
3411 score: &score,
3412 expected: BenchmarkExpectation {
3413 phrase_boundaries: 5,
3414 ..BenchmarkExpectation::default()
3415 },
3416 },
3417 ];
3418 let suite = run_benchmark_suite(&cases);
3419 assert_eq!(suite.case_count, 2);
3420 assert_eq!(suite.passed_case_count, 1);
3421 assert_eq!(suite.failed_case_count, 1);
3422 assert_eq!(suite.cases.len(), 2);
3423 assert_eq!(suite.precision_percent, 100);
3424 assert_eq!(suite.recall_percent, 90);
3425 assert_eq!(suite.explanation_completeness_percent, 100);
3426 }
3427
3428 struct TestPass(&'static str);
3429
3430 impl AnalysisPass for TestPass {
3431 fn id(&self) -> &str {
3432 self.0
3433 }
3434
3435 fn run(&self, _score: &Score) -> serde_json::Value {
3436 serde_json::json!({ "pass": self.0 })
3437 }
3438 }
3439
3440 #[test]
3441 fn extension_passes_run_in_stable_id_order() {
3442 let score = Score::default();
3443 let beta = TestPass("beta");
3444 let alpha = TestPass("alpha");
3445 let results = run_analysis_passes(&score, &[&beta, &alpha]).unwrap();
3446 assert_eq!(
3447 results
3448 .iter()
3449 .map(|result| result.pass_id.as_str())
3450 .collect::<Vec<_>>(),
3451 ["alpha", "beta"]
3452 );
3453 assert_eq!(results[0].output["pass"], "alpha");
3454 }
3455
3456 #[test]
3457 fn extension_passes_reject_empty_and_duplicate_ids() {
3458 let score = Score::default();
3459 let empty = TestPass("");
3460 assert_eq!(
3461 run_analysis_passes(&score, &[&empty]),
3462 Err(AnalysisPassError::EmptyId)
3463 );
3464 let first = TestPass("same");
3465 let second = TestPass("same");
3466 assert_eq!(
3467 run_analysis_passes(&score, &[&first, &second]),
3468 Err(AnalysisPassError::DuplicateId("same".to_string()))
3469 );
3470 }
3471}