Skip to main content

sound_to_patch/notes/
midi.rs

1//! Reading a note list from a Standard MIDI File.
2//!
3//! A render analysed against the MIDI file that produced it needs no
4//! transcription: the file gives the notes. Note times come from the file's
5//! own tempo map, and every tempo in it must match the stem's BPM, because
6//! a wrong time base would shift every note against the audio.
7
8use std::collections::HashMap;
9use std::error::Error;
10use std::fmt;
11use std::io;
12use std::path::{Path, PathBuf};
13
14use midly::{Format, MetaMessage, MidiMessage, Smf, Timing, TrackEventKind};
15
16use super::{Note, NoteError, NoteList, Velocity};
17use crate::cast;
18use crate::units::{Bpm, MidiPitch, Seconds};
19
20/// The tempo of a file without a tempo event, by the MIDI standard.
21const DEFAULT_TEMPO_US: u32 = 500_000;
22/// How far a tempo in the file may lie from `--bpm`. A tempo is stored in
23/// whole microseconds per beat, which rounds 140 BPM to 140.00003.
24pub const BPM_TOLERANCE: f64 = 0.01;
25/// The General MIDI percussion channel, channel 10, counted from 0. Its
26/// keys name drums, not pitches.
27pub const DRUM_CHANNEL: u8 = 9;
28
29/// Reads the notes of a MIDI file at `path`, checking its tempo against
30/// `bpm`.
31///
32/// Every note-on with a velocity above 0 starts a note, and the next
33/// note-off of the same key on the same channel and track ends it, first
34/// in, first out. A note still sounding at the end of its track ends there.
35/// A note of zero length carries no sound and is left out. Notes on the
36/// percussion channel, channel 10 ([`DRUM_CHANNEL`] counted from 0), are
37/// drums whose keys are no pitch, so they are left out too.
38///
39/// # Errors
40///
41/// Returns a [`MidiError`] that names the file and the cause: an unreadable
42/// or malformed file, a sequential (format 2) file, a tempo that differs
43/// from `bpm`, or a file without notes.
44pub fn read_midi(path: &Path, bpm: Bpm) -> Result<NoteList, MidiError> {
45    read_midi_near(path, bpm, None)
46}
47
48/// Reads the notes of a MIDI file at `path` as [`read_midi`] does, and
49/// also accepts a file whose every tempo matches `refined`, the trusted
50/// tempo refined from the stem. A `refined` equal to `bpm` counts as none.
51///
52/// # Errors
53///
54/// As [`read_midi`], with a tempo that matches neither `bpm` nor
55/// `refined`.
56pub fn read_midi_near(path: &Path, bpm: Bpm, refined: Option<Bpm>) -> Result<NoteList, MidiError> {
57    let bytes = std::fs::read(path).map_err(|source| MidiError {
58        path: path.to_path_buf(),
59        kind: Kind::Open(source),
60    })?;
61    notes_near(path, &bytes, bpm, refined)
62}
63
64/// Reads the notes of a MIDI file at `path` as [`read_midi`] does, and
65/// returns the file's bytes with them, so a caller can hash the very bytes
66/// it read.
67///
68/// # Errors
69///
70/// As [`read_midi`].
71pub fn read_midi_bytes(path: &Path, bpm: Bpm) -> Result<(NoteList, Vec<u8>), MidiError> {
72    let bytes = std::fs::read(path).map_err(|source| MidiError {
73        path: path.to_path_buf(),
74        kind: Kind::Open(source),
75    })?;
76    Ok((notes_near(path, &bytes, bpm, None)?, bytes))
77}
78
79fn notes_near(
80    path: &Path,
81    bytes: &[u8],
82    bpm: Bpm,
83    refined: Option<Bpm>,
84) -> Result<NoteList, MidiError> {
85    let fail = |kind| MidiError {
86        path: path.to_path_buf(),
87        kind,
88    };
89    let smf = Smf::parse(bytes).map_err(|source| fail(Kind::Parse(source.to_string())))?;
90    match (notes_of(&smf, bpm), refined) {
91        (Err(Kind::Tempo { .. }), Some(refined)) if refined != bpm => notes_of(&smf, refined)
92            .map_err(|kind| match kind {
93                Kind::Tempo {
94                    file_bpm, default, ..
95                } => Kind::Tempo {
96                    file_bpm,
97                    bpm,
98                    refined: Some(refined),
99                    default,
100                },
101                other => other,
102            }),
103        (result, _) => result,
104    }
105    .map_err(fail)
106}
107
108fn notes_of(smf: &Smf<'_>, bpm: Bpm) -> Result<NoteList, Kind> {
109    if smf.header.format == Format::Sequential {
110        return Err(Kind::Sequential);
111    }
112    let clock = match smf.header.timing {
113        Timing::Metrical(ticks) => Clock::metrical(smf, ticks.as_int(), bpm)?,
114        Timing::Timecode(fps, subframes) => Clock::Timecode {
115            ticks_per_second: f64::from(fps.as_f32()) * f64::from(subframes),
116        },
117    };
118    let mut notes = Vec::new();
119    let mut drums = 0_usize;
120    for (track, events) in smf.tracks.iter().enumerate() {
121        let mut tick = 0_u64;
122        let mut open: HashMap<(u8, u8), Vec<(u64, u8)>> = HashMap::new();
123        for event in events {
124            tick += u64::from(event.delta.as_int());
125            let TrackEventKind::Midi { channel, message } = event.kind else {
126                continue;
127            };
128            if channel.as_int() == DRUM_CHANNEL {
129                if matches!(message, MidiMessage::NoteOn { vel, .. } if vel.as_int() > 0) {
130                    drums += 1;
131                }
132                continue;
133            }
134            let slot = |key: midly::num::u7| (channel.as_int(), key.as_int());
135            match message {
136                MidiMessage::NoteOn { key, vel } if vel.as_int() > 0 => {
137                    open.entry(slot(key))
138                        .or_default()
139                        .push((tick, vel.as_int()));
140                }
141                MidiMessage::NoteOn { key, .. } | MidiMessage::NoteOff { key, .. } => {
142                    if let Some(queue) = open.get_mut(&slot(key))
143                        && !queue.is_empty()
144                    {
145                        let (onset, velocity) = queue.remove(0);
146                        push_note(
147                            &mut notes,
148                            &clock,
149                            track,
150                            onset,
151                            tick,
152                            key.as_int(),
153                            velocity,
154                        )?;
155                    }
156                }
157                _ => {}
158            }
159        }
160        let mut unclosed: Vec<_> = open
161            .into_iter()
162            .flat_map(|((_, key), queue)| queue.into_iter().map(move |(onset, v)| (onset, key, v)))
163            .collect();
164        unclosed.sort_unstable();
165        for (onset, key, velocity) in unclosed {
166            push_note(&mut notes, &clock, track, onset, tick, key, velocity)?;
167        }
168    }
169    if notes.is_empty() {
170        return Err(Kind::NoNotes { drums });
171    }
172    Ok(NoteList::new(notes))
173}
174
175fn push_note(
176    notes: &mut Vec<Note>,
177    clock: &Clock,
178    track: usize,
179    onset: u64,
180    offset: u64,
181    key: u8,
182    velocity: u8,
183) -> Result<(), Kind> {
184    if offset <= onset {
185        return Ok(());
186    }
187    let note = Note::new(
188        clock.seconds(onset),
189        clock.seconds(offset),
190        MidiPitch::clamped(f64::from(key)),
191    )
192    .and_then(|note| Ok(note.with_velocity(Velocity::new(velocity)?)))
193    .map_err(|source| Kind::Note { track, source })?;
194    notes.push(note);
195    Ok(())
196}
197
198/// Ticks to seconds.
199enum Clock {
200    /// Ticks per beat with a tempo map of `(tick, seconds at that tick,
201    /// seconds per tick from there)`, sorted by tick.
202    Metrical { map: Vec<(u64, f64, f64)> },
203    /// A fixed number of ticks per second.
204    Timecode { ticks_per_second: f64 },
205}
206
207impl Clock {
208    /// The tempo map of a metrical file, from the tempo events of every
209    /// track. Every tempo, including the default before the first event,
210    /// must match `bpm`.
211    fn metrical(smf: &Smf<'_>, ticks_per_beat: u16, bpm: Bpm) -> Result<Self, Kind> {
212        let mut tempos = Vec::new();
213        for events in &smf.tracks {
214            let mut tick = 0_u64;
215            for event in events {
216                tick += u64::from(event.delta.as_int());
217                if let TrackEventKind::Meta(MetaMessage::Tempo(us)) = event.kind {
218                    tempos.push((tick, us.as_int(), false));
219                }
220            }
221        }
222        tempos.sort_by_key(|&(tick, _, _)| tick);
223        if tempos.first().is_none_or(|&(tick, _, _)| tick > 0) {
224            tempos.insert(0, (0, DEFAULT_TEMPO_US, true));
225        }
226        let per_beat = f64::from(ticks_per_beat.max(1));
227        let mut map: Vec<(u64, f64, f64)> = Vec::with_capacity(tempos.len());
228        for (tick, us, default) in tempos {
229            let file_bpm = 60e6 / f64::from(us.max(1));
230            if (file_bpm - bpm.get()).abs() > BPM_TOLERANCE {
231                return Err(Kind::Tempo {
232                    file_bpm,
233                    bpm,
234                    refined: None,
235                    default,
236                });
237            }
238            let seconds = map.last().map_or(0.0, |&(start, at, per_tick)| {
239                at + cast::u64_to_f64(tick - start) * per_tick
240            });
241            map.push((tick, seconds, f64::from(us) * 1e-6 / per_beat));
242        }
243        Ok(Self::Metrical { map })
244    }
245
246    fn seconds(&self, tick: u64) -> Seconds {
247        let seconds = match self {
248            Self::Metrical { map } => {
249                let index = map.partition_point(|&(start, _, _)| start <= tick);
250                map.get(index.saturating_sub(1))
251                    .map_or(0.0, |&(start, at, per_tick)| {
252                        at + cast::u64_to_f64(tick - start) * per_tick
253                    })
254            }
255            Self::Timecode { ticks_per_second } => {
256                cast::u64_to_f64(tick) / ticks_per_second.max(1.0)
257            }
258        };
259        Seconds::clamped(seconds)
260    }
261}
262
263/// A MIDI file that cannot give a note list.
264#[derive(Debug)]
265pub struct MidiError {
266    path: PathBuf,
267    kind: Kind,
268}
269
270impl MidiError {
271    /// Returns the path of the file.
272    #[must_use]
273    pub fn path(&self) -> &Path {
274        &self.path
275    }
276}
277
278#[derive(Debug)]
279enum Kind {
280    Open(io::Error),
281    Parse(String),
282    Sequential,
283    Tempo {
284        file_bpm: f64,
285        bpm: Bpm,
286        refined: Option<Bpm>,
287        default: bool,
288    },
289    Note {
290        track: usize,
291        source: NoteError,
292    },
293    NoNotes {
294        drums: usize,
295    },
296}
297
298impl fmt::Display for MidiError {
299    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
300        write!(f, "cannot read the notes of {}: ", self.path.display())?;
301        match &self.kind {
302            Kind::Open(source) => write!(f, "cannot open the file ({source}). Check the path"),
303            Kind::Parse(message) => write!(
304                f,
305                "the file is not a valid MIDI file ({message}). Export the notes again as a Standard MIDI File"
306            ),
307            Kind::Sequential => write!(
308                f,
309                "the file is a sequential (format 2) MIDI file. Export the notes as format 0 or 1"
310            ),
311            Kind::Tempo {
312                file_bpm,
313                bpm,
314                refined,
315                default,
316            } => {
317                let origin = if *default {
318                    " (the MIDI default; the file has no tempo event at its start)"
319                } else {
320                    ""
321                };
322                let refined = refined.map_or_else(String::new, |refined| {
323                    format!(" and the tempo refined from the stem is {}", refined.get())
324                });
325                write!(
326                    f,
327                    "the file plays at {file_bpm:.3} BPM{origin}, but --bpm is {}{refined}. Export the notes at the stem's tempo or pass the file's tempo as --bpm",
328                    bpm.get()
329                )
330            }
331            Kind::Note { track, source } => write!(f, "track {track} holds a bad note: {source}"),
332            Kind::NoNotes { drums: 0 } => {
333                write!(f, "the file holds no notes. Export the notes again")
334            }
335            Kind::NoNotes { drums } => write!(
336                f,
337                "the file holds only drum notes ({drums} on channel 10), which have no pitch. Export the pitched part"
338            ),
339        }
340    }
341}
342
343impl Error for MidiError {
344    fn source(&self) -> Option<&(dyn Error + 'static)> {
345        match &self.kind {
346            Kind::Open(source) => Some(source),
347            Kind::Note { source, .. } => Some(source),
348            _ => None,
349        }
350    }
351}