Skip to main content

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 [&note.note_id, &note.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}