Skip to main content

acorde_core/model/
validate.rs

1use super::gm::instrument_range;
2use super::score::Score;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// A structural error found by [`validate`].
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub enum ValidationError {
9    /// The score has no parts to validate.
10    EmptyScore,
11    /// A part has no staves.
12    PartWithoutStaves { part: usize },
13    /// A staff has no measures.
14    StaffWithoutMeasures { part: usize, staff: usize },
15    /// Staves in one part do not cover the same number of measures.
16    MeasureCountMismatch {
17        part: usize,
18        staff: usize,
19        expected: usize,
20        found: usize,
21    },
22    /// A time signature has an unsupported numerator or denominator.
23    InvalidTimeSignature {
24        part: usize,
25        staff: usize,
26        measure: usize,
27        numerator: u8,
28        denominator: u8,
29    },
30    /// Beat-count mismatch: the notes in a voice don't fill the time signature.
31    BeatCount {
32        part: usize,
33        staff: usize,
34        measure: usize,
35        voice: usize,
36        expected_beats: f64,
37        found_beats: f64,
38    },
39    /// A note pitch lies outside the practical range for the part's GM instrument.
40    OutOfRange {
41        part_index: usize,
42        staff_index: usize,
43        measure_index: usize,
44        note_index: usize,
45        pitch_midi: u8,
46        instrument_range: (u8, u8),
47    },
48    /// Tablature staff metadata is internally inconsistent.
49    InvalidTablature {
50        part: usize,
51        staff: usize,
52        reason: TablatureValidationReason,
53    },
54    /// A note's explicit string is not present on its tablature staff.
55    TabPositionOutOfRange {
56        part: usize,
57        staff: usize,
58        measure: usize,
59        voice: usize,
60        note: usize,
61        string: u8,
62        lines: u8,
63    },
64    /// A pitch's microtonal cents component is outside the canonical -99..99 range.
65    MicrotoneOutOfRange {
66        part: usize,
67        staff: usize,
68        measure: usize,
69        voice: usize,
70        note: usize,
71        pitch: usize,
72        microtone_cents: i16,
73    },
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub enum TablatureValidationReason {
78    InvalidLineCount { lines: u8 },
79    TooManyTunings { tuning_count: usize, lines: u8 },
80    TuningOutOfMidiRange { index: usize, midi: i16 },
81}
82
83/// A non-fatal advisory warning found by [`validate`].
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub enum ValidationWarning {
86    /// A measure's beat count is less than the time signature (incomplete bar).
87    IncompleteBar {
88        part: usize,
89        staff: usize,
90        measure: usize,
91        expected_beats: f64,
92        actual_beats: f64,
93    },
94    /// Two volta brackets in the same staff overlap or share the same number.
95    OverlappingVolta { part: usize, staff: usize },
96    /// A part has no notes across all measures.
97    EmptyPart { part: usize },
98    /// The same rehearsal mark text appears more than once.
99    DuplicateRehearsalMark { mark: String },
100}
101
102/// Combined result of [`validate`]: errors that indicate broken structure, plus advisory warnings.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ValidationReport {
105    pub errors: Vec<ValidationError>,
106    pub warnings: Vec<ValidationWarning>,
107}
108
109impl ValidationReport {
110    /// `true` when there are no errors (warnings may still be present).
111    pub fn is_valid(&self) -> bool {
112        self.errors.is_empty()
113    }
114}
115
116/// Check every voice in every measure for structural correctness.
117///
118/// Checks performed:
119/// - **Errors**: beat-count mismatch, out-of-range pitch.
120/// - **Warnings**: incomplete bar (underfull voice), overlapping volta brackets,
121///   empty parts, duplicate rehearsal marks.
122///
123/// Multi-rest placeholder measures and empty voices are skipped.
124/// Percussion parts (MIDI channel 9) are exempt from pitch-range checks.
125pub fn validate(score: &Score) -> ValidationReport {
126    let mut errors = Vec::new();
127    let mut warnings = Vec::new();
128
129    let mut rehearsal_counts: HashMap<String, usize> = HashMap::new();
130
131    if score.parts.is_empty() {
132        errors.push(ValidationError::EmptyScore);
133    }
134
135    for (pi, part) in score.parts.iter().enumerate() {
136        let range = instrument_range(part.midi_program);
137        let is_percussion = part.midi_channel == 9;
138        let mut part_has_notes = false;
139
140        if part.staves.is_empty() {
141            errors.push(ValidationError::PartWithoutStaves { part: pi });
142            continue;
143        }
144
145        let expected_measure_count = part.staves[0].measures.len();
146
147        for (si, staff) in part.staves.iter().enumerate() {
148            if staff.measures.is_empty() {
149                errors.push(ValidationError::StaffWithoutMeasures {
150                    part: pi,
151                    staff: si,
152                });
153                continue;
154            }
155            if staff.measures.len() != expected_measure_count {
156                errors.push(ValidationError::MeasureCountMismatch {
157                    part: pi,
158                    staff: si,
159                    expected: expected_measure_count,
160                    found: staff.measures.len(),
161                });
162            }
163
164            if let Some(tab) = &staff.tablature {
165                if !(1..=64).contains(&tab.lines) {
166                    errors.push(ValidationError::InvalidTablature {
167                        part: pi,
168                        staff: si,
169                        reason: TablatureValidationReason::InvalidLineCount { lines: tab.lines },
170                    });
171                } else if tab.tuning_midi.len() > usize::from(tab.lines) {
172                    errors.push(ValidationError::InvalidTablature {
173                        part: pi,
174                        staff: si,
175                        reason: TablatureValidationReason::TooManyTunings {
176                            tuning_count: tab.tuning_midi.len(),
177                            lines: tab.lines,
178                        },
179                    });
180                }
181                for (index, &midi) in tab.tuning_midi.iter().enumerate() {
182                    if !(0..=127).contains(&midi) {
183                        errors.push(ValidationError::InvalidTablature {
184                            part: pi,
185                            staff: si,
186                            reason: TablatureValidationReason::TuningOutOfMidiRange { index, midi },
187                        });
188                    }
189                }
190            }
191
192            let mut current_ts = score.settings.time_signature.clone();
193            let mut volta_numbers_seen: Vec<u8> = Vec::new();
194
195            for (mi, measure) in staff.measures.iter().enumerate() {
196                if let Some(ts) = &measure.time_sig {
197                    current_ts = ts.clone();
198                }
199                if !valid_time_signature(&current_ts) {
200                    errors.push(ValidationError::InvalidTimeSignature {
201                        part: pi,
202                        staff: si,
203                        measure: mi,
204                        numerator: current_ts.numerator,
205                        denominator: current_ts.denominator,
206                    });
207                    continue;
208                }
209                if measure.multi_rest_count.is_some() {
210                    continue;
211                }
212
213                // Rehearsal mark deduplication
214                if let Some(ref mark) = measure.rehearsal {
215                    let entry = rehearsal_counts.entry(mark.clone()).or_insert(0);
216                    *entry += 1;
217                }
218
219                // Volta overlap detection
220                if let Some(ref volta) = measure.volta {
221                    if volta_numbers_seen.contains(&volta.number) {
222                        warnings.push(ValidationWarning::OverlappingVolta {
223                            part: pi,
224                            staff: si,
225                        });
226                    } else {
227                        volta_numbers_seen.push(volta.number);
228                    }
229                }
230
231                let expected = current_ts.total_beats();
232                for (vi, voice) in measure.voices.iter().enumerate() {
233                    if voice.is_empty() {
234                        continue;
235                    }
236                    let non_rest_count: usize = voice.iter().filter(|n| !n.is_rest).count();
237                    if non_rest_count > 0 {
238                        part_has_notes = true;
239                    }
240                    let total: f64 = voice.iter().map(|n| n.beats()).sum();
241                    if total > expected + 0.02 {
242                        errors.push(ValidationError::BeatCount {
243                            part: pi,
244                            staff: si,
245                            measure: mi,
246                            voice: vi,
247                            expected_beats: expected,
248                            found_beats: total,
249                        });
250                    } else if total < expected - 0.02 && non_rest_count > 0 {
251                        warnings.push(ValidationWarning::IncompleteBar {
252                            part: pi,
253                            staff: si,
254                            measure: mi,
255                            expected_beats: expected,
256                            actual_beats: total,
257                        });
258                    }
259
260                    for (ni, note) in voice.iter().enumerate() {
261                        if note.is_rest || note.is_grace {
262                            continue;
263                        }
264                        for (pitch_index, pitch) in note.pitches.iter().enumerate() {
265                            if !(-99..=99).contains(&pitch.microtone_cents) {
266                                errors.push(ValidationError::MicrotoneOutOfRange {
267                                    part: pi,
268                                    staff: si,
269                                    measure: mi,
270                                    voice: vi,
271                                    note: ni,
272                                    pitch: pitch_index,
273                                    microtone_cents: pitch.microtone_cents,
274                                });
275                            }
276                        }
277                    }
278
279                    if !is_percussion {
280                        let transpose = staff.transpose_semitones;
281                        for (ni, note) in voice.iter().enumerate() {
282                            if note.is_rest || note.is_grace {
283                                continue;
284                            }
285                            if let Some(tab) = &staff.tablature {
286                                let positions =
287                                    note.tab_position.iter().chain(note.tab_positions.iter());
288                                for position in positions {
289                                    if position.string == 0 || position.string > tab.lines {
290                                        errors.push(ValidationError::TabPositionOutOfRange {
291                                            part: pi,
292                                            staff: si,
293                                            measure: mi,
294                                            voice: vi,
295                                            note: ni,
296                                            string: position.string,
297                                            lines: tab.lines,
298                                        });
299                                    }
300                                }
301                            }
302                            for pitch in &note.pitches {
303                                let midi = (pitch.to_midi() + transpose as i16).clamp(0, 127) as u8;
304                                if midi < range.0 || midi > range.1 {
305                                    errors.push(ValidationError::OutOfRange {
306                                        part_index: pi,
307                                        staff_index: si,
308                                        measure_index: mi,
309                                        note_index: ni,
310                                        pitch_midi: midi,
311                                        instrument_range: range,
312                                    });
313                                }
314                            }
315                        }
316                    }
317                }
318            }
319        }
320
321        if !part_has_notes {
322            warnings.push(ValidationWarning::EmptyPart { part: pi });
323        }
324    }
325
326    for (mark, count) in &rehearsal_counts {
327        if *count > 1 {
328            warnings.push(ValidationWarning::DuplicateRehearsalMark { mark: mark.clone() });
329        }
330    }
331
332    ValidationReport { errors, warnings }
333}
334
335fn valid_time_signature(time: &super::notation::TimeSignature) -> bool {
336    time.numerator > 0 && matches!(time.denominator, 1 | 2 | 4 | 8 | 16 | 32 | 64)
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::model::{
343        duration::Duration,
344        pitch::{Pitch, Step},
345        score::{Note, Score},
346    };
347
348    #[test]
349    fn validate_clean_score_returns_empty_errors() {
350        let score = Score::new("T", 120, 4, 4, 0, 1);
351        assert!(validate(&score).errors.is_empty());
352    }
353
354    #[test]
355    fn validate_empty_score_returns_structural_error() {
356        let mut score = Score::new("T", 120, 4, 4, 0, 1);
357        score.parts.clear();
358        let report = validate(&score);
359        assert!(
360            report
361                .errors
362                .iter()
363                .any(|error| matches!(error, ValidationError::EmptyScore))
364        );
365    }
366
367    #[test]
368    fn validate_detects_missing_staves_and_measures() {
369        let mut score = Score::new("T", 120, 4, 4, 0, 1);
370        score.parts[0].staves.clear();
371        let report = validate(&score);
372        assert!(
373            report
374                .errors
375                .iter()
376                .any(|error| matches!(error, ValidationError::PartWithoutStaves { part: 0 }))
377        );
378
379        score.parts[0].staves.push(crate::model::score::Staff::new(
380            crate::model::notation::Clef::Treble,
381        ));
382        let report = validate(&score);
383        assert!(report.errors.iter().any(|error| matches!(
384            error,
385            ValidationError::StaffWithoutMeasures { part: 0, staff: 0 }
386        )));
387    }
388
389    #[test]
390    fn validate_detects_staff_measure_count_mismatch() {
391        let mut score = Score::template(crate::model::score::ScoreTemplate::Piano);
392        score.parts[0].staves[1].measures.pop();
393        let report = validate(&score);
394        assert!(report.errors.iter().any(|error| matches!(
395            error,
396            ValidationError::MeasureCountMismatch {
397                part: 0,
398                staff: 1,
399                expected: 4,
400                found: 3
401            }
402        )));
403    }
404
405    #[test]
406    fn validate_detects_invalid_time_signature() {
407        let mut score = Score::new("T", 120, 4, 4, 0, 1);
408        score.parts[0].staves[0].measures[0].time_sig =
409            Some(crate::model::notation::TimeSignature {
410                numerator: 0,
411                denominator: 3,
412            });
413        let report = validate(&score);
414        assert!(report.errors.iter().any(|error| matches!(
415            error,
416            ValidationError::InvalidTimeSignature {
417                part: 0,
418                staff: 0,
419                measure: 0,
420                numerator: 0,
421                denominator: 3
422            }
423        )));
424    }
425
426    #[test]
427    fn validate_overfull_measure_returns_error() {
428        let mut score = Score::new("T", 120, 4, 4, 0, 1);
429        score.parts[0].staves[0].measures[0].voices[0]
430            .push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
431        let report = validate(&score);
432        assert!(!report.errors.is_empty());
433        assert!(matches!(
434            report.errors[0],
435            ValidationError::BeatCount {
436                measure: 0,
437                voice: 0,
438                ..
439            }
440        ));
441    }
442
443    #[test]
444    fn validate_skips_multi_rest() {
445        let mut score = Score::new("T", 120, 4, 4, 0, 1);
446        score.parts[0].staves[0].measures[0].multi_rest_count = Some(4);
447        score.parts[0].staves[0].measures[0].voices[0].clear();
448        assert!(validate(&score).errors.is_empty());
449    }
450
451    #[test]
452    fn validate_out_of_range_pitch_detected() {
453        // Piano (program 0): range 21–108. C9 (midi=120) is out of range.
454        let mut score = Score::new("T", 120, 4, 4, 0, 1);
455        score.parts[0].midi_program = 0;
456        score.parts[0].staves[0].measures[0].voices[0] =
457            vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
458        let report = validate(&score);
459        assert!(report.errors.iter().any(
460            |e| matches!(e, ValidationError::OutOfRange { pitch_midi, .. } if *pitch_midi == 120)
461        ));
462    }
463
464    #[test]
465    fn validate_percussion_channel_skips_range_check() {
466        // Channel 9 = percussion; even extreme pitches should not trigger OutOfRange.
467        let mut score = Score::new("T", 120, 4, 4, 0, 1);
468        score.parts[0].midi_channel = 9;
469        score.parts[0].midi_program = 0;
470        score.parts[0].staves[0].measures[0].voices[0] =
471            vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
472        let report = validate(&score);
473        assert!(
474            !report
475                .errors
476                .iter()
477                .any(|e| matches!(e, ValidationError::OutOfRange { .. }))
478        );
479    }
480
481    #[test]
482    fn validate_in_range_pitch_ok() {
483        // Piano C4 (midi=60) is in range 21–108.
484        let mut score = Score::new("T", 120, 4, 4, 0, 1);
485        score.parts[0].midi_program = 0;
486        score.parts[0].staves[0].measures[0].voices[0] =
487            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
488        assert!(
489            !validate(&score)
490                .errors
491                .iter()
492                .any(|e| matches!(e, ValidationError::OutOfRange { .. }))
493        );
494    }
495
496    #[test]
497    fn validate_rejects_deserialized_microtone_out_of_range() {
498        let mut score = Score::new("T", 120, 4, 4, 0, 1);
499        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
500        note.pitches[0].microtone_cents = 100;
501        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
502        let report = validate(&score);
503        assert!(report.errors.iter().any(|error| matches!(
504            error,
505            ValidationError::MicrotoneOutOfRange {
506                microtone_cents: 100,
507                ..
508            }
509        )));
510    }
511
512    #[test]
513    fn validate_rejects_invalid_tablature_metadata_and_positions() {
514        let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
515        score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
516            lines: 6,
517            tuning_midi: vec![64, 59, 55, 50, 45, 40, 35],
518            capo: 0,
519        });
520        let mut note = Note::new(Pitch::new(Step::E, 4), Duration::Whole);
521        note.tab_position = Some(super::super::notation::TabPosition { string: 7, fret: 0 });
522        note.tab_positions = vec![super::super::notation::TabPosition { string: 8, fret: 3 }];
523        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
524
525        let report = validate(&score);
526        assert!(report.errors.iter().any(|error| matches!(
527            error,
528            ValidationError::InvalidTablature {
529                reason: TablatureValidationReason::TooManyTunings { .. },
530                ..
531            }
532        )));
533        assert!(
534            report
535                .errors
536                .iter()
537                .any(|error| matches!(error, ValidationError::TabPositionOutOfRange { .. }))
538        );
539        assert_eq!(
540            report
541                .errors
542                .iter()
543                .filter(|error| matches!(error, ValidationError::TabPositionOutOfRange { .. }))
544                .count(),
545            2
546        );
547    }
548
549    #[test]
550    fn validate_empty_part_warning() {
551        let score = Score::new("T", 120, 4, 4, 0, 1);
552        let report = validate(&score);
553        assert!(
554            report
555                .warnings
556                .iter()
557                .any(|w| matches!(w, ValidationWarning::EmptyPart { part: 0 }))
558        );
559    }
560
561    #[test]
562    fn validate_duplicate_rehearsal_mark_warning() {
563        use crate::model::score::Score;
564        let mut score = Score::new("T", 120, 4, 4, 0, 2);
565        score.parts[0].staves[0].measures[0].rehearsal = Some("A".to_string());
566        score.parts[0].staves[0].measures[1].rehearsal = Some("A".to_string());
567        let report = validate(&score);
568        assert!(report.warnings.iter().any(
569            |w| matches!(w, ValidationWarning::DuplicateRehearsalMark { mark } if mark == "A")
570        ));
571    }
572}