sim_lib_music_core/score.rs
1//! Identity-bearing score forms and loss-audited conversion reports.
2
3mod convert;
4
5use std::collections::BTreeSet;
6use std::fmt;
7
8use thiserror::Error;
9
10use crate::{
11 AtomRef, Melody, MusicError, MusicObject, Note, PianoRoll, Progression, Time, TimedAtom,
12};
13use crate::{Chord, Counterpoint};
14
15pub use convert::convert_score;
16
17/// Stable identity for a voice, note, or score event.
18///
19/// Imported forms without native identifiers receive deterministic identifiers
20/// derived from their structural position. Once allocated, the identifiers are
21/// carried by staff, snapshot, change-stream, and exact-transform operations.
22#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub struct ObjectId(String);
24
25impl ObjectId {
26 /// Creates an identifier from a stable, non-empty string.
27 pub fn new(value: impl Into<String>) -> Result<Self, ConversionError> {
28 let value = value.into();
29 if value.trim().is_empty() {
30 return Err(ConversionError::InvalidIdentity(
31 "object identity cannot be empty".to_owned(),
32 ));
33 }
34 Ok(Self(value))
35 }
36
37 /// Returns the identifier's stable wire value.
38 pub fn as_str(&self) -> &str {
39 &self.0
40 }
41
42 pub(crate) fn derived(kind: &str, path: impl fmt::Display) -> Self {
43 Self(format!("{kind}/{path}"))
44 }
45}
46
47impl fmt::Display for ObjectId {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 f.write_str(&self.0)
50 }
51}
52
53/// A note placed on an identity-bearing staff.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct StaffNote {
56 /// Identity of the containing voice.
57 pub voice_id: ObjectId,
58 /// Identity of the logical note across reversible pitch/time transforms.
59 pub note_id: ObjectId,
60 /// Identity of this note event across score representations.
61 pub event_id: ObjectId,
62 /// Exact absolute onset in whole-note units.
63 pub onset: Time,
64 /// Musical note payload.
65 pub note: Note,
66}
67
68impl StaffNote {
69 /// Returns the exact half-open end of this note.
70 pub fn end(&self) -> Time {
71 self.onset + self.note.duration
72 }
73}
74
75/// One named staff voice with an exact notated span.
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct StaffVoice {
78 /// Stable voice identity.
79 pub id: ObjectId,
80 /// Human-facing voice name.
81 pub name: String,
82 /// Exact span, including trailing silence.
83 pub duration: Time,
84 /// Notes belonging to the voice, in stable time order.
85 pub notes: Vec<StaffNote>,
86}
87
88/// Identity-bearing canonical score timeline.
89///
90/// A staff is the lossless interlingua for note-bearing catalog forms. It
91/// retains voice boundaries, exact rational timing, trailing silence, and
92/// stable note/event identities without requiring those concerns in the
93/// lightweight [`Note`] value.
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub struct Staff {
96 /// Independent or simultaneous voices.
97 pub voices: Vec<StaffVoice>,
98}
99
100impl Staff {
101 /// Builds and validates a staff, sorting voices and notes canonically.
102 pub fn new(mut voices: Vec<StaffVoice>) -> Result<Self, ConversionError> {
103 let zero = Time::from_integer(0);
104 let mut identities = BTreeSet::new();
105 for voice in &mut voices {
106 if voice.duration < zero {
107 return Err(ConversionError::Music(MusicError::NegativeDuration));
108 }
109 if !identities.insert(voice.id.clone()) {
110 return Err(ConversionError::DuplicateIdentity(voice.id.clone()));
111 }
112 for note in &voice.notes {
113 if note.voice_id != voice.id {
114 return Err(ConversionError::InvalidIdentity(format!(
115 "event {} names voice {} but belongs to {}",
116 note.event_id, note.voice_id, voice.id
117 )));
118 }
119 if note.onset < zero {
120 return Err(ConversionError::Music(MusicError::NegativeOnset));
121 }
122 if note.note.duration < zero {
123 return Err(ConversionError::Music(MusicError::NegativeDuration));
124 }
125 if note.end() > voice.duration {
126 return Err(ConversionError::InvalidIdentity(format!(
127 "event {} ends after voice {}",
128 note.event_id, voice.id
129 )));
130 }
131 for id in [¬e.note_id, ¬e.event_id] {
132 if !identities.insert(id.clone()) {
133 return Err(ConversionError::DuplicateIdentity(id.clone()));
134 }
135 }
136 }
137 voice.notes.sort_by(staff_note_order);
138 }
139 voices.sort_by(|left, right| left.id.cmp(&right.id));
140 Ok(Self { voices })
141 }
142
143 /// Returns the exact parallel span of all voices.
144 pub fn duration(&self) -> Time {
145 self.voices
146 .iter()
147 .map(|voice| voice.duration)
148 .max()
149 .unwrap_or_else(|| Time::from_integer(0))
150 }
151
152 /// Iterates over all notes in canonical voice/time order.
153 pub fn notes(&self) -> impl Iterator<Item = &StaffNote> {
154 self.voices.iter().flat_map(|voice| voice.notes.iter())
155 }
156
157 /// Returns every voice, note, and event identity in sorted order.
158 pub fn object_ids(&self) -> Vec<ObjectId> {
159 let mut ids = self
160 .voices
161 .iter()
162 .flat_map(|voice| {
163 std::iter::once(voice.id.clone()).chain(
164 voice
165 .notes
166 .iter()
167 .flat_map(|note| [note.note_id.clone(), note.event_id.clone()]),
168 )
169 })
170 .collect::<Vec<_>>();
171 ids.sort();
172 ids.dedup();
173 ids
174 }
175}
176
177impl MusicObject for Staff {
178 fn kind(&self) -> &'static str {
179 "Staff"
180 }
181
182 fn duration(&self) -> Time {
183 Staff::duration(self)
184 }
185
186 fn voices<'a>(&'a self, offset: Time, out: &mut Vec<TimedAtom<'a>>) {
187 for item in self.notes() {
188 out.push(TimedAtom {
189 onset: offset + item.onset,
190 atom: AtomRef::Note(item.note.clone()),
191 });
192 }
193 }
194
195 fn clone_box(&self) -> Box<dyn MusicObject> {
196 Box::new(self.clone())
197 }
198
199 fn as_any(&self) -> &dyn std::any::Any {
200 self
201 }
202}
203
204/// Complete pitch-activity snapshot at one exact boundary.
205#[derive(Clone, Debug, PartialEq, Eq)]
206pub struct MusicSnapshot {
207 /// Exact snapshot time.
208 pub at: Time,
209 /// Complete set of identity-bearing notes sounding in the half-open
210 /// interval at `at`.
211 pub sounding: Vec<StaffNote>,
212}
213
214/// Voice metadata retained by non-staff event representations.
215#[derive(Clone, Debug, PartialEq, Eq)]
216pub struct ScoreVoice {
217 /// Stable voice identity.
218 pub id: ObjectId,
219 /// Human-facing voice name.
220 pub name: String,
221 /// Exact voice span, including trailing silence.
222 pub duration: Time,
223}
224
225/// Event-boundary snapshot representation of a score.
226#[derive(Clone, Debug, PartialEq, Eq)]
227pub struct SnapshotStream {
228 /// Exact total score span, including trailing silence.
229 pub duration: Time,
230 /// Voice identities, names, and individual spans.
231 pub voices: Vec<ScoreVoice>,
232 /// Complete event-boundary snapshots in strictly ascending time order.
233 pub snapshots: Vec<MusicSnapshot>,
234}
235
236/// One identity-bearing change in a score event stream.
237#[derive(Clone, Debug, PartialEq, Eq)]
238pub enum MusicChange {
239 /// A note starts; the complete note payload makes the stream self-contained.
240 NoteStarted(StaffNote),
241 /// A note ends at the given exact time.
242 NoteEnded {
243 /// Exact release time.
244 at: Time,
245 /// Voice containing the note.
246 voice_id: ObjectId,
247 /// Logical note identity.
248 note_id: ObjectId,
249 /// Event identity.
250 event_id: ObjectId,
251 },
252}
253
254impl MusicChange {
255 /// Returns the exact time at which this change occurs.
256 pub fn at(&self) -> Time {
257 match self {
258 Self::NoteStarted(note) => note.onset,
259 Self::NoteEnded { at, .. } => *at,
260 }
261 }
262}
263
264/// Chronological note-on/note-off representation of a score.
265#[derive(Clone, Debug, PartialEq, Eq)]
266pub struct MusicChangeStream {
267 /// Exact total score span, including trailing silence.
268 pub duration: Time,
269 /// Voice identities, names, and individual spans.
270 pub voices: Vec<ScoreVoice>,
271 /// Changes in deterministic time, release-before-start identity order.
272 pub changes: Vec<MusicChange>,
273}
274
275/// Catalog score representations supported by [`convert_score`].
276#[derive(Clone, Debug, PartialEq, Eq)]
277pub enum ScoreForm {
278 /// Monophonic melody.
279 Melody(Melody),
280 /// Simultaneous chord.
281 Chord(Chord),
282 /// Identity-bearing staff.
283 Staff(Staff),
284 /// Named independent voices.
285 Counterpoint(Counterpoint),
286 /// Timed piano-roll lanes.
287 PianoRoll(PianoRoll),
288 /// Event-boundary snapshots.
289 Snapshot(SnapshotStream),
290 /// Chronological start/end changes.
291 ChangeStream(MusicChangeStream),
292 /// Sequential chord progression.
293 Progression(Progression),
294}
295
296impl ScoreForm {
297 /// Returns this representation's catalog kind.
298 pub fn kind(&self) -> ScoreFormKind {
299 match self {
300 Self::Melody(_) => ScoreFormKind::Melody,
301 Self::Chord(_) => ScoreFormKind::Chord,
302 Self::Staff(_) => ScoreFormKind::Staff,
303 Self::Counterpoint(_) => ScoreFormKind::Counterpoint,
304 Self::PianoRoll(_) => ScoreFormKind::PianoRoll,
305 Self::Snapshot(_) => ScoreFormKind::Snapshot,
306 Self::ChangeStream(_) => ScoreFormKind::ChangeStream,
307 Self::Progression(_) => ScoreFormKind::Progression,
308 }
309 }
310}
311
312/// Target representation for a catalog conversion.
313#[derive(Copy, Clone, Debug, PartialEq, Eq)]
314pub enum ScoreFormKind {
315 /// [`ScoreForm::Melody`].
316 Melody,
317 /// [`ScoreForm::Chord`].
318 Chord,
319 /// [`ScoreForm::Staff`].
320 Staff,
321 /// [`ScoreForm::Counterpoint`].
322 Counterpoint,
323 /// [`ScoreForm::PianoRoll`].
324 PianoRoll,
325 /// [`ScoreForm::Snapshot`].
326 Snapshot,
327 /// [`ScoreForm::ChangeStream`].
328 ChangeStream,
329 /// [`ScoreForm::Progression`].
330 Progression,
331}
332
333/// Explicit choice used when a target cannot represent every simultaneous line.
334#[derive(Copy, Clone, Debug, PartialEq, Eq)]
335pub enum AmbiguousConversionPolicy {
336 /// Reject rather than guess or discard material.
337 Reject,
338 /// Retain the line with the highest first sounding pitch.
339 KeepHighest,
340 /// Retain the line with the lowest first sounding pitch.
341 KeepLowest,
342 /// Retain the first line in canonical identity order.
343 KeepFirst,
344}
345
346/// Machine-readable kind of information a conversion could not carry.
347#[derive(Clone, Debug, PartialEq, Eq)]
348pub enum ConversionLossKind {
349 /// An explicit rest boundary became implicit silence.
350 ExplicitRest,
351 /// A chord or progression label could not be represented.
352 HarmonicLabel,
353 /// A progression key annotation could not be represented.
354 KeyAnnotation,
355 /// Piano-roll grid metadata could not be represented.
356 PianoRollGrid,
357 /// A non-note piano-roll cell could not be represented.
358 NonNoteCell,
359 /// A voice or note was discarded under the selected ambiguity policy.
360 DiscardedVoice,
361 /// Distinct voice boundaries collapsed in a target without voices.
362 VoiceBoundary,
363 /// A voice name or exact silent span could not be represented.
364 VoiceMetadata,
365 /// Stable note or event identities survive only in the report sidecar.
366 IdentityMetadata,
367 /// A zero-duration event is invisible to sounding-note snapshots.
368 ZeroDurationSnapshot,
369 /// Silence could not be represented by the target form.
370 Silence,
371 /// Change-stream boundaries disagreed with their note payload.
372 InconsistentChange,
373 /// A target-only label had to be synthesized.
374 SynthesizedLabel,
375 /// The source music object's structural form is not carried by a semantic event form.
376 SourceStructure,
377 /// An absolute pitch/time anchor was intentionally omitted from a relative form.
378 RelativeAnchor,
379}
380
381/// One explicit, identity-addressed conversion loss.
382#[derive(Clone, Debug, PartialEq, Eq)]
383pub struct ConversionLoss {
384 /// Stable loss classification.
385 pub kind: ConversionLossKind,
386 /// Closest affected identity, when one exists.
387 pub object: Option<ObjectId>,
388 /// Human-readable exact reason.
389 pub detail: String,
390}
391
392impl ConversionLoss {
393 /// Builds one conversion loss with an optional affected score identity.
394 ///
395 /// Conversion owners outside `sim-lib-music-core` use this constructor when
396 /// they reuse [`MusicConversion`] for another exact music representation.
397 pub fn new(
398 kind: ConversionLossKind,
399 object: Option<ObjectId>,
400 detail: impl Into<String>,
401 ) -> Self {
402 Self {
403 kind,
404 object,
405 detail: detail.into(),
406 }
407 }
408}
409
410/// Converted value with identity retention and complete loss evidence.
411#[derive(Clone, Debug, PartialEq, Eq)]
412pub struct MusicConversion<T> {
413 /// Converted value.
414 pub value: T,
415 /// Voice, note, and event identities retained in the result or its report.
416 pub preserved: Vec<ObjectId>,
417 /// Facts not representable in the target form.
418 pub losses: Vec<ConversionLoss>,
419}
420
421impl<T> MusicConversion<T> {
422 /// Maps the converted value while retaining its audit report.
423 pub fn map<U>(self, f: impl FnOnce(T) -> U) -> MusicConversion<U> {
424 MusicConversion {
425 value: f(self.value),
426 preserved: self.preserved,
427 losses: self.losses,
428 }
429 }
430
431 /// Returns `true` when no conversion loss was recorded.
432 pub fn is_lossless(&self) -> bool {
433 self.losses.is_empty()
434 }
435}
436
437/// Error raised when a requested conversion cannot honor its explicit policy.
438#[derive(Debug, Error, Clone, PartialEq, Eq)]
439pub enum ConversionError {
440 /// A source value violated a music-model invariant.
441 #[error(transparent)]
442 Music(#[from] MusicError),
443 /// The explicit reject policy encountered an ambiguous conversion.
444 #[error("ambiguous {from:?} to {to:?} conversion: {detail}")]
445 Ambiguous {
446 /// Source form.
447 from: ScoreFormKind,
448 /// Requested target form.
449 to: ScoreFormKind,
450 /// Stable explanation of the ambiguity.
451 detail: String,
452 },
453 /// An identity was empty or inconsistent with its container.
454 #[error("invalid score identity: {0}")]
455 InvalidIdentity(String),
456 /// Two different score objects shared one identity.
457 #[error("duplicate score identity {0}")]
458 DuplicateIdentity(ObjectId),
459}
460
461pub(crate) fn staff_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}