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