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#[derive(Debug, Error, Clone, PartialEq, Eq)]
19pub enum LowerError {
20 #[error("TPQ must be non-zero")]
22 ZeroTpq,
23 #[error("pitch is outside MIDI range")]
25 PitchOutOfRange,
26 #[error("velocity is outside 1..=127")]
28 VelocityOutOfRange {
29 velocity: u8,
31 },
32 #[error("channel is outside 0..=15")]
34 ChannelOutOfRange {
35 channel: u8,
37 },
38 #[error("duration cannot be represented at target TPQ")]
40 InexactTime,
41 #[error("tempo must be positive and finite")]
43 InvalidTempo,
44 #[error("time signature denominator must be a power of two")]
46 InvalidTimeSignature,
47 #[error("piano-roll cell {cell_kind} in lane {lane} cannot be exported to SMF")]
49 UnsupportedPianoRollCell {
50 lane: String,
52 cell_kind: String,
54 },
55 #[error(transparent)]
57 Smf(#[from] SmfError),
58}
59
60#[derive(Clone, Debug, PartialEq)]
62pub struct TempoMap {
63 pub points: Vec<(Time, f64)>,
65}
66
67impl TempoMap {
68 pub fn constant(bpm: f64) -> Self {
79 Self {
80 points: vec![(Ratio::from_integer(0), bpm)],
81 }
82 }
83}
84
85#[derive(Clone, Debug, PartialEq)]
87pub struct LowerOpts {
88 pub tpq: u32,
90 pub tempo_map: TempoMap,
92 pub track_split: TrackSplit,
94}
95
96#[derive(Copy, Clone, Debug, PartialEq, Eq)]
98pub enum TrackSplit {
99 SingleTrack,
101 ByChannel,
103 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(¬e.velocity) {
186 return Err(LowerError::VelocityOutOfRange {
187 velocity: note.velocity,
188 });
189 }
190 Ok((midi_key, channel, U7(note.velocity)))
191}
192
193pub fn lower(object: &dyn MusicObject, opts: &LowerOpts) -> Result<SmfFile, LowerError> {
195 build_lowered_file(object, None, opts)
196}
197
198pub 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
205pub 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
211pub 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(¬e.note)?;
427 let start = time_to_ticks(note.onset, tpq)?;
428 let end = time_to_ticks(note.onset + gate_duration(¬e.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
511pub const fn default_us_per_quarter() -> u32 {
513 DEFAULT_US_PER_QUARTER
514}