Skip to main content

sim_lib_music_consonance/
constraints.rs

1use 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/// Consonance report family addressed by a completion threshold.
11#[derive(Copy, Clone, Debug, PartialEq, Eq)]
12pub enum MetricFamily {
13    /// Set-domain pitch analysis.
14    Pitch,
15    /// Frequency- and amplitude-domain analysis.
16    Acoustic,
17    /// Exact-ratio contextual analysis.
18    Ratio,
19    /// Event-commonality contextual analysis.
20    Commonality,
21    /// Voice-leading contextual analysis.
22    Leading,
23}
24
25/// Explicit upper and lower bounds for one named consonance metric.
26#[derive(Clone, Debug, Default, PartialEq)]
27pub struct MetricBounds {
28    /// Maximum accepted unnormalized roughness mass.
29    pub max_roughness_mass: Option<f64>,
30    /// Maximum accepted normalized density.
31    pub max_normalized_density: Option<f64>,
32    /// Minimum accepted harmonic or continuity context.
33    pub min_harmonic_context: Option<f64>,
34    /// Maximum accepted harmonic or continuity context.
35    pub max_harmonic_context: Option<f64>,
36}
37
38/// One named metric threshold over all or part of the score.
39#[derive(Clone, Debug, PartialEq)]
40pub struct MetricThreshold {
41    /// Metric family.
42    pub family: MetricFamily,
43    /// Exact model name within the family.
44    pub model: String,
45    /// Optional half-open score span to which the threshold applies.
46    pub span: Option<TimeSpan>,
47    /// Component bounds checked independently in every intersecting window.
48    pub bounds: MetricBounds,
49}
50
51/// A pitch range applied globally or to one named voice.
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct PitchRangeConstraint {
54    /// `None` applies the range to every added note.
55    pub voice_id: Option<ObjectId>,
56    /// Inclusive lower pitch.
57    pub lowest: Pitch,
58    /// Inclusive upper pitch.
59    pub highest: Pitch,
60}
61
62/// Source material that completion must leave undisturbed.
63#[derive(Clone, Debug, Default, PartialEq, Eq)]
64pub struct PreservationConstraints {
65    /// Source identities that must exist before search.
66    ///
67    /// Every source identity is retained by the additive patch contract; this
68    /// list lets callers make their protected subset explicit and auditable.
69    pub required_ids: Vec<ObjectId>,
70    /// Spans in which completion may not introduce sounding notes.
71    pub protected_spans: Vec<TimeSpan>,
72}
73
74/// Explicit style envelope for candidate subsets.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct StyleConstraints {
77    /// Addition kinds allowed in a result.
78    pub allowed_kinds: BTreeSet<AdditionKind>,
79    /// Minimum number of semantic additions in a finished patch.
80    pub min_additions: usize,
81    /// Maximum number of semantic additions in a patch.
82    pub max_additions: Option<usize>,
83    /// Maximum number of introduced notes.
84    pub max_added_notes: Option<usize>,
85    /// Maximum number of introduced voices.
86    pub max_new_voices: Option<usize>,
87    /// Maximum introduced notes sounding simultaneously.
88    pub max_simultaneous_added_notes: Option<usize>,
89    /// Articulations allowed on introduced notes; empty means all.
90    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/// Complete metric, preservation, range, and style policy for completion.
117#[derive(Clone, Debug, Default, PartialEq)]
118pub struct CompletionConstraints {
119    /// Required after-report thresholds.
120    pub thresholds: Vec<MetricThreshold>,
121    /// Immutable-source and protected-span requirements.
122    pub preservation: PreservationConstraints,
123    /// Global and voice-specific pitch ranges.
124    pub ranges: Vec<PitchRangeConstraint>,
125    /// Candidate subset style envelope.
126    pub style: StyleConstraints,
127}
128
129/// Invalid or unsatisfied completion constraint.
130#[derive(Clone, Debug, Error, PartialEq)]
131pub enum ConstraintError {
132    /// A policy value is malformed.
133    #[error("invalid completion constraint: {0}")]
134    Invalid(String),
135    /// The patch itself was invalid.
136    #[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(&notes) > 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(&note.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 == &note.voice_id)
272            })
273            .all(|range| {
274                (range.lowest.semitone()..=range.highest.semitone())
275                    .contains(&note.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(&note.event_id))
393        })
394        .map(|window| window.window.span.clone())
395        .collect()
396}