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