1use acorde_core::{
4 ChangeHint, ChordSymbol, KeySignature, NoteAddr, Pitch, Score, Step, detect_chord,
5 roman_numeral, roman_numeral_figured, transpose_fifths,
6};
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, HashMap, VecDeque};
9use thiserror::Error;
10
11pub const ANALYSIS_SCHEMA_VERSION: u32 = 14;
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct ChordLabel {
17 pub address: NoteAddr,
18 pub chord: ChordSymbol,
19 #[serde(default)]
21 pub name: String,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub roman_numeral: Option<String>,
24 pub confidence: u8,
25 pub rule_id: String,
26 pub evidence: Vec<NoteAddr>,
27}
28
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct AnalysisResult {
32 pub schema_version: u32,
33 #[serde(default)]
35 pub score_fingerprint: String,
36 pub chords: Vec<ChordLabel>,
37 pub intervals: Vec<IntervalObservation>,
38 #[serde(default)]
39 pub key_estimates: Vec<KeyEstimate>,
40 #[serde(default)]
41 pub cadence_candidates: Vec<CadenceCandidate>,
42 #[serde(default)]
43 pub voice_leading: Vec<VoiceLeadingObservation>,
44 #[serde(default)]
45 pub satb_diagnostics: Vec<SatbDiagnostic>,
46 #[serde(default)]
47 pub motifs: Vec<MotifPattern>,
48 #[serde(default)]
49 pub phrase_boundaries: Vec<PhraseBoundary>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54pub enum AnalysisCategory {
55 Chords,
56 Intervals,
57 KeyEstimates,
58 CadenceCandidates,
59 VoiceLeading,
60 SatbDiagnostics,
61 Motifs,
62 PhraseBoundaries,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct AnalysisRegion {
68 pub part: usize,
69 pub staff: usize,
70 pub start_measure: usize,
71 pub end_measure: usize,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct AnalysisRefreshPlan {
77 pub local_categories: Vec<AnalysisCategory>,
78 pub global_categories: Vec<AnalysisCategory>,
79 pub region: Option<AnalysisRegion>,
80 pub context_before: usize,
81 pub context_after: usize,
82}
83
84pub fn analysis_refresh_plan(hint: &ChangeHint) -> AnalysisRefreshPlan {
86 let all = all_analysis_categories();
87 if !hint.layout_dirty && !hint.playback_dirty {
88 return AnalysisRefreshPlan {
89 local_categories: Vec::new(),
90 global_categories: Vec::new(),
91 region: None,
92 context_before: 0,
93 context_after: 0,
94 };
95 }
96 match &hint.scope {
97 acorde_core::ChangeScope::Measures {
98 part,
99 staff,
100 start,
101 end,
102 } => AnalysisRefreshPlan {
103 local_categories: vec![
104 AnalysisCategory::Chords,
105 AnalysisCategory::Intervals,
106 AnalysisCategory::CadenceCandidates,
107 AnalysisCategory::VoiceLeading,
108 AnalysisCategory::SatbDiagnostics,
109 AnalysisCategory::PhraseBoundaries,
110 ],
111 global_categories: vec![AnalysisCategory::KeyEstimates, AnalysisCategory::Motifs],
112 region: Some(AnalysisRegion {
113 part: *part,
114 staff: *staff,
115 start_measure: *start,
116 end_measure: *end,
117 }),
118 context_before: 1,
119 context_after: 1,
120 },
121 acorde_core::ChangeScope::Part(_) | acorde_core::ChangeScope::Global => {
122 AnalysisRefreshPlan {
123 local_categories: Vec::new(),
124 global_categories: all,
125 region: None,
126 context_before: 0,
127 context_after: 0,
128 }
129 }
130 }
131}
132
133fn all_analysis_categories() -> Vec<AnalysisCategory> {
134 vec![
135 AnalysisCategory::Chords,
136 AnalysisCategory::Intervals,
137 AnalysisCategory::KeyEstimates,
138 AnalysisCategory::CadenceCandidates,
139 AnalysisCategory::VoiceLeading,
140 AnalysisCategory::SatbDiagnostics,
141 AnalysisCategory::Motifs,
142 AnalysisCategory::PhraseBoundaries,
143 ]
144}
145
146pub fn affected_categories_for_change_hint(hint: &ChangeHint) -> Vec<AnalysisCategory> {
152 let plan = analysis_refresh_plan(hint);
153 let selected: Vec<_> = plan
154 .local_categories
155 .into_iter()
156 .chain(plan.global_categories)
157 .collect();
158 all_analysis_categories()
159 .into_iter()
160 .filter(|category| selected.contains(category))
161 .collect()
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166pub struct AnalysisDiff {
167 pub previous_score_fingerprint: String,
168 pub current_score_fingerprint: String,
169 pub schema_changed: bool,
170 pub changed_categories: Vec<AnalysisCategory>,
171}
172
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct AnalysisEditResult {
176 pub analysis: AnalysisResult,
177 pub diff: AnalysisDiff,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub struct AnalysisProvenance {
183 pub category: AnalysisCategory,
184 pub rule_id: String,
185 pub confidence: u8,
186 pub evidence: Vec<NoteAddr>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub label: Option<String>,
190}
191
192pub fn analysis_provenance(
194 analysis: &AnalysisResult,
195 address: &NoteAddr,
196) -> Vec<AnalysisProvenance> {
197 let mut findings = Vec::new();
198 let mut add = |category, rule_id: &str, confidence, evidence: &[NoteAddr]| {
199 if evidence.iter().any(|item| item == address) {
200 findings.push(AnalysisProvenance {
201 category,
202 rule_id: rule_id.to_string(),
203 confidence,
204 evidence: evidence.to_vec(),
205 label: None,
206 });
207 }
208 };
209 for item in &analysis.chords {
210 add(
211 AnalysisCategory::Chords,
212 &item.rule_id,
213 item.confidence,
214 &item.evidence,
215 );
216 }
217 for item in &analysis.intervals {
218 add(
219 AnalysisCategory::Intervals,
220 &item.rule_id,
221 100,
222 &item.evidence,
223 );
224 }
225 for item in &analysis.key_estimates {
226 add(
227 AnalysisCategory::KeyEstimates,
228 &item.rule_id,
229 item.confidence,
230 &item.evidence,
231 );
232 }
233 for item in &analysis.cadence_candidates {
234 add(
235 AnalysisCategory::CadenceCandidates,
236 &item.rule_id,
237 item.confidence,
238 &item.evidence,
239 );
240 }
241 for item in &analysis.voice_leading {
242 add(
243 AnalysisCategory::VoiceLeading,
244 &item.rule_id,
245 item.confidence,
246 &item.evidence,
247 );
248 }
249 for item in &analysis.satb_diagnostics {
250 add(
251 AnalysisCategory::SatbDiagnostics,
252 &item.rule_id,
253 item.confidence,
254 &item.evidence,
255 );
256 }
257 for item in &analysis.motifs {
258 for occurrence in &item.occurrences {
259 add(
260 AnalysisCategory::Motifs,
261 &item.rule_id,
262 item.confidence,
263 &occurrence.evidence,
264 );
265 }
266 }
267 for item in &analysis.phrase_boundaries {
268 add(
269 AnalysisCategory::PhraseBoundaries,
270 &item.rule_id,
271 item.confidence,
272 &item.evidence,
273 );
274 }
275 for finding in &mut findings {
276 if finding.category == AnalysisCategory::Chords {
277 finding.label = analysis
278 .chords
279 .iter()
280 .find(|item| item.rule_id == finding.rule_id && item.evidence == finding.evidence)
281 .map(|item| item.name.clone());
282 }
283 }
284 findings.sort_by_key(|item| {
285 (
286 analysis_category_rank(item.category),
287 item.rule_id.clone(),
288 item.evidence
289 .iter()
290 .map(|address| {
291 (
292 address.part,
293 address.staff,
294 address.measure,
295 address.voice,
296 address.note,
297 )
298 })
299 .collect::<Vec<_>>(),
300 )
301 });
302 findings
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307pub struct AnalysisChangeExplanation {
308 pub diff: AnalysisDiff,
309 pub previous: Vec<AnalysisProvenance>,
310 pub current: Vec<AnalysisProvenance>,
311}
312
313pub fn explain_analysis_change(
315 previous: &AnalysisResult,
316 current: &AnalysisResult,
317 address: &NoteAddr,
318) -> AnalysisChangeExplanation {
319 AnalysisChangeExplanation {
320 diff: diff_analysis(previous, current),
321 previous: analysis_provenance(previous, address),
322 current: analysis_provenance(current, address),
323 }
324}
325
326fn analysis_category_rank(category: AnalysisCategory) -> u8 {
327 match category {
328 AnalysisCategory::Chords => 0,
329 AnalysisCategory::Intervals => 1,
330 AnalysisCategory::KeyEstimates => 2,
331 AnalysisCategory::CadenceCandidates => 3,
332 AnalysisCategory::VoiceLeading => 4,
333 AnalysisCategory::SatbDiagnostics => 5,
334 AnalysisCategory::Motifs => 6,
335 AnalysisCategory::PhraseBoundaries => 7,
336 }
337}
338
339impl AnalysisDiff {
340 pub fn is_empty(&self) -> bool {
342 !self.schema_changed && self.changed_categories.is_empty()
343 }
344}
345
346pub fn diff_analysis(previous: &AnalysisResult, current: &AnalysisResult) -> AnalysisDiff {
348 let mut changed_categories = Vec::new();
349 if previous.chords != current.chords {
350 changed_categories.push(AnalysisCategory::Chords);
351 }
352 if previous.intervals != current.intervals {
353 changed_categories.push(AnalysisCategory::Intervals);
354 }
355 if previous.key_estimates != current.key_estimates {
356 changed_categories.push(AnalysisCategory::KeyEstimates);
357 }
358 if previous.cadence_candidates != current.cadence_candidates {
359 changed_categories.push(AnalysisCategory::CadenceCandidates);
360 }
361 if previous.voice_leading != current.voice_leading {
362 changed_categories.push(AnalysisCategory::VoiceLeading);
363 }
364 if previous.satb_diagnostics != current.satb_diagnostics {
365 changed_categories.push(AnalysisCategory::SatbDiagnostics);
366 }
367 if previous.motifs != current.motifs {
368 changed_categories.push(AnalysisCategory::Motifs);
369 }
370 if previous.phrase_boundaries != current.phrase_boundaries {
371 changed_categories.push(AnalysisCategory::PhraseBoundaries);
372 }
373 AnalysisDiff {
374 previous_score_fingerprint: previous.score_fingerprint.clone(),
375 current_score_fingerprint: current.score_fingerprint.clone(),
376 schema_changed: previous.schema_version != current.schema_version,
377 changed_categories,
378 }
379}
380
381impl AnalysisResult {
382 pub fn cache_key(&self) -> String {
384 format!(
385 "analysis-v{}-{}",
386 self.schema_version, self.score_fingerprint
387 )
388 }
389
390 pub fn matches_score(&self, score: &Score) -> bool {
392 self.score_fingerprint == score_fingerprint(score)
393 }
394}
395
396pub trait AnalysisPass {
398 fn id(&self) -> &str;
400
401 fn run(&self, score: &Score) -> serde_json::Value;
403}
404
405#[derive(Debug, Error, Clone, PartialEq, Eq)]
407pub enum AnalysisPassError {
408 #[error("analysis pass ID must not be empty")]
409 EmptyId,
410 #[error("duplicate analysis pass ID: {0}")]
411 DuplicateId(String),
412}
413
414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
416pub struct AnalysisPassResult {
417 pub pass_id: String,
418 pub output: serde_json::Value,
419}
420
421pub fn run_analysis_passes(
423 score: &Score,
424 passes: &[&dyn AnalysisPass],
425) -> Result<Vec<AnalysisPassResult>, AnalysisPassError> {
426 let mut ordered: Vec<&dyn AnalysisPass> = passes.to_vec();
427 for pass in &ordered {
428 if pass.id().is_empty() {
429 return Err(AnalysisPassError::EmptyId);
430 }
431 }
432 ordered.sort_by(|left, right| left.id().cmp(right.id()));
433 for pair in ordered.windows(2) {
434 if pair[0].id() == pair[1].id() {
435 return Err(AnalysisPassError::DuplicateId(pair[0].id().to_string()));
436 }
437 }
438 Ok(ordered
439 .into_iter()
440 .map(|pass| AnalysisPassResult {
441 pass_id: pass.id().to_string(),
442 output: pass.run(score),
443 })
444 .collect())
445}
446
447#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
449pub struct KeyEstimate {
450 pub key: KeySignature,
451 pub covered_pitches: usize,
452 pub total_pitches: usize,
453 #[serde(default)]
455 pub weighted_covered_beats: f64,
456 #[serde(default)]
458 pub total_duration_beats: f64,
459 pub confidence: u8,
460 pub rule_id: String,
461 pub evidence: Vec<NoteAddr>,
462}
463
464#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
466pub struct LocalKeyEstimate {
467 pub measure: usize,
468 pub window_start: usize,
469 pub window_end_exclusive: usize,
470 pub key: KeySignature,
471 pub profile_correlation: f64,
473 pub rule_id: String,
474}
475
476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
478pub struct CadenceCandidate {
479 pub from: NoteAddr,
480 pub to: NoteAddr,
481 pub kind: CadenceKind,
482 pub confidence: u8,
483 pub rule_id: String,
484 pub evidence: Vec<NoteAddr>,
485}
486
487#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
489pub enum CadenceKind {
490 Authentic,
491 Plagal,
492 Deceptive,
493 Half,
494}
495
496#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
498pub struct VoiceLeadingObservation {
499 pub upper: NoteAddr,
500 pub lower: NoteAddr,
501 pub upper_motion: i16,
502 pub lower_motion: i16,
503 #[serde(default)]
505 pub upper_motion_cents: i32,
506 #[serde(default)]
508 pub lower_motion_cents: i32,
509 pub parallel_perfect: bool,
510 pub confidence: u8,
511 pub rule_id: String,
512 pub evidence: Vec<NoteAddr>,
513}
514
515#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
517pub struct QuartetNote {
518 pub address: NoteAddr,
519 pub pitch: Pitch,
520 pub onset_beats: f64,
522 pub duration_beats: f64,
523}
524
525#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
530pub struct VoiceLeadingQuartet {
531 pub first_before: QuartetNote,
532 pub first_after: QuartetNote,
533 pub second_before: QuartetNote,
534 pub second_after: QuartetNote,
535 pub contrary_motion: bool,
536 pub similar_motion: bool,
537 pub parallel_fifth: bool,
540 pub parallel_octave: bool,
542 pub proper_resolution_without_key: bool,
544 pub rule_id: String,
545 pub evidence: Vec<NoteAddr>,
546}
547
548#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
550pub struct SatbDiagnostic {
551 pub upper: NoteAddr,
552 pub lower: NoteAddr,
553 pub kind: SatbDiagnosticKind,
554 pub severity: SatbSeverity,
555 pub confidence: u8,
556 pub rule_id: String,
557 pub evidence: Vec<NoteAddr>,
558}
559
560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
562pub enum SatbDiagnosticKind {
563 VoiceCrossing,
564 WideSpacing,
565 ParallelPerfect,
566}
567
568#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
570pub enum SatbSeverity {
571 Error,
572 Warning,
573}
574
575#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
577pub struct MotifPattern {
578 pub signature: Vec<i8>,
579 pub occurrences: Vec<MotifOccurrence>,
580 pub confidence: u8,
581 pub rule_id: String,
582}
583
584#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
586pub struct MotifOccurrence {
587 pub start: NoteAddr,
588 pub end: NoteAddr,
589 pub evidence: Vec<NoteAddr>,
590}
591
592#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
594pub struct PhraseBoundary {
595 pub address: NoteAddr,
596 pub reason: PhraseBoundaryReason,
597 pub confidence: u8,
598 pub rule_id: String,
599 pub evidence: Vec<NoteAddr>,
600}
601
602#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
604pub enum PhraseBoundaryReason {
605 RestTermination,
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
610pub struct BenchmarkExpectation {
611 pub chords: usize,
612 pub intervals: usize,
613 pub key_estimates: usize,
614 pub cadence_candidates: usize,
615 pub voice_leading: usize,
616 pub satb_diagnostics: usize,
617 pub motifs: usize,
618 pub phrase_boundaries: usize,
619}
620
621#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
623pub struct AnalysisCounts {
624 pub chords: usize,
625 pub intervals: usize,
626 pub key_estimates: usize,
627 pub cadence_candidates: usize,
628 pub voice_leading: usize,
629 pub satb_diagnostics: usize,
630 pub motifs: usize,
631 pub phrase_boundaries: usize,
632}
633
634#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
636pub enum BenchmarkCategory {
637 Chords,
638 Intervals,
639 KeyEstimates,
640 CadenceCandidates,
641 VoiceLeading,
642 SatbDiagnostics,
643 Motifs,
644 PhraseBoundaries,
645}
646
647#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
649pub struct BenchmarkFailure {
650 pub category: BenchmarkCategory,
651 pub expected: usize,
652 pub predicted: usize,
653 pub missing: usize,
654 pub excess: usize,
655}
656
657#[derive(Debug, Clone)]
659pub struct BenchmarkCase<'a> {
660 pub name: &'a str,
661 pub score: &'a Score,
662 pub expected: BenchmarkExpectation,
663}
664
665#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
667pub struct BenchmarkCaseReport {
668 pub name: String,
669 pub predicted: AnalysisCounts,
670 pub expected: BenchmarkExpectation,
671 pub precision_percent: u8,
672 pub recall_percent: u8,
673 pub explanation_completeness_percent: u8,
674 pub failures: Vec<BenchmarkFailure>,
675}
676
677#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
679pub struct BenchmarkSuiteReport {
680 pub cases: Vec<BenchmarkCaseReport>,
681 pub case_count: usize,
682 pub passed_case_count: usize,
683 pub failed_case_count: usize,
684 pub precision_percent: u8,
685 pub recall_percent: u8,
686 pub explanation_completeness_percent: u8,
687}
688
689#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
691pub struct IntervalObservation {
692 pub from: NoteAddr,
693 pub to: NoteAddr,
694 pub semitones: u8,
695 #[serde(default)]
697 pub cents: i32,
698 pub diatonic_steps: i8,
699 pub rule_id: String,
700 pub evidence: Vec<NoteAddr>,
701}
702
703pub fn analyze_score(score: &Score) -> AnalysisResult {
705 let chords = analyze_chords_only(score);
706 let intervals = analyze_intervals(score);
707 let key_estimates = estimate_keys(score);
708 let cadence_candidates = analyze_cadences(&chords);
709 let voice_leading = analyze_voice_leading(score);
710 let satb_diagnostics = analyze_satb_in_region(score, &voice_leading, None);
711 let motifs = analyze_motifs(score);
712 let phrase_boundaries = analyze_phrase_boundaries(score);
713 AnalysisResult {
714 schema_version: ANALYSIS_SCHEMA_VERSION,
715 score_fingerprint: score_fingerprint(score),
716 chords,
717 intervals,
718 key_estimates,
719 cadence_candidates,
720 voice_leading,
721 satb_diagnostics,
722 motifs,
723 phrase_boundaries,
724 }
725}
726
727fn analyze_chords_only(score: &Score) -> Vec<ChordLabel> {
728 analyze_chords_in_region(score, None)
729}
730
731#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
733pub struct VerticalSonority {
734 pub measure: usize,
738 pub beat: f64,
740 pub label: ChordLabel,
741}
742
743#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
745pub struct VerticalTimelineIssue {
746 pub measure: usize,
747 pub part: usize,
748 pub staff: usize,
749 pub kind: VerticalTimelineIssueKind,
750}
751
752#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
753#[serde(rename_all = "snake_case")]
754pub enum VerticalTimelineIssueKind {
755 MissingMeasure,
756 DifferentStart,
757 DifferentLength,
758 InvalidLength,
759 InvalidNoteDuration,
760 OverfullVoice,
761 UnrepresentableConcertPitch,
762}
763
764#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
766pub struct VerticalAnalysisReport {
767 pub sonorities: Vec<VerticalSonority>,
768 pub timeline_issues: Vec<VerticalTimelineIssue>,
769}
770
771#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
773pub struct VerticalCadenceReport {
774 pub cadence_candidates: Vec<CadenceCandidate>,
775 pub timeline_issues: Vec<VerticalTimelineIssue>,
776}
777
778fn vertical_measure_starts(score: &Score) -> Vec<Vec<Vec<f64>>> {
779 score
780 .parts
781 .iter()
782 .map(|part| {
783 part.staves
784 .iter()
785 .map(|staff| {
786 let mut start = 0.0;
787 (0..staff.measures.len())
788 .map(|index| {
789 let current = start;
790 start += staff.measure_beats(index, &score.settings.time_signature);
791 current
792 })
793 .collect()
794 })
795 .collect()
796 })
797 .collect()
798}
799
800fn vertical_measure_alignment_issues(
801 score: &Score,
802 starts: &[Vec<Vec<f64>>],
803 measure_index: usize,
804) -> Vec<VerticalTimelineIssue> {
805 const EPSILON: f64 = 1e-7;
806 let mut reference: Option<(f64, f64)> = None;
807 let mut issues = Vec::new();
808 for (part_index, part) in score.parts.iter().enumerate() {
809 for (staff_index, staff) in part.staves.iter().enumerate() {
810 let Some(measure) = staff.measures.get(measure_index) else {
811 issues.push(VerticalTimelineIssue {
812 measure: measure_index,
813 part: part_index,
814 staff: staff_index,
815 kind: VerticalTimelineIssueKind::MissingMeasure,
816 });
817 continue;
818 };
819 let start = starts[part_index][staff_index][measure_index];
820 let length = staff.measure_beats(measure_index, &score.settings.time_signature);
821 if !start.is_finite()
822 || !length.is_finite()
823 || length <= 0.0
824 || measure
825 .actual_length
826 .is_some_and(|value| value.beats().is_none())
827 {
828 issues.push(VerticalTimelineIssue {
829 measure: measure_index,
830 part: part_index,
831 staff: staff_index,
832 kind: VerticalTimelineIssueKind::InvalidLength,
833 });
834 continue;
835 }
836 for voice in &measure.voices {
837 let mut elapsed = 0.0;
838 let mut invalid = false;
839 for note in voice {
840 let duration = if voice.len() == 1 && note.is_plain_whole_rest() {
841 length
842 } else {
843 note.beats()
844 };
845 if !duration.is_finite() || duration < 0.0 {
846 issues.push(VerticalTimelineIssue {
847 measure: measure_index,
848 part: part_index,
849 staff: staff_index,
850 kind: VerticalTimelineIssueKind::InvalidNoteDuration,
851 });
852 invalid = true;
853 break;
854 }
855 elapsed += duration;
856 }
857 if !invalid && elapsed > length + EPSILON {
858 issues.push(VerticalTimelineIssue {
859 measure: measure_index,
860 part: part_index,
861 staff: staff_index,
862 kind: VerticalTimelineIssueKind::OverfullVoice,
863 });
864 }
865 }
866 if let Some((reference_start, reference_length)) = reference {
867 if (start - reference_start).abs() > EPSILON {
868 issues.push(VerticalTimelineIssue {
869 measure: measure_index,
870 part: part_index,
871 staff: staff_index,
872 kind: VerticalTimelineIssueKind::DifferentStart,
873 });
874 }
875 if (length - reference_length).abs() > EPSILON {
876 issues.push(VerticalTimelineIssue {
877 measure: measure_index,
878 part: part_index,
879 staff: staff_index,
880 kind: VerticalTimelineIssueKind::DifferentLength,
881 });
882 }
883 } else {
884 reference = Some((start, length));
885 }
886 }
887 }
888 issues
889}
890
891struct WrittenEvent<'a> {
892 start: f64,
893 end: f64,
894 address: NoteAddr,
895 note: &'a acorde_core::Note,
896}
897
898fn written_measure_events(score: &Score, measure_index: usize) -> Vec<WrittenEvent<'_>> {
901 let mut events = Vec::new();
902 for (part_index, part) in score.parts.iter().enumerate() {
903 for staff_index in 0..part.staves.len() {
904 events.extend(written_staff_measure_events(
905 score,
906 part_index,
907 staff_index,
908 measure_index,
909 ));
910 }
911 }
912 events
913}
914
915fn written_staff_measure_events(
916 score: &Score,
917 part_index: usize,
918 staff_index: usize,
919 measure_index: usize,
920) -> Vec<WrittenEvent<'_>> {
921 let Some(staff) = score
922 .parts
923 .get(part_index)
924 .and_then(|part| part.staves.get(staff_index))
925 else {
926 return Vec::new();
927 };
928 let Some(measure) = staff.measures.get(measure_index) else {
929 return Vec::new();
930 };
931 let bar_beats = staff.measure_beats(measure_index, &score.settings.time_signature);
932 let mut events = Vec::new();
933 for (voice_index, voice) in measure.voices.iter().enumerate() {
934 let mut beat = 0.0;
935 for (note_index, note) in voice.iter().enumerate() {
936 let duration = if voice.len() == 1 && note.is_plain_whole_rest() {
937 bar_beats
938 } else {
939 note.beats()
940 };
941 if !duration.is_finite() || duration < 0.0 {
942 continue;
943 }
944 events.push(WrittenEvent {
945 start: beat,
946 end: beat + duration,
947 address: NoteAddr {
948 part: part_index,
949 staff: staff_index,
950 measure: measure_index,
951 voice: voice_index,
952 note: note_index,
953 },
954 note,
955 });
956 beat += duration;
957 }
958 }
959 events
960}
961
962fn sounding_transpose(score: &Score, address: &NoteAddr) -> Option<i8> {
963 let part = score.parts.get(address.part)?;
964 let staff = part.staves.get(address.staff)?;
965 let measure = staff.measures.get(address.measure)?;
966 let instrument = measure
967 .instrument_change
968 .as_ref()
969 .or(part.instrument.as_ref());
970 let channel = instrument.map_or(part.midi_channel, |value| value.midi_channel);
971 Some(if channel == 9 {
972 0
973 } else {
974 staff
975 .transpose_semitones
976 .saturating_add(instrument.map_or(0, |value| value.transpose_semitones))
977 })
978}
979
980fn concert_key_at_measure(
981 score: &Score,
982 part_index: usize,
983 staff_index: usize,
984 measure_index: usize,
985) -> Option<KeySignature> {
986 let staff = score.parts.get(part_index)?.staves.get(staff_index)?;
987 let measure = staff.measures.get(measure_index)?;
988 let address = NoteAddr {
989 part: part_index,
990 staff: staff_index,
991 measure: measure_index,
992 voice: 0,
993 note: 0,
994 };
995 let semitones = sounding_transpose(score, &address)?;
996 if measure.key_sig.is_none()
997 && let Some(previous_index) = measure_index.checked_sub(1)
998 && sounding_transpose(
999 score,
1000 &NoteAddr {
1001 measure: previous_index,
1002 ..address.clone()
1003 },
1004 ) != Some(semitones)
1005 {
1006 return None;
1007 }
1008 let explicit = staff
1009 .measures
1010 .iter()
1011 .take(measure_index + 1)
1012 .rev()
1013 .find_map(|measure| measure.key_sig.as_ref());
1014 let written = if semitones == 0 {
1017 explicit.unwrap_or(&score.settings.key_signature)
1018 } else {
1019 explicit?
1020 };
1021 Some(KeySignature {
1022 fifths: transpose_fifths(written.fifths, &written.mode, semitones),
1023 mode: written.mode.clone(),
1024 })
1025}
1026
1027fn agreed_concert_key_at_measure(score: &Score, measure_index: usize) -> Option<KeySignature> {
1028 let mut agreed: Option<KeySignature> = None;
1029 for (part_index, part) in score.parts.iter().enumerate() {
1030 for (staff_index, staff) in part.staves.iter().enumerate() {
1031 let measure = staff.measures.get(measure_index)?;
1032 let contributes = measure.voices.iter().flatten().any(|note| {
1033 (!note.is_rest && !note.is_unpitched && !note.pitches.is_empty())
1034 || note.chord_symbol.is_some()
1035 });
1036 if !contributes {
1037 continue;
1038 }
1039 let candidate = concert_key_at_measure(score, part_index, staff_index, measure_index)?;
1040 if let Some(previous) = &agreed
1041 && previous != &candidate
1042 {
1043 return None;
1044 }
1045 agreed = Some(candidate);
1046 }
1047 }
1048 agreed
1049}
1050
1051fn agreed_concert_key_for_addresses<'a>(
1052 score: &Score,
1053 addresses: impl IntoIterator<Item = &'a NoteAddr>,
1054) -> Option<KeySignature> {
1055 let mut agreed: Option<KeySignature> = None;
1056 for address in addresses {
1057 let candidate =
1058 concert_key_at_measure(score, address.part, address.staff, address.measure)?;
1059 if agreed
1060 .as_ref()
1061 .is_some_and(|previous| previous != &candidate)
1062 {
1063 return None;
1064 }
1065 agreed = Some(candidate);
1066 }
1067 agreed
1068}
1069
1070struct TieStartCandidate {
1071 address: NoteAddr,
1072 within_measure_start: f64,
1073 absolute_end: f64,
1074}
1075
1076type TieStartIndex = HashMap<(usize, i32), Vec<TieStartCandidate>>;
1077
1078fn tie_start_index(score: &Score, starts: &[Vec<Vec<f64>>]) -> TieStartIndex {
1079 const EPSILON: f64 = 1e-7;
1080 let mut index: TieStartIndex = HashMap::new();
1081 for (part_index, part) in score.parts.iter().enumerate() {
1082 for (staff_index, staff) in part.staves.iter().enumerate() {
1083 for (measure_index, &measure_start) in
1084 starts[part_index][staff_index].iter().enumerate()
1085 {
1086 let bar_length = staff.measure_beats(measure_index, &score.settings.time_signature);
1087 for event in
1088 written_staff_measure_events(score, part_index, staff_index, measure_index)
1089 {
1090 if event.note.is_rest
1091 || event.note.is_unpitched
1092 || event.end - event.start <= EPSILON
1093 || event.end > bar_length + EPSILON
1094 || !measure_start.is_finite()
1095 {
1096 continue;
1097 }
1098 let Some(transpose) = sounding_transpose(score, &event.address) else {
1099 continue;
1100 };
1101 for (pitch_index, pitch) in event.note.pitches.iter().enumerate() {
1102 if event.note.pitch_tie_start(pitch_index) {
1103 let cents = pitch.to_midi_cents() + i32::from(transpose) * 100;
1104 index
1105 .entry((part_index, cents))
1106 .or_default()
1107 .push(TieStartCandidate {
1108 address: event.address.clone(),
1109 within_measure_start: event.start,
1110 absolute_end: measure_start + event.end,
1111 });
1112 }
1113 }
1114 }
1115 }
1116 }
1117 }
1118 index
1119}
1120
1121fn is_tied_continuation(
1125 score: &Score,
1126 event: &WrittenEvent<'_>,
1127 pitch_index: usize,
1128 starts: &[Vec<Vec<f64>>],
1129 tie_starts: &TieStartIndex,
1130) -> bool {
1131 const EPSILON: f64 = 1e-7;
1132 if !event.note.pitch_tie_end(pitch_index) {
1133 return false;
1134 }
1135 let Some(pitch) = event.note.pitches.get(pitch_index) else {
1136 return false;
1137 };
1138 let Some(transpose) = sounding_transpose(score, &event.address) else {
1139 return false;
1140 };
1141 let Some(&measure_start) = starts
1142 .get(event.address.part)
1143 .and_then(|part| part.get(event.address.staff))
1144 .and_then(|staff| staff.get(event.address.measure))
1145 else {
1146 return false;
1147 };
1148 let absolute_start = measure_start + event.start;
1149 if !absolute_start.is_finite() {
1150 return false;
1151 }
1152 let cents = pitch.to_midi_cents() + i32::from(transpose) * 100;
1153 let explicit_source = event
1154 .note
1155 .pitch_tie_sources
1156 .get(pitch_index)
1157 .and_then(Option::as_ref);
1158 tie_starts
1159 .get(&(event.address.part, cents))
1160 .is_some_and(|candidates| {
1161 candidates.iter().any(|candidate| {
1162 (candidate.absolute_end - absolute_start).abs() < EPSILON
1163 && explicit_source.is_none_or(|source| {
1164 source.matches(
1165 &event.address,
1166 &candidate.address,
1167 candidate.within_measure_start - event.start,
1168 )
1169 })
1170 })
1171 })
1172}
1173
1174fn has_new_pitched_attack(
1175 score: &Score,
1176 event: &WrittenEvent<'_>,
1177 starts: &[Vec<Vec<f64>>],
1178 tie_starts: &TieStartIndex,
1179) -> bool {
1180 event
1181 .note
1182 .pitches
1183 .iter()
1184 .enumerate()
1185 .any(|(index, _)| !is_tied_continuation(score, event, index, starts, tie_starts))
1186}
1187
1188fn concert_pitch(
1192 score: &Score,
1193 event: &WrittenEvent<'_>,
1194 pitch: &Pitch,
1195 key: &KeySignature,
1196) -> Option<Pitch> {
1197 let semitones = sounding_transpose(score, &event.address)?;
1198 if semitones == 0 {
1199 return Some(pitch.clone());
1200 }
1201 let base =
1202 (pitch.to_midi_cents() - i32::from(pitch.microtone_cents)) / 100 + i32::from(semitones);
1203 let step_index = match pitch.step {
1204 Step::C => 0,
1205 Step::D => 1,
1206 Step::E => 2,
1207 Step::F => 3,
1208 Step::G => 4,
1209 Step::A => 5,
1210 Step::B => 6,
1211 };
1212 const DIATONIC_STEPS: [i32; 12] = [0, 0, 1, 2, 2, 3, 3, 4, 5, 5, 6, 6];
1216 let shift =
1217 semitones.div_euclid(12) as i32 * 7 + DIATONIC_STEPS[semitones.rem_euclid(12) as usize];
1218 let diatonic = i32::from(pitch.octave) * 7 + step_index + shift;
1219 let step = match diatonic.rem_euclid(7) {
1220 0 => Step::C,
1221 1 => Step::D,
1222 2 => Step::E,
1223 3 => Step::F,
1224 4 => Step::G,
1225 5 => Step::A,
1226 _ => Step::B,
1227 };
1228 let octave = diatonic.div_euclid(7);
1229 let alter = base - (octave + 1) * 12 - i32::from(step.to_semitone());
1230 if let (Ok(octave), Ok(alter)) = (i8::try_from(octave), i8::try_from(alter))
1231 && (-2..=2).contains(&alter)
1232 {
1233 return Pitch::try_with_microtone(step, octave, alter, pitch.microtone_cents);
1234 }
1235 let mut result = Pitch::from_midi((60 + base.rem_euclid(12)) as u8, key.fifths < 0);
1236 let octave =
1237 (base - i32::from(result.step.to_semitone()) - i32::from(result.alter)).div_euclid(12) - 1;
1238 result.octave = i8::try_from(octave).ok()?;
1239 result.microtone_cents = pitch.microtone_cents;
1240 Some(result)
1241}
1242
1243pub fn analyze_vertical_sonorities(score: &Score) -> Vec<VerticalSonority> {
1253 analyze_vertical_sonorities_report(score).sonorities
1254}
1255
1256pub fn analyze_vertical_sonorities_report(score: &Score) -> VerticalAnalysisReport {
1257 analyze_vertical_sonorities_with_key(score, None)
1258}
1259
1260pub fn analyze_vertical_sonorities_in_key(
1265 score: &Score,
1266 key: &KeySignature,
1267) -> Vec<VerticalSonority> {
1268 analyze_vertical_sonorities_in_key_report(score, key).sonorities
1269}
1270
1271pub fn analyze_vertical_sonorities_in_key_report(
1272 score: &Score,
1273 key: &KeySignature,
1274) -> VerticalAnalysisReport {
1275 analyze_vertical_sonorities_with_key(score, Some(key))
1276}
1277
1278pub fn analyze_arpeggiated_bars_report(score: &Score) -> VerticalAnalysisReport {
1284 analyze_arpeggiated_bars_with_key(score, None)
1285}
1286
1287pub fn analyze_arpeggiated_bars_in_key_report(
1289 score: &Score,
1290 key: &KeySignature,
1291) -> VerticalAnalysisReport {
1292 analyze_arpeggiated_bars_with_key(score, Some(key))
1293}
1294
1295pub fn analyze_vertical_cadences_report(score: &Score) -> VerticalCadenceReport {
1303 analyze_vertical_cadences_with_key(score, None)
1304}
1305
1306pub fn analyze_vertical_cadences_in_key_report(
1308 score: &Score,
1309 key: &KeySignature,
1310) -> VerticalCadenceReport {
1311 analyze_vertical_cadences_with_key(score, Some(key))
1312}
1313
1314fn is_plain_roman(roman: &str, numeral: &str) -> bool {
1315 if roman.contains('/') {
1316 return false;
1317 }
1318 roman.strip_prefix(numeral).is_some_and(|suffix| {
1319 suffix.is_empty() || suffix.starts_with(|character: char| character.is_ascii_digit())
1320 })
1321}
1322
1323fn active_outer_sounding_semitones(score: &Score, measure: usize, beat: f64) -> Option<(i32, i32)> {
1324 const EPSILON: f64 = 1e-7;
1325 let mut bass = None;
1326 let mut top = None;
1327 for event in written_measure_events(score, measure) {
1328 if event.start > beat + EPSILON
1329 || event.end <= beat + EPSILON
1330 || event.note.is_rest
1331 || event.note.is_unpitched
1332 {
1333 continue;
1334 }
1335 let transpose = i32::from(sounding_transpose(score, &event.address)?);
1336 for pitch in &event.note.pitches {
1337 let cents = pitch.to_midi_cents() + transpose * 100;
1338 if cents.rem_euclid(100) != 0 {
1339 return None;
1340 }
1341 let semitone = cents / 100;
1342 bass = Some(bass.map_or(semitone, |low: i32| low.min(semitone)));
1343 top = Some(top.map_or(semitone, |high: i32| high.max(semitone)));
1344 }
1345 }
1346 Some((bass?, top?))
1347}
1348
1349fn analyze_vertical_cadences_with_key(
1350 score: &Score,
1351 key_override: Option<&KeySignature>,
1352) -> VerticalCadenceReport {
1353 let report = analyze_arpeggiated_bars_with_key(score, key_override);
1354 let mut result = VerticalCadenceReport {
1355 cadence_candidates: Vec::new(),
1356 timeline_issues: report.timeline_issues,
1357 };
1358 if valid_alternate_bar_partition(score, &vertical_measure_starts(score)).is_some() {
1360 return result;
1361 }
1362 let mut sonorities: Vec<_> = report.sonorities.iter().collect();
1363 sonorities.sort_by(|left, right| {
1364 left.measure
1365 .cmp(&right.measure)
1366 .then_with(|| left.beat.total_cmp(&right.beat))
1367 });
1368 for (index, current) in sonorities.iter().enumerate() {
1369 if result
1370 .timeline_issues
1371 .iter()
1372 .any(|issue| issue.measure == current.measure)
1373 {
1374 continue;
1375 }
1376 let Some(roman) = current.label.roman_numeral.as_deref() else {
1377 continue;
1378 };
1379 if !is_plain_roman(roman, "I") {
1380 continue;
1381 }
1382 let previous = sonorities[index.saturating_sub(8)..index]
1383 .iter()
1384 .rev()
1385 .find(|previous| {
1386 current.measure.saturating_sub(previous.measure) <= 1
1387 && previous
1388 .label
1389 .roman_numeral
1390 .as_deref()
1391 .is_some_and(|label| label != roman)
1392 });
1393 let Some(previous) = previous else {
1394 continue;
1395 };
1396 if result
1397 .timeline_issues
1398 .iter()
1399 .any(|issue| issue.measure == previous.measure)
1400 || !previous
1401 .label
1402 .roman_numeral
1403 .as_deref()
1404 .is_some_and(|label| is_plain_roman(label, "V"))
1405 {
1406 continue;
1407 }
1408 let Some((previous_bass, _)) =
1409 active_outer_sounding_semitones(score, previous.measure, previous.beat)
1410 else {
1411 continue;
1412 };
1413 let Some((current_bass, current_top)) =
1414 active_outer_sounding_semitones(score, current.measure, current.beat)
1415 else {
1416 continue;
1417 };
1418 if (current_bass - previous_bass).rem_euclid(12) != 5
1419 || (current_top - current_bass).rem_euclid(12) != 0
1420 {
1421 continue;
1422 }
1423 let mut evidence = previous.label.evidence.clone();
1424 for address in ¤t.label.evidence {
1425 if !evidence.contains(address) {
1426 evidence.push(address.clone());
1427 }
1428 }
1429 result.cadence_candidates.push(CadenceCandidate {
1430 from: previous.label.address.clone(),
1431 to: current.label.address.clone(),
1432 kind: CadenceKind::Authentic,
1433 confidence: if current.beat.abs() < 1e-7 { 70 } else { 55 },
1434 rule_id: "vertical-dominant-tonic-outer-voices".to_string(),
1435 evidence,
1436 });
1437 }
1438 result
1439}
1440
1441fn analyze_arpeggiated_bars_with_key(
1442 score: &Score,
1443 key_override: Option<&KeySignature>,
1444) -> VerticalAnalysisReport {
1445 const EPSILON: f64 = 1e-7;
1446 let mut report = analyze_vertical_sonorities_with_key(score, key_override);
1447 let starts = vertical_measure_starts(score);
1448 if valid_alternate_bar_partition(score, &starts).is_some() {
1451 return report;
1452 }
1453 let tie_starts = tie_start_index(score, &starts);
1454 let Some(reference) = score.parts.first().and_then(|part| part.staves.first()) else {
1455 return report;
1456 };
1457 for measure_index in 0..reference.measures.len() {
1458 if report
1459 .timeline_issues
1460 .iter()
1461 .any(|issue| issue.measure == measure_index)
1462 || report
1463 .sonorities
1464 .iter()
1465 .any(|item| item.measure == measure_index && item.beat.abs() < EPSILON)
1466 {
1467 continue;
1468 }
1469 let length = reference.measure_beats(measure_index, &score.settings.time_signature);
1470 if !length.is_finite() || length <= 0.0 {
1471 continue;
1472 }
1473 let window = (length / 2.0).min(2.0);
1474 let written = written_measure_events(score, measure_index);
1475 let opening: Vec<_> = written
1476 .iter()
1477 .filter(|event| {
1478 !event.note.is_rest
1479 && !event.note.is_unpitched
1480 && !event.note.pitches.is_empty()
1481 && event.end > event.start + EPSILON
1482 && event.start < window - EPSILON
1483 })
1484 .collect();
1485 if opening.len() < 4
1486 || !opening.iter().any(|event| {
1487 event.start.abs() < EPSILON
1488 && has_new_pitched_attack(score, event, &starts, &tie_starts)
1489 })
1490 || opening
1491 .iter()
1492 .any(|event| event.start > EPSILON && event.note.chord_symbol.is_some())
1493 {
1494 continue;
1495 }
1496 let resolved_key = key_override
1497 .cloned()
1498 .or_else(|| agreed_concert_key_at_measure(score, measure_index));
1499 let spelling_key = resolved_key
1500 .as_ref()
1501 .unwrap_or(&score.settings.key_signature);
1502 let Some(pitches): Option<Vec<_>> = opening
1503 .iter()
1504 .flat_map(|event| {
1505 event
1506 .note
1507 .pitches
1508 .iter()
1509 .map(|pitch| concert_pitch(score, event, pitch, spelling_key))
1510 })
1511 .collect()
1512 else {
1513 continue;
1514 };
1515 let Some(chord) = detect_chord(&pitches) else {
1516 continue;
1517 };
1518 if !matches!(
1519 chord.kind.as_str(),
1520 "major"
1521 | "minor"
1522 | "diminished"
1523 | "augmented"
1524 | "dominant"
1525 | "major-seventh"
1526 | "minor-seventh"
1527 | "diminished-seventh"
1528 | "half-diminished"
1529 ) {
1530 continue;
1531 }
1532 let Some(anchor) = opening.iter().find(|event| event.start.abs() < EPSILON) else {
1533 continue;
1534 };
1535 report.sonorities.push(VerticalSonority {
1536 measure: measure_index,
1537 beat: 0.0,
1538 label: ChordLabel {
1539 address: anchor.address.clone(),
1540 name: chord_name(&chord),
1541 roman_numeral: resolved_key
1542 .as_ref()
1543 .and_then(|key| roman_numeral_figured(&chord, key)),
1544 chord,
1545 confidence: 55,
1546 rule_id: "bar-arpeggio-template".to_string(),
1547 evidence: opening.iter().map(|event| event.address.clone()).collect(),
1548 },
1549 });
1550 }
1551 for measure_index in 0..reference.measures.len() {
1555 if report
1556 .timeline_issues
1557 .iter()
1558 .any(|issue| issue.measure == measure_index)
1559 {
1560 continue;
1561 }
1562 let written = written_measure_events(score, measure_index);
1563 let known: Vec<_> = report
1564 .sonorities
1565 .iter()
1566 .filter(|item| item.measure == measure_index)
1567 .cloned()
1568 .collect();
1569 let mut onsets: Vec<_> = written
1570 .iter()
1571 .filter(|event| {
1572 !event.note.is_rest
1573 && !event.note.is_unpitched
1574 && !event.note.pitches.is_empty()
1575 && event.end > event.start + EPSILON
1576 && has_new_pitched_attack(score, event, &starts, &tie_starts)
1577 })
1578 .map(|event| event.start)
1579 .collect();
1580 onsets.sort_by(f64::total_cmp);
1581 onsets.dedup_by(|left, right| (*left - *right).abs() < EPSILON);
1582 for onset in onsets {
1583 if known.iter().any(|item| (item.beat - onset).abs() < EPSILON) {
1584 continue;
1585 }
1586 let Some(next) = known
1587 .iter()
1588 .find(|item| item.beat > onset + EPSILON && item.beat <= onset + 0.5 + EPSILON)
1589 else {
1590 continue;
1591 };
1592 let opening: Vec<_> = written
1593 .iter()
1594 .filter(|event| {
1595 !event.note.is_rest
1596 && !event.note.is_unpitched
1597 && !event.note.pitches.is_empty()
1598 && event.end > onset + EPSILON
1599 && event.start <= next.beat + EPSILON
1600 })
1601 .collect();
1602 let resolved_key = key_override
1603 .cloned()
1604 .or_else(|| agreed_concert_key_at_measure(score, measure_index));
1605 let spelling_key = resolved_key
1606 .as_ref()
1607 .unwrap_or(&score.settings.key_signature);
1608 let Some(pitches): Option<Vec<_>> = opening
1609 .iter()
1610 .flat_map(|event| {
1611 event
1612 .note
1613 .pitches
1614 .iter()
1615 .map(|pitch| concert_pitch(score, event, pitch, spelling_key))
1616 })
1617 .collect()
1618 else {
1619 continue;
1620 };
1621 let Some(chord) = detect_chord(&pitches) else {
1622 continue;
1623 };
1624 if !matches!(
1625 chord.kind.as_str(),
1626 "major"
1627 | "minor"
1628 | "diminished"
1629 | "augmented"
1630 | "dominant"
1631 | "major-seventh"
1632 | "minor-seventh"
1633 | "diminished-seventh"
1634 | "half-diminished"
1635 ) {
1636 continue;
1637 }
1638 let Some(anchor) = opening.iter().find(|event| {
1639 (event.start - onset).abs() < EPSILON
1640 && has_new_pitched_attack(score, event, &starts, &tie_starts)
1641 }) else {
1642 continue;
1643 };
1644 report.sonorities.push(VerticalSonority {
1645 measure: measure_index,
1646 beat: onset,
1647 label: ChordLabel {
1648 address: anchor.address.clone(),
1649 name: chord_name(&chord),
1650 roman_numeral: resolved_key
1651 .as_ref()
1652 .and_then(|key| roman_numeral_figured(&chord, key)),
1653 chord,
1654 confidence: 45,
1655 rule_id: "forward-completed-sonority".to_string(),
1656 evidence: opening.iter().map(|event| event.address.clone()).collect(),
1657 },
1658 });
1659 }
1660 }
1661 report.sonorities.sort_by(|left, right| {
1662 left.measure
1663 .cmp(&right.measure)
1664 .then_with(|| left.beat.total_cmp(&right.beat))
1665 });
1666 report
1667}
1668
1669struct AbsoluteWrittenEvent<'a> {
1670 start: f64,
1671 end: f64,
1672 written: WrittenEvent<'a>,
1673}
1674
1675fn valid_alternate_bar_partition(
1678 score: &Score,
1679 starts: &[Vec<Vec<f64>>],
1680) -> Option<(usize, usize)> {
1681 const EPSILON: f64 = 1e-7;
1682 let (reference_part, reference_staff, reference) = score
1683 .parts
1684 .iter()
1685 .enumerate()
1686 .flat_map(|(part_index, part)| {
1687 part.staves
1688 .iter()
1689 .enumerate()
1690 .map(move |(staff_index, staff)| (part_index, staff_index, staff))
1691 })
1692 .next()?;
1693 if reference.measures.is_empty() {
1694 return None;
1695 }
1696 let reference_starts = starts.get(reference_part)?.get(reference_staff)?;
1697 let reference_last = reference.measures.len() - 1;
1698 let reference_total = reference_starts.get(reference_last)?
1699 + reference.measure_beats(reference_last, &score.settings.time_signature);
1700 if !reference_total.is_finite() || reference_total <= 0.0 {
1701 return None;
1702 }
1703 let mut differs = false;
1704 for (part_index, part) in score.parts.iter().enumerate() {
1705 for (staff_index, staff) in part.staves.iter().enumerate() {
1706 let staff_starts = starts.get(part_index)?.get(staff_index)?;
1707 let last = staff.measures.len().checked_sub(1)?;
1708 let total =
1709 staff_starts.get(last)? + staff.measure_beats(last, &score.settings.time_signature);
1710 if !total.is_finite() || (total - reference_total).abs() > EPSILON {
1711 return None;
1712 }
1713 differs |= staff.measures.len() != reference.measures.len();
1714 for (measure_index, measure) in staff.measures.iter().enumerate() {
1715 let start = *staff_starts.get(measure_index)?;
1716 let length = staff.measure_beats(measure_index, &score.settings.time_signature);
1717 if !start.is_finite()
1718 || !length.is_finite()
1719 || length <= 0.0
1720 || measure
1721 .actual_length
1722 .is_some_and(|actual| actual.beats().is_none())
1723 {
1724 return None;
1725 }
1726 if let Some(reference_start) = reference_starts.get(measure_index) {
1727 differs |= (start - reference_start).abs() > EPSILON;
1728 differs |= (length
1729 - reference.measure_beats(measure_index, &score.settings.time_signature))
1730 .abs()
1731 > EPSILON;
1732 }
1733 for voice in &measure.voices {
1734 let mut elapsed = 0.0;
1735 for note in voice {
1736 let duration = if voice.len() == 1 && note.is_plain_whole_rest() {
1737 length
1738 } else {
1739 note.beats()
1740 };
1741 if !duration.is_finite() || duration < 0.0 {
1742 return None;
1743 }
1744 elapsed += duration;
1745 }
1746 if elapsed > length + EPSILON {
1747 return None;
1748 }
1749 }
1750 }
1751 }
1752 }
1753 differs.then_some((reference_part, reference_staff))
1754}
1755
1756fn analyze_vertical_sonorities_on_absolute_timeline(
1757 score: &Score,
1758 starts: &[Vec<Vec<f64>>],
1759 tie_starts: &TieStartIndex,
1760 reference_part: usize,
1761 reference_staff: usize,
1762 key_override: Option<&KeySignature>,
1763) -> VerticalAnalysisReport {
1764 const EPSILON: f64 = 1e-7;
1765 let reference = &score.parts[reference_part].staves[reference_staff];
1766 let reference_starts = &starts[reference_part][reference_staff];
1767 let mut events = Vec::new();
1768 for (part_index, part) in score.parts.iter().enumerate() {
1769 for (staff_index, staff) in part.staves.iter().enumerate() {
1770 for (measure_index, &measure_start) in starts[part_index][staff_index]
1771 .iter()
1772 .enumerate()
1773 .take(staff.measures.len())
1774 {
1775 for written in
1776 written_staff_measure_events(score, part_index, staff_index, measure_index)
1777 {
1778 events.push(AbsoluteWrittenEvent {
1779 start: measure_start + written.start,
1780 end: measure_start + written.end,
1781 written,
1782 });
1783 }
1784 }
1785 }
1786 }
1787 let mut key = key_override
1788 .cloned()
1789 .unwrap_or_else(|| score.settings.key_signature.clone());
1790 let mut result = Vec::new();
1791 let mut timeline_issues = Vec::new();
1792 for (measure_index, measure) in reference.measures.iter().enumerate() {
1793 let bar_start = reference_starts[measure_index];
1794 let bar_end =
1795 bar_start + reference.measure_beats(measure_index, &score.settings.time_signature);
1796 if key_override.is_none()
1797 && let Some(next_key) = &measure.key_sig
1798 {
1799 key = next_key.clone();
1800 }
1801 let window: Vec<_> = events
1802 .iter()
1803 .filter(|event| {
1804 (event.start < bar_end - EPSILON && event.end > bar_start + EPSILON)
1805 || (event.start >= bar_start - EPSILON
1806 && event.start < bar_end - EPSILON
1807 && event.written.note.chord_symbol.is_some())
1808 })
1809 .collect();
1810 let mut invalid = false;
1811 for event in &window {
1812 if event.written.note.is_rest || event.written.note.is_unpitched {
1813 continue;
1814 }
1815 if event
1816 .written
1817 .note
1818 .pitches
1819 .iter()
1820 .any(|pitch| concert_pitch(score, &event.written, pitch, &key).is_none())
1821 {
1822 timeline_issues.push(VerticalTimelineIssue {
1823 measure: event.written.address.measure,
1824 part: event.written.address.part,
1825 staff: event.written.address.staff,
1826 kind: VerticalTimelineIssueKind::UnrepresentableConcertPitch,
1827 });
1828 invalid = true;
1829 }
1830 }
1831 if invalid {
1832 continue;
1833 }
1834 let mut authored = Vec::new();
1835 let mut sounding = Vec::new();
1836 let mut onsets = Vec::new();
1837 for event in &window {
1838 if event.start >= bar_start - EPSILON
1839 && let Some(chord) = &event.written.note.chord_symbol
1840 {
1841 authored.push((event.start, event.written.address.clone(), chord));
1842 onsets.push(event.start);
1843 }
1844 if !event.written.note.is_rest
1845 && !event.written.note.is_unpitched
1846 && !event.written.note.pitches.is_empty()
1847 && event.end - event.start > EPSILON
1848 {
1849 sounding.push(*event);
1850 if event.start >= bar_start - EPSILON
1851 && has_new_pitched_attack(score, &event.written, starts, tie_starts)
1852 {
1853 onsets.push(event.start);
1854 }
1855 }
1856 }
1857 onsets.sort_by(f64::total_cmp);
1858 onsets.dedup_by(|left, right| (*left - *right).abs() < EPSILON);
1859 for onset in onsets {
1860 let authored_here: Vec<_> = authored
1861 .iter()
1862 .filter(|(start, _, _)| (*start - onset).abs() < EPSILON)
1863 .collect();
1864 let active: Vec<_> = sounding
1865 .iter()
1866 .filter(|event| event.start <= onset + EPSILON && event.end > onset + EPSILON)
1867 .collect();
1868 let resolved_key = key_override.cloned().or_else(|| {
1869 agreed_concert_key_for_addresses(
1870 score,
1871 active
1872 .iter()
1873 .map(|event| &event.written.address)
1874 .chain(authored_here.iter().map(|entry| &entry.1)),
1875 )
1876 });
1877 if !authored_here.is_empty() {
1878 result.extend(authored_here.into_iter().map(|(_, address, chord)| {
1879 VerticalSonority {
1880 measure: measure_index,
1881 beat: onset - bar_start,
1882 label: ChordLabel {
1883 address: address.clone(),
1884 chord: (*chord).clone(),
1885 name: chord_name(chord),
1886 roman_numeral: resolved_key
1887 .as_ref()
1888 .filter(|_| sounding_transpose(score, address) == Some(0))
1889 .and_then(|key| roman_numeral_figured(chord, key)),
1890 confidence: 100,
1891 rule_id: "authored-chord-symbol".to_string(),
1892 evidence: vec![address.clone()],
1893 },
1894 }
1895 }));
1896 continue;
1897 }
1898 let Some(pitches): Option<Vec<_>> = active
1899 .iter()
1900 .flat_map(|event| {
1901 event
1902 .written
1903 .note
1904 .pitches
1905 .iter()
1906 .map(|pitch| concert_pitch(score, &event.written, pitch, &key))
1907 })
1908 .collect()
1909 else {
1910 continue;
1911 };
1912 let Some(chord) = detect_chord(&pitches) else {
1913 continue;
1914 };
1915 let Some(anchor) = active.iter().find(|event| {
1916 (event.start - onset).abs() < EPSILON
1917 && has_new_pitched_attack(score, &event.written, starts, tie_starts)
1918 }) else {
1919 continue;
1920 };
1921 result.push(VerticalSonority {
1922 measure: measure_index,
1923 beat: onset - bar_start,
1924 label: ChordLabel {
1925 address: anchor.written.address.clone(),
1926 name: chord_name(&chord),
1927 roman_numeral: resolved_key
1928 .as_ref()
1929 .and_then(|key| roman_numeral_figured(&chord, key)),
1930 chord,
1931 confidence: 70,
1932 rule_id: "vertical-sonority-template".to_string(),
1933 evidence: active
1934 .iter()
1935 .map(|event| event.written.address.clone())
1936 .collect(),
1937 },
1938 });
1939 }
1940 }
1941 VerticalAnalysisReport {
1942 sonorities: result,
1943 timeline_issues,
1944 }
1945}
1946
1947fn analyze_vertical_sonorities_with_key(
1948 score: &Score,
1949 key_override: Option<&KeySignature>,
1950) -> VerticalAnalysisReport {
1951 const EPSILON: f64 = 1e-7;
1952 let measure_count = score
1953 .parts
1954 .iter()
1955 .flat_map(|part| part.staves.iter())
1956 .map(|staff| staff.measures.len())
1957 .max()
1958 .unwrap_or(0);
1959 let mut key = key_override
1960 .cloned()
1961 .unwrap_or_else(|| score.settings.key_signature.clone());
1962 let mut result = Vec::new();
1963 let mut timeline_issues = Vec::new();
1964 let starts = vertical_measure_starts(score);
1965 let tie_starts = tie_start_index(score, &starts);
1966 if let Some((reference_part, reference_staff)) = valid_alternate_bar_partition(score, &starts) {
1967 return analyze_vertical_sonorities_on_absolute_timeline(
1968 score,
1969 &starts,
1970 &tie_starts,
1971 reference_part,
1972 reference_staff,
1973 key_override,
1974 );
1975 }
1976
1977 for measure_index in 0..measure_count {
1978 let issues = vertical_measure_alignment_issues(score, &starts, measure_index);
1979 if !issues.is_empty() {
1980 timeline_issues.extend(issues);
1981 continue;
1982 }
1983 if key_override.is_none()
1984 && let Some(next_key) = score
1985 .parts
1986 .iter()
1987 .flat_map(|part| part.staves.iter())
1988 .filter_map(|staff| staff.measures.get(measure_index))
1989 .find_map(|measure| measure.key_sig.as_ref())
1990 {
1991 key = next_key.clone();
1992 }
1993 let resolved_key = key_override
1994 .cloned()
1995 .or_else(|| agreed_concert_key_at_measure(score, measure_index));
1996 let spelling_key = resolved_key.as_ref().unwrap_or(&key);
1997 let written = written_measure_events(score, measure_index);
1998 let mut unrepresentable = Vec::new();
1999 for event in &written {
2000 if event.note.is_rest || event.note.is_unpitched {
2001 continue;
2002 }
2003 for pitch in &event.note.pitches {
2004 if concert_pitch(score, event, pitch, spelling_key).is_none() {
2005 unrepresentable.push(VerticalTimelineIssue {
2006 measure: measure_index,
2007 part: event.address.part,
2008 staff: event.address.staff,
2009 kind: VerticalTimelineIssueKind::UnrepresentableConcertPitch,
2010 });
2011 }
2012 }
2013 }
2014 if !unrepresentable.is_empty() {
2015 timeline_issues.extend(unrepresentable);
2016 continue;
2017 }
2018 let mut sounding = Vec::new();
2019 let mut authored = Vec::new();
2020 let mut onsets = Vec::new();
2021 for event in &written {
2022 if let Some(chord) = &event.note.chord_symbol {
2023 let authored_key = resolved_key
2024 .as_ref()
2025 .filter(|_| sounding_transpose(score, &event.address) == Some(0));
2026 authored.push((
2027 event.start,
2028 ChordLabel {
2029 address: event.address.clone(),
2030 chord: chord.clone(),
2031 name: chord_name(chord),
2032 roman_numeral: authored_key
2033 .and_then(|key| roman_numeral_figured(chord, key)),
2034 confidence: 100,
2035 rule_id: "authored-chord-symbol".to_string(),
2036 evidence: vec![event.address.clone()],
2037 },
2038 ));
2039 onsets.push(event.start);
2040 }
2041 if !event.note.is_rest
2042 && !event.note.is_unpitched
2043 && !event.note.pitches.is_empty()
2044 && event.end - event.start > EPSILON
2045 {
2046 sounding.push(event);
2047 if has_new_pitched_attack(score, event, &starts, &tie_starts) {
2048 onsets.push(event.start);
2049 }
2050 }
2051 }
2052 onsets.sort_by(f64::total_cmp);
2053 onsets.dedup_by(|left, right| (*left - *right).abs() < EPSILON);
2054 for beat in onsets {
2055 let authored_here: Vec<_> = authored
2056 .iter()
2057 .filter(|(onset, _)| (*onset - beat).abs() < EPSILON)
2058 .collect();
2059 if !authored_here.is_empty() {
2060 result.extend(
2061 authored_here
2062 .into_iter()
2063 .map(|(_, label)| VerticalSonority {
2064 measure: measure_index,
2065 beat,
2066 label: label.clone(),
2067 }),
2068 );
2069 continue;
2070 }
2071 let active: Vec<_> = sounding
2072 .iter()
2073 .filter(|event| event.start <= beat + EPSILON && event.end > beat + EPSILON)
2074 .collect();
2075 let Some(pitches): Option<Vec<_>> = active
2076 .iter()
2077 .flat_map(|event| {
2078 event
2079 .note
2080 .pitches
2081 .iter()
2082 .map(|pitch| concert_pitch(score, event, pitch, spelling_key))
2083 })
2084 .collect()
2085 else {
2086 continue;
2087 };
2088 let Some(chord) = detect_chord(&pitches) else {
2089 continue;
2090 };
2091 let Some(anchor) = active.iter().find(|event| {
2092 (event.start - beat).abs() < EPSILON
2093 && has_new_pitched_attack(score, event, &starts, &tie_starts)
2094 }) else {
2095 continue;
2096 };
2097 result.push(VerticalSonority {
2098 measure: measure_index,
2099 beat,
2100 label: ChordLabel {
2101 address: anchor.address.clone(),
2102 name: chord_name(&chord),
2103 roman_numeral: resolved_key
2104 .as_ref()
2105 .and_then(|key| roman_numeral_figured(&chord, key)),
2106 chord,
2107 confidence: 70,
2108 rule_id: "vertical-sonority-template".to_string(),
2109 evidence: active.iter().map(|event| event.address.clone()).collect(),
2110 },
2111 });
2112 }
2113 }
2114 VerticalAnalysisReport {
2115 sonorities: result,
2116 timeline_issues,
2117 }
2118}
2119
2120pub fn analyze_chords_in_region(score: &Score, region: Option<&AnalysisRegion>) -> Vec<ChordLabel> {
2122 let mut chords = Vec::new();
2123 for (part_index, part) in score.parts.iter().enumerate() {
2124 for (staff_index, staff) in part.staves.iter().enumerate() {
2125 for (measure_index, measure) in staff.measures.iter().enumerate() {
2126 if let Some(region) = region
2127 && (part_index != region.part
2128 || staff_index != region.staff
2129 || measure_index < region.start_measure
2130 || measure_index >= region.end_measure)
2131 {
2132 continue;
2133 }
2134 let key = measure
2135 .key_sig
2136 .as_ref()
2137 .unwrap_or(&score.settings.key_signature);
2138 for (voice_index, voice) in measure.voices.iter().enumerate() {
2139 let authored: Vec<_> = voice
2140 .iter()
2141 .enumerate()
2142 .filter_map(|(note_index, note)| {
2143 note.chord_symbol.as_ref().map(|chord| (note_index, chord))
2144 })
2145 .collect();
2146 if !authored.is_empty() {
2147 for (note_index, chord) in authored {
2148 let address = NoteAddr {
2149 part: part_index,
2150 staff: staff_index,
2151 measure: measure_index,
2152 voice: voice_index,
2153 note: note_index,
2154 };
2155 chords.push(ChordLabel {
2156 address: address.clone(),
2157 name: chord_name(chord),
2158 roman_numeral: roman_numeral(chord, key),
2159 chord: chord.clone(),
2160 confidence: 100,
2161 rule_id: "authored-chord-symbol".to_string(),
2162 evidence: vec![address],
2163 });
2164 }
2165 continue;
2166 }
2167 let pitched: Vec<_> = voice
2168 .iter()
2169 .enumerate()
2170 .filter(|(_, note)| !note.is_rest && !note.pitches.is_empty())
2171 .collect();
2172 let pitches: Vec<_> = pitched
2173 .iter()
2174 .flat_map(|(_, note)| note.pitches.iter().cloned())
2175 .collect();
2176 let Some(chord) = detect_chord(&pitches) else {
2177 continue;
2178 };
2179 let evidence = pitched
2180 .iter()
2181 .map(|(note_index, _)| NoteAddr {
2182 part: part_index,
2183 staff: staff_index,
2184 measure: measure_index,
2185 voice: voice_index,
2186 note: *note_index,
2187 })
2188 .collect();
2189 chords.push(ChordLabel {
2190 address: NoteAddr {
2191 part: part_index,
2192 staff: staff_index,
2193 measure: measure_index,
2194 voice: voice_index,
2195 note: pitched[0].0,
2196 },
2197 name: chord_name(&chord),
2198 roman_numeral: roman_numeral(&chord, key),
2199 chord,
2200 confidence: 100,
2201 rule_id: "pitch-class-template".to_string(),
2202 evidence,
2203 });
2204 }
2205 }
2206 }
2207 }
2208 chords
2209}
2210
2211pub fn analyze_selected_categories(
2217 score: &Score,
2218 previous: &AnalysisResult,
2219 categories: &[AnalysisCategory],
2220) -> AnalysisResult {
2221 analyze_selected_categories_in_region(score, previous, categories, None)
2222}
2223
2224pub fn analyze_selected_categories_in_region(
2226 score: &Score,
2227 previous: &AnalysisResult,
2228 categories: &[AnalysisCategory],
2229 region: Option<&AnalysisRegion>,
2230) -> AnalysisResult {
2231 let selected = |category| categories.contains(&category);
2232 let chords =
2233 if selected(AnalysisCategory::Chords) || selected(AnalysisCategory::CadenceCandidates) {
2234 let refreshed = analyze_chords_in_region(score, region);
2235 merge_chord_region(&previous.chords, refreshed, region)
2236 } else {
2237 previous.chords.clone()
2238 };
2239 let intervals = if selected(AnalysisCategory::Intervals) {
2240 merge_interval_region(
2241 &previous.intervals,
2242 analyze_intervals_in_region(score, region),
2243 region,
2244 )
2245 } else {
2246 previous.intervals.clone()
2247 };
2248 let key_estimates = if selected(AnalysisCategory::KeyEstimates) {
2249 estimate_keys(score)
2250 } else {
2251 previous.key_estimates.clone()
2252 };
2253 let cadence_candidates = if selected(AnalysisCategory::CadenceCandidates) {
2254 analyze_cadences(&chords)
2255 } else {
2256 previous.cadence_candidates.clone()
2257 };
2258 let voice_leading = if selected(AnalysisCategory::VoiceLeading)
2259 || selected(AnalysisCategory::SatbDiagnostics)
2260 {
2261 merge_voice_leading_region(
2262 &previous.voice_leading,
2263 analyze_voice_leading_in_region(score, region),
2264 region,
2265 )
2266 } else {
2267 previous.voice_leading.clone()
2268 };
2269 let satb_diagnostics = if selected(AnalysisCategory::SatbDiagnostics) {
2270 merge_satb_region(
2271 &previous.satb_diagnostics,
2272 analyze_satb_in_region(score, &voice_leading, region),
2273 region,
2274 )
2275 } else {
2276 previous.satb_diagnostics.clone()
2277 };
2278 let motifs = if selected(AnalysisCategory::Motifs) {
2279 analyze_motifs(score)
2280 } else {
2281 previous.motifs.clone()
2282 };
2283 let phrase_boundaries = if selected(AnalysisCategory::PhraseBoundaries) {
2284 analyze_phrase_boundaries(score)
2285 } else {
2286 previous.phrase_boundaries.clone()
2287 };
2288 AnalysisResult {
2289 schema_version: ANALYSIS_SCHEMA_VERSION,
2290 score_fingerprint: score_fingerprint(score),
2291 chords,
2292 intervals,
2293 key_estimates,
2294 cadence_candidates,
2295 voice_leading,
2296 satb_diagnostics,
2297 motifs,
2298 phrase_boundaries,
2299 }
2300}
2301
2302fn merge_interval_region(
2303 previous: &[IntervalObservation],
2304 refreshed: Vec<IntervalObservation>,
2305 region: Option<&AnalysisRegion>,
2306) -> Vec<IntervalObservation> {
2307 let Some(region) = region else {
2308 return refreshed;
2309 };
2310 let mut merged: Vec<_> = previous
2311 .iter()
2312 .filter(|item| {
2313 !analysis_region_contains(region, &item.from)
2314 && !analysis_region_contains(region, &item.to)
2315 })
2316 .cloned()
2317 .collect();
2318 merged.extend(refreshed);
2319 merged.sort_by_key(|item| {
2320 (
2321 item.from.part,
2322 item.from.staff,
2323 item.from.measure,
2324 item.from.voice,
2325 item.from.note,
2326 item.to.measure,
2327 item.to.note,
2328 )
2329 });
2330 merged
2331}
2332
2333fn merge_voice_leading_region(
2334 previous: &[VoiceLeadingObservation],
2335 refreshed: Vec<VoiceLeadingObservation>,
2336 region: Option<&AnalysisRegion>,
2337) -> Vec<VoiceLeadingObservation> {
2338 let Some(region) = region else {
2339 return refreshed;
2340 };
2341 let mut merged: Vec<_> = previous
2342 .iter()
2343 .filter(|item| {
2344 !analysis_region_contains(region, &item.upper)
2345 && !analysis_region_contains(region, &item.lower)
2346 })
2347 .cloned()
2348 .collect();
2349 merged.extend(refreshed);
2350 merged.sort_by_key(|item| {
2351 (
2352 item.upper.part,
2353 item.upper.staff,
2354 item.upper.measure,
2355 item.upper.voice,
2356 item.upper.note,
2357 item.lower.voice,
2358 )
2359 });
2360 merged
2361}
2362
2363fn merge_chord_region(
2364 previous: &[ChordLabel],
2365 refreshed: Vec<ChordLabel>,
2366 region: Option<&AnalysisRegion>,
2367) -> Vec<ChordLabel> {
2368 let Some(region) = region else {
2369 return refreshed;
2370 };
2371 let mut merged: Vec<_> = previous
2372 .iter()
2373 .filter(|label| !analysis_region_contains(region, &label.address))
2374 .cloned()
2375 .collect();
2376 merged.extend(refreshed);
2377 merged.sort_by_key(|label| {
2378 (
2379 label.address.part,
2380 label.address.staff,
2381 label.address.measure,
2382 label.address.voice,
2383 label.address.note,
2384 )
2385 });
2386 merged
2387}
2388
2389fn merge_satb_region(
2390 previous: &[SatbDiagnostic],
2391 refreshed: Vec<SatbDiagnostic>,
2392 region: Option<&AnalysisRegion>,
2393) -> Vec<SatbDiagnostic> {
2394 let Some(region) = region else {
2395 return refreshed;
2396 };
2397 let mut merged: Vec<_> = previous
2398 .iter()
2399 .filter(|diagnostic| {
2400 !diagnostic
2401 .evidence
2402 .iter()
2403 .any(|address| analysis_region_contains(region, address))
2404 })
2405 .cloned()
2406 .collect();
2407 merged.extend(refreshed);
2408 merged.sort_by_key(|diagnostic| {
2409 (
2410 diagnostic.upper.part,
2411 diagnostic.upper.staff,
2412 diagnostic.upper.measure,
2413 diagnostic.upper.voice,
2414 diagnostic.upper.note,
2415 diagnostic.lower.voice,
2416 diagnostic.rule_id.clone(),
2417 )
2418 });
2419 merged
2420}
2421
2422fn analysis_region_contains(region: &AnalysisRegion, address: &NoteAddr) -> bool {
2423 address.part == region.part
2424 && address.staff == region.staff
2425 && (region.start_measure..region.end_measure).contains(&address.measure)
2426}
2427
2428pub fn score_fingerprint(score: &Score) -> String {
2430 let mut value = serde_json::to_value(score).unwrap_or_default();
2431 remove_generated_ids(&mut value);
2432 let bytes = serde_json::to_vec(&value).unwrap_or_default();
2433 let hash = fnv1a64(&bytes);
2434 format!("fnv1a64-{hash:016x}")
2435}
2436
2437fn remove_generated_ids(value: &mut serde_json::Value) {
2438 match value {
2439 serde_json::Value::Object(object) => {
2440 object.remove("id");
2441 for child in object.values_mut() {
2442 remove_generated_ids(child);
2443 }
2444 }
2445 serde_json::Value::Array(values) => {
2446 for child in values {
2447 remove_generated_ids(child);
2448 }
2449 }
2450 _ => {}
2451 }
2452}
2453
2454pub fn analysis_cache_key(score: &Score) -> String {
2456 format!(
2457 "analysis-v{}-{}",
2458 ANALYSIS_SCHEMA_VERSION,
2459 score_fingerprint(score)
2460 )
2461}
2462
2463#[derive(Debug, Error, Clone, PartialEq, Eq)]
2465pub enum AnalysisCacheError {
2466 #[error("analysis cache capacity must be greater than zero")]
2467 ZeroCapacity,
2468}
2469
2470#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2472pub struct AnalysisCacheStats {
2473 pub hits: usize,
2474 pub misses: usize,
2475}
2476
2477#[derive(Debug, Clone)]
2484pub struct AnalysisCache {
2485 capacity: usize,
2486 entries: BTreeMap<String, AnalysisResult>,
2487 insertion_order: VecDeque<String>,
2488 stats: AnalysisCacheStats,
2489}
2490
2491impl Default for AnalysisCache {
2492 fn default() -> Self {
2493 Self {
2494 capacity: 16,
2495 entries: BTreeMap::new(),
2496 insertion_order: VecDeque::new(),
2497 stats: AnalysisCacheStats::default(),
2498 }
2499 }
2500}
2501
2502impl AnalysisCache {
2503 pub fn with_capacity(capacity: usize) -> Result<Self, AnalysisCacheError> {
2505 if capacity == 0 {
2506 return Err(AnalysisCacheError::ZeroCapacity);
2507 }
2508 Ok(Self {
2509 capacity,
2510 entries: BTreeMap::new(),
2511 insertion_order: VecDeque::new(),
2512 stats: AnalysisCacheStats::default(),
2513 })
2514 }
2515
2516 pub fn capacity(&self) -> usize {
2518 self.capacity
2519 }
2520
2521 pub fn stats(&self) -> AnalysisCacheStats {
2523 self.stats
2524 }
2525
2526 pub fn reset_stats(&mut self) {
2528 self.stats = AnalysisCacheStats::default();
2529 }
2530
2531 pub fn get(&self, score: &Score) -> Option<&AnalysisResult> {
2533 self.entries.get(&analysis_cache_key(score))
2534 }
2535
2536 pub fn analyze(&mut self, score: &Score) -> AnalysisResult {
2538 let key = analysis_cache_key(score);
2539 if let Some(result) = self.entries.get(&key) {
2540 self.stats.hits = self.stats.hits.saturating_add(1);
2541 return result.clone();
2542 }
2543
2544 self.stats.misses = self.stats.misses.saturating_add(1);
2545 let result = analyze_score(score);
2546 self.insert(key, result.clone());
2547 result
2548 }
2549
2550 pub fn analyze_batch(&mut self, scores: &[Score]) -> Vec<AnalysisResult> {
2552 scores.iter().map(|score| self.analyze(score)).collect()
2553 }
2554
2555 pub fn analyze_after_edit(&mut self, previous: &Score, current: &Score) -> AnalysisResult {
2557 if analysis_cache_key(previous) != analysis_cache_key(current) {
2558 self.invalidate(previous);
2559 }
2560 self.analyze(current)
2561 }
2562
2563 pub fn analyze_after_edit_with_diff(
2565 &mut self,
2566 previous_score: &Score,
2567 previous_result: &AnalysisResult,
2568 current: &Score,
2569 ) -> AnalysisEditResult {
2570 let analysis = self.analyze_after_edit(previous_score, current);
2571 let diff = diff_analysis(previous_result, &analysis);
2572 AnalysisEditResult { analysis, diff }
2573 }
2574
2575 pub fn analyze_selected_after_edit(
2577 &mut self,
2578 previous_score: &Score,
2579 previous_result: &AnalysisResult,
2580 current: &Score,
2581 categories: &[AnalysisCategory],
2582 ) -> AnalysisResult {
2583 let key = analysis_cache_key(current);
2584 if let Some(result) = self.entries.get(&key) {
2585 self.stats.hits = self.stats.hits.saturating_add(1);
2586 return result.clone();
2587 }
2588 self.stats.misses = self.stats.misses.saturating_add(1);
2589 if analysis_cache_key(previous_score) != key {
2590 self.invalidate(previous_score);
2591 }
2592 let result = analyze_selected_categories(current, previous_result, categories);
2593 self.insert(key, result.clone());
2594 result
2595 }
2596
2597 pub fn analyze_after_edit_with_plan(
2599 &mut self,
2600 previous_score: &Score,
2601 previous_result: &AnalysisResult,
2602 current: &Score,
2603 plan: &AnalysisRefreshPlan,
2604 ) -> AnalysisEditResult {
2605 let categories: Vec<_> = plan
2606 .local_categories
2607 .iter()
2608 .chain(&plan.global_categories)
2609 .copied()
2610 .collect();
2611 let key = analysis_cache_key(current);
2612 let analysis = if let Some(result) = self.entries.get(&key) {
2613 self.stats.hits = self.stats.hits.saturating_add(1);
2614 result.clone()
2615 } else {
2616 self.stats.misses = self.stats.misses.saturating_add(1);
2617 if analysis_cache_key(previous_score) != key {
2618 self.invalidate(previous_score);
2619 }
2620 let result = analyze_selected_categories_in_region(
2621 current,
2622 previous_result,
2623 &categories,
2624 plan.region.as_ref(),
2625 );
2626 self.insert(key, result.clone());
2627 result
2628 };
2629 AnalysisEditResult {
2630 diff: diff_analysis(previous_result, &analysis),
2631 analysis,
2632 }
2633 }
2634
2635 pub fn analyze_after_edit_with_hint(
2637 &mut self,
2638 previous_score: &Score,
2639 previous_result: &AnalysisResult,
2640 current: &Score,
2641 hint: &ChangeHint,
2642 ) -> AnalysisEditResult {
2643 let plan = analysis_refresh_plan(hint);
2644 self.analyze_after_edit_with_plan(previous_score, previous_result, current, &plan)
2645 }
2646
2647 pub fn insert(&mut self, key: String, result: AnalysisResult) {
2649 if self.entries.contains_key(&key) {
2650 self.entries.insert(key.clone(), result);
2651 self.insertion_order.retain(|existing| existing != &key);
2652 } else {
2653 self.entries.insert(key.clone(), result);
2654 }
2655 self.insertion_order.push_back(key);
2656 while self.entries.len() > self.capacity {
2657 if let Some(oldest) = self.insertion_order.pop_front() {
2658 self.entries.remove(&oldest);
2659 }
2660 }
2661 }
2662
2663 pub fn invalidate(&mut self, score: &Score) -> bool {
2665 let key = analysis_cache_key(score);
2666 let removed = self.entries.remove(&key).is_some();
2667 if removed {
2668 self.insertion_order.retain(|existing| existing != &key);
2669 }
2670 removed
2671 }
2672
2673 pub fn clear(&mut self) {
2675 self.entries.clear();
2676 self.insertion_order.clear();
2677 }
2678
2679 pub fn len(&self) -> usize {
2681 self.entries.len()
2682 }
2683
2684 pub fn is_empty(&self) -> bool {
2686 self.entries.is_empty()
2687 }
2688}
2689
2690fn fnv1a64(bytes: &[u8]) -> u64 {
2691 bytes.iter().fold(0xcbf29ce484222325u64, |hash, byte| {
2692 (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
2693 })
2694}
2695
2696impl AnalysisCounts {
2697 fn from_result(result: &AnalysisResult) -> Self {
2698 Self {
2699 chords: result.chords.len(),
2700 intervals: result.intervals.len(),
2701 key_estimates: result.key_estimates.len(),
2702 cadence_candidates: result.cadence_candidates.len(),
2703 voice_leading: result.voice_leading.len(),
2704 satb_diagnostics: result.satb_diagnostics.len(),
2705 motifs: result.motifs.len(),
2706 phrase_boundaries: result.phrase_boundaries.len(),
2707 }
2708 }
2709
2710 fn total(self) -> usize {
2711 self.chords
2712 + self.intervals
2713 + self.key_estimates
2714 + self.cadence_candidates
2715 + self.voice_leading
2716 + self.satb_diagnostics
2717 + self.motifs
2718 + self.phrase_boundaries
2719 }
2720
2721 fn explained(self, result: &AnalysisResult) -> usize {
2722 result
2723 .chords
2724 .iter()
2725 .filter(|item| !item.evidence.is_empty())
2726 .count()
2727 + result
2728 .intervals
2729 .iter()
2730 .filter(|item| !item.evidence.is_empty())
2731 .count()
2732 + result
2733 .key_estimates
2734 .iter()
2735 .filter(|item| !item.evidence.is_empty())
2736 .count()
2737 + result
2738 .cadence_candidates
2739 .iter()
2740 .filter(|item| !item.evidence.is_empty())
2741 .count()
2742 + result
2743 .voice_leading
2744 .iter()
2745 .filter(|item| !item.evidence.is_empty())
2746 .count()
2747 + result
2748 .satb_diagnostics
2749 .iter()
2750 .filter(|item| !item.evidence.is_empty())
2751 .count()
2752 + result
2753 .motifs
2754 .iter()
2755 .map(|item| {
2756 item.occurrences
2757 .iter()
2758 .filter(|occurrence| !occurrence.evidence.is_empty())
2759 .count()
2760 })
2761 .sum::<usize>()
2762 + result
2763 .phrase_boundaries
2764 .iter()
2765 .filter(|item| !item.evidence.is_empty())
2766 .count()
2767 }
2768}
2769
2770pub fn benchmark_case(case: &BenchmarkCase<'_>) -> BenchmarkCaseReport {
2772 let result = analyze_score(case.score);
2773 let predicted = AnalysisCounts::from_result(&result);
2774 let expected = case.expected;
2775 let matched = predicted.chords.min(expected.chords)
2776 + predicted.intervals.min(expected.intervals)
2777 + predicted.key_estimates.min(expected.key_estimates)
2778 + predicted
2779 .cadence_candidates
2780 .min(expected.cadence_candidates)
2781 + predicted.voice_leading.min(expected.voice_leading)
2782 + predicted.satb_diagnostics.min(expected.satb_diagnostics)
2783 + predicted.motifs.min(expected.motifs)
2784 + predicted.phrase_boundaries.min(expected.phrase_boundaries);
2785 let expected_total = AnalysisCounts {
2786 chords: expected.chords,
2787 intervals: expected.intervals,
2788 key_estimates: expected.key_estimates,
2789 cadence_candidates: expected.cadence_candidates,
2790 voice_leading: expected.voice_leading,
2791 satb_diagnostics: expected.satb_diagnostics,
2792 motifs: expected.motifs,
2793 phrase_boundaries: expected.phrase_boundaries,
2794 }
2795 .total();
2796 let predicted_total = predicted.total();
2797 let failures = [
2798 (BenchmarkCategory::Chords, expected.chords, predicted.chords),
2799 (
2800 BenchmarkCategory::Intervals,
2801 expected.intervals,
2802 predicted.intervals,
2803 ),
2804 (
2805 BenchmarkCategory::KeyEstimates,
2806 expected.key_estimates,
2807 predicted.key_estimates,
2808 ),
2809 (
2810 BenchmarkCategory::CadenceCandidates,
2811 expected.cadence_candidates,
2812 predicted.cadence_candidates,
2813 ),
2814 (
2815 BenchmarkCategory::VoiceLeading,
2816 expected.voice_leading,
2817 predicted.voice_leading,
2818 ),
2819 (
2820 BenchmarkCategory::SatbDiagnostics,
2821 expected.satb_diagnostics,
2822 predicted.satb_diagnostics,
2823 ),
2824 (BenchmarkCategory::Motifs, expected.motifs, predicted.motifs),
2825 (
2826 BenchmarkCategory::PhraseBoundaries,
2827 expected.phrase_boundaries,
2828 predicted.phrase_boundaries,
2829 ),
2830 ]
2831 .into_iter()
2832 .filter_map(|(category, expected, predicted)| {
2833 if expected == predicted {
2834 return None;
2835 }
2836 Some(BenchmarkFailure {
2837 category,
2838 expected,
2839 predicted,
2840 missing: expected.saturating_sub(predicted),
2841 excess: predicted.saturating_sub(expected),
2842 })
2843 })
2844 .collect();
2845 BenchmarkCaseReport {
2846 name: case.name.to_string(),
2847 predicted,
2848 expected,
2849 precision_percent: percentage(matched, predicted_total),
2850 recall_percent: percentage(matched, expected_total),
2851 explanation_completeness_percent: percentage(predicted.explained(&result), predicted_total),
2852 failures,
2853 }
2854}
2855
2856pub fn run_benchmark(cases: &[BenchmarkCase<'_>]) -> Vec<BenchmarkCaseReport> {
2858 cases.iter().map(benchmark_case).collect()
2859}
2860
2861pub fn run_benchmark_suite(cases: &[BenchmarkCase<'_>]) -> BenchmarkSuiteReport {
2863 let reports = run_benchmark(cases);
2864 let case_count = reports.len();
2865 let passed_case_count = reports
2866 .iter()
2867 .filter(|report| report.failures.is_empty())
2868 .count();
2869 let failed_case_count = case_count.saturating_sub(passed_case_count);
2870 let precision_total: usize = reports
2871 .iter()
2872 .map(|report| usize::from(report.precision_percent))
2873 .sum();
2874 let recall_total: usize = reports
2875 .iter()
2876 .map(|report| usize::from(report.recall_percent))
2877 .sum();
2878 let explanation_total: usize = reports
2879 .iter()
2880 .map(|report| usize::from(report.explanation_completeness_percent))
2881 .sum();
2882 let metric_denominator = case_count.saturating_mul(100);
2883 let aggregate_metric = |total: usize| {
2884 if case_count == 0 {
2885 0
2886 } else {
2887 percentage(total, metric_denominator)
2888 }
2889 };
2890 BenchmarkSuiteReport {
2891 cases: reports,
2892 case_count,
2893 passed_case_count,
2894 failed_case_count,
2895 precision_percent: aggregate_metric(precision_total),
2896 recall_percent: aggregate_metric(recall_total),
2897 explanation_completeness_percent: aggregate_metric(explanation_total),
2898 }
2899}
2900
2901fn percentage(numerator: usize, denominator: usize) -> u8 {
2902 match numerator.saturating_mul(100).checked_div(denominator) {
2903 Some(value) => value.min(100) as u8,
2904 None => 100,
2905 }
2906}
2907
2908pub fn analyze_satb(score: &Score) -> Vec<SatbDiagnostic> {
2910 let voice_leading = analyze_voice_leading(score);
2911 analyze_satb_in_region(score, &voice_leading, None)
2912}
2913
2914pub fn analyze_satb_in_region(
2916 score: &Score,
2917 voice_leading: &[VoiceLeadingObservation],
2918 region: Option<&AnalysisRegion>,
2919) -> Vec<SatbDiagnostic> {
2920 const EPSILON: f64 = 1e-7;
2921 let mut diagnostics = Vec::new();
2922 for (part_index, part) in score.parts.iter().enumerate() {
2923 for (staff_index, staff) in part.staves.iter().enumerate() {
2924 for (measure_index, measure) in staff.measures.iter().enumerate() {
2925 if let Some(region) = region
2926 && (part_index != region.part
2927 || staff_index != region.staff
2928 || measure_index < region.start_measure
2929 || measure_index >= region.end_measure)
2930 {
2931 continue;
2932 }
2933 let written =
2934 written_staff_measure_events(score, part_index, staff_index, measure_index);
2935 for upper_index in 0..measure.voices.len().saturating_sub(1) {
2936 let pair: Vec<_> = written
2937 .iter()
2938 .filter(|event| {
2939 event.address.part == part_index
2940 && event.address.staff == staff_index
2941 && (event.address.voice == upper_index
2942 || event.address.voice == upper_index + 1)
2943 })
2944 .collect();
2945 let mut onsets: Vec<_> = pair.iter().map(|event| event.start).collect();
2946 onsets.sort_by(f64::total_cmp);
2947 onsets.dedup_by(|left, right| (*left - *right).abs() < EPSILON);
2948 for beat in onsets {
2949 let active = |voice_index: usize| {
2950 pair.iter()
2951 .find(|event| {
2952 event.address.voice == voice_index
2953 && event.start <= beat + EPSILON
2954 && event.end > beat + EPSILON
2955 && !event.note.is_rest
2956 && !event.note.is_unpitched
2957 })
2958 .copied()
2959 };
2960 let (Some(upper_event), Some(lower_event)) =
2961 (active(upper_index), active(upper_index + 1))
2962 else {
2963 continue;
2964 };
2965 let (Some(upper), Some(lower)) = (
2966 upper_event.note.pitches.first(),
2967 lower_event.note.pitches.first(),
2968 ) else {
2969 continue;
2970 };
2971 let upper_addr = upper_event.address.clone();
2972 let lower_addr = lower_event.address.clone();
2973 let distance = upper.to_midi() - lower.to_midi();
2974 if distance < 0 {
2975 diagnostics.push(satb_diagnostic(
2976 upper_addr.clone(),
2977 lower_addr.clone(),
2978 SatbDiagnosticKind::VoiceCrossing,
2979 SatbSeverity::Error,
2980 "satb-voice-crossing",
2981 ));
2982 } else if distance > 24 {
2983 diagnostics.push(satb_diagnostic(
2984 upper_addr.clone(),
2985 lower_addr.clone(),
2986 SatbDiagnosticKind::WideSpacing,
2987 SatbSeverity::Warning,
2988 "satb-wide-spacing",
2989 ));
2990 }
2991 }
2992 }
2993 }
2994 }
2995 }
2996 for observation in voice_leading {
2997 if let Some(region) = region
2998 && !observation
2999 .evidence
3000 .iter()
3001 .any(|address| analysis_region_contains(region, address))
3002 {
3003 continue;
3004 }
3005 if observation.parallel_perfect {
3006 diagnostics.push(satb_diagnostic(
3007 observation.upper.clone(),
3008 observation.lower.clone(),
3009 SatbDiagnosticKind::ParallelPerfect,
3010 SatbSeverity::Warning,
3011 "satb-parallel-perfect",
3012 ));
3013 }
3014 }
3015 diagnostics
3016}
3017
3018fn satb_diagnostic(
3019 upper: NoteAddr,
3020 lower: NoteAddr,
3021 kind: SatbDiagnosticKind,
3022 severity: SatbSeverity,
3023 rule_id: &str,
3024) -> SatbDiagnostic {
3025 SatbDiagnostic {
3026 evidence: vec![upper.clone(), lower.clone()],
3027 upper,
3028 lower,
3029 kind,
3030 severity,
3031 confidence: 100,
3032 rule_id: rule_id.to_string(),
3033 }
3034}
3035
3036pub fn analyze_motifs(score: &Score) -> Vec<MotifPattern> {
3038 let mut groups: BTreeMap<(usize, usize, usize, Vec<i8>), Vec<MotifOccurrence>> =
3039 BTreeMap::new();
3040 for (part_index, part) in score.parts.iter().enumerate() {
3041 for (staff_index, staff) in part.staves.iter().enumerate() {
3042 let Some(first_measure) = staff.measures.first() else {
3043 continue;
3044 };
3045 for (voice_index, _) in first_measure.voices.iter().enumerate() {
3046 let mut segment = Vec::new();
3047 let mut segments = Vec::new();
3048 for (measure_index, measure) in staff.measures.iter().enumerate() {
3049 for (note_index, note) in measure.voices[voice_index].iter().enumerate() {
3050 let Some(pitch) = note.pitches.first() else {
3051 if segment.len() >= 3 {
3052 segments.push(std::mem::take(&mut segment));
3053 } else {
3054 segment.clear();
3055 }
3056 continue;
3057 };
3058 if note.is_rest {
3059 if segment.len() >= 3 {
3060 segments.push(std::mem::take(&mut segment));
3061 } else {
3062 segment.clear();
3063 }
3064 continue;
3065 }
3066 segment.push((
3067 NoteAddr {
3068 part: part_index,
3069 staff: staff_index,
3070 measure: measure_index,
3071 voice: voice_index,
3072 note: note_index,
3073 },
3074 pitch.to_midi(),
3075 ));
3076 }
3077 }
3078 if segment.len() >= 3 {
3079 segments.push(segment);
3080 }
3081 for segment in segments {
3082 for window in segment.windows(3) {
3083 let signature = vec![
3084 (window[1].1 - window[0].1) as i8,
3085 (window[2].1 - window[1].1) as i8,
3086 ];
3087 let occurrence = MotifOccurrence {
3088 start: window[0].0.clone(),
3089 end: window[2].0.clone(),
3090 evidence: window.iter().map(|(address, _)| address.clone()).collect(),
3091 };
3092 groups
3093 .entry((part_index, staff_index, voice_index, signature))
3094 .or_default()
3095 .push(occurrence);
3096 }
3097 }
3098 }
3099 }
3100 }
3101 groups
3102 .into_iter()
3103 .filter(|(_, occurrences)| occurrences.len() >= 2)
3104 .map(|((_, _, _, signature), occurrences)| MotifPattern {
3105 signature,
3106 occurrences,
3107 confidence: 100,
3108 rule_id: "repeated-three-note-interval-pattern".to_string(),
3109 })
3110 .collect()
3111}
3112
3113pub fn analyze_phrase_boundaries(score: &Score) -> Vec<PhraseBoundary> {
3115 let mut boundaries = Vec::new();
3116 for (part_index, part) in score.parts.iter().enumerate() {
3117 for (staff_index, staff) in part.staves.iter().enumerate() {
3118 for (measure_index, measure) in staff.measures.iter().enumerate() {
3119 for (voice_index, voice) in measure.voices.iter().enumerate() {
3120 let Some((note_index, note)) = voice.iter().enumerate().next_back() else {
3121 continue;
3122 };
3123 if !note.is_rest {
3124 continue;
3125 }
3126 let address = NoteAddr {
3127 part: part_index,
3128 staff: staff_index,
3129 measure: measure_index,
3130 voice: voice_index,
3131 note: note_index,
3132 };
3133 boundaries.push(PhraseBoundary {
3134 address: address.clone(),
3135 reason: PhraseBoundaryReason::RestTermination,
3136 confidence: 100,
3137 rule_id: "measure-ending-rest".to_string(),
3138 evidence: vec![address],
3139 });
3140 }
3141 }
3142 }
3143 }
3144 boundaries
3145}
3146
3147pub fn analyze_cadences(chords: &[ChordLabel]) -> Vec<CadenceCandidate> {
3149 let mut candidates = Vec::new();
3150 let mut previous: HashMap<(usize, usize, usize), &ChordLabel> = HashMap::new();
3151 for chord in chords {
3152 let key = (chord.address.part, chord.address.staff, chord.address.voice);
3153 let Some(previous_chord) = previous.insert(key, chord) else {
3154 continue;
3155 };
3156 let Some(from_roman) = previous_chord.roman_numeral.as_deref() else {
3157 continue;
3158 };
3159 let Some(to_roman) = chord.roman_numeral.as_deref() else {
3160 continue;
3161 };
3162 let from_figure = roman_figure(from_roman);
3163 let to_figure = roman_figure(to_roman);
3164 let kind = match (from_figure, to_figure) {
3165 ("V" | "V7", "I") => CadenceKind::Authentic,
3166 ("IV", "I") => CadenceKind::Plagal,
3167 ("V" | "V7", "vi") => CadenceKind::Deceptive,
3168 (_, "V" | "V7") => CadenceKind::Half,
3169 _ => continue,
3170 };
3171 let evidence = vec![previous_chord.address.clone(), chord.address.clone()];
3172 candidates.push(CadenceCandidate {
3173 from: previous_chord.address.clone(),
3174 to: chord.address.clone(),
3175 kind,
3176 confidence: 100,
3177 rule_id: "roman-numeral-cadence-transition".to_string(),
3178 evidence,
3179 });
3180 }
3181 candidates
3182}
3183
3184fn roman_figure(roman: &str) -> &str {
3185 roman.find('/').map_or(roman, |index| &roman[..index])
3186}
3187
3188pub fn analyze_voice_leading_quartets(score: &Score) -> Vec<VoiceLeadingQuartet> {
3195 const EPSILON: f64 = 1e-7;
3196 let starts = vertical_measure_starts(score);
3197 let mut voices: BTreeMap<(usize, usize, usize), Vec<QuartetNote>> = BTreeMap::new();
3198 for (part_index, part) in score.parts.iter().enumerate() {
3199 for (staff_index, staff) in part.staves.iter().enumerate() {
3200 for (measure_index, measure) in staff.measures.iter().enumerate() {
3201 let bar_start = starts[part_index][staff_index][measure_index];
3202 let bar_length = staff.measure_beats(measure_index, &score.settings.time_signature);
3203 if !bar_start.is_finite() || !bar_length.is_finite() || bar_length <= 0.0 {
3204 continue;
3205 }
3206 let written =
3207 written_staff_measure_events(score, part_index, staff_index, measure_index);
3208 for (voice_index, voice) in measure.voices.iter().enumerate() {
3209 let events: Vec<_> = written
3210 .iter()
3211 .filter(|event| event.address.voice == voice_index)
3212 .collect();
3213 if events.len() != voice.len()
3216 || events.iter().any(|event| {
3217 event.end > bar_length + EPSILON
3218 || event.start < -EPSILON
3219 || event.end < event.start - EPSILON
3220 })
3221 {
3222 continue;
3223 }
3224 let stream = voices
3225 .entry((part_index, staff_index, voice_index))
3226 .or_default();
3227 for event in events {
3228 if event.note.is_rest
3229 || event.note.is_unpitched
3230 || event.note.pitches.len() != 1
3231 || event.end <= event.start + EPSILON
3232 {
3233 continue;
3234 }
3235 stream.push(QuartetNote {
3236 address: event.address.clone(),
3237 pitch: event.note.pitches[0].clone(),
3238 onset_beats: bar_start + event.start,
3239 duration_beats: event.end - event.start,
3240 });
3241 }
3242 }
3243 }
3244 }
3245 }
3246
3247 let streams: Vec<_> = voices
3248 .into_values()
3249 .filter(|voice| !voice.is_empty())
3250 .collect();
3251 let mut quartets = Vec::new();
3252 for (first_index, first) in streams.iter().enumerate() {
3253 for second in streams.iter().skip(first_index + 1) {
3254 let mut onsets: Vec<_> = first
3255 .iter()
3256 .chain(second.iter())
3257 .map(|note| note.onset_beats)
3258 .collect();
3259 onsets.sort_by(f64::total_cmp);
3260 onsets.dedup_by(|left, right| (*left - *right).abs() < EPSILON);
3261 let mut first_index = 0;
3262 let mut second_index = 0;
3263 let mut previous: Option<(&QuartetNote, &QuartetNote)> = None;
3264 for beat in onsets {
3265 while first_index + 1 < first.len()
3266 && first[first_index + 1].onset_beats <= beat + EPSILON
3267 {
3268 first_index += 1;
3269 }
3270 while second_index + 1 < second.len()
3271 && second[second_index + 1].onset_beats <= beat + EPSILON
3272 {
3273 second_index += 1;
3274 }
3275 let current =
3276 first
3277 .get(first_index)
3278 .zip(second.get(second_index))
3279 .filter(|(a, b)| {
3280 a.onset_beats <= beat + EPSILON
3281 && b.onset_beats <= beat + EPSILON
3282 && a.onset_beats + a.duration_beats > beat + EPSILON
3283 && b.onset_beats + b.duration_beats > beat + EPSILON
3284 });
3285 let Some((first_after, second_after)) = current else {
3286 previous = None;
3287 continue;
3288 };
3289 if let Some((first_before, second_before)) = previous
3290 && (first_before.address != first_after.address
3291 || second_before.address != second_after.address)
3292 && first_before.onset_beats + first_before.duration_beats + EPSILON >= beat
3293 && second_before.onset_beats + second_before.duration_beats + EPSILON >= beat
3294 {
3295 let first_motion =
3296 first_after.pitch.to_midi_cents() - first_before.pitch.to_midi_cents();
3297 let second_motion =
3298 second_after.pitch.to_midi_cents() - second_before.pitch.to_midi_cents();
3299 let similar_motion = first_motion != 0
3300 && second_motion != 0
3301 && first_motion.signum() == second_motion.signum();
3302 let contrary_motion = first_motion != 0
3303 && second_motion != 0
3304 && first_motion.signum() != second_motion.signum();
3305 let parallel_fifth = (similar_motion || contrary_motion)
3306 && spelled_perfect_interval(&first_before.pitch, &second_before.pitch, 5)
3307 && spelled_perfect_interval(&first_after.pitch, &second_after.pitch, 5);
3308 let parallel_octave = (similar_motion || contrary_motion)
3309 && spelled_perfect_interval(&first_before.pitch, &second_before.pitch, 8)
3310 && spelled_perfect_interval(&first_after.pitch, &second_after.pitch, 8);
3311 let proper_resolution_without_key = keyless_quartet_resolution(
3312 &first_before.pitch,
3313 &first_after.pitch,
3314 &second_before.pitch,
3315 &second_after.pitch,
3316 first_motion,
3317 second_motion,
3318 );
3319 quartets.push(VoiceLeadingQuartet {
3320 first_before: first_before.clone(),
3321 first_after: first_after.clone(),
3322 second_before: second_before.clone(),
3323 second_after: second_after.clone(),
3324 contrary_motion,
3325 similar_motion,
3326 parallel_fifth,
3327 parallel_octave,
3328 proper_resolution_without_key,
3329 rule_id: "located-written-voice-quartet".to_string(),
3330 evidence: vec![
3331 first_before.address.clone(),
3332 first_after.address.clone(),
3333 second_before.address.clone(),
3334 second_after.address.clone(),
3335 ],
3336 });
3337 }
3338 previous = Some((first_after, second_after));
3339 }
3340 }
3341 }
3342 quartets
3343}
3344
3345fn spelled_perfect_interval(a: &Pitch, b: &Pitch, number: u8) -> bool {
3346 let diatonic = quartet_diatonic_distance(a, b);
3347 let cents = (a.to_midi_cents() - b.to_midi_cents()).abs();
3348 match number {
3349 5 => diatonic % 7 == 4 && cents % 1200 == 700,
3350 8 => diatonic >= 7 && diatonic % 7 == 0 && cents >= 1200 && cents % 1200 == 0,
3351 _ => false,
3352 }
3353}
3354
3355fn keyless_quartet_resolution(
3356 first_before: &Pitch,
3357 first_after: &Pitch,
3358 second_before: &Pitch,
3359 second_after: &Pitch,
3360 first_motion: i32,
3361 second_motion: i32,
3362) -> bool {
3363 if first_motion == 0 && second_motion == 0 {
3364 return true;
3365 }
3366 let initial_diatonic = quartet_diatonic_distance(first_before, second_before) % 7;
3367 let initial_cents = (first_before.to_midi_cents() - second_before.to_midi_cents()).abs() % 1200;
3368 let final_generic = quartet_diatonic_distance(first_after, second_after) % 7 + 1;
3369 match (initial_diatonic, initial_cents) {
3370 (3, 500) => first_motion <= 0, (3, 600) => first_motion > 0 && second_motion < 0 && final_generic == 6, (4, 600) => first_motion < 0 && second_motion > 0 && final_generic == 3, (6, 1000) => final_generic == 3, _ => true,
3375 }
3376}
3377
3378fn quartet_diatonic_distance(a: &Pitch, b: &Pitch) -> i32 {
3379 fn index(step: &Step) -> i32 {
3380 match step {
3381 Step::C => 0,
3382 Step::D => 1,
3383 Step::E => 2,
3384 Step::F => 3,
3385 Step::G => 4,
3386 Step::A => 5,
3387 Step::B => 6,
3388 }
3389 }
3390 ((i32::from(a.octave) - i32::from(b.octave)) * 7 + index(&a.step) - index(&b.step)).abs()
3391}
3392
3393pub fn analyze_voice_leading(score: &Score) -> Vec<VoiceLeadingObservation> {
3395 analyze_voice_leading_in_region(score, None)
3396}
3397
3398pub fn analyze_voice_leading_in_region(
3400 score: &Score,
3401 region: Option<&AnalysisRegion>,
3402) -> Vec<VoiceLeadingObservation> {
3403 const EPSILON: f64 = 1e-7;
3404 let mut observations = Vec::new();
3405 for (part_index, part) in score.parts.iter().enumerate() {
3406 for (staff_index, staff) in part.staves.iter().enumerate() {
3407 for (measure_index, measure) in staff.measures.iter().enumerate() {
3408 if let Some(region) = region
3409 && (part_index != region.part
3410 || staff_index != region.staff
3411 || measure_index < region.start_measure
3412 || measure_index >= region.end_measure)
3413 {
3414 continue;
3415 }
3416 let written =
3417 written_staff_measure_events(score, part_index, staff_index, measure_index);
3418 for upper_index in 0..measure.voices.len().saturating_sub(1) {
3419 let pair: Vec<_> = written
3420 .iter()
3421 .filter(|event| {
3422 event.address.part == part_index
3423 && event.address.staff == staff_index
3424 && (event.address.voice == upper_index
3425 || event.address.voice == upper_index + 1)
3426 })
3427 .collect();
3428 let mut onsets: Vec<_> = pair.iter().map(|event| event.start).collect();
3429 onsets.sort_by(f64::total_cmp);
3430 onsets.dedup_by(|left, right| (*left - *right).abs() < EPSILON);
3431 let active = |voice_index: usize, beat: f64| {
3432 pair.iter()
3433 .find(|event| {
3434 event.address.voice == voice_index
3435 && event.start <= beat + EPSILON
3436 && event.end > beat + EPSILON
3437 && !event.note.is_rest
3438 && !event.note.is_unpitched
3439 && !event.note.pitches.is_empty()
3440 })
3441 .copied()
3442 };
3443 let mut previous = None;
3444 for beat in onsets {
3445 let (Some(upper), Some(lower)) =
3446 (active(upper_index, beat), active(upper_index + 1, beat))
3447 else {
3448 previous = None;
3449 continue;
3450 };
3451 if let Some((prev_upper, prev_lower)) = previous {
3452 let prev_upper: &WrittenEvent<'_> = prev_upper;
3453 let prev_lower: &WrittenEvent<'_> = prev_lower;
3454 if prev_upper.end + EPSILON >= beat
3455 && prev_lower.end + EPSILON >= beat
3456 && (prev_upper.address != upper.address
3457 || prev_lower.address != lower.address)
3458 {
3459 let (Some(u0), Some(l0), Some(u1), Some(l1)) = (
3460 prev_upper.note.pitches.first(),
3461 prev_lower.note.pitches.first(),
3462 upper.note.pitches.first(),
3463 lower.note.pitches.first(),
3464 ) else {
3465 previous = Some((upper, lower));
3466 continue;
3467 };
3468 let upper_motion_cents = u1.to_midi_cents() - u0.to_midi_cents();
3469 let lower_motion_cents = l1.to_midi_cents() - l0.to_midi_cents();
3470 let initial =
3471 (u0.to_midi_cents() - l0.to_midi_cents()).unsigned_abs() % 1200;
3472 let next =
3473 (u1.to_midi_cents() - l1.to_midi_cents()).unsigned_abs() % 1200;
3474 observations.push(VoiceLeadingObservation {
3475 upper: prev_upper.address.clone(),
3476 lower: prev_lower.address.clone(),
3477 upper_motion: u1.to_midi() - u0.to_midi(),
3478 lower_motion: l1.to_midi() - l0.to_midi(),
3479 upper_motion_cents,
3480 lower_motion_cents,
3481 parallel_perfect: matches!(initial, 0 | 700)
3482 && initial == next
3483 && upper_motion_cents != 0
3484 && upper_motion_cents.signum()
3485 == lower_motion_cents.signum(),
3486 confidence: 100,
3487 rule_id: "time-aligned-adjacent-voice-leading".to_string(),
3488 evidence: vec![
3489 prev_upper.address.clone(),
3490 prev_lower.address.clone(),
3491 ],
3492 });
3493 }
3494 }
3495 previous = Some((upper, lower));
3496 }
3497 }
3498 }
3499 }
3500 }
3501 observations
3502}
3503
3504pub fn analyze_chords(score: &Score) -> AnalysisResult {
3506 analyze_score(score)
3507}
3508
3509pub fn analyze_batch(scores: &[Score]) -> Vec<AnalysisResult> {
3511 scores.iter().map(analyze_score).collect()
3512}
3513
3514pub fn analyze_stream<I>(scores: I) -> impl Iterator<Item = AnalysisResult>
3516where
3517 I: IntoIterator<Item = Score>,
3518{
3519 scores.into_iter().map(|score| analyze_score(&score))
3520}
3521
3522pub fn estimate_keys(score: &Score) -> Vec<KeyEstimate> {
3524 estimate_key_candidates(score, true)
3525}
3526
3527fn estimate_key_candidates(score: &Score, only_best_coverage: bool) -> Vec<KeyEstimate> {
3528 let mut pitches = Vec::new();
3529 let mut evidence = Vec::new();
3530 for (part_index, part) in score.parts.iter().enumerate() {
3531 for (staff_index, staff) in part.staves.iter().enumerate() {
3532 for (measure_index, measure) in staff.measures.iter().enumerate() {
3533 for (voice_index, voice) in measure.voices.iter().enumerate() {
3534 for (note_index, note) in voice.iter().enumerate() {
3535 if note.is_rest {
3536 continue;
3537 }
3538 let duration_beats = note.beats().max(0.0);
3539 pitches.extend(note.pitches.iter().map(|pitch| (pitch, duration_beats)));
3540 if !note.pitches.is_empty() {
3541 evidence.push(NoteAddr {
3542 part: part_index,
3543 staff: staff_index,
3544 measure: measure_index,
3545 voice: voice_index,
3546 note: note_index,
3547 });
3548 }
3549 }
3550 }
3551 }
3552 }
3553 }
3554 if pitches.is_empty() {
3555 return Vec::new();
3556 }
3557 let total_pitches = pitches.len();
3558 let total_duration_beats: f64 = pitches.iter().map(|(_, beats)| *beats).sum();
3559 let mut candidates = Vec::with_capacity(30);
3560 for fifths in -7..=7 {
3561 for mode in ["major", "minor"] {
3562 let key = KeySignature {
3563 fifths,
3564 mode: mode.to_string(),
3565 };
3566 let covered = pitches
3567 .iter()
3568 .filter(|(pitch, _)| key.contains_pitch(pitch))
3569 .count();
3570 let weighted_covered_beats = pitches
3571 .iter()
3572 .filter(|(pitch, _)| key.contains_pitch(pitch))
3573 .map(|(_, beats)| *beats)
3574 .sum::<f64>();
3575 candidates.push((key, covered, weighted_covered_beats));
3576 }
3577 }
3578 candidates.sort_by(
3579 |(left_key, left_score, left_weight), (right_key, right_score, right_weight)| {
3580 right_weight
3581 .total_cmp(left_weight)
3582 .then_with(|| right_score.cmp(left_score))
3583 .then_with(|| left_key.fifths.abs().cmp(&right_key.fifths.abs()))
3584 .then_with(|| left_key.fifths.cmp(&right_key.fifths))
3585 .then_with(|| left_key.mode.cmp(&right_key.mode))
3586 },
3587 );
3588 let best_weight = candidates[0].2;
3589 candidates
3590 .into_iter()
3591 .take_while(|(_, _, weighted)| {
3592 !only_best_coverage || weighted.total_cmp(&best_weight).is_eq()
3593 })
3594 .map(
3595 |(key, covered_pitches, weighted_covered_beats)| KeyEstimate {
3596 key,
3597 covered_pitches,
3598 total_pitches,
3599 weighted_covered_beats,
3600 total_duration_beats,
3601 confidence: if total_duration_beats > 0.0 {
3602 ((weighted_covered_beats / total_duration_beats * 100.0).round() as u8).min(100)
3603 } else {
3604 0
3605 },
3606 rule_id: "duration-weighted-diatonic-pitch-coverage".to_string(),
3607 evidence: evidence.clone(),
3608 },
3609 )
3610 .collect()
3611}
3612
3613pub fn estimate_keys_with_tonal_profile(score: &Score) -> Vec<KeyEstimate> {
3620 let mut estimates = estimate_keys(score);
3621 rank_key_estimates_by_tonal_profile(
3622 score,
3623 &mut estimates,
3624 &KRUMHANSL_MAJOR,
3625 &KRUMHANSL_MINOR,
3626 "duration-weighted-diatonic-pitch-coverage+krumhansl-profile-ranking",
3627 );
3628 estimates
3629}
3630
3631pub fn estimate_keys_with_aarden_essen_profile(score: &Score) -> Vec<KeyEstimate> {
3639 let mut estimates = estimate_key_candidates(score, false);
3640 if !rank_key_estimates_by_tonal_profile(
3641 score,
3642 &mut estimates,
3643 &AARDEN_ESSEN_MAJOR,
3644 &AARDEN_ESSEN_MINOR,
3645 "duration-weighted-aarden-essen-profile-ranking",
3646 ) {
3647 return estimate_keys(score);
3648 }
3649 estimates
3650}
3651
3652const KRUMHANSL_MAJOR: [f64; 12] = [
3655 6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88,
3656];
3657const KRUMHANSL_MINOR: [f64; 12] = [
3658 6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17,
3659];
3660const AARDEN_ESSEN_MAJOR: [f64; 12] = [
3661 17.7661, 0.145624, 14.9265, 0.160186, 19.8049, 11.3587, 0.291248, 22.062, 0.145624, 8.15494,
3662 0.232998, 4.95122,
3663];
3664const AARDEN_ESSEN_MINOR: [f64; 12] = [
3665 18.2648, 0.737619, 14.0499, 16.8599, 0.702494, 14.4362, 0.702494, 18.6161, 4.56621, 1.93186,
3666 7.37619, 1.75623,
3667];
3668
3669fn rank_key_estimates_by_tonal_profile(
3670 score: &Score,
3671 estimates: &mut [KeyEstimate],
3672 major: &[f64; 12],
3673 minor: &[f64; 12],
3674 rule_id: &str,
3675) -> bool {
3676 if estimates.len() < 2 {
3677 return false;
3678 }
3679
3680 let mut pitch_classes = [0.0_f64; 12];
3681 for part in &score.parts {
3682 for staff in &part.staves {
3683 for measure in &staff.measures {
3684 for voice in &measure.voices {
3685 for note in voice {
3686 if note.is_rest {
3687 continue;
3688 }
3689 let beats = note.beats();
3690 if !beats.is_finite() || beats <= 0.0 {
3691 continue;
3692 }
3693 for pitch in ¬e.pitches {
3694 if pitch.microtone_cents != 0 {
3695 return false;
3696 }
3697 pitch_classes[pitch.to_midi().rem_euclid(12) as usize] += beats;
3698 }
3699 }
3700 }
3701 }
3702 }
3703 }
3704 let observed_mean = pitch_classes.iter().sum::<f64>() / 12.0;
3705 let observed_variance = pitch_classes
3706 .iter()
3707 .map(|value| (value - observed_mean).powi(2))
3708 .sum::<f64>();
3709 if observed_variance <= 0.0 {
3710 return false;
3711 }
3712 let correlation = |estimate: &KeyEstimate| {
3713 let weights = if estimate.key.mode == "minor" {
3714 minor
3715 } else {
3716 major
3717 };
3718 let tonic = (estimate.key.fifths * 7 + if estimate.key.mode == "minor" { 9 } else { 0 })
3719 .rem_euclid(12) as usize;
3720 let profile_mean = weights.iter().sum::<f64>() / 12.0;
3721 let profile_variance = weights
3722 .iter()
3723 .map(|value| (value - profile_mean).powi(2))
3724 .sum::<f64>();
3725 let covariance = pitch_classes
3726 .iter()
3727 .enumerate()
3728 .map(|(class, value)| {
3729 (value - observed_mean) * (weights[(class + 12 - tonic) % 12] - profile_mean)
3730 })
3731 .sum::<f64>();
3732 covariance / (observed_variance * profile_variance).sqrt()
3733 };
3734 estimates.sort_by(|left, right| correlation(right).total_cmp(&correlation(left)));
3735 for estimate in estimates {
3736 estimate.rule_id = rule_id.to_string();
3737 }
3738 true
3739}
3740
3741pub fn estimate_local_keys_with_aarden_essen_profile(
3749 score: &Score,
3750 radius: usize,
3751) -> Vec<LocalKeyEstimate> {
3752 let measure_count = score
3753 .parts
3754 .iter()
3755 .flat_map(|part| &part.staves)
3756 .map(|staff| staff.measures.len())
3757 .max()
3758 .unwrap_or(0);
3759 let mut histograms = vec![[0.0_f64; 12]; measure_count];
3760 for part in &score.parts {
3761 for staff in &part.staves {
3762 for (index, measure) in staff.measures.iter().enumerate() {
3763 for voice in &measure.voices {
3764 for note in voice {
3765 if note.is_rest || note.is_unpitched {
3766 continue;
3767 }
3768 let beats = note.beats();
3769 if !beats.is_finite() || beats <= 0.0 {
3770 continue;
3771 }
3772 for pitch in ¬e.pitches {
3773 if pitch.microtone_cents == 0 {
3774 histograms[index][pitch.to_midi().rem_euclid(12) as usize] += beats;
3775 }
3776 }
3777 }
3778 }
3779 }
3780 }
3781 }
3782 let written_measures = score
3783 .parts
3784 .first()
3785 .and_then(|part| part.staves.first())
3786 .map(|staff| staff.measures.as_slice())
3787 .unwrap_or(&[]);
3788 let mut reference_fifths = 0_i8;
3789 let mut estimates = Vec::with_capacity(measure_count);
3790 for measure in 0..measure_count {
3791 if let Some(key) = written_measures
3792 .get(measure)
3793 .and_then(|written| written.key_sig.as_ref())
3794 {
3795 reference_fifths = key.fifths;
3796 }
3797 let start = measure.saturating_sub(radius);
3798 let end = measure
3799 .saturating_add(radius)
3800 .saturating_add(1)
3801 .min(measure_count);
3802 let mut observed = [0.0_f64; 12];
3803 for histogram in &histograms[start..end] {
3804 for (class, weight) in histogram.iter().enumerate() {
3805 observed[class] += weight;
3806 }
3807 }
3808 let observed_mean = observed.iter().sum::<f64>() / 12.0;
3809 let observed_variance = observed
3810 .iter()
3811 .map(|weight| (weight - observed_mean).powi(2))
3812 .sum::<f64>();
3813 if observed_variance <= 0.0 {
3814 continue;
3815 }
3816 let mut best: Option<(f64, i8, &str)> = None;
3817 for fifths in -7_i8..=7 {
3818 for mode in ["major", "minor"] {
3819 let weights = if mode == "minor" {
3820 &AARDEN_ESSEN_MINOR
3821 } else {
3822 &AARDEN_ESSEN_MAJOR
3823 };
3824 let tonic = (i32::from(fifths) * 7 + if mode == "minor" { 9 } else { 0 })
3825 .rem_euclid(12) as usize;
3826 let profile_mean = weights.iter().sum::<f64>() / 12.0;
3827 let profile_variance = weights
3828 .iter()
3829 .map(|weight| (weight - profile_mean).powi(2))
3830 .sum::<f64>();
3831 let covariance = observed
3832 .iter()
3833 .enumerate()
3834 .map(|(class, weight)| {
3835 (weight - observed_mean)
3836 * (weights[(class + 12 - tonic) % 12] - profile_mean)
3837 })
3838 .sum::<f64>();
3839 let correlation = covariance / (observed_variance * profile_variance).sqrt();
3840 let better = best.is_none_or(|(best_correlation, best_fifths, best_mode)| {
3841 correlation.total_cmp(&best_correlation).is_gt()
3842 || (correlation.total_cmp(&best_correlation).is_eq()
3843 && (
3844 (i16::from(fifths) - i16::from(reference_fifths)).abs(),
3845 i16::from(fifths).abs(),
3846 fifths,
3847 std::cmp::Reverse(mode),
3848 ) < (
3849 (i16::from(best_fifths) - i16::from(reference_fifths)).abs(),
3850 i16::from(best_fifths).abs(),
3851 best_fifths,
3852 std::cmp::Reverse(best_mode),
3853 ))
3854 });
3855 if better {
3856 best = Some((correlation, fifths, mode));
3857 }
3858 }
3859 }
3860 if let Some((profile_correlation, fifths, mode)) = best {
3861 estimates.push(LocalKeyEstimate {
3862 measure,
3863 window_start: start,
3864 window_end_exclusive: end,
3865 key: KeySignature {
3866 fifths,
3867 mode: mode.to_string(),
3868 },
3869 profile_correlation,
3870 rule_id: "centered-aarden-essen-local-window".to_string(),
3871 });
3872 }
3873 }
3874 estimates
3875}
3876
3877pub fn analyze_intervals(score: &Score) -> Vec<IntervalObservation> {
3879 analyze_intervals_in_region(score, None)
3880}
3881
3882pub fn analyze_intervals_in_region(
3884 score: &Score,
3885 region: Option<&AnalysisRegion>,
3886) -> Vec<IntervalObservation> {
3887 let mut observations = Vec::new();
3888 for (part_index, part) in score.parts.iter().enumerate() {
3889 for (staff_index, staff) in part.staves.iter().enumerate() {
3890 for (measure_index, measure) in staff.measures.iter().enumerate() {
3891 for (voice_index, voice) in measure.voices.iter().enumerate() {
3892 for pair in voice.iter().enumerate().collect::<Vec<_>>().windows(2) {
3893 let (from_index, from_note) = pair[0];
3894 let (to_index, to_note) = pair[1];
3895 if from_note.is_rest || to_note.is_rest {
3896 continue;
3897 }
3898 let Some(from) = from_note.pitches.first() else {
3899 continue;
3900 };
3901 let Some(to) = to_note.pitches.first() else {
3902 continue;
3903 };
3904 let from_addr = NoteAddr {
3905 part: part_index,
3906 staff: staff_index,
3907 measure: measure_index,
3908 voice: voice_index,
3909 note: from_index,
3910 };
3911 let to_addr = NoteAddr {
3912 part: part_index,
3913 staff: staff_index,
3914 measure: measure_index,
3915 voice: voice_index,
3916 note: to_index,
3917 };
3918 if let Some(region) = region
3919 && !analysis_region_contains(region, &from_addr)
3920 && !analysis_region_contains(region, &to_addr)
3921 {
3922 continue;
3923 }
3924 observations.push(IntervalObservation {
3925 from: from_addr.clone(),
3926 to: to_addr.clone(),
3927 semitones: (to.to_midi() - from.to_midi()).unsigned_abs() as u8,
3928 cents: to.to_midi_cents() - from.to_midi_cents(),
3929 diatonic_steps: diatonic_distance(from, to),
3930 rule_id: "adjacent-melodic-interval".to_string(),
3931 evidence: vec![from_addr, to_addr],
3932 });
3933 }
3934 }
3935 }
3936 }
3937 }
3938 observations
3939}
3940
3941fn diatonic_distance(from: &acorde_core::Pitch, to: &acorde_core::Pitch) -> i8 {
3942 let step_index = |step: &acorde_core::Step| match step {
3943 acorde_core::Step::C => 0i16,
3944 acorde_core::Step::D => 1,
3945 acorde_core::Step::E => 2,
3946 acorde_core::Step::F => 3,
3947 acorde_core::Step::G => 4,
3948 acorde_core::Step::A => 5,
3949 acorde_core::Step::B => 6,
3950 };
3951 (i16::from(to.octave) * 7 + step_index(&to.step)
3952 - (i16::from(from.octave) * 7 + step_index(&from.step))) as i8
3953}
3954
3955pub fn chord_name(chord: &ChordSymbol) -> String {
3957 chord.display_text()
3958}
3959
3960#[cfg(test)]
3961mod tests {
3962 use super::*;
3963 use acorde_core::{Duration, MeasureLength, Note, Pitch, Score, Step, TupletInfo};
3964
3965 #[test]
3966 fn labels_chord_with_note_addresses_and_roman_numeral() {
3967 let mut score = Score::default();
3968 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
3969 voice.clear();
3970 for step in [Step::C, Step::E, Step::G] {
3971 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
3972 }
3973 let result = analyze_chords(&score);
3974 assert_eq!(result.schema_version, ANALYSIS_SCHEMA_VERSION);
3975 assert_eq!(result.chords.len(), 1);
3976 assert_eq!(result.intervals.len(), 2);
3977 assert!(!result.key_estimates.is_empty());
3978 assert_eq!(result.chords[0].address.note, 0);
3979 assert_eq!(result.chords[0].evidence.len(), 3);
3980 assert_eq!(result.chords[0].roman_numeral.as_deref(), Some("I"));
3981 assert_eq!(chord_name(&result.chords[0].chord), "C");
3982 assert_eq!(result.chords[0].name, "C");
3983 }
3984
3985 #[test]
3986 fn vertical_sonorities_do_not_turn_a_sequential_melody_into_a_chord() {
3987 let mut score = Score::new("melody", 120, 4, 4, 0, 1);
3988 score.parts[0].staves[0].measures[0].voices[0] = [Step::C, Step::E, Step::G]
3989 .into_iter()
3990 .map(|step| Note::new(Pitch::new(step, 4), Duration::Quarter))
3991 .collect();
3992
3993 assert!(analyze_vertical_sonorities(&score).is_empty());
3994 assert_eq!(analyze_chords(&score).chords.len(), 1);
3996 }
3997
3998 #[test]
3999 fn opt_in_arpeggiated_bar_labels_its_downbeat_with_later_note_evidence() {
4000 let mut score = Score::new("arpeggio", 120, 4, 4, 0, 1);
4001 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
4002 *voice = [(Step::C, 4), (Step::E, 4), (Step::G, 4), (Step::C, 5)]
4003 .into_iter()
4004 .map(|(step, octave)| Note::new(Pitch::new(step, octave), Duration::Eighth))
4005 .collect();
4006 voice.push(Note::rest(Duration::Half));
4007
4008 assert!(analyze_vertical_sonorities(&score).is_empty());
4009 let report = analyze_arpeggiated_bars_report(&score);
4010 assert!(report.timeline_issues.is_empty());
4011 assert_eq!(report.sonorities.len(), 1);
4012 let inferred = &report.sonorities[0];
4013 assert_eq!((inferred.measure, inferred.beat), (0, 0.0));
4014 assert_eq!(inferred.label.roman_numeral.as_deref(), Some("I"));
4015 assert_eq!(inferred.label.rule_id, "bar-arpeggio-template");
4016 assert_eq!(inferred.label.evidence.len(), 4);
4017
4018 score.parts[0].staves[0].measures[0].voices[0][3].pitches[0] = Pitch::new(Step::F, 4);
4021 assert!(
4022 analyze_arpeggiated_bars_report(&score)
4023 .sonorities
4024 .is_empty()
4025 );
4026 }
4027
4028 #[test]
4029 fn opt_in_forward_completion_requires_an_exact_tertian_window() {
4030 let mut score = Score::new("staggered chord", 120, 4, 4, 0, 1);
4031 let measure = &mut score.parts[0].staves[0].measures[0];
4032 measure.voices[0] = vec![
4033 Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
4034 Note::rest(Duration::Half),
4035 Note::rest(Duration::Quarter),
4036 ];
4037 let mut upper = Note::new(Pitch::new(Step::E, 4), Duration::Eighth);
4038 upper.pitches.push(Pitch::new(Step::G, 4));
4039 measure.voices[1] = vec![
4040 Note::rest(Duration::Eighth),
4041 upper,
4042 Note::rest(Duration::Half),
4043 Note::rest(Duration::Quarter),
4044 ];
4045
4046 let baseline = analyze_vertical_sonorities(&score);
4047 assert_eq!(baseline.len(), 1);
4048 assert_eq!(baseline[0].beat, 0.5);
4049 let inferred = analyze_arpeggiated_bars_report(&score);
4050 assert_eq!(inferred.sonorities.len(), 2);
4051 assert_eq!(inferred.sonorities[0].beat, 0.0);
4052 assert_eq!(
4053 inferred.sonorities[0].label.roman_numeral.as_deref(),
4054 Some("I")
4055 );
4056 assert_eq!(
4057 inferred.sonorities[0].label.rule_id,
4058 "forward-completed-sonority"
4059 );
4060 assert_eq!(inferred.sonorities[0].label.evidence.len(), 2);
4061
4062 let mut shifted = score.clone();
4063 let upper = shifted.parts[0].staves[0].measures[0].voices[1][1].clone();
4064 shifted.parts[0].staves[0].measures[0].voices[0] = vec![
4065 Note::rest(Duration::Eighth),
4066 Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
4067 Note::rest(Duration::Half),
4068 Note::rest(Duration::Eighth),
4069 ];
4070 shifted.parts[0].staves[0].measures[0].voices[1] = vec![
4071 Note::rest(Duration::Quarter),
4072 upper,
4073 Note::rest(Duration::Half),
4074 Note::rest(Duration::Eighth),
4075 ];
4076 let offbeat = analyze_arpeggiated_bars_report(&shifted);
4077 assert_eq!(offbeat.sonorities[0].beat, 0.5);
4078 assert_eq!(
4079 offbeat.sonorities[0].label.rule_id,
4080 "forward-completed-sonority"
4081 );
4082
4083 score.parts[0].staves[0].measures[0].voices[0] = vec![
4085 Note::new(Pitch::with_alter(Step::F, 4, 1), Duration::Eighth),
4086 Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
4087 Note::rest(Duration::Half),
4088 Note::rest(Duration::Quarter),
4089 ];
4090 let changed = analyze_arpeggiated_bars_report(&score);
4091 assert!(
4092 changed
4093 .sonorities
4094 .iter()
4095 .all(|item| item.label.rule_id != "forward-completed-sonority")
4096 );
4097 }
4098
4099 #[test]
4100 fn opt_in_vertical_cadence_uses_bass_and_outer_voice_resolution() {
4101 let mut score = Score::new("dominant tonic", 120, 4, 4, 0, 2);
4102 for (measure_index, bass_step, bass_octave, upper_pitches) in [
4103 (
4104 0,
4105 Step::G,
4106 3,
4107 vec![
4108 Pitch::new(Step::B, 4),
4109 Pitch::new(Step::D, 5),
4110 Pitch::new(Step::G, 5),
4111 ],
4112 ),
4113 (
4114 1,
4115 Step::C,
4116 3,
4117 vec![
4118 Pitch::new(Step::E, 4),
4119 Pitch::new(Step::G, 4),
4120 Pitch::new(Step::C, 5),
4121 ],
4122 ),
4123 ] {
4124 let measure = &mut score.parts[0].staves[0].measures[measure_index];
4125 measure.voices[0] = vec![Note::new(
4126 Pitch::new(bass_step, bass_octave),
4127 Duration::Whole,
4128 )];
4129 let mut upper = Note::new(upper_pitches[0].clone(), Duration::Whole);
4130 upper.pitches.extend(upper_pitches.into_iter().skip(1));
4131 measure.voices[1] = vec![upper];
4132 }
4133
4134 let report = analyze_vertical_cadences_report(&score);
4135 assert!(report.timeline_issues.is_empty());
4136 assert_eq!(report.cadence_candidates.len(), 1);
4137 let candidate = &report.cadence_candidates[0];
4138 assert_eq!(candidate.kind, CadenceKind::Authentic);
4139 assert_eq!(candidate.from.measure, 0);
4140 assert_eq!(candidate.to.measure, 1);
4141 assert_eq!(candidate.rule_id, "vertical-dominant-tonic-outer-voices");
4142
4143 let upper = &mut score.parts[0].staves[0].measures[1].voices[1][0];
4144 upper.pitches.retain(|pitch| pitch.step != Step::C);
4145 assert!(
4146 analyze_vertical_cadences_report(&score)
4147 .cadence_candidates
4148 .is_empty()
4149 );
4150 }
4151
4152 #[test]
4153 fn local_key_windows_keep_physical_measure_addresses() {
4154 assert!(estimate_local_keys_with_aarden_essen_profile(&Score::default(), 4).is_empty());
4155 let mut score = Score::new("local keys", 120, 4, 4, 0, 3);
4156 for measure in &mut score.parts[0].staves[0].measures {
4157 let mut chord = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4158 chord.pitches.push(Pitch::new(Step::E, 4));
4159 chord.pitches.push(Pitch::new(Step::G, 4));
4160 measure.voices[0] = vec![chord];
4161 }
4162 let keys = estimate_local_keys_with_aarden_essen_profile(&score, 1);
4163 assert_eq!(keys.len(), 3);
4164 assert_eq!(keys[0].measure, 0);
4165 assert_eq!((keys[0].window_start, keys[0].window_end_exclusive), (0, 2));
4166 assert_eq!((keys[1].window_start, keys[1].window_end_exclusive), (0, 3));
4167 assert_eq!((keys[2].window_start, keys[2].window_end_exclusive), (1, 3));
4168 assert!(keys.iter().all(|key| key.profile_correlation.is_finite()));
4169 }
4170
4171 #[test]
4172 fn vertical_sonorities_include_held_notes_across_parts() {
4173 let mut score = Score::new("vertical", 120, 4, 4, 0, 1);
4174 let part = score.parts[0].clone();
4175 score.parts.push(part.clone());
4176 score.parts.push(part);
4177 score.parts[0].staves[0].measures[0].voices[0] =
4178 vec![Note::new(Pitch::new(Step::C, 3), Duration::Whole)];
4179 score.parts[1].staves[0].measures[0].voices[0] = vec![
4180 Note::new(Pitch::new(Step::E, 4), Duration::Half),
4181 Note::new(Pitch::new(Step::F, 4), Duration::Half),
4182 ];
4183 score.parts[2].staves[0].measures[0].voices[0] =
4184 vec![Note::new(Pitch::new(Step::G, 4), Duration::Whole)];
4185
4186 let sonorities = analyze_vertical_sonorities(&score);
4187 assert_eq!(sonorities.len(), 2);
4188 assert_eq!((sonorities[0].measure, sonorities[0].beat), (0, 0.0));
4189 assert_eq!((sonorities[1].measure, sonorities[1].beat), (0, 2.0));
4190 assert_eq!(sonorities[0].label.chord.root, "C");
4191 assert_eq!(sonorities[0].label.chord.kind, "major");
4192 assert_eq!(sonorities[1].label.chord.kind, "suspended-fourth");
4193 assert!(sonorities.iter().all(|item| item.label.evidence.len() == 3));
4194 }
4195
4196 #[test]
4197 fn vertical_sonorities_preserve_authored_chord_at_its_beat() {
4198 let mut score = Score::new("authored", 120, 4, 4, 0, 1);
4199 let part = score.parts[0].clone();
4200 score.parts.push(part.clone());
4201 score.parts.push(part);
4202 let mut bass = Note::new(Pitch::new(Step::C, 3), Duration::Quarter);
4203 bass.chord_symbol = Some(ChordSymbol {
4204 root: "D".to_string(),
4205 kind: "minor".to_string(),
4206 bass: None,
4207 placement: None,
4208 extender: false,
4209 harmonic_degree: None,
4210 harmony_function: None,
4211 harmony_type: None,
4212 chord_ref: None,
4213 range_end: None,
4214 degrees: Vec::new(),
4215 });
4216 score.parts[0].staves[0].measures[0].voices[0] = vec![bass];
4217 score.parts[1].staves[0].measures[0].voices[0] =
4218 vec![Note::new(Pitch::new(Step::E, 4), Duration::Quarter)];
4219 score.parts[2].staves[0].measures[0].voices[0] =
4220 vec![Note::new(Pitch::new(Step::G, 4), Duration::Quarter)];
4221
4222 let sonorities = analyze_vertical_sonorities(&score);
4223 assert_eq!(sonorities.len(), 1);
4224 assert_eq!(sonorities[0].label.rule_id, "authored-chord-symbol");
4225 assert_eq!(sonorities[0].label.chord.root, "D");
4226 assert_eq!(sonorities[0].label.confidence, 100);
4227
4228 score.parts[0].staves[0].transpose_semitones = -2;
4229 let key = KeySignature {
4230 fifths: 0,
4231 mode: "major".to_string(),
4232 };
4233 let transposing = analyze_vertical_sonorities_in_key(&score, &key);
4234 assert_eq!(transposing[0].label.chord.root, "D");
4235 assert_eq!(transposing[0].label.roman_numeral, None);
4236 }
4237
4238 #[test]
4239 fn vertical_sonorities_can_use_an_independently_estimated_minor_key() {
4240 let mut score = Score::new("minor", 120, 4, 4, -3, 1);
4241 let part = score.parts[0].clone();
4242 score.parts.push(part.clone());
4243 score.parts.push(part);
4244 for (part_index, (step, alter)) in [(Step::C, 0), (Step::E, -1), (Step::G, 0)]
4245 .into_iter()
4246 .enumerate()
4247 {
4248 score.parts[part_index].staves[0].measures[0].voices[0] = vec![Note::new(
4249 Pitch::with_alter(step, 4, alter),
4250 Duration::Quarter,
4251 )];
4252 }
4253 let c_minor = KeySignature {
4254 fifths: -3,
4255 mode: "minor".to_string(),
4256 };
4257 assert_eq!(
4258 analyze_vertical_sonorities(&score)[0]
4259 .label
4260 .roman_numeral
4261 .as_deref(),
4262 Some("vi")
4263 );
4264 assert_eq!(
4265 analyze_vertical_sonorities_in_key(&score, &c_minor)[0]
4266 .label
4267 .roman_numeral
4268 .as_deref(),
4269 Some("i")
4270 );
4271 }
4272
4273 #[test]
4274 fn vertical_sonorities_report_unequal_part_bar_lengths() {
4275 let mut score = Score::new("unequal", 120, 4, 4, 0, 2);
4276 let part = score.parts[0].clone();
4277 score.parts.push(part.clone());
4278 score.parts.push(part);
4279 for (part_index, step) in [Step::C, Step::E, Step::G].into_iter().enumerate() {
4280 score.parts[part_index].staves[0].measures[0].voices[0] =
4281 vec![Note::new(Pitch::new(step, 4), Duration::Quarter)];
4282 }
4283 score.parts[2].staves[0].measures[0].actual_length = Some(MeasureLength {
4284 numerator: 3,
4285 denominator: 4,
4286 });
4287 let report = analyze_vertical_sonorities_report(&score);
4288 assert!(report.sonorities.iter().all(|item| item.measure != 0));
4289 assert_eq!(report.timeline_issues[0].part, 2);
4290 assert_eq!(
4291 report.timeline_issues[0].kind,
4292 VerticalTimelineIssueKind::DifferentLength
4293 );
4294 assert!(report.timeline_issues.iter().any(|issue| {
4295 issue.measure == 1 && issue.kind == VerticalTimelineIssueKind::DifferentStart
4296 }));
4297 assert_eq!(analyze_vertical_sonorities(&score), report.sonorities);
4298 }
4299
4300 #[test]
4301 fn vertical_harmony_aligns_two_short_bars_with_one_long_bar() {
4302 let mut score = Score::new("mixed bar partitions", 120, 4, 4, 0, 2);
4303 let part = score.parts[0].clone();
4304 score.parts.push(part.clone());
4305 score.parts.push(part);
4306
4307 let mut first_c = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4308 first_c.tie_start = true;
4309 let mut second_c = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4310 second_c.tie_end = true;
4311 score.parts[0].staves[0].measures[0].voices[0] = vec![first_c];
4312 score.parts[0].staves[0].measures[1].voices[0] = vec![second_c];
4313
4314 score.parts[1].staves[0].measures.truncate(1);
4315 score.parts[1].staves[0].measures[0].actual_length = Some(MeasureLength {
4316 numerator: 2,
4317 denominator: 1,
4318 });
4319 score.parts[1].staves[0].measures[0].voices[0] =
4320 vec![Note::new(Pitch::new(Step::E, 4), Duration::Breve)];
4321
4322 for measure in &mut score.parts[2].staves[0].measures {
4323 measure.voices[0] = vec![Note::new(Pitch::new(Step::G, 4), Duration::Whole)];
4324 }
4325
4326 let key = KeySignature {
4327 fifths: 0,
4328 mode: "major".to_string(),
4329 };
4330 let report = analyze_vertical_sonorities_in_key_report(&score, &key);
4331 assert!(report.timeline_issues.is_empty());
4332 assert_eq!(report.sonorities.len(), 2);
4333 assert_eq!(
4334 (report.sonorities[1].measure, report.sonorities[1].beat),
4335 (1, 0.0)
4336 );
4337 assert_eq!(report.sonorities[1].label.chord.root, "C");
4338 assert_eq!(
4339 report.sonorities[1].label.roman_numeral.as_deref(),
4340 Some("I")
4341 );
4342 assert!(
4343 report.sonorities[1]
4344 .label
4345 .evidence
4346 .iter()
4347 .any(|address| address.part == 1 && address.measure == 0)
4348 );
4349
4350 let without_key = analyze_vertical_sonorities_report(&score);
4351 assert_eq!(without_key.sonorities.len(), 2);
4352 assert_eq!(
4353 without_key.sonorities[1].label.roman_numeral.as_deref(),
4354 Some("I")
4355 );
4356
4357 let mut transposing = score.clone();
4358 transposing.parts[2].staves[0].transpose_semitones = -2;
4359 for measure in &mut transposing.parts[2].staves[0].measures {
4360 measure.key_sig = Some(KeySignature {
4361 fifths: 2,
4362 mode: "major".to_string(),
4363 });
4364 measure.voices[0][0].pitches[0] = Pitch::new(Step::A, 4);
4365 }
4366 let converted = analyze_vertical_sonorities_report(&transposing);
4367 assert_eq!(converted.sonorities.len(), 2);
4368 assert_eq!(
4369 converted.sonorities[1].label.roman_numeral.as_deref(),
4370 Some("I")
4371 );
4372
4373 score.parts[2].staves[0].measures[1].key_sig = Some(KeySignature {
4374 fifths: 1,
4375 mode: "major".to_string(),
4376 });
4377 let conflicting = analyze_vertical_sonorities_report(&score);
4378 assert_eq!(conflicting.sonorities.len(), 2);
4379 assert_eq!(
4380 conflicting.sonorities[0].label.roman_numeral.as_deref(),
4381 Some("I")
4382 );
4383 assert_eq!(conflicting.sonorities[1].label.roman_numeral, None);
4384 }
4385
4386 #[test]
4387 fn vertical_harmony_uses_long_reference_bar_coordinates() {
4388 let mut score = Score::new("long reference bar", 120, 4, 4, 0, 2);
4389 let part = score.parts[0].clone();
4390 score.parts.push(part.clone());
4391 score.parts.push(part);
4392 score.parts[0].staves[0].measures.truncate(1);
4393 score.parts[0].staves[0].measures[0].actual_length = Some(MeasureLength {
4394 numerator: 2,
4395 denominator: 1,
4396 });
4397 score.parts[0].staves[0].measures[0].voices[0] =
4398 vec![Note::new(Pitch::new(Step::C, 4), Duration::Breve)];
4399 for measure_index in 0..2 {
4400 score.parts[1].staves[0].measures[measure_index].voices[0] =
4401 vec![Note::new(Pitch::new(Step::E, 4), Duration::Whole)];
4402 score.parts[2].staves[0].measures[measure_index].voices[0] =
4403 vec![Note::new(Pitch::new(Step::G, 4), Duration::Whole)];
4404 }
4405
4406 let report = analyze_vertical_sonorities_report(&score);
4407 assert!(report.timeline_issues.is_empty());
4408 assert_eq!(report.sonorities.len(), 2);
4409 assert_eq!(
4410 (report.sonorities[1].measure, report.sonorities[1].beat),
4411 (0, 4.0)
4412 );
4413 assert_eq!(report.sonorities[1].label.chord.root, "C");
4414 assert_eq!(report.sonorities[1].label.address.measure, 1);
4415 }
4416
4417 #[test]
4418 fn vertical_sonorities_do_not_relabel_a_fully_tied_barline() {
4419 let mut score = Score::new("ties", 120, 4, 4, 0, 2);
4420 let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4421 first
4422 .pitches
4423 .extend([Pitch::new(Step::E, 4), Pitch::new(Step::G, 4)]);
4424 first.set_pitch_ties(&[true, true, true], &[false, false, false]);
4425 let mut second = first.clone();
4426 second.set_pitch_ties(&[false, false, false], &[true, true, true]);
4427 score.parts[0].staves[0].measures[0].voices[0] = vec![first];
4428 score.parts[0].staves[0].measures[1].voices[0] = vec![second];
4429 let report = analyze_vertical_sonorities_report(&score);
4430 assert!(report.timeline_issues.is_empty());
4431 assert_eq!(report.sonorities.len(), 1);
4432 assert_eq!(report.sonorities[0].measure, 0);
4433 let opt_in = analyze_arpeggiated_bars_report(&score);
4434 assert_eq!(opt_in.sonorities.len(), 1);
4435 assert_eq!(opt_in.sonorities[0].measure, 0);
4436 }
4437
4438 #[test]
4439 fn vertical_harmony_recognizes_tie_across_voices() {
4440 let mut score = Score::new("voice crossing tie", 120, 4, 4, 0, 2);
4441 let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4442 first
4443 .pitches
4444 .extend([Pitch::new(Step::E, 4), Pitch::new(Step::G, 4)]);
4445 first.set_pitch_ties(&[true, true, true], &[false, false, false]);
4446 let mut second = first.clone();
4447 second.set_pitch_ties(&[false, false, false], &[true, true, true]);
4448 score.parts[0].staves[0].measures[0].voices[0] = vec![first];
4449 score.parts[0].staves[0].measures[1].voices[1] = vec![second];
4450
4451 let report = analyze_vertical_sonorities_report(&score);
4452 assert!(report.timeline_issues.is_empty());
4453 assert_eq!(report.sonorities.len(), 1);
4454 assert_eq!(report.sonorities[0].measure, 0);
4455 }
4456
4457 #[test]
4458 fn cross_staff_tie_uses_explicit_source_location() {
4459 let mut score = Score::new("staff crossing tie", 120, 4, 4, 0, 2);
4460 let other_staff = score.parts[0].staves[0].clone();
4461 score.parts[0].staves.push(other_staff);
4462 let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4463 first
4464 .pitches
4465 .extend([Pitch::new(Step::E, 4), Pitch::new(Step::G, 4)]);
4466 first.set_pitch_ties(&[true, true, true], &[false, false, false]);
4467 let mut second = first.clone();
4468 second.set_pitch_ties(&[false, false, false], &[true, true, true]);
4469 let source = acorde_core::TieSourceLocation {
4470 measures: -1,
4471 staves: -1,
4472 fractions: Some("3/4".to_string()),
4473 ..Default::default()
4474 };
4475 second.pitch_tie_sources = vec![Some(source); 3];
4476 score.parts[0].staves[0].measures[0].voices[0] = vec![
4477 Note::rest(Duration::Quarter),
4478 Note::rest(Duration::Quarter),
4479 Note::rest(Duration::Quarter),
4480 first,
4481 ];
4482 score.parts[0].staves[1].measures[1].voices[0] = vec![second];
4483
4484 let report = analyze_vertical_sonorities_report(&score);
4485 assert!(report.timeline_issues.is_empty());
4486 assert_eq!(report.sonorities.len(), 1);
4487 assert_eq!(report.sonorities[0].beat, 3.0);
4488
4489 for source in &mut score.parts[0].staves[1].measures[1].voices[0][0].pitch_tie_sources {
4490 source.as_mut().expect("source exists").staves = 0;
4491 }
4492 let invalid_source = analyze_vertical_sonorities_report(&score);
4493 assert_eq!(invalid_source.sonorities.len(), 2);
4494 assert_eq!(invalid_source.sonorities[1].measure, 1);
4495 }
4496
4497 #[test]
4498 fn partial_or_dangling_ties_keep_new_harmonic_attacks() {
4499 let mut score = Score::new("partial ties", 120, 4, 4, 0, 2);
4500 let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4501 first
4502 .pitches
4503 .extend([Pitch::new(Step::E, 4), Pitch::new(Step::G, 4)]);
4504 first.set_pitch_ties(&[true, false, false], &[false, false, false]);
4505 let mut second = first.clone();
4506 second.set_pitch_ties(&[false, false, false], &[true, false, false]);
4507 score.parts[0].staves[0].measures[0].voices[0] = vec![first];
4508 score.parts[0].staves[0].measures[1].voices[0] = vec![second.clone()];
4509 let report = analyze_vertical_sonorities_report(&score);
4510 assert_eq!(report.sonorities.len(), 2);
4511 assert_eq!(report.sonorities[1].measure, 1);
4512
4513 score.parts[0].staves[0].measures[0].voices[0][0]
4514 .set_pitch_ties(&[false, false, false], &[false, false, false]);
4515 second.set_pitch_ties(&[false, false, false], &[true, true, true]);
4516 score.parts[0].staves[0].measures[1].voices[0] = vec![second];
4517 let report = analyze_vertical_sonorities_report(&score);
4518 assert_eq!(report.sonorities.len(), 2);
4519 }
4520
4521 #[test]
4522 fn tie_end_in_newly_used_voice_is_an_attack_without_a_predecessor() {
4523 let mut score = Score::new("new voice", 120, 4, 4, 0, 2);
4524 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4525 note.pitches
4526 .extend([Pitch::new(Step::E, 4), Pitch::new(Step::G, 4)]);
4527 note.set_pitch_ties(&[false, false, false], &[true, true, true]);
4528 score.parts[0].staves[0].measures[1].voices[1] = vec![note];
4529
4530 let report = analyze_vertical_sonorities_report(&score);
4531 assert!(report.timeline_issues.is_empty());
4532 assert_eq!(report.sonorities.len(), 1);
4533 assert_eq!(report.sonorities[0].measure, 1);
4534 assert_eq!(report.sonorities[0].label.address.voice, 1);
4535 }
4536
4537 #[test]
4538 fn instrument_change_breaks_a_written_pitch_tie_for_harmony() {
4539 let mut score = Score::new("changed sounding pitch", 120, 4, 4, 0, 2);
4540 let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4541 first
4542 .pitches
4543 .extend([Pitch::new(Step::E, 4), Pitch::new(Step::G, 4)]);
4544 first.set_pitch_ties(&[true, true, true], &[false, false, false]);
4545 let mut second = first.clone();
4546 second.set_pitch_ties(&[false, false, false], &[true, true, true]);
4547 score.parts[0].staves[0].measures[0].voices[0] = vec![first];
4548 score.parts[0].staves[0].measures[1].voices[0] = vec![second];
4549 let mut clarinet = acorde_core::InstrumentDefinition::new("clarinet", "Clarinet");
4550 clarinet.transpose_semitones = -2;
4551 score.parts[0].staves[0].measures[1].instrument_change = Some(clarinet);
4552
4553 let report = analyze_vertical_sonorities_report(&score);
4554 assert!(report.timeline_issues.is_empty());
4555 assert_eq!(report.sonorities.len(), 2);
4556 assert_eq!(report.sonorities[0].label.chord.root, "C");
4557 assert_eq!(report.sonorities[1].label.chord.root, "Bb");
4558 }
4559
4560 #[test]
4561 fn new_attack_combines_with_tied_pitches_across_parts() {
4562 let mut score = Score::new("held harmony", 120, 4, 4, 0, 2);
4563 let part = score.parts[0].clone();
4564 score.parts.push(part.clone());
4565 score.parts.push(part);
4566 for (part_index, step) in [Step::C, Step::E, Step::G].into_iter().enumerate() {
4567 let mut first = Note::new(Pitch::new(step, 4), Duration::Whole);
4568 let mut second = first.clone();
4569 if part_index < 2 {
4570 first.tie_start = true;
4571 second.tie_end = true;
4572 }
4573 score.parts[part_index].staves[0].measures[0].voices[0] = vec![first];
4574 score.parts[part_index].staves[0].measures[1].voices[0] = vec![second];
4575 }
4576 let report = analyze_vertical_sonorities_report(&score);
4577 assert!(report.timeline_issues.is_empty());
4578 let second = report
4579 .sonorities
4580 .iter()
4581 .find(|sonority| sonority.measure == 1)
4582 .expect("the new G attack triggers a sonority");
4583 assert_eq!(second.label.evidence.len(), 3);
4584 assert_eq!(second.label.address.part, 2);
4585 }
4586
4587 #[test]
4588 fn vertical_harmony_uses_concert_pitches_for_transposing_parts() {
4589 let mut score = Score::new("clarinet and piano", 120, 4, 4, 0, 1);
4590 let part = score.parts[0].clone();
4591 score.parts.push(part.clone());
4592 score.parts.push(part);
4593 score.parts[0].staves[0].transpose_semitones = -2;
4594 for (part_index, step) in [Step::D, Step::E, Step::G].into_iter().enumerate() {
4595 score.parts[part_index].staves[0].measures[0].voices[0] =
4596 vec![Note::new(Pitch::new(step, 4), Duration::Quarter)];
4597 }
4598 let key = KeySignature {
4599 fifths: 0,
4600 mode: "major".to_string(),
4601 };
4602 let in_key = analyze_vertical_sonorities_in_key_report(&score, &key);
4603 assert!(in_key.timeline_issues.is_empty());
4604 assert_eq!(in_key.sonorities.len(), 1);
4605 assert_eq!(in_key.sonorities[0].label.chord.root, "C");
4606 assert_eq!(
4607 in_key.sonorities[0].label.roman_numeral.as_deref(),
4608 Some("I")
4609 );
4610
4611 let without_concert_key = analyze_vertical_sonorities_report(&score);
4612 assert_eq!(without_concert_key.sonorities[0].label.chord.root, "C");
4613 assert_eq!(without_concert_key.sonorities[0].label.roman_numeral, None);
4614
4615 score.parts[0].staves[0].measures[0].key_sig = Some(KeySignature {
4616 fifths: 2,
4617 mode: "major".to_string(),
4618 });
4619 let reconciled = analyze_vertical_sonorities_report(&score);
4620 assert_eq!(
4621 reconciled.sonorities[0].label.roman_numeral.as_deref(),
4622 Some("I")
4623 );
4624
4625 let mut silent = score.parts[1].clone();
4626 silent.staves[0].measures[0].voices[0] = vec![Note::rest(Duration::Whole)];
4627 silent.staves[0].measures[0].key_sig = Some(KeySignature {
4628 fifths: 1,
4629 mode: "major".to_string(),
4630 });
4631 score.parts.push(silent);
4632 let silent_conflict = analyze_vertical_sonorities_report(&score);
4633 assert_eq!(
4634 silent_conflict.sonorities[0].label.roman_numeral.as_deref(),
4635 Some("I")
4636 );
4637
4638 score.parts[1].staves[0].measures[0].key_sig = Some(KeySignature {
4639 fifths: 1,
4640 mode: "major".to_string(),
4641 });
4642 let conflicting = analyze_vertical_sonorities_report(&score);
4643 assert_eq!(conflicting.sonorities[0].label.roman_numeral, None);
4644 }
4645
4646 #[test]
4647 fn vertical_harmony_uses_measure_instrument_change() {
4648 let mut score = Score::new("changing instrument", 120, 4, 4, 0, 2);
4649 let part = score.parts[0].clone();
4650 score.parts.push(part.clone());
4651 score.parts.push(part);
4652 let mut clarinet = acorde_core::InstrumentDefinition::new("clarinet", "Clarinet");
4653 clarinet.transpose_semitones = -2;
4654 score.parts[0].instrument = Some(clarinet);
4655 score.parts[0].staves[0].measures[1].instrument_change =
4656 Some(acorde_core::InstrumentDefinition::new("piano", "Piano"));
4657 for (part_index, (part, step)) in score
4658 .parts
4659 .iter_mut()
4660 .zip([Step::D, Step::E, Step::G])
4661 .enumerate()
4662 {
4663 for (measure_index, measure) in part.staves[0].measures.iter_mut().enumerate() {
4664 let written = if measure_index == 1 && part_index == 0 {
4665 Step::C
4666 } else {
4667 step.clone()
4668 };
4669 measure.voices[0] = vec![Note::new(Pitch::new(written, 4), Duration::Quarter)];
4670 }
4671 }
4672 let key = KeySignature {
4673 fifths: 0,
4674 mode: "major".to_string(),
4675 };
4676 let report = analyze_vertical_sonorities_in_key_report(&score, &key);
4677 assert!(report.timeline_issues.is_empty());
4678 assert_eq!(report.sonorities.len(), 2);
4679 assert!(
4680 report
4681 .sonorities
4682 .iter()
4683 .all(|item| item.label.chord.root == "C")
4684 );
4685
4686 score.parts[0].staves[0].measures[0].key_sig = Some(KeySignature {
4687 fifths: 2,
4688 mode: "major".to_string(),
4689 });
4690 let ambiguous = analyze_vertical_sonorities_report(&score);
4691 assert_eq!(
4692 ambiguous.sonorities[0].label.roman_numeral.as_deref(),
4693 Some("I")
4694 );
4695 assert_eq!(ambiguous.sonorities[1].label.roman_numeral, None);
4696
4697 score.parts[0].staves[0].measures[1].key_sig = Some(key);
4698 let reconciled = analyze_vertical_sonorities_report(&score);
4699 assert_eq!(
4700 reconciled.sonorities[1].label.roman_numeral.as_deref(),
4701 Some("I")
4702 );
4703 }
4704
4705 #[test]
4706 fn e_flat_instrument_keeps_concert_chord_spelling() {
4707 let mut score = Score::new("alto sax and piano", 120, 4, 4, 0, 1);
4708 let part = score.parts[0].clone();
4709 score.parts.push(part.clone());
4710 score.parts.push(part);
4711 score.parts[0].staves[0].transpose_semitones = -9;
4712 for (part_index, pitch) in [
4713 Pitch::new(Step::C, 5),
4714 Pitch::new(Step::G, 4),
4715 Pitch::with_alter(Step::B, 4, -1),
4716 ]
4717 .into_iter()
4718 .enumerate()
4719 {
4720 score.parts[part_index].staves[0].measures[0].voices[0] =
4721 vec![Note::new(pitch, Duration::Quarter)];
4722 }
4723 let report = analyze_vertical_sonorities_report(&score);
4724 assert!(report.timeline_issues.is_empty());
4725 assert_eq!(report.sonorities[0].label.chord.root, "Eb");
4726 }
4727
4728 #[test]
4729 fn unrepresentable_concert_pitch_omits_the_measure() {
4730 let mut score = Score::new("out of range", 120, 4, 4, 0, 1);
4731 score.parts[0].staves[0].transpose_semitones = 12;
4732 score.parts[0].staves[0].measures[0].voices[0] =
4733 vec![Note::new(Pitch::new(Step::B, 127), Duration::Quarter)];
4734
4735 let report = analyze_vertical_sonorities_report(&score);
4736 assert!(report.sonorities.is_empty());
4737 assert_eq!(
4738 report.timeline_issues[0].kind,
4739 VerticalTimelineIssueKind::UnrepresentableConcertPitch
4740 );
4741 }
4742
4743 #[test]
4744 fn vertical_sonorities_report_invalid_and_overfull_voice_timing() {
4745 let mut score = Score::default();
4746 score.parts[0].staves[0].measures[0].voices[0] = (0..5)
4747 .map(|_| Note::new(Pitch::new(Step::C, 4), Duration::Quarter))
4748 .collect();
4749 let report = analyze_vertical_sonorities_report(&score);
4750 assert!(report.sonorities.is_empty());
4751 assert_eq!(
4752 report.timeline_issues[0].kind,
4753 VerticalTimelineIssueKind::OverfullVoice
4754 );
4755
4756 let mut malformed = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4757 malformed.tuplet = Some(TupletInfo {
4758 actual_notes: 0,
4759 normal_notes: 2,
4760 });
4761 score.parts[0].staves[0].measures[0].voices[0] = vec![malformed];
4762 let report = analyze_vertical_sonorities_report(&score);
4763 assert!(report.sonorities.is_empty());
4764 assert_eq!(
4765 report.timeline_issues[0].kind,
4766 VerticalTimelineIssueKind::InvalidNoteDuration
4767 );
4768
4769 score.parts[0].staves[0].measures[0].voices[0].clear();
4770 score.parts[0].staves[0].measures[0].actual_length = Some(MeasureLength {
4771 numerator: 1,
4772 denominator: 0,
4773 });
4774 let report = analyze_vertical_sonorities_report(&score);
4775 assert_eq!(
4776 report.timeline_issues[0].kind,
4777 VerticalTimelineIssueKind::InvalidLength
4778 );
4779 }
4780
4781 #[test]
4782 fn authored_chord_symbol_wins_with_source_address_provenance() {
4783 use acorde_core::ChordSymbol;
4784
4785 let mut score = Score::default();
4786 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
4787 voice.clear();
4788 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4789 note.chord_symbol = Some(ChordSymbol {
4790 root: "F".to_owned(),
4791 kind: "dominant".to_owned(),
4792 bass: Some("A".to_owned()),
4793 placement: None,
4794 extender: false,
4795 harmonic_degree: Some("4".to_owned()),
4796 harmony_function: Some("D".to_owned()),
4797 harmony_type: None,
4798 chord_ref: None,
4799 range_end: None,
4800 degrees: Vec::new(),
4801 });
4802 voice.push(note);
4803
4804 let result = analyze_score(&score);
4805 assert_eq!(result.chords.len(), 1);
4806 assert_eq!(result.chords[0].rule_id, "authored-chord-symbol");
4807 assert_eq!(result.chords[0].address.note, 0);
4808 assert_eq!(
4809 result.chords[0].evidence,
4810 vec![result.chords[0].address.clone()]
4811 );
4812 assert_eq!(chord_name(&result.chords[0].chord), "F7/A");
4813 assert_eq!(result.chords[0].name, "F7/A");
4814 let finding = analysis_provenance(&result, &result.chords[0].address)
4815 .into_iter()
4816 .find(|finding| finding.category == AnalysisCategory::Chords)
4817 .expect("authored chord provenance");
4818 assert_eq!(finding.label.as_deref(), Some("F7/A"));
4819 }
4820
4821 #[test]
4822 fn chord_name_preserves_authored_degree_extensions() {
4823 use acorde_core::{ChordDegree, ChordSymbol};
4824
4825 let chord = ChordSymbol {
4826 root: "C".to_owned(),
4827 kind: "dominant".to_owned(),
4828 bass: None,
4829 placement: None,
4830 extender: false,
4831 harmonic_degree: None,
4832 harmony_function: None,
4833 harmony_type: None,
4834 chord_ref: None,
4835 range_end: None,
4836 degrees: vec![
4837 ChordDegree {
4838 value: 9,
4839 alter: 1,
4840 kind: "add".to_owned(),
4841 },
4842 ChordDegree {
4843 value: 5,
4844 alter: -1,
4845 kind: "alter".to_owned(),
4846 },
4847 ChordDegree {
4848 value: 3,
4849 alter: 0,
4850 kind: "subtract".to_owned(),
4851 },
4852 ],
4853 };
4854
4855 assert_eq!(chord_name(&chord), "C7add#9b5no3");
4856 }
4857
4858 #[test]
4859 fn provenance_returns_stable_explanations_for_a_note() {
4860 let mut score = Score::default();
4861 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
4862 voice.clear();
4863 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
4864 voice.push(Note::new(Pitch::new(Step::E, 4), Duration::Quarter));
4865 voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
4866 let address = NoteAddr {
4867 part: 0,
4868 staff: 0,
4869 measure: 0,
4870 voice: 0,
4871 note: 0,
4872 };
4873 let findings = analysis_provenance(&analyze_score(&score), &address);
4874 let chord = findings
4875 .iter()
4876 .find(|finding| finding.category == AnalysisCategory::Chords)
4877 .expect("chord provenance");
4878 assert_eq!(chord.rule_id, "pitch-class-template");
4879 assert_eq!(chord.label.as_deref(), Some("C"));
4880 assert_eq!(chord.evidence[0], address);
4881 assert!(findings.windows(2).all(|pair| {
4882 (analysis_category_rank(pair[0].category), &pair[0].rule_id)
4883 <= (analysis_category_rank(pair[1].category), &pair[1].rule_id)
4884 }));
4885 }
4886
4887 #[test]
4888 fn change_explanation_combines_diff_and_before_after_provenance() {
4889 let mut previous_score = Score::default();
4890 let voice = &mut previous_score.parts[0].staves[0].measures[0].voices[0];
4891 voice.clear();
4892 for step in [Step::C, Step::E, Step::G] {
4893 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
4894 }
4895 let previous = analyze_score(&previous_score);
4896 let mut current_score = previous_score.clone();
4897 current_score.parts[0].staves[0].measures[0].voices[0][1] =
4898 Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
4899 let current = analyze_score(¤t_score);
4900 let address = NoteAddr {
4901 part: 0,
4902 staff: 0,
4903 measure: 0,
4904 voice: 0,
4905 note: 1,
4906 };
4907
4908 let explanation = explain_analysis_change(&previous, ¤t, &address);
4909 assert!(!explanation.diff.is_empty());
4910 assert!(!explanation.previous.is_empty());
4911 assert!(!explanation.current.is_empty());
4912 }
4913
4914 #[test]
4915 fn analysis_has_stable_score_fingerprint() {
4916 let score = Score::default();
4917 let repeated = analyze_score(&score);
4918 assert_eq!(repeated.score_fingerprint, score_fingerprint(&score));
4919 assert_eq!(
4920 repeated.score_fingerprint,
4921 analyze_score(&score).score_fingerprint
4922 );
4923
4924 let mut changed = score.clone();
4925 changed.metadata.title = "Changed".to_string();
4926 assert_ne!(repeated.score_fingerprint, score_fingerprint(&changed));
4927 }
4928
4929 #[test]
4930 fn fingerprint_uses_fnv1a_byte_order() {
4931 assert_eq!(fnv1a64(b"hello"), 0xa430d84680aabd0b);
4932 }
4933
4934 #[test]
4935 fn cache_key_includes_schema_and_score_identity() {
4936 let result = analyze_score(&Score::default());
4937 assert!(result.cache_key().starts_with("analysis-v14-fnv1a64-"));
4938 assert_eq!(result.cache_key(), analysis_cache_key(&Score::default()));
4939 let mut changed = result.clone();
4940 changed.schema_version = ANALYSIS_SCHEMA_VERSION + 1;
4941 assert_ne!(result.cache_key(), changed.cache_key());
4942 }
4943
4944 #[test]
4945 fn analysis_cache_reuses_identical_score_and_misses_after_edit() {
4946 let score = Score::default();
4947 let mut cache = AnalysisCache::with_capacity(2).unwrap();
4948 let first = cache.analyze(&score);
4949 assert_eq!(cache.len(), 1);
4950 assert!(cache.get(&score).is_some());
4951 let second = cache.analyze(&score);
4952 assert_eq!(first, second);
4953
4954 let mut changed = score.clone();
4955 changed.metadata.title = "changed".to_owned();
4956 assert!(cache.get(&changed).is_none());
4957 let changed_result = cache.analyze(&changed);
4958 assert_ne!(first.score_fingerprint, changed_result.score_fingerprint);
4959 assert_eq!(cache.len(), 2);
4960 }
4961
4962 #[test]
4963 fn analysis_cache_eviction_is_bounded_and_deterministic() {
4964 let mut cache = AnalysisCache::with_capacity(1).unwrap();
4965 let first = Score::default();
4966 let mut second = first.clone();
4967 second.metadata.title = "second".to_owned();
4968 cache.analyze(&first);
4969 cache.analyze(&second);
4970 assert_eq!(cache.len(), 1);
4971 assert!(cache.get(&first).is_none());
4972 assert!(cache.get(&second).is_some());
4973 }
4974
4975 #[test]
4976 fn analysis_cache_batch_preserves_order_and_reuses_duplicate_scores() {
4977 let first = Score::default();
4978 let mut second = first.clone();
4979 second.metadata.title = "second".to_owned();
4980 let mut cache = AnalysisCache::with_capacity(2).unwrap();
4981 let results = cache.analyze_batch(&[second.clone(), first.clone(), second.clone()]);
4982
4983 assert_eq!(results.len(), 3);
4984 assert_eq!(results[0].score_fingerprint, results[2].score_fingerprint);
4985 assert_ne!(results[0].score_fingerprint, results[1].score_fingerprint);
4986 assert_eq!(cache.len(), 2);
4987 assert_eq!(cache.stats(), AnalysisCacheStats { hits: 1, misses: 2 });
4988 cache.reset_stats();
4989 assert_eq!(cache.stats(), AnalysisCacheStats::default());
4990 assert!(cache.get(&first).is_some());
4991 }
4992
4993 #[test]
4994 fn analysis_cache_invalidates_one_score_without_affecting_others() {
4995 let first = Score::default();
4996 let mut second = first.clone();
4997 second.metadata.title = "second".to_owned();
4998 let mut cache = AnalysisCache::with_capacity(2).unwrap();
4999 cache.analyze_batch(&[first.clone(), second.clone()]);
5000
5001 assert!(cache.invalidate(&first));
5002 assert!(!cache.invalidate(&first));
5003 assert!(cache.get(&first).is_none());
5004 assert!(cache.get(&second).is_some());
5005 assert_eq!(cache.len(), 1);
5006 }
5007
5008 #[test]
5009 fn analysis_cache_analyze_after_edit_reclaims_previous_snapshot() {
5010 let previous = Score::default();
5011 let mut current = previous.clone();
5012 current.metadata.title = "edited".to_owned();
5013 let mut cache = AnalysisCache::with_capacity(2).unwrap();
5014 cache.analyze(&previous);
5015
5016 let result = cache.analyze_after_edit(&previous, ¤t);
5017 assert!(result.matches_score(¤t));
5018 assert!(cache.get(&previous).is_none());
5019 assert!(cache.get(¤t).is_some());
5020 assert_eq!(cache.len(), 1);
5021 }
5022
5023 #[test]
5024 fn analysis_cache_analyze_after_noop_edit_reuses_existing_result() {
5025 let score = Score::default();
5026 let mut cache = AnalysisCache::with_capacity(1).unwrap();
5027 cache.analyze(&score);
5028 cache.reset_stats();
5029
5030 let result = cache.analyze_after_edit(&score, &score);
5031 assert!(result.matches_score(&score));
5032 assert_eq!(cache.stats(), AnalysisCacheStats { hits: 1, misses: 0 });
5033 assert_eq!(cache.len(), 1);
5034 }
5035
5036 #[test]
5037 fn analysis_cache_edit_with_diff_returns_result_and_changed_categories() {
5038 let previous = Score::default();
5039 let mut current = previous.clone();
5040 let voice = &mut current.parts[0].staves[0].measures[0].voices[0];
5041 voice.clear();
5042 for step in [Step::C, Step::E, Step::G] {
5043 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
5044 }
5045 let previous_result = analyze_score(&previous);
5046 let mut cache = AnalysisCache::with_capacity(2).unwrap();
5047 cache.analyze(&previous);
5048
5049 let edited = cache.analyze_after_edit_with_diff(&previous, &previous_result, ¤t);
5050 assert!(edited.analysis.matches_score(¤t));
5051 assert_eq!(edited.diff.changed_categories[0], AnalysisCategory::Chords);
5052 assert!(
5053 edited
5054 .diff
5055 .changed_categories
5056 .contains(&AnalysisCategory::Intervals)
5057 );
5058 assert!(cache.get(&previous).is_none());
5059 assert!(cache.get(¤t).is_some());
5060
5061 cache.reset_stats();
5062 let no_op = cache.analyze_after_edit_with_diff(¤t, &edited.analysis, ¤t);
5063 assert!(no_op.diff.is_empty());
5064 assert_eq!(cache.stats(), AnalysisCacheStats { hits: 1, misses: 0 });
5065 }
5066
5067 #[test]
5068 fn selected_analysis_reuses_unchanged_categories_and_rebinds_identity() {
5069 let previous = Score::default();
5070 let previous_result = analyze_score(&previous);
5071 let mut current = previous.clone();
5072 current.metadata.title = "edited".to_owned();
5073 let mut cache = AnalysisCache::with_capacity(2).unwrap();
5074 let result = cache.analyze_selected_after_edit(&previous, &previous_result, ¤t, &[]);
5075
5076 assert!(result.matches_score(¤t));
5077 assert_eq!(result.chords, previous_result.chords);
5078 assert_ne!(result.score_fingerprint, previous_result.score_fingerprint);
5079 assert_eq!(cache.stats(), AnalysisCacheStats { hits: 0, misses: 1 });
5080 }
5081
5082 #[test]
5083 fn hinted_analysis_uses_clean_hint_without_recomputing_categories() {
5084 let previous = Score::default();
5085 let previous_result = analyze_score(&previous);
5086 let mut current = previous.clone();
5087 current.metadata.title = "edited".to_owned();
5088 let hint = ChangeHint {
5089 scope: acorde_core::ChangeScope::Global,
5090 layout_dirty: false,
5091 playback_dirty: false,
5092 };
5093 let mut cache = AnalysisCache::with_capacity(2).unwrap();
5094 let edited =
5095 cache.analyze_after_edit_with_hint(&previous, &previous_result, ¤t, &hint);
5096
5097 assert!(edited.diff.is_empty());
5098 assert!(edited.analysis.matches_score(¤t));
5099 assert_eq!(edited.analysis.chords, previous_result.chords);
5100 }
5101
5102 #[test]
5103 fn analysis_cache_rejects_zero_capacity() {
5104 assert!(matches!(
5105 AnalysisCache::with_capacity(0),
5106 Err(AnalysisCacheError::ZeroCapacity)
5107 ));
5108 }
5109
5110 #[test]
5111 fn analysis_result_rejects_a_different_score() {
5112 let score = Score::default();
5113 let result = analyze_score(&score);
5114 assert!(result.matches_score(&score));
5115 let mut changed = score.clone();
5116 changed.metadata.title = "Changed".to_string();
5117 assert!(!result.matches_score(&changed));
5118 }
5119
5120 #[test]
5121 fn analysis_diff_is_empty_for_identical_results() {
5122 let result = analyze_score(&Score::default());
5123 let diff = diff_analysis(&result, &result);
5124 assert!(diff.is_empty());
5125 assert_eq!(diff.previous_score_fingerprint, result.score_fingerprint);
5126 assert_eq!(diff.current_score_fingerprint, result.score_fingerprint);
5127 }
5128
5129 #[test]
5130 fn analysis_diff_keeps_score_identity_separate_from_category_changes() {
5131 let mut first_score = Score::default();
5132 first_score.metadata.title = "first".to_owned();
5133 let mut second_score = first_score.clone();
5134 second_score.metadata.title = "second".to_owned();
5135 let diff = diff_analysis(&analyze_score(&first_score), &analyze_score(&second_score));
5136
5137 assert!(diff.is_empty());
5138 assert!(diff.changed_categories.is_empty());
5139 assert_ne!(
5140 diff.previous_score_fingerprint,
5141 diff.current_score_fingerprint
5142 );
5143 }
5144
5145 #[test]
5146 fn analysis_diff_reports_changed_categories_in_stable_order() {
5147 let previous = analyze_score(&Score::default());
5148 let mut changed_score = Score::default();
5149 let voice = &mut changed_score.parts[0].staves[0].measures[0].voices[0];
5150 voice.clear();
5151 for step in [Step::C, Step::E, Step::G] {
5152 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
5153 }
5154 let current = analyze_score(&changed_score);
5155 let diff = diff_analysis(&previous, ¤t);
5156
5157 assert!(!diff.is_empty());
5158 assert_eq!(diff.changed_categories[0], AnalysisCategory::Chords);
5159 assert!(
5160 diff.changed_categories
5161 .contains(&AnalysisCategory::Intervals)
5162 );
5163 }
5164
5165 #[test]
5166 fn affected_categories_are_conservative_and_stable() {
5167 let clean = ChangeHint {
5168 scope: acorde_core::ChangeScope::Global,
5169 layout_dirty: false,
5170 playback_dirty: false,
5171 };
5172 assert!(affected_categories_for_change_hint(&clean).is_empty());
5173
5174 let dirty = ChangeHint {
5175 scope: acorde_core::ChangeScope::Measures {
5176 part: 0,
5177 staff: 0,
5178 start: 1,
5179 end: 2,
5180 },
5181 layout_dirty: false,
5182 playback_dirty: true,
5183 };
5184 assert_eq!(
5185 affected_categories_for_change_hint(&dirty),
5186 vec![
5187 AnalysisCategory::Chords,
5188 AnalysisCategory::Intervals,
5189 AnalysisCategory::KeyEstimates,
5190 AnalysisCategory::CadenceCandidates,
5191 AnalysisCategory::VoiceLeading,
5192 AnalysisCategory::SatbDiagnostics,
5193 AnalysisCategory::Motifs,
5194 AnalysisCategory::PhraseBoundaries,
5195 ]
5196 );
5197 }
5198
5199 #[test]
5200 fn refresh_plan_separates_local_and_global_dependencies() {
5201 let hint = ChangeHint {
5202 scope: acorde_core::ChangeScope::Measures {
5203 part: 1,
5204 staff: 2,
5205 start: 3,
5206 end: 4,
5207 },
5208 layout_dirty: true,
5209 playback_dirty: true,
5210 };
5211 let plan = analysis_refresh_plan(&hint);
5212 assert_eq!(
5213 plan.region,
5214 Some(AnalysisRegion {
5215 part: 1,
5216 staff: 2,
5217 start_measure: 3,
5218 end_measure: 4,
5219 })
5220 );
5221 assert!(plan.local_categories.contains(&AnalysisCategory::Chords));
5222 assert!(
5223 plan.local_categories
5224 .contains(&AnalysisCategory::CadenceCandidates)
5225 );
5226 assert_eq!(
5227 plan.global_categories,
5228 vec![AnalysisCategory::KeyEstimates, AnalysisCategory::Motifs]
5229 );
5230 assert_eq!(plan.context_before, 1);
5231 assert_eq!(plan.context_after, 1);
5232 }
5233
5234 #[test]
5235 fn chord_region_keeps_outside_results_and_refreshes_inside() {
5236 let mut score = Score::default();
5237 for measure in score.parts[0].staves[0].measures.iter_mut().take(2) {
5238 let voice = &mut measure.voices[0];
5239 voice.clear();
5240 for step in [Step::C, Step::E, Step::G] {
5241 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
5242 }
5243 }
5244 let region = AnalysisRegion {
5245 part: 0,
5246 staff: 0,
5247 start_measure: 0,
5248 end_measure: 1,
5249 };
5250 let chords = analyze_chords_in_region(&score, Some(®ion));
5251 assert_eq!(chords.len(), 1);
5252 assert_eq!(chords[0].address.measure, 0);
5253 }
5254
5255 #[test]
5256 fn local_analysis_refresh_matches_full_pass_after_a_middle_measure_edit() {
5257 let mut score = Score::default();
5258 for measure in &mut score.parts[0].staves[0].measures {
5259 measure.voices[0] = [Step::C, Step::E, Step::G]
5260 .into_iter()
5261 .map(|step| Note::new(Pitch::new(step, 4), Duration::Quarter))
5262 .collect();
5263 measure.voices[1] = [Step::G, Step::A, Step::B]
5264 .into_iter()
5265 .map(|step| Note::new(Pitch::new(step, 3), Duration::Quarter))
5266 .collect();
5267 }
5268 let before = analyze_score(&score);
5269 score.parts[0].staves[0].measures[1].voices[0][1] =
5270 Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
5271 score.parts[0].staves[0].measures[1].voices[1][1] =
5272 Note::new(Pitch::new(Step::F, 3), Duration::Quarter);
5273 let region = AnalysisRegion {
5274 part: 0,
5275 staff: 0,
5276 start_measure: 1,
5277 end_measure: 2,
5278 };
5279 let refreshed = analyze_selected_categories_in_region(
5280 &score,
5281 &before,
5282 &[
5283 AnalysisCategory::Chords,
5284 AnalysisCategory::Intervals,
5285 AnalysisCategory::VoiceLeading,
5286 ],
5287 Some(®ion),
5288 );
5289 let full = analyze_score(&score);
5290 assert_eq!(refreshed.chords, full.chords);
5291 assert_eq!(refreshed.intervals, full.intervals);
5292 assert_eq!(refreshed.voice_leading, full.voice_leading);
5293 assert_eq!(refreshed.key_estimates, before.key_estimates);
5294 }
5295
5296 #[test]
5297 fn interval_region_includes_boundary_observations() {
5298 let mut score = Score::default();
5299 for measure in score.parts[0].staves[0].measures.iter_mut().take(2) {
5300 let voice = &mut measure.voices[0];
5301 voice.clear();
5302 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
5303 voice.push(Note::new(Pitch::new(Step::D, 4), Duration::Quarter));
5304 }
5305 let region = AnalysisRegion {
5306 part: 0,
5307 staff: 0,
5308 start_measure: 0,
5309 end_measure: 1,
5310 };
5311 let intervals = analyze_intervals_in_region(&score, Some(®ion));
5312 assert_eq!(intervals.len(), 1);
5313 assert_eq!(intervals[0].from.measure, 0);
5314 }
5315
5316 #[test]
5317 fn voice_leading_region_limits_measure_traversal() {
5318 let mut score = Score::default();
5319 for measure in score.parts[0].staves[0].measures.iter_mut().take(2) {
5320 measure.voices[0].clear();
5321 measure.voices[1].clear();
5322 measure.voices[0].push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
5323 measure.voices[1].push(Note::new(Pitch::new(Step::G, 3), Duration::Quarter));
5324 measure.voices[0].push(Note::new(Pitch::new(Step::D, 4), Duration::Quarter));
5325 measure.voices[1].push(Note::new(Pitch::new(Step::A, 3), Duration::Quarter));
5326 }
5327 let region = AnalysisRegion {
5328 part: 0,
5329 staff: 0,
5330 start_measure: 1,
5331 end_measure: 2,
5332 };
5333 let observations = analyze_voice_leading_in_region(&score, Some(®ion));
5334 assert_eq!(observations.len(), 1);
5335 assert_eq!(observations[0].upper.measure, 1);
5336 }
5337
5338 #[test]
5339 fn interval_observation_preserves_direction_and_evidence() {
5340 let mut score = Score::default();
5341 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5342 voice.clear();
5343 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
5344 voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
5345 let intervals = analyze_intervals(&score);
5346 assert_eq!(intervals.len(), 1);
5347 assert_eq!(intervals[0].semitones, 7);
5348 assert_eq!(intervals[0].diatonic_steps, 4);
5349 assert_eq!(intervals[0].evidence.len(), 2);
5350 }
5351
5352 #[test]
5353 fn interval_analysis_treats_rests_as_melodic_boundaries() {
5354 let mut score = Score::default();
5355 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5356 voice.clear();
5357 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
5358 voice.push(Note::rest(Duration::Quarter));
5359 voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
5360
5361 assert!(analyze_intervals(&score).is_empty());
5362 }
5363
5364 #[test]
5365 fn interval_observation_preserves_exact_microtonal_distance() {
5366 let mut score = Score::default();
5367 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5368 voice.clear();
5369 voice.push(Note::new(
5370 Pitch::try_with_microtone(Step::C, 4, 0, 25).expect("valid microtone"),
5371 Duration::Quarter,
5372 ));
5373 voice.push(Note::new(Pitch::new(Step::D, 4), Duration::Quarter));
5374
5375 let intervals = analyze_intervals(&score);
5376 assert_eq!(intervals.len(), 1);
5377 assert_eq!(intervals[0].semitones, 2);
5378 assert_eq!(intervals[0].cents, 175);
5379 }
5380
5381 #[test]
5382 fn key_estimates_report_duration_weighted_coverage() {
5383 let mut score = Score::default();
5384 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5385 voice.clear();
5386 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Whole));
5387 voice.push(Note::new(Pitch::new(Step::D, 4), Duration::Eighth));
5388
5389 let estimates = estimate_keys(&score);
5390 assert!(!estimates.is_empty());
5391 assert!(estimates.iter().all(|estimate| {
5392 (estimate.total_duration_beats - 4.5).abs() < f64::EPSILON
5393 && estimate.weighted_covered_beats <= estimate.total_duration_beats
5394 && estimate.rule_id == "duration-weighted-diatonic-pitch-coverage"
5395 }));
5396 }
5397
5398 #[test]
5399 fn does_not_invent_label_for_unknown_pitch_set() {
5400 let mut score = Score::default();
5401 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5402 voice.clear();
5403 for step in [Step::C, Step::C] {
5404 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
5405 }
5406 assert!(analyze_chords(&score).chords.is_empty());
5407 }
5408
5409 #[test]
5410 fn preserves_relative_major_minor_key_ambiguity() {
5411 let mut score = Score::default();
5412 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5413 voice.clear();
5414 for step in [
5415 Step::C,
5416 Step::D,
5417 Step::E,
5418 Step::F,
5419 Step::G,
5420 Step::A,
5421 Step::B,
5422 ] {
5423 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
5424 }
5425 let estimates = estimate_keys(&score);
5426 assert!(
5427 estimates
5428 .iter()
5429 .any(|estimate| estimate.key.display_name() == "C major")
5430 );
5431 assert!(
5432 estimates
5433 .iter()
5434 .any(|estimate| estimate.key.display_name() == "A minor")
5435 );
5436 assert!(estimates.iter().all(|estimate| estimate.confidence == 100));
5437 }
5438
5439 #[test]
5440 fn tonal_profile_ranks_minor_center_without_discarding_legacy_candidates() {
5441 let mut score = Score::default();
5442 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5443 voice.clear();
5444 for (step, duration) in [
5445 (Step::A, Duration::Whole),
5446 (Step::C, Duration::Quarter),
5447 (Step::E, Duration::Quarter),
5448 (Step::A, Duration::Whole),
5449 ] {
5450 voice.push(Note::new(Pitch::new(step, 4), duration));
5451 }
5452
5453 let estimates = estimate_keys(&score);
5454 let original_keys: Vec<_> = estimates
5455 .iter()
5456 .map(|estimate| estimate.key.display_name())
5457 .collect();
5458 assert!(original_keys.contains(&"C major".to_string()));
5459 assert!(original_keys.contains(&"A minor".to_string()));
5460 let ranked = estimate_keys_with_tonal_profile(&score);
5461 assert_eq!(ranked[0].key.display_name(), "A minor");
5462 assert_eq!(ranked.len(), original_keys.len());
5463 assert!(
5464 ranked
5465 .iter()
5466 .all(|estimate| estimate.rule_id.ends_with("krumhansl-profile-ranking"))
5467 );
5468 }
5469
5470 #[test]
5471 fn tonal_profile_does_not_round_microtones_into_key_evidence() {
5472 let mut score = Score::default();
5473 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5474 voice.clear();
5475 voice.push(Note::new(Pitch::new(Step::A, 4), Duration::Quarter));
5476 voice.push(Note::new(
5477 Pitch::with_microtone(Step::C, 5, 0, 25),
5478 Duration::Quarter,
5479 ));
5480 let original = estimate_keys(&score);
5481 assert_eq!(estimate_keys_with_tonal_profile(&score), original);
5482 }
5483
5484 #[test]
5485 fn aarden_essen_profile_keeps_candidates_discarded_by_coverage() {
5486 let mut score = Score::default();
5487 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5488 voice.clear();
5489 for step in [
5490 Step::A,
5491 Step::B,
5492 Step::C,
5493 Step::D,
5494 Step::E,
5495 Step::F,
5496 Step::G,
5497 ] {
5498 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
5499 }
5500 let coverage = estimate_keys(&score);
5501 let full = estimate_keys_with_aarden_essen_profile(&score);
5502 assert!(coverage.len() < full.len());
5503 assert_eq!(full.len(), 30);
5504 assert!(
5505 full.iter()
5506 .any(|estimate| estimate.key.display_name() == "A major")
5507 );
5508 assert!(full.iter().all(|estimate| {
5509 estimate.rule_id == "duration-weighted-aarden-essen-profile-ranking"
5510 }));
5511 }
5512
5513 #[test]
5514 fn aarden_essen_profile_falls_back_on_microtones() {
5515 let mut score = Score::default();
5516 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5517 voice.clear();
5518 voice.push(Note::new(Pitch::new(Step::A, 4), Duration::Quarter));
5519 voice.push(Note::new(
5520 Pitch::with_microtone(Step::C, 5, 0, 25),
5521 Duration::Quarter,
5522 ));
5523 assert_eq!(
5524 estimate_keys_with_aarden_essen_profile(&score),
5525 estimate_keys(&score)
5526 );
5527 }
5528
5529 #[test]
5530 fn returns_no_key_for_empty_score() {
5531 assert!(estimate_keys(&Score::default()).is_empty());
5532 }
5533
5534 #[test]
5535 fn batch_and_stream_preserve_score_order() {
5536 let scores = vec![Score::default(), Score::default()];
5537 let batch = analyze_batch(&scores);
5538 let streamed: Vec<_> = analyze_stream(scores.clone()).collect();
5539 assert_eq!(batch, streamed);
5540 assert_eq!(batch.len(), scores.len());
5541 }
5542
5543 #[test]
5544 fn detects_authentic_cadence_from_adjacent_measures() {
5545 let mut score = Score::default();
5546 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5547 voice.clear();
5548 for (step, octave) in [(Step::G, 3), (Step::B, 3), (Step::D, 4)] {
5549 voice.push(Note::new(Pitch::new(step, octave), Duration::Quarter));
5550 }
5551 let voice = &mut score.parts[0].staves[0].measures[1].voices[0];
5552 voice.clear();
5553 for step in [Step::C, Step::E, Step::G] {
5554 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
5555 }
5556 let result = analyze_score(&score);
5557 assert_eq!(result.cadence_candidates.len(), 1);
5558 assert_eq!(result.cadence_candidates[0].kind, CadenceKind::Authentic);
5559 assert_eq!(result.cadence_candidates[0].evidence.len(), 2);
5560 }
5561
5562 #[test]
5563 fn flags_parallel_octaves_between_aligned_voices() {
5564 let mut score = Score::default();
5565 let measure = &mut score.parts[0].staves[0].measures[0];
5566 measure.voices[0].clear();
5567 measure.voices[1].clear();
5568 for (upper, lower) in [(Step::C, Step::C), (Step::D, Step::D)] {
5569 measure.voices[0].push(Note::new(Pitch::new(upper, 4), Duration::Quarter));
5570 measure.voices[1].push(Note::new(Pitch::new(lower, 3), Duration::Quarter));
5571 }
5572 let observations = analyze_voice_leading(&score);
5573 assert_eq!(observations.len(), 1);
5574 assert!(observations[0].parallel_perfect);
5575 assert_eq!(observations[0].upper_motion_cents, 200);
5576 assert_eq!(observations[0].lower_motion_cents, 200);
5577 assert_eq!(observations[0].evidence.len(), 2);
5578 let diagnostics = analyze_satb(&score);
5579 assert_eq!(diagnostics.len(), 1);
5580 assert_eq!(diagnostics[0].kind, SatbDiagnosticKind::ParallelPerfect);
5581 assert_eq!(diagnostics[0].severity, SatbSeverity::Warning);
5582 }
5583
5584 #[test]
5585 fn located_quartets_include_cross_bar_nonadjacent_voices() {
5586 let mut score = Score::default();
5587 for (measure_index, pitches) in [
5588 [(Step::G, 4), (Step::E, 4), (Step::C, 4)],
5589 [(Step::A, 4), (Step::F, 4), (Step::D, 4)],
5590 ]
5591 .into_iter()
5592 .enumerate()
5593 {
5594 let measure = &mut score.parts[0].staves[0].measures[measure_index];
5595 for (voice_index, (step, octave)) in pitches.into_iter().enumerate() {
5596 measure.voices[voice_index] =
5597 vec![Note::new(Pitch::new(step, octave), Duration::Whole)];
5598 }
5599 }
5600 let quartets = analyze_voice_leading_quartets(&score);
5601 assert_eq!(quartets.len(), 3);
5602 let outer = quartets
5603 .iter()
5604 .find(|quartet| quartet.second_before.address.voice == 2)
5605 .unwrap();
5606 assert_eq!(outer.first_before.address.measure, 0);
5607 assert_eq!(outer.first_after.address.measure, 1);
5608 assert_eq!(outer.first_after.onset_beats, 4.0);
5609 assert_eq!(outer.second_before.duration_beats, 4.0);
5610 assert!(outer.similar_motion);
5611 assert!(outer.parallel_fifth);
5612 assert_eq!(outer.evidence.len(), 4);
5613 }
5614
5615 #[test]
5616 fn located_quartets_separate_perfect_spelling_and_motion() {
5617 let mut score = Score::default();
5618 let measure = &mut score.parts[0].staves[0].measures[0];
5619 measure.voices[0] = vec![
5620 Note::new(Pitch::new(Step::G, 4), Duration::Quarter),
5621 Note::new(Pitch::new(Step::A, 4), Duration::Quarter),
5622 ];
5623 measure.voices[1] = vec![
5624 Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
5625 Note::new(Pitch::new(Step::D, 3), Duration::Quarter),
5626 ];
5627 let quartet = &analyze_voice_leading_quartets(&score)[0];
5628 assert!(quartet.contrary_motion);
5629 assert!(!quartet.similar_motion);
5630 assert!(quartet.parallel_fifth);
5631 assert!(!quartet.parallel_octave);
5632
5633 score.parts[0].staves[0].measures[0].voices[1][0].pitches[0] =
5634 Pitch::with_alter(Step::C, 4, 1);
5635 assert!(!analyze_voice_leading_quartets(&score)[0].parallel_fifth);
5636 }
5637
5638 #[test]
5639 fn located_quartets_align_distinct_parts_and_reject_overfull_bars() {
5640 let mut score = Score::default();
5641 score.parts.push(score.parts[0].clone());
5642 score.parts[0].staves[0].measures[0].voices[0] = vec![
5643 Note::new(Pitch::new(Step::C, 5), Duration::Half),
5644 Note::new(Pitch::new(Step::D, 5), Duration::Half),
5645 ];
5646 score.parts[1].staves[0].measures[0].voices[0] = vec![
5647 Note::new(Pitch::new(Step::C, 4), Duration::Half),
5648 Note::new(Pitch::new(Step::D, 4), Duration::Half),
5649 ];
5650 let quartets = analyze_voice_leading_quartets(&score);
5651 assert_eq!(quartets.len(), 1);
5652 assert_eq!(quartets[0].first_before.address.part, 0);
5653 assert_eq!(quartets[0].second_before.address.part, 1);
5654 assert!(quartets[0].parallel_octave);
5655
5656 score.parts[1].staves[0].measures[0].voices[0]
5657 .push(Note::new(Pitch::new(Step::E, 4), Duration::Quarter));
5658 assert!(analyze_voice_leading_quartets(&score).is_empty());
5659 }
5660
5661 #[test]
5662 fn located_quartets_do_not_bridge_rests_or_call_unisons_octaves() {
5663 let mut score = Score::default();
5664 {
5665 let measure = &mut score.parts[0].staves[0].measures[0];
5666 for voice in measure.voices.iter_mut().take(2) {
5667 *voice = vec![
5668 Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
5669 Note::rest(Duration::Quarter),
5670 Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
5671 ];
5672 }
5673 }
5674 assert!(analyze_voice_leading_quartets(&score).is_empty());
5675 for voice in score.parts[0].staves[0].measures[0]
5676 .voices
5677 .iter_mut()
5678 .take(2)
5679 {
5680 voice.remove(1);
5681 }
5682 let quartet = &analyze_voice_leading_quartets(&score)[0];
5683 assert!(quartet.similar_motion);
5684 assert!(!quartet.parallel_octave);
5685 }
5686
5687 #[test]
5688 fn keyless_quartet_resolution_checks_each_dissonance_family() {
5689 let pitch = |step, octave| Pitch::new(step, octave);
5690 let flat = |step, octave| Pitch::with_alter(step, octave, -1);
5691 let resolves = |a: Pitch, b: Pitch, c: Pitch, d: Pitch| {
5692 let first_motion = b.to_midi_cents() - a.to_midi_cents();
5693 let second_motion = d.to_midi_cents() - c.to_midi_cents();
5694 keyless_quartet_resolution(&a, &b, &c, &d, first_motion, second_motion)
5695 };
5696 assert!(!resolves(
5698 pitch(Step::F, 5),
5699 pitch(Step::G, 5),
5700 pitch(Step::C, 4),
5701 pitch(Step::C, 4)
5702 ));
5703 assert!(resolves(
5704 pitch(Step::F, 5),
5705 pitch(Step::E, 5),
5706 pitch(Step::C, 4),
5707 pitch(Step::C, 4)
5708 ));
5709 assert!(resolves(
5711 pitch(Step::E, 5),
5712 pitch(Step::F, 5),
5713 flat(Step::B, 4),
5714 pitch(Step::A, 4)
5715 ));
5716 assert!(resolves(
5717 flat(Step::B, 4),
5718 pitch(Step::A, 4),
5719 pitch(Step::E, 4),
5720 pitch(Step::F, 4)
5721 ));
5722 assert!(resolves(
5724 flat(Step::B, 4),
5725 pitch(Step::A, 4),
5726 pitch(Step::C, 4),
5727 pitch(Step::F, 4)
5728 ));
5729 assert!(!resolves(
5730 flat(Step::B, 4),
5731 pitch(Step::A, 4),
5732 pitch(Step::C, 4),
5733 pitch(Step::G, 4)
5734 ));
5735 }
5736
5737 #[test]
5738 fn voice_leading_does_not_bridge_rests() {
5739 let mut score = Score::default();
5740 let measure = &mut score.parts[0].staves[0].measures[0];
5741 measure.voices[0].clear();
5742 measure.voices[1].clear();
5743 let (upper_voices, lower_voices) = measure.voices.split_at_mut(1);
5744 for voice in [&mut upper_voices[0], &mut lower_voices[0]] {
5745 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
5746 voice.push(Note::rest(Duration::Quarter));
5747 voice.push(Note::new(Pitch::new(Step::D, 4), Duration::Quarter));
5748 }
5749 assert!(analyze_voice_leading(&score).is_empty());
5750 }
5751
5752 #[test]
5753 fn voice_leading_uses_written_onsets_instead_of_matching_note_indices() {
5754 let mut score = Score::default();
5755 let measure = &mut score.parts[0].staves[0].measures[0];
5756 measure.voices[0] = vec![
5757 Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
5758 Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
5759 ];
5760 measure.voices[1] = vec![
5761 Note::new(Pitch::new(Step::C, 3), Duration::Half),
5762 Note::new(Pitch::new(Step::D, 3), Duration::Quarter),
5763 ];
5764 let observations = analyze_voice_leading(&score);
5765 assert_eq!(observations.len(), 1);
5766 assert_eq!(observations[0].upper_motion_cents, 200);
5767 assert_eq!(observations[0].lower_motion_cents, 0);
5768 assert!(!observations[0].parallel_perfect);
5769 assert_eq!(observations[0].lower.note, 0);
5770 }
5771
5772 #[test]
5773 fn satb_does_not_compare_notes_at_different_written_beats() {
5774 let mut score = Score::default();
5775 let measure = &mut score.parts[0].staves[0].measures[0];
5776 measure.voices[0] = vec![
5777 Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
5778 Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
5779 ];
5780 measure.voices[1] = vec![
5781 Note::new(Pitch::new(Step::C, 3), Duration::Half),
5782 Note::new(Pitch::new(Step::D, 5), Duration::Quarter),
5783 ];
5784 assert!(analyze_satb(&score).is_empty());
5785 }
5786
5787 #[test]
5788 fn written_timeline_uses_actual_length_for_a_lone_whole_rest() {
5789 let mut score = Score::new("pickup", 120, 3, 4, 0, 1);
5790 let measure = &mut score.parts[0].staves[0].measures[0];
5791 measure.voices[0] = vec![Note::rest(Duration::Whole)];
5792 let events = written_measure_events(&score, 0);
5793 assert_eq!(events.len(), 1);
5794 assert_eq!(events[0].start, 0.0);
5795 assert_eq!(events[0].end, 3.0);
5796 }
5797
5798 #[test]
5799 fn reports_voice_crossing_as_error() {
5800 let mut score = Score::default();
5801 let measure = &mut score.parts[0].staves[0].measures[0];
5802 measure.voices[0].clear();
5803 measure.voices[1].clear();
5804 measure.voices[0].push(Note::new(Pitch::new(Step::C, 3), Duration::Quarter));
5805 measure.voices[1].push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
5806 let diagnostics = analyze_satb(&score);
5807 assert_eq!(diagnostics.len(), 1);
5808 assert_eq!(diagnostics[0].kind, SatbDiagnosticKind::VoiceCrossing);
5809 assert_eq!(diagnostics[0].severity, SatbSeverity::Error);
5810 }
5811
5812 #[test]
5813 fn satb_region_replaces_local_findings_and_preserves_outside_findings() {
5814 let mut previous_score = Score::default();
5815 for measure in &mut previous_score.parts[0].staves[0].measures {
5816 measure.voices[0].clear();
5817 measure.voices[1].clear();
5818 measure.voices[0].push(Note::new(Pitch::new(Step::C, 3), Duration::Quarter));
5819 measure.voices[1].push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
5820 }
5821 let previous = analyze_score(&previous_score);
5822
5823 let mut edited_score = previous_score.clone();
5824 let measure = &mut edited_score.parts[0].staves[0].measures[0];
5825 measure.voices[0][0] = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
5826 let region = AnalysisRegion {
5827 part: 0,
5828 staff: 0,
5829 start_measure: 0,
5830 end_measure: 1,
5831 };
5832 let refreshed = analyze_selected_categories_in_region(
5833 &edited_score,
5834 &previous,
5835 &[AnalysisCategory::SatbDiagnostics],
5836 Some(®ion),
5837 );
5838
5839 assert_eq!(refreshed.satb_diagnostics.len(), 3);
5840 assert!(
5841 refreshed
5842 .satb_diagnostics
5843 .iter()
5844 .all(|diagnostic| diagnostic.upper.measure >= 1)
5845 );
5846 }
5847
5848 #[test]
5849 fn finds_repeated_melodic_motif_with_source_spans() {
5850 let mut score = Score::default();
5851 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5852 voice.clear();
5853 for step in [Step::C, Step::D, Step::E, Step::G, Step::A, Step::B] {
5854 voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
5855 }
5856 let motifs = analyze_motifs(&score);
5857 assert_eq!(motifs.len(), 1);
5858 assert_eq!(motifs[0].signature, vec![2, 2]);
5859 assert_eq!(motifs[0].occurrences.len(), 2);
5860 assert_eq!(motifs[0].occurrences[0].evidence.len(), 3);
5861 }
5862
5863 #[test]
5864 fn reports_measure_ending_rest_as_phrase_boundary() {
5865 let mut score = Score::default();
5866 let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5867 voice.clear();
5868 voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
5869 voice.push(Note::rest(Duration::Quarter));
5870 let boundaries = analyze_phrase_boundaries(&score);
5871 assert!(boundaries.iter().any(|boundary| {
5872 boundary.reason == PhraseBoundaryReason::RestTermination
5873 && boundary.address.measure == 0
5874 && boundary.address.note == 1
5875 }));
5876 }
5877
5878 #[test]
5879 fn benchmark_reports_perfect_scores_for_hand_verified_empty_fixture() {
5880 let score = Score::default();
5881 let cases = [BenchmarkCase {
5882 name: "empty-score",
5883 score: &score,
5884 expected: BenchmarkExpectation {
5885 phrase_boundaries: 4,
5886 ..BenchmarkExpectation::default()
5887 },
5888 }];
5889 let reports = run_benchmark(&cases);
5890 assert_eq!(reports.len(), 1);
5891 assert_eq!(reports[0].precision_percent, 100);
5892 assert_eq!(reports[0].recall_percent, 100);
5893 assert_eq!(reports[0].explanation_completeness_percent, 100);
5894 assert!(reports[0].failures.is_empty());
5895 }
5896
5897 #[test]
5898 fn benchmark_reports_category_level_failure_details() {
5899 let score = Score::default();
5900 let cases = [BenchmarkCase {
5901 name: "under-annotated-score",
5902 score: &score,
5903 expected: BenchmarkExpectation {
5904 phrase_boundaries: 5,
5905 ..BenchmarkExpectation::default()
5906 },
5907 }];
5908 let reports = run_benchmark(&cases);
5909 assert_eq!(
5910 reports[0].failures,
5911 vec![BenchmarkFailure {
5912 category: BenchmarkCategory::PhraseBoundaries,
5913 expected: 5,
5914 predicted: 4,
5915 missing: 1,
5916 excess: 0,
5917 }]
5918 );
5919 }
5920
5921 #[test]
5922 fn benchmark_suite_aggregates_case_status_and_metrics() {
5923 let score = Score::default();
5924 let cases = [
5925 BenchmarkCase {
5926 name: "passing",
5927 score: &score,
5928 expected: BenchmarkExpectation {
5929 phrase_boundaries: 4,
5930 ..BenchmarkExpectation::default()
5931 },
5932 },
5933 BenchmarkCase {
5934 name: "failing",
5935 score: &score,
5936 expected: BenchmarkExpectation {
5937 phrase_boundaries: 5,
5938 ..BenchmarkExpectation::default()
5939 },
5940 },
5941 ];
5942 let suite = run_benchmark_suite(&cases);
5943 assert_eq!(suite.case_count, 2);
5944 assert_eq!(suite.passed_case_count, 1);
5945 assert_eq!(suite.failed_case_count, 1);
5946 assert_eq!(suite.cases.len(), 2);
5947 assert_eq!(suite.precision_percent, 100);
5948 assert_eq!(suite.recall_percent, 90);
5949 assert_eq!(suite.explanation_completeness_percent, 100);
5950 }
5951
5952 struct TestPass(&'static str);
5953
5954 impl AnalysisPass for TestPass {
5955 fn id(&self) -> &str {
5956 self.0
5957 }
5958
5959 fn run(&self, _score: &Score) -> serde_json::Value {
5960 serde_json::json!({ "pass": self.0 })
5961 }
5962 }
5963
5964 #[test]
5965 fn extension_passes_run_in_stable_id_order() {
5966 let score = Score::default();
5967 let beta = TestPass("beta");
5968 let alpha = TestPass("alpha");
5969 let results = run_analysis_passes(&score, &[&beta, &alpha]).unwrap();
5970 assert_eq!(
5971 results
5972 .iter()
5973 .map(|result| result.pass_id.as_str())
5974 .collect::<Vec<_>>(),
5975 ["alpha", "beta"]
5976 );
5977 assert_eq!(results[0].output["pass"], "alpha");
5978 }
5979
5980 #[test]
5981 fn extension_passes_reject_empty_and_duplicate_ids() {
5982 let score = Score::default();
5983 let empty = TestPass("");
5984 assert_eq!(
5985 run_analysis_passes(&score, &[&empty]),
5986 Err(AnalysisPassError::EmptyId)
5987 );
5988 let first = TestPass("same");
5989 let second = TestPass("same");
5990 assert_eq!(
5991 run_analysis_passes(&score, &[&first, &second]),
5992 Err(AnalysisPassError::DuplicateId("same".to_string()))
5993 );
5994 }
5995}