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    /// Beat-count mismatch: the notes in a voice don't fill the time signature.
10    BeatCount {
11        part: usize,
12        staff: usize,
13        measure: usize,
14        voice: usize,
15        expected_beats: f64,
16        found_beats: f64,
17    },
18    /// A note pitch lies outside the practical range for the part's GM instrument.
19    OutOfRange {
20        part_index: usize,
21        staff_index: usize,
22        measure_index: usize,
23        note_index: usize,
24        pitch_midi: u8,
25        instrument_range: (u8, u8),
26    },
27}
28
29/// A non-fatal advisory warning found by [`validate`].
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub enum ValidationWarning {
32    /// A measure's beat count is less than the time signature (incomplete bar).
33    IncompleteBar {
34        part: usize,
35        staff: usize,
36        measure: usize,
37        expected_beats: f64,
38        actual_beats: f64,
39    },
40    /// Two volta brackets in the same staff overlap or share the same number.
41    OverlappingVolta { part: usize, staff: usize },
42    /// A part has no notes across all measures.
43    EmptyPart { part: usize },
44    /// The same rehearsal mark text appears more than once.
45    DuplicateRehearsalMark { mark: String },
46}
47
48/// Combined result of [`validate`]: errors that indicate broken structure, plus advisory warnings.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ValidationReport {
51    pub errors: Vec<ValidationError>,
52    pub warnings: Vec<ValidationWarning>,
53}
54
55impl ValidationReport {
56    /// `true` when there are no errors (warnings may still be present).
57    pub fn is_valid(&self) -> bool {
58        self.errors.is_empty()
59    }
60}
61
62/// Check every voice in every measure for structural correctness.
63///
64/// Checks performed:
65/// - **Errors**: beat-count mismatch, out-of-range pitch.
66/// - **Warnings**: incomplete bar (underfull voice), overlapping volta brackets,
67///   empty parts, duplicate rehearsal marks.
68///
69/// Multi-rest placeholder measures and empty voices are skipped.
70/// Percussion parts (MIDI channel 9) are exempt from pitch-range checks.
71pub fn validate(score: &Score) -> ValidationReport {
72    let mut errors = Vec::new();
73    let mut warnings = Vec::new();
74
75    let mut rehearsal_counts: HashMap<String, usize> = HashMap::new();
76
77    for (pi, part) in score.parts.iter().enumerate() {
78        let range = instrument_range(part.midi_program);
79        let is_percussion = part.midi_channel == 9;
80        let mut part_has_notes = false;
81
82        for (si, staff) in part.staves.iter().enumerate() {
83            let mut current_ts = score.settings.time_signature.clone();
84            let mut volta_numbers_seen: Vec<u8> = Vec::new();
85
86            for (mi, measure) in staff.measures.iter().enumerate() {
87                if let Some(ts) = &measure.time_sig {
88                    current_ts = ts.clone();
89                }
90                if measure.multi_rest_count.is_some() {
91                    continue;
92                }
93
94                // Rehearsal mark deduplication
95                if let Some(ref mark) = measure.rehearsal {
96                    let entry = rehearsal_counts.entry(mark.clone()).or_insert(0);
97                    *entry += 1;
98                }
99
100                // Volta overlap detection
101                if let Some(ref volta) = measure.volta {
102                    if volta_numbers_seen.contains(&volta.number) {
103                        warnings.push(ValidationWarning::OverlappingVolta {
104                            part: pi,
105                            staff: si,
106                        });
107                    } else {
108                        volta_numbers_seen.push(volta.number);
109                    }
110                }
111
112                let expected = current_ts.total_beats();
113                for (vi, voice) in measure.voices.iter().enumerate() {
114                    if voice.is_empty() {
115                        continue;
116                    }
117                    let non_rest_count: usize = voice.iter().filter(|n| !n.is_rest).count();
118                    if non_rest_count > 0 {
119                        part_has_notes = true;
120                    }
121                    let total: f64 = voice.iter().map(|n| n.beats()).sum();
122                    if total > expected + 0.02 {
123                        errors.push(ValidationError::BeatCount {
124                            part: pi,
125                            staff: si,
126                            measure: mi,
127                            voice: vi,
128                            expected_beats: expected,
129                            found_beats: total,
130                        });
131                    } else if total < expected - 0.02 && non_rest_count > 0 {
132                        warnings.push(ValidationWarning::IncompleteBar {
133                            part: pi,
134                            staff: si,
135                            measure: mi,
136                            expected_beats: expected,
137                            actual_beats: total,
138                        });
139                    }
140
141                    if !is_percussion {
142                        let transpose = staff.transpose_semitones;
143                        for (ni, note) in voice.iter().enumerate() {
144                            if note.is_rest || note.is_grace {
145                                continue;
146                            }
147                            for pitch in &note.pitches {
148                                let midi = (pitch.to_midi() + transpose as i16).clamp(0, 127) as u8;
149                                if midi < range.0 || midi > range.1 {
150                                    errors.push(ValidationError::OutOfRange {
151                                        part_index: pi,
152                                        staff_index: si,
153                                        measure_index: mi,
154                                        note_index: ni,
155                                        pitch_midi: midi,
156                                        instrument_range: range,
157                                    });
158                                }
159                            }
160                        }
161                    }
162                }
163            }
164        }
165
166        if !part_has_notes {
167            warnings.push(ValidationWarning::EmptyPart { part: pi });
168        }
169    }
170
171    for (mark, count) in &rehearsal_counts {
172        if *count > 1 {
173            warnings.push(ValidationWarning::DuplicateRehearsalMark { mark: mark.clone() });
174        }
175    }
176
177    ValidationReport { errors, warnings }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::model::{
184        duration::Duration,
185        pitch::{Pitch, Step},
186        score::{Note, Score},
187    };
188
189    #[test]
190    fn validate_clean_score_returns_empty_errors() {
191        let score = Score::new("T", 120, 4, 4, 0, 1);
192        assert!(validate(&score).errors.is_empty());
193    }
194
195    #[test]
196    fn validate_overfull_measure_returns_error() {
197        let mut score = Score::new("T", 120, 4, 4, 0, 1);
198        score.parts[0].staves[0].measures[0].voices[0]
199            .push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
200        let report = validate(&score);
201        assert!(!report.errors.is_empty());
202        assert!(matches!(
203            report.errors[0],
204            ValidationError::BeatCount {
205                measure: 0,
206                voice: 0,
207                ..
208            }
209        ));
210    }
211
212    #[test]
213    fn validate_skips_multi_rest() {
214        let mut score = Score::new("T", 120, 4, 4, 0, 1);
215        score.parts[0].staves[0].measures[0].multi_rest_count = Some(4);
216        score.parts[0].staves[0].measures[0].voices[0].clear();
217        assert!(validate(&score).errors.is_empty());
218    }
219
220    #[test]
221    fn validate_out_of_range_pitch_detected() {
222        // Piano (program 0): range 21–108. C9 (midi=120) is out of range.
223        let mut score = Score::new("T", 120, 4, 4, 0, 1);
224        score.parts[0].midi_program = 0;
225        score.parts[0].staves[0].measures[0].voices[0] =
226            vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
227        let report = validate(&score);
228        assert!(report.errors.iter().any(
229            |e| matches!(e, ValidationError::OutOfRange { pitch_midi, .. } if *pitch_midi == 120)
230        ));
231    }
232
233    #[test]
234    fn validate_percussion_channel_skips_range_check() {
235        // Channel 9 = percussion; even extreme pitches should not trigger OutOfRange.
236        let mut score = Score::new("T", 120, 4, 4, 0, 1);
237        score.parts[0].midi_channel = 9;
238        score.parts[0].midi_program = 0;
239        score.parts[0].staves[0].measures[0].voices[0] =
240            vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
241        let report = validate(&score);
242        assert!(
243            !report
244                .errors
245                .iter()
246                .any(|e| matches!(e, ValidationError::OutOfRange { .. }))
247        );
248    }
249
250    #[test]
251    fn validate_in_range_pitch_ok() {
252        // Piano C4 (midi=60) is in range 21–108.
253        let mut score = Score::new("T", 120, 4, 4, 0, 1);
254        score.parts[0].midi_program = 0;
255        score.parts[0].staves[0].measures[0].voices[0] =
256            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
257        assert!(
258            !validate(&score)
259                .errors
260                .iter()
261                .any(|e| matches!(e, ValidationError::OutOfRange { .. }))
262        );
263    }
264
265    #[test]
266    fn validate_empty_part_warning() {
267        let score = Score::new("T", 120, 4, 4, 0, 1);
268        let report = validate(&score);
269        assert!(
270            report
271                .warnings
272                .iter()
273                .any(|w| matches!(w, ValidationWarning::EmptyPart { part: 0 }))
274        );
275    }
276
277    #[test]
278    fn validate_duplicate_rehearsal_mark_warning() {
279        use crate::model::score::Score;
280        let mut score = Score::new("T", 120, 4, 4, 0, 2);
281        score.parts[0].staves[0].measures[0].rehearsal = Some("A".to_string());
282        score.parts[0].staves[0].measures[1].rehearsal = Some("A".to_string());
283        let report = validate(&score);
284        assert!(report.warnings.iter().any(
285            |w| matches!(w, ValidationWarning::DuplicateRehearsalMark { mark } if mark == "A")
286        ));
287    }
288}