Skip to main content

sim_lib_music_lower/
model.rs

1use num_rational::Ratio;
2use thiserror::Error;
3
4use sim_lib_midi_core::{
5    Channel, ChannelMessage, DEFAULT_US_PER_QUARTER, MetaBucket, MetaEvent, MidiEvent, MidiPayload,
6    TickTime, U7, bpm_to_us_per_quarter, synthetic_origin,
7};
8use sim_lib_midi_smf::{
9    SmfDivision, SmfError, SmfFile, SmfFormat, SmfTrack, write_smf as write_smf_bytes,
10};
11use sim_lib_music_core::{
12    Articulation, AtomRef, Counterpoint, Music, MusicObject, Note, PianoRoll, Score, Time,
13};
14
15use crate::piano_roll::build_piano_roll_file;
16
17/// Error raised while lowering music to a MIDI file.
18#[derive(Debug, Error, Clone, PartialEq, Eq)]
19pub enum LowerError {
20    /// The target ticks-per-quarter resolution was zero.
21    #[error("TPQ must be non-zero")]
22    ZeroTpq,
23    /// A pitch fell outside the representable MIDI range.
24    #[error("pitch is outside MIDI range")]
25    PitchOutOfRange,
26    /// A note velocity fell outside the playable `1..=127` range.
27    #[error("velocity is outside 1..=127")]
28    VelocityOutOfRange {
29        /// The offending velocity value.
30        velocity: u8,
31    },
32    /// A note channel fell outside the `0..=15` range.
33    #[error("channel is outside 0..=15")]
34    ChannelOutOfRange {
35        /// The offending channel value.
36        channel: u8,
37    },
38    /// A duration could not be represented exactly at the target TPQ.
39    #[error("duration cannot be represented at target TPQ")]
40    InexactTime,
41    /// A tempo value was not positive and finite.
42    #[error("tempo must be positive and finite")]
43    InvalidTempo,
44    /// A time-signature denominator was not a power of two.
45    #[error("time signature denominator must be a power of two")]
46    InvalidTimeSignature,
47    /// A piano-roll cell had no SMF representation.
48    #[error("piano-roll cell {cell_kind} in lane {lane} cannot be exported to SMF")]
49    UnsupportedPianoRollCell {
50        /// The lane containing the unsupported cell.
51        lane: String,
52        /// The kind of the unsupported cell.
53        cell_kind: String,
54    },
55    /// A serialization error surfaced from `sim-lib-midi-smf`.
56    #[error(transparent)]
57    Smf(#[from] SmfError),
58}
59
60/// A piecewise-constant tempo curve mapping music time to beats per minute.
61#[derive(Clone, Debug, PartialEq)]
62pub struct TempoMap {
63    /// Tempo change points as `(time, bpm)` pairs in time order.
64    pub points: Vec<(Time, f64)>,
65}
66
67impl TempoMap {
68    /// Builds a tempo map holding a single constant tempo from time zero.
69    ///
70    /// # Examples
71    ///
72    /// ```
73    /// use sim_lib_music_lower::TempoMap;
74    ///
75    /// let map = TempoMap::constant(120.0);
76    /// assert_eq!(map.points.len(), 1);
77    /// ```
78    pub fn constant(bpm: f64) -> Self {
79        Self {
80            points: vec![(Ratio::from_integer(0), bpm)],
81        }
82    }
83}
84
85/// Options controlling how music is lowered to a MIDI file.
86#[derive(Clone, Debug, PartialEq)]
87pub struct LowerOpts {
88    /// Ticks-per-quarter resolution of the output file.
89    pub tpq: u32,
90    /// Tempo curve written as tempo meta events.
91    pub tempo_map: TempoMap,
92    /// Policy for splitting notes across MIDI tracks.
93    pub track_split: TrackSplit,
94}
95
96/// Policy for distributing lowered notes across MIDI tracks.
97#[derive(Copy, Clone, Debug, PartialEq, Eq)]
98pub enum TrackSplit {
99    /// Write all notes to a single track.
100    SingleTrack,
101    /// Split notes into one track per MIDI channel.
102    ByChannel,
103    /// Split notes into one track per counterpoint voice.
104    CounterpointVoices,
105}
106
107impl Default for LowerOpts {
108    fn default() -> Self {
109        Self {
110            tpq: 480,
111            tempo_map: TempoMap::constant(120.0),
112            track_split: TrackSplit::SingleTrack,
113        }
114    }
115}
116
117#[derive(Clone, Debug, PartialEq, Eq)]
118struct LoweredNote {
119    onset: Time,
120    note: Note,
121}
122
123#[derive(Clone, Debug, PartialEq, Eq)]
124struct LowerTrack {
125    name: String,
126    notes: Vec<LoweredNote>,
127}
128
129pub(crate) fn checked_tick_time(ticks: i64, tpq: u32) -> Result<TickTime, LowerError> {
130    TickTime::new(ticks, tpq).map_err(|_| LowerError::ZeroTpq)
131}
132
133pub(crate) fn time_to_ticks(time: Time, tpq: u32) -> Result<i64, LowerError> {
134    if tpq == 0 {
135        return Err(LowerError::ZeroTpq);
136    }
137    let scaled = time * Ratio::from_integer(i64::from(tpq) * 4);
138    if *scaled.denom() != 1 {
139        return Err(LowerError::InexactTime);
140    }
141    Ok(*scaled.numer())
142}
143
144fn bpm_to_tempo_meta(bpm: f64) -> Result<MetaEvent, LowerError> {
145    if !bpm.is_finite() || bpm <= 0.0 {
146        return Err(LowerError::InvalidTempo);
147    }
148    Ok(MetaEvent::Tempo {
149        us_per_quarter: bpm_to_us_per_quarter(bpm),
150    })
151}
152
153pub(crate) fn tempo_meta_events(opts: &LowerOpts) -> Result<Vec<MidiEvent>, LowerError> {
154    let points = if opts.tempo_map.points.is_empty() {
155        vec![(Ratio::from_integer(0), 120.0)]
156    } else {
157        opts.tempo_map.points.clone()
158    };
159    points
160        .into_iter()
161        .map(|(time, bpm)| {
162            let ticks = time_to_ticks(time, opts.tpq)?;
163            Ok(MidiEvent {
164                time: checked_tick_time(ticks, opts.tpq)?,
165                origin: synthetic_origin(),
166                payload: MidiPayload::Meta(bpm_to_tempo_meta(bpm)?),
167            })
168        })
169        .collect()
170}
171
172fn gate_duration(note: &Note) -> Time {
173    match note.articulation {
174        Articulation::Staccato => note.duration / Ratio::from_integer(2),
175        Articulation::Legato | Articulation::Tenuto => note.duration,
176        _ => note.duration,
177    }
178}
179
180pub(crate) fn validate_note(note: &Note) -> Result<(u8, Channel, U7), LowerError> {
181    let midi_key = note.pitch.to_midi().ok_or(LowerError::PitchOutOfRange)?;
182    let channel = Channel::new(note.channel.0).map_err(|_| LowerError::ChannelOutOfRange {
183        channel: note.channel.0,
184    })?;
185    if !(1..=127).contains(&note.velocity) {
186        return Err(LowerError::VelocityOutOfRange {
187            velocity: note.velocity,
188        });
189    }
190    Ok((midi_key, channel, U7(note.velocity)))
191}
192
193/// Lowers any music object into an in-memory MIDI file.
194pub fn lower(object: &dyn MusicObject, opts: &LowerOpts) -> Result<SmfFile, LowerError> {
195    build_lowered_file(object, None, opts)
196}
197
198/// Lowers a `Score` into a MIDI file, applying the score's tempo to the map.
199pub fn lower_score(score: &Score, opts: &LowerOpts) -> Result<SmfFile, LowerError> {
200    let mut opts = opts.clone();
201    opts.tempo_map = score_tempo_map(score, &opts.tempo_map);
202    build_lowered_file(&score.body, Some(score), &opts)
203}
204
205/// Lowers a `Score` and serializes the resulting MIDI file to bytes.
206pub fn write_smf(score: &Score, opts: &LowerOpts) -> Result<Vec<u8>, LowerError> {
207    let file = lower_score(score, opts)?;
208    Ok(write_smf_bytes(&file)?)
209}
210
211/// Returns whether two music objects lower to byte-identical MIDI files.
212pub fn equivalent_under_lowering(
213    left: &dyn MusicObject,
214    right: &dyn MusicObject,
215    opts: &LowerOpts,
216) -> Result<bool, LowerError> {
217    Ok(lower(left, opts)? == lower(right, opts)?)
218}
219
220fn build_lowered_file(
221    object: &dyn MusicObject,
222    score: Option<&Score>,
223    opts: &LowerOpts,
224) -> Result<SmfFile, LowerError> {
225    if let Some(roll) = piano_roll_object(object) {
226        return build_piano_roll_file(roll, score, opts);
227    }
228    let note_tracks = build_note_tracks(object, opts.track_split);
229    let multi_track = note_tracks.len() > 1;
230    let mut tracks = Vec::new();
231    let mut meta_events = tempo_meta_events(opts)?;
232    if let Some(score) = score {
233        meta_events.extend(score_meta_events(score, opts.tpq)?);
234    }
235    if multi_track {
236        tracks.push(SmfTrack {
237            events: with_track_name("Conductor", meta_events, opts.tpq)?,
238        });
239        for track in note_tracks {
240            tracks.push(SmfTrack {
241                events: lower_track_events(&track, opts.tpq)?,
242            });
243        }
244    } else {
245        let mut events = meta_events;
246        if let Some(track) = note_tracks.first() {
247            events.extend(lowered_note_events(&track.notes, opts.tpq)?);
248            let name = score.map_or_else(|| "Music".to_owned(), |_| "Score".to_owned());
249            tracks.push(SmfTrack {
250                events: with_track_name(&name, events, opts.tpq)?,
251            });
252        } else {
253            let name = score.map_or_else(|| "Music".to_owned(), |_| "Score".to_owned());
254            tracks.push(SmfTrack {
255                events: with_track_name(&name, events, opts.tpq)?,
256            });
257        }
258    }
259    let mut file = SmfFile {
260        format: if multi_track {
261            SmfFormat::Simultaneous
262        } else {
263            SmfFormat::SingleTrack
264        },
265        division: metrical_division(opts.tpq)?,
266        tracks,
267    };
268    file.canonicalize();
269    Ok(file)
270}
271
272pub(crate) fn metrical_division(tpq: u32) -> Result<SmfDivision, LowerError> {
273    let tpq = u16::try_from(tpq).map_err(|_| SmfError::TpqOutOfRange(tpq))?;
274    SmfDivision::metrical(tpq).ok_or_else(|| SmfError::TpqOutOfRange(u32::from(tpq)).into())
275}
276
277fn score_tempo_map(score: &Score, opts_tempo_map: &TempoMap) -> TempoMap {
278    let mut points = vec![(Ratio::from_integer(0), f64::from(score.tempo_bpm))];
279    points.extend(
280        opts_tempo_map
281            .points
282            .iter()
283            .filter(|(time, _)| *time > Ratio::from_integer(0))
284            .cloned(),
285    );
286    TempoMap { points }
287}
288
289pub(crate) fn score_meta_events(score: &Score, tpq: u32) -> Result<Vec<MidiEvent>, LowerError> {
290    let zero = checked_tick_time(0, tpq)?;
291    let mut events = vec![MidiEvent {
292        time: zero,
293        origin: synthetic_origin(),
294        payload: MidiPayload::Meta(MetaEvent::TimeSig {
295            num: score.time_signature.0,
296            den_pow2: time_signature_den_pow2(score.time_signature.1)?,
297            clocks_per_click: 24,
298            thirty_seconds_per_quarter: 8,
299        }),
300    }];
301    if let Some(key) = score.key.as_deref().and_then(parse_key_signature) {
302        events.push(MidiEvent {
303            time: zero,
304            origin: synthetic_origin(),
305            payload: MidiPayload::Meta(MetaEvent::KeySig {
306                sharps_flats: key.0,
307                minor: key.1,
308            }),
309        });
310    }
311    Ok(events)
312}
313
314fn build_note_tracks(object: &dyn MusicObject, track_split: TrackSplit) -> Vec<LowerTrack> {
315    match track_split {
316        TrackSplit::SingleTrack => vec![LowerTrack {
317            name: object.kind().to_owned(),
318            notes: collect_notes(object),
319        }],
320        TrackSplit::ByChannel => channel_tracks(object),
321        TrackSplit::CounterpointVoices => counterpoint_tracks(object).unwrap_or_else(|| {
322            vec![LowerTrack {
323                name: "Voice 1".to_owned(),
324                notes: collect_notes(object),
325            }]
326        }),
327    }
328}
329
330fn collect_notes(object: &dyn MusicObject) -> Vec<LoweredNote> {
331    let mut atoms = Vec::new();
332    object.voices(Ratio::from_integer(0), &mut atoms);
333    let mut notes = atoms
334        .into_iter()
335        .filter_map(|atom| match atom.atom {
336            AtomRef::Note(note) => Some(LoweredNote {
337                onset: atom.onset,
338                note,
339            }),
340            AtomRef::Rest(_) | AtomRef::Phantom(_) => None,
341        })
342        .collect::<Vec<_>>();
343    notes.sort_by_key(|lowered| {
344        (
345            lowered.onset,
346            lowered.note.channel.0,
347            lowered.note.pitch.semitone(),
348        )
349    });
350    notes
351}
352
353fn channel_tracks(object: &dyn MusicObject) -> Vec<LowerTrack> {
354    let mut groups = std::collections::BTreeMap::<u8, Vec<LoweredNote>>::new();
355    for note in collect_notes(object) {
356        groups.entry(note.note.channel.0).or_default().push(note);
357    }
358    if groups.is_empty() {
359        return vec![LowerTrack {
360            name: object.kind().to_owned(),
361            notes: Vec::new(),
362        }];
363    }
364    groups
365        .into_iter()
366        .map(|(channel, notes)| LowerTrack {
367            name: format!("Channel {}", channel + 1),
368            notes,
369        })
370        .collect()
371}
372
373fn counterpoint_tracks(object: &dyn MusicObject) -> Option<Vec<LowerTrack>> {
374    let counterpoint = score_counterpoint(object)?;
375    let tracks = counterpoint
376        .voices
377        .iter()
378        .zip(counterpoint.normalized_voice_names())
379        .map(|(voice, name)| LowerTrack {
380            name,
381            notes: collect_notes(voice),
382        })
383        .collect::<Vec<_>>();
384    Some(if tracks.is_empty() {
385        vec![LowerTrack {
386            name: "Voice 1".to_owned(),
387            notes: Vec::new(),
388        }]
389    } else {
390        tracks
391    })
392}
393
394fn score_counterpoint(object: &dyn MusicObject) -> Option<&Counterpoint> {
395    if let Some(counterpoint) = object.as_any().downcast_ref::<Counterpoint>() {
396        return Some(counterpoint);
397    }
398    if let Some(Music::Counterpoint(counterpoint)) = object.as_any().downcast_ref::<Music>() {
399        return Some(counterpoint);
400    }
401    let score = object.as_any().downcast_ref::<Score>()?;
402    match &score.body {
403        Music::Counterpoint(counterpoint) => Some(counterpoint),
404        _ => None,
405    }
406}
407
408fn piano_roll_object(object: &dyn MusicObject) -> Option<&PianoRoll> {
409    if let Some(roll) = object.as_any().downcast_ref::<PianoRoll>() {
410        return Some(roll);
411    }
412    if let Some(Music::PianoRoll(roll)) = object.as_any().downcast_ref::<Music>() {
413        return Some(roll);
414    }
415    None
416}
417
418fn lower_track_events(track: &LowerTrack, tpq: u32) -> Result<Vec<MidiEvent>, LowerError> {
419    let events = lowered_note_events(&track.notes, tpq)?;
420    with_track_name(&track.name, events, tpq)
421}
422
423fn lowered_note_events(notes: &[LoweredNote], tpq: u32) -> Result<Vec<MidiEvent>, LowerError> {
424    let mut events = Vec::with_capacity(notes.len().saturating_mul(2));
425    for note in notes {
426        let (midi_key, channel, velocity) = validate_note(&note.note)?;
427        let start = time_to_ticks(note.onset, tpq)?;
428        let end = time_to_ticks(note.onset + gate_duration(&note.note), tpq)?;
429        events.push(MidiEvent {
430            time: checked_tick_time(start, tpq)?,
431            origin: synthetic_origin(),
432            payload: MidiPayload::Channel(ChannelMessage::NoteOn {
433                ch: channel,
434                key: U7(midi_key),
435                vel: velocity,
436            }),
437        });
438        events.push(MidiEvent {
439            time: checked_tick_time(end, tpq)?,
440            origin: synthetic_origin(),
441            payload: MidiPayload::Channel(ChannelMessage::NoteOff {
442                ch: channel,
443                key: U7(midi_key),
444                vel: U7(0),
445            }),
446        });
447    }
448    Ok(events)
449}
450
451pub(crate) fn with_track_name(
452    name: &str,
453    mut events: Vec<MidiEvent>,
454    tpq: u32,
455) -> Result<Vec<MidiEvent>, LowerError> {
456    events.push(MidiEvent {
457        time: checked_tick_time(0, tpq)?,
458        origin: synthetic_origin(),
459        payload: MidiPayload::Meta(MetaEvent::Other(MetaBucket {
460            type_byte: 0x03,
461            data: name.as_bytes().to_vec(),
462        })),
463    });
464    Ok(events)
465}
466
467fn time_signature_den_pow2(denominator: u8) -> Result<u8, LowerError> {
468    if denominator == 0 || !denominator.is_power_of_two() {
469        return Err(LowerError::InvalidTimeSignature);
470    }
471    Ok(denominator.trailing_zeros() as u8)
472}
473
474fn parse_key_signature(key: &str) -> Option<(i8, bool)> {
475    let trimmed = key.trim();
476    let (tonic, minor) = trimmed
477        .strip_suffix('m')
478        .map(|tonic| (tonic, true))
479        .unwrap_or((trimmed, false));
480    let sharps_flats = match tonic {
481        "C" => 0,
482        "G" => 1,
483        "D" => 2,
484        "A" => 3,
485        "E" => 4,
486        "B" => 5,
487        "F#" | "Gb" => {
488            if tonic == "F#" {
489                6
490            } else {
491                -6
492            }
493        }
494        "C#" | "Db" => {
495            if tonic == "C#" {
496                7
497            } else {
498                -5
499            }
500        }
501        "F" => -1,
502        "Bb" => -2,
503        "Eb" => -3,
504        "Ab" => -4,
505        "Cb" => -7,
506        _ => return None,
507    };
508    Some((sharps_flats, minor))
509}
510
511/// Returns the default microseconds-per-quarter-note tempo value.
512pub const fn default_us_per_quarter() -> u32 {
513    DEFAULT_US_PER_QUARTER
514}