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