Skip to main content

sim_lib_music_consonance/
patch.rs

1use std::collections::BTreeSet;
2
3use sim_kernel::{ContentId, Datum, Symbol};
4use sim_lib_music_core::{Articulation, ObjectId, Staff, StaffNote, StaffVoice, Time};
5use sim_lib_music_transform::{
6    AdditiveStaffPatch, apply_additive_staff_patch, remove_additive_staff_patch,
7};
8use thiserror::Error;
9
10/// Kernel content identity used to bind a patch to one immutable staff.
11pub type ContentKey = ContentId;
12
13/// Semantic class of a consonance addition.
14#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
15pub enum AdditionKind {
16    /// One independently proposed note.
17    Note,
18    /// A bounded figure around an existing event.
19    Ornament,
20    /// Simultaneous notes proposed as one harmonic unit.
21    Chord,
22    /// A sustained harmonic pedal point.
23    Pedal,
24    /// An octave or unison doubling of an existing event.
25    Doubling,
26    /// A complete new voice.
27    Voice,
28}
29
30/// One independently proposed note.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct NoteAddition {
33    /// Identity-bearing note payload to add.
34    pub note: StaffNote,
35}
36
37/// A bounded note figure anchored to existing material.
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub struct OrnamentAddition {
40    /// Existing event that gives the ornament its musical context.
41    pub anchor_event_id: ObjectId,
42    /// Ordered identity-bearing ornament notes.
43    pub notes: Vec<StaffNote>,
44}
45
46/// Simultaneous notes introduced as one harmonic choice.
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct ChordAddition {
49    /// Optional authored harmonic label retained as provenance.
50    pub label: Option<String>,
51    /// Identity-bearing notes sharing one exact onset and release.
52    pub notes: Vec<StaffNote>,
53}
54
55/// A sustained harmonic pedal point.
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub struct PedalAddition {
58    /// Optional authored harmonic label retained as provenance.
59    pub label: Option<String>,
60    /// Long-lived identity-bearing pedal note.
61    pub note: StaffNote,
62}
63
64/// An octave or unison doubling of an existing event.
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct DoublingAddition {
67    /// Existing event whose onset, duration, and pitch class are doubled.
68    pub source_event_id: ObjectId,
69    /// Fresh identity-bearing doubling.
70    pub note: StaffNote,
71}
72
73/// A complete independent voice introduced by completion.
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct VoiceAddition {
76    /// New voice, including every fresh note identity.
77    pub voice: StaffVoice,
78}
79
80/// One typed, strictly additive consonance proposal.
81#[derive(Clone, Debug, PartialEq, Eq)]
82pub enum Addition {
83    /// One note.
84    Note(NoteAddition),
85    /// A note figure around an existing event.
86    Ornament(OrnamentAddition),
87    /// Simultaneous harmonic notes.
88    Chord(ChordAddition),
89    /// A sustained pedal point.
90    Pedal(PedalAddition),
91    /// An octave or unison doubling.
92    Doubling(DoublingAddition),
93    /// A complete independent voice.
94    Voice(VoiceAddition),
95}
96
97impl Addition {
98    /// Returns the semantic addition class.
99    pub fn kind(&self) -> AdditionKind {
100        match self {
101            Self::Note(_) => AdditionKind::Note,
102            Self::Ornament(_) => AdditionKind::Ornament,
103            Self::Chord(_) => AdditionKind::Chord,
104            Self::Pedal(_) => AdditionKind::Pedal,
105            Self::Doubling(_) => AdditionKind::Doubling,
106            Self::Voice(_) => AdditionKind::Voice,
107        }
108    }
109
110    /// Iterates over every note introduced by this addition.
111    pub fn notes(&self) -> Box<dyn Iterator<Item = &StaffNote> + '_> {
112        match self {
113            Self::Note(value) => Box::new(std::iter::once(&value.note)),
114            Self::Ornament(value) => Box::new(value.notes.iter()),
115            Self::Chord(value) => Box::new(value.notes.iter()),
116            Self::Pedal(value) => Box::new(std::iter::once(&value.note)),
117            Self::Doubling(value) => Box::new(std::iter::once(&value.note)),
118            Self::Voice(value) => Box::new(value.voice.notes.iter()),
119        }
120    }
121}
122
123/// A content-bound collection of typed score additions.
124#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct ConsonancePatch {
126    /// Content identity of the exact staff to which the patch applies.
127    pub base: ContentKey,
128    /// Typed material introduced by the patch.
129    pub additions: Vec<Addition>,
130}
131
132impl ConsonancePatch {
133    /// Builds and validates a patch against an immutable source staff.
134    pub fn new(source: &Staff, additions: Vec<Addition>) -> Result<Self, PatchError> {
135        let patch = Self {
136            base: staff_content_key(source)?,
137            additions,
138        };
139        patch.compile(source)?;
140        Ok(patch)
141    }
142
143    pub(crate) fn compile(&self, source: &Staff) -> Result<AdditiveStaffPatch, PatchError> {
144        validate_additions(source, &self.additions)?;
145        let mut patch = AdditiveStaffPatch::default();
146        for addition in &self.additions {
147            match addition {
148                Addition::Voice(value) => patch.voices.push(value.voice.clone()),
149                _ => patch.notes.extend(addition.notes().cloned()),
150            }
151        }
152        apply_additive_staff_patch(source, &patch)
153            .map_err(|error| PatchError::InvalidAddition(error.to_string()))?;
154        Ok(patch)
155    }
156}
157
158/// Applies a content-bound consonance patch without mutating its source.
159pub fn apply_patch(source: &Staff, patch: &ConsonancePatch) -> Result<Staff, PatchError> {
160    require_base(source, &patch.base)?;
161    let additions = patch.compile(source)?;
162    apply_additive_staff_patch(source, &additions)
163        .map(|transform| transform.value)
164        .map_err(|error| PatchError::InvalidAddition(error.to_string()))
165}
166
167/// Removes exactly a patch's introduced material and verifies its base content.
168pub fn remove_patch(completed: &Staff, patch: &ConsonancePatch) -> Result<Staff, PatchError> {
169    let additions = compile_without_source(&patch.additions);
170    let source = remove_additive_staff_patch(completed, &additions)
171        .map(|transform| transform.value)
172        .map_err(|error| PatchError::InvalidInverse(error.to_string()))?;
173    require_base(&source, &patch.base)?;
174    Ok(source)
175}
176
177/// Computes the canonical kernel content identity of an exact staff.
178pub fn staff_content_key(staff: &Staff) -> Result<ContentKey, PatchError> {
179    staff_datum(staff)
180        .content_id()
181        .map_err(|error| PatchError::ContentIdentity(error.to_string()))
182}
183
184/// Failure to construct, apply, or invert a consonance patch.
185#[derive(Clone, Debug, Error, PartialEq, Eq)]
186pub enum PatchError {
187    /// The patch names a different immutable base staff.
188    #[error("consonance patch base does not match the supplied staff")]
189    BaseMismatch,
190    /// Typed addition invariants were violated.
191    #[error("invalid consonance addition: {0}")]
192    InvalidAddition(String),
193    /// Exact inverse validation failed.
194    #[error("invalid consonance patch inverse: {0}")]
195    InvalidInverse(String),
196    /// Canonical staff hashing failed.
197    #[error("staff content identity failed: {0}")]
198    ContentIdentity(String),
199}
200
201fn validate_additions(source: &Staff, additions: &[Addition]) -> Result<(), PatchError> {
202    let duration = source.duration();
203    let source_events = source
204        .notes()
205        .map(|note| (note.event_id.clone(), note))
206        .collect::<std::collections::BTreeMap<_, _>>();
207    for addition in additions {
208        validate_semantics(addition, &source_events)?;
209        for note in addition.notes() {
210            if note.note.duration <= Time::from_integer(0)
211                || note.onset < Time::from_integer(0)
212                || note.end() > duration
213            {
214                return invalid("added notes must have positive spans inside the source duration");
215            }
216        }
217        if let Addition::Voice(value) = addition
218            && value.voice.duration != duration
219        {
220            return invalid("added voices must retain the source staff duration");
221        }
222    }
223    Ok(())
224}
225
226fn validate_semantics(
227    addition: &Addition,
228    source_events: &std::collections::BTreeMap<ObjectId, &StaffNote>,
229) -> Result<(), PatchError> {
230    match addition {
231        Addition::Note(_) => {}
232        Addition::Ornament(value) => {
233            if !source_events.contains_key(&value.anchor_event_id) || value.notes.is_empty() {
234                return invalid("an ornament needs an existing anchor and at least one note");
235            }
236        }
237        Addition::Chord(value) => {
238            let Some(first) = value.notes.first() else {
239                return invalid("a chord addition must contain notes");
240            };
241            if value.notes.len() < 2
242                || value
243                    .notes
244                    .iter()
245                    .any(|note| note.onset != first.onset || note.end() != first.end())
246            {
247                return invalid("a chord addition needs at least two notes with one exact span");
248            }
249        }
250        Addition::Pedal(_) => {}
251        Addition::Doubling(value) => {
252            let Some(source) = source_events.get(&value.source_event_id) else {
253                return invalid("a doubling must name an existing source event");
254            };
255            if value.note.onset != source.onset
256                || value.note.note.duration != source.note.duration
257                || value.note.note.pitch.class != source.note.pitch.class
258            {
259                return invalid("a doubling must retain source onset, duration, and pitch class");
260            }
261        }
262        Addition::Voice(value) if value.voice.notes.is_empty() => {
263            return invalid("an added voice must contain at least one note");
264        }
265        Addition::Voice(_) => {}
266    }
267    Ok(())
268}
269
270fn compile_without_source(additions: &[Addition]) -> AdditiveStaffPatch {
271    let mut patch = AdditiveStaffPatch::default();
272    for addition in additions {
273        match addition {
274            Addition::Voice(value) => patch.voices.push(value.voice.clone()),
275            _ => patch.notes.extend(addition.notes().cloned()),
276        }
277    }
278    patch
279}
280
281fn require_base(staff: &Staff, expected: &ContentKey) -> Result<(), PatchError> {
282    if staff_content_key(staff)? == *expected {
283        Ok(())
284    } else {
285        Err(PatchError::BaseMismatch)
286    }
287}
288
289fn staff_datum(staff: &Staff) -> Datum {
290    Datum::Node {
291        tag: Symbol::qualified("music/consonance", "staff-v1"),
292        fields: vec![
293            (Symbol::new("duration"), time_datum(staff.duration())),
294            (
295                Symbol::new("voices"),
296                Datum::Vector(staff.voices.iter().map(voice_datum).collect()),
297            ),
298        ],
299    }
300}
301
302fn voice_datum(voice: &StaffVoice) -> Datum {
303    Datum::Vector(vec![
304        Datum::String(voice.id.to_string()),
305        Datum::String(voice.name.clone()),
306        time_datum(voice.duration),
307        Datum::Vector(voice.notes.iter().map(note_datum).collect()),
308    ])
309}
310
311fn note_datum(note: &StaffNote) -> Datum {
312    Datum::Vector(vec![
313        Datum::String(note.voice_id.to_string()),
314        Datum::String(note.note_id.to_string()),
315        Datum::String(note.event_id.to_string()),
316        time_datum(note.onset),
317        time_datum(note.note.duration),
318        Datum::String(note.note.pitch.semitone().to_string()),
319        Datum::String(note.note.velocity.to_string()),
320        Datum::String(note.note.channel.0.to_string()),
321        Datum::String(articulation_name(note.note.articulation).to_owned()),
322    ])
323}
324
325fn time_datum(value: Time) -> Datum {
326    Datum::String(format!("{}/{}", value.numer(), value.denom()))
327}
328
329fn articulation_name(value: Articulation) -> &'static str {
330    match value {
331        Articulation::Normal => "normal",
332        Articulation::Staccato => "staccato",
333        Articulation::Legato => "legato",
334        Articulation::Tenuto => "tenuto",
335        Articulation::Accent => "accent",
336        Articulation::Marcato => "marcato",
337    }
338}
339
340fn invalid<T>(reason: impl Into<String>) -> Result<T, PatchError> {
341    Err(PatchError::InvalidAddition(reason.into()))
342}
343
344pub(crate) fn addition_ids(additions: &[Addition]) -> Vec<ObjectId> {
345    let mut ids = BTreeSet::new();
346    for addition in additions {
347        if let Addition::Voice(value) = addition {
348            ids.insert(value.voice.id.clone());
349        }
350        for note in addition.notes() {
351            ids.insert(note.note_id.clone());
352            ids.insert(note.event_id.clone());
353        }
354    }
355    ids.into_iter().collect()
356}