Skip to main content

acorde_core/model/
omr.rs

1//! Provider-neutral optical music recognition (OMR) proposal contract.
2//!
3//! This module deliberately does not decode images, access the network, or invoke a recognition
4//! model. Those operations belong to a host such as Acorde Composer. A host converts its
5//! untrusted recognition output into an [`OmrProposal`], asks this module for a command plan, and
6//! presents the source regions and diagnostics for review before applying the plan to a
7//! [`crate::ScoreEngine`].
8
9use 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
20/// Current serialized OMR-proposal contract version.
21pub const OMR_PROPOSAL_CONTRACT_VERSION: u32 = 1;
22
23/// Bounded sizes accepted by the proposal-to-command boundary.
24pub 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/// A rectangle in normalized page coordinates, where each component is in `0.0..=1.0`.
30///
31/// Hosts retain the pixel image themselves. Normalized geometry keeps this contract independent
32/// of DPI and allows a review UI to map source locations back onto a resized image.
33#[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    /// Whether this is a finite, non-empty rectangle contained in its page.
43    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/// A page supplied by the host. `source_id` is an opaque host identifier, never a filesystem
58/// path or a URL interpreted by acorde.
59#[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/// The canonical score lane to replace after a complete recognition pass.
68#[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/// A recognized note or rest. Chords are represented by several pitches with one duration.
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78pub struct OmrRhythmicEvent {
79    #[serde(default)]
80    pub pitches: Vec<Pitch>,
81    /// Set for an unpitched percussion event; `pitches` then stores display placement only.
82    #[serde(default)]
83    pub is_unpitched: bool,
84    /// Optional source percussion/instrument identity attached to an unpitched event.
85    #[serde(default)]
86    pub instrument_id: Option<String>,
87    /// One tablature string/fret position per pitch. The initial local PNG recognizer does not
88    /// populate this, but an external TAB OMR provider may do so without losing the association.
89    #[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    /// Model confidence in the closed interval `0.0..=1.0`.
96    pub confidence: f32,
97}
98
99impl OmrRhythmicEvent {
100    /// Construct a rest event for a fully recognized measure.
101    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    /// Construct a pitched event for a fully recognized measure.
115    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/// A complete, reviewable voice measure proposed by an OMR host.
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173pub struct OmrMeasureProposal {
174    pub target: OmrMeasureTarget,
175    pub page_index: u32,
176    pub region: OmrRegion,
177    /// Incomplete measures are retained for review but never auto-converted to commands.
178    #[serde(default)]
179    pub complete: bool,
180    /// Aggregate confidence for the measure structure, independent of individual event scores.
181    pub confidence: f32,
182    #[serde(default)]
183    pub events: Vec<OmrRhythmicEvent>,
184}
185
186/// One note created by a recognized voice measure. An annotation target may only refer to an
187/// event in the same proposal, so a host cannot silently attach OMR text to unrelated score data.
188#[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/// A semantic note annotation supplied by an OMR provider after it has recognized the notation
195/// and text. Raw OCR strings belong in [`OmrUnresolvedItem`] until a host can map them to one of
196/// these canonical forms without guessing.
197#[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        /// Verse numbers start at one. `None` addresses verse one.
204        #[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/// A source-addressable annotation whose target is a note in a complete OMR measure proposal.
242#[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/// An item the recognizer saw but cannot yet map to acorde's canonical notation model.
252///
253/// This preserves review context without inventing semantics or silently dropping information.
254#[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/// Provider-neutral recognition result supplied by a host.
263#[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/// Typed reason why a proposal was withheld from automatic application.
289#[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/// A source-addressable diagnostic for a proposal. `index` is the index into the proposal list
309/// identified by the diagnostic kind (measure, annotation, or unresolved item), so a host can
310/// focus its review UI without reparsing image data.
311#[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/// Commands that may be applied through [`crate::ScoreEngine::batch_apply_labeled`] after a host
330/// has shown the associated diagnostics to the user. Any diagnostic clears `commands`, so a
331/// caller cannot accidentally apply only a subset of a proposal.
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct OmrCommandPlan {
334    pub commands: Vec<Command>,
335    pub diagnostics: Vec<OmrDiagnostic>,
336}
337
338impl OmrCommandPlan {
339    /// True only when every supplied item is represented by a command and no review is required.
340    pub fn is_ready(&self) -> bool {
341        self.diagnostics.is_empty() && !self.commands.is_empty()
342    }
343}
344
345impl OmrProposal {
346    /// Convert complete, high-confidence proposal measures into one undoable command per target.
347    ///
348    /// The method performs no image work and never mutates `score`. It rejects a partial or
349    /// ambiguous measure rather than guessing missing rhythm. The returned commands replace a
350    /// whole voice measure, preserving the command engine's rollback and validation boundary.
351    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}