Source code for madmom.io.midi

# encoding: utf-8
# pylint: disable=no-member
"""
This module contains MIDI functionality.

"""

from __future__ import absolute_import, division, print_function

import numpy as np
import mido

DEFAULT_TEMPO = 500000  # microseconds per quarter note (i.e. 120 bpm in 4/4)
DEFAULT_TICKS_PER_BEAT = 480  # ticks per quarter note
DEFAULT_TIME_SIGNATURE = (4, 4)


# TODO: remove these unit conversion functions after upstream PR is merged
#       https://github.com/olemb/mido/pull/114
[docs]def tick2second(tick, ticks_per_beat=DEFAULT_TICKS_PER_BEAT, tempo=DEFAULT_TEMPO): """ Convert absolute time in ticks to seconds. Returns absolute time in seconds for a chosen MIDI file time resolution (ticks/pulses per quarter note, also called PPQN) and tempo (microseconds per quarter note). """ # Note: both tempo (microseconds) and ticks are per quarter note # thus the time signature is irrelevant scale = tempo * 1e-6 / ticks_per_beat return tick * scale
[docs]def second2tick(second, ticks_per_beat=DEFAULT_TICKS_PER_BEAT, tempo=DEFAULT_TEMPO): """ Convert absolute time in seconds to ticks. Returns absolute time in ticks for a chosen MIDI file time resolution (ticks/pulses per quarter note, also called PPQN) and tempo (microseconds per quarter note). """ # Note: both tempo (microseconds) and ticks are per quarter note # thus the time signature is irrelevant scale = tempo * 1e-6 / ticks_per_beat return int(round(second / scale))
[docs]def bpm2tempo(bpm, time_signature=DEFAULT_TIME_SIGNATURE): """ Convert BPM (beats per minute) to MIDI file tempo (microseconds per quarter note). Depending on the chosen time signature a bar contains a different number of beats. These beats are multiples/fractions of a quarter note, thus the returned BPM depend on the time signature. """ return int(round(60 * 1e6 / bpm * time_signature[1] / 4.))
[docs]def tempo2bpm(tempo, time_signature=DEFAULT_TIME_SIGNATURE): """ Convert MIDI file tempo (microseconds per quarter note) to BPM (beats per minute). Depending on the chosen time signature a bar contains a different number of beats. These beats are multiples/fractions of a quarter note, thus the returned tempo depends on the time signature. """ return 60 * 1e6 / tempo * time_signature[1] / 4.
[docs]def tick2beat(tick, ticks_per_beat=DEFAULT_TICKS_PER_BEAT, time_signature=DEFAULT_TIME_SIGNATURE): """ Convert ticks to beats. Returns beats for a chosen MIDI file time resolution (ticks/pulses per quarter note, also called PPQN) and time signature. """ return tick / (4. * ticks_per_beat / time_signature[1])
[docs]def beat2tick(beat, ticks_per_beat=DEFAULT_TICKS_PER_BEAT, time_signature=DEFAULT_TIME_SIGNATURE): """ Convert beats to ticks. Returns ticks for a chosen MIDI file time resolution (ticks/pulses per quarter note, also called PPQN) and time signature. """ return int(round(beat * 4. * ticks_per_beat / time_signature[1]))
[docs]class MIDIFile(mido.MidiFile): """ MIDI File. Parameters ---------- filename : str MIDI file name. file_format : int, optional MIDI file format (0, 1, 2). ticks_per_beat : int, optional Resolution (i.e. ticks per quarter note) of the MIDI file. unit : str, optional Unit of all MIDI messages, can be one of the following: - 'ticks', 't': use native MIDI ticks as unit, - 'seconds', 's': use seconds as unit, - 'beats', 'b' : use beats as unit. timing : str, optional Timing of all MIDI messages, can be one of the following: - 'absolute', 'abs', 'a': use absolute timing. - 'relative', 'rel', 'r': use relative timing, i.e. delta to previous message. Examples -------- Create a MIDI file from an array with notes. The format of the note array is: 'onset time', 'pitch', 'duration', 'velocity', 'channel'. The last column can be omitted, assuming channel 0. >>> notes = np.array([[0, 50, 1, 60], [0.5, 62, 0.5, 90]]) >>> m = MIDIFile.from_notes(notes) >>> m # doctest: +ELLIPSIS <madmom.io.midi.MIDIFile object at 0x...> The notes can be accessed as a numpy array in various formats (default is seconds): >>> m.notes array([[ 0. , 50. , 1. , 60. , 0. ], [ 0.5, 62. , 0.5, 90. , 0. ]]) >>> m.unit ='ticks' >>> m.notes array([[ 0., 50., 960., 60., 0.], [480., 62., 480., 90., 0.]]) >>> m.unit = 'seconds' >>> m.notes array([[ 0. , 50. , 1. , 60. , 0. ], [ 0.5, 62. , 0.5, 90. , 0. ]]) >>> m.unit = 'beats' >>> m.notes array([[ 0., 50., 2., 60., 0.], [ 1., 62., 1., 90., 0.]]) >>> m = MIDIFile.from_notes(notes, tempo=60) >>> m.notes array([[ 0. , 50. , 1. , 60. , 0. ], [ 0.5, 62. , 0.5, 90. , 0. ]]) >>> m.unit = 'ticks' >>> m.notes array([[ 0., 50., 480., 60., 0.], [240., 62., 240., 90., 0.]]) >>> m.unit = 'beats' >>> m.notes array([[ 0. , 50. , 1. , 60. , 0. ], [ 0.5, 62. , 0.5, 90. , 0. ]]) >>> m = MIDIFile.from_notes(notes, time_signature=(2, 2)) >>> m.notes array([[ 0. , 50. , 1. , 60. , 0. ], [ 0.5, 62. , 0.5, 90. , 0. ]]) >>> m.unit = 'ticks' >>> m.notes array([[ 0., 50., 1920., 60., 0.], [ 960., 62., 960., 90., 0.]]) >>> m.unit = 'beats' >>> m.notes array([[ 0., 50., 2., 60., 0.], [ 1., 62., 1., 90., 0.]]) >>> m = MIDIFile.from_notes(notes, tempo=60, time_signature=(2, 2)) >>> m.notes array([[ 0. , 50. , 1. , 60. , 0. ], [ 0.5, 62. , 0.5, 90. , 0. ]]) >>> m.unit = 'ticks' >>> m.notes array([[ 0., 50., 960., 60., 0.], [480., 62., 480., 90., 0.]]) >>> m.unit = 'beats' >>> m.notes array([[ 0. , 50. , 1. , 60. , 0. ], [ 0.5, 62. , 0.5, 90. , 0. ]]) >>> m = MIDIFile.from_notes(notes, tempo=240, time_signature=(3, 8)) >>> m.notes array([[ 0. , 50. , 1. , 60. , 0. ], [ 0.5, 62. , 0.5, 90. , 0. ]]) >>> m.unit = 'ticks' >>> m.notes array([[ 0., 50., 960., 60., 0.], [480., 62., 480., 90., 0.]]) >>> m.unit = 'beats' >>> m.notes array([[ 0., 50., 4., 60., 0.], [ 2., 62., 2., 90., 0.]]) """ UNIT = 'seconds' TIMING = 'absolute' def __init__(self, filename=None, file_format=0, ticks_per_beat=DEFAULT_TICKS_PER_BEAT, unit=UNIT, timing=TIMING, **kwargs): # instantiate a MIDIFile super(MIDIFile, self).__init__(filename=filename, type=file_format, ticks_per_beat=ticks_per_beat, **kwargs) # add attributes for unit conversion self.unit = unit self.timing = timing # TODO: remove this method after upstream PR is merged # https://github.com/olemb/mido/pull/115 def __iter__(self): # The tracks of type 2 files are not in sync, so they can # not be played back like this. if self.type == 2: raise TypeError("can't merge tracks in type 2 (asynchronous) file") tempo = DEFAULT_TEMPO time_signature = DEFAULT_TIME_SIGNATURE cum_delta = 0 for msg in mido.merge_tracks(self.tracks): # Convert relative message time to desired unit if msg.time > 0: if self.unit.lower() in ('t', 'ticks'): delta = msg.time elif self.unit.lower() in ('s', 'sec', 'seconds'): delta = tick2second(msg.time, self.ticks_per_beat, tempo) elif self.unit.lower() in ('b', 'beats'): delta = tick2beat(msg.time, self.ticks_per_beat, time_signature) else: raise ValueError("`unit` must be either 'ticks', 't', " "'seconds', 's', 'beats', 'b', not %s." % self.unit) else: delta = 0 # Convert relative time to absolute values if needed if self.timing.lower() in ('a', 'abs', 'absolute'): cum_delta += delta elif self.timing.lower() in ('r', 'rel', 'relative'): cum_delta = delta else: raise ValueError("`timing` must be either 'relative', 'rel', " "'r', or 'absolute', 'abs', 'a', not %s." % self.timing) yield msg.copy(time=cum_delta) if msg.type == 'set_tempo': tempo = msg.tempo elif msg.type == 'time_signature': time_signature = (msg.numerator, msg.denominator) def __repr__(self): return object.__repr__(self) @property def tempi(self): """ Tempi (mircoseconds per quarter note) of the MIDI file. Returns ------- tempi : numpy array Array with tempi (time, tempo). Notes ----- The time will be given in the unit set by `unit`. """ # list for all tempi tempi = [] # process all events for msg in self: if msg.type == 'set_tempo': tempi.append((msg.time, msg.tempo)) # make sure a tempo is set (and occurs at time 0) if not tempi or tempi[0][0] > 0: tempi.insert(0, (0, DEFAULT_TEMPO)) # tempo is given in microseconds per quarter note # TODO: add otption to return in BPM return np.asarray(tempi, np.float) @property def time_signatures(self): """ Time signatures of the MIDI file. Returns ------- time_signatures : numpy array Array with time signatures (time, numerator, denominator). Notes ----- The time will be given in the unit set by `unit`. """ # list for all time signature signatures = [] # process all events for msg in self: if msg.type == 'time_signature': signatures.append((msg.time, msg.numerator, msg.denominator)) # make sure a signatures is set (and occurs at time 0) if not signatures or signatures[0][0] > 0: signatures.insert(0, (0, DEFAULT_TIME_SIGNATURE[0], DEFAULT_TIME_SIGNATURE[1])) # return time signatures return np.asarray(signatures, dtype=np.float) @property def notes(self): """ Notes of the MIDI file. Returns ------- notes : numpy array Array with notes (onset time, pitch, duration, velocity, channel). """ # list for all notes notes = [] # dictionary for storing the last onset time and velocity for each # individual note (i.e. same pitch and channel) sounding_notes = {} # as key for the dict use channel * 128 (max number of pitches) + pitch def note_hash(channel, pitch): """Generate a note hash.""" return channel * 128 + pitch # process all events for msg in self: # use only note on or note off events note_on = msg.type == 'note_on' note_off = msg.type == 'note_off' # hash sounding note if note_on or note_off: note = note_hash(msg.channel, msg.note) # if it's a note on event with a velocity > 0, if note_on and msg.velocity > 0: # save the onset time and velocity sounding_notes[note] = (msg.time, msg.velocity) # if it's a note off or a note on event with a velocity of 0, elif note_off or (note_on and msg.velocity == 0): if note not in sounding_notes: import warnings warnings.warn('ignoring MIDI message %s' % msg) continue # append the note to the list notes.append((sounding_notes[note][0], msg.note, msg.time - sounding_notes[note][0], sounding_notes[note][1], msg.channel)) # remove hash from dict del sounding_notes[note] # sort the notes and convert to numpy array return np.asarray(sorted(notes), dtype=np.float)
[docs] @classmethod def from_notes(cls, notes, unit='seconds', tempo=DEFAULT_TEMPO, time_signature=DEFAULT_TIME_SIGNATURE, ticks_per_beat=DEFAULT_TICKS_PER_BEAT): """ Create a MIDIFile from the given notes. Parameters ---------- notes : numpy array Array with notes, one per row. The columns are defined as: (onset time, pitch, duration, velocity, [channel]). unit : str, optional Unit of `notes`, can be one of the following: - 'seconds', 's': use seconds as unit, - 'ticks', 't': use native MIDI ticks as unit, - 'beats', 'b' : use beats as unit. tempo : float, optional Tempo of the MIDI track, given in bpm or microseconds per quarter note. The unit is determined automatically by the value: - `tempo` <= 1000: bpm - `tempo` > 1000: microseconds per quarter note time_signature : tuple, optional Time signature of the track, e.g. (4, 4) for 4/4. ticks_per_beat : int, optional Resolution (i.e. ticks per quarter note) of the MIDI file. Returns ------- :class:`MIDIFile` instance :class:`MIDIFile` instance with all notes collected in one track. Notes ----- All note events (including the generated tempo and time signature events) are written into a single track (i.e. MIDI file format 0). """ # create new MIDI file midi_file = cls(file_format=0, ticks_per_beat=ticks_per_beat, unit=unit, timing='absolute') # convert tempo if tempo <= 1000: # convert from bpm to tempo tempo = bpm2tempo(tempo, time_signature) else: # tempo given in ticks per quarter note # i.e. we have to adjust according to the time signature tempo = int(tempo * time_signature[1] / 4) # create new track and add tempo and time signature information track = midi_file.add_track() track.append(mido.MetaMessage('set_tempo', tempo=tempo)) track.append(mido.MetaMessage('time_signature', numerator=time_signature[0], denominator=time_signature[1])) # create note on/off messages with absolute timing messages = [] for note in notes: try: onset, pitch, duration, velocity, channel = note channel = int(channel) velocity = int(velocity) except ValueError: onset, pitch, duration, velocity = note channel = 0 pitch = int(pitch) velocity = int(velocity) offset = onset + duration # create MIDI messages onset = second2tick(onset, ticks_per_beat, tempo) note_on = mido.Message('note_on', time=onset, note=pitch, velocity=velocity, channel=channel) offset = second2tick(offset, ticks_per_beat, tempo) note_off = mido.Message('note_off', time=offset, note=pitch, channel=channel) # append to list messages.extend([note_on, note_off]) # sort them, convert to relative timing and append to track messages.sort(key=lambda msg: msg.time) messages = mido.midifiles.tracks._to_reltime(messages) track.extend(messages) # return MIDI file return midi_file
[docs] def save(self, filename): """ Save to MIDI file. Parameters ---------- filename : str or open file handle The MIDI file name. """ from . import open_file # write the MIDI stream with open_file(filename, 'wb') as f: self._save(f)
[docs]def load_midi(filename): """ Load notes from a MIDI file. Parameters ---------- filename: str MIDI file. Returns ------- numpy array Notes ('onset time' 'note number' 'duration' 'velocity' 'channel') """ return MIDIFile(filename).notes
[docs]def write_midi(notes, filename, duration=0.6, velocity=100): """ Write notes to a MIDI file. Parameters ---------- notes : numpy array, shape (num_notes, 2) Notes, one per row (column definition see notes). filename : str Output MIDI file. duration : float, optional Note duration if not defined by `notes`. velocity : int, optional Note velocity if not defined by `notes`. Returns ------- numpy array Notes (including note length, velocity and channel). Notes ----- The note columns format must be (duration, velocity and channel optional): 'onset time' 'note number' ['duration' ['velocity' ['channel']]] """ from ..utils import expand_notes # expand the array to have a default duration and velocity notes = expand_notes(notes, duration, velocity) # write the notes to the file and return them MIDIFile.from_notes(notes).save(filename)