sim_lib_music_consonance/
constraints.rs1use std::collections::BTreeSet;
2
3use sim_lib_music_core::{Articulation, ObjectId, Pitch, Staff, StaffNote};
4use thiserror::Error;
5
6use crate::{
7 Addition, AdditionKind, ConsonanceReport, MetricReport, PatchError, TimeSpan, apply_patch,
8};
9
10#[derive(Copy, Clone, Debug, PartialEq, Eq)]
12pub enum MetricFamily {
13 Pitch,
15 Acoustic,
17 Ratio,
19 Commonality,
21 Leading,
23}
24
25#[derive(Clone, Debug, Default, PartialEq)]
27pub struct MetricBounds {
28 pub max_roughness_mass: Option<f64>,
30 pub max_normalized_density: Option<f64>,
32 pub min_harmonic_context: Option<f64>,
34 pub max_harmonic_context: Option<f64>,
36}
37
38#[derive(Clone, Debug, PartialEq)]
40pub struct MetricThreshold {
41 pub family: MetricFamily,
43 pub model: String,
45 pub span: Option<TimeSpan>,
47 pub bounds: MetricBounds,
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct PitchRangeConstraint {
54 pub voice_id: Option<ObjectId>,
56 pub lowest: Pitch,
58 pub highest: Pitch,
60}
61
62#[derive(Clone, Debug, Default, PartialEq, Eq)]
64pub struct PreservationConstraints {
65 pub required_ids: Vec<ObjectId>,
70 pub protected_spans: Vec<TimeSpan>,
72}
73
74#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct StyleConstraints {
77 pub allowed_kinds: BTreeSet<AdditionKind>,
79 pub min_additions: usize,
81 pub max_additions: Option<usize>,
83 pub max_added_notes: Option<usize>,
85 pub max_new_voices: Option<usize>,
87 pub max_simultaneous_added_notes: Option<usize>,
89 pub allowed_articulations: Vec<Articulation>,
91}
92
93impl Default for StyleConstraints {
94 fn default() -> Self {
95 Self {
96 allowed_kinds: [
97 AdditionKind::Note,
98 AdditionKind::Ornament,
99 AdditionKind::Chord,
100 AdditionKind::Pedal,
101 AdditionKind::Doubling,
102 AdditionKind::Voice,
103 ]
104 .into_iter()
105 .collect(),
106 min_additions: 0,
107 max_additions: None,
108 max_added_notes: None,
109 max_new_voices: None,
110 max_simultaneous_added_notes: None,
111 allowed_articulations: Vec::new(),
112 }
113 }
114}
115
116#[derive(Clone, Debug, Default, PartialEq)]
118pub struct CompletionConstraints {
119 pub thresholds: Vec<MetricThreshold>,
121 pub preservation: PreservationConstraints,
123 pub ranges: Vec<PitchRangeConstraint>,
125 pub style: StyleConstraints,
127}
128
129#[derive(Clone, Debug, Error, PartialEq)]
131pub enum ConstraintError {
132 #[error("invalid completion constraint: {0}")]
134 Invalid(String),
135 #[error(transparent)]
137 Patch(#[from] PatchError),
138}
139
140impl CompletionConstraints {
141 pub(crate) fn validate(&self, source: &Staff) -> Result<(), ConstraintError> {
142 let source_ids = source.object_ids().into_iter().collect::<BTreeSet<_>>();
143 if let Some(id) = self
144 .preservation
145 .required_ids
146 .iter()
147 .find(|id| !source_ids.contains(*id))
148 {
149 return invalid(format!("required source identity {id} does not exist"));
150 }
151 for range in &self.ranges {
152 if range.lowest.semitone() > range.highest.semitone() {
153 return invalid("pitch range lower bound exceeds its upper bound");
154 }
155 }
156 if self
157 .style
158 .max_additions
159 .is_some_and(|limit| limit < self.style.min_additions)
160 {
161 return invalid("maximum additions is below minimum additions");
162 }
163 for threshold in &self.thresholds {
164 if threshold.model.trim().is_empty() {
165 return invalid("metric threshold model cannot be empty");
166 }
167 for value in [
168 threshold.bounds.max_roughness_mass,
169 threshold.bounds.max_normalized_density,
170 threshold.bounds.min_harmonic_context,
171 threshold.bounds.max_harmonic_context,
172 ]
173 .into_iter()
174 .flatten()
175 {
176 if !value.is_finite() {
177 return invalid("metric threshold values must be finite");
178 }
179 }
180 if threshold
181 .bounds
182 .min_harmonic_context
183 .zip(threshold.bounds.max_harmonic_context)
184 .is_some_and(|(minimum, maximum)| minimum > maximum)
185 {
186 return invalid("harmonic-context minimum exceeds its maximum");
187 }
188 }
189 Ok(())
190 }
191
192 pub(crate) fn accepts_partial(
193 &self,
194 source: &Staff,
195 additions: &[Addition],
196 ) -> Result<bool, ConstraintError> {
197 if self
198 .style
199 .max_additions
200 .is_some_and(|limit| additions.len() > limit)
201 || additions
202 .iter()
203 .any(|addition| !self.style.allowed_kinds.contains(&addition.kind()))
204 {
205 return Ok(false);
206 }
207 let notes = addition_notes(additions);
208 if self
209 .style
210 .max_added_notes
211 .is_some_and(|limit| notes.len() > limit)
212 || self
213 .style
214 .max_new_voices
215 .is_some_and(|limit| new_voice_count(additions) > limit)
216 || self
217 .style
218 .max_simultaneous_added_notes
219 .is_some_and(|limit| maximum_simultaneous(¬es) > limit)
220 {
221 return Ok(false);
222 }
223 if !self.style.allowed_articulations.is_empty()
224 && notes.iter().any(|note| {
225 !self
226 .style
227 .allowed_articulations
228 .contains(¬e.note.articulation)
229 })
230 {
231 return Ok(false);
232 }
233 if notes.iter().any(|note| !self.note_is_in_range(note)) {
234 return Ok(false);
235 }
236 if notes.iter().any(|note| {
237 self.preservation
238 .protected_spans
239 .iter()
240 .any(|span| overlaps_note(span, note))
241 }) {
242 return Ok(false);
243 }
244 let patch = crate::ConsonancePatch::new(source, additions.to_vec())?;
245 apply_patch(source, &patch)?;
246 Ok(true)
247 }
248
249 pub(crate) fn accepts_complete(
250 &self,
251 source: &Staff,
252 additions: &[Addition],
253 report: &ConsonanceReport,
254 ) -> Result<bool, ConstraintError> {
255 if additions.len() < self.style.min_additions || !self.accepts_partial(source, additions)? {
256 return Ok(false);
257 }
258 Ok(self
259 .thresholds
260 .iter()
261 .all(|threshold| threshold_accepts(threshold, report)))
262 }
263
264 fn note_is_in_range(&self, note: &StaffNote) -> bool {
265 self.ranges
266 .iter()
267 .filter(|range| {
268 range
269 .voice_id
270 .as_ref()
271 .is_none_or(|voice| voice == ¬e.voice_id)
272 })
273 .all(|range| {
274 (range.lowest.semitone()..=range.highest.semitone())
275 .contains(¬e.note.pitch.semitone())
276 })
277 }
278}
279
280fn threshold_accepts(threshold: &MetricThreshold, report: &ConsonanceReport) -> bool {
281 let mut matched = false;
282 for window in &report.windows {
283 if threshold
284 .span
285 .as_ref()
286 .is_some_and(|span| !spans_overlap(span, &window.window.span))
287 {
288 continue;
289 }
290 let metric = match threshold.family {
291 MetricFamily::Pitch => window
292 .pitch
293 .iter()
294 .find(|metric| metric.model == threshold.model),
295 MetricFamily::Acoustic => window
296 .acoustic
297 .iter()
298 .find(|metric| metric.model == threshold.model),
299 MetricFamily::Ratio => named_metric(&window.ratio, &threshold.model),
300 MetricFamily::Commonality => named_metric(&window.commonality, &threshold.model),
301 MetricFamily::Leading => named_metric(&window.leading, &threshold.model),
302 };
303 let Some(metric) = metric else {
304 return false;
305 };
306 matched = true;
307 if !bounds_accept(&threshold.bounds, metric) {
308 return false;
309 }
310 }
311 matched
312}
313
314fn named_metric<'a>(metric: &'a MetricReport, name: &str) -> Option<&'a MetricReport> {
315 (metric.model == name).then_some(metric)
316}
317
318fn bounds_accept(bounds: &MetricBounds, metric: &MetricReport) -> bool {
319 bounds
320 .max_roughness_mass
321 .is_none_or(|limit| metric.roughness_mass <= limit)
322 && bounds
323 .max_normalized_density
324 .is_none_or(|limit| metric.normalized_density <= limit)
325 && bounds
326 .min_harmonic_context
327 .is_none_or(|limit| metric.harmonic_context >= limit)
328 && bounds
329 .max_harmonic_context
330 .is_none_or(|limit| metric.harmonic_context <= limit)
331}
332
333fn addition_notes(additions: &[Addition]) -> Vec<&StaffNote> {
334 additions
335 .iter()
336 .flat_map(|addition| addition.notes())
337 .collect()
338}
339
340fn new_voice_count(additions: &[Addition]) -> usize {
341 additions
342 .iter()
343 .filter(|addition| matches!(addition, Addition::Voice(_)))
344 .count()
345}
346
347fn maximum_simultaneous(notes: &[&StaffNote]) -> usize {
348 let mut boundaries = notes
349 .iter()
350 .flat_map(|note| [note.onset, note.end()])
351 .collect::<Vec<_>>();
352 boundaries.sort();
353 boundaries.dedup();
354 boundaries
355 .into_iter()
356 .map(|at| {
357 notes
358 .iter()
359 .filter(|note| note.onset <= at && at < note.end())
360 .count()
361 })
362 .max()
363 .unwrap_or(0)
364}
365
366fn overlaps_note(span: &TimeSpan, note: &StaffNote) -> bool {
367 span.start < note.end() && note.onset < span.end
368}
369
370fn spans_overlap(left: &TimeSpan, right: &TimeSpan) -> bool {
371 left.start < right.end && right.start < left.end
372}
373
374fn invalid<T>(reason: impl Into<String>) -> Result<T, ConstraintError> {
375 Err(ConstraintError::Invalid(reason.into()))
376}
377
378pub(crate) fn changed_spans(report: &ConsonanceReport, additions: &[Addition]) -> Vec<TimeSpan> {
379 let event_ids = additions
380 .iter()
381 .flat_map(|addition| addition.notes())
382 .map(|note| note.event_id.clone())
383 .collect::<BTreeSet<_>>();
384 report
385 .windows
386 .iter()
387 .filter(|window| {
388 window
389 .window
390 .notes
391 .iter()
392 .any(|note| event_ids.contains(¬e.event_id))
393 })
394 .map(|window| window.window.span.clone())
395 .collect()
396}