"""MIDI input interface."""
import warnings
from collections import OrderedDict, defaultdict
from operator import attrgetter
from pathlib import Path
from typing import List, Union
import numpy as np
from mido import MidiFile, tempo2bpm
from pretty_midi import Instrument
from pretty_midi import KeySignature as PmKeySignature
from pretty_midi import Lyric as PmLyric
from pretty_midi import Note as PmNote
from pretty_midi import PrettyMIDI
from pretty_midi import TimeSignature as PmTimeSignature
from ..classes import (
Annotation,
KeySignature,
Lyric,
Metadata,
Note,
Tempo,
TimeSignature,
Track,
)
from ..music import DEFAULT_RESOLUTION, Music
from ..utils import note_str_to_note_num
[docs]class MIDIError(Exception):
"""An error class for MIDI related exceptions."""
def _is_drum(channel):
# Mido numbers channels 0 to 15 instead of 1 to 16
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', 'all'}, default: 'fifo'
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.
- 'fifo' (first in first out): close the earliest note on
- 'lifo' (first in first out): close the latest note on
- 'all': close all note on messages
Returns
-------
:class:`muspy.Music`
Converted Music object.
"""
if duplicate_note_mode.lower() not in ("fifo", "lifo", "all"):
raise ValueError(
"`duplicate_note_mode` must be one of 'fifo', 'lifo' and " "'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: List[Tempo] = []
key_signatures: List[KeySignature] = []
time_signatures: List[TimeSignature] = []
lyrics: List[Lyric] = []
annotations: List[Annotation] = []
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=int(time), qpm=float(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=int(time), root=root, mode=mode)
)
# Time signature messages
elif msg.type == "time_signature":
time_signatures.append(
TimeSignature(
time=int(time),
numerator=int(msg.numerator),
denominator=int(msg.denominator),
)
)
# Lyric messages
elif msg.type == "lyrics":
lyrics.append(Lyric(time=int(time), lyric=str(msg.text)))
# Marker messages
elif msg.type == "marker":
annotations.append(
Annotation(
time=int(time),
annotation=str(msg.text),
group="marker",
)
)
# Text messages
elif msg.type == "text":
annotations.append(
Annotation(
time=int(time), annotation=str(msg.text), group="text"
)
)
# Copyright messages
elif msg.type == "copyright":
copyrights.append(str(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 handling duplicate notes.
# '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=int(onset),
pitch=int(msg.note),
duration=int(time - onset),
velocity=int(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=int(onset),
pitch=int(msg.note),
duration=int(time - onset),
velocity=int(velocity),
)
)
del active_notes[note_key][-1]
# 'close_all' - close all note on messages
elif duplicate_note_mode.lower() == "close_all":
for onset, velocity in active_notes[note_key]:
track.notes.append(
Note(
time=int(onset),
pitch=int(msg.note),
duration=int(time - onset),
velocity=int(velocity),
)
)
del active_notes[note_key]
# Control change messages
elif msg.type == "control_change":
# Get the active track
program = channel_programs[msg.channel]
track = _get_active_track(track_idx, program, msg.channel)
# Append the control change message as an annotation
track.annotations.append(
Annotation(
time=int(time),
annotation={
"number": int(msg.control),
"value": int(msg.value),
},
group="control_change",
)
)
# 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=int(onset),
pitch=int(note),
duration=int(time - onset),
velocity=int(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=str(song_title),
source_format="midi",
copyright=" ".join(copyrights) if copyrights else None,
)
return Music(
metadata=metadata,
resolution=int(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, 'all'}, default: 'fifo'
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.
- 'fifo' (first in first out): close the earliest note on
- 'lifo' (first in first out):close the latest note on
- '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 from_pretty_midi_key_signature(
key_signature: PmKeySignature,
) -> KeySignature:
"""Return a pretty_midi KeySignature object as a KeySignature.
Parameters
----------
key_signature : :class:`pretty_midi.KeySignature`
pretty_midi KeySignature object to convert.
Returns
-------
:class:`muspy.KeySignature`
Converted key signature.
Note
----
The `time` attribute of the converted object will be of type float
as pretty_midi uses the absolute timing system.
"""
is_minor, root = divmod(key_signature.key_number, 12)
mode = "minor" if is_minor else "major"
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=RuntimeWarning)
return KeySignature(
time=float(key_signature.time), # type: ignore
root=root,
mode=mode,
)
def from_pretty_midi_time_signature(
time_signature: PmTimeSignature,
) -> TimeSignature:
"""Return a pretty_midi TimeSignature object as a TimeSignature.
Parameters
----------
time_signature : :class:`pretty_midi.TimeSignature`
pretty_midi TimeSignature object to convert.
Returns
-------
:class:`muspy.TimeSignature`
Converted time signature.
Note
----
The `time` attribute of the converted object will be of type float
as pretty_midi uses the absolute timing system.
"""
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=RuntimeWarning)
return TimeSignature(
time=float(time_signature.time), # type: ignore
numerator=time_signature.numerator,
denominator=time_signature.denominator,
)
def from_pretty_midi_lyric(lyric: PmLyric) -> Lyric:
"""Return a pretty_midi Lyric object as a Lyric object.
Parameters
----------
lyric : :class:`pretty_midi.Lyric`
pretty_midi Lyric object to convert.
Returns
-------
:class:`muspy.Lyric`
Converted lyric.
Note
----
The `time` attribute of the converted object will be of type float
as pretty_midi uses the absolute timing system.
"""
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=RuntimeWarning)
return Lyric(
time=float(lyric.time), # type: ignore
lyric=str(lyric.text),
)
def from_pretty_midi_note(note: PmNote) -> Note:
"""Return pretty_midi Note object as a Note object.
Parameters
----------
note : :class:`pretty_midi.Note`
pretty_midi Note object to convert.
Returns
-------
:class:`muspy.Note`
Converted note.
Note
----
The `time` and `duration` attributes of the converted object will be
of type float as pretty_midi uses the absolute timing system.
"""
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=RuntimeWarning)
return Note(
time=float(note.start), # type: ignore
duration=float(note.duration), # type: ignore
pitch=int(note.pitch),
velocity=int(note.velocity),
)
def from_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`
Converted track.
"""
return Track(
program=int(instrument.program),
is_drum=bool(instrument.is_drum),
name=str(instrument.name),
notes=[from_pretty_midi_note(note) for note in instrument.notes],
)
[docs]def from_pretty_midi(midi: PrettyMIDI, resolution: int = None) -> Music:
"""Return a pretty_midi PrettyMIDI object as a Music object.
Parameters
----------
midi : :class:`pretty_midi.PrettyMIDI`
PrettyMIDI object to convert.
resolution : int, default: `muspy.DEFAULT_RESOLUTION` (24)
Time steps per quarter note.
Returns
-------
:class:`muspy.Music`
Converted Music object.
"""
if resolution is None:
resolution = DEFAULT_RESOLUTION
tempo_realtimes, tempi = midi.get_tempo_changes()
assert len(tempi) > 0
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=RuntimeWarning)
tempos = [
Tempo(time=float(time), qpm=float(tempo)) # type: ignore
for time, tempo in zip(tempo_realtimes, tempi)
]
key_signatures = [
from_pretty_midi_key_signature(key_signature)
for key_signature in midi.key_signature_changes
]
time_signatures = [
from_pretty_midi_time_signature(time_signature)
for time_signature in midi.time_signature_changes
]
lyrics = [from_pretty_midi_lyric(lyric) for lyric in midi.lyrics]
tracks = [from_pretty_midi_instrument(track) for track in midi.instruments]
music = Music(
metadata=Metadata(source_format="midi"),
tempos=tempos,
key_signatures=key_signatures,
time_signatures=time_signatures,
lyrics=lyrics,
tracks=tracks,
)
# NOTE: pretty_midi uses the absolute timing system, so we have to
# convert all the timings into metrical timing.
# Remove unnecessary tempo changes to speed up the search
if len(tempi) > 1:
last_tempo = tempi[0]
last_time = tempo_realtimes[0]
tempo_realtimes = tempo_realtimes.tolist()
tempi = tempi.tolist()
i = 1
while i < len(tempo_realtimes):
if tempi[i] == last_tempo:
del tempo_realtimes[i]
del tempi[i]
elif tempo_realtimes[i] == last_time:
del tempo_realtimes[i - 1]
del tempi[i - 1]
else:
last_tempo = tempi[i]
i += 1
tempo_realtimes = np.array(tempo_realtimes)
tempi = np.array(tempi)
if len(tempi) == 1:
def map_time(time: float) -> int:
factor = resolution * float(tempi[0]) / 60.0 # type: ignore
return round(time * factor)
else:
# Compute the tempo time in metrical timing of each tempo change
tempo_times = np.cumsum(
np.diff(tempo_realtimes) * resolution * tempi[:-1] / 60.0
)
tempo_times = np.round(tempo_times).astype(int).tolist()
tempo_times = np.insert(tempo_times, 0, 0)
def map_time(time: float) -> int:
idx = np.searchsorted(tempo_realtimes, time, side="right") - 1
residual = time - tempo_realtimes[idx]
factor = resolution * tempi[idx] / 60.0
return round(tempo_times[idx] + residual * factor)
# Adjust timing
music.adjust_time(func=map_time)
return music
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'}, default: 'mido'
Backend to use.
duplicate_note_mode : {'fifo', 'lifo, 'all'}, default: 'fifo'
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. Only used when `backend`
is 'mido'.
- 'fifo' (first in first out): close the earliest note on
- 'lifo' (first in first out):close the latest note on
- 'all': close all note on messages
Returns
-------
:class:`muspy.Music`
Converted Music object.
"""
if backend == "mido":
return read_midi_mido(path, duplicate_note_mode=duplicate_note_mode)
if backend == "pretty_midi":
return read_midi_pretty_midi(path)
raise ValueError("`backend` must by one of 'mido' and 'pretty_midi'.")