Skip to main content

acorde_core/model/
validate.rs

1use std::collections::HashMap;
2use serde::{Deserialize, Serialize};
3use super::gm::instrument_range;
4use super::score::Score;
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 { part: pi, staff: si });
104                    } else {
105                        volta_numbers_seen.push(volta.number);
106                    }
107                }
108
109                let expected = current_ts.total_beats();
110                for (vi, voice) in measure.voices.iter().enumerate() {
111                    if voice.is_empty() {
112                        continue;
113                    }
114                    let non_rest_count: usize = voice.iter().filter(|n| !n.is_rest).count();
115                    if non_rest_count > 0 {
116                        part_has_notes = true;
117                    }
118                    let total: f64 = voice.iter().map(|n| n.beats()).sum();
119                    if total > expected + 0.02 {
120                        errors.push(ValidationError::BeatCount {
121                            part: pi,
122                            staff: si,
123                            measure: mi,
124                            voice: vi,
125                            expected_beats: expected,
126                            found_beats: total,
127                        });
128                    } else if total < expected - 0.02 && non_rest_count > 0 {
129                        warnings.push(ValidationWarning::IncompleteBar {
130                            part: pi, staff: si, measure: mi,
131                            expected_beats: expected, actual_beats: total,
132                        });
133                    }
134
135                    if !is_percussion {
136                        let transpose = staff.transpose_semitones;
137                        for (ni, note) in voice.iter().enumerate() {
138                            if note.is_rest || note.is_grace {
139                                continue;
140                            }
141                            for pitch in &note.pitches {
142                                let midi = (pitch.to_midi() + transpose as i16).clamp(0, 127) as u8;
143                                if midi < range.0 || midi > range.1 {
144                                    errors.push(ValidationError::OutOfRange {
145                                        part_index: pi,
146                                        staff_index: si,
147                                        measure_index: mi,
148                                        note_index: ni,
149                                        pitch_midi: midi,
150                                        instrument_range: range,
151                                    });
152                                }
153                            }
154                        }
155                    }
156                }
157            }
158        }
159
160        if !part_has_notes {
161            warnings.push(ValidationWarning::EmptyPart { part: pi });
162        }
163    }
164
165    for (mark, count) in &rehearsal_counts {
166        if *count > 1 {
167            warnings.push(ValidationWarning::DuplicateRehearsalMark { mark: mark.clone() });
168        }
169    }
170
171    ValidationReport { errors, warnings }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::model::{
178        duration::Duration,
179        pitch::{Pitch, Step},
180        score::{Note, Score},
181    };
182
183    #[test]
184    fn validate_clean_score_returns_empty_errors() {
185        let score = Score::new("T", 120, 4, 4, 0, 1);
186        assert!(validate(&score).errors.is_empty());
187    }
188
189    #[test]
190    fn validate_overfull_measure_returns_error() {
191        let mut score = Score::new("T", 120, 4, 4, 0, 1);
192        score.parts[0].staves[0].measures[0].voices[0]
193            .push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
194        let report = validate(&score);
195        assert!(!report.errors.is_empty());
196        assert!(matches!(report.errors[0], ValidationError::BeatCount { measure: 0, voice: 0, .. }));
197    }
198
199    #[test]
200    fn validate_skips_multi_rest() {
201        let mut score = Score::new("T", 120, 4, 4, 0, 1);
202        score.parts[0].staves[0].measures[0].multi_rest_count = Some(4);
203        score.parts[0].staves[0].measures[0].voices[0].clear();
204        assert!(validate(&score).errors.is_empty());
205    }
206
207    #[test]
208    fn validate_out_of_range_pitch_detected() {
209        // Piano (program 0): range 21–108. C9 (midi=120) is out of range.
210        let mut score = Score::new("T", 120, 4, 4, 0, 1);
211        score.parts[0].midi_program = 0;
212        score.parts[0].staves[0].measures[0].voices[0] =
213            vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
214        let report = validate(&score);
215        assert!(report.errors.iter().any(|e| matches!(e, ValidationError::OutOfRange { pitch_midi, .. } if *pitch_midi == 120)));
216    }
217
218    #[test]
219    fn validate_percussion_channel_skips_range_check() {
220        // Channel 9 = percussion; even extreme pitches should not trigger OutOfRange.
221        let mut score = Score::new("T", 120, 4, 4, 0, 1);
222        score.parts[0].midi_channel = 9;
223        score.parts[0].midi_program = 0;
224        score.parts[0].staves[0].measures[0].voices[0] =
225            vec![Note::new(Pitch::new(Step::C, 9), Duration::Whole)];
226        let report = validate(&score);
227        assert!(!report.errors.iter().any(|e| matches!(e, ValidationError::OutOfRange { .. })));
228    }
229
230    #[test]
231    fn validate_in_range_pitch_ok() {
232        // Piano C4 (midi=60) is in range 21–108.
233        let mut score = Score::new("T", 120, 4, 4, 0, 1);
234        score.parts[0].midi_program = 0;
235        score.parts[0].staves[0].measures[0].voices[0] =
236            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
237        assert!(!validate(&score).errors.iter().any(|e| matches!(e, ValidationError::OutOfRange { .. })));
238    }
239
240    #[test]
241    fn validate_empty_part_warning() {
242        let score = Score::new("T", 120, 4, 4, 0, 1);
243        let report = validate(&score);
244        assert!(report.warnings.iter().any(|w| matches!(w, ValidationWarning::EmptyPart { part: 0 })));
245    }
246
247    #[test]
248    fn validate_duplicate_rehearsal_mark_warning() {
249        use crate::model::score::Score;
250        let mut score = Score::new("T", 120, 4, 4, 0, 2);
251        score.parts[0].staves[0].measures[0].rehearsal = Some("A".to_string());
252        score.parts[0].staves[0].measures[1].rehearsal = Some("A".to_string());
253        let report = validate(&score);
254        assert!(report.warnings.iter().any(|w| matches!(w, ValidationWarning::DuplicateRehearsalMark { mark } if mark == "A")));
255    }
256}