1use acorde_core::{
4 ChangeHint, ChordSymbol, KeySignature, NoteAddr, Score, detect_chord, roman_numeral,
5};
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, HashMap, VecDeque};
8use thiserror::Error;
9
10pub const ANALYSIS_SCHEMA_VERSION: u32 = 7;
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct ChordLabel {
16 pub address: NoteAddr,
17 pub chord: ChordSymbol,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub roman_numeral: Option<String>,
20 pub confidence: u8,
21 pub rule_id: String,
22 pub evidence: Vec<NoteAddr>,
23}
24
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub struct AnalysisResult {
28 pub schema_version: u32,
29 #[serde(default)]
31 pub score_fingerprint: String,
32 pub chords: Vec<ChordLabel>,
33 pub intervals: Vec<IntervalObservation>,
34 #[serde(default)]
35 pub key_estimates: Vec<KeyEstimate>,
36 #[serde(default)]
37 pub cadence_candidates: Vec<CadenceCandidate>,
38 #[serde(default)]
39 pub voice_leading: Vec<VoiceLeadingObservation>,
40 #[serde(default)]
41 pub satb_diagnostics: Vec<SatbDiagnostic>,
42 #[serde(default)]
43 pub motifs: Vec<MotifPattern>,
44 #[serde(default)]
45 pub phrase_boundaries: Vec<PhraseBoundary>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub enum AnalysisCategory {
51 Chords,
52 Intervals,
53 KeyEstimates,
54 CadenceCandidates,
55 VoiceLeading,
56 SatbDiagnostics,
57 Motifs,
58 PhraseBoundaries,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct AnalysisRegion {
64 pub part: usize,
65 pub staff: usize,
66 pub start_measure: usize,
67 pub end_measure: usize,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct AnalysisRefreshPlan {
73 pub local_categories: Vec<AnalysisCategory>,
74 pub global_categories: Vec<AnalysisCategory>,
75 pub region: Option<AnalysisRegion>,
76 pub context_before: usize,
77 pub context_after: usize,
78}
79
80pub fn analysis_refresh_plan(hint: &ChangeHint) -> AnalysisRefreshPlan {
82 let all = all_analysis_categories();
83 if !hint.layout_dirty && !hint.playback_dirty {
84 return AnalysisRefreshPlan {
85 local_categories: Vec::new(),
86 global_categories: Vec::new(),
87 region: None,
88 context_before: 0,
89 context_after: 0,
90 };
91 }
92 match &hint.scope {
93 acorde_core::ChangeScope::Measures {
94 part,
95 staff,
96 start,
97 end,
98 } => AnalysisRefreshPlan {
99 local_categories: vec![
100 AnalysisCategory::Chords,
101 AnalysisCategory::Intervals,
102 AnalysisCategory::CadenceCandidates,
103 AnalysisCategory::VoiceLeading,
104 AnalysisCategory::SatbDiagnostics,
105 AnalysisCategory::PhraseBoundaries,
106 ],
107 global_categories: vec![AnalysisCategory::KeyEstimates, AnalysisCategory::Motifs],
108 region: Some(AnalysisRegion {
109 part: *part,
110 staff: *staff,
111 start_measure: *start,
112 end_measure: *end,
113 }),
114 context_before: 1,
115 context_after: 1,
116 },
117 acorde_core::ChangeScope::Part(_) | acorde_core::ChangeScope::Global => {
118 AnalysisRefreshPlan {
119 local_categories: Vec::new(),
120 global_categories: all,
121 region: None,
122 context_before: 0,
123 context_after: 0,
124 }
125 }
126 }
127}
128
129fn all_analysis_categories() -> Vec<AnalysisCategory> {
130 vec![
131 AnalysisCategory::Chords,
132 AnalysisCategory::Intervals,
133 AnalysisCategory::KeyEstimates,
134 AnalysisCategory::CadenceCandidates,
135 AnalysisCategory::VoiceLeading,
136 AnalysisCategory::SatbDiagnostics,
137 AnalysisCategory::Motifs,
138 AnalysisCategory::PhraseBoundaries,
139 ]
140}
141
142pub fn affected_categories_for_change_hint(hint: &ChangeHint) -> Vec<AnalysisCategory> {
148 let plan = analysis_refresh_plan(hint);
149 let selected: Vec<_> = plan
150 .local_categories
151 .into_iter()
152 .chain(plan.global_categories)
153 .collect();
154 all_analysis_categories()
155 .into_iter()
156 .filter(|category| selected.contains(category))
157 .collect()
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct AnalysisDiff {
163 pub previous_score_fingerprint: String,
164 pub current_score_fingerprint: String,
165 pub schema_changed: bool,
166 pub changed_categories: Vec<AnalysisCategory>,
167}
168
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171pub struct AnalysisEditResult {
172 pub analysis: AnalysisResult,
173 pub diff: AnalysisDiff,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct AnalysisProvenance {
179 pub category: AnalysisCategory,
180 pub rule_id: String,
181 pub confidence: u8,
182 pub evidence: Vec<NoteAddr>,
183}
184
185pub fn analysis_provenance(
187 analysis: &AnalysisResult,
188 address: &NoteAddr,
189) -> Vec<AnalysisProvenance> {
190 let mut findings = Vec::new();
191 let mut add = |category, rule_id: &str, confidence, evidence: &[NoteAddr]| {
192 if evidence.iter().any(|item| item == address) {
193 findings.push(AnalysisProvenance {
194 category,
195 rule_id: rule_id.to_string(),
196 confidence,
197 evidence: evidence.to_vec(),
198 });
199 }
200 };
201 for item in &analysis.chords {
202 add(
203 AnalysisCategory::Chords,
204 &item.rule_id,
205 item.confidence,
206 &item.evidence,
207 );
208 }
209 for item in &analysis.intervals {
210 add(
211 AnalysisCategory::Intervals,
212 &item.rule_id,
213 100,
214 &item.evidence,
215 );
216 }
217 for item in &analysis.key_estimates {
218 add(
219 AnalysisCategory::KeyEstimates,
220 &item.rule_id,
221 item.confidence,
222 &item.evidence,
223 );
224 }
225 for item in &analysis.cadence_candidates {
226 add(
227 AnalysisCategory::CadenceCandidates,
228 &item.rule_id,
229 item.confidence,
230 &item.evidence,
231 );
232 }
233 for item in &analysis.voice_leading {
234 add(
235 AnalysisCategory::VoiceLeading,
236 &item.rule_id,
237 item.confidence,
238 &item.evidence,
239 );
240 }
241 for item in &analysis.satb_diagnostics {
242 add(
243 AnalysisCategory::SatbDiagnostics,
244 &item.rule_id,
245 item.confidence,
246 &item.evidence,
247 );
248 }
249 for item in &analysis.motifs {
250 for occurrence in &item.occurrences {
251 add(
252 AnalysisCategory::Motifs,
253 &item.rule_id,
254 item.confidence,
255 &occurrence.evidence,
256 );
257 }
258 }
259 for item in &analysis.phrase_boundaries {
260 add(
261 AnalysisCategory::PhraseBoundaries,
262 &item.rule_id,
263 item.confidence,
264 &item.evidence,
265 );
266 }
267 findings.sort_by_key(|item| {
268 (
269 analysis_category_rank(item.category),
270 item.rule_id.clone(),
271 item.evidence
272 .iter()
273 .map(|address| {
274 (
275 address.part,
276 address.staff,
277 address.measure,
278 address.voice,
279 address.note,
280 )
281 })
282 .collect::<Vec<_>>(),
283 )
284 });
285 findings
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub struct AnalysisChangeExplanation {
291 pub diff: AnalysisDiff,
292 pub previous: Vec<AnalysisProvenance>,
293 pub current: Vec<AnalysisProvenance>,
294}
295
296pub fn explain_analysis_change(
298 previous: &AnalysisResult,
299 current: &AnalysisResult,
300 address: &NoteAddr,
301) -> AnalysisChangeExplanation {
302 AnalysisChangeExplanation {
303 diff: diff_analysis(previous, current),
304 previous: analysis_provenance(previous, address),
305 current: analysis_provenance(current, address),
306 }
307}
308
309fn analysis_category_rank(category: AnalysisCategory) -> u8 {
310 match category {
311 AnalysisCategory::Chords => 0,
312 AnalysisCategory::Intervals => 1,
313 AnalysisCategory::KeyEstimates => 2,
314 AnalysisCategory::CadenceCandidates => 3,
315 AnalysisCategory::VoiceLeading => 4,
316 AnalysisCategory::SatbDiagnostics => 5,
317 AnalysisCategory::Motifs => 6,
318 AnalysisCategory::PhraseBoundaries => 7,
319 }
320}
321
322impl AnalysisDiff {
323 pub fn is_empty(&self) -> bool {
325 !self.schema_changed && self.changed_categories.is_empty()
326 }
327}
328
329pub fn diff_analysis(previous: &AnalysisResult, current: &AnalysisResult) -> AnalysisDiff {
331 let mut changed_categories = Vec::new();
332 if previous.chords != current.chords {
333 changed_categories.push(AnalysisCategory::Chords);
334 }
335 if previous.intervals != current.intervals {
336 changed_categories.push(AnalysisCategory::Intervals);
337 }
338 if previous.key_estimates != current.key_estimates {
339 changed_categories.push(AnalysisCategory::KeyEstimates);
340 }
341 if previous.cadence_candidates != current.cadence_candidates {
342 changed_categories.push(AnalysisCategory::CadenceCandidates);
343 }
344 if previous.voice_leading != current.voice_leading {
345 changed_categories.push(AnalysisCategory::VoiceLeading);
346 }
347 if previous.satb_diagnostics != current.satb_diagnostics {
348 changed_categories.push(AnalysisCategory::SatbDiagnostics);
349 }
350 if previous.motifs != current.motifs {
351 changed_categories.push(AnalysisCategory::Motifs);
352 }
353 if previous.phrase_boundaries != current.phrase_boundaries {
354 changed_categories.push(AnalysisCategory::PhraseBoundaries);
355 }
356 AnalysisDiff {
357 previous_score_fingerprint: previous.score_fingerprint.clone(),
358 current_score_fingerprint: current.score_fingerprint.clone(),
359 schema_changed: previous.schema_version != current.schema_version,
360 changed_categories,
361 }
362}
363
364impl AnalysisResult {
365 pub fn cache_key(&self) -> String {
367 format!(
368 "analysis-v{}-{}",
369 self.schema_version, self.score_fingerprint
370 )
371 }
372
373 pub fn matches_score(&self, score: &Score) -> bool {
375 self.score_fingerprint == score_fingerprint(score)
376 }
377}
378
379pub trait AnalysisPass {
381 fn id(&self) -> &str;
383
384 fn run(&self, score: &Score) -> serde_json::Value;
386}
387
388#[derive(Debug, Error, Clone, PartialEq, Eq)]
390pub enum AnalysisPassError {
391 #[error("analysis pass ID must not be empty")]
392 EmptyId,
393 #[error("duplicate analysis pass ID: {0}")]
394 DuplicateId(String),
395}
396
397#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
399pub struct AnalysisPassResult {
400 pub pass_id: String,
401 pub output: serde_json::Value,
402}
403
404pub fn run_analysis_passes(
406 score: &Score,
407 passes: &[&dyn AnalysisPass],
408) -> Result<Vec<AnalysisPassResult>, AnalysisPassError> {
409 let mut ordered: Vec<&dyn AnalysisPass> = passes.to_vec();
410 for pass in &ordered {
411 if pass.id().is_empty() {
412 return Err(AnalysisPassError::EmptyId);
413 }
414 }
415 ordered.sort_by(|left, right| left.id().cmp(right.id()));
416 for pair in ordered.windows(2) {
417 if pair[0].id() == pair[1].id() {
418 return Err(AnalysisPassError::DuplicateId(pair[0].id().to_string()));
419 }
420 }
421 Ok(ordered
422 .into_iter()
423 .map(|pass| AnalysisPassResult {
424 pass_id: pass.id().to_string(),
425 output: pass.run(score),
426 })
427 .collect())
428}
429
430#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
432pub struct KeyEstimate {
433 pub key: KeySignature,
434 pub covered_pitches: usize,
435 pub total_pitches: usize,
436 pub confidence: u8,
437 pub rule_id: String,
438 pub evidence: Vec<NoteAddr>,
439}
440
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443pub struct CadenceCandidate {
444 pub from: NoteAddr,
445 pub to: NoteAddr,
446 pub kind: CadenceKind,
447 pub confidence: u8,
448 pub rule_id: String,
449 pub evidence: Vec<NoteAddr>,
450}
451
452#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
454pub enum CadenceKind {
455 Authentic,
456 Plagal,
457 Deceptive,
458 Half,
459}
460
461#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
463pub struct VoiceLeadingObservation {
464 pub upper: NoteAddr,
465 pub lower: NoteAddr,
466 pub upper_motion: i16,
467 pub lower_motion: i16,
468 pub parallel_perfect: bool,
469 pub confidence: u8,
470 pub rule_id: String,
471 pub evidence: Vec<NoteAddr>,
472}
473
474#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
476pub struct SatbDiagnostic {
477 pub upper: NoteAddr,
478 pub lower: NoteAddr,
479 pub kind: SatbDiagnosticKind,
480 pub severity: SatbSeverity,
481 pub confidence: u8,
482 pub rule_id: String,
483 pub evidence: Vec<NoteAddr>,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
488pub enum SatbDiagnosticKind {
489 VoiceCrossing,
490 WideSpacing,
491 ParallelPerfect,
492}
493
494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
496pub enum SatbSeverity {
497 Error,
498 Warning,
499}
500
501#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
503pub struct MotifPattern {
504 pub signature: Vec<i8>,
505 pub occurrences: Vec<MotifOccurrence>,
506 pub confidence: u8,
507 pub rule_id: String,
508}
509
510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
512pub struct MotifOccurrence {
513 pub start: NoteAddr,
514 pub end: NoteAddr,
515 pub evidence: Vec<NoteAddr>,
516}
517
518#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
520pub struct PhraseBoundary {
521 pub address: NoteAddr,
522 pub reason: PhraseBoundaryReason,
523 pub confidence: u8,
524 pub rule_id: String,
525 pub evidence: Vec<NoteAddr>,
526}
527
528#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
530pub enum PhraseBoundaryReason {
531 RestTermination,
532}
533
534#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
536pub struct BenchmarkExpectation {
537 pub chords: usize,
538 pub intervals: usize,
539 pub key_estimates: usize,
540 pub cadence_candidates: usize,
541 pub voice_leading: usize,
542 pub satb_diagnostics: usize,
543 pub motifs: usize,
544 pub phrase_boundaries: usize,
545}
546
547#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
549pub struct AnalysisCounts {
550 pub chords: usize,
551 pub intervals: usize,
552 pub key_estimates: usize,
553 pub cadence_candidates: usize,
554 pub voice_leading: usize,
555 pub satb_diagnostics: usize,
556 pub motifs: usize,
557 pub phrase_boundaries: usize,
558}
559
560#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
562pub enum BenchmarkCategory {
563 Chords,
564 Intervals,
565 KeyEstimates,
566 CadenceCandidates,
567 VoiceLeading,
568 SatbDiagnostics,
569 Motifs,
570 PhraseBoundaries,
571}
572
573#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
575pub struct BenchmarkFailure {
576 pub category: BenchmarkCategory,
577 pub expected: usize,
578 pub predicted: usize,
579 pub missing: usize,
580 pub excess: usize,
581}
582
583#[derive(Debug, Clone)]
585pub struct BenchmarkCase<'a> {
586 pub name: &'a str,
587 pub score: &'a Score,
588 pub expected: BenchmarkExpectation,
589}
590
591#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
593pub struct BenchmarkCaseReport {
594 pub name: String,
595 pub predicted: AnalysisCounts,
596 pub expected: BenchmarkExpectation,
597 pub precision_percent: u8,
598 pub recall_percent: u8,
599 pub explanation_completeness_percent: u8,
600 pub failures: Vec<BenchmarkFailure>,
601}
602
603#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
605pub struct BenchmarkSuiteReport {
606 pub cases: Vec<BenchmarkCaseReport>,
607 pub case_count: usize,
608 pub passed_case_count: usize,
609 pub failed_case_count: usize,
610 pub precision_percent: u8,
611 pub recall_percent: u8,
612 pub explanation_completeness_percent: u8,
613}
614
615#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
617pub struct IntervalObservation {
618 pub from: NoteAddr,
619 pub to: NoteAddr,
620 pub semitones: u8,
621 pub diatonic_steps: i8,
622 pub rule_id: String,
623 pub evidence: Vec<NoteAddr>,
624}
625
626pub fn analyze_score(score: &Score) -> AnalysisResult {
628 let chords = analyze_chords_only(score);
629 let intervals = analyze_intervals(score);
630 let key_estimates = estimate_keys(score);
631 let cadence_candidates = analyze_cadences(&chords);
632 let voice_leading = analyze_voice_leading(score);
633 let satb_diagnostics = analyze_satb_in_region(score, &voice_leading, None);
634 let motifs = analyze_motifs(score);
635 let phrase_boundaries = analyze_phrase_boundaries(score);
636 AnalysisResult {
637 schema_version: ANALYSIS_SCHEMA_VERSION,
638 score_fingerprint: score_fingerprint(score),
639 chords,
640 intervals,
641 key_estimates,
642 cadence_candidates,
643 voice_leading,
644 satb_diagnostics,
645 motifs,
646 phrase_boundaries,
647 }
648}
649
650fn analyze_chords_only(score: &Score) -> Vec<ChordLabel> {
651 analyze_chords_in_region(score, None)
652}
653
654pub fn analyze_chords_in_region(score: &Score, region: Option<&AnalysisRegion>) -> Vec<ChordLabel> {
656 let mut chords = Vec::new();
657 for (part_index, part) in score.parts.iter().enumerate() {
658 for (staff_index, staff) in part.staves.iter().enumerate() {
659 for (measure_index, measure) in staff.measures.iter().enumerate() {
660 if let Some(region) = region
661 && (part_index != region.part
662 || staff_index != region.staff
663 || measure_index < region.start_measure
664 || measure_index >= region.end_measure)
665 {
666 continue;
667 }
668 let key = measure
669 .key_sig
670 .as_ref()
671 .unwrap_or(&score.settings.key_signature);
672 for (voice_index, voice) in measure.voices.iter().enumerate() {
673 let pitched: Vec<_> = voice
674 .iter()
675 .enumerate()
676 .filter(|(_, note)| !note.is_rest && !note.pitches.is_empty())
677 .collect();
678 let pitches: Vec<_> = pitched
679 .iter()
680 .flat_map(|(_, note)| note.pitches.iter().cloned())
681 .collect();
682 let Some(chord) = detect_chord(&pitches) else {
683 continue;
684 };
685 let evidence = pitched
686 .iter()
687 .map(|(note_index, _)| NoteAddr {
688 part: part_index,
689 staff: staff_index,
690 measure: measure_index,
691 voice: voice_index,
692 note: *note_index,
693 })
694 .collect();
695 chords.push(ChordLabel {
696 address: NoteAddr {
697 part: part_index,
698 staff: staff_index,
699 measure: measure_index,
700 voice: voice_index,
701 note: pitched[0].0,
702 },
703 roman_numeral: roman_numeral(&chord, key),
704 chord,
705 confidence: 100,
706 rule_id: "pitch-class-template".to_string(),
707 evidence,
708 });
709 }
710 }
711 }
712 }
713 chords
714}
715
716pub fn analyze_selected_categories(
722 score: &Score,
723 previous: &AnalysisResult,
724 categories: &[AnalysisCategory],
725) -> AnalysisResult {
726 analyze_selected_categories_in_region(score, previous, categories, None)
727}
728
729pub fn analyze_selected_categories_in_region(
731 score: &Score,
732 previous: &AnalysisResult,
733 categories: &[AnalysisCategory],
734 region: Option<&AnalysisRegion>,
735) -> AnalysisResult {
736 let selected = |category| categories.contains(&category);
737 let chords =
738 if selected(AnalysisCategory::Chords) || selected(AnalysisCategory::CadenceCandidates) {
739 let refreshed = analyze_chords_in_region(score, region);
740 merge_chord_region(&previous.chords, refreshed, region)
741 } else {
742 previous.chords.clone()
743 };
744 let intervals = if selected(AnalysisCategory::Intervals) {
745 merge_interval_region(
746 &previous.intervals,
747 analyze_intervals_in_region(score, region),
748 region,
749 )
750 } else {
751 previous.intervals.clone()
752 };
753 let key_estimates = if selected(AnalysisCategory::KeyEstimates) {
754 estimate_keys(score)
755 } else {
756 previous.key_estimates.clone()
757 };
758 let cadence_candidates = if selected(AnalysisCategory::CadenceCandidates) {
759 analyze_cadences(&chords)
760 } else {
761 previous.cadence_candidates.clone()
762 };
763 let voice_leading = if selected(AnalysisCategory::VoiceLeading)
764 || selected(AnalysisCategory::SatbDiagnostics)
765 {
766 merge_voice_leading_region(
767 &previous.voice_leading,
768 analyze_voice_leading_in_region(score, region),
769 region,
770 )
771 } else {
772 previous.voice_leading.clone()
773 };
774 let satb_diagnostics = if selected(AnalysisCategory::SatbDiagnostics) {
775 merge_satb_region(
776 &previous.satb_diagnostics,
777 analyze_satb_in_region(score, &voice_leading, region),
778 region,
779 )
780 } else {
781 previous.satb_diagnostics.clone()
782 };
783 let motifs = if selected(AnalysisCategory::Motifs) {
784 analyze_motifs(score)
785 } else {
786 previous.motifs.clone()
787 };
788 let phrase_boundaries = if selected(AnalysisCategory::PhraseBoundaries) {
789 analyze_phrase_boundaries(score)
790 } else {
791 previous.phrase_boundaries.clone()
792 };
793 AnalysisResult {
794 schema_version: ANALYSIS_SCHEMA_VERSION,
795 score_fingerprint: score_fingerprint(score),
796 chords,
797 intervals,
798 key_estimates,
799 cadence_candidates,
800 voice_leading,
801 satb_diagnostics,
802 motifs,
803 phrase_boundaries,
804 }
805}
806
807fn merge_interval_region(
808 previous: &[IntervalObservation],
809 refreshed: Vec<IntervalObservation>,
810 region: Option<&AnalysisRegion>,
811) -> Vec<IntervalObservation> {
812 let Some(region) = region else {
813 return refreshed;
814 };
815 let mut merged: Vec<_> = previous
816 .iter()
817 .filter(|item| {
818 !analysis_region_contains(region, &item.from)
819 && !analysis_region_contains(region, &item.to)
820 })
821 .cloned()
822 .collect();
823 merged.extend(refreshed);
824 merged.sort_by_key(|item| {
825 (
826 item.from.part,
827 item.from.staff,
828 item.from.measure,
829 item.from.voice,
830 item.from.note,
831 item.to.measure,
832 item.to.note,
833 )
834 });
835 merged
836}
837
838fn merge_voice_leading_region(
839 previous: &[VoiceLeadingObservation],
840 refreshed: Vec<VoiceLeadingObservation>,
841 region: Option<&AnalysisRegion>,
842) -> Vec<VoiceLeadingObservation> {
843 let Some(region) = region else {
844 return refreshed;
845 };
846 let mut merged: Vec<_> = previous
847 .iter()
848 .filter(|item| {
849 !analysis_region_contains(region, &item.upper)
850 && !analysis_region_contains(region, &item.lower)
851 })
852 .cloned()
853 .collect();
854 merged.extend(refreshed);
855 merged.sort_by_key(|item| {
856 (
857 item.upper.part,
858 item.upper.staff,
859 item.upper.measure,
860 item.upper.voice,
861 item.upper.note,
862 item.lower.voice,
863 )
864 });
865 merged
866}
867
868fn merge_chord_region(
869 previous: &[ChordLabel],
870 refreshed: Vec<ChordLabel>,
871 region: Option<&AnalysisRegion>,
872) -> Vec<ChordLabel> {
873 let Some(region) = region else {
874 return refreshed;
875 };
876 let mut merged: Vec<_> = previous
877 .iter()
878 .filter(|label| !analysis_region_contains(region, &label.address))
879 .cloned()
880 .collect();
881 merged.extend(refreshed);
882 merged.sort_by_key(|label| {
883 (
884 label.address.part,
885 label.address.staff,
886 label.address.measure,
887 label.address.voice,
888 label.address.note,
889 )
890 });
891 merged
892}
893
894fn merge_satb_region(
895 previous: &[SatbDiagnostic],
896 refreshed: Vec<SatbDiagnostic>,
897 region: Option<&AnalysisRegion>,
898) -> Vec<SatbDiagnostic> {
899 let Some(region) = region else {
900 return refreshed;
901 };
902 let mut merged: Vec<_> = previous
903 .iter()
904 .filter(|diagnostic| {
905 !diagnostic
906 .evidence
907 .iter()
908 .any(|address| analysis_region_contains(region, address))
909 })
910 .cloned()
911 .collect();
912 merged.extend(refreshed);
913 merged.sort_by_key(|diagnostic| {
914 (
915 diagnostic.upper.part,
916 diagnostic.upper.staff,
917 diagnostic.upper.measure,
918 diagnostic.upper.voice,
919 diagnostic.upper.note,
920 diagnostic.lower.voice,
921 diagnostic.rule_id.clone(),
922 )
923 });
924 merged
925}
926
927fn analysis_region_contains(region: &AnalysisRegion, address: &NoteAddr) -> bool {
928 address.part == region.part
929 && address.staff == region.staff
930 && (region.start_measure..region.end_measure).contains(&address.measure)
931}
932
933pub fn score_fingerprint(score: &Score) -> String {
935 let mut value = serde_json::to_value(score).unwrap_or_default();
936 remove_generated_ids(&mut value);
937 let bytes = serde_json::to_vec(&value).unwrap_or_default();
938 let hash = fnv1a64(&bytes);
939 format!("fnv1a64-{hash:016x}")
940}
941
942fn remove_generated_ids(value: &mut serde_json::Value) {
943 match value {
944 serde_json::Value::Object(object) => {
945 object.remove("id");
946 for child in object.values_mut() {
947 remove_generated_ids(child);
948 }
949 }
950 serde_json::Value::Array(values) => {
951 for child in values {
952 remove_generated_ids(child);
953 }
954 }
955 _ => {}
956 }
957}
958
959pub fn analysis_cache_key(score: &Score) -> String {
961 format!(
962 "analysis-v{}-{}",
963 ANALYSIS_SCHEMA_VERSION,
964 score_fingerprint(score)
965 )
966}
967
968#[derive(Debug, Error, Clone, PartialEq, Eq)]
970pub enum AnalysisCacheError {
971 #[error("analysis cache capacity must be greater than zero")]
972 ZeroCapacity,
973}
974
975#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
977pub struct AnalysisCacheStats {
978 pub hits: usize,
979 pub misses: usize,
980}
981
982#[derive(Debug, Clone)]
989pub struct AnalysisCache {
990 capacity: usize,
991 entries: BTreeMap<String, AnalysisResult>,
992 insertion_order: VecDeque<String>,
993 stats: AnalysisCacheStats,
994}
995
996impl Default for AnalysisCache {
997 fn default() -> Self {
998 Self {
999 capacity: 16,
1000 entries: BTreeMap::new(),
1001 insertion_order: VecDeque::new(),
1002 stats: AnalysisCacheStats::default(),
1003 }
1004 }
1005}
1006
1007impl AnalysisCache {
1008 pub fn with_capacity(capacity: usize) -> Result<Self, AnalysisCacheError> {
1010 if capacity == 0 {
1011 return Err(AnalysisCacheError::ZeroCapacity);
1012 }
1013 Ok(Self {
1014 capacity,
1015 entries: BTreeMap::new(),
1016 insertion_order: VecDeque::new(),
1017 stats: AnalysisCacheStats::default(),
1018 })
1019 }
1020
1021 pub fn capacity(&self) -> usize {
1023 self.capacity
1024 }
1025
1026 pub fn stats(&self) -> AnalysisCacheStats {
1028 self.stats
1029 }
1030
1031 pub fn reset_stats(&mut self) {
1033 self.stats = AnalysisCacheStats::default();
1034 }
1035
1036 pub fn get(&self, score: &Score) -> Option<&AnalysisResult> {
1038 self.entries.get(&analysis_cache_key(score))
1039 }
1040
1041 pub fn analyze(&mut self, score: &Score) -> AnalysisResult {
1043 let key = analysis_cache_key(score);
1044 if let Some(result) = self.entries.get(&key) {
1045 self.stats.hits = self.stats.hits.saturating_add(1);
1046 return result.clone();
1047 }
1048
1049 self.stats.misses = self.stats.misses.saturating_add(1);
1050 let result = analyze_score(score);
1051 self.insert(key, result.clone());
1052 result
1053 }
1054
1055 pub fn analyze_batch(&mut self, scores: &[Score]) -> Vec<AnalysisResult> {
1057 scores.iter().map(|score| self.analyze(score)).collect()
1058 }
1059
1060 pub fn analyze_after_edit(&mut self, previous: &Score, current: &Score) -> AnalysisResult {
1062 if analysis_cache_key(previous) != analysis_cache_key(current) {
1063 self.invalidate(previous);
1064 }
1065 self.analyze(current)
1066 }
1067
1068 pub fn analyze_after_edit_with_diff(
1070 &mut self,
1071 previous_score: &Score,
1072 previous_result: &AnalysisResult,
1073 current: &Score,
1074 ) -> AnalysisEditResult {
1075 let analysis = self.analyze_after_edit(previous_score, current);
1076 let diff = diff_analysis(previous_result, &analysis);
1077 AnalysisEditResult { analysis, diff }
1078 }
1079
1080 pub fn analyze_selected_after_edit(
1082 &mut self,
1083 previous_score: &Score,
1084 previous_result: &AnalysisResult,
1085 current: &Score,
1086 categories: &[AnalysisCategory],
1087 ) -> AnalysisResult {
1088 let key = analysis_cache_key(current);
1089 if let Some(result) = self.entries.get(&key) {
1090 self.stats.hits = self.stats.hits.saturating_add(1);
1091 return result.clone();
1092 }
1093 self.stats.misses = self.stats.misses.saturating_add(1);
1094 if analysis_cache_key(previous_score) != key {
1095 self.invalidate(previous_score);
1096 }
1097 let result = analyze_selected_categories(current, previous_result, categories);
1098 self.insert(key, result.clone());
1099 result
1100 }
1101
1102 pub fn analyze_after_edit_with_plan(
1104 &mut self,
1105 previous_score: &Score,
1106 previous_result: &AnalysisResult,
1107 current: &Score,
1108 plan: &AnalysisRefreshPlan,
1109 ) -> AnalysisEditResult {
1110 let categories: Vec<_> = plan
1111 .local_categories
1112 .iter()
1113 .chain(&plan.global_categories)
1114 .copied()
1115 .collect();
1116 let key = analysis_cache_key(current);
1117 let analysis = if let Some(result) = self.entries.get(&key) {
1118 self.stats.hits = self.stats.hits.saturating_add(1);
1119 result.clone()
1120 } else {
1121 self.stats.misses = self.stats.misses.saturating_add(1);
1122 if analysis_cache_key(previous_score) != key {
1123 self.invalidate(previous_score);
1124 }
1125 let result = analyze_selected_categories_in_region(
1126 current,
1127 previous_result,
1128 &categories,
1129 plan.region.as_ref(),
1130 );
1131 self.insert(key, result.clone());
1132 result
1133 };
1134 AnalysisEditResult {
1135 diff: diff_analysis(previous_result, &analysis),
1136 analysis,
1137 }
1138 }
1139
1140 pub fn analyze_after_edit_with_hint(
1142 &mut self,
1143 previous_score: &Score,
1144 previous_result: &AnalysisResult,
1145 current: &Score,
1146 hint: &ChangeHint,
1147 ) -> AnalysisEditResult {
1148 let plan = analysis_refresh_plan(hint);
1149 self.analyze_after_edit_with_plan(previous_score, previous_result, current, &plan)
1150 }
1151
1152 pub fn insert(&mut self, key: String, result: AnalysisResult) {
1154 if self.entries.contains_key(&key) {
1155 self.entries.insert(key.clone(), result);
1156 self.insertion_order.retain(|existing| existing != &key);
1157 } else {
1158 self.entries.insert(key.clone(), result);
1159 }
1160 self.insertion_order.push_back(key);
1161 while self.entries.len() > self.capacity {
1162 if let Some(oldest) = self.insertion_order.pop_front() {
1163 self.entries.remove(&oldest);
1164 }
1165 }
1166 }
1167
1168 pub fn invalidate(&mut self, score: &Score) -> bool {
1170 let key = analysis_cache_key(score);
1171 let removed = self.entries.remove(&key).is_some();
1172 if removed {
1173 self.insertion_order.retain(|existing| existing != &key);
1174 }
1175 removed
1176 }
1177
1178 pub fn clear(&mut self) {
1180 self.entries.clear();
1181 self.insertion_order.clear();
1182 }
1183
1184 pub fn len(&self) -> usize {
1186 self.entries.len()
1187 }
1188
1189 pub fn is_empty(&self) -> bool {
1191 self.entries.is_empty()
1192 }
1193}
1194
1195fn fnv1a64(bytes: &[u8]) -> u64 {
1196 bytes.iter().fold(0xcbf29ce484222325u64, |hash, byte| {
1197 (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
1198 })
1199}
1200
1201impl AnalysisCounts {
1202 fn from_result(result: &AnalysisResult) -> Self {
1203 Self {
1204 chords: result.chords.len(),
1205 intervals: result.intervals.len(),
1206 key_estimates: result.key_estimates.len(),
1207 cadence_candidates: result.cadence_candidates.len(),
1208 voice_leading: result.voice_leading.len(),
1209 satb_diagnostics: result.satb_diagnostics.len(),
1210 motifs: result.motifs.len(),
1211 phrase_boundaries: result.phrase_boundaries.len(),
1212 }
1213 }
1214
1215 fn total(self) -> usize {
1216 self.chords
1217 + self.intervals
1218 + self.key_estimates
1219 + self.cadence_candidates
1220 + self.voice_leading
1221 + self.satb_diagnostics
1222 + self.motifs
1223 + self.phrase_boundaries
1224 }
1225
1226 fn explained(self, result: &AnalysisResult) -> usize {
1227 result
1228 .chords
1229 .iter()
1230 .filter(|item| !item.evidence.is_empty())
1231 .count()
1232 + result
1233 .intervals
1234 .iter()
1235 .filter(|item| !item.evidence.is_empty())
1236 .count()
1237 + result
1238 .key_estimates
1239 .iter()
1240 .filter(|item| !item.evidence.is_empty())
1241 .count()
1242 + result
1243 .cadence_candidates
1244 .iter()
1245 .filter(|item| !item.evidence.is_empty())
1246 .count()
1247 + result
1248 .voice_leading
1249 .iter()
1250 .filter(|item| !item.evidence.is_empty())
1251 .count()
1252 + result
1253 .satb_diagnostics
1254 .iter()
1255 .filter(|item| !item.evidence.is_empty())
1256 .count()
1257 + result
1258 .motifs
1259 .iter()
1260 .map(|item| {
1261 item.occurrences
1262 .iter()
1263 .filter(|occurrence| !occurrence.evidence.is_empty())
1264 .count()
1265 })
1266 .sum::<usize>()
1267 + result
1268 .phrase_boundaries
1269 .iter()
1270 .filter(|item| !item.evidence.is_empty())
1271 .count()
1272 }
1273}
1274
1275pub fn benchmark_case(case: &BenchmarkCase<'_>) -> BenchmarkCaseReport {
1277 let result = analyze_score(case.score);
1278 let predicted = AnalysisCounts::from_result(&result);
1279 let expected = case.expected;
1280 let matched = predicted.chords.min(expected.chords)
1281 + predicted.intervals.min(expected.intervals)
1282 + predicted.key_estimates.min(expected.key_estimates)
1283 + predicted
1284 .cadence_candidates
1285 .min(expected.cadence_candidates)
1286 + predicted.voice_leading.min(expected.voice_leading)
1287 + predicted.satb_diagnostics.min(expected.satb_diagnostics)
1288 + predicted.motifs.min(expected.motifs)
1289 + predicted.phrase_boundaries.min(expected.phrase_boundaries);
1290 let expected_total = AnalysisCounts {
1291 chords: expected.chords,
1292 intervals: expected.intervals,
1293 key_estimates: expected.key_estimates,
1294 cadence_candidates: expected.cadence_candidates,
1295 voice_leading: expected.voice_leading,
1296 satb_diagnostics: expected.satb_diagnostics,
1297 motifs: expected.motifs,
1298 phrase_boundaries: expected.phrase_boundaries,
1299 }
1300 .total();
1301 let predicted_total = predicted.total();
1302 let failures = [
1303 (BenchmarkCategory::Chords, expected.chords, predicted.chords),
1304 (
1305 BenchmarkCategory::Intervals,
1306 expected.intervals,
1307 predicted.intervals,
1308 ),
1309 (
1310 BenchmarkCategory::KeyEstimates,
1311 expected.key_estimates,
1312 predicted.key_estimates,
1313 ),
1314 (
1315 BenchmarkCategory::CadenceCandidates,
1316 expected.cadence_candidates,
1317 predicted.cadence_candidates,
1318 ),
1319 (
1320 BenchmarkCategory::VoiceLeading,
1321 expected.voice_leading,
1322 predicted.voice_leading,
1323 ),
1324 (
1325 BenchmarkCategory::SatbDiagnostics,
1326 expected.satb_diagnostics,
1327 predicted.satb_diagnostics,
1328 ),
1329 (BenchmarkCategory::Motifs, expected.motifs, predicted.motifs),
1330 (
1331 BenchmarkCategory::PhraseBoundaries,
1332 expected.phrase_boundaries,
1333 predicted.phrase_boundaries,
1334 ),
1335 ]
1336 .into_iter()
1337 .filter_map(|(category, expected, predicted)| {
1338 if expected == predicted {
1339 return None;
1340 }
1341 Some(BenchmarkFailure {
1342 category,
1343 expected,
1344 predicted,
1345 missing: expected.saturating_sub(predicted),
1346 excess: predicted.saturating_sub(expected),
1347 })
1348 })
1349 .collect();
1350 BenchmarkCaseReport {
1351 name: case.name.to_string(),
1352 predicted,
1353 expected,
1354 precision_percent: percentage(matched, predicted_total),
1355 recall_percent: percentage(matched, expected_total),
1356 explanation_completeness_percent: percentage(predicted.explained(&result), predicted_total),
1357 failures,
1358 }
1359}
1360
1361pub fn run_benchmark(cases: &[BenchmarkCase<'_>]) -> Vec<BenchmarkCaseReport> {
1363 cases.iter().map(benchmark_case).collect()
1364}
1365
1366pub fn run_benchmark_suite(cases: &[BenchmarkCase<'_>]) -> BenchmarkSuiteReport {
1368 let reports = run_benchmark(cases);
1369 let case_count = reports.len();
1370 let passed_case_count = reports
1371 .iter()
1372 .filter(|report| report.failures.is_empty())
1373 .count();
1374 let failed_case_count = case_count.saturating_sub(passed_case_count);
1375 let precision_total: usize = reports
1376 .iter()
1377 .map(|report| usize::from(report.precision_percent))
1378 .sum();
1379 let recall_total: usize = reports
1380 .iter()
1381 .map(|report| usize::from(report.recall_percent))
1382 .sum();
1383 let explanation_total: usize = reports
1384 .iter()
1385 .map(|report| usize::from(report.explanation_completeness_percent))
1386 .sum();
1387 let metric_denominator = case_count.saturating_mul(100);
1388 let aggregate_metric = |total: usize| {
1389 if case_count == 0 {
1390 0
1391 } else {
1392 percentage(total, metric_denominator)
1393 }
1394 };
1395 BenchmarkSuiteReport {
1396 cases: reports,
1397 case_count,
1398 passed_case_count,
1399 failed_case_count,
1400 precision_percent: aggregate_metric(precision_total),
1401 recall_percent: aggregate_metric(recall_total),
1402 explanation_completeness_percent: aggregate_metric(explanation_total),
1403 }
1404}
1405
1406fn percentage(numerator: usize, denominator: usize) -> u8 {
1407 match numerator.saturating_mul(100).checked_div(denominator) {
1408 Some(value) => value.min(100) as u8,
1409 None => 100,
1410 }
1411}
1412
1413pub fn analyze_satb(score: &Score) -> Vec<SatbDiagnostic> {
1415 let voice_leading = analyze_voice_leading(score);
1416 analyze_satb_in_region(score, &voice_leading, None)
1417}
1418
1419pub fn analyze_satb_in_region(
1421 score: &Score,
1422 voice_leading: &[VoiceLeadingObservation],
1423 region: Option<&AnalysisRegion>,
1424) -> Vec<SatbDiagnostic> {
1425 let mut diagnostics = Vec::new();
1426 for (part_index, part) in score.parts.iter().enumerate() {
1427 for (staff_index, staff) in part.staves.iter().enumerate() {
1428 for (measure_index, measure) in staff.measures.iter().enumerate() {
1429 if let Some(region) = region
1430 && (part_index != region.part
1431 || staff_index != region.staff
1432 || measure_index < region.start_measure
1433 || measure_index >= region.end_measure)
1434 {
1435 continue;
1436 }
1437 for (upper_index, upper_voice) in measure.voices.iter().enumerate() {
1438 let Some(lower_voice) = measure.voices.get(upper_index + 1) else {
1439 continue;
1440 };
1441 for note_index in 0..upper_voice.len().min(lower_voice.len()) {
1442 let Some(upper) = upper_voice[note_index].pitches.first() else {
1443 continue;
1444 };
1445 let Some(lower) = lower_voice[note_index].pitches.first() else {
1446 continue;
1447 };
1448 let upper_addr = NoteAddr {
1449 part: part_index,
1450 staff: staff_index,
1451 measure: measure_index,
1452 voice: upper_index,
1453 note: note_index,
1454 };
1455 let lower_addr = NoteAddr {
1456 part: part_index,
1457 staff: staff_index,
1458 measure: measure_index,
1459 voice: upper_index + 1,
1460 note: note_index,
1461 };
1462 let distance = upper.to_midi() - lower.to_midi();
1463 if distance < 0 {
1464 diagnostics.push(satb_diagnostic(
1465 upper_addr.clone(),
1466 lower_addr.clone(),
1467 SatbDiagnosticKind::VoiceCrossing,
1468 SatbSeverity::Error,
1469 "satb-voice-crossing",
1470 ));
1471 } else if distance > 24 {
1472 diagnostics.push(satb_diagnostic(
1473 upper_addr.clone(),
1474 lower_addr.clone(),
1475 SatbDiagnosticKind::WideSpacing,
1476 SatbSeverity::Warning,
1477 "satb-wide-spacing",
1478 ));
1479 }
1480 }
1481 }
1482 }
1483 }
1484 }
1485 for observation in voice_leading {
1486 if let Some(region) = region
1487 && !observation
1488 .evidence
1489 .iter()
1490 .any(|address| analysis_region_contains(region, address))
1491 {
1492 continue;
1493 }
1494 if observation.parallel_perfect {
1495 diagnostics.push(satb_diagnostic(
1496 observation.upper.clone(),
1497 observation.lower.clone(),
1498 SatbDiagnosticKind::ParallelPerfect,
1499 SatbSeverity::Warning,
1500 "satb-parallel-perfect",
1501 ));
1502 }
1503 }
1504 diagnostics
1505}
1506
1507fn satb_diagnostic(
1508 upper: NoteAddr,
1509 lower: NoteAddr,
1510 kind: SatbDiagnosticKind,
1511 severity: SatbSeverity,
1512 rule_id: &str,
1513) -> SatbDiagnostic {
1514 SatbDiagnostic {
1515 evidence: vec![upper.clone(), lower.clone()],
1516 upper,
1517 lower,
1518 kind,
1519 severity,
1520 confidence: 100,
1521 rule_id: rule_id.to_string(),
1522 }
1523}
1524
1525pub fn analyze_motifs(score: &Score) -> Vec<MotifPattern> {
1527 let mut groups: BTreeMap<(usize, usize, usize, Vec<i8>), Vec<MotifOccurrence>> =
1528 BTreeMap::new();
1529 for (part_index, part) in score.parts.iter().enumerate() {
1530 for (staff_index, staff) in part.staves.iter().enumerate() {
1531 let Some(first_measure) = staff.measures.first() else {
1532 continue;
1533 };
1534 for (voice_index, _) in first_measure.voices.iter().enumerate() {
1535 let mut segment = Vec::new();
1536 let mut segments = Vec::new();
1537 for (measure_index, measure) in staff.measures.iter().enumerate() {
1538 for (note_index, note) in measure.voices[voice_index].iter().enumerate() {
1539 let Some(pitch) = note.pitches.first() else {
1540 if segment.len() >= 3 {
1541 segments.push(std::mem::take(&mut segment));
1542 } else {
1543 segment.clear();
1544 }
1545 continue;
1546 };
1547 if note.is_rest {
1548 if segment.len() >= 3 {
1549 segments.push(std::mem::take(&mut segment));
1550 } else {
1551 segment.clear();
1552 }
1553 continue;
1554 }
1555 segment.push((
1556 NoteAddr {
1557 part: part_index,
1558 staff: staff_index,
1559 measure: measure_index,
1560 voice: voice_index,
1561 note: note_index,
1562 },
1563 pitch.to_midi(),
1564 ));
1565 }
1566 }
1567 if segment.len() >= 3 {
1568 segments.push(segment);
1569 }
1570 for segment in segments {
1571 for window in segment.windows(3) {
1572 let signature = vec![
1573 (window[1].1 - window[0].1) as i8,
1574 (window[2].1 - window[1].1) as i8,
1575 ];
1576 let occurrence = MotifOccurrence {
1577 start: window[0].0.clone(),
1578 end: window[2].0.clone(),
1579 evidence: window.iter().map(|(address, _)| address.clone()).collect(),
1580 };
1581 groups
1582 .entry((part_index, staff_index, voice_index, signature))
1583 .or_default()
1584 .push(occurrence);
1585 }
1586 }
1587 }
1588 }
1589 }
1590 groups
1591 .into_iter()
1592 .filter(|(_, occurrences)| occurrences.len() >= 2)
1593 .map(|((_, _, _, signature), occurrences)| MotifPattern {
1594 signature,
1595 occurrences,
1596 confidence: 100,
1597 rule_id: "repeated-three-note-interval-pattern".to_string(),
1598 })
1599 .collect()
1600}
1601
1602pub fn analyze_phrase_boundaries(score: &Score) -> Vec<PhraseBoundary> {
1604 let mut boundaries = Vec::new();
1605 for (part_index, part) in score.parts.iter().enumerate() {
1606 for (staff_index, staff) in part.staves.iter().enumerate() {
1607 for (measure_index, measure) in staff.measures.iter().enumerate() {
1608 for (voice_index, voice) in measure.voices.iter().enumerate() {
1609 let Some((note_index, note)) = voice.iter().enumerate().next_back() else {
1610 continue;
1611 };
1612 if !note.is_rest {
1613 continue;
1614 }
1615 let address = NoteAddr {
1616 part: part_index,
1617 staff: staff_index,
1618 measure: measure_index,
1619 voice: voice_index,
1620 note: note_index,
1621 };
1622 boundaries.push(PhraseBoundary {
1623 address: address.clone(),
1624 reason: PhraseBoundaryReason::RestTermination,
1625 confidence: 100,
1626 rule_id: "measure-ending-rest".to_string(),
1627 evidence: vec![address],
1628 });
1629 }
1630 }
1631 }
1632 }
1633 boundaries
1634}
1635
1636pub fn analyze_cadences(chords: &[ChordLabel]) -> Vec<CadenceCandidate> {
1638 let mut candidates = Vec::new();
1639 let mut previous: HashMap<(usize, usize, usize), &ChordLabel> = HashMap::new();
1640 for chord in chords {
1641 let key = (chord.address.part, chord.address.staff, chord.address.voice);
1642 let Some(previous_chord) = previous.insert(key, chord) else {
1643 continue;
1644 };
1645 let Some(from_roman) = previous_chord.roman_numeral.as_deref() else {
1646 continue;
1647 };
1648 let Some(to_roman) = chord.roman_numeral.as_deref() else {
1649 continue;
1650 };
1651 let from_figure = roman_figure(from_roman);
1652 let to_figure = roman_figure(to_roman);
1653 let kind = match (from_figure, to_figure) {
1654 ("V" | "V7", "I") => CadenceKind::Authentic,
1655 ("IV", "I") => CadenceKind::Plagal,
1656 ("V" | "V7", "vi") => CadenceKind::Deceptive,
1657 (_, "V" | "V7") => CadenceKind::Half,
1658 _ => continue,
1659 };
1660 let evidence = vec![previous_chord.address.clone(), chord.address.clone()];
1661 candidates.push(CadenceCandidate {
1662 from: previous_chord.address.clone(),
1663 to: chord.address.clone(),
1664 kind,
1665 confidence: 100,
1666 rule_id: "roman-numeral-cadence-transition".to_string(),
1667 evidence,
1668 });
1669 }
1670 candidates
1671}
1672
1673fn roman_figure(roman: &str) -> &str {
1674 roman.find('/').map_or(roman, |index| &roman[..index])
1675}
1676
1677pub fn analyze_voice_leading(score: &Score) -> Vec<VoiceLeadingObservation> {
1679 analyze_voice_leading_in_region(score, None)
1680}
1681
1682pub fn analyze_voice_leading_in_region(
1684 score: &Score,
1685 region: Option<&AnalysisRegion>,
1686) -> Vec<VoiceLeadingObservation> {
1687 let mut observations = Vec::new();
1688 for (part_index, part) in score.parts.iter().enumerate() {
1689 for (staff_index, staff) in part.staves.iter().enumerate() {
1690 for (measure_index, measure) in staff.measures.iter().enumerate() {
1691 if let Some(region) = region
1692 && (part_index != region.part
1693 || staff_index != region.staff
1694 || measure_index < region.start_measure
1695 || measure_index >= region.end_measure)
1696 {
1697 continue;
1698 }
1699 for (upper_index, upper_voice) in measure.voices.iter().enumerate() {
1700 let Some(lower_voice) = measure.voices.get(upper_index + 1) else {
1701 continue;
1702 };
1703 let count = upper_voice.len().min(lower_voice.len());
1704 for note_index in 0..count {
1705 let Some(upper) = upper_voice[note_index].pitches.first() else {
1706 continue;
1707 };
1708 let Some(lower) = lower_voice[note_index].pitches.first() else {
1709 continue;
1710 };
1711 let next_upper = upper_voice[note_index + 1..]
1712 .iter()
1713 .find_map(|note| note.pitches.first());
1714 let next_lower = lower_voice[note_index + 1..]
1715 .iter()
1716 .find_map(|note| note.pitches.first());
1717 let (Some(next_upper), Some(next_lower)) = (next_upper, next_lower) else {
1718 continue;
1719 };
1720 let upper_addr = NoteAddr {
1721 part: part_index,
1722 staff: staff_index,
1723 measure: measure_index,
1724 voice: upper_index,
1725 note: note_index,
1726 };
1727 let lower_addr = NoteAddr {
1728 part: part_index,
1729 staff: staff_index,
1730 measure: measure_index,
1731 voice: upper_index + 1,
1732 note: note_index,
1733 };
1734 let upper_next_midi = next_upper.to_midi();
1735 let lower_next_midi = next_lower.to_midi();
1736 let upper_motion = upper_next_midi - upper.to_midi();
1737 let lower_motion = lower_next_midi - lower.to_midi();
1738 let initial = (upper.to_midi() - lower.to_midi()).unsigned_abs() % 12;
1739 let next = (upper_next_midi - lower_next_midi).unsigned_abs() % 12;
1740 observations.push(VoiceLeadingObservation {
1741 upper: upper_addr.clone(),
1742 lower: lower_addr.clone(),
1743 upper_motion,
1744 lower_motion,
1745 parallel_perfect: matches!(initial, 0 | 7)
1746 && initial == next
1747 && upper_motion != 0
1748 && upper_motion.signum() == lower_motion.signum(),
1749 confidence: 100,
1750 rule_id: "aligned-adjacent-voice-leading".to_string(),
1751 evidence: vec![upper_addr, lower_addr],
1752 });
1753 }
1754 }
1755 }
1756 }
1757 }
1758 observations
1759}
1760
1761pub fn analyze_chords(score: &Score) -> AnalysisResult {
1763 analyze_score(score)
1764}
1765
1766pub fn analyze_batch(scores: &[Score]) -> Vec<AnalysisResult> {
1768 scores.iter().map(analyze_score).collect()
1769}
1770
1771pub fn analyze_stream<I>(scores: I) -> impl Iterator<Item = AnalysisResult>
1773where
1774 I: IntoIterator<Item = Score>,
1775{
1776 scores.into_iter().map(|score| analyze_score(&score))
1777}
1778
1779pub fn estimate_keys(score: &Score) -> Vec<KeyEstimate> {
1781 let mut pitches = Vec::new();
1782 let mut evidence = Vec::new();
1783 for (part_index, part) in score.parts.iter().enumerate() {
1784 for (staff_index, staff) in part.staves.iter().enumerate() {
1785 for (measure_index, measure) in staff.measures.iter().enumerate() {
1786 for (voice_index, voice) in measure.voices.iter().enumerate() {
1787 for (note_index, note) in voice.iter().enumerate() {
1788 if note.is_rest {
1789 continue;
1790 }
1791 pitches.extend(note.pitches.iter());
1792 if !note.pitches.is_empty() {
1793 evidence.push(NoteAddr {
1794 part: part_index,
1795 staff: staff_index,
1796 measure: measure_index,
1797 voice: voice_index,
1798 note: note_index,
1799 });
1800 }
1801 }
1802 }
1803 }
1804 }
1805 }
1806 if pitches.is_empty() {
1807 return Vec::new();
1808 }
1809 let total_pitches = pitches.len();
1810 let mut candidates = Vec::with_capacity(30);
1811 for fifths in -7..=7 {
1812 for mode in ["major", "minor"] {
1813 let key = KeySignature {
1814 fifths,
1815 mode: mode.to_string(),
1816 };
1817 let covered = pitches
1818 .iter()
1819 .filter(|pitch| key.contains_pitch(pitch))
1820 .count();
1821 candidates.push((key, covered));
1822 }
1823 }
1824 candidates.sort_by(|(left_key, left_score), (right_key, right_score)| {
1825 right_score
1826 .cmp(left_score)
1827 .then_with(|| left_key.fifths.abs().cmp(&right_key.fifths.abs()))
1828 .then_with(|| left_key.fifths.cmp(&right_key.fifths))
1829 .then_with(|| left_key.mode.cmp(&right_key.mode))
1830 });
1831 let best = candidates[0].1;
1832 candidates
1833 .into_iter()
1834 .take_while(|(_, covered)| *covered == best)
1835 .map(|(key, covered_pitches)| KeyEstimate {
1836 key,
1837 covered_pitches,
1838 total_pitches,
1839 confidence: ((covered_pitches * 100) / total_pitches) as u8,
1840 rule_id: "diatonic-pitch-coverage".to_string(),
1841 evidence: evidence.clone(),
1842 })
1843 .collect()
1844}
1845
1846pub fn analyze_intervals(score: &Score) -> Vec<IntervalObservation> {
1848 analyze_intervals_in_region(score, None)
1849}
1850
1851pub fn analyze_intervals_in_region(
1853 score: &Score,
1854 region: Option<&AnalysisRegion>,
1855) -> Vec<IntervalObservation> {
1856 let mut observations = Vec::new();
1857 for (part_index, part) in score.parts.iter().enumerate() {
1858 for (staff_index, staff) in part.staves.iter().enumerate() {
1859 for (measure_index, measure) in staff.measures.iter().enumerate() {
1860 for (voice_index, voice) in measure.voices.iter().enumerate() {
1861 let notes: Vec<_> = voice
1862 .iter()
1863 .enumerate()
1864 .filter_map(|(note_index, note)| {
1865 if note.is_rest {
1866 None
1867 } else {
1868 note.pitches.first().map(|pitch| (note_index, pitch))
1869 }
1870 })
1871 .collect();
1872 for pair in notes.windows(2) {
1873 let (from_index, from) = pair[0];
1874 let (to_index, to) = pair[1];
1875 let from_addr = NoteAddr {
1876 part: part_index,
1877 staff: staff_index,
1878 measure: measure_index,
1879 voice: voice_index,
1880 note: from_index,
1881 };
1882 let to_addr = NoteAddr {
1883 part: part_index,
1884 staff: staff_index,
1885 measure: measure_index,
1886 voice: voice_index,
1887 note: to_index,
1888 };
1889 if let Some(region) = region
1890 && !analysis_region_contains(region, &from_addr)
1891 && !analysis_region_contains(region, &to_addr)
1892 {
1893 continue;
1894 }
1895 observations.push(IntervalObservation {
1896 from: from_addr.clone(),
1897 to: to_addr.clone(),
1898 semitones: (to.to_midi() - from.to_midi()).unsigned_abs() as u8,
1899 diatonic_steps: diatonic_distance(from, to),
1900 rule_id: "adjacent-melodic-interval".to_string(),
1901 evidence: vec![from_addr, to_addr],
1902 });
1903 }
1904 }
1905 }
1906 }
1907 }
1908 observations
1909}
1910
1911fn diatonic_distance(from: &acorde_core::Pitch, to: &acorde_core::Pitch) -> i8 {
1912 let step_index = |step: &acorde_core::Step| match step {
1913 acorde_core::Step::C => 0i16,
1914 acorde_core::Step::D => 1,
1915 acorde_core::Step::E => 2,
1916 acorde_core::Step::F => 3,
1917 acorde_core::Step::G => 4,
1918 acorde_core::Step::A => 5,
1919 acorde_core::Step::B => 6,
1920 };
1921 (i16::from(to.octave) * 7 + step_index(&to.step)
1922 - (i16::from(from.octave) * 7 + step_index(&from.step))) as i8
1923}
1924
1925pub fn chord_name(chord: &ChordSymbol) -> String {
1927 let suffix = match chord.kind.as_str() {
1928 "major" => "",
1929 "minor" => "m",
1930 "dominant" => "7",
1931 "major-seventh" => "maj7",
1932 "minor-seventh" => "m7",
1933 "diminished" => "dim",
1934 "diminished-seventh" => "dim7",
1935 "half-diminished" => "ø7",
1936 "augmented" => "+",
1937 _ => chord.kind.as_str(),
1938 };
1939 let bass = chord
1940 .bass
1941 .as_deref()
1942 .map_or(String::new(), |bass| format!("/{bass}"));
1943 format!("{}{suffix}{bass}", chord.root)
1944}
1945
1946#[cfg(test)]
1947mod tests {
1948 use super::*;
1949 use acorde_core::{Duration, Note, Pitch, Score, Step};
1950
1951 #[test]
1952 fn labels_chord_with_note_addresses_and_roman_numeral() {
1953 let mut score = Score::default();
1954 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
1955 voice.clear();
1956 for step in [Step::C, Step::E, Step::G] {
1957 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
1958 }
1959 let result = analyze_chords(&score);
1960 assert_eq!(result.schema_version, ANALYSIS_SCHEMA_VERSION);
1961 assert_eq!(result.chords.len(), 1);
1962 assert_eq!(result.intervals.len(), 2);
1963 assert!(!result.key_estimates.is_empty());
1964 assert_eq!(result.chords[0].address.note, 0);
1965 assert_eq!(result.chords[0].evidence.len(), 3);
1966 assert_eq!(result.chords[0].roman_numeral.as_deref(), Some("I"));
1967 assert_eq!(chord_name(&result.chords[0].chord), "C");
1968 }
1969
1970 #[test]
1971 fn provenance_returns_stable_explanations_for_a_note() {
1972 let mut score = Score::default();
1973 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
1974 voice.clear();
1975 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
1976 voice.push(Note::new(Pitch::new(Step::E, 4), Duration::Quarter));
1977 voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
1978 let address = NoteAddr {
1979 part: 0,
1980 staff: 0,
1981 measure: 0,
1982 voice: 0,
1983 note: 0,
1984 };
1985 let findings = analysis_provenance(&analyze_score(&score), &address);
1986 let chord = findings
1987 .iter()
1988 .find(|finding| finding.category == AnalysisCategory::Chords)
1989 .expect("chord provenance");
1990 assert_eq!(chord.rule_id, "pitch-class-template");
1991 assert_eq!(chord.evidence[0], address);
1992 assert!(findings.windows(2).all(|pair| {
1993 (analysis_category_rank(pair[0].category), &pair[0].rule_id)
1994 <= (analysis_category_rank(pair[1].category), &pair[1].rule_id)
1995 }));
1996 }
1997
1998 #[test]
1999 fn change_explanation_combines_diff_and_before_after_provenance() {
2000 let mut previous_score = Score::default();
2001 let voice = &mut previous_score.parts[0].staves[0].measures[0].voices[0];
2002 voice.clear();
2003 for step in [Step::C, Step::E, Step::G] {
2004 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2005 }
2006 let previous = analyze_score(&previous_score);
2007 let mut current_score = previous_score.clone();
2008 current_score.parts[0].staves[0].measures[0].voices[0][1] =
2009 Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
2010 let current = analyze_score(¤t_score);
2011 let address = NoteAddr {
2012 part: 0,
2013 staff: 0,
2014 measure: 0,
2015 voice: 0,
2016 note: 1,
2017 };
2018
2019 let explanation = explain_analysis_change(&previous, ¤t, &address);
2020 assert!(!explanation.diff.is_empty());
2021 assert!(!explanation.previous.is_empty());
2022 assert!(!explanation.current.is_empty());
2023 }
2024
2025 #[test]
2026 fn analysis_has_stable_score_fingerprint() {
2027 let score = Score::default();
2028 let repeated = analyze_score(&score);
2029 assert_eq!(repeated.score_fingerprint, score_fingerprint(&score));
2030 assert_eq!(
2031 repeated.score_fingerprint,
2032 analyze_score(&score).score_fingerprint
2033 );
2034
2035 let mut changed = score.clone();
2036 changed.metadata.title = "Changed".to_string();
2037 assert_ne!(repeated.score_fingerprint, score_fingerprint(&changed));
2038 }
2039
2040 #[test]
2041 fn fingerprint_uses_fnv1a_byte_order() {
2042 assert_eq!(fnv1a64(b"hello"), 0xa430d84680aabd0b);
2043 }
2044
2045 #[test]
2046 fn cache_key_includes_schema_and_score_identity() {
2047 let result = analyze_score(&Score::default());
2048 assert!(result.cache_key().starts_with("analysis-v7-fnv1a64-"));
2049 assert_eq!(result.cache_key(), analysis_cache_key(&Score::default()));
2050 let mut changed = result.clone();
2051 changed.schema_version = 8;
2052 assert_ne!(result.cache_key(), changed.cache_key());
2053 }
2054
2055 #[test]
2056 fn analysis_cache_reuses_identical_score_and_misses_after_edit() {
2057 let score = Score::default();
2058 let mut cache = AnalysisCache::with_capacity(2).unwrap();
2059 let first = cache.analyze(&score);
2060 assert_eq!(cache.len(), 1);
2061 assert!(cache.get(&score).is_some());
2062 let second = cache.analyze(&score);
2063 assert_eq!(first, second);
2064
2065 let mut changed = score.clone();
2066 changed.metadata.title = "changed".to_owned();
2067 assert!(cache.get(&changed).is_none());
2068 let changed_result = cache.analyze(&changed);
2069 assert_ne!(first.score_fingerprint, changed_result.score_fingerprint);
2070 assert_eq!(cache.len(), 2);
2071 }
2072
2073 #[test]
2074 fn analysis_cache_eviction_is_bounded_and_deterministic() {
2075 let mut cache = AnalysisCache::with_capacity(1).unwrap();
2076 let first = Score::default();
2077 let mut second = first.clone();
2078 second.metadata.title = "second".to_owned();
2079 cache.analyze(&first);
2080 cache.analyze(&second);
2081 assert_eq!(cache.len(), 1);
2082 assert!(cache.get(&first).is_none());
2083 assert!(cache.get(&second).is_some());
2084 }
2085
2086 #[test]
2087 fn analysis_cache_batch_preserves_order_and_reuses_duplicate_scores() {
2088 let first = Score::default();
2089 let mut second = first.clone();
2090 second.metadata.title = "second".to_owned();
2091 let mut cache = AnalysisCache::with_capacity(2).unwrap();
2092 let results = cache.analyze_batch(&[second.clone(), first.clone(), second.clone()]);
2093
2094 assert_eq!(results.len(), 3);
2095 assert_eq!(results[0].score_fingerprint, results[2].score_fingerprint);
2096 assert_ne!(results[0].score_fingerprint, results[1].score_fingerprint);
2097 assert_eq!(cache.len(), 2);
2098 assert_eq!(cache.stats(), AnalysisCacheStats { hits: 1, misses: 2 });
2099 cache.reset_stats();
2100 assert_eq!(cache.stats(), AnalysisCacheStats::default());
2101 assert!(cache.get(&first).is_some());
2102 }
2103
2104 #[test]
2105 fn analysis_cache_invalidates_one_score_without_affecting_others() {
2106 let first = Score::default();
2107 let mut second = first.clone();
2108 second.metadata.title = "second".to_owned();
2109 let mut cache = AnalysisCache::with_capacity(2).unwrap();
2110 cache.analyze_batch(&[first.clone(), second.clone()]);
2111
2112 assert!(cache.invalidate(&first));
2113 assert!(!cache.invalidate(&first));
2114 assert!(cache.get(&first).is_none());
2115 assert!(cache.get(&second).is_some());
2116 assert_eq!(cache.len(), 1);
2117 }
2118
2119 #[test]
2120 fn analysis_cache_analyze_after_edit_reclaims_previous_snapshot() {
2121 let previous = Score::default();
2122 let mut current = previous.clone();
2123 current.metadata.title = "edited".to_owned();
2124 let mut cache = AnalysisCache::with_capacity(2).unwrap();
2125 cache.analyze(&previous);
2126
2127 let result = cache.analyze_after_edit(&previous, ¤t);
2128 assert!(result.matches_score(¤t));
2129 assert!(cache.get(&previous).is_none());
2130 assert!(cache.get(¤t).is_some());
2131 assert_eq!(cache.len(), 1);
2132 }
2133
2134 #[test]
2135 fn analysis_cache_analyze_after_noop_edit_reuses_existing_result() {
2136 let score = Score::default();
2137 let mut cache = AnalysisCache::with_capacity(1).unwrap();
2138 cache.analyze(&score);
2139 cache.reset_stats();
2140
2141 let result = cache.analyze_after_edit(&score, &score);
2142 assert!(result.matches_score(&score));
2143 assert_eq!(cache.stats(), AnalysisCacheStats { hits: 1, misses: 0 });
2144 assert_eq!(cache.len(), 1);
2145 }
2146
2147 #[test]
2148 fn analysis_cache_edit_with_diff_returns_result_and_changed_categories() {
2149 let previous = Score::default();
2150 let mut current = previous.clone();
2151 let voice = &mut current.parts[0].staves[0].measures[0].voices[0];
2152 voice.clear();
2153 for step in [Step::C, Step::E, Step::G] {
2154 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2155 }
2156 let previous_result = analyze_score(&previous);
2157 let mut cache = AnalysisCache::with_capacity(2).unwrap();
2158 cache.analyze(&previous);
2159
2160 let edited = cache.analyze_after_edit_with_diff(&previous, &previous_result, ¤t);
2161 assert!(edited.analysis.matches_score(¤t));
2162 assert_eq!(edited.diff.changed_categories[0], AnalysisCategory::Chords);
2163 assert!(
2164 edited
2165 .diff
2166 .changed_categories
2167 .contains(&AnalysisCategory::Intervals)
2168 );
2169 assert!(cache.get(&previous).is_none());
2170 assert!(cache.get(¤t).is_some());
2171
2172 cache.reset_stats();
2173 let no_op = cache.analyze_after_edit_with_diff(¤t, &edited.analysis, ¤t);
2174 assert!(no_op.diff.is_empty());
2175 assert_eq!(cache.stats(), AnalysisCacheStats { hits: 1, misses: 0 });
2176 }
2177
2178 #[test]
2179 fn selected_analysis_reuses_unchanged_categories_and_rebinds_identity() {
2180 let previous = Score::default();
2181 let previous_result = analyze_score(&previous);
2182 let mut current = previous.clone();
2183 current.metadata.title = "edited".to_owned();
2184 let mut cache = AnalysisCache::with_capacity(2).unwrap();
2185 let result = cache.analyze_selected_after_edit(&previous, &previous_result, ¤t, &[]);
2186
2187 assert!(result.matches_score(¤t));
2188 assert_eq!(result.chords, previous_result.chords);
2189 assert_ne!(result.score_fingerprint, previous_result.score_fingerprint);
2190 assert_eq!(cache.stats(), AnalysisCacheStats { hits: 0, misses: 1 });
2191 }
2192
2193 #[test]
2194 fn hinted_analysis_uses_clean_hint_without_recomputing_categories() {
2195 let previous = Score::default();
2196 let previous_result = analyze_score(&previous);
2197 let mut current = previous.clone();
2198 current.metadata.title = "edited".to_owned();
2199 let hint = ChangeHint {
2200 scope: acorde_core::ChangeScope::Global,
2201 layout_dirty: false,
2202 playback_dirty: false,
2203 };
2204 let mut cache = AnalysisCache::with_capacity(2).unwrap();
2205 let edited =
2206 cache.analyze_after_edit_with_hint(&previous, &previous_result, ¤t, &hint);
2207
2208 assert!(edited.diff.is_empty());
2209 assert!(edited.analysis.matches_score(¤t));
2210 assert_eq!(edited.analysis.chords, previous_result.chords);
2211 }
2212
2213 #[test]
2214 fn analysis_cache_rejects_zero_capacity() {
2215 assert!(matches!(
2216 AnalysisCache::with_capacity(0),
2217 Err(AnalysisCacheError::ZeroCapacity)
2218 ));
2219 }
2220
2221 #[test]
2222 fn analysis_result_rejects_a_different_score() {
2223 let score = Score::default();
2224 let result = analyze_score(&score);
2225 assert!(result.matches_score(&score));
2226 let mut changed = score.clone();
2227 changed.metadata.title = "Changed".to_string();
2228 assert!(!result.matches_score(&changed));
2229 }
2230
2231 #[test]
2232 fn analysis_diff_is_empty_for_identical_results() {
2233 let result = analyze_score(&Score::default());
2234 let diff = diff_analysis(&result, &result);
2235 assert!(diff.is_empty());
2236 assert_eq!(diff.previous_score_fingerprint, result.score_fingerprint);
2237 assert_eq!(diff.current_score_fingerprint, result.score_fingerprint);
2238 }
2239
2240 #[test]
2241 fn analysis_diff_keeps_score_identity_separate_from_category_changes() {
2242 let mut first_score = Score::default();
2243 first_score.metadata.title = "first".to_owned();
2244 let mut second_score = first_score.clone();
2245 second_score.metadata.title = "second".to_owned();
2246 let diff = diff_analysis(&analyze_score(&first_score), &analyze_score(&second_score));
2247
2248 assert!(diff.is_empty());
2249 assert!(diff.changed_categories.is_empty());
2250 assert_ne!(
2251 diff.previous_score_fingerprint,
2252 diff.current_score_fingerprint
2253 );
2254 }
2255
2256 #[test]
2257 fn analysis_diff_reports_changed_categories_in_stable_order() {
2258 let previous = analyze_score(&Score::default());
2259 let mut changed_score = Score::default();
2260 let voice = &mut changed_score.parts[0].staves[0].measures[0].voices[0];
2261 voice.clear();
2262 for step in [Step::C, Step::E, Step::G] {
2263 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2264 }
2265 let current = analyze_score(&changed_score);
2266 let diff = diff_analysis(&previous, ¤t);
2267
2268 assert!(!diff.is_empty());
2269 assert_eq!(diff.changed_categories[0], AnalysisCategory::Chords);
2270 assert!(
2271 diff.changed_categories
2272 .contains(&AnalysisCategory::Intervals)
2273 );
2274 }
2275
2276 #[test]
2277 fn affected_categories_are_conservative_and_stable() {
2278 let clean = ChangeHint {
2279 scope: acorde_core::ChangeScope::Global,
2280 layout_dirty: false,
2281 playback_dirty: false,
2282 };
2283 assert!(affected_categories_for_change_hint(&clean).is_empty());
2284
2285 let dirty = ChangeHint {
2286 scope: acorde_core::ChangeScope::Measures {
2287 part: 0,
2288 staff: 0,
2289 start: 1,
2290 end: 2,
2291 },
2292 layout_dirty: false,
2293 playback_dirty: true,
2294 };
2295 assert_eq!(
2296 affected_categories_for_change_hint(&dirty),
2297 vec![
2298 AnalysisCategory::Chords,
2299 AnalysisCategory::Intervals,
2300 AnalysisCategory::KeyEstimates,
2301 AnalysisCategory::CadenceCandidates,
2302 AnalysisCategory::VoiceLeading,
2303 AnalysisCategory::SatbDiagnostics,
2304 AnalysisCategory::Motifs,
2305 AnalysisCategory::PhraseBoundaries,
2306 ]
2307 );
2308 }
2309
2310 #[test]
2311 fn refresh_plan_separates_local_and_global_dependencies() {
2312 let hint = ChangeHint {
2313 scope: acorde_core::ChangeScope::Measures {
2314 part: 1,
2315 staff: 2,
2316 start: 3,
2317 end: 4,
2318 },
2319 layout_dirty: true,
2320 playback_dirty: true,
2321 };
2322 let plan = analysis_refresh_plan(&hint);
2323 assert_eq!(
2324 plan.region,
2325 Some(AnalysisRegion {
2326 part: 1,
2327 staff: 2,
2328 start_measure: 3,
2329 end_measure: 4,
2330 })
2331 );
2332 assert!(plan.local_categories.contains(&AnalysisCategory::Chords));
2333 assert!(
2334 plan.local_categories
2335 .contains(&AnalysisCategory::CadenceCandidates)
2336 );
2337 assert_eq!(
2338 plan.global_categories,
2339 vec![AnalysisCategory::KeyEstimates, AnalysisCategory::Motifs]
2340 );
2341 assert_eq!(plan.context_before, 1);
2342 assert_eq!(plan.context_after, 1);
2343 }
2344
2345 #[test]
2346 fn chord_region_keeps_outside_results_and_refreshes_inside() {
2347 let mut score = Score::default();
2348 for measure_index in 0..2 {
2349 let voice = &mut score.parts[0].staves[0].measures[measure_index].voices[0];
2350 voice.clear();
2351 for step in [Step::C, Step::E, Step::G] {
2352 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2353 }
2354 }
2355 let region = AnalysisRegion {
2356 part: 0,
2357 staff: 0,
2358 start_measure: 0,
2359 end_measure: 1,
2360 };
2361 let chords = analyze_chords_in_region(&score, Some(®ion));
2362 assert_eq!(chords.len(), 1);
2363 assert_eq!(chords[0].address.measure, 0);
2364 }
2365
2366 #[test]
2367 fn interval_region_includes_boundary_observations() {
2368 let mut score = Score::default();
2369 for measure_index in 0..2 {
2370 let voice = &mut score.parts[0].staves[0].measures[measure_index].voices[0];
2371 voice.clear();
2372 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2373 voice.push(Note::new(Pitch::new(Step::D, 4), Duration::Quarter));
2374 }
2375 let region = AnalysisRegion {
2376 part: 0,
2377 staff: 0,
2378 start_measure: 0,
2379 end_measure: 1,
2380 };
2381 let intervals = analyze_intervals_in_region(&score, Some(®ion));
2382 assert_eq!(intervals.len(), 1);
2383 assert_eq!(intervals[0].from.measure, 0);
2384 }
2385
2386 #[test]
2387 fn voice_leading_region_limits_measure_traversal() {
2388 let mut score = Score::default();
2389 for measure_index in 0..2 {
2390 let measure = &mut score.parts[0].staves[0].measures[measure_index];
2391 measure.voices[0].clear();
2392 measure.voices[1].clear();
2393 measure.voices[0].push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2394 measure.voices[1].push(Note::new(Pitch::new(Step::G, 3), Duration::Quarter));
2395 measure.voices[0].push(Note::new(Pitch::new(Step::D, 4), Duration::Quarter));
2396 measure.voices[1].push(Note::new(Pitch::new(Step::A, 3), Duration::Quarter));
2397 }
2398 let region = AnalysisRegion {
2399 part: 0,
2400 staff: 0,
2401 start_measure: 1,
2402 end_measure: 2,
2403 };
2404 let observations = analyze_voice_leading_in_region(&score, Some(®ion));
2405 assert_eq!(observations.len(), 1);
2406 assert_eq!(observations[0].upper.measure, 1);
2407 }
2408
2409 #[test]
2410 fn interval_observation_preserves_direction_and_evidence() {
2411 let mut score = Score::default();
2412 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2413 voice.clear();
2414 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2415 voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
2416 let intervals = analyze_intervals(&score);
2417 assert_eq!(intervals.len(), 1);
2418 assert_eq!(intervals[0].semitones, 7);
2419 assert_eq!(intervals[0].diatonic_steps, 4);
2420 assert_eq!(intervals[0].evidence.len(), 2);
2421 }
2422
2423 #[test]
2424 fn does_not_invent_label_for_unknown_pitch_set() {
2425 let mut score = Score::default();
2426 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2427 voice.clear();
2428 for step in [Step::C, Step::C] {
2429 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2430 }
2431 assert!(analyze_chords(&score).chords.is_empty());
2432 }
2433
2434 #[test]
2435 fn preserves_relative_major_minor_key_ambiguity() {
2436 let mut score = Score::default();
2437 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2438 voice.clear();
2439 for step in [
2440 Step::C,
2441 Step::D,
2442 Step::E,
2443 Step::F,
2444 Step::G,
2445 Step::A,
2446 Step::B,
2447 ] {
2448 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2449 }
2450 let estimates = estimate_keys(&score);
2451 assert!(
2452 estimates
2453 .iter()
2454 .any(|estimate| estimate.key.display_name() == "C major")
2455 );
2456 assert!(
2457 estimates
2458 .iter()
2459 .any(|estimate| estimate.key.display_name() == "A minor")
2460 );
2461 assert!(estimates.iter().all(|estimate| estimate.confidence == 100));
2462 }
2463
2464 #[test]
2465 fn returns_no_key_for_empty_score() {
2466 assert!(estimate_keys(&Score::default()).is_empty());
2467 }
2468
2469 #[test]
2470 fn batch_and_stream_preserve_score_order() {
2471 let scores = vec![Score::default(), Score::default()];
2472 let batch = analyze_batch(&scores);
2473 let streamed: Vec<_> = analyze_stream(scores.clone()).collect();
2474 assert_eq!(batch, streamed);
2475 assert_eq!(batch.len(), scores.len());
2476 }
2477
2478 #[test]
2479 fn detects_authentic_cadence_from_adjacent_measures() {
2480 let mut score = Score::default();
2481 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2482 voice.clear();
2483 for (step, octave) in [(Step::G, 3), (Step::B, 3), (Step::D, 4)] {
2484 voice.push(Note::new(Pitch::new(step, octave), Duration::Quarter));
2485 }
2486 let voice = &mut score.parts[0].staves[0].measures[1].voices[0];
2487 voice.clear();
2488 for step in [Step::C, Step::E, Step::G] {
2489 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2490 }
2491 let result = analyze_score(&score);
2492 assert_eq!(result.cadence_candidates.len(), 1);
2493 assert_eq!(result.cadence_candidates[0].kind, CadenceKind::Authentic);
2494 assert_eq!(result.cadence_candidates[0].evidence.len(), 2);
2495 }
2496
2497 #[test]
2498 fn flags_parallel_octaves_between_aligned_voices() {
2499 let mut score = Score::default();
2500 let measure = &mut score.parts[0].staves[0].measures[0];
2501 measure.voices[0].clear();
2502 measure.voices[1].clear();
2503 for (upper, lower) in [(Step::C, Step::C), (Step::D, Step::D)] {
2504 measure.voices[0].push(Note::new(Pitch::new(upper, 4), Duration::Quarter));
2505 measure.voices[1].push(Note::new(Pitch::new(lower, 3), Duration::Quarter));
2506 }
2507 let observations = analyze_voice_leading(&score);
2508 assert_eq!(observations.len(), 1);
2509 assert!(observations[0].parallel_perfect);
2510 assert_eq!(observations[0].evidence.len(), 2);
2511 let diagnostics = analyze_satb(&score);
2512 assert_eq!(diagnostics.len(), 1);
2513 assert_eq!(diagnostics[0].kind, SatbDiagnosticKind::ParallelPerfect);
2514 assert_eq!(diagnostics[0].severity, SatbSeverity::Warning);
2515 }
2516
2517 #[test]
2518 fn reports_voice_crossing_as_error() {
2519 let mut score = Score::default();
2520 let measure = &mut score.parts[0].staves[0].measures[0];
2521 measure.voices[0].clear();
2522 measure.voices[1].clear();
2523 measure.voices[0].push(Note::new(Pitch::new(Step::C, 3), Duration::Quarter));
2524 measure.voices[1].push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2525 let diagnostics = analyze_satb(&score);
2526 assert_eq!(diagnostics.len(), 1);
2527 assert_eq!(diagnostics[0].kind, SatbDiagnosticKind::VoiceCrossing);
2528 assert_eq!(diagnostics[0].severity, SatbSeverity::Error);
2529 }
2530
2531 #[test]
2532 fn satb_region_replaces_local_findings_and_preserves_outside_findings() {
2533 let mut previous_score = Score::default();
2534 for measure in &mut previous_score.parts[0].staves[0].measures {
2535 measure.voices[0].clear();
2536 measure.voices[1].clear();
2537 measure.voices[0].push(Note::new(Pitch::new(Step::C, 3), Duration::Quarter));
2538 measure.voices[1].push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2539 }
2540 let previous = analyze_score(&previous_score);
2541
2542 let mut edited_score = previous_score.clone();
2543 let measure = &mut edited_score.parts[0].staves[0].measures[0];
2544 measure.voices[0][0] = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2545 let region = AnalysisRegion {
2546 part: 0,
2547 staff: 0,
2548 start_measure: 0,
2549 end_measure: 1,
2550 };
2551 let refreshed = analyze_selected_categories_in_region(
2552 &edited_score,
2553 &previous,
2554 &[AnalysisCategory::SatbDiagnostics],
2555 Some(®ion),
2556 );
2557
2558 assert_eq!(refreshed.satb_diagnostics.len(), 3);
2559 assert!(
2560 refreshed
2561 .satb_diagnostics
2562 .iter()
2563 .all(|diagnostic| diagnostic.upper.measure >= 1)
2564 );
2565 }
2566
2567 #[test]
2568 fn finds_repeated_melodic_motif_with_source_spans() {
2569 let mut score = Score::default();
2570 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2571 voice.clear();
2572 for step in [Step::C, Step::D, Step::E, Step::G, Step::A, Step::B] {
2573 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2574 }
2575 let motifs = analyze_motifs(&score);
2576 assert_eq!(motifs.len(), 1);
2577 assert_eq!(motifs[0].signature, vec![2, 2]);
2578 assert_eq!(motifs[0].occurrences.len(), 2);
2579 assert_eq!(motifs[0].occurrences[0].evidence.len(), 3);
2580 }
2581
2582 #[test]
2583 fn reports_measure_ending_rest_as_phrase_boundary() {
2584 let mut score = Score::default();
2585 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2586 voice.clear();
2587 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2588 voice.push(Note::rest(Duration::Quarter));
2589 let boundaries = analyze_phrase_boundaries(&score);
2590 assert!(boundaries.iter().any(|boundary| {
2591 boundary.reason == PhraseBoundaryReason::RestTermination
2592 && boundary.address.measure == 0
2593 && boundary.address.note == 1
2594 }));
2595 }
2596
2597 #[test]
2598 fn benchmark_reports_perfect_scores_for_hand_verified_empty_fixture() {
2599 let score = Score::default();
2600 let cases = [BenchmarkCase {
2601 name: "empty-score",
2602 score: &score,
2603 expected: BenchmarkExpectation {
2604 phrase_boundaries: 4,
2605 ..BenchmarkExpectation::default()
2606 },
2607 }];
2608 let reports = run_benchmark(&cases);
2609 assert_eq!(reports.len(), 1);
2610 assert_eq!(reports[0].precision_percent, 100);
2611 assert_eq!(reports[0].recall_percent, 100);
2612 assert_eq!(reports[0].explanation_completeness_percent, 100);
2613 assert!(reports[0].failures.is_empty());
2614 }
2615
2616 #[test]
2617 fn benchmark_reports_category_level_failure_details() {
2618 let score = Score::default();
2619 let cases = [BenchmarkCase {
2620 name: "under-annotated-score",
2621 score: &score,
2622 expected: BenchmarkExpectation {
2623 phrase_boundaries: 5,
2624 ..BenchmarkExpectation::default()
2625 },
2626 }];
2627 let reports = run_benchmark(&cases);
2628 assert_eq!(
2629 reports[0].failures,
2630 vec![BenchmarkFailure {
2631 category: BenchmarkCategory::PhraseBoundaries,
2632 expected: 5,
2633 predicted: 4,
2634 missing: 1,
2635 excess: 0,
2636 }]
2637 );
2638 }
2639
2640 #[test]
2641 fn benchmark_suite_aggregates_case_status_and_metrics() {
2642 let score = Score::default();
2643 let cases = [
2644 BenchmarkCase {
2645 name: "passing",
2646 score: &score,
2647 expected: BenchmarkExpectation {
2648 phrase_boundaries: 4,
2649 ..BenchmarkExpectation::default()
2650 },
2651 },
2652 BenchmarkCase {
2653 name: "failing",
2654 score: &score,
2655 expected: BenchmarkExpectation {
2656 phrase_boundaries: 5,
2657 ..BenchmarkExpectation::default()
2658 },
2659 },
2660 ];
2661 let suite = run_benchmark_suite(&cases);
2662 assert_eq!(suite.case_count, 2);
2663 assert_eq!(suite.passed_case_count, 1);
2664 assert_eq!(suite.failed_case_count, 1);
2665 assert_eq!(suite.cases.len(), 2);
2666 assert_eq!(suite.precision_percent, 100);
2667 assert_eq!(suite.recall_percent, 90);
2668 assert_eq!(suite.explanation_completeness_percent, 100);
2669 }
2670
2671 struct TestPass(&'static str);
2672
2673 impl AnalysisPass for TestPass {
2674 fn id(&self) -> &str {
2675 self.0
2676 }
2677
2678 fn run(&self, _score: &Score) -> serde_json::Value {
2679 serde_json::json!({ "pass": self.0 })
2680 }
2681 }
2682
2683 #[test]
2684 fn extension_passes_run_in_stable_id_order() {
2685 let score = Score::default();
2686 let beta = TestPass("beta");
2687 let alpha = TestPass("alpha");
2688 let results = run_analysis_passes(&score, &[&beta, &alpha]).unwrap();
2689 assert_eq!(
2690 results
2691 .iter()
2692 .map(|result| result.pass_id.as_str())
2693 .collect::<Vec<_>>(),
2694 ["alpha", "beta"]
2695 );
2696 assert_eq!(results[0].output["pass"], "alpha");
2697 }
2698
2699 #[test]
2700 fn extension_passes_reject_empty_and_duplicate_ids() {
2701 let score = Score::default();
2702 let empty = TestPass("");
2703 assert_eq!(
2704 run_analysis_passes(&score, &[&empty]),
2705 Err(AnalysisPassError::EmptyId)
2706 );
2707 let first = TestPass("same");
2708 let second = TestPass("same");
2709 assert_eq!(
2710 run_analysis_passes(&score, &[&first, &second]),
2711 Err(AnalysisPassError::DuplicateId("same".to_string()))
2712 );
2713 }
2714}