Skip to main content

sim_lib_music_transform/
exact.rs

1//! Exact identity-preserving staff transforms and their audit reports.
2
3mod additive;
4mod composition;
5mod leading;
6mod progression;
7mod register;
8
9use std::collections::BTreeSet;
10
11use sim_lib_music_core::{
12    Articulation, Channel, ObjectId, Pitch, Staff, StaffNote, StaffVoice, Time,
13};
14
15use crate::TransformError;
16
17pub use additive::*;
18pub use composition::*;
19pub use leading::*;
20pub use progression::*;
21pub use register::*;
22
23/// One reversible or explicitly destructive change made by an exact transform.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub enum MusicTransformChange {
26    /// A note onset changed.
27    Onset {
28        /// Affected event.
29        event_id: ObjectId,
30        /// Exact prior onset.
31        before: Time,
32        /// Exact new onset.
33        after: Time,
34    },
35    /// A note duration changed.
36    Duration {
37        /// Affected event.
38        event_id: ObjectId,
39        /// Exact prior duration.
40        before: Time,
41        /// Exact new duration.
42        after: Time,
43    },
44    /// A note articulation changed.
45    Articulation {
46        /// Affected event.
47        event_id: ObjectId,
48        /// Prior articulation.
49        before: Articulation,
50        /// New articulation.
51        after: Articulation,
52    },
53    /// A note pitch changed while its pitch class stayed fixed.
54    Pitch {
55        /// Affected event.
56        event_id: ObjectId,
57        /// Prior pitch.
58        before: Pitch,
59        /// New pitch.
60        after: Pitch,
61    },
62    /// A note moved to another voice.
63    Voice {
64        /// Affected event.
65        event_id: ObjectId,
66        /// Prior voice identity.
67        before: ObjectId,
68        /// New voice identity.
69        after: ObjectId,
70    },
71    /// Voice separation allocated a new voice identity.
72    CreatedVoice {
73        /// New identity.
74        voice_id: ObjectId,
75        /// Original voice from which it was split.
76        source_voice_id: ObjectId,
77    },
78    /// Repetition derived fresh note/event identities for a later occurrence.
79    RepeatedIdentity {
80        /// Original logical note identity.
81        source_note_id: ObjectId,
82        /// Original event identity.
83        source_event_id: ObjectId,
84        /// Derived logical note identity.
85        repeated_note_id: ObjectId,
86        /// Derived event identity.
87        repeated_event_id: ObjectId,
88        /// Zero-based occurrence index, always greater than zero.
89        occurrence: usize,
90    },
91    /// A rhythm mask or slice removed an event.
92    Removed {
93        /// Removed logical note identity.
94        note_id: ObjectId,
95        /// Removed event identity.
96        event_id: ObjectId,
97        /// Stable reason for removal.
98        reason: &'static str,
99    },
100    /// An additive transform introduced a new independent voice.
101    AddedVoice {
102        /// New voice identity.
103        voice_id: ObjectId,
104    },
105    /// An additive transform introduced a note without changing source notes.
106    AddedNote {
107        /// Containing voice.
108        voice_id: ObjectId,
109        /// New logical note identity.
110        note_id: ObjectId,
111        /// New event identity.
112        event_id: ObjectId,
113    },
114    /// Reversing an additive transform removed its introduced voice.
115    RemovedVoice {
116        /// Removed voice identity.
117        voice_id: ObjectId,
118    },
119}
120
121/// Exact transform value paired with identity and change evidence.
122#[derive(Clone, Debug, PartialEq, Eq)]
123pub struct MusicTransform<T> {
124    /// Transformed value.
125    pub value: T,
126    /// Identities still present after the transform.
127    pub preserved: Vec<ObjectId>,
128    /// Complete ordered edits performed by the transform.
129    pub changes: Vec<MusicTransformChange>,
130}
131
132impl<T> MusicTransform<T> {
133    /// Returns `true` when the transform left its input unchanged.
134    pub fn is_unchanged(&self) -> bool {
135        self.changes.is_empty()
136    }
137}
138
139/// Exact half-open sustain-pedal interval.
140#[derive(Copy, Clone, Debug, PartialEq, Eq)]
141pub struct SustainSpan {
142    /// Pedal-down time.
143    pub start: Time,
144    /// Pedal-up time.
145    pub end: Time,
146    /// Optional channel restriction.
147    pub channel: Option<Channel>,
148}
149
150impl SustainSpan {
151    /// Builds a sustain span; validity is checked when applying it.
152    pub fn new(start: Time, end: Time, channel: Option<Channel>) -> Self {
153        Self {
154            start,
155            end,
156            channel,
157        }
158    }
159}
160
161/// Ordering used for simultaneous notes during delayed-note voice separation.
162#[derive(Copy, Clone, Debug, PartialEq, Eq)]
163pub enum DelayedNoteOrder {
164    /// Stable pitch/event identity order.
165    Stable,
166    /// Higher pitches receive earlier voice slots.
167    HighestFirst,
168    /// Lower pitches receive earlier voice slots.
169    LowestFirst,
170}
171
172/// Periodic exact-onset rhythm mask.
173#[derive(Clone, Debug, PartialEq, Eq)]
174pub struct RhythmMask {
175    step: Time,
176    pattern: Vec<bool>,
177}
178
179impl RhythmMask {
180    /// Builds a non-empty mask with a positive exact step.
181    pub fn new(step: Time, pattern: Vec<bool>) -> Result<Self, TransformError> {
182        if step <= Time::from_integer(0) {
183            return Err(TransformError::InvalidFactor);
184        }
185        if pattern.is_empty() {
186            return Err(TransformError::InvalidTransformOutput {
187                transform: "rhythm-mask",
188                reason: "pattern must not be empty",
189            });
190        }
191        Ok(Self { step, pattern })
192    }
193
194    /// Returns the exact duration of one mask slot.
195    pub fn step(&self) -> Time {
196        self.step
197    }
198
199    /// Returns the periodic keep/drop pattern.
200    pub fn pattern(&self) -> &[bool] {
201        &self.pattern
202    }
203
204    fn keeps(&self, onset: Time) -> bool {
205        let slots = onset / self.step;
206        let slot = slots.numer().div_euclid(*slots.denom());
207        self.pattern[slot.rem_euclid(self.pattern.len() as i64) as usize]
208    }
209}
210
211/// Extends note releases that occur while one of `spans` is active.
212///
213/// Onsets, pitches, identities, and exact rational time are retained. A note
214/// released inside overlapping sustain spans is extended through the furthest
215/// applicable pedal-up boundary. Transitive extension is independent of the
216/// order in which spans are supplied.
217pub fn sustain_staff(
218    staff: &Staff,
219    spans: &[SustainSpan],
220) -> Result<MusicTransform<Staff>, TransformError> {
221    validate_spans(spans)?;
222    transform_notes(staff, |mut note, changes| {
223        let before = note.note.duration;
224        let mut end = note.end();
225        loop {
226            let prior_end = end;
227            for span in spans {
228                if span
229                    .channel
230                    .is_none_or(|channel| channel == note.note.channel)
231                    && end >= span.start
232                    && end < span.end
233                    && note.onset < span.end
234                {
235                    end = span.end;
236                }
237            }
238            if end == prior_end {
239                break;
240            }
241        }
242        note.note.duration = end - note.onset;
243        if note.note.duration != before {
244            changes.push(MusicTransformChange::Duration {
245                event_id: note.event_id.clone(),
246                before,
247                after: note.note.duration,
248            });
249        }
250        note
251    })
252}
253
254/// Connects each note in a voice to its next onset and marks it legato.
255///
256/// Existing overlaps are not shortened. The final note of each voice is left
257/// unchanged because there is no following articulation target.
258pub fn slur_staff(staff: &Staff) -> Result<MusicTransform<Staff>, TransformError> {
259    let mut voices = staff.voices.clone();
260    let mut changes = Vec::new();
261    for voice in &mut voices {
262        voice.notes.sort_by(note_order);
263        for index in 0..voice.notes.len().saturating_sub(1) {
264            let next_onset = voice.notes[index + 1].onset;
265            let note = &mut voice.notes[index];
266            if note.end() < next_onset {
267                let before = note.note.duration;
268                note.note.duration = next_onset - note.onset;
269                changes.push(MusicTransformChange::Duration {
270                    event_id: note.event_id.clone(),
271                    before,
272                    after: note.note.duration,
273                });
274            }
275            if note.note.articulation != Articulation::Legato {
276                let before = note.note.articulation;
277                note.note.articulation = Articulation::Legato;
278                changes.push(MusicTransformChange::Articulation {
279                    event_id: note.event_id.clone(),
280                    before,
281                    after: Articulation::Legato,
282                });
283            }
284        }
285    }
286    finish(voices, changes)
287}
288
289/// Expands every onset, note duration, and voice span by a positive exact factor.
290pub fn expand_staff(staff: &Staff, factor: Time) -> Result<MusicTransform<Staff>, TransformError> {
291    if factor <= Time::from_integer(0) {
292        return Err(TransformError::InvalidFactor);
293    }
294    let mut voices = staff.voices.clone();
295    let mut changes = Vec::new();
296    for voice in &mut voices {
297        voice.duration *= factor;
298        for note in &mut voice.notes {
299            let onset = note.onset;
300            let duration = note.note.duration;
301            note.onset *= factor;
302            note.note.duration *= factor;
303            if note.onset != onset {
304                changes.push(MusicTransformChange::Onset {
305                    event_id: note.event_id.clone(),
306                    before: onset,
307                    after: note.onset,
308                });
309            }
310            if note.note.duration != duration {
311                changes.push(MusicTransformChange::Duration {
312                    event_id: note.event_id.clone(),
313                    before: duration,
314                    after: note.note.duration,
315                });
316            }
317        }
318    }
319    finish(voices, changes)
320}
321
322/// Splits delayed overlapping notes into monophonic voices without moving them.
323///
324/// Exact abutment (`previous.end == next.onset`) remains in one voice. The first
325/// output retains the original voice id; additional lines receive deterministic
326/// derived ids, while every note/event identity is preserved.
327pub fn separate_delayed_notes(
328    staff: &Staff,
329    order: DelayedNoteOrder,
330) -> Result<MusicTransform<Staff>, TransformError> {
331    let mut output = Vec::new();
332    let mut changes = Vec::new();
333    for voice in &staff.voices {
334        let mut notes = voice.notes.clone();
335        notes.sort_by(|left, right| delayed_order(left, right, order));
336        let mut lines = Vec::<StaffVoice>::new();
337        for mut note in notes {
338            let slot = lines.iter().position(|line| {
339                line.notes
340                    .last()
341                    .is_none_or(|last| last.end() <= note.onset)
342            });
343            let index = slot.unwrap_or(lines.len());
344            if index == lines.len() {
345                let id = if index == 0 {
346                    voice.id.clone()
347                } else {
348                    ObjectId::new(format!("{}/delayed-{index}", voice.id))
349                        .expect("derived voice identity is non-empty")
350                };
351                if index > 0 {
352                    changes.push(MusicTransformChange::CreatedVoice {
353                        voice_id: id.clone(),
354                        source_voice_id: voice.id.clone(),
355                    });
356                }
357                lines.push(StaffVoice {
358                    id,
359                    name: if index == 0 {
360                        voice.name.clone()
361                    } else {
362                        format!("{} delayed {}", voice.name, index + 1)
363                    },
364                    duration: voice.duration,
365                    notes: Vec::new(),
366                });
367            }
368            let destination = lines[index].id.clone();
369            if note.voice_id != destination {
370                changes.push(MusicTransformChange::Voice {
371                    event_id: note.event_id.clone(),
372                    before: note.voice_id.clone(),
373                    after: destination.clone(),
374                });
375                note.voice_id = destination;
376            }
377            lines[index].notes.push(note);
378        }
379        if lines.is_empty() {
380            lines.push(voice.clone());
381        }
382        output.extend(lines);
383    }
384    finish(output, changes)
385}
386
387fn transform_notes(
388    staff: &Staff,
389    mut f: impl FnMut(StaffNote, &mut Vec<MusicTransformChange>) -> StaffNote,
390) -> Result<MusicTransform<Staff>, TransformError> {
391    let mut voices = staff.voices.clone();
392    let mut changes = Vec::new();
393    for voice in &mut voices {
394        voice.notes = voice
395            .notes
396            .drain(..)
397            .map(|note| f(note, &mut changes))
398            .collect();
399        if let Some(end) = voice.notes.iter().map(StaffNote::end).max() {
400            voice.duration = voice.duration.max(end);
401        }
402    }
403    finish(voices, changes)
404}
405
406fn finish(
407    voices: Vec<StaffVoice>,
408    changes: Vec<MusicTransformChange>,
409) -> Result<MusicTransform<Staff>, TransformError> {
410    let staff = Staff::new(voices).map_err(TransformError::InvalidStaff)?;
411    let mut created = BTreeSet::new();
412    for change in &changes {
413        match change {
414            MusicTransformChange::CreatedVoice { voice_id, .. } => {
415                created.insert(voice_id);
416            }
417            MusicTransformChange::RepeatedIdentity {
418                repeated_note_id,
419                repeated_event_id,
420                ..
421            } => {
422                created.insert(repeated_note_id);
423                created.insert(repeated_event_id);
424            }
425            MusicTransformChange::AddedVoice { voice_id } => {
426                created.insert(voice_id);
427            }
428            MusicTransformChange::AddedNote {
429                note_id, event_id, ..
430            } => {
431                created.insert(note_id);
432                created.insert(event_id);
433            }
434            _ => {}
435        }
436    }
437    Ok(MusicTransform {
438        preserved: staff
439            .object_ids()
440            .into_iter()
441            .filter(|id| !created.contains(id))
442            .collect(),
443        value: staff,
444        changes,
445    })
446}
447
448fn validate_spans(spans: &[SustainSpan]) -> Result<(), TransformError> {
449    if spans
450        .iter()
451        .any(|span| span.start < Time::from_integer(0) || span.end < span.start)
452    {
453        return Err(TransformError::InvalidTransformOutput {
454            transform: "sustain",
455            reason: "sustain spans must satisfy 0 <= start <= end",
456        });
457    }
458    Ok(())
459}
460
461fn note_order(left: &StaffNote, right: &StaffNote) -> std::cmp::Ordering {
462    left.onset
463        .cmp(&right.onset)
464        .then_with(|| left.note.pitch.cmp(&right.note.pitch))
465        .then_with(|| left.event_id.cmp(&right.event_id))
466}
467
468fn delayed_order(
469    left: &StaffNote,
470    right: &StaffNote,
471    order: DelayedNoteOrder,
472) -> std::cmp::Ordering {
473    left.onset.cmp(&right.onset).then_with(|| {
474        let pitch = left.note.pitch.cmp(&right.note.pitch);
475        let pitch = match order {
476            DelayedNoteOrder::Stable | DelayedNoteOrder::LowestFirst => pitch,
477            DelayedNoteOrder::HighestFirst => pitch.reverse(),
478        };
479        pitch.then_with(|| left.event_id.cmp(&right.event_id))
480    })
481}