1use super::commands::{
10 Command, PasteVoiceCmd, SetChordSymbolCmd, SetDynamicCmd, SetLyricCmd, apply_command,
11};
12use super::duration::Duration;
13use super::notation::{ChordSymbol, Dynamic, Lyric, TabPosition};
14use super::pitch::Pitch;
15use super::score::{Note, Score};
16use super::validate::validate;
17use serde::{Deserialize, Serialize};
18use std::collections::{BTreeMap, BTreeSet};
19
20pub const OMR_PROPOSAL_CONTRACT_VERSION: u32 = 1;
22
23pub const MAX_OMR_PAGES: usize = 1_000;
25pub const MAX_OMR_MEASURES: usize = 100_000;
26pub const MAX_OMR_EVENTS_PER_MEASURE: usize = 4_096;
27pub const MAX_OMR_ANNOTATIONS: usize = 100_000;
28
29#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
34pub struct OmrRegion {
35 pub x: f32,
36 pub y: f32,
37 pub width: f32,
38 pub height: f32,
39}
40
41impl OmrRegion {
42 pub fn is_valid(self) -> bool {
44 self.x.is_finite()
45 && self.y.is_finite()
46 && self.width.is_finite()
47 && self.height.is_finite()
48 && (0.0..=1.0).contains(&self.x)
49 && (0.0..=1.0).contains(&self.y)
50 && self.width > 0.0
51 && self.height > 0.0
52 && self.x + self.width <= 1.0
53 && self.y + self.height <= 1.0
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct OmrPage {
61 pub source_id: String,
62 pub page_index: u32,
63 pub pixel_width: u32,
64 pub pixel_height: u32,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
69pub struct OmrMeasureTarget {
70 pub part_index: usize,
71 pub staff_index: usize,
72 pub measure_index: usize,
73 pub voice_index: usize,
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78pub struct OmrRhythmicEvent {
79 #[serde(default)]
80 pub pitches: Vec<Pitch>,
81 #[serde(default)]
83 pub is_unpitched: bool,
84 #[serde(default)]
86 pub instrument_id: Option<String>,
87 #[serde(default)]
90 pub tab_positions: Vec<TabPosition>,
91 pub duration: Duration,
92 #[serde(default)]
93 pub dot_count: u8,
94 pub region: OmrRegion,
95 pub confidence: f32,
97}
98
99impl OmrRhythmicEvent {
100 pub fn rest(duration: Duration, region: OmrRegion, confidence: f32) -> Self {
102 Self {
103 pitches: Vec::new(),
104 is_unpitched: false,
105 instrument_id: None,
106 tab_positions: Vec::new(),
107 duration,
108 dot_count: 0,
109 region,
110 confidence,
111 }
112 }
113
114 pub fn note(
116 pitches: Vec<Pitch>,
117 duration: Duration,
118 region: OmrRegion,
119 confidence: f32,
120 ) -> Self {
121 Self {
122 pitches,
123 is_unpitched: false,
124 instrument_id: None,
125 tab_positions: Vec::new(),
126 duration,
127 dot_count: 0,
128 region,
129 confidence,
130 }
131 }
132
133 fn to_note(&self) -> Note {
134 let mut note = match self.pitches.split_first() {
135 Some((pitch, remaining)) => {
136 let mut note = Note::new(pitch.clone(), self.duration.clone());
137 note.pitches.extend(remaining.iter().cloned());
138 note
139 }
140 None => Note::rest(self.duration.clone()),
141 };
142 note.dot_count = self.dot_count;
143 note.is_unpitched = self.is_unpitched;
144 note.instrument_id = self.instrument_id.clone();
145 note.tab_positions = self.tab_positions.clone();
146 note.tab_position = note.tab_positions.first().cloned();
147 note.string_number = note.tab_position.as_ref().map(|position| position.string);
148 note
149 }
150
151 fn is_valid(&self) -> bool {
152 let is_rest = self.pitches.is_empty();
153 let tab_positions_match_pitches = self.tab_positions.is_empty()
154 || (!is_rest && self.tab_positions.len() == self.pitches.len());
155 let valid_tab_positions = self
156 .tab_positions
157 .iter()
158 .all(|position| position.string > 0);
159 let valid_instrument = self
160 .instrument_id
161 .as_ref()
162 .is_none_or(|id| !id.trim().is_empty() && id.len() <= 128);
163 (!self.is_unpitched || !is_rest)
164 && (!is_rest || (self.instrument_id.is_none() && self.tab_positions.is_empty()))
165 && tab_positions_match_pitches
166 && valid_tab_positions
167 && valid_instrument
168 }
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173pub struct OmrMeasureProposal {
174 pub target: OmrMeasureTarget,
175 pub page_index: u32,
176 pub region: OmrRegion,
177 #[serde(default)]
179 pub complete: bool,
180 pub confidence: f32,
182 #[serde(default)]
183 pub events: Vec<OmrRhythmicEvent>,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
189pub struct OmrNoteTarget {
190 pub measure: OmrMeasureTarget,
191 pub note_index: usize,
192}
193
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
199pub enum OmrNoteAnnotation {
200 Dynamic(Dynamic),
201 Lyric {
202 lyric: Lyric,
203 #[serde(default)]
205 verse: Option<u8>,
206 },
207 ChordSymbol(Box<ChordSymbol>),
208}
209
210impl OmrNoteAnnotation {
211 fn kind_key(&self) -> &'static str {
212 match self {
213 Self::Dynamic(_) => "dynamic",
214 Self::Lyric { .. } => "lyric",
215 Self::ChordSymbol(_) => "chord_symbol",
216 }
217 }
218
219 fn is_valid(&self) -> bool {
220 match self {
221 Self::Dynamic(_) => true,
222 Self::Lyric { lyric, verse } => {
223 lyric.text.trim().len() <= 1_024
224 && !lyric.text.trim().is_empty()
225 && matches!(
226 lyric.syllabic.as_str(),
227 "single" | "begin" | "middle" | "end"
228 )
229 && verse.is_none_or(|verse| (1..=32).contains(&verse))
230 }
231 Self::ChordSymbol(chord) => {
232 !chord.root.trim().is_empty()
233 && chord.root.len() <= 16
234 && !chord.kind.trim().is_empty()
235 && chord.kind.len() <= 128
236 }
237 }
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
243pub struct OmrNoteAnnotationProposal {
244 pub target: OmrNoteTarget,
245 pub page_index: u32,
246 pub region: OmrRegion,
247 pub confidence: f32,
248 pub annotation: OmrNoteAnnotation,
249}
250
251#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
255pub struct OmrUnresolvedItem {
256 pub page_index: u32,
257 pub kind: String,
258 pub region: OmrRegion,
259 pub confidence: f32,
260}
261
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
264pub struct OmrProposal {
265 pub contract_version: u32,
266 #[serde(default)]
267 pub pages: Vec<OmrPage>,
268 #[serde(default)]
269 pub measures: Vec<OmrMeasureProposal>,
270 #[serde(default)]
271 pub annotations: Vec<OmrNoteAnnotationProposal>,
272 #[serde(default)]
273 pub unresolved: Vec<OmrUnresolvedItem>,
274}
275
276impl Default for OmrProposal {
277 fn default() -> Self {
278 Self {
279 contract_version: OMR_PROPOSAL_CONTRACT_VERSION,
280 pages: Vec::new(),
281 measures: Vec::new(),
282 annotations: Vec::new(),
283 unresolved: Vec::new(),
284 }
285 }
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(rename_all = "snake_case")]
291pub enum OmrDiagnosticKind {
292 UnsupportedContractVersion,
293 ProposalLimitExceeded,
294 InvalidPage,
295 InvalidRegion,
296 InvalidConfidence,
297 DuplicateMeasureTarget,
298 InvalidTarget,
299 InvalidMeasureContent,
300 IncompleteMeasure,
301 EmptyMeasure,
302 LowConfidence,
303 DuplicateAnnotationTarget,
304 InvalidAnnotation,
305 UnsupportedItem,
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312pub struct OmrDiagnostic {
313 pub kind: OmrDiagnosticKind,
314 pub message: String,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub index: Option<usize>,
317}
318
319impl OmrDiagnostic {
320 fn new(kind: OmrDiagnosticKind, message: impl Into<String>, index: Option<usize>) -> Self {
321 Self {
322 kind,
323 message: message.into(),
324 index,
325 }
326 }
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct OmrCommandPlan {
334 pub commands: Vec<Command>,
335 pub diagnostics: Vec<OmrDiagnostic>,
336}
337
338impl OmrCommandPlan {
339 pub fn is_ready(&self) -> bool {
341 self.diagnostics.is_empty() && !self.commands.is_empty()
342 }
343}
344
345impl OmrProposal {
346 pub fn command_plan(&self, score: &Score, minimum_confidence: f32) -> OmrCommandPlan {
352 let mut plan = OmrCommandPlan {
353 commands: Vec::new(),
354 diagnostics: Vec::new(),
355 };
356 if self.contract_version != OMR_PROPOSAL_CONTRACT_VERSION {
357 plan.diagnostics.push(OmrDiagnostic::new(
358 OmrDiagnosticKind::UnsupportedContractVersion,
359 format!(
360 "OMR proposal contract {} is unsupported (expected {})",
361 self.contract_version, OMR_PROPOSAL_CONTRACT_VERSION
362 ),
363 None,
364 ));
365 return plan;
366 }
367 if !minimum_confidence.is_finite() || !(0.0..=1.0).contains(&minimum_confidence) {
368 plan.diagnostics.push(OmrDiagnostic::new(
369 OmrDiagnosticKind::InvalidConfidence,
370 "minimum confidence must be finite and within 0.0..=1.0",
371 None,
372 ));
373 return plan;
374 }
375 if self.pages.len() > MAX_OMR_PAGES
376 || self.measures.len() > MAX_OMR_MEASURES
377 || self.annotations.len() > MAX_OMR_ANNOTATIONS
378 {
379 plan.diagnostics.push(OmrDiagnostic::new(
380 OmrDiagnosticKind::ProposalLimitExceeded,
381 "OMR proposal exceeds the bounded page, measure, or annotation limit",
382 None,
383 ));
384 return plan;
385 }
386
387 let mut page_indices = BTreeSet::new();
388 for page in &self.pages {
389 let valid = !page.source_id.is_empty()
390 && page.source_id.len() <= 512
391 && page.pixel_width > 0
392 && page.pixel_height > 0
393 && page_indices.insert(page.page_index);
394 if !valid {
395 plan.diagnostics.push(OmrDiagnostic::new(
396 OmrDiagnosticKind::InvalidPage,
397 "OMR page must have a unique bounded source id and non-zero dimensions",
398 None,
399 ));
400 }
401 }
402
403 let mut targets = BTreeSet::new();
404 let mut recognized_note_counts = BTreeMap::new();
405 for (index, measure) in self.measures.iter().enumerate() {
406 if !targets.insert(measure.target) {
407 plan.diagnostics.push(OmrDiagnostic::new(
408 OmrDiagnosticKind::DuplicateMeasureTarget,
409 "multiple OMR proposals target the same voice measure",
410 Some(index),
411 ));
412 continue;
413 }
414 if !page_indices.contains(&measure.page_index) {
415 plan.diagnostics.push(OmrDiagnostic::new(
416 OmrDiagnosticKind::InvalidPage,
417 "OMR measure refers to an unknown or invalid page",
418 Some(index),
419 ));
420 continue;
421 }
422 if !measure.region.is_valid() {
423 plan.diagnostics.push(OmrDiagnostic::new(
424 OmrDiagnosticKind::InvalidRegion,
425 "OMR measure region is outside its normalized page",
426 Some(index),
427 ));
428 continue;
429 }
430 if !measure.confidence.is_finite() || !(0.0..=1.0).contains(&measure.confidence) {
431 plan.diagnostics.push(OmrDiagnostic::new(
432 OmrDiagnosticKind::InvalidConfidence,
433 "OMR measure confidence must be finite and within 0.0..=1.0",
434 Some(index),
435 ));
436 continue;
437 }
438 if !measure.complete {
439 plan.diagnostics.push(OmrDiagnostic::new(
440 OmrDiagnosticKind::IncompleteMeasure,
441 "OMR measure is incomplete and requires review",
442 Some(index),
443 ));
444 continue;
445 }
446 if measure.events.is_empty() || measure.events.len() > MAX_OMR_EVENTS_PER_MEASURE {
447 plan.diagnostics.push(OmrDiagnostic::new(
448 OmrDiagnosticKind::EmptyMeasure,
449 "OMR measure must contain a bounded, non-empty event list",
450 Some(index),
451 ));
452 continue;
453 }
454 if measure.confidence < minimum_confidence
455 || measure
456 .events
457 .iter()
458 .any(|event| event.confidence < minimum_confidence)
459 {
460 plan.diagnostics.push(OmrDiagnostic::new(
461 OmrDiagnosticKind::LowConfidence,
462 "OMR measure or event confidence is below the review threshold",
463 Some(index),
464 ));
465 continue;
466 }
467 if measure.events.iter().any(|event| {
468 !event.region.is_valid()
469 || !event.confidence.is_finite()
470 || !(0.0..=1.0).contains(&event.confidence)
471 || !event.is_valid()
472 }) {
473 plan.diagnostics.push(OmrDiagnostic::new(
474 OmrDiagnosticKind::InvalidMeasureContent,
475 "OMR event has invalid geometry, confidence, TAB, or percussion data",
476 Some(index),
477 ));
478 continue;
479 }
480 let target = measure.target;
481 let staff = score
482 .parts
483 .get(target.part_index)
484 .and_then(|part| part.staves.get(target.staff_index))
485 .filter(|staff| staff.measures.get(target.measure_index).is_some());
486 let Some(staff) = staff.filter(|_| target.voice_index < 4) else {
487 plan.diagnostics.push(OmrDiagnostic::new(
488 OmrDiagnosticKind::InvalidTarget,
489 "OMR measure target does not exist in the current score",
490 Some(index),
491 ));
492 continue;
493 };
494 let expected_beats =
495 staff.measure_beats(target.measure_index, &score.settings.time_signature);
496 let proposed_beats = measure
497 .events
498 .iter()
499 .map(|event| event.duration.beats(event.dot_count))
500 .sum::<f64>();
501 if (expected_beats - proposed_beats).abs() > 1e-9 {
502 plan.diagnostics.push(OmrDiagnostic::new(
503 OmrDiagnosticKind::InvalidMeasureContent,
504 format!(
505 "OMR measure lasts {proposed_beats} beats but the target measure requires {expected_beats}"
506 ),
507 Some(index),
508 ));
509 continue;
510 }
511 recognized_note_counts.insert(target, measure.events.len());
512 plan.commands.push(Command::PasteVoice(PasteVoiceCmd {
513 part_index: target.part_index,
514 staff_index: target.staff_index,
515 measure_index: target.measure_index,
516 voice_index: target.voice_index,
517 notes: measure
518 .events
519 .iter()
520 .map(OmrRhythmicEvent::to_note)
521 .collect(),
522 }));
523 }
524
525 let mut annotation_targets = BTreeSet::new();
526 for (index, annotation) in self.annotations.iter().enumerate() {
527 let target_key = (annotation.target, annotation.annotation.kind_key());
528 if !annotation_targets.insert(target_key) {
529 plan.diagnostics.push(OmrDiagnostic::new(
530 OmrDiagnosticKind::DuplicateAnnotationTarget,
531 "multiple OMR annotations of the same kind target one recognized note",
532 Some(index),
533 ));
534 continue;
535 }
536 if !page_indices.contains(&annotation.page_index) {
537 plan.diagnostics.push(OmrDiagnostic::new(
538 OmrDiagnosticKind::InvalidPage,
539 "OMR annotation refers to an unknown or invalid page",
540 Some(index),
541 ));
542 continue;
543 }
544 if !annotation.region.is_valid() {
545 plan.diagnostics.push(OmrDiagnostic::new(
546 OmrDiagnosticKind::InvalidRegion,
547 "OMR annotation region is outside its normalized page",
548 Some(index),
549 ));
550 continue;
551 }
552 if !annotation.confidence.is_finite() || !(0.0..=1.0).contains(&annotation.confidence) {
553 plan.diagnostics.push(OmrDiagnostic::new(
554 OmrDiagnosticKind::InvalidConfidence,
555 "OMR annotation confidence must be finite and within 0.0..=1.0",
556 Some(index),
557 ));
558 continue;
559 }
560 if annotation.confidence < minimum_confidence {
561 plan.diagnostics.push(OmrDiagnostic::new(
562 OmrDiagnosticKind::LowConfidence,
563 "OMR annotation confidence is below the review threshold",
564 Some(index),
565 ));
566 continue;
567 }
568 if !annotation.annotation.is_valid() {
569 plan.diagnostics.push(OmrDiagnostic::new(
570 OmrDiagnosticKind::InvalidAnnotation,
571 "OMR annotation is not a bounded canonical value",
572 Some(index),
573 ));
574 continue;
575 }
576 let target = annotation.target;
577 if !recognized_note_counts
578 .get(&target.measure)
579 .is_some_and(|count| target.note_index < *count)
580 {
581 plan.diagnostics.push(OmrDiagnostic::new(
582 OmrDiagnosticKind::InvalidTarget,
583 "OMR annotation target is not a note in a complete recognized measure",
584 Some(index),
585 ));
586 continue;
587 }
588 let measure = target.measure;
589 match &annotation.annotation {
590 OmrNoteAnnotation::Dynamic(dynamic) => {
591 plan.commands.push(Command::SetDynamic(SetDynamicCmd {
592 part_index: measure.part_index,
593 staff_index: measure.staff_index,
594 measure_index: measure.measure_index,
595 voice: measure.voice_index,
596 note_index: target.note_index,
597 dynamic: Some(*dynamic),
598 }));
599 }
600 OmrNoteAnnotation::Lyric { lyric, verse } => {
601 plan.commands.push(Command::SetLyric(SetLyricCmd {
602 part_index: measure.part_index,
603 staff_index: measure.staff_index,
604 measure_index: measure.measure_index,
605 voice: measure.voice_index,
606 note_index: target.note_index,
607 lyric: Some(lyric.clone()),
608 verse: *verse,
609 }));
610 }
611 OmrNoteAnnotation::ChordSymbol(chord) => {
612 plan.commands
613 .push(Command::SetChordSymbol(SetChordSymbolCmd {
614 part_index: measure.part_index,
615 staff_index: measure.staff_index,
616 measure_index: measure.measure_index,
617 voice: measure.voice_index,
618 note_index: target.note_index,
619 chord: Some((**chord).clone()),
620 }));
621 }
622 }
623 }
624
625 for (index, item) in self.unresolved.iter().enumerate() {
626 let valid = page_indices.contains(&item.page_index)
627 && item.region.is_valid()
628 && item.confidence.is_finite()
629 && (0.0..=1.0).contains(&item.confidence)
630 && !item.kind.trim().is_empty()
631 && item.kind.len() <= 128;
632 plan.diagnostics.push(OmrDiagnostic::new(
633 if valid {
634 OmrDiagnosticKind::UnsupportedItem
635 } else {
636 OmrDiagnosticKind::InvalidRegion
637 },
638 if valid {
639 format!(
640 "OMR item '{}' is not supported by the canonical model",
641 item.kind
642 )
643 } else {
644 "OMR unresolved item has invalid page, region, confidence, or kind".to_string()
645 },
646 Some(index),
647 ));
648 }
649 if plan.diagnostics.is_empty() && !plan.commands.is_empty() {
650 let mut candidate = score.clone();
651 for command in &plan.commands {
652 if let Err(error) = apply_command(command, &mut candidate) {
653 plan.diagnostics.push(OmrDiagnostic::new(
654 OmrDiagnosticKind::InvalidMeasureContent,
655 format!("OMR command cannot be applied: {error}"),
656 None,
657 ));
658 break;
659 }
660 }
661 if !validate(&candidate).is_valid() {
662 plan.diagnostics.push(OmrDiagnostic::new(
663 OmrDiagnosticKind::InvalidMeasureContent,
664 "OMR proposal would violate score structural validation",
665 None,
666 ));
667 }
668 }
669 if !plan.diagnostics.is_empty() {
670 plan.commands.clear();
671 }
672 plan
673 }
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679 use crate::{Duration, Pitch, Score, ScoreEngine, Step};
680
681 fn region() -> OmrRegion {
682 OmrRegion {
683 x: 0.1,
684 y: 0.1,
685 width: 0.2,
686 height: 0.1,
687 }
688 }
689
690 fn complete_quarter_measure() -> OmrMeasureProposal {
691 OmrMeasureProposal {
692 target: OmrMeasureTarget {
693 part_index: 0,
694 staff_index: 0,
695 measure_index: 0,
696 voice_index: 0,
697 },
698 page_index: 0,
699 region: region(),
700 complete: true,
701 confidence: 0.99,
702 events: (0..4)
703 .map(|_| {
704 OmrRhythmicEvent::note(
705 vec![Pitch::new(Step::C, 4)],
706 Duration::Quarter,
707 region(),
708 0.99,
709 )
710 })
711 .collect(),
712 }
713 }
714
715 fn proposal(measure: OmrMeasureProposal) -> OmrProposal {
716 OmrProposal {
717 pages: vec![OmrPage {
718 source_id: "page-1".to_string(),
719 page_index: 0,
720 pixel_width: 1200,
721 pixel_height: 1600,
722 }],
723 measures: vec![measure],
724 ..OmrProposal::default()
725 }
726 }
727
728 #[test]
729 fn complete_high_confidence_measure_becomes_undoable_commands() {
730 let proposal = proposal(complete_quarter_measure());
731 let mut engine = ScoreEngine::new();
732 let plan = proposal.command_plan(&engine.score, 0.9);
733 assert!(plan.is_ready());
734 engine
735 .batch_apply_labeled(plan.commands, "ApplyOMR")
736 .unwrap();
737 let voice = &engine.score.parts[0].staves[0].measures[0].voices[0];
738 assert_eq!(voice.len(), 4);
739 assert!(voice.iter().all(|note| !note.is_rest));
740 assert_eq!(engine.undo_label().as_deref(), Some("ApplyOMR"));
741 engine.undo().unwrap();
742 assert!(
743 engine.score.parts[0].staves[0].measures[0].voices[0]
744 .iter()
745 .all(|note| note.is_rest)
746 );
747 }
748
749 #[test]
750 fn incomplete_or_low_confidence_measure_is_never_auto_applied() {
751 let mut measure = complete_quarter_measure();
752 measure.complete = false;
753 let plan = proposal(measure).command_plan(&Score::default(), 0.9);
754 assert!(plan.commands.is_empty());
755 assert!(
756 plan.diagnostics
757 .iter()
758 .any(|diagnostic| diagnostic.kind == OmrDiagnosticKind::IncompleteMeasure)
759 );
760
761 let mut measure = complete_quarter_measure();
762 measure.confidence = 0.5;
763 let plan = proposal(measure).command_plan(&Score::default(), 0.9);
764 assert!(plan.commands.is_empty());
765 assert!(
766 plan.diagnostics
767 .iter()
768 .any(|diagnostic| diagnostic.kind == OmrDiagnosticKind::LowConfidence)
769 );
770 }
771
772 #[test]
773 fn annotations_follow_the_recognized_note_commands() {
774 let mut proposal = proposal(complete_quarter_measure());
775 let target = OmrNoteTarget {
776 measure: proposal.measures[0].target,
777 note_index: 0,
778 };
779 proposal.annotations = vec![
780 OmrNoteAnnotationProposal {
781 target,
782 page_index: 0,
783 region: region(),
784 confidence: 0.99,
785 annotation: OmrNoteAnnotation::Dynamic(Dynamic::Mf),
786 },
787 OmrNoteAnnotationProposal {
788 target,
789 page_index: 0,
790 region: region(),
791 confidence: 0.99,
792 annotation: OmrNoteAnnotation::Lyric {
793 lyric: Lyric {
794 text: "la".to_string(),
795 syllabic: "single".to_string(),
796 extend: false,
797 },
798 verse: None,
799 },
800 },
801 ];
802 let mut engine = ScoreEngine::new();
803 let plan = proposal.command_plan(&engine.score, 0.9);
804 assert!(plan.is_ready(), "{:#?}", plan.diagnostics);
805 engine
806 .batch_apply_labeled(plan.commands, "ApplyOMR")
807 .unwrap();
808 let note = &engine.score.parts[0].staves[0].measures[0].voices[0][0];
809 assert_eq!(note.dynamic, Some(Dynamic::Mf));
810 assert_eq!(
811 note.lyric.as_ref().map(|lyric| lyric.text.as_str()),
812 Some("la")
813 );
814 }
815
816 #[test]
817 fn annotation_outside_a_recognized_measure_clears_the_plan() {
818 let mut proposal = proposal(complete_quarter_measure());
819 proposal.annotations.push(OmrNoteAnnotationProposal {
820 target: OmrNoteTarget {
821 measure: OmrMeasureTarget {
822 measure_index: 1,
823 ..proposal.measures[0].target
824 },
825 note_index: 0,
826 },
827 page_index: 0,
828 region: region(),
829 confidence: 0.99,
830 annotation: OmrNoteAnnotation::Dynamic(Dynamic::Mf),
831 });
832 let plan = proposal.command_plan(&Score::default(), 0.9);
833 assert!(plan.commands.is_empty());
834 assert!(
835 plan.diagnostics
836 .iter()
837 .any(|diagnostic| diagnostic.kind == OmrDiagnosticKind::InvalidTarget)
838 );
839 }
840
841 #[test]
842 fn event_tablature_and_percussion_metadata_reach_the_created_notes() {
843 let mut measure = complete_quarter_measure();
844 measure.events[0].tab_positions = vec![TabPosition { string: 6, fret: 3 }];
845 measure.events[1].is_unpitched = true;
846 measure.events[1].instrument_id = Some("P1-I39".to_string());
847 let proposal = proposal(measure);
848 let mut engine = ScoreEngine::new();
849 let plan = proposal.command_plan(&engine.score, 0.9);
850 assert!(plan.is_ready(), "{:#?}", plan.diagnostics);
851 engine
852 .batch_apply_labeled(plan.commands, "ApplyOMR")
853 .unwrap();
854 let notes = &engine.score.parts[0].staves[0].measures[0].voices[0];
855 assert_eq!(
856 notes[0].tab_position,
857 Some(TabPosition { string: 6, fret: 3 })
858 );
859 assert_eq!(
860 notes[0].tab_positions,
861 vec![TabPosition { string: 6, fret: 3 }]
862 );
863 assert!(notes[1].is_unpitched);
864 assert_eq!(notes[1].instrument_id.as_deref(), Some("P1-I39"));
865 }
866
867 #[test]
868 fn invalid_tab_event_metadata_clears_the_plan() {
869 let mut measure = complete_quarter_measure();
870 measure.events[0].tab_positions = vec![TabPosition { string: 0, fret: 3 }];
871 let plan = proposal(measure).command_plan(&Score::default(), 0.9);
872 assert!(plan.commands.is_empty());
873 assert!(
874 plan.diagnostics
875 .iter()
876 .any(|diagnostic| diagnostic.kind == OmrDiagnosticKind::InvalidMeasureContent)
877 );
878 }
879
880 #[test]
881 fn unresolved_items_are_reported_not_dropped() {
882 let mut proposal = proposal(complete_quarter_measure());
883 proposal.unresolved.push(OmrUnresolvedItem {
884 page_index: 0,
885 kind: "lyric".to_string(),
886 region: region(),
887 confidence: 0.8,
888 });
889 let plan = proposal.command_plan(&Score::default(), 0.9);
890 assert!(plan.commands.is_empty());
891 assert!(!plan.is_ready());
892 assert!(
893 plan.diagnostics
894 .iter()
895 .any(|diagnostic| diagnostic.kind == OmrDiagnosticKind::UnsupportedItem)
896 );
897 }
898
899 #[test]
900 fn invalid_contract_is_rejected_before_commands_are_built() {
901 let mut proposal = proposal(complete_quarter_measure());
902 proposal.contract_version = OMR_PROPOSAL_CONTRACT_VERSION + 1;
903 let plan = proposal.command_plan(&Score::default(), 0.9);
904 assert!(plan.commands.is_empty());
905 assert_eq!(
906 plan.diagnostics[0].kind,
907 OmrDiagnosticKind::UnsupportedContractVersion
908 );
909 }
910
911 #[test]
912 fn invalid_measure_rhythm_is_rejected_before_apply() {
913 let mut measure = complete_quarter_measure();
914 measure.events.pop();
915 let plan = proposal(measure).command_plan(&Score::default(), 0.9);
916 assert!(plan.commands.is_empty());
917 assert!(
918 plan.diagnostics
919 .iter()
920 .any(|diagnostic| diagnostic.kind == OmrDiagnosticKind::InvalidMeasureContent)
921 );
922 }
923
924 #[test]
925 fn serialized_proposal_requires_a_contract_version() {
926 let json = r#"{"pages":[],"measures":[],"unresolved":[]}"#;
927 assert!(serde_json::from_str::<OmrProposal>(json).is_err());
928 }
929}