"""MIDI input interface."""
from collections import OrderedDict, defaultdict
from operator import attrgetter
from pathlib import Path
from typing import List, Union
from mido import MidiFile, tempo2bpm
from pretty_midi import Instrument
from pretty_midi import Note as PrettyMIDINote
from pretty_midi import PrettyMIDI
from ..classes import (
Annotation,
KeySignature,
Lyric,
Metadata,
Note,
Tempo,
TimeSignature,
Track,
)
from ..music import Music
from ..utils import note_str_to_note_num
[docs]class MIDIError(Exception):
"""An error class for MIDI related exceptions."""
def _is_drum(channel):
return channel == 9
[docs]def from_mido(midi: MidiFile, duplicate_note_mode: str = "fifo") -> Music:
"""Return a mido MidiFile object as a Music object.
Parameters
----------
midi : :class:`mido.MidiFile`
Mido MidiFile object to convert.
duplicate_note_mode : {'fifo', 'lifo, 'close_all'}
Policy for dealing with duplicate notes. When a note off
message is presetned while there are multiple correspoding note
on messages that have not yet been closed, we need a policy to
decide which note on messages to close. Defaults to 'fifo'.
- 'fifo' (first in first out): close the earliest note on
- 'lifo' (first in first out):close the latest note on
- 'close_all': close all note on messages
Returns
-------
:class:`muspy.Music`
Converted Music object.
"""
if duplicate_note_mode.lower() not in ("fifo", "lifo", "close_all"):
raise ValueError(
"`duplicate_note_mode` must be one of 'fifo', 'lifo' and "
"'close_all'."
)
def _get_active_track(t_idx, program, channel):
"""Return the active track."""
key = (program, channel)
if key in tracks[t_idx]:
return tracks[t_idx][key]
tracks[t_idx][key] = Track(program, _is_drum(channel))
return tracks[t_idx][key]
# Raise MIDIError if the MIDI file is of Type 2 (i.e., asynchronous)
if midi.type == 2:
raise MIDIError("Type 2 MIDI file is not supported.")
# Raise MIDIError if ticks_per_beat is not positive
if midi.ticks_per_beat < 1:
raise MIDIError("`ticks_per_beat` must be positive.")
time = 0
song_title = None
tempos, key_signatures, time_signatures = [], [], []
lyrics, annotations = [], []
copyrights = []
# Create a list to store converted tracks
tracks: List[OrderedDict] = [
OrderedDict() for _ in range(len(midi.tracks))
]
# Create a list to store track names
track_names = [None] * len(midi.tracks)
# Iterate over MIDI tracks
for track_idx, midi_track in enumerate(midi.tracks):
# Set current time to zero
time = 0
# Keep track of the program used in each channel
channel_programs = [0] * 16
# Keep track of active note on messages
active_notes = defaultdict(list)
# Iterate over MIDI messages
for msg in midi_track:
# Update current time (delta time is used in a MIDI message)
time += msg.time
# === Meta Data ===
# Tempo messages
if msg.type == "set_tempo":
tempos.append(Tempo(time=time, qpm=tempo2bpm(msg.tempo)))
# Key signature messages
elif msg.type == "key_signature":
if msg.key.endswith("m"):
mode = "minor"
root = note_str_to_note_num(msg.key[:-1])
else:
mode = "major"
root = note_str_to_note_num(msg.key)
key_signatures.append(
KeySignature(time=time, root=root, mode=mode)
)
# Time signature messages
elif msg.type == "time_signature":
time_signatures.append(
TimeSignature(
time=time,
numerator=msg.numerator,
denominator=msg.denominator,
)
)
# Lyric messages
elif msg.type == "lyrics":
lyrics.append(Lyric(time=time, lyric=msg.text))
# Marker messages
elif msg.type == "marker":
annotations.append(
Annotation(time=time, annotation=msg.text, group="marker")
)
# Text messages
elif msg.type == "text":
annotations.append(
Annotation(time=time, annotation=msg.text, group="text")
)
# Copyright messages
elif msg.type == "copyright":
copyrights.append(msg.text)
# === Track specific Data ===
# Track name messages
elif msg.type == "track_name":
if midi.type == 0 or track_idx == 0:
song_title = msg.name
else:
track_names[track_idx] = msg.name
# Program change messages
elif msg.type == "program_change":
# Change program of the channel
channel_programs[msg.channel] = msg.program
# Note on messages
elif msg.type == "note_on" and msg.velocity > 0:
# Will later be closed by a note off message
active_notes[(msg.channel, msg.note)].append(
(time, msg.velocity)
)
# Note off messages
# NOTE: A note on message with a zero velocity is also
# considered a note off message
elif msg.type == "note_off" or (
msg.type == "note_on" and msg.velocity == 0
):
# Skip it if there is no active notes
note_key = (msg.channel, msg.note)
if not active_notes[note_key]:
continue
# Get the active track
program = channel_programs[msg.channel]
track = _get_active_track(track_idx, program, msg.channel)
# NOTE: There is no way to disambiguate duplicate notes
# (of the same pitch on the same channel). Thus, we
# need a policy for duplicate mode.
# 'FIFO': (first in first out) close the earliest note
if duplicate_note_mode.lower() == "fifo":
onset, velocity = active_notes[note_key][0]
track.notes.append(
Note(
time=onset,
pitch=msg.note,
duration=time - onset,
velocity=velocity,
)
)
del active_notes[note_key][0]
# 'LIFO': (last in first out) close the latest note on
elif duplicate_note_mode.lower() == "lifo":
onset, velocity = active_notes[note_key][-1]
track.notes.append(
Note(
time=onset,
pitch=msg.note,
duration=time - onset,
velocity=velocity,
)
)
del active_notes[note_key][-1]
# 'close_all' - close all note on messages
elif duplicate_note_mode.lower() in ("close_all", "close all"):
for onset, velocity in active_notes[note_key]:
track.notes.append(
Note(
time=onset,
pitch=msg.note,
duration=time - onset,
velocity=velocity,
)
)
del active_notes[note_key]
# End of track message
elif msg.type == "end_of_track":
break
# Close all active notes
for (channel, note), note_ons in active_notes.items():
program = channel_programs[channel]
track = _get_active_track(track_idx, program, channel)
for onset, velocity in note_ons:
track.notes.append(
Note(
time=onset,
pitch=note,
duration=time - onset,
velocity=velocity,
)
)
music_tracks = []
for track, track_name in zip(tracks, track_names):
for sub_track in track.values():
sub_track.name = track_name
music_tracks.extend(track.values())
# Sort notes
for music_track in music_tracks:
music_track.notes.sort(
key=attrgetter("time", "pitch", "duration", "velocity")
)
# Meta data
metadata = Metadata(
title=song_title,
source_format="midi",
copyright=" ".join(copyrights) if copyrights else None,
)
return Music(
metadata=metadata,
resolution=midi.ticks_per_beat,
tempos=tempos,
key_signatures=key_signatures,
time_signatures=time_signatures,
lyrics=lyrics,
tracks=music_tracks,
)
def read_midi_mido(
path: Union[str, Path], duplicate_note_mode: str = "fifo"
) -> Music:
"""Read a MIDI file into a Music object using mido backend.
Parameters
----------
path : str or Path
Path to the MIDI file to read.
duplicate_note_mode : {'fifo', 'lifo, 'close_all'}
Policy for dealing with duplicate notes. When a note off message
is presetned while there are multiple correspoding note on
messages that have not yet been closed, we need a policy to
decide which note on messages to close. Defaults to 'fifo'.
- 'fifo' (first in first out): close the earliest note on
- 'lifo' (first in first out):close the latest note on
- 'close_all': close all note on messages
Returns
-------
:class:`muspy.Music`
Converted Music object.
"""
midi = MidiFile(filename=str(path))
music = from_mido(midi, duplicate_note_mode=duplicate_note_mode)
music.metadata.source_filename = Path(path).name
return music
def parse_pretty_midi_key_signatures(midi: PrettyMIDI) -> List[KeySignature]:
"""Return KeySignature objects parsed from a PrettyMIDI object.
Parameters
----------
midi : :class:`pretty_midi.PrettyMIDI`
PrettyMIDI object to convert.
Returns
-------
list of :class:`muspy.KeySignature`
Parsed key signatures.
"""
key_signatures = []
for key_signature in midi.key_signature_changes:
is_minor, root = divmod(key_signature.key_number, 12)
mode = "minor" if is_minor else "major"
key_signatures.append(KeySignature(key_signature.time, root, mode))
return key_signatures
def parse_pretty_midi_time_signatures(midi: PrettyMIDI) -> List[TimeSignature]:
"""Return TimeSignature objects parsed from a PrettyMIDI object.
Parameters
----------
midi : :class:`pretty_midi.PrettyMIDI`
PrettyMIDI object to convert.
Returns
-------
list of :class:`muspy.TimeSignature`
Parsed time signatures.
"""
time_signatures = []
for time_signature in midi.time_signature_changes:
time_signatures.append(
TimeSignature(
time_signature.time,
time_signature.numerator,
time_signature.denominator,
)
)
return time_signatures
def parse_pretty_midi_lyrics(midi: PrettyMIDI) -> List[Lyric]:
"""Return Lyric objects parsed from a PrettyMIDI object.
Parameters
----------
midi : :class:`pretty_midi.PrettyMIDI`
PrettyMIDI object to convert.
Returns
-------
list of :class:`muspy.Lyric`
Parsed lyrics.
"""
return [Lyric(lyric.time, lyric.text) for lyric in midi.lyrics]
def parse_pretty_midi_note(note: PrettyMIDINote) -> Note:
"""Return pretty_midi Note object as a MusPy Note object.
Parameters
----------
note : :class:`pretty_midi.Note`
pretty_midi Note object to convert.
Returns
-------
:class:`muspy.Note`
Parsed note.
"""
return Note(note.start, note.duration, note.pitch, note.velocity)
def parse_pretty_midi_instrument(instrument: Instrument) -> Track:
"""Return a pretty_midi Instrument object as a Track object.
Parameters
----------
instrument : :class:`pretty_midi.Instrument`
pretty_midi Instrument object to convert.
Returns
-------
:class:`muspy.Track`
Parsed track.
"""
notes = [parse_pretty_midi_note(note) for note in instrument.notes]
return Track(
instrument.program, instrument.is_drum, instrument.name, notes
)
[docs]def from_pretty_midi(midi: PrettyMIDI) -> Music:
"""Return a pretty_midi PrettyMIDI object as a Music object.
Parameters
----------
midi : :class:`pretty_midi.PrettyMIDI`
PrettyMIDI object to convert.
Returns
-------
:class:`muspy.Music`
Converted Music object.
"""
key_signatures = parse_pretty_midi_key_signatures(midi)
time_signatures = parse_pretty_midi_time_signatures(midi)
lyrics = parse_pretty_midi_lyrics(midi)
tracks = [parse_pretty_midi_instrument(track) for track in midi.tracks]
return Music(
metadata=Metadata(source_format="midi"),
key_signatures=key_signatures,
time_signatures=time_signatures,
lyrics=lyrics,
tracks=tracks,
)
def read_midi_pretty_midi(path: Union[str, Path]) -> Music:
"""Read a MIDI file into a Music object using pretty_midi backend.
Parameters
----------
path : str or Path
Path to the MIDI file to read.
Returns
-------
:class:`muspy.Music`
Converted Music object.
"""
music = from_pretty_midi(PrettyMIDI(str(path)))
music.metadata.source_filename = Path(path).name
return music
[docs]def read_midi(
path: Union[str, Path],
backend: str = "mido",
duplicate_note_mode: str = "fifo",
) -> Music:
"""Read a MIDI file into a Music object.
Parameters
----------
path : str or Path
Path to the MIDI file to read.
backend: {'mido', 'pretty_midi'}
Backend to use.
duplicate_note_mode : {'fifo', 'lifo, 'close_all'}
Policy for dealing with duplicate notes. When a note off message
is presetned while there are multiple correspoding note on
messages that have not yet been closed, we need a policy to
decide which note on messages to close. Defaults to 'fifo'. Only
used when `backend='mido'`.
- 'fifo' (first in first out): close the earliest note on
- 'lifo' (first in first out):close the latest note on
- 'close_all': close all note on messages
Returns
-------
:class:`muspy.Music`
Converted Music object.
"""
if backend == "mido":
return read_midi_mido(path, duplicate_note_mode)
if backend == "pretty_midi":
return read_midi_pretty_midi(path)
raise ValueError("`backend` must by one of 'mido' and 'pretty_midi'.")