Module musx.midi.midifile
The midifile module provide support for reading and writing midi files.
Expand source code
################################################################################
"""
The midifile module provide support for reading and writing midi files.
"""
import os.path
from . import midimsg as mm
from . import midievent as me
from .gm import AcousticGrandPiano
from ..seq import Seq
from ..note import Note
from ..tools import rescale
class MidiFile:
"""
A class for reading and writing midi files.
A MidiFile is an iterable so its tracks can be iterated, sliced and
mapped.
Parameters
----------
path : string
The pathname to the midi file on disk.
tracks : list
A list of one or more tracks, each track is a Seq.
divs : int
The midi file's ticks-per-quarter setting, defaults to 480.
"""
level = 0
"""The MIDI level of the file, either 0, 1, or 2."""
divisions = 0
"""The number of ticks per quarter note."""
tracks = []
"""
The tracks of the MidiFile. Each track is a Seq. Your first track
(track 0 in the file) should start with a tempo message otherwise
the data will be performed using the musx default tempo mm=60.
"""
pathname = ""
"""The pathname of the MidiFile."""
_running_status = 0
def __init__(self, path, tracks=[], divs=480):
if not isinstance(path, str) or len(path) == 0:
raise TypeError(f"'{path}' is not a valid pathname string.")
if not isinstance(tracks, list):
tracks = [tracks]
else:
tracks = tracks.copy() # always copy user's list
if any(not isinstance(t, Seq) for t in tracks):
raise TypeError(f"{tracks} is not a valid list of midi tracks.")
if not isinstance(divs, int) and divs > 0:
raise ValueError(f"{divs} is not a valid divisions-per-quarter.")
self.tracks = tracks
self.divisions = divs
self.pathname = path
def clear(self):
"""Removes all the data from the MidiFile."""
self.level = 0
self.divisions = 0
self.tracks = []
self._running_status = 0
self.pathname = ""
@staticmethod
def fileversion(pathname):
"""
A static function that returns a version of pathname guaranteed to not
reference an existing file. Use this function to ensure that you will
not overwrite a previous version of the MidiFile already on disk.
Parameters
----------
pathname : string
The pathname of the file to version.
Returns
-------
A pathname string guaranteed to not overwrite an existing file on disk.
Example
-------
In this example, the file "foo.mid" would go through the following
versioning until a version was found that did not reference an existing file:
"foo.mid", "foo1.mid", "foo2.mid" ...
```py
MidiFile(fileversion("/path/to/foo.mid"))
```
"""
def nextversion(p):
name, extn = os.path.splitext(p)
vers = 1
yield name + extn
while True:
yield f"{name}{vers}{extn}"
vers += 1
# check versions until not found.
for f in nextversion(pathname):
if not os.path.isfile(f):
return f
def read(self, secs=True):
"""
Reads the file in MidiFile.pathname and collects the track data
as a list of Seqs. Any existing tracks in the MidiFile will be
cleared before reading.
Parameters
----------
time : 'secs' | 'ticks' | 'raw' | Fraction()
The format to import midi time values as. 'secs' is seconds,
'ticks' is time expressed in midi ticks, and 'raw' are the
raw delta values that precede each event in the file. The
default value is 'secs'.
Returns
-------
The MidiFile.
"""
pathname = self.pathname
with open(pathname, "rb") as stream:
self.clear()
self.pathname = pathname # restore the pathname after clearing!
length = self._read_chunk_length(stream, b'MThd')
assert length == 6, "MThd chunk length 6 not found."
self.level = self._bytes_to_int(stream.read(2))
tracks = self._bytes_to_int(stream.read(2))
# read divisions as a two byte signed quantity. if its positive
# then it represents ticks per quarter. if its negative then its
# smpte format where the upper byte contains -24, -25 or -30,
# and the lower byte is positive subframes. Example: millisecond
# smpte timing would be 0xE728 = -25 40 = 25*40 = 1000ms
# see http://midi.teragonaudio.com/tech/midifile/mthd.htm
self.divisions = self._bytes_to_int(stream.read(2), True)
if self.divisions < 0:
raise NotImplementedError("Cowardly refusing to import SMPTE format midi file.")
# print("level=", level, "tracks=", tracks, "divisions=", divisions)
# process all the tracks in the file
for _ in range(tracks):
self._read_track(stream, secs)
return self
def write(self, secs=True):
"""
Writes the MidiFile's track data to the file in MidiFile.pathname.
Parameters
----------
secs : bool
If true the MidiEvents in the track data contain time in seconds
andthis is converted to midi ticks. Otherwise the tracks should
contain tick data.
Returns
-------
The MidiFile.
"""
trnum = len(self.tracks)
if trnum == 0:
raise ValueError("no tracks to write.")
level = 0 if trnum == 1 else 1
divs = self.divisions
pathname = self.pathname # self.fileversion(pathname)
with open(pathname, "wb") as stream:
self._write_chunk_length(stream, b'MThd', 6)
stream.write(self._int_to_bytes(level, 2))
stream.write(self._int_to_bytes(trnum, 2))
stream.write(self._int_to_bytes(divs, 2))
force_tempo = False
# if the first event in the first track is not a
# tempo event then force tempo==60.
if not isinstance(self.tracks[0][0], me.MidiEvent) or not self.tracks[0][0].is_meta(mm.kTempo):
force_tempo = True
microdivs = 0
if hasattr(self.tracks[0], "metatrack"):
microdivs = self.tracks[0].microdivs
# write the tracks
for t in self.tracks:
self._write_track(stream, t, divs, microdivs, force_tempo)
force_tempo = False
self.format = level
self.divisions = divs
return self
def addtrack(self, seq):
"""
Appends a Seq to the midi file's track list.
Parameters
----------
seq : Seq
The Seq to append to the current tracks in the MidiFile.
"""
self.tracks.append(seq)
def print(self, tracks=True, hints=True):
"""
Prints the track contents of the MidiFile.
Parameters
----------
tracks : bool | int | list
Specifies which tracks to print. If tracks is True
then all tracks are printed, if tracks is an integer
then that track number is printed, otherwise tracks
is a list of integers and only those tracks
are printed. Track numbers are 0 based, and track
0 is often a "meta track" of a midi file.
hints : bool
If true then hints are printed in the printed listing,
otherwise they are not.
Returns
-------
The MidiFile.
"""
for i, t in enumerate(self.tracks):
if tracks is True or t is i or i in tracks:
t.print()
@classmethod
def metatrack(cls, tempo=60, timesig=[4,4], keysig=[0,0], ins={}, microdivs=1):
"""
Returns a sequence containing a series of midi meta events that
define the contents for a midi file's track 0, including its tempo,
time signature, key signature, instrument assignments (program changes)
and micro tuning setup using channel tuning. This meta data can then
be assigned as track 0 in a level 1 midifile, or the initial contents
of a level 0 midi file.
Parameters
----------
tempo : int
The quarter-note metronome tempo of midi notes in the file.
Note that musx's tempo defaults to 60 bmp not 120 bpm.
timesig : [num, den]
A list of two integers indicating the numerator and denominator
of a midi time signature, with den being a power of 2.
keysig : [sharpflats, mode]
A list of two integers indicating a key signature and mode.
sharpflats is an integer -7 to 7 where negative numbers are
flats keys, 0 is C and 1 to 7 are sharp keys. The mode
value should be 0 for major and 1 for minor.
ins : {chan: instrument, ...}
A dictionary of upto 16 channel numbers 0 to 15 and their
general midi program asignment. See musx.midi.gm for the
list of midi instument constants.
microdivs : int 1 to 16
Specifies microtonal divisions per semitone (semitone/microdivs).
The default value is 1, so semitone/1 = semitone and no microtonal
output will occur. If microdivs is 2 then semitone/2 = 50 cent
quantization, e.g. quartertone tuning. musx uses channel tuning
to procuce microtones. This means that when microdivs is 2 musx
will claim successive pairs of channels for quarter-tone tuning
so the channels available for different instuments is 0, 2, 4,
6, 8, 10, 12, and 14.
The maxmimum number of microdivs is 16, or 6.25 cents, which is very
close to the frequency limen. This will claim all 16 channels in
order to produce microtone so the only channel available for
instrument assignment is channel 0. For more information see the
micro.py demo file.
"""
tempo = me.MidiEvent.meta_tempo(tempo)
if not len(timesig) == 2 and all(isinstance(n, int) for n in timesig):
raise TypeError(f"invalid timesig: {timesig}.")
timesig = me.MidiEvent.meta_time_signature(timesig[0], timesig[1])
insts = {i: AcousticGrandPiano for i in range(16)}
for c,p in ins.items():
if not isinstance(c, int) and 0 <= c <= 15:
raise TypeError("invalid midi channel: {c}.")
if not isinstance(p, int) and 0 <= p <= 127:
raise TypeError("invalid gm instrument: {p}.")
insts[c] = p
if not len(keysig) == 2 and all(isinstance(n, int) for n in keysig):
raise ValueError(f"invalid timesig: {keysig}.")
keysig = me.MidiEvent.meta_key_signature(keysig[0], keysig[1])
meta = [tempo, timesig, keysig]
meta += [me.MidiEvent.program_change(c,p) for c,p in insts.items()]
if not (1 <= microdivs <= 16):
raise ValueError(f"invalid microtuning value: {microdivs}.")
if microdivs > 1:
values = cls._channel_tuning(microdivs)
for c,v in enumerate(values):
# calculate the pitch bend value
b = round(rescale(v, -2, 2, 0, 16383))
meta.append(me.MidiEvent.pitch_bend(c, b))
metaseq = Seq()
metaseq.events = meta
# mark this seq as being a midi meta track and add the microdivs value
metaseq.metatrack = True
metaseq.microdivs = microdivs if microdivs > 1 else 0
return metaseq
def __str__(self):
return f"<MidiFile: '{self.pathname}' {hex(id(self))}>"
__repr__ = __str__
def __iter__(self):
"""Impelements the iterator protocol."""
return iter(self.tracks)
def __getitem__(self, index):
"""Impelements the iterator protocol."""
return self.tracks[index] # index can be slice
def __len__(self):
"""Impelements the iterator protocol."""
return len(self.tracks)
@staticmethod
def _int_to_bytes(val, num):
"""Converts integer value into the specified number of bytes."""
return val.to_bytes(num, 'big')
@staticmethod
def _bytes_to_int(bytez, sign=False):
"""Returns integer value of bytes."""
return int.from_bytes(bytez, 'big', signed=sign)
def _read_chunk_length(self, stream, ident):
assert stream.read(4) == ident, f"Chunk {ident} not found."
return self._bytes_to_int(stream.read(4))
def _write_chunk_length(self, stream, ident, length):
stream.write(ident)
stream.write(self._int_to_bytes(length, 4))
def _read_varlen_value(self, stream):
"""Reads a variable length quantity and returns its integer value."""
value = self._bytes_to_int(stream.read(1))
if value & 0x80: # value's top bit is 1 so keep reading
value &= 0x7F # its actual value is the lower 7 bits
while True:
value <<= 7 # left shift to make room for next byte
other = self._bytes_to_int(stream.read(1))
value += other & 0x7F
if not other & 0x80: # done if upper bit is not 1
break
return value
@staticmethod
def _write_varlen_value(stream, val):
"""Writes integer value as variable length quantity."""
vlq = []
for i in range(21, 0, -7):
if val >= (1 << i):
vlq.append(((val >> i) & 0x7F) | 0x80)
vlq.append(val & 0x7F)
#print("val=", val, "vlq=", vlq)
#stream.write(bytes(vlq))
for b in vlq:
stream.write(MidiFile._int_to_bytes(b, 1))
def _read_meta_message(self, stream):
metatype = self._bytes_to_int(stream.read(1))
length = self._read_varlen_value(stream)
if mm.kText <= metatype <= mm.kDevName:
return [0xFF, metatype, length, stream.read(length)]
if metatype in [mm.kChanPrefix, mm.kMidiPort, mm.kTempo,
mm.kSMPTEOff, mm.kTimeSig, mm.kKeySig,
mm.kSeqNumber]:
msg = [0xFF, metatype, length]
for n in stream.read(length):
msg.append(n)
return msg
if metatype == mm.kEOT:
return [0xFF, metatype, length]
if metatype == mm.kSeqEvent:
return [0xFF, metatype, length, stream.read(length)]
raise NotImplementedError(f"_read_meta_message: unhandled metatype {hex(metatype)}.")
def _read_channel_message(self, stream, status):
stat = status & 0xF0
if mm.kNoteOff <= stat < mm.kProgChange or stat > mm.kChanPress:
# two data bytes: note off, note on, aftertouch, controller, and pitchbend
val1 = self._bytes_to_int(stream.read(1))
val2 = self._bytes_to_int(stream.read(1))
return [status, val1, val2]
else:
# one data byte: program change, channel pressure
val1 = self._bytes_to_int(stream.read(1))
return [status, val1]
def _read_sysex_message(self, stream, status):
# Note: the length includes the terminal EOE
length = self._read_varlen_value(stream)
return [status, length, stream.read(length)]
def _read_message(self, stream):
# [:1] because peek can return more than one byte
status = self._bytes_to_int(stream.peek(1)[:1])
if status & 0x80:
# have a channel message
if status < mm.kSysEx:
self._running_status = status
stream.read(1)
else:
status = self._running_status
assert status & 0x80, "status byte not found."
if status < mm.kSysEx: # a channel message
return self._read_channel_message(stream, status)
elif status == mm.kSysEx or status <= mm.kEOE: # a sysex message
self._running_status = 0
return self._read_sysex_message(stream, status)
elif status == mm.kMetaMsg: # a meta message
self._running_status = 0
return self._read_meta_message(stream)
else:
raise NotImplementedError(f"channel status {hex(status)} unsupported.")
@staticmethod
def _write_message(stream, message):
status = message[0]
if status < mm.kSysEx:
#print("writing", message)
for b in message:
stream.write(MidiFile._int_to_bytes(b, 1))
#stream.write(bytes(message))
elif status == mm.kMetaMsg:
stream.write(MidiFile._int_to_bytes(status, 1))
meta = message[1]
stream.write(MidiFile._int_to_bytes(meta, 1))
if mm.kText <= meta <= mm.kDevName or meta == mm.kSeqEvent:
MidiFile._write_varlen_value(stream, message[2])
stream.write(message[3]) # a bytes struct
else:
for b in message[2:]:
stream.write(MidiFile._int_to_bytes(b, 1))
# stream.write(bytes(message[2:]))
elif status == mm.kSysEx or status == mm.kEOE:
stream.write(status)
MidiFile._write_varlen_value(stream, message[1])
stream.write(message[2]) # a bytes struct
else:
raise NotImplementedError(f"Unsupported message: {message}.")
def _read_track(self, stream, tosecs):
abs_delta = 0
# read the length of the track. since this is not dependable we
# ignore it and use the required EOT message to stop the track.
self._read_chunk_length(stream, b'MTrk')
self._running_status = 0
trk = []
while True:
delta = self._read_varlen_value(stream)
abs_delta += delta
msg = self._read_message(stream)
# print(delta, msg)
# break on EOT, which is required by the midifile spec.
if mm.is_meta_message_type(msg, mm.kEOT): # mm.is_meta_eot(msg):
break
time = abs_delta / self.divisions if tosecs else abs_delta
trk.append(me.MidiEvent(msg, time))
# add the track as a sequence in the midi file
self.tracks.append(Seq(trk))
def _write_track(self, stream, track, divs, microdivs, force_tempo=False):
##print("write_track------------------------------")
#track.print()
self._write_chunk_length(stream, b'MTrk', 0)
# save the begin position of this track.
track_beg = stream.tell()
# the previous time, used to calculate delta times between events.
prev_time = 0
# If force_tempo is True then this is the first track
# and the user did not provide a tempo marking. In
# this case write an initial tempo message for mm=60
if force_tempo:
MidiFile._write_varlen_value(stream, 0)
MidiFile._write_message(stream, mm.meta_tempo(1000000))
# pending queue of note offs, used if the track contains Note objects.
off_queue = []
# write out all the events in the track
##foo = 0
for ev in track.serialize():
##print(foo,"\t", ev); foo += 1
# write out any pending offs <= ev.time
prev_time = MidiFile._write_offs(stream, off_queue, ev.time, prev_time, divs)
# if we encounter a Note object, enqueue a note off and write a note on immediately.
if isinstance(ev, Note):
chan = ev.instrument
key = ev._pitchtokey()
vel = int(ev.amplitude * 127)
if isinstance(key, float):
if not key.is_integer() and microdivs > 1:
chan, key = MidiFile._microtune(chan, key, microdivs)
else:
key = int(key)
noteon = me.MidiEvent([mm.kNoteOn | chan, key, vel], ev.time)
noteoff = me.MidiEvent([mm.kNoteOff | chan, key, 127], ev.time+ev.duration)
MidiFile._enqueue_off(noteoff, off_queue)
ev = noteon
#print("ev.message", ev.message)
delta = int((ev.time - prev_time) * divs)
MidiFile._write_varlen_value(stream, delta)
MidiFile._write_message(stream, ev.message)
prev_time = ev.time
# flush any remaining note offs.
MidiFile._write_offs(stream, off_queue, prev_time, prev_time, divs, True)
# add a 0 delta and EOT
MidiFile._write_varlen_value(stream, 0)
MidiFile._write_message(stream, mm.meta_eot())
# calculate the length of the track we just wrote
track_end = stream.tell()
track_len = track_end - track_beg
# go to track header's 4-byte length field and write the length
stream.seek(track_beg - 4)
stream.write(self._int_to_bytes(track_len, 4))
# reposition back here to write the next track
stream.seek(track_end)
@staticmethod
def _enqueue_off(off, queue):
"""Adds a note off to the note off queue at the latest possible position."""
i = 0; l = len(queue)
while i < l and queue[i].time <= off.time:
i += 1
queue.insert(i, off)
@staticmethod
def _write_offs(stream, queue, time, prev, divs, all=False):
"""Writes offs <= current time, returns updated previous time."""
while (queue and (all or (queue[0].time <= time))):
MidiFile._write_varlen_value(stream, int((queue[0].time - prev) * divs))
MidiFile._write_message(stream, queue[0].message)
prev = queue.pop(0).time
return prev
@staticmethod
def _microtune(channel, floatkey, microdivs):
"""
Quantizes a floating point key number to div number of divisions
per semitone.
"""
microincr = 1.0 / microdivs # microtonal increment
keynumber = int(floatkey) # int version
remainder = floatkey - keynumber # float's fractional portion is microtones
microchan = 0 # the microtonal channel to shift to
for i in range(0, microdivs +1 ): # iterate number of divisions plus 1.
if microincr * i <= remainder < microincr * (i + 1): # found rem in this bucket!
microchan = i # offset to microtuned channel in midi file.
break
channel += microchan # shift note to microtuned channel
return channel, keynumber
@staticmethod
def _channel_tuning(microdivs):
"""
Internal function that converts the microdivs value
(the number of divisions per semitone) into a
sequence of cent values above the standard midi
key number and then repeatedly assigns the sequence
across all 16 midi channels.
Example: if microdivs is 2, then the semitone is divided
into two 50 cent steps [0, .5], this pattern is then
repeated eight times over the sixteen midi channels
[0, .5, 0, .5, ... 0, .5] yielding eight pairs of channels
at indexes 0, 2, 4, ... 14 tuned for quarter tones.
Parameters
----------
microdivs : int
The number of divisions per semitone, 1 is semitone,
2 is quarter tone, etc.
Returns
-------
A sequence of microdivs adjustments for all 16 channels.
"""
microdivs = max(1, min(16, microdivs))
cents = [0.0]
for i in range(1, microdivs):
cents.append(1.0 * i/microdivs)
# return a row of 16 repeating cent values
return [cents[i % len(cents)] for i in range(16)]
Classes
class MidiFile (path, tracks=[], divs=480)
-
A class for reading and writing midi files.
A MidiFile is an iterable so its tracks can be iterated, sliced and mapped.
Parameters
path
:string
- The pathname to the midi file on disk.
tracks
:list
- A list of one or more tracks, each track is a Seq.
divs
:int
- The midi file's ticks-per-quarter setting, defaults to 480.
Expand source code
class MidiFile: """ A class for reading and writing midi files. A MidiFile is an iterable so its tracks can be iterated, sliced and mapped. Parameters ---------- path : string The pathname to the midi file on disk. tracks : list A list of one or more tracks, each track is a Seq. divs : int The midi file's ticks-per-quarter setting, defaults to 480. """ level = 0 """The MIDI level of the file, either 0, 1, or 2.""" divisions = 0 """The number of ticks per quarter note.""" tracks = [] """ The tracks of the MidiFile. Each track is a Seq. Your first track (track 0 in the file) should start with a tempo message otherwise the data will be performed using the musx default tempo mm=60. """ pathname = "" """The pathname of the MidiFile.""" _running_status = 0 def __init__(self, path, tracks=[], divs=480): if not isinstance(path, str) or len(path) == 0: raise TypeError(f"'{path}' is not a valid pathname string.") if not isinstance(tracks, list): tracks = [tracks] else: tracks = tracks.copy() # always copy user's list if any(not isinstance(t, Seq) for t in tracks): raise TypeError(f"{tracks} is not a valid list of midi tracks.") if not isinstance(divs, int) and divs > 0: raise ValueError(f"{divs} is not a valid divisions-per-quarter.") self.tracks = tracks self.divisions = divs self.pathname = path def clear(self): """Removes all the data from the MidiFile.""" self.level = 0 self.divisions = 0 self.tracks = [] self._running_status = 0 self.pathname = "" @staticmethod def fileversion(pathname): """ A static function that returns a version of pathname guaranteed to not reference an existing file. Use this function to ensure that you will not overwrite a previous version of the MidiFile already on disk. Parameters ---------- pathname : string The pathname of the file to version. Returns ------- A pathname string guaranteed to not overwrite an existing file on disk. Example ------- In this example, the file "foo.mid" would go through the following versioning until a version was found that did not reference an existing file: "foo.mid", "foo1.mid", "foo2.mid" ... ```py MidiFile(fileversion("/path/to/foo.mid")) ``` """ def nextversion(p): name, extn = os.path.splitext(p) vers = 1 yield name + extn while True: yield f"{name}{vers}{extn}" vers += 1 # check versions until not found. for f in nextversion(pathname): if not os.path.isfile(f): return f def read(self, secs=True): """ Reads the file in MidiFile.pathname and collects the track data as a list of Seqs. Any existing tracks in the MidiFile will be cleared before reading. Parameters ---------- time : 'secs' | 'ticks' | 'raw' | Fraction() The format to import midi time values as. 'secs' is seconds, 'ticks' is time expressed in midi ticks, and 'raw' are the raw delta values that precede each event in the file. The default value is 'secs'. Returns ------- The MidiFile. """ pathname = self.pathname with open(pathname, "rb") as stream: self.clear() self.pathname = pathname # restore the pathname after clearing! length = self._read_chunk_length(stream, b'MThd') assert length == 6, "MThd chunk length 6 not found." self.level = self._bytes_to_int(stream.read(2)) tracks = self._bytes_to_int(stream.read(2)) # read divisions as a two byte signed quantity. if its positive # then it represents ticks per quarter. if its negative then its # smpte format where the upper byte contains -24, -25 or -30, # and the lower byte is positive subframes. Example: millisecond # smpte timing would be 0xE728 = -25 40 = 25*40 = 1000ms # see http://midi.teragonaudio.com/tech/midifile/mthd.htm self.divisions = self._bytes_to_int(stream.read(2), True) if self.divisions < 0: raise NotImplementedError("Cowardly refusing to import SMPTE format midi file.") # print("level=", level, "tracks=", tracks, "divisions=", divisions) # process all the tracks in the file for _ in range(tracks): self._read_track(stream, secs) return self def write(self, secs=True): """ Writes the MidiFile's track data to the file in MidiFile.pathname. Parameters ---------- secs : bool If true the MidiEvents in the track data contain time in seconds andthis is converted to midi ticks. Otherwise the tracks should contain tick data. Returns ------- The MidiFile. """ trnum = len(self.tracks) if trnum == 0: raise ValueError("no tracks to write.") level = 0 if trnum == 1 else 1 divs = self.divisions pathname = self.pathname # self.fileversion(pathname) with open(pathname, "wb") as stream: self._write_chunk_length(stream, b'MThd', 6) stream.write(self._int_to_bytes(level, 2)) stream.write(self._int_to_bytes(trnum, 2)) stream.write(self._int_to_bytes(divs, 2)) force_tempo = False # if the first event in the first track is not a # tempo event then force tempo==60. if not isinstance(self.tracks[0][0], me.MidiEvent) or not self.tracks[0][0].is_meta(mm.kTempo): force_tempo = True microdivs = 0 if hasattr(self.tracks[0], "metatrack"): microdivs = self.tracks[0].microdivs # write the tracks for t in self.tracks: self._write_track(stream, t, divs, microdivs, force_tempo) force_tempo = False self.format = level self.divisions = divs return self def addtrack(self, seq): """ Appends a Seq to the midi file's track list. Parameters ---------- seq : Seq The Seq to append to the current tracks in the MidiFile. """ self.tracks.append(seq) def print(self, tracks=True, hints=True): """ Prints the track contents of the MidiFile. Parameters ---------- tracks : bool | int | list Specifies which tracks to print. If tracks is True then all tracks are printed, if tracks is an integer then that track number is printed, otherwise tracks is a list of integers and only those tracks are printed. Track numbers are 0 based, and track 0 is often a "meta track" of a midi file. hints : bool If true then hints are printed in the printed listing, otherwise they are not. Returns ------- The MidiFile. """ for i, t in enumerate(self.tracks): if tracks is True or t is i or i in tracks: t.print() @classmethod def metatrack(cls, tempo=60, timesig=[4,4], keysig=[0,0], ins={}, microdivs=1): """ Returns a sequence containing a series of midi meta events that define the contents for a midi file's track 0, including its tempo, time signature, key signature, instrument assignments (program changes) and micro tuning setup using channel tuning. This meta data can then be assigned as track 0 in a level 1 midifile, or the initial contents of a level 0 midi file. Parameters ---------- tempo : int The quarter-note metronome tempo of midi notes in the file. Note that musx's tempo defaults to 60 bmp not 120 bpm. timesig : [num, den] A list of two integers indicating the numerator and denominator of a midi time signature, with den being a power of 2. keysig : [sharpflats, mode] A list of two integers indicating a key signature and mode. sharpflats is an integer -7 to 7 where negative numbers are flats keys, 0 is C and 1 to 7 are sharp keys. The mode value should be 0 for major and 1 for minor. ins : {chan: instrument, ...} A dictionary of upto 16 channel numbers 0 to 15 and their general midi program asignment. See musx.midi.gm for the list of midi instument constants. microdivs : int 1 to 16 Specifies microtonal divisions per semitone (semitone/microdivs). The default value is 1, so semitone/1 = semitone and no microtonal output will occur. If microdivs is 2 then semitone/2 = 50 cent quantization, e.g. quartertone tuning. musx uses channel tuning to procuce microtones. This means that when microdivs is 2 musx will claim successive pairs of channels for quarter-tone tuning so the channels available for different instuments is 0, 2, 4, 6, 8, 10, 12, and 14. The maxmimum number of microdivs is 16, or 6.25 cents, which is very close to the frequency limen. This will claim all 16 channels in order to produce microtone so the only channel available for instrument assignment is channel 0. For more information see the micro.py demo file. """ tempo = me.MidiEvent.meta_tempo(tempo) if not len(timesig) == 2 and all(isinstance(n, int) for n in timesig): raise TypeError(f"invalid timesig: {timesig}.") timesig = me.MidiEvent.meta_time_signature(timesig[0], timesig[1]) insts = {i: AcousticGrandPiano for i in range(16)} for c,p in ins.items(): if not isinstance(c, int) and 0 <= c <= 15: raise TypeError("invalid midi channel: {c}.") if not isinstance(p, int) and 0 <= p <= 127: raise TypeError("invalid gm instrument: {p}.") insts[c] = p if not len(keysig) == 2 and all(isinstance(n, int) for n in keysig): raise ValueError(f"invalid timesig: {keysig}.") keysig = me.MidiEvent.meta_key_signature(keysig[0], keysig[1]) meta = [tempo, timesig, keysig] meta += [me.MidiEvent.program_change(c,p) for c,p in insts.items()] if not (1 <= microdivs <= 16): raise ValueError(f"invalid microtuning value: {microdivs}.") if microdivs > 1: values = cls._channel_tuning(microdivs) for c,v in enumerate(values): # calculate the pitch bend value b = round(rescale(v, -2, 2, 0, 16383)) meta.append(me.MidiEvent.pitch_bend(c, b)) metaseq = Seq() metaseq.events = meta # mark this seq as being a midi meta track and add the microdivs value metaseq.metatrack = True metaseq.microdivs = microdivs if microdivs > 1 else 0 return metaseq def __str__(self): return f"<MidiFile: '{self.pathname}' {hex(id(self))}>" __repr__ = __str__ def __iter__(self): """Impelements the iterator protocol.""" return iter(self.tracks) def __getitem__(self, index): """Impelements the iterator protocol.""" return self.tracks[index] # index can be slice def __len__(self): """Impelements the iterator protocol.""" return len(self.tracks) @staticmethod def _int_to_bytes(val, num): """Converts integer value into the specified number of bytes.""" return val.to_bytes(num, 'big') @staticmethod def _bytes_to_int(bytez, sign=False): """Returns integer value of bytes.""" return int.from_bytes(bytez, 'big', signed=sign) def _read_chunk_length(self, stream, ident): assert stream.read(4) == ident, f"Chunk {ident} not found." return self._bytes_to_int(stream.read(4)) def _write_chunk_length(self, stream, ident, length): stream.write(ident) stream.write(self._int_to_bytes(length, 4)) def _read_varlen_value(self, stream): """Reads a variable length quantity and returns its integer value.""" value = self._bytes_to_int(stream.read(1)) if value & 0x80: # value's top bit is 1 so keep reading value &= 0x7F # its actual value is the lower 7 bits while True: value <<= 7 # left shift to make room for next byte other = self._bytes_to_int(stream.read(1)) value += other & 0x7F if not other & 0x80: # done if upper bit is not 1 break return value @staticmethod def _write_varlen_value(stream, val): """Writes integer value as variable length quantity.""" vlq = [] for i in range(21, 0, -7): if val >= (1 << i): vlq.append(((val >> i) & 0x7F) | 0x80) vlq.append(val & 0x7F) #print("val=", val, "vlq=", vlq) #stream.write(bytes(vlq)) for b in vlq: stream.write(MidiFile._int_to_bytes(b, 1)) def _read_meta_message(self, stream): metatype = self._bytes_to_int(stream.read(1)) length = self._read_varlen_value(stream) if mm.kText <= metatype <= mm.kDevName: return [0xFF, metatype, length, stream.read(length)] if metatype in [mm.kChanPrefix, mm.kMidiPort, mm.kTempo, mm.kSMPTEOff, mm.kTimeSig, mm.kKeySig, mm.kSeqNumber]: msg = [0xFF, metatype, length] for n in stream.read(length): msg.append(n) return msg if metatype == mm.kEOT: return [0xFF, metatype, length] if metatype == mm.kSeqEvent: return [0xFF, metatype, length, stream.read(length)] raise NotImplementedError(f"_read_meta_message: unhandled metatype {hex(metatype)}.") def _read_channel_message(self, stream, status): stat = status & 0xF0 if mm.kNoteOff <= stat < mm.kProgChange or stat > mm.kChanPress: # two data bytes: note off, note on, aftertouch, controller, and pitchbend val1 = self._bytes_to_int(stream.read(1)) val2 = self._bytes_to_int(stream.read(1)) return [status, val1, val2] else: # one data byte: program change, channel pressure val1 = self._bytes_to_int(stream.read(1)) return [status, val1] def _read_sysex_message(self, stream, status): # Note: the length includes the terminal EOE length = self._read_varlen_value(stream) return [status, length, stream.read(length)] def _read_message(self, stream): # [:1] because peek can return more than one byte status = self._bytes_to_int(stream.peek(1)[:1]) if status & 0x80: # have a channel message if status < mm.kSysEx: self._running_status = status stream.read(1) else: status = self._running_status assert status & 0x80, "status byte not found." if status < mm.kSysEx: # a channel message return self._read_channel_message(stream, status) elif status == mm.kSysEx or status <= mm.kEOE: # a sysex message self._running_status = 0 return self._read_sysex_message(stream, status) elif status == mm.kMetaMsg: # a meta message self._running_status = 0 return self._read_meta_message(stream) else: raise NotImplementedError(f"channel status {hex(status)} unsupported.") @staticmethod def _write_message(stream, message): status = message[0] if status < mm.kSysEx: #print("writing", message) for b in message: stream.write(MidiFile._int_to_bytes(b, 1)) #stream.write(bytes(message)) elif status == mm.kMetaMsg: stream.write(MidiFile._int_to_bytes(status, 1)) meta = message[1] stream.write(MidiFile._int_to_bytes(meta, 1)) if mm.kText <= meta <= mm.kDevName or meta == mm.kSeqEvent: MidiFile._write_varlen_value(stream, message[2]) stream.write(message[3]) # a bytes struct else: for b in message[2:]: stream.write(MidiFile._int_to_bytes(b, 1)) # stream.write(bytes(message[2:])) elif status == mm.kSysEx or status == mm.kEOE: stream.write(status) MidiFile._write_varlen_value(stream, message[1]) stream.write(message[2]) # a bytes struct else: raise NotImplementedError(f"Unsupported message: {message}.") def _read_track(self, stream, tosecs): abs_delta = 0 # read the length of the track. since this is not dependable we # ignore it and use the required EOT message to stop the track. self._read_chunk_length(stream, b'MTrk') self._running_status = 0 trk = [] while True: delta = self._read_varlen_value(stream) abs_delta += delta msg = self._read_message(stream) # print(delta, msg) # break on EOT, which is required by the midifile spec. if mm.is_meta_message_type(msg, mm.kEOT): # mm.is_meta_eot(msg): break time = abs_delta / self.divisions if tosecs else abs_delta trk.append(me.MidiEvent(msg, time)) # add the track as a sequence in the midi file self.tracks.append(Seq(trk)) def _write_track(self, stream, track, divs, microdivs, force_tempo=False): ##print("write_track------------------------------") #track.print() self._write_chunk_length(stream, b'MTrk', 0) # save the begin position of this track. track_beg = stream.tell() # the previous time, used to calculate delta times between events. prev_time = 0 # If force_tempo is True then this is the first track # and the user did not provide a tempo marking. In # this case write an initial tempo message for mm=60 if force_tempo: MidiFile._write_varlen_value(stream, 0) MidiFile._write_message(stream, mm.meta_tempo(1000000)) # pending queue of note offs, used if the track contains Note objects. off_queue = [] # write out all the events in the track ##foo = 0 for ev in track.serialize(): ##print(foo,"\t", ev); foo += 1 # write out any pending offs <= ev.time prev_time = MidiFile._write_offs(stream, off_queue, ev.time, prev_time, divs) # if we encounter a Note object, enqueue a note off and write a note on immediately. if isinstance(ev, Note): chan = ev.instrument key = ev._pitchtokey() vel = int(ev.amplitude * 127) if isinstance(key, float): if not key.is_integer() and microdivs > 1: chan, key = MidiFile._microtune(chan, key, microdivs) else: key = int(key) noteon = me.MidiEvent([mm.kNoteOn | chan, key, vel], ev.time) noteoff = me.MidiEvent([mm.kNoteOff | chan, key, 127], ev.time+ev.duration) MidiFile._enqueue_off(noteoff, off_queue) ev = noteon #print("ev.message", ev.message) delta = int((ev.time - prev_time) * divs) MidiFile._write_varlen_value(stream, delta) MidiFile._write_message(stream, ev.message) prev_time = ev.time # flush any remaining note offs. MidiFile._write_offs(stream, off_queue, prev_time, prev_time, divs, True) # add a 0 delta and EOT MidiFile._write_varlen_value(stream, 0) MidiFile._write_message(stream, mm.meta_eot()) # calculate the length of the track we just wrote track_end = stream.tell() track_len = track_end - track_beg # go to track header's 4-byte length field and write the length stream.seek(track_beg - 4) stream.write(self._int_to_bytes(track_len, 4)) # reposition back here to write the next track stream.seek(track_end) @staticmethod def _enqueue_off(off, queue): """Adds a note off to the note off queue at the latest possible position.""" i = 0; l = len(queue) while i < l and queue[i].time <= off.time: i += 1 queue.insert(i, off) @staticmethod def _write_offs(stream, queue, time, prev, divs, all=False): """Writes offs <= current time, returns updated previous time.""" while (queue and (all or (queue[0].time <= time))): MidiFile._write_varlen_value(stream, int((queue[0].time - prev) * divs)) MidiFile._write_message(stream, queue[0].message) prev = queue.pop(0).time return prev @staticmethod def _microtune(channel, floatkey, microdivs): """ Quantizes a floating point key number to div number of divisions per semitone. """ microincr = 1.0 / microdivs # microtonal increment keynumber = int(floatkey) # int version remainder = floatkey - keynumber # float's fractional portion is microtones microchan = 0 # the microtonal channel to shift to for i in range(0, microdivs +1 ): # iterate number of divisions plus 1. if microincr * i <= remainder < microincr * (i + 1): # found rem in this bucket! microchan = i # offset to microtuned channel in midi file. break channel += microchan # shift note to microtuned channel return channel, keynumber @staticmethod def _channel_tuning(microdivs): """ Internal function that converts the microdivs value (the number of divisions per semitone) into a sequence of cent values above the standard midi key number and then repeatedly assigns the sequence across all 16 midi channels. Example: if microdivs is 2, then the semitone is divided into two 50 cent steps [0, .5], this pattern is then repeated eight times over the sixteen midi channels [0, .5, 0, .5, ... 0, .5] yielding eight pairs of channels at indexes 0, 2, 4, ... 14 tuned for quarter tones. Parameters ---------- microdivs : int The number of divisions per semitone, 1 is semitone, 2 is quarter tone, etc. Returns ------- A sequence of microdivs adjustments for all 16 channels. """ microdivs = max(1, min(16, microdivs)) cents = [0.0] for i in range(1, microdivs): cents.append(1.0 * i/microdivs) # return a row of 16 repeating cent values return [cents[i % len(cents)] for i in range(16)]
Class variables
var divisions
-
The number of ticks per quarter note.
var level
-
The MIDI level of the file, either 0, 1, or 2.
var pathname
-
The pathname of the MidiFile.
var tracks
-
The tracks of the MidiFile. Each track is a Seq. Your first track (track 0 in the file) should start with a tempo message otherwise the data will be performed using the musx default tempo mm=60.
Static methods
def fileversion(pathname)
-
A static function that returns a version of pathname guaranteed to not reference an existing file. Use this function to ensure that you will not overwrite a previous version of the MidiFile already on disk.
Parameters
pathname
:string
- The pathname of the file to version.
Returns
A pathname string guaranteed to not overwrite an existing file on disk.
Example
In this example, the file "foo.mid" would go through the following versioning until a version was found that did not reference an existing file: "foo.mid", "foo1.mid", "foo2.mid" …
MidiFile(fileversion("/path/to/foo.mid"))
Expand source code
@staticmethod def fileversion(pathname): """ A static function that returns a version of pathname guaranteed to not reference an existing file. Use this function to ensure that you will not overwrite a previous version of the MidiFile already on disk. Parameters ---------- pathname : string The pathname of the file to version. Returns ------- A pathname string guaranteed to not overwrite an existing file on disk. Example ------- In this example, the file "foo.mid" would go through the following versioning until a version was found that did not reference an existing file: "foo.mid", "foo1.mid", "foo2.mid" ... ```py MidiFile(fileversion("/path/to/foo.mid")) ``` """ def nextversion(p): name, extn = os.path.splitext(p) vers = 1 yield name + extn while True: yield f"{name}{vers}{extn}" vers += 1 # check versions until not found. for f in nextversion(pathname): if not os.path.isfile(f): return f
def metatrack(tempo=60, timesig=[4, 4], keysig=[0, 0], ins={}, microdivs=1)
-
Returns a sequence containing a series of midi meta events that define the contents for a midi file's track 0, including its tempo, time signature, key signature, instrument assignments (program changes) and micro tuning setup using channel tuning. This meta data can then be assigned as track 0 in a level 1 midifile, or the initial contents of a level 0 midi file.
Parameters
tempo
:int
- The quarter-note metronome tempo of midi notes in the file. Note that musx's tempo defaults to 60 bmp not 120 bpm.
timesig
:[num, den]
- A list of two integers indicating the numerator and denominator of a midi time signature, with den being a power of 2.
keysig
:[sharpflats, mode]
- A list of two integers indicating a key signature and mode. sharpflats is an integer -7 to 7 where negative numbers are flats keys, 0 is C and 1 to 7 are sharp keys. The mode value should be 0 for major and 1 for minor.
ins
:{chan: instrument, ...}
- A dictionary of upto 16 channel numbers 0 to 15 and their general midi program asignment. See musx.midi.gm for the list of midi instument constants.
microdivs
:int 1 to 16
- Specifies microtonal divisions per semitone (semitone/microdivs). The default value is 1, so semitone/1 = semitone and no microtonal output will occur. If microdivs is 2 then semitone/2 = 50 cent quantization, e.g. quartertone tuning. musx uses channel tuning to procuce microtones. This means that when microdivs is 2 musx will claim successive pairs of channels for quarter-tone tuning so the channels available for different instuments is 0, 2, 4, 6, 8, 10, 12, and 14. The maxmimum number of microdivs is 16, or 6.25 cents, which is very close to the frequency limen. This will claim all 16 channels in order to produce microtone so the only channel available for instrument assignment is channel 0. For more information see the micro.py demo file.
Expand source code
@classmethod def metatrack(cls, tempo=60, timesig=[4,4], keysig=[0,0], ins={}, microdivs=1): """ Returns a sequence containing a series of midi meta events that define the contents for a midi file's track 0, including its tempo, time signature, key signature, instrument assignments (program changes) and micro tuning setup using channel tuning. This meta data can then be assigned as track 0 in a level 1 midifile, or the initial contents of a level 0 midi file. Parameters ---------- tempo : int The quarter-note metronome tempo of midi notes in the file. Note that musx's tempo defaults to 60 bmp not 120 bpm. timesig : [num, den] A list of two integers indicating the numerator and denominator of a midi time signature, with den being a power of 2. keysig : [sharpflats, mode] A list of two integers indicating a key signature and mode. sharpflats is an integer -7 to 7 where negative numbers are flats keys, 0 is C and 1 to 7 are sharp keys. The mode value should be 0 for major and 1 for minor. ins : {chan: instrument, ...} A dictionary of upto 16 channel numbers 0 to 15 and their general midi program asignment. See musx.midi.gm for the list of midi instument constants. microdivs : int 1 to 16 Specifies microtonal divisions per semitone (semitone/microdivs). The default value is 1, so semitone/1 = semitone and no microtonal output will occur. If microdivs is 2 then semitone/2 = 50 cent quantization, e.g. quartertone tuning. musx uses channel tuning to procuce microtones. This means that when microdivs is 2 musx will claim successive pairs of channels for quarter-tone tuning so the channels available for different instuments is 0, 2, 4, 6, 8, 10, 12, and 14. The maxmimum number of microdivs is 16, or 6.25 cents, which is very close to the frequency limen. This will claim all 16 channels in order to produce microtone so the only channel available for instrument assignment is channel 0. For more information see the micro.py demo file. """ tempo = me.MidiEvent.meta_tempo(tempo) if not len(timesig) == 2 and all(isinstance(n, int) for n in timesig): raise TypeError(f"invalid timesig: {timesig}.") timesig = me.MidiEvent.meta_time_signature(timesig[0], timesig[1]) insts = {i: AcousticGrandPiano for i in range(16)} for c,p in ins.items(): if not isinstance(c, int) and 0 <= c <= 15: raise TypeError("invalid midi channel: {c}.") if not isinstance(p, int) and 0 <= p <= 127: raise TypeError("invalid gm instrument: {p}.") insts[c] = p if not len(keysig) == 2 and all(isinstance(n, int) for n in keysig): raise ValueError(f"invalid timesig: {keysig}.") keysig = me.MidiEvent.meta_key_signature(keysig[0], keysig[1]) meta = [tempo, timesig, keysig] meta += [me.MidiEvent.program_change(c,p) for c,p in insts.items()] if not (1 <= microdivs <= 16): raise ValueError(f"invalid microtuning value: {microdivs}.") if microdivs > 1: values = cls._channel_tuning(microdivs) for c,v in enumerate(values): # calculate the pitch bend value b = round(rescale(v, -2, 2, 0, 16383)) meta.append(me.MidiEvent.pitch_bend(c, b)) metaseq = Seq() metaseq.events = meta # mark this seq as being a midi meta track and add the microdivs value metaseq.metatrack = True metaseq.microdivs = microdivs if microdivs > 1 else 0 return metaseq
Methods
def addtrack(self, seq)
-
Appends a Seq to the midi file's track list.
Parameters
seq
:Seq
- The Seq to append to the current tracks in the MidiFile.
Expand source code
def addtrack(self, seq): """ Appends a Seq to the midi file's track list. Parameters ---------- seq : Seq The Seq to append to the current tracks in the MidiFile. """ self.tracks.append(seq)
def clear(self)
-
Removes all the data from the MidiFile.
Expand source code
def clear(self): """Removes all the data from the MidiFile.""" self.level = 0 self.divisions = 0 self.tracks = [] self._running_status = 0 self.pathname = ""
def print(self, tracks=True, hints=True)
-
Prints the track contents of the MidiFile.
Parameters
tracks
:bool | int | list
- Specifies which tracks to print. If tracks is True then all tracks are printed, if tracks is an integer then that track number is printed, otherwise tracks is a list of integers and only those tracks are printed. Track numbers are 0 based, and track 0 is often a "meta track" of a midi file.
hints
:bool
- If true then hints are printed in the printed listing, otherwise they are not.
Returns
The MidiFile.
Expand source code
def print(self, tracks=True, hints=True): """ Prints the track contents of the MidiFile. Parameters ---------- tracks : bool | int | list Specifies which tracks to print. If tracks is True then all tracks are printed, if tracks is an integer then that track number is printed, otherwise tracks is a list of integers and only those tracks are printed. Track numbers are 0 based, and track 0 is often a "meta track" of a midi file. hints : bool If true then hints are printed in the printed listing, otherwise they are not. Returns ------- The MidiFile. """ for i, t in enumerate(self.tracks): if tracks is True or t is i or i in tracks: t.print()
def read(self, secs=True)
-
Reads the file in MidiFile.pathname and collects the track data as a list of Seqs. Any existing tracks in the MidiFile will be cleared before reading.
Parameters
time
:'secs' | 'ticks' | 'raw' | Fraction()
- The format to import midi time values as. 'secs' is seconds, 'ticks' is time expressed in midi ticks, and 'raw' are the raw delta values that precede each event in the file. The default value is 'secs'.
Returns
The MidiFile.
Expand source code
def read(self, secs=True): """ Reads the file in MidiFile.pathname and collects the track data as a list of Seqs. Any existing tracks in the MidiFile will be cleared before reading. Parameters ---------- time : 'secs' | 'ticks' | 'raw' | Fraction() The format to import midi time values as. 'secs' is seconds, 'ticks' is time expressed in midi ticks, and 'raw' are the raw delta values that precede each event in the file. The default value is 'secs'. Returns ------- The MidiFile. """ pathname = self.pathname with open(pathname, "rb") as stream: self.clear() self.pathname = pathname # restore the pathname after clearing! length = self._read_chunk_length(stream, b'MThd') assert length == 6, "MThd chunk length 6 not found." self.level = self._bytes_to_int(stream.read(2)) tracks = self._bytes_to_int(stream.read(2)) # read divisions as a two byte signed quantity. if its positive # then it represents ticks per quarter. if its negative then its # smpte format where the upper byte contains -24, -25 or -30, # and the lower byte is positive subframes. Example: millisecond # smpte timing would be 0xE728 = -25 40 = 25*40 = 1000ms # see http://midi.teragonaudio.com/tech/midifile/mthd.htm self.divisions = self._bytes_to_int(stream.read(2), True) if self.divisions < 0: raise NotImplementedError("Cowardly refusing to import SMPTE format midi file.") # print("level=", level, "tracks=", tracks, "divisions=", divisions) # process all the tracks in the file for _ in range(tracks): self._read_track(stream, secs) return self
def write(self, secs=True)
-
Writes the MidiFile's track data to the file in MidiFile.pathname.
Parameters
secs
:bool
- If true the MidiEvents in the track data contain time in seconds andthis is converted to midi ticks. Otherwise the tracks should contain tick data.
Returns
The MidiFile.
Expand source code
def write(self, secs=True): """ Writes the MidiFile's track data to the file in MidiFile.pathname. Parameters ---------- secs : bool If true the MidiEvents in the track data contain time in seconds andthis is converted to midi ticks. Otherwise the tracks should contain tick data. Returns ------- The MidiFile. """ trnum = len(self.tracks) if trnum == 0: raise ValueError("no tracks to write.") level = 0 if trnum == 1 else 1 divs = self.divisions pathname = self.pathname # self.fileversion(pathname) with open(pathname, "wb") as stream: self._write_chunk_length(stream, b'MThd', 6) stream.write(self._int_to_bytes(level, 2)) stream.write(self._int_to_bytes(trnum, 2)) stream.write(self._int_to_bytes(divs, 2)) force_tempo = False # if the first event in the first track is not a # tempo event then force tempo==60. if not isinstance(self.tracks[0][0], me.MidiEvent) or not self.tracks[0][0].is_meta(mm.kTempo): force_tempo = True microdivs = 0 if hasattr(self.tracks[0], "metatrack"): microdivs = self.tracks[0].microdivs # write the tracks for t in self.tracks: self._write_track(stream, t, divs, microdivs, force_tempo) force_tempo = False self.format = level self.divisions = divs return self