# encoding: utf-8
# pylint: disable=no-member
# pylint: disable=invalid-name
# pylint: disable=too-many-arguments
# pylint: disable=too-few-public-methods
"""
This module contains MIDI functionality.
Almost all code is taken from Giles Hall's python-midi package:
https://github.com/vishnubob/python-midi
It combines the complete package in a single file, to make it easier to
distribute. Most notable changes are `MIDITrack` and `MIDIFile` classes which
handle all data i/o and provide a interface which allows to read/display all
notes as simple numpy arrays. Also, the EventRegistry is handled differently.
The last merged commit is 3053fefe.
Since then the following commits have been added functionality-wise:
- 0964c0b (prevent multiple tick conversions)
- c43bf37 (add pitch and value properties to AfterTouchEvent)
- 40111c6 (add 0x08 MetaEvent: ProgramNameEvent)
- 43de818 (handle unknown MIDI meta events gracefully)
Additionally, the module has been updated to work with Python3.
The MIT License (MIT)
Copyright (c) 2013 Giles F. Hall
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
from __future__ import absolute_import, division, print_function
import sys
import math
import struct
import numpy as np
# constants
OCTAVE_MAX_VALUE = 12
OCTAVE_VALUES = list(range(OCTAVE_MAX_VALUE))
NOTE_NAMES = ['C', 'Cs', 'D', 'Ds', 'E', 'F', 'Fs', 'G', 'Gs', 'A', 'As', 'B']
WHITE_KEYS = [0, 2, 4, 5, 7, 9, 11]
BLACK_KEYS = [1, 3, 6, 8, 10]
NOTE_PER_OCTAVE = len(NOTE_NAMES)
NOTE_VALUES = list(range(OCTAVE_MAX_VALUE * NOTE_PER_OCTAVE))
NOTE_NAME_MAP_FLAT = {}
NOTE_VALUE_MAP_FLAT = []
NOTE_NAME_MAP_SHARP = {}
NOTE_VALUE_MAP_SHARP = []
for index in range(128):
note_idx = index % NOTE_PER_OCTAVE
oct_idx = index / OCTAVE_MAX_VALUE
note_name = NOTE_NAMES[note_idx]
if len(note_name) == 2:
# sharp note
flat = NOTE_NAMES[note_idx + 1] + 'b'
NOTE_NAME_MAP_FLAT['%s_%d' % (flat, oct_idx)] = index
NOTE_NAME_MAP_SHARP['%s_%d' % (note_name, oct_idx)] = index
NOTE_VALUE_MAP_FLAT.append('%s_%d' % (flat, oct_idx))
NOTE_VALUE_MAP_SHARP.append('%s_%d' % (note_name, oct_idx))
globals()['%s_%d' % (note_name[0] + 's', oct_idx)] = index
globals()['%s_%d' % (flat, oct_idx)] = index
else:
NOTE_NAME_MAP_FLAT['%s_%d' % (note_name, oct_idx)] = index
NOTE_NAME_MAP_SHARP['%s_%d' % (note_name, oct_idx)] = index
NOTE_VALUE_MAP_FLAT.append('%s_%d' % (note_name, oct_idx))
NOTE_VALUE_MAP_SHARP.append('%s_%d' % (note_name, oct_idx))
globals()['%s_%d' % (note_name, oct_idx)] = index
BEAT_NAMES = ['whole', 'half', 'quarter', 'eighth', 'sixteenth',
'thirty-second', 'sixty-fourth']
BEAT_VALUES = [4, 2, 1, .5, .25, .125, .0625]
WHOLE = 0
HALF = 1
QUARTER = 2
EIGHTH = 3
SIXTEENTH = 4
THIRTY_SECOND = 5
SIXTY_FOURTH = 6
HEADER_SIZE = 14
RESOLUTION = 480 # ticks per quarter note
TEMPO = 120
TIME_SIGNATURE_NUMERATOR = 4
TIME_SIGNATURE_DENOMINATOR = 4
SECONDS_PER_QUARTER_NOTE = 60. / TEMPO
SECONDS_PER_TICK = SECONDS_PER_QUARTER_NOTE / RESOLUTION
# Ensure Python2/3 compatibility when reading bytes from MIDI files
if sys.version_info[0] == 2:
int2byte = chr
def byte2int(byte):
"""Convert a byte-character to an integer."""
return ord(byte)
else:
int2byte = struct.Struct(">B").pack
[docs] def byte2int(byte):
"""Convert a byte-character to an integer."""
return byte
# functions for packing / unpacking variable length data
[docs]def read_variable_length(data):
"""
Read a variable length variable from the given data.
Parameters
----------
data : bytearray
Data of variable length.
Returns
-------
length : int
Length in bytes.
"""
next_byte = 1
value = 0
while next_byte:
next_value = byte2int(next(data))
# is the hi-bit set?
if not next_value & 0x80:
# no next BYTE
next_byte = 0
# mask out the 8th bit
next_value &= 0x7f
# shift last value up 7 bits
value <<= 7
# add new value
value += next_value
return value
[docs]def write_variable_length(value):
"""
Write a variable length variable.
Parameters
----------
value : bytearray
Value to be encoded as a variable of variable length.
Returns
-------
bytearray
Variable with variable length.
"""
result = bytearray()
result.insert(0, value & 0x7F)
value >>= 7
if value:
result.insert(0, (value & 0x7F) | 0x80)
value >>= 7
if value:
result.insert(0, (value & 0x7F) | 0x80)
value >>= 7
if value:
result.insert(0, (value & 0x7F) | 0x80)
return result
[docs]class EventRegistry(object):
"""
Class for registering Events.
Event classes should be registered manually by calling
EventRegistry.register_event(EventClass) after the class definition.
"""
Events = {}
MetaEvents = {}
@classmethod
[docs] def register_event(cls, event):
"""
Registers an event in the registry.
Parameters
----------
event : :class:`Event` instance
Event to be registered.
Raises
------
ValueError
For unknown events.
"""
# normal events
if any(b in (Event, NoteEvent) for b in event.__bases__):
# raise an error if the event class is registered already
if event.status_msg in cls.Events:
raise AssertionError("Event %s already registered" %
event.name)
# register the Event
cls.Events[event.status_msg] = event
# meta events
elif any(b in (MetaEvent, MetaEventWithText) for b in event.__bases__):
# raise an error if the meta event class is registered already
if event.meta_command in EventRegistry.MetaEvents:
raise AssertionError("Event %s already registered" %
event.name)
# register the MetaEvent
cls.MetaEvents[event.meta_command] = event
else:
# raise an error
raise ValueError("Unknown base class in event type: %s" %
event.__bases__)
[docs]class AbstractEvent(object):
"""
Abstract Event.
"""
name = "Generic MIDI Event"
length = 0
status_msg = 0x0
def __init__(self, **kwargs):
if isinstance(self.length, int):
data = [0] * self.length
else:
data = []
self.tick = 0
self.data = data
for key in kwargs:
setattr(self, key, kwargs[key])
def __cmp__(self, other):
raise RuntimeError('add missing comparison operators')
def __lt__(self, other):
return self.tick < other.tick
def __gt__(self, other):
return self.tick > other.tick
def __str__(self):
return "%s: tick: %s data: %s" % (self.__class__.__name__, self.tick,
self.data)
# do not register AbstractEvent
[docs]class Event(AbstractEvent):
"""
Event.
"""
name = 'Event'
def __init__(self, **kwargs):
if 'channel' not in kwargs:
# TODO: copying needed?
kwargs = kwargs.copy()
kwargs['channel'] = 0
super(Event, self).__init__(**kwargs)
def __cmp__(self, other):
if self.tick < other.tick:
return -1
elif self.tick > other.tick:
return 1
return 0
def __str__(self):
return "%s: tick: %s channel: %s" % (self.__class__.__name__,
self.tick, self.channel)
@classmethod
[docs] def is_event(cls, status_msg):
"""
Indicates whether the given status message belongs to this event.
Parameters
----------
status_msg : int
Status message.
Returns
-------
bool
True if the given status message belongs to this event.
"""
return cls.status_msg == (status_msg & 0xF0)
# do not register Event
# do not register MetaEvent
[docs]class MetaEventWithText(MetaEvent):
"""
Meta Event With Text.
"""
def __init__(self, **kwargs):
super(MetaEventWithText, self).__init__(**kwargs)
if 'text' not in kwargs:
self.text = ''.join(chr(datum) for datum in self.data)
def __str__(self):
return "%s: %s" % (self.__class__.__name__, self.text)
# do not register MetaEventWithText
[docs]class NoteEvent(Event):
"""
NoteEvent is a special subclass of Event that is not meant to be used as a
concrete class. It defines the generalities of NoteOn and NoteOff events.
"""
length = 2
@property
def pitch(self):
"""
Pitch of the note event.
"""
return self.data[0]
@pitch.setter
def pitch(self, pitch):
"""
Set the pitch of the note event.
Parameters
----------
pitch : int
Pitch of the note.
"""
self.data[0] = pitch
@property
def velocity(self):
"""
Velocity of the note event.
"""
return self.data[1]
@velocity.setter
def velocity(self, velocity):
"""
Set the velocity of the note event.
Parameters
----------
velocity : int
Velocity of the note.
"""
self.data[1] = velocity
# do not register NoteEvent
[docs]class NoteOnEvent(NoteEvent):
"""
Note On Event.
"""
status_msg = 0x90
name = 'Note On'
EventRegistry.register_event(NoteOnEvent)
[docs]class NoteOffEvent(NoteEvent):
"""
Note Off Event.
"""
status_msg = 0x80
name = 'Note Off'
EventRegistry.register_event(NoteOffEvent)
[docs]class AfterTouchEvent(Event):
"""
After Touch Event.
"""
status_msg = 0xA0
length = 2
name = 'After Touch'
@property
def pitch(self):
"""
Pitch of the after touch event.
"""
return self.data[0]
@pitch.setter
def pitch(self, pitch):
"""
Set the pitch of the after touch event.
Parameters
----------
pitch : int
Pitch of the after touch event.
"""
self.data[0] = pitch
@property
def value(self):
"""
Value of the after touch event.
"""
return self.data[1]
@value.setter
def value(self, value):
"""
Set the value of the after touch event.
Parameters
----------
value : int
Value of the after touch event.
"""
self.data[1] = value
EventRegistry.register_event(AfterTouchEvent)
[docs]class ControlChangeEvent(Event):
"""
Control Change Event.
"""
status_msg = 0xB0
length = 2
name = 'Control Change'
@property
def control(self):
"""
Control ID.
"""
return self.data[0]
@control.setter
def control(self, control):
"""
Set control ID.
Parameters
----------
control : int
Control ID.
"""
self.data[0] = control
@property
def value(self):
"""
Value of the controller.
"""
return self.data[1]
@value.setter
def value(self, value):
"""
Set the value of the controller.
Parameters
----------
value : int
Value of the controller.
"""
self.data[1] = value
EventRegistry.register_event(ControlChangeEvent)
[docs]class ProgramChangeEvent(Event):
"""
Program Change Event.
"""
status_msg = 0xC0
length = 1
name = 'Program Change'
@property
def value(self):
"""
Value of the Program Change Event.
"""
return self.data[0]
@value.setter
def value(self, value):
"""
Set the value of the Program Change Event.
Parameters
----------
value : int
Value of the Program Change Event.
"""
self.data[0] = value
EventRegistry.register_event(ProgramChangeEvent)
[docs]class ChannelAfterTouchEvent(Event):
"""
Channel After Touch Event.
"""
status_msg = 0xD0
length = 1
name = 'Channel After Touch'
@property
def value(self):
"""
Value of the Channel After Touch Event.
"""
return self.data[0]
@value.setter
def value(self, value):
"""
Set the value of the Channel After Touch Event.
Parameters
----------
value : int
Value of the Channel After Touch Event.
"""
self.data[0] = value
EventRegistry.register_event(ChannelAfterTouchEvent)
[docs]class PitchWheelEvent(Event):
"""
Pitch Wheel Event.
"""
status_msg = 0xE0
length = 2
name = 'Pitch Wheel'
@property
def pitch(self):
"""
Pitch of the Pitch Wheel Event.
"""
return ((self.data[1] << 7) | self.data[0]) - 0x2000
@pitch.setter
def pitch(self, pitch):
"""
Set the pitch of the Pitch Wheel Event.
Parameters
----------
pitch : int
Pitch of the Pitch Wheel Event.
"""
value = pitch + 0x2000
self.data[0] = value & 0x7F
self.data[1] = (value >> 7) & 0x7F
EventRegistry.register_event(PitchWheelEvent)
[docs]class SysExEvent(Event):
"""
System Exclusive Event.
"""
status_msg = 0xF0
length = 'variable'
name = 'SysEx'
@classmethod
[docs] def is_event(cls, status_msg):
"""
Indicates whether the given status message belongs to this event.
Parameters
----------
status_msg : int
Status message.
Returns
-------
bool
True if the given status message belongs to this event.
"""
return cls.status_msg == status_msg
EventRegistry.register_event(SysExEvent)
EventRegistry.register_event(SequenceNumberMetaEvent)
[docs]class TextMetaEvent(MetaEventWithText):
"""
Text Meta Event.
"""
meta_command = 0x01
length = 'variable'
name = 'Text'
EventRegistry.register_event(TextMetaEvent)
EventRegistry.register_event(CopyrightMetaEvent)
[docs]class TrackNameEvent(MetaEventWithText):
"""
Track Name Event.
"""
meta_command = 0x03
length = 'variable'
name = 'Track Name'
EventRegistry.register_event(TrackNameEvent)
[docs]class InstrumentNameEvent(MetaEventWithText):
"""
Instrument Name Event.
"""
meta_command = 0x04
length = 'variable'
name = 'Instrument Name'
EventRegistry.register_event(InstrumentNameEvent)
[docs]class LyricsEvent(MetaEventWithText):
"""
Lyrics Event.
"""
meta_command = 0x05
length = 'variable'
name = 'Lyrics'
EventRegistry.register_event(LyricsEvent)
[docs]class MarkerEvent(MetaEventWithText):
"""
Marker Event.
"""
meta_command = 0x06
length = 'variable'
name = 'Marker'
EventRegistry.register_event(MarkerEvent)
[docs]class CuePointEvent(MetaEventWithText):
"""
Cue Point Event.
"""
meta_command = 0x07
length = 'variable'
name = 'Cue Point'
EventRegistry.register_event(CuePointEvent)
[docs]class ProgramNameEvent(MetaEventWithText):
"""
Program Name Event.
"""
meta_command = 0x08
length = 'varlen'
name = 'Program Name'
EventRegistry.register_event(ProgramNameEvent)
EventRegistry.register_event(UnknownMetaEvent)
[docs]class ChannelPrefixEvent(MetaEvent):
"""
Channel Prefix Event.
"""
meta_command = 0x20
length = 1
name = 'Channel Prefix'
EventRegistry.register_event(ChannelPrefixEvent)
[docs]class PortEvent(MetaEvent):
"""
Port Event.
"""
meta_command = 0x21
name = 'MIDI Port/Cable'
EventRegistry.register_event(PortEvent)
[docs]class TrackLoopEvent(MetaEvent):
"""
Track Loop Event.
"""
meta_command = 0x2E
name = 'Track Loop'
EventRegistry.register_event(TrackLoopEvent)
[docs]class EndOfTrackEvent(MetaEvent):
"""
End Of Track Event.
"""
meta_command = 0x2F
name = 'End of Track'
EventRegistry.register_event(EndOfTrackEvent)
[docs]class SetTempoEvent(MetaEvent):
"""
Set Tempo Event.
"""
meta_command = 0x51
length = 3
name = 'Set Tempo'
@property
def microseconds_per_quarter_note(self):
"""
Microseconds per quarter note.
"""
assert len(self.data) == 3
values = [self.data[x] << (16 - (8 * x)) for x in range(3)]
return sum(values)
@microseconds_per_quarter_note.setter
def microseconds_per_quarter_note(self, microseconds):
"""
Set microseconds per quarter note.
Parameters
----------
microseconds : int
Microseconds per quarter note.
"""
self.data = [(microseconds >> (16 - (8 * x)) & 0xFF) for x in range(3)]
EventRegistry.register_event(SetTempoEvent)
[docs]class SmpteOffsetEvent(MetaEvent):
"""
SMPTE Offset Event.
"""
meta_command = 0x54
name = 'SMPTE Offset'
EventRegistry.register_event(SmpteOffsetEvent)
[docs]class TimeSignatureEvent(MetaEvent):
"""
Time Signature Event.
"""
meta_command = 0x58
length = 4
name = 'Time Signature'
@property
def numerator(self):
"""
Numerator of the time signature.
"""
return self.data[0]
@numerator.setter
def numerator(self, numerator):
"""
Set numerator of the time signature.
Parameters
----------
numerator : int
Numerator of the time signature.
"""
self.data[0] = numerator
@property
def denominator(self):
"""
Denominator of the time signature.
"""
return 2 ** self.data[1]
@denominator.setter
def denominator(self, denominator):
"""
Set denominator of the time signature.
Parameters
----------
denominator : int
Denominator of the time signature.
"""
self.data[1] = int(math.log(denominator, 2))
@property
def metronome(self):
"""
Metronome.
"""
return self.data[2]
@metronome.setter
def metronome(self, metronome):
"""
Set metronome of the time signature.
Parameters
----------
metronome : int
Metronome of the time signature.
"""
self.data[2] = metronome
@property
def thirty_seconds(self):
"""
Thirty-seconds of the time signature.
"""
return self.data[3]
@thirty_seconds.setter
def thirty_seconds(self, thirty_seconds):
"""
Set thirty-seconds of the time signature.
Parameters
----------
thirty_seconds : int
Thirty-seconds of the time signature.
"""
self.data[3] = thirty_seconds
EventRegistry.register_event(TimeSignatureEvent)
[docs]class KeySignatureEvent(MetaEvent):
"""
Key Signature Event.
"""
meta_command = 0x59
length = 2
name = 'Key Signature'
@property
def alternatives(self):
"""
Alternatives of the key signature.
"""
return self.data[0] - 256 if self.data[0] > 127 else self.data[0]
@alternatives.setter
def alternatives(self, alternatives):
"""
Set alternatives of the key signature.
Parameters
----------
alternatives : int
Alternatives of the key signature.
"""
self.data[0] = 256 + alternatives if alternatives < 0 else alternatives
@property
def minor(self):
"""
Major / minor.
"""
return self.data[1]
@minor.setter
def minor(self, val):
"""
Set major / minor.
Parameters
----------
val : int
Major / minor.
"""
self.data[1] = val
EventRegistry.register_event(KeySignatureEvent)
[docs]class SequencerSpecificEvent(MetaEvent):
"""
Sequencer Specific Event.
"""
meta_command = 0x7F
name = 'Sequencer Specific'
EventRegistry.register_event(SequencerSpecificEvent)
# MIDI Track
[docs]class MIDITrack(object):
"""
MIDI Track.
Parameters
----------
events : list
MIDI events.
"""
def __init__(self, events=None):
if events is None:
self.events = []
else:
self.events = events
self._make_ticks_abs()
def _make_ticks_abs(self):
"""
Make the track's timing information absolute.
"""
running_tick = 0
for event in self.events:
event.tick += running_tick
running_tick = event.tick
def _make_ticks_rel(self):
"""
Make the track's timing information relative.
"""
running_tick = 0
for event in self.events:
event.tick -= running_tick
running_tick += event.tick
@property
def data_stream(self):
"""
MIDI data stream representation of the track.
"""
# first make sure the timing information is relative
self._make_ticks_rel()
# and unset the status message
status = None
# then encode all events of the track
track_data = bytearray()
for event in self.events:
# encode the event data, first the timing information
track_data.extend(write_variable_length(event.tick))
# is the event a MetaEvent?
if isinstance(event, MetaEvent):
track_data.append(event.status_msg)
track_data.append(event.meta_command)
track_data.extend(write_variable_length(len(event.data)))
track_data.extend(event.data)
# is this event a SysEx Event?
elif isinstance(event, SysExEvent):
track_data.append(0xF0)
track_data.extend(event.data)
track_data.append(0xF7)
# not a meta or SysEx event, must be a general message
elif isinstance(event, Event):
if not status or status.status_msg != event.status_msg or \
status.channel != event.channel:
status = event
track_data.append(event.status_msg | event.channel)
track_data.extend(event.data)
else:
raise ValueError("Unknown MIDI Event: " + str(event))
# prepare the track header
track_header = b'MTrk%s' % struct.pack(">L", len(track_data))
# convert back to absolute ticks
self._make_ticks_abs()
# return the track header + data
return track_header + track_data
@classmethod
[docs] def from_file(cls, midi_stream):
"""
Create a MIDI track by reading the data from a stream.
Parameters
----------
midi_stream : open file handle
MIDI file stream (e.g. open MIDI file handle)
Returns
-------
:class:`MIDITrack` instance
:class:`MIDITrack` instance
"""
events = []
# reset the status
status = None
# first four bytes are Track header
chunk = midi_stream.read(4)
if chunk != b'MTrk':
raise TypeError("Bad track header in MIDI file: %s" % chunk)
# next four bytes are track size
track_size = struct.unpack(">L", midi_stream.read(4))[0]
track_data = iter(midi_stream.read(track_size))
# read in all events
while True:
try:
# first datum is variable length representing the delta-time
tick = read_variable_length(track_data)
# next byte is status message
status_msg = byte2int(next(track_data))
# is the event a MetaEvent?
if MetaEvent.is_event(status_msg):
cmd = byte2int(next(track_data))
if cmd not in EventRegistry.MetaEvents:
import warnings
warnings.warn("Unknown Meta MIDI Event: %s" % cmd)
event_cls = UnknownMetaEvent
else:
event_cls = EventRegistry.MetaEvents[cmd]
data_len = read_variable_length(track_data)
data = [byte2int(next(track_data)) for _ in
range(data_len)]
# create an event and append it to the list
events.append(event_cls(tick=tick, data=data,
meta_command=cmd))
# is this event a SysEx Event?
elif SysExEvent.is_event(status_msg):
data = []
while True:
datum = byte2int(next(track_data))
if datum == 0xF7:
break
data.append(datum)
# create an event and append it to the list
events.append(SysExEvent(tick=tick, data=data))
# not a meta or SysEx event, must be a general MIDI event
else:
key = status_msg & 0xF0
if key not in EventRegistry.Events:
assert status, "Bad byte value"
data = []
key = status & 0xF0
event_cls = EventRegistry.Events[key]
channel = status & 0x0F
data.append(status_msg)
data += [byte2int(next(track_data)) for _ in
range(event_cls.length - 1)]
# create an event and append it to the list
events.append(event_cls(tick=tick, channel=channel,
data=data))
else:
status = status_msg
event_cls = EventRegistry.Events[key]
channel = status & 0x0F
data = [byte2int(next(track_data)) for _ in
range(event_cls.length)]
# create an event and append it to the list
events.append(event_cls(tick=tick, channel=channel,
data=data))
# no more events to be processed
except StopIteration:
break
# create a new track and return it
return cls(events)
@classmethod
[docs] def from_notes(cls, notes, resolution=RESOLUTION):
"""
Create a MIDI track from the given notes.
Parameters
----------
notes : numpy array
Array with the notes, one per row. The columns must be:
(onset time, pitch, duration, velocity).
resolution : int
Resolution (i.e. microseconds per quarter note) of the MIDI track.
Returns
-------
:class:`MIDITrack` instance
:class:`MIDITrack` instance
"""
events = []
# FIXME: what we do here s basically writing a MIDI format 0 file,
# since we put all events in a single (the given) track. The
# tempo and time signature stuff is just a hack!
# first set a tempo, assume a tempo of 120bpm and 4/4 time
# signature, thus 1 quarter note is 0.5 sec long
tempo = SetTempoEvent()
tempo.microseconds_per_quarter_note = int(0.5 * 1e6)
sig = TimeSignatureEvent()
sig.denominator = 4
sig.numerator = 4
# beats per second
bps = 2
# add the notes
for note in notes:
# add NoteOn
e_on = NoteOnEvent()
e_on.tick = int(note[0] * resolution * bps)
e_on.pitch = int(note[1])
e_on.velocity = int(note[3])
# and NoteOff
e_off = NoteOffEvent()
e_off.tick = int((note[0] + note[2]) * resolution * bps)
e_off.pitch = int(note[1])
events.append(e_on)
events.append(e_off)
# sort the events and prepend the tempo and time signature events
events = sorted(events)
events.insert(0, sig)
events.insert(0, tempo)
# create a track, set it to absolute timing and return it
return cls(events, relative_timing=False)
# File I/O classes
[docs]class MIDIFile(object):
"""
MIDI File.
Parameters
----------
tracks : list
List of :class:`MIDITrack` instances.
resolution : int, optional
Resolution (i.e. microseconds per quarter note).
file_format : int, optional
Format of the MIDI file.
"""
def __init__(self, tracks=None, resolution=RESOLUTION, file_format=0):
# init variables
if tracks is None:
self.tracks = []
elif isinstance(tracks, MIDITrack):
self.tracks = [tracks]
elif isinstance(tracks, list):
self.tracks = tracks
else:
raise ValueError('file_format of `tracks` not supported.')
self.resolution = resolution # i.e. microseconds per quarter note
# FIXME: right now we can write only format 0 files...
self.format = file_format
@property
def ticks_per_quarter_note(self):
"""
Number of ticks per quarter note.
"""
return self.resolution
[docs] def tempi(self):
"""
Tempi of the MIDI file.
Returns
-------
tempi : numpy array
Array with tempi (tick, seconds per tick, cumulative time).
"""
# create an empty tempo list
tempi = None
for track in self.tracks:
# get a list with tempo events
tempo_events = [e for e in track.events if
isinstance(e, SetTempoEvent)]
if tempi is None and len(tempo_events) > 0:
# convert to desired format (tick, microseconds per tick)
tempi = [(e.tick, e.microseconds_per_quarter_note /
(1e6 * self.resolution)) for e in tempo_events]
elif tempi is not None and len(tempo_events) > 0:
# tempo events should be contained only in the first track
# of a MIDI file
raise ValueError('SetTempoEvents should be only in the first '
'track of a MIDI file.')
# make sure a tempo is set
if tempi is None:
tempi = [(0, SECONDS_PER_TICK)]
# and the first tempo occurs at tick 0
if tempi[0][0] > 0:
tempi.insert(0, (0, SECONDS_PER_TICK))
# sort (just to be sure)
tempi.sort()
# re-iterate over the list to calculate the cumulative time
for i, _ in enumerate(tempi):
if i == 0:
tempi[i] = (tempi[i][0], tempi[i][1], 0)
else:
ticks = tempi[i][0] - tempi[i - 1][0]
cum_time = tempi[i - 1][2] + ticks * tempi[i - 1][1]
tempi[i] = (tempi[i][0], tempi[i][1], cum_time)
# return tempo
return np.asarray(tempi, np.float)
[docs] def time_signatures(self):
"""
Time signatures of the MIDI file.
Returns
-------
time_signatures : numpy array
Array with time signatures (tick, numerator, denominator).
"""
signatures = None
for track in self.tracks:
# get a list with time signature events
time_signature_events = [e for e in track.events if
isinstance(e, TimeSignatureEvent)]
if signatures is None and len(time_signature_events) > 0:
# convert to desired format
signatures = [(e.tick, e.numerator, e.denominator)
for e in time_signature_events]
elif signatures is not None and len(time_signature_events) > 0:
# time signature events should be contained only in the first
# track of a MIDI file, thus raise an error
raise ValueError('TimeSignatureEvent should be only in the '
'first track of a MIDI file.')
# make sure a time signature is set and the first one occurs at tick 0
if signatures is None:
signatures = [(0, TIME_SIGNATURE_NUMERATOR,
TIME_SIGNATURE_DENOMINATOR)]
if signatures[0][0] > 0:
signatures.insert(0, (0, TIME_SIGNATURE_NUMERATOR,
TIME_SIGNATURE_DENOMINATOR))
# return time signatures
return np.asarray(signatures, dtype=int)
[docs] def notes(self, note_time_unit='s'):
"""
Notes of the MIDI file.
Parameters
----------
note_time_unit : {'s', 'b'}
Time unit for notes, seconds ('s') or beats ('b').
Returns
-------
notes : numpy array
Array with notes (onset time, pitch, duration, velocity).
"""
# list for all notes
notes = []
# dictionaries for storing the last onset and velocity per pitch
note_onsets = {}
note_velocities = {}
for track in self.tracks:
# get a list with note events
note_events = [e for e in track.events if isinstance(e, NoteEvent)]
# process all events
tick = 0
for e in note_events:
if tick > e.tick:
raise AssertionError('note events must be sorted!')
is_note_on = isinstance(e, NoteOnEvent)
is_note_off = isinstance(e, NoteOffEvent)
# if it's a note on event with a velocity > 0,
if is_note_on and e.velocity > 0:
# save the onset time and velocity
note_onsets[e.pitch] = e.tick
note_velocities[e.pitch] = e.velocity
# if it's a note off event or a note on with a velocity of 0,
elif is_note_off or (is_note_on and e.velocity == 0):
# the old velocity must be greater 0
if note_velocities[e.pitch] <= 0:
raise AssertionError('note velocity must be positive')
if note_onsets[e.pitch] > e.tick:
raise AssertionError('note duration must be positive')
# append the note to the list
notes.append((note_onsets[e.pitch], e.pitch,
e.tick - note_onsets[e.pitch],
note_velocities[e.pitch]))
else:
raise TypeError('unexpected NoteEvent')
tick = e.tick
# sort the notes and convert to numpy array
notes.sort()
notes = np.asarray(notes, dtype=np.float)
# convert onset times and durations from ticks to a meaningful unit
# and return the notes
if note_time_unit == 's':
return self._note_ticks_to_seconds(notes)
elif note_time_unit == 'b':
return self._note_ticks_to_beats(notes)
else:
raise ValueError("note_time_unit must be either 's' (seconds) or "
"'b' (beats), not %s." % note_time_unit)
def _note_ticks_to_beats(self, notes):
"""
Converts onsets and offsets of notes from ticks to beats.
Parameters
----------
notes : numpy array or list of tuples
Notes (onset, pitch, offset, velocity).
Returns
-------
notes : numpy array
Notes with onsets and offsets in beats.
"""
tpq = self.ticks_per_quarter_note
time_signatures = self.time_signatures().astype(np.float)
# change the second column of time_signatures to beat position of the
# signature change, the first column is now the tick position, the
# second column the beat position and the third column the new beat
# unit after the signature change
time_signatures[0, 1] = 0
# quarter notes between time signature changes
qnbtsc = np.diff(time_signatures[:, 0]) / tpq
# beats between time signature changes
bbtsc = qnbtsc * (time_signatures[:-1, 2] / 4.0)
# compute beat position of each time signature change
time_signatures[1:, 1] = bbtsc.cumsum()
# iterate over all notes
for note in notes:
onset, _, offset, _ = note
# get info about last time signature change
tsc = time_signatures[np.argmax(time_signatures[:, 0] > onset) - 1]
# adjust onset
onset_ticks_since_tsc = onset - tsc[0]
note[0] = tsc[1] + (onset_ticks_since_tsc / tpq) * (tsc[2] / 4.)
# adjust offsets
offset_ticks_since_tsc = offset - tsc[0]
note[2] = tsc[1] + (offset_ticks_since_tsc / tpq) * (tsc[2] / 4.)
# return notes
return notes
def _note_ticks_to_seconds(self, notes):
"""
Converts onsets and offsets of notes from ticks to seconds.
Parameters
----------
notes : numpy array or list of tuples
Notes (onset, pitch, offset, velocity).
Returns
-------
notes : numpy array
Notes with onset and offset times in seconds.
"""
# cache tempo
tempi = self.tempi()
# iterate over all notes
for note in notes:
onset, _, offset, _ = note
# get last tempo for the onset and offset
t_on = tempi[np.argmax(tempi[:, 0] > onset) - 1]
t_off = tempi[np.argmax(tempi[:, 0] > offset) - 1]
# adjust the note onset and offset
note[0] = (onset - t_on[0]) * t_on[1] + t_on[2]
note[2] = (offset - t_off[0]) * t_off[1] + t_off[2]
# return notes
return notes
# methods for writing MIDI stuff
@property
def data_stream(self):
"""
MIDI data stream representation of the MIDI file.
"""
# generate a MIDI header
data = b'MThd%s' % struct.pack(">LHHH", 6, self.format,
len(self.tracks), self.resolution)
# append the tracks
for track in self.tracks:
data += track.data_stream
# return the raw data
return data
[docs] def write(self, midi_file):
"""
Write a MIDI file.
Parameters
----------
midi_file : str
The MIDI file name.
"""
# if we get a filename, open the file
if not hasattr(midi_file, 'write'):
midi_file = open(midi_file, 'wb')
# write the MIDI stream
midi_file.write(self.data_stream)
# close the file
midi_file.close()
@classmethod
[docs] def from_file(cls, midi_file):
"""
Create a MIDI file instance from a .mid file.
Parameters
----------
midi_file : str
Name of the .mid file to load.
Returns
-------
:class:`MIDIFile` instance
:class:`MIDIFile` instance
"""
tracks = []
resolution = None
midi_format = None
with open(midi_file, 'rb') as midi_file:
# read in file header
# first four bytes are MIDI header
chunk = midi_file.read(4)
if chunk != b'MThd':
raise TypeError("Bad header in MIDI file: %s", chunk)
# next four bytes are header size
# next two bytes specify the format version
# next two bytes specify the number of tracks
# next two bytes specify the resolution/PPQ/Parts Per Quarter
# (in other words, how many ticks per quarter note)
data = struct.unpack(">LHHH", midi_file.read(10))
header_size = data[0]
midi_format = data[1]
num_tracks = data[2]
resolution = data[3]
# if the top bit of the resolution word is 0, the following 15 bits
# describe the time division in ticks per beat
if resolution & 0x8000 == 0:
resolution = resolution
# otherwise the following 15 bits describe the time division in
# frames per second
else:
# from http://www.sonicspot.com/guide/midifiles.html:
# Frames per second is defined by breaking the remaining 15
# bytes into two values. The top 7 bits (bit mask 0x7F00)
# define a value for the number of SMPTE frames and can be
# 24, 25, 29 (for 29.97 fps) or 30. The remaining byte
# (bit mask 0x00FF) defines how many clock ticks or track delta
# positions there are per frame. So a time division example of
# 0x9978 could be broken down into it's three parts: the top
# bit is one, so it is in SMPTE frames per second format, the
# following 7 bits have a value of 25 (0x19) and the bottom
# byte has a value of 120 (0x78). This means the example plays
# at 24(?) frames per second SMPTE time and has 120 ticks per
# frame.
raise NotImplementedError("frames per second resolution not "
"implemented yet.")
# skip the remaining part of the header
if header_size > HEADER_SIZE:
midi_file.read(header_size - HEADER_SIZE)
# read in all tracks
for _ in range(num_tracks):
# read in one track and append it to the tracks list
track = MIDITrack.from_file(midi_file)
tracks.append(track)
if resolution is None or midi_format is None:
raise IOError('unable to read MIDI file %s.' % midi_file)
# return a newly created object
return cls(tracks=tracks, resolution=resolution,
file_format=midi_format)
@classmethod
[docs] def from_notes(cls, notes):
"""
Create a MIDIFile instance from a numpy array with notes.
Parameters
----------
notes : numpy array or list of tuples
Notes (onset, pitch, offset, velocity).
Returns
-------
:class:`MIDIFile` instance
:class:`MIDIFile` instance with all notes collected in one track.
"""
# create a new track from the notes and then a MIDIFile instance
return cls(MIDITrack.from_notes(notes))
@staticmethod
[docs] def add_arguments(parser, length=None, velocity=None):
"""
Add MIDI related arguments to an existing parser object.
Parameters
----------
parser : argparse parser instance
Existing argparse parser object.
length : float, optional
Default length of the notes [seconds].
velocity : int, optional
Default velocity of the notes.
Returns
-------
argparse argument group
MIDI argument parser group object.
"""
# add MIDI related options to the existing parser
g = parser.add_argument_group('MIDI arguments')
g.add_argument('--midi', action='store_true', help='save as MIDI')
if length is not None:
g.add_argument('--note_length', action='store', type=float,
default=length,
help='set the note length [default=%(default).2f]')
if velocity is not None:
g.add_argument('--note_velocity', action='store', type=int,
default=velocity,
help='set the note velocity [default=%(default)i]')
# return the argument group so it can be modified if needed
return g
[docs]def process_notes(data, output=None):
"""
This is a simple processing function. It either loads the notes from a MIDI
file and or writes the notes to a file.
The behaviour depends on the presence of the `output` argument, if 'None'
is given, the notes are read, otherwise the notes are written to file.
Parameters
----------
data : str or numpy array
MIDI file to be loaded (if `output` is 'None') / notes to be written.
output : str, optional
Output file name. If set, the notes given by `data` are written.
Returns
-------
notes : numpy array
Notes read/written.
"""
if output is None:
# load the notes
return MIDIFile.from_file(data).notes()
else:
MIDIFile.from_notes(data).write(output)
return data