Skip to main content

sim_lib_music_transform/exact/
composition.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use sim_lib_music_core::{ObjectId, Staff, StaffNote, StaffVoice, Time};
4
5use super::{MusicTransform, MusicTransformChange, RhythmMask, finish};
6use crate::TransformError;
7
8/// Composes staffs in sequence, shifting later onsets by exact prior durations.
9///
10/// Matching voice ids are joined. Note and event identities must be globally
11/// distinct, because silently renaming them would violate identity preservation.
12pub fn sequence_staff(parts: &[Staff]) -> Result<MusicTransform<Staff>, TransformError> {
13    let mut voices = BTreeMap::<ObjectId, StaffVoice>::new();
14    let mut seen = BTreeSet::new();
15    let mut offset = Time::from_integer(0);
16    let mut changes = Vec::new();
17    for staff in parts {
18        for voice in &staff.voices {
19            let output = voices
20                .entry(voice.id.clone())
21                .or_insert_with(|| StaffVoice {
22                    id: voice.id.clone(),
23                    name: voice.name.clone(),
24                    duration: Time::from_integer(0),
25                    notes: Vec::new(),
26                });
27            if output.name != voice.name {
28                return Err(TransformError::InvalidTransformOutput {
29                    transform: "sequence-staff",
30                    reason: "matching voice ids have different names",
31                });
32            }
33            for note in &voice.notes {
34                require_unique_note(note, &mut seen, "sequence-staff")?;
35                let mut shifted = note.clone();
36                shifted.onset += offset;
37                if shifted.onset != note.onset {
38                    changes.push(MusicTransformChange::Onset {
39                        event_id: note.event_id.clone(),
40                        before: note.onset,
41                        after: shifted.onset,
42                    });
43                }
44                output.notes.push(shifted);
45            }
46        }
47        offset += staff.duration();
48    }
49    for voice in voices.values_mut() {
50        voice.duration = offset;
51    }
52    finish(voices.into_values().collect(), changes)
53}
54
55/// Composes staffs in parallel, retaining exact onsets and maximum duration.
56pub fn parallel_staff(parts: &[Staff]) -> Result<MusicTransform<Staff>, TransformError> {
57    let mut voices = BTreeMap::<ObjectId, StaffVoice>::new();
58    let mut seen = BTreeSet::new();
59    for staff in parts {
60        for voice in &staff.voices {
61            let output = voices
62                .entry(voice.id.clone())
63                .or_insert_with(|| StaffVoice {
64                    id: voice.id.clone(),
65                    name: voice.name.clone(),
66                    duration: Time::from_integer(0),
67                    notes: Vec::new(),
68                });
69            if output.name != voice.name {
70                return Err(TransformError::InvalidTransformOutput {
71                    transform: "parallel-staff",
72                    reason: "matching voice ids have different names",
73                });
74            }
75            output.duration = output.duration.max(voice.duration);
76            for note in &voice.notes {
77                require_unique_note(note, &mut seen, "parallel-staff")?;
78                output.notes.push(note.clone());
79            }
80        }
81    }
82    finish(voices.into_values().collect(), Vec::new())
83}
84
85/// Extracts an exact half-open staff slice and reports clipping/removal.
86pub fn slice_staff(
87    staff: &Staff,
88    start: Time,
89    end: Time,
90) -> Result<MusicTransform<Staff>, TransformError> {
91    if start < Time::from_integer(0) || end < start {
92        return Err(TransformError::InvalidTransformOutput {
93            transform: "slice-staff",
94            reason: "slice must satisfy 0 <= start <= end",
95        });
96    }
97    let duration = end - start;
98    let mut voices = Vec::new();
99    let mut changes = Vec::new();
100    for voice in &staff.voices {
101        let mut notes = Vec::new();
102        for note in &voice.notes {
103            let clipped_start = note.onset.max(start);
104            let clipped_end = note.end().min(end);
105            if clipped_start >= clipped_end {
106                changes.push(MusicTransformChange::Removed {
107                    note_id: note.note_id.clone(),
108                    event_id: note.event_id.clone(),
109                    reason: "outside slice",
110                });
111                continue;
112            }
113            let mut clipped = note.clone();
114            clipped.onset = clipped_start - start;
115            clipped.note.duration = clipped_end - clipped_start;
116            if clipped.onset != note.onset {
117                changes.push(MusicTransformChange::Onset {
118                    event_id: note.event_id.clone(),
119                    before: note.onset,
120                    after: clipped.onset,
121                });
122            }
123            if clipped.note.duration != note.note.duration {
124                changes.push(MusicTransformChange::Duration {
125                    event_id: note.event_id.clone(),
126                    before: note.note.duration,
127                    after: clipped.note.duration,
128                });
129            }
130            notes.push(clipped);
131        }
132        voices.push(StaffVoice {
133            id: voice.id.clone(),
134            name: voice.name.clone(),
135            duration,
136            notes,
137        });
138    }
139    finish(voices, changes)
140}
141
142/// Keeps notes whose exact onset lands on a `true` periodic mask slot.
143pub fn apply_rhythm_mask(
144    staff: &Staff,
145    mask: &RhythmMask,
146) -> Result<MusicTransform<Staff>, TransformError> {
147    let mut voices = staff.voices.clone();
148    let mut changes = Vec::new();
149    for voice in &mut voices {
150        voice.notes.retain(|note| {
151            if mask.keeps(note.onset) {
152                true
153            } else {
154                changes.push(MusicTransformChange::Removed {
155                    note_id: note.note_id.clone(),
156                    event_id: note.event_id.clone(),
157                    reason: "rhythm mask",
158                });
159                false
160            }
161        });
162    }
163    finish(voices, changes)
164}
165
166fn require_unique_note(
167    note: &StaffNote,
168    seen: &mut BTreeSet<ObjectId>,
169    transform: &'static str,
170) -> Result<(), TransformError> {
171    if !seen.insert(note.note_id.clone()) || !seen.insert(note.event_id.clone()) {
172        return Err(TransformError::InvalidTransformOutput {
173            transform,
174            reason: "duplicate note or event identity",
175        });
176    }
177    Ok(())
178}