Source code for music21.midi.base

# ------------------------------------------------------------------------------
# Name:         midi/__init__.py
# Purpose:      Access to MIDI library / music21 classes for dealing with midi data
#
# Authors:      Christopher Ariza
#               Michael Scott Asato Cuthbert
#               (Will Ware -- see docs)
#
# Copyright:    Copyright © 2011-2013, 2019-2025 Michael Scott Asato Cuthbert
#               Some parts of this module are in the Public Domain, see details.
# License:      BSD, see license.txt
# ------------------------------------------------------------------------------
'''
Objects and tools for processing MIDI data.  Converts from MIDI files to
:class:`~music21.midi.MidiEvent`, :class:`~music21.midi.MidiTrack`, and
:class:`~music21.midi.MidiFile` objects, and vice versa.

This module originally used routines from Will Ware's public domain midi.py
library from 2001 which was once posted at
groups.google.com/g/alt.sources/msg/0c5fc523e050c35e
'''
from __future__ import annotations

__all__ = [
    'ChannelModeMessages',
    'ChannelVoiceMessages',
    'DeltaTime',
    'METAEVENT_MARKER',
    'MetaEvents',
    'MidiEvent',
    'MidiException',
    'MidiFile',
    'MidiTrack',
    'SysExEvents',
    'getNumber',
    'getVariableLengthNumber',
    'putNumber',
    'putNumbersAsList',
    'putVariableLengthNumber',
]

from collections.abc import Iterable
import unicodedata
from typing import overload
import typing as t

if t.TYPE_CHECKING:
    import pathlib

from music21.common.enums import ContainsEnum
from music21 import defaults
from music21 import environment
from music21 import exceptions21
from music21 import prebase

environLocal = environment.Environment('midi.base')

# good midi reference:
# http://www.sonicspot.com/guide/midifiles.html
# ------------------------------------------------------------------------------
class MidiException(exceptions21.Music21Exception):
    pass

# ------------------------------------------------------------------------------
@overload
def getNumber(midiBytes: int, length: int) -> tuple[int, int]:
    ...

@overload
def getNumber(midiBytes: bytes, length: int) -> tuple[int, bytes]:
    ...

[docs] def getNumber(midiBytes: bytes|int, length: int) -> tuple[int, bytes|int]: ''' Extract the first `length` bytes from a bytes object (or int) and return it and the remaining bytes object as a 2-tuple The `length` is the number of chars to read. This will sum a length greater than 1 if desired. Note that MIDI uses big-endian for everything. This is the inverse of Python's chr() function. Read first two bytes from the bytes encoding of "tests" and return the remainder >>> midi.getNumber(b'tests', 2) (29797, b'sts') That number comes from: >>> ord('t') * 256 + ord('e') 29797 Demonstration of reading the whole length of a byte in: >>> midi.getNumber(b'test', 4) (1952805748, b'') Reading in zero bytes leaves the midiStr unchanged: >>> midi.getNumber(b'test', 0) (0, b'test') The method can also take in an integer and return an integer and the remainder part. This usage might be deprecated in the future and has already been replaced within music21 internal code. >>> midi.getNumber(516, 1) # = 2*256 + 4 (4, 512) * Changed in v10: remove Python 2 legacy string as input -- use bytes instead. ''' summation = 0 if isinstance(midiBytes, bytes): for i in range(length): midiByte = midiBytes[i] summation = (summation << 8) + midiByte return summation, midiBytes[length:] else: # midiStr is an int midNum = midiBytes summation = midNum - ((midNum >> (8 * length)) << (8 * length)) bigBytes = midNum - summation return summation, bigBytes
[docs] def getVariableLengthNumber(midiBytes: bytes) -> tuple[int, bytes]: r''' Given a string or bytes of data, strip off the first character, or all high-byte characters terminating with one whose ord() function is < 0x80. Thus, a variable number of bytes might be read. After finding the appropriate termination, return the remaining string. This is necessary as DeltaTime times are given with variable size, and thus may be of different numbers if characters are used. >>> midi.getVariableLengthNumber(b'A-u') (65, b'-u') >>> midi.getVariableLengthNumber(b'-u') (45, b'u') >>> midi.getVariableLengthNumber(b'u') (117, b'') >>> midi.getVariableLengthNumber(b'test') (116, b'est') >>> midi.getVariableLengthNumber(b'E@-E') (69, b'@-E') >>> midi.getVariableLengthNumber(b'@-E') (64, b'-E') >>> midi.getVariableLengthNumber(b'-E') (45, b'E') >>> midi.getVariableLengthNumber(b'E') (69, b'') Test that variable length characters work: >>> midi.getVariableLengthNumber(b'\xff\x7f') (16383, b'') >>> midi.getVariableLengthNumber('中xy'.encode('utf-8')) (210638584, b'y') If no low-byte character is encoded, raises an IndexError >>> midi.getVariableLengthNumber('中国'.encode('utf-8')) Traceback (most recent call last): IndexError: index out of range Up to v9.7, this method could also take a string as input and return a string. This usage is now removed. ''' # from http://faydoc.tripod.com/formats/mid.htm # This allows the number to be read one byte at a time, and when you see # a msb of 0, you know that it was the last (least significant) byte of the number. summation = 0 i = 0 while i < 999: # should return eventually (was "while True") x = midiBytes[i] # environLocal.printDebug(['getVariableLengthNumber: examined char:', # bin(ord(midiBytes[i]))) summation = (summation << 7) + (x & 0x7F) i += 1 if not (x & 0x80): # environLocal.printDebug([f' getVariableLengthNumber: depth read into string: {i}']) return summation, midiBytes[i:] raise MidiException('did not find the end of the number!')
[docs] def putNumber(num: int, length: int) -> bytes: r''' Put a single number as a hex number at the end of a bytes object `length` bytes long. >>> midi.putNumber(3, 4) b'\x00\x00\x00\x03' >>> midi.putNumber(0, 1) b'\x00' >>> midi.putNumber(258, 2) b'\x01\x02' If the number is larger than the length currently only the least significant bytes are returned. This behavior may change in the near future to raise an exception instead. >>> midi.putNumber(258, 1) b'\x02' ''' lst = bytearray() for i in range(length): n = 8 * (length - 1 - i) thisNum = (num >> n) & 0xFF lst.append(thisNum) return bytes(lst)
[docs] def putVariableLengthNumber(x: int) -> bytes: r''' Turn an integer number into the smallest bytes object that can hold it for MIDI Numbers < 128 are encoded as single bytes and are the same as bytes([x]) >>> midi.putVariableLengthNumber(4) b'\x04' >>> midi.putVariableLengthNumber(127) b'\x7f' >>> bytes([127]) b'\x7f' >>> midi.putVariableLengthNumber(0) b'\x00' Numbers > 7bit but < 16384 need two characters, with the first character set as 0x80 + n // 128 and the second character set as n % 128: >>> midi.putVariableLengthNumber(128) b'\x81\x00' >>> midi.putVariableLengthNumber(129) b'\x81\x01' >>> midi.putVariableLengthNumber(255) b'\x81\x7f' This differs from the 8-bit representation of `bytes([x])`: >>> bytes([128]) b'\x80' >>> bytes([255]) b'\xff' This method can also deal with numbers > 255, which `bytes` cannot. >>> midi.putVariableLengthNumber(256) b'\x82\x00' >>> bytes([256]) Traceback (most recent call last): ValueError: bytes must be in range(0, 256) It also differs from the normal way of representing 256 in Python bytes: >>> bytes([1, 0]) b'\x01\x00' Here are MIDI representation of other numbers that are too large to fit in 8 bits but stored in two bytes in MIDI. >>> midi.putVariableLengthNumber(1024) b'\x88\x00' Notice that the least significant byte is second. >>> midi.putVariableLengthNumber(8192) b'\xc0\x00' >>> midi.putVariableLengthNumber(8193) b'\xc0\x01' This is the maximum normal MIDI number that can be stored in 2 bytes. >>> midi.putVariableLengthNumber(16383) b'\xff\x7f' Numbers >= 16384 are not used in 2-byte classic MIDI, but this routine continues the basic principle from above: >>> midi.putVariableLengthNumber(16384) b'\x81\x80\x00' >>> midi.putVariableLengthNumber(16384 + 128) b'\x81\x81\x00' >>> midi.putVariableLengthNumber(16384 * 2) b'\x82\x80\x00' >>> midi.putVariableLengthNumber(16384 ** 2) b'\x81\x80\x80\x80\x00' Negative numbers raise MidiException >>> midi.putVariableLengthNumber(-1) Traceback (most recent call last): music21.midi.base.MidiException: cannot putVariableLengthNumber() when number is negative: -1 ''' # environLocal.printDebug(['calling putVariableLengthNumber(x) with', x]) # note: negative numbers will cause an infinite loop here if x < 0: raise MidiException(f'cannot putVariableLengthNumber() when number is negative: {x}') lst = bytearray() while True: y, x = x & 0x7F, x >> 7 lst.append(y + 0x80) if x == 0: break lst.reverse() lst[-1] = lst[-1] & 0x7F return bytes(lst)
[docs] def putNumbersAsList(numList: Iterable[int]) -> bytes: r''' Translate a list of numbers (0-255) into bytes. Used for encoding data messages where each byte encodes a different discrete value. >>> midi.putNumbersAsList([0, 0, 0, 3]) b'\x00\x00\x00\x03' For positive numbers this method behaves the same as `bytes(numList)` and that method should be used if you are sure all numbers are positive. >>> bytes([0, 0, 0, 3]) b'\x00\x00\x00\x03' If a number is < 0 then it wraps around from the top. This is used in places like MIDI key signatures where flats are represented by `256 - flats`. >>> midi.putNumbersAsList([0, 0, 0, -3]) b'\x00\x00\x00\xfd' >>> midi.putNumbersAsList([0, 0, 0, -1]) b'\x00\x00\x00\xff' The behavior with negative numbers is different from `bytes(numList)` List can be of any length >>> midi.putNumbersAsList([1, 16, 255]) b'\x01\x10\xff' Any number > 255 (or less than -255) raises an exception: >>> midi.putNumbersAsList([256]) Traceback (most recent call last): music21.midi.base.MidiException: Cannot place a number > 255 in a list: 256 ''' post = bytearray() for n in numList: # assume if a number exceeds range count down from top? if n < 0: n = n % 256 # -1 will be 255 if n >= 256: raise MidiException(f'Cannot place a number > 255 in a list: {n}') post.append(n) return bytes(post)
[docs] class ChannelVoiceMessages(ContainsEnum): ''' ChannelVoiceMessages are the main MIDI messages for channel 1-16, such as NOTE_ON, NOTE_OFF, etc. ''' NOTE_OFF = 0x80 NOTE_ON = 0x90 POLYPHONIC_KEY_PRESSURE = 0xA0 CONTROLLER_CHANGE = 0xB0 PROGRAM_CHANGE = 0xC0 CHANNEL_KEY_PRESSURE = 0xD0 PITCH_BEND = 0xE0
[docs] class ChannelModeMessages(ContainsEnum): ALL_SOUND_OFF = 0x78 RESET_ALL_CONTROLLERS = 0x79 LOCAL_CONTROL = 0x7A ALL_NOTES_OFF = 0x7B OMNI_MODE_OFF = 0x7C OMNI_MODE_ON = 0x7D MONO_MODE_ON = 0x7E POLY_MODE_ON = 0x7F
[docs] class MetaEvents(ContainsEnum): SEQUENCE_NUMBER = 0x00 TEXT_EVENT = 0x01 COPYRIGHT_NOTICE = 0x02 SEQUENCE_TRACK_NAME = 0x03 INSTRUMENT_NAME = 0x04 LYRIC = 0x05 MARKER = 0x06 CUE_POINT = 0x07 PROGRAM_NAME = 0x08 # optional event is used to embed the # patch/program name that is called up by the immediately # subsequent Bank Select and Program Change messages. # It serves to aid the end user in making an intelligent # program choice when using different hardware. SOUND_SET_UNSUPPORTED = 0x09 MIDI_CHANNEL_PREFIX = 0x20 MIDI_PORT = 0x21 END_OF_TRACK = 0x2F SET_TEMPO = 0x51 SMPTE_OFFSET = 0x54 TIME_SIGNATURE = 0x58 KEY_SIGNATURE = 0x59 SEQUENCER_SPECIFIC_META_EVENT = 0x7F UNKNOWN = 0xFF # Container for any unknown code
[docs] class SysExEvents(ContainsEnum): F0_SYSEX_EVENT = 0xF0 F7_SYSEX_EVENT = 0xF7
METAEVENT_MARKER = 0xFF MidiEventTypes = ( ChannelVoiceMessages | ChannelModeMessages | MetaEvents | SysExEvents | t.Literal['DeltaTime'] ) # ------------------------------------------------------------------------------
[docs] class MidiEvent(prebase.ProtoM21Object): ''' A model of a MIDI event, including note-on, note-off, program change, controller change, any many others. MidiEvent objects are paired (preceded) by :class:`~music21.midi.DeltaTime` objects in the list of events in a MidiTrack object. The `track` argument must be a :class:`~music21.midi.MidiTrack` object. The `type` attribute is an enumeration of a Midi event from the ChannelVoiceMessages, ChannelModeMessages, SysExEvents, or MetaEvents enums; if unspecified, defaults to MetaEvents.UNKNOWN. The `channel` attribute is an integer channel id, from 1 to 16. The `time` attribute is an integer duration of the event in ticks. This value can be zero. This value is not essential, as ultimate time positioning is determined by :class:`~music21.midi.DeltaTime` objects. The `pitch` attribute is only defined for note-on and note-off messages. The attribute stores an integer representation (0-127, with 60 = middle C). The `velocity` attribute is only defined for note-on and note-off messages. The attribute stores an integer representation (0-127). A note-on message with velocity 0 is generally assumed to be the same as a note-off message. The `data` attribute is used for storing other messages, such as SEQUENCE_TRACK_NAME string values. .. warning:: The attributes `.midiProgram` and `.midiChannel` on :class:`~music21.instrument.Instrument` objects are 0-indexed, just as they need to be in the written binary .mid. However, as a convenience, :attr:`MidiEvent.channel` is 1-indexed. No analogous convenience is provided for program change data. >>> mt = midi.MidiTrack(1) >>> me1 = midi.MidiEvent(mt) >>> me1.type = midi.ChannelVoiceMessages.NOTE_ON >>> me1.channel = 3 >>> me1.time = 200 >>> me1.pitch = 60 >>> me1.velocity = 120 >>> me1 <music21.midi.MidiEvent NOTE_ON, t=200, track=1, channel=3, pitch=60, velocity=120> >>> me2 = midi.MidiEvent(mt) >>> me2.type = midi.MetaEvents.SEQUENCE_TRACK_NAME >>> me2.data = 'guitar' >>> me2 <music21.midi.MidiEvent SEQUENCE_TRACK_NAME, track=1, data=b'guitar'> * Changed in v9.7: None is not a valid type anymore. Use MetaEvents.UNKNOWN instead. Channel defaults to 1. ''' # noinspection PyShadowingBuiltins # pylint: disable=redefined-builtin def __init__(self, track: MidiTrack|None = None, type: MidiEventTypes = MetaEvents.UNKNOWN, time: int = 0, channel: int = 1) -> None: self.track: MidiTrack|None = track # a MidiTrack object self.type: MidiEventTypes = type self.time: int = time self.channel: int = channel self.parameter1: int|bytes|None = None # pitch or first data value self.parameter2: int|bytes|None = None # velocity or second data value # data is a property # if this is a Note on/off, need to store original # pitch space value in order to determine if this has a microtone self.centShift: int|None = None # store a reference to a corresponding event # if a noteOn, store the note off, and vice versa # circular ref -- but modern Python will garbage collect it. self.correspondingEvent: MidiEvent|None = None # store and pass on a running status if found self.lastStatusByte: int|None = None @property def sortOrder(self) -> int: ''' Ensure that for MidiEvents at the same "time", that order is NOTE_OFF, PITCH_BEND, all others. >>> CVM = midi.ChannelVoiceMessages >>> noteOn = midi.MidiEvent(type=CVM.NOTE_ON) >>> noteOff = midi.MidiEvent(type=CVM.NOTE_OFF) >>> pitchBend = midi.MidiEvent(type=CVM.PITCH_BEND) >>> sorted([noteOn, noteOff, pitchBend], key=lambda me: me.sortOrder) [<music21.midi.MidiEvent NOTE_OFF, track=None, channel=1>, <music21.midi.MidiEvent PITCH_BEND, track=None, channel=1>, <music21.midi.MidiEvent NOTE_ON, track=None, channel=1>] ''' # update based on type; type may be set after init if self.type == ChannelVoiceMessages.NOTE_OFF: # should come before pitch bend return -20 elif self.type == ChannelVoiceMessages.PITCH_BEND: # go before note events return -10 else: return 0 def _reprInternal(self) -> str: trackIndex: int|None if self.track is None: trackIndex = None elif isinstance(self.track, int): # should not happen anymore trackIndex = self.track else: trackIndex = self.track.index printType: str if isinstance(self.type, ContainsEnum): printType = self.type.name elif self.type is None: printType = 'None' else: # should not happen anymore printType = repr(self.type) r = f'{printType}, ' if self.time != 0: r += f't={self.time!r}, ' r += f'track={trackIndex}' if self.isChannelEvent(): r += f', channel={self.channel!r}' if self.type in (ChannelVoiceMessages.NOTE_ON, ChannelVoiceMessages.NOTE_OFF): attrList = ['pitch', 'velocity'] else: if self.parameter2 is None: attrList = ['data'] else: attrList = ['parameter1', 'parameter2'] for attrib in attrList: if getattr(self, attrib) is not None: r = r + ', ' + attrib + '=' + repr(getattr(self, attrib)) return r # provide parameter access to pitch and velocity @property def pitch(self) -> int|None: # only return pitch if this is note on /off if (self.type in (ChannelVoiceMessages.NOTE_ON, ChannelVoiceMessages.NOTE_OFF) and self.parameter1 is not None): return int(self.parameter1) else: return None @pitch.setter def pitch(self, value: int|None) -> None: self.parameter1 = value @property def velocity(self) -> int|None: if not isinstance(self.parameter2, int): return None return self.parameter2 @velocity.setter def velocity(self, value: int|None) -> None: self.parameter2 = value # store generic data in parameter 1 @property def data(self) -> int|bytes|None: r''' Read or set the data (`.parameter1`) for the object Does some automatic conversions: >>> me = midi.MidiEvent(type=midi.ChannelModeMessages.LOCAL_CONTROL) >>> me.data = True >>> me.data b'\x01' ''' return self.parameter1 @data.setter def data(self, value: int|str|bytes|bool|None) -> None: if value is not None and not isinstance(value, bytes): if isinstance(value, str): value = value.encode('utf-8') elif isinstance(value, bool): value = bytes([int(value)]) self.parameter1 = value
[docs] def isChannelEvent(self) -> bool: ''' returns bool if the channel matters for this event >>> me = midi.MidiEvent(type=midi.ChannelVoiceMessages.NOTE_ON, channel=4) >>> me.isChannelEvent() True >>> dt = midi.DeltaTime() >>> dt.isChannelEvent() False ''' return self.type in ChannelVoiceMessages or self.type in ChannelModeMessages
[docs] def setPitchBend(self, cents: int|float, bendRange: int = 2) -> None: ''' Treat this event as a pitch bend value, and set the .parameter1 and .parameter2 fields appropriately given a specified bend value in cents. Also called Pitch Wheel The `bendRange` parameter gives the number of half steps in the bend range. >>> mt = midi.MidiTrack(1) >>> me1 = midi.MidiEvent(mt, type=midi.ChannelVoiceMessages.PITCH_BEND) >>> me1.setPitchBend(50) >>> me1.parameter1, me1.parameter2 (0, 80) >>> me1.setPitchBend(100) >>> me1.parameter1, me1.parameter2 (0, 96) Neutral is 0, 64 >>> me1.setPitchBend(0) >>> me1.parameter1, me1.parameter2 (0, 64) Parameter 2 is the most significant digit, not parameter 1. >>> me1.setPitchBend(101) >>> me1.parameter1, me1.parameter2 (40, 96) Exceeding maximum pitch bend sets the max (127, 127) >>> me1.setPitchBend(200) >>> me1.parameter1, me1.parameter2 (127, 127) >>> me1.setPitchBend(300) >>> me1.parameter1, me1.parameter2 (127, 127) >>> me1.setPitchBend(-50) >>> me1.parameter1, me1.parameter2 (0, 48) >>> me1.setPitchBend(-100) >>> me1.parameter1, me1.parameter2 (0, 32) >>> me1.setPitchBend(-196) >>> me1.parameter1, me1.parameter2 (36, 1) >>> me1.setPitchBend(-200) >>> me1.parameter1, me1.parameter2 (0, 0) Again, excess trimmed >>> me1.setPitchBend(-300) >>> me1.parameter1, me1.parameter2 (0, 0) But a larger `bendRange` can be set in semitones for non-GM devices: >>> me1.setPitchBend(-300, bendRange=4) >>> me1.parameter1, me1.parameter2 (0, 16) >>> me1.setPitchBend(-399, bendRange=4) >>> me1.parameter1, me1.parameter2 (20, 0) OMIT_FROM_DOCS Pitch bends very close to 0 formerly had problems >>> me1.setPitchBend(-196) >>> me1.parameter1, me1.parameter2 (36, 1) >>> me1.setPitchBend(-197) >>> me1.parameter1, me1.parameter2 (123, 0) ''' # value range is 0, 16383 (0 to 0x4000 - 1) # center should be 8192 (0x2000) centRange = bendRange * 100 if cents > centRange: cents = centRange elif cents < -1 * centRange: cents = -1 * centRange center = 0x2000 topSpan = 0x4000 - 1 - center bottomSpan = center shiftScalar = cents / centRange if cents > 0: shift = int(round(shiftScalar * topSpan)) elif cents < 0: shift = int(round(shiftScalar * bottomSpan)) # will be negative else: # cents is zero shift = 0 target = center + shift # produce a 1-2-byte value with most significant byte first. charValue = putVariableLengthNumber(target) d1 = charValue[0] # d1 = parameter2; bytes[i] returns an int # need to convert from 8 bit to 7, so using & 0x7F d1 = d1 & 0x7F if len(charValue) > 1: d2 = charValue[1] d2 = d2 & 0x7F else: d2 = d1 d1 = 0 # environLocal.printDebug(['got target char value', charValue, # 'getVariableLengthNumber(charValue)', getVariableLengthNumber(charValue)[0], # 'd1', d1, 'd2', d2,]) self.parameter1 = d2 self.parameter2 = d1 # d1 is the most significant byte here
[docs] def parseChannelVoiceMessage(self, midiBytes: bytes) -> bytes: r''' Take a set of bytes that represent a ChannelVoiceMessage and set the appropriate event type and data, returning the remaining bytes. Channel voice messages start with a one-byte message from 0x80 to 0xEF, where the first nibble (8-E) is the message type and the second nibble (0-F) is the channel represented in hex. First let's create a MidiEvent that does not know its type. >>> mt = midi.MidiTrack(1) >>> me1 = midi.MidiEvent(mt) >>> me1 <music21.midi.MidiEvent UNKNOWN, track=1> Now create two messages -- a note on (at pitch = 60 or C4) at velocity 120 >>> midBytes = bytes([0x92, 60, 120]) >>> midBytes b'\x92<x' Add a second hypothetical message (would normally be a delta time) >>> midBytes += b'hello' >>> midBytes b'\x92<xhello' Now see how parsing this ChannelVoiceMessage changes the MidiEvent >>> remainder = me1.parseChannelVoiceMessage(midBytes) >>> me1 <music21.midi.MidiEvent NOTE_ON, track=1, channel=3, pitch=60, velocity=120> The remainder would probably contain a delta time and following events, but here we'll just show that it passes whatever is left through. >>> remainder b'hello' We will ignore remainders for the rest of this demo. Note that all attributes are set properly: >>> me1.type <ChannelVoiceMessages.NOTE_ON: 0x90> >>> me1.pitch # 60 = middle C 60 >>> me1.velocity 120 The channel is 3 because 0x90 is NOTE_ON for channel 1, 0x91 is 2, and 0x92 is 3, etc. >>> me1.channel 3 Now let's make a program change on channel 16: >>> me2 = midi.MidiEvent(mt) >>> rem = me2.parseChannelVoiceMessage(bytes([0xCF, 71])) >>> me2 <music21.midi.MidiEvent PROGRAM_CHANGE, track=1, channel=16, data=71> >>> me2.data # 71 = clarinet (0-127 indexed) 71 Program changes and channel pressures only go to 127. More than that signals an error: >>> me2.parseChannelVoiceMessage(bytes([0xCF, 200])) Traceback (most recent call last): music21.midi.base.MidiException: Cannot have a <ChannelVoiceMessages.PROGRAM_CHANGE: 0xC0> followed by a byte > 127: 200 ''' # x, y, and z define characteristics of the first two chars # for x: The left nybble (4 bits) contains the actual command, and the right nibble # contains the midi channel number on which the command will be executed. if len(midiBytes) < 2: raise ValueError(f'length of {midiBytes!r} must be at least 2') byte0 = midiBytes[0] byte1 = midiBytes[1] byte2 = 0 if len(midiBytes) > 2: # very likely, but may be translating in pieces byte2 = midiBytes[2] msgNybble: int = byte0 & 0xF0 # 0x80, 0x90, 0xA0 ... 0xE0 channelNybble: int = byte0 & 0x0F # 0-15 self.channel = channelNybble + 1 self.type = ChannelVoiceMessages(msgNybble) # environLocal.printDebug(['MidiEvent.read()', self.type]) if self.type in (ChannelVoiceMessages.PROGRAM_CHANGE, ChannelVoiceMessages.CHANNEL_KEY_PRESSURE): if byte1 > 127: raise MidiException( f'Cannot have a {self.type!r} followed by a byte > 127: {byte1}') self.data = byte1 return midiBytes[2:] elif self.type == ChannelVoiceMessages.CONTROLLER_CHANGE: specificDataSet = False if ChannelModeMessages.hasValue(byte1): self.type = ChannelModeMessages(byte1) if self.type == ChannelModeMessages.LOCAL_CONTROL: specificDataSet = True self.data = (midiBytes[2] == 0x7F) elif self.type == ChannelModeMessages.MONO_MODE_ON: specificDataSet = True # see http://midi.teragonaudio.com/tech/midispec/mono.htm self.data = midiBytes[2] if not specificDataSet: self.parameter1 = byte1 # this is the controller id self.parameter2 = byte2 # this is the controller value return midiBytes[3:] elif self.type == ChannelVoiceMessages.PITCH_BEND: self.parameter1 = byte1 # least significant byte self.parameter2 = byte2 # most significant byte return midiBytes[3:] elif self.type in (ChannelVoiceMessages.NOTE_ON, ChannelVoiceMessages.NOTE_OFF): # next two bytes: pitch, velocity self.pitch = byte1 self.velocity = byte2 return midiBytes[3:] elif self.type == ChannelVoiceMessages.POLYPHONIC_KEY_PRESSURE: self.parameter1 = byte1 # pitch self.parameter2 = byte2 # pressure return midiBytes[3:] raise TypeError(f'expected ChannelVoiceMessage, got {self.type}') # pragma: no cover
[docs] def read(self, midiBytes: bytes) -> bytes: r''' Parse the bytes given and take the beginning section and convert it into data for this event and return the now truncated bytes. >>> channel = 0x2 >>> noteOnMessage = midi.ChannelVoiceMessages.NOTE_ON | channel >>> hex(noteOnMessage) '0x92' This is how the system reads note-on messages (0x90-0x9F) and channels >>> hex(0x91 & 0xF0) # testing message type extraction '0x90' >>> hex(0x92 & 0xF0) # testing message type extraction '0x90' >>> (0x90 & 0x0F) + 1 # getting the channel 1 >>> (0x9F & 0x0F) + 1 # getting the channel 16 ''' if len(midiBytes) < 2: # often what we have here are null events: # the string is simply: 0x00 environLocal.printDebug( ['MidiEvent.read(): got bad data string', repr(midiBytes)]) return b'' # x, y, and z define characteristics of the first two chars # for x: The left nybble (4 bits) contains the actual command, and the right nibble # contains the midi channel number on which the command will be executed. byte0: int = midiBytes[0] # extracting a single val from a byte makes it an int # detect running status: if the status byte is less than 0x80, it is # not a status byte, but a data byte if byte0 < 0x80: # environLocal.printDebug(['MidiEvent.read(): found running status even data', # 'self.lastStatusByte:', self.lastStatusByte]) if self.lastStatusByte is not None: runningStatusByte = bytes([self.lastStatusByte]) else: # provide a default runningStatusByte = b'\x90' # add the running status byte to the front of the string # and process as before midiBytes = runningStatusByte + midiBytes byte0 = midiBytes[0] elif byte0 != 0xff: # store last status byte, unless it's a meta message self.lastStatusByte = byte0 msgType: int = byte0 & 0xF0 # bitwise and to derive message type w/o channel byte1: int = midiBytes[1] # environLocal.printDebug([ # 'MidiEvent.read(): trying to parse a MIDI event, looking at first two chars:', # 'hex(byte0)', hex(byte0), 'hex(byte[1])', hex(byte[1]),]) if ChannelVoiceMessages.hasValue(msgType): # NOTE_ON and NOTE_OFF and PROGRAM_CHANGE, PITCH_BEND, etc. return self.parseChannelVoiceMessage(midiBytes) elif SysExEvents.hasValue(byte0): self.type = SysExEvents(byte0) length, midiBytesAfterLength = getVariableLengthNumber(midiBytes[1:]) self.data = midiBytesAfterLength[:length] return midiBytesAfterLength[length:] # SEQUENCE_TRACK_NAME and other MetaEvents are here elif byte0 == METAEVENT_MARKER: # 0xFF if MetaEvents.hasValue(byte1): self.type = MetaEvents(byte1) else: # environLocal.printDebug([f' unknown meta event: FF {byte1:02X}']) # sys.stdout.flush() self.type = MetaEvents.UNKNOWN length, midiBytesAfterLength = getVariableLengthNumber(midiBytes[2:]) self.data = midiBytesAfterLength[:length] # return remainder return midiBytesAfterLength[length:] else: # an uncaught message environLocal.printDebug(['got unknown midi event type', hex(byte0), 'hex(midiBytes[1])', hex(midiBytes[1])]) raise MidiException(f'Unknown midi event type {hex(byte0)}')
[docs] def getBytes(self) -> bytes: r''' Return a set of bytes for this MIDI event (used for translating from music21 to MIDI) >>> noteOn = midi.MidiEvent(type=midi.ChannelVoiceMessages.NOTE_ON, channel=10) >>> noteOn.pitch = 60 >>> noteOn.velocity = 127 >>> noteOn.getBytes() b'\x99<\x7f' Changing the pitch changes the second byte (from less than to greater than): >>> noteOn.pitch = 62 >>> noteOn.getBytes() b'\x99>\x7f' >>> ord('>') 62 Changing the velocity changes the third byte: >>> noteOn.velocity = 64 >>> noteOn.getBytes() b'\x99>@' The third byte is `\x40` but it is represented by '@' >>> ord('@') 64 >>> hex(64) '0x40' And changing the channel changes the first byte: >>> noteOn.channel = 11 >>> noteOn.getBytes() b'\x9a>@' ''' if t.TYPE_CHECKING: # no DeltaTime here. assert isinstance(self.type, ContainsEnum) if self.type in ChannelVoiceMessages: # environLocal.printDebug(['writing channelVoiceMessages', self.type]) bytes0 = bytes([self.channel - 1 + self.type.value]) # for writing note-on/note-off if self.type not in (ChannelVoiceMessages.PROGRAM_CHANGE, ChannelVoiceMessages.CHANNEL_KEY_PRESSURE): if self.parameter1 is None or self.parameter2 is None: raise MidiException( 'Cannot write MIDI event without parameter1 and parameter2 set.' ) # this results in a two-part string, like '\x00\x00' param1data: bytes if isinstance(self.parameter1, int): param1data = bytes([self.parameter1]) else: param1data = self.parameter1 param2data: bytes if isinstance(self.parameter2, int): param2data = bytes([self.parameter2]) else: param2data = self.parameter2 data = param1data + param2data elif self.type == ChannelVoiceMessages.PROGRAM_CHANGE and isinstance(self.data, int): data = bytes([self.data]) else: # all the other messages try: if isinstance(self.data, int): data = bytes([self.data]) elif isinstance(self.data, bytes): data = self.data else: raise TypeError('data must be bytes or int') except (TypeError, ValueError): raise MidiException( f'Got incorrect data for {self} in .data: {self.data!r}, ' + 'cannot parse Miscellaneous Message') return bytes0 + data elif self.type in ChannelModeMessages and self.data is not None: channelMode = bytes([0xB0 + self.channel - 1]) msgValue = bytes([self.type.value]) if isinstance(self.data, int): data = bytes([self.data]) else: data = self.data return channelMode + msgValue + data elif self.type in SysExEvents and isinstance(self.data, bytes): s = bytes([self.type.value]) s = s + putVariableLengthNumber(len(self.data)) sys_data = t.cast(bytes, self.data) return s + sys_data elif self.type in MetaEvents and isinstance(self.data, (bytes, str)): s = bytes([METAEVENT_MARKER]) s += bytes([self.type.value]) s += putVariableLengthNumber(len(self.data)) try: sys_data = t.cast(bytes, self.data) return s + sys_data except (UnicodeDecodeError, TypeError): # environLocal.printDebug(['cannot decode data', self.data]) # normalize can take bytes. # noinspection PyTypeChecker return s + unicodedata.normalize( 'NFKD', t.cast(str, self.data) ).encode('ascii', 'ignore') else: raise MidiException(f'unknown midi event type: {self.type!r}')
# --------------------------------------------------------------------------
[docs] def isNoteOn(self) -> bool: ''' Return a boolean if this is a note-on message and velocity is not zero. >>> mt = midi.MidiTrack(1) >>> me1 = midi.MidiEvent(mt) >>> me1.type = midi.ChannelVoiceMessages.NOTE_ON >>> me1.velocity = 120 >>> me1.isNoteOn() True >>> me1.isNoteOff() False A zero velocity note-on is treated as a note-off: >>> me1.velocity = 0 >>> me1.isNoteOn() False >>> me1.isNoteOff() True A midi event can be neither a note on or a note off. >>> me1.type = midi.ChannelVoiceMessages.PROGRAM_CHANGE >>> me1.isNoteOn() False >>> me1.isNoteOff() False ''' if self.type == ChannelVoiceMessages.NOTE_ON and self.velocity != 0: return True return False
[docs] def isNoteOff(self) -> bool: ''' Return a boolean if this should be interpreted as a note-off message, either as a real note-off or as a note-on with zero velocity. >>> mt = midi.MidiTrack(1) >>> me1 = midi.MidiEvent(mt) >>> me1.type = midi.ChannelVoiceMessages.NOTE_OFF >>> me1.isNoteOn() False >>> me1.isNoteOff() True >>> me2 = midi.MidiEvent(mt) >>> me2.type = midi.ChannelVoiceMessages.NOTE_ON >>> me2.velocity = 0 >>> me2.isNoteOn() False >>> me2.isNoteOff() True See :meth:`~music21.midi.MidiEvent.isNoteOn` for more examples. ''' if self.type == ChannelVoiceMessages.NOTE_OFF: return True elif self.type == ChannelVoiceMessages.NOTE_ON and self.velocity == 0: return True return False
[docs] def isDeltaTime(self) -> bool: ''' Return a boolean if this is a DeltaTime subclass. >>> mt = midi.MidiTrack(1) >>> dt = midi.DeltaTime(mt) >>> dt.isDeltaTime() True ''' if self.type == 'DeltaTime': return True return False
[docs] def matchedNoteOff(self, other: MidiEvent) -> bool: ''' Returns True if `other` is a MIDI event that specifies a note-off message for this message. That is, this event is a NOTE_ON message, and the other is a NOTE_OFF message for this pitch on this channel. Otherwise returns False >>> mt = midi.MidiTrack(1) >>> me1 = midi.MidiEvent(mt) >>> me1.type = midi.ChannelVoiceMessages.NOTE_ON >>> me1.velocity = 120 >>> me1.pitch = 60 >>> me2 = midi.MidiEvent(mt) >>> me2.type = midi.ChannelVoiceMessages.NOTE_ON >>> me2.velocity = 0 >>> me2.pitch = 60 `me2` is a Note off for `me1` because it has velocity 0 and matches pitch. >>> me1.matchedNoteOff(me2) True Now the pitch does not match, so it does not work. >>> me2.pitch = 61 >>> me1.matchedNoteOff(me2) False >>> me2.type = midi.ChannelVoiceMessages.NOTE_OFF >>> me1.matchedNoteOff(me2) False >>> me2.pitch = 60 >>> me1.matchedNoteOff(me2) True Channels must match also: >>> me2.channel = 12 >>> me1.matchedNoteOff(me2) False Note that this method is no longer used in MIDI Parsing because it is inefficient. ''' if self.isNoteOn() and other.isNoteOff(): if self.pitch == other.pitch and self.channel == other.channel: return True return False
[docs] class DeltaTime(MidiEvent): r''' A :class:`~music21.midi.MidiEvent` subclass that stores the time change (in ticks) since the start or since the last MidiEvent. Pairs of DeltaTime and MidiEvent objects are the basic presentation of temporal data. The `track` argument must be a :class:`~music21.midi.MidiTrack` object. Time values are in integers, representing ticks. The `channel` attribute, inherited from MidiEvent is not used and set to 1 unless overridden (it does not do anything if set and is not written out to MIDI). >>> mt = midi.MidiTrack(1) >>> dt = midi.DeltaTime(mt) >>> dt.time = 1 >>> dt <music21.midi.DeltaTime t=1, track=1> Changed in v9.7 -- DeltaTime repr no longer lists its channel (to de-emphasize it). Channel defaults to 1. ''' def __init__( self, track: MidiTrack|None = None, time: int = 0, channel: int = 1, ) -> None: super().__init__(track, time=time, channel=channel) self.type = 'DeltaTime' def _reprInternal(self) -> str: rep = super()._reprInternal() rep = rep.replace("'DeltaTime', ", '') if self.time == 0: rep = '(empty) ' + rep return rep
[docs] def isChannelEvent(self) -> bool: ''' Save processing time -- is always False ''' return False
[docs] def readUntilLowByte(self, oldBytes: bytes) -> tuple[int, bytes]: r''' Read a byte-string until hitting a character below 0x80 and return the converted number and the rest of the bytes. The first number is also stored in `self.time`. >>> mt = midi.MidiTrack(1) >>> dt = midi.DeltaTime(mt) >>> dt.time 0 >>> dt.readUntilLowByte(b'\x20') (32, b'') >>> dt.time 32 >>> dt.readUntilLowByte(b'\x20hello') (32, b'hello') here the '\x82' is above 0x80 so the 'h' is read as part of the continuation. >>> dt.readUntilLowByte(b'\x82hello') (360, b'ello') * Changed in v9: had an incompatible signature with MidiEvent. ''' self.time, newBytes = getVariableLengthNumber(oldBytes) return self.time, newBytes
[docs] def getBytes(self) -> bytes: r''' Convert the time integer into a set of bytes. >>> mt = midi.MidiTrack(1) >>> dt = midi.DeltaTime(mt) >>> dt.time = 1 >>> dt.getBytes() b'\x01' >>> dt.time = 128 >>> dt.getBytes() b'\x81\x00' >>> dt.time = 257 >>> dt.getBytes() b'\x82\x01' >>> dt.time = 16385 >>> dt.getBytes() b'\x81\x80\x01' ''' midiBytes = putVariableLengthNumber(self.time) return midiBytes
[docs] class MidiTrack(prebase.ProtoM21Object): r''' A MIDI Track. Each track contains an index and a list of events. All events are stored in the `events` list, in order. An `index` is an integer identifier for this object. It is often called "trackId" though technically the id for a MidiTrack is always b'MTrk' >>> mt = midi.MidiTrack(index=3) >>> mt.events [] >>> mt.index 3 The data consists of all the midi data after b'MTrk' >>> mt.data b'' And the .length is the same as the data's length >>> mt.length 0 After reading a string >>> mt.read(b'MTrk\x00\x00\x00\x16\x00\xff\x03\x00\x00' ... + b'\xe0\x00@\x00\x90CZ\x88\x00\x80C\x00\x88\x00\xff/\x00') b'' The returned value is what is left over after the track is read (for instance the data for another track) >>> mt.length 22 * New in v9.7: len(mt) returns the same as mt.length, but more Pythonic. >>> len(mt) 22 >>> mt.data[:8] b'\x00\xff\x03\x00\x00\xe0\x00@' Note that the '\x16' got translated to ascii '@'. >>> mt.events [<music21.midi.DeltaTime (empty) track=3>, <music21.midi.MidiEvent SEQUENCE_TRACK_NAME, track=3, data=b''>, <music21.midi.DeltaTime (empty) track=3>, <music21.midi.MidiEvent PITCH_BEND, track=3, channel=1, parameter1=0, parameter2=64>, <music21.midi.DeltaTime (empty) track=3>, <music21.midi.MidiEvent NOTE_ON, track=3, channel=1, pitch=67, velocity=90>, <music21.midi.DeltaTime t=1024, track=3>, <music21.midi.MidiEvent NOTE_OFF, track=3, channel=1, pitch=67, velocity=0>, <music21.midi.DeltaTime t=1024, track=3>, <music21.midi.MidiEvent END_OF_TRACK, track=3, data=b''>] >>> mt <music21.midi.MidiTrack 3 -- 10 events> There is a class attribute of the headerId which is the same for all MidiTrack objects >>> midi.MidiTrack.headerId b'MTrk' ''' headerId = b'MTrk' def __init__(self, index: int = 0) -> None: self.index = index self.events: list[MidiEvent] = [] # or DeltaTime subclass self.data = b'' def __len__(self) -> int: ''' * New in v9.7. ''' return len(self.data) @property def length(self) -> int: return len(self.data)
[docs] def read(self, midiBytes: bytes) -> bytes: ''' Read as much of the bytes object (representing midi data) as necessary; return the remaining bytes object for reassignment and further processing. The string should begin with `MTrk`, specifying a Midi Track Stores the read data (after the header and length information) in self.data Calls `processDataToEvents` which creates :class:`~music21.midi.DeltaTime` and :class:`~music21.midi.MidiEvent` objects and stores them in self.events ''' if not midiBytes[:4] == self.headerId: raise MidiException('badly formed midi string: missing leading MTrk') # get the 4 chars after the MTrk encoding length, midiBytes = getNumber(midiBytes[4:], 4) # environLocal.printDebug(['MidiTrack.read(): got chunk size', length]) # all event data is in the track str trackData = midiBytes[:length] self.data = trackData remainder = midiBytes[length:] self.processDataToEvents(trackData) return remainder # remainder string after extracting track data
[docs] def processDataToEvents(self, trackData: bytes = b'') -> None: ''' Populate .events with trackData. Called by .read() ''' time: int = 0 # a running counter of ticks previousMidiEvent: MidiEvent|None = None dt: int trackDataCandidate: bytes timeCandidate: int while trackData: # shave off the time stamp from the event delta_t = DeltaTime(track=self) # return extracted time, as well as remaining bytes dt, trackDataCandidate = delta_t.readUntilLowByte(trackData) # this is the offset that this event happens at, in ticks timeCandidate = time + dt # pass self to event, set this MidiTrack as the track for this event midiEvent = MidiEvent(track=self) if previousMidiEvent is not None: # synchronize the last status byte midiEvent.lastStatusByte = previousMidiEvent.lastStatusByte # some midi events may raise errors; simply skip for now try: trackData = midiEvent.read(trackDataCandidate) time = timeCandidate except MidiException: # assume that trackData, after delta extraction, is still correct # environLocal.printDebug(['forced to skip event; delta_t:', delta_t]) # set to result after taking delta time trackData = trackDataCandidate continue # only append if we get this far self.events.append(delta_t) self.events.append(midiEvent) previousMidiEvent = midiEvent
[docs] def getBytes(self) -> bytes: r''' Returns bytes of midi-data from the `.events` in the object. For conversion from music21 to MIDI. For example: two events, one note-on and one note-off on C4 which appears as "<" in the data. >>> mt = midi.MidiTrack(index=2) >>> dt1 = midi.DeltaTime(mt, time=0) >>> noteOn = midi.MidiEvent(mt, type=midi.ChannelVoiceMessages.NOTE_ON, channel=3) >>> noteOn.pitch = 60 >>> noteOn.velocity = 20 >>> dt2 = midi.DeltaTime(mt, time=1030) >>> noteOff = midi.MidiEvent(mt, type=midi.ChannelVoiceMessages.NOTE_OFF, channel=3) >>> noteOff.pitch = 60 >>> noteOff.velocity = 0 >>> mt.events = [dt1, noteOn, dt2, noteOff] >>> bytes = mt.getBytes() >>> bytes b'MTrk\x00\x00\x00\t\x00\x92<\x14\x88\x06\x82<\x00' Explanation: The first four bytes `MTrk` are the header for any MIDI Track >>> bytes[:4] b'MTrk' The next four bytes are the length of the data in bytes, the final `b'\t'` indicates there are 9 bytes to follow. >>> midi.base.getNumber(bytes[4:8], 4) (9, b'') The next byte is an empty delta time event. >>> bytes[8] 0 The next three bytes are the note-on event. >>> noteOn2 = midi.MidiEvent() >>> _ = noteOn2.parseChannelVoiceMessage(bytes[9:12]) >>> noteOn2 <music21.midi.MidiEvent NOTE_ON, track=None, channel=3, pitch=60, velocity=20> Followed by two bytes for the DeltaTime of 1030 ticks. >>> dt3 = midi.DeltaTime() >>> dt3.readUntilLowByte(bytes[12:14]) (1030, b'') The 1030 is stored as b'\x88\x06' where the first byte is 136 and the second 6, but MIDI stores each continuing digit as the last 7 bits of a 8-bit number with first bit 1, so we remove the first bit by subtracting 128: 136 - 128 = 8. Then we multiply 8 by 128 to get 1024 and add 6 to get 1030. Finally, the last three bytes are the note-off event. >>> noteOff2 = midi.MidiEvent() >>> _ = noteOff2.parseChannelVoiceMessage(bytes[14:]) >>> noteOff2 <music21.midi.MidiEvent NOTE_OFF, track=None, channel=3, pitch=60, velocity=0> ''' # set time to the first event # time = self.events[0].time # build str using MidiEvents midiBytes: list[bytes] = [] for midiEvent in self.events: # this writes both delta time and message events try: midiBytes.append(midiEvent.getBytes()) except MidiException as ex: environLocal.warn(f'Conversion error for {midiEvent}: {ex}; ignored.') bytes_out = b''.join(midiBytes) return self.headerId + putNumber(len(bytes_out), 4) + bytes_out
def _reprInternal(self) -> str: r = f'{self.index} -- {len(self.events)} events' return r # --------------------------------------------------------------------------
[docs] def updateEvents(self) -> None: ''' We may attach events to this track before setting their `track` parameter. This method will move through all events and set their track to this track. >>> mt = midi.MidiTrack(index=2) >>> noteOn = midi.MidiEvent(type=midi.ChannelVoiceMessages.NOTE_ON, channel=1) >>> noteOn.pitch = 60 >>> noteOn.velocity = 20 >>> noteOn <music21.midi.MidiEvent NOTE_ON, track=None, channel=1, pitch=60, velocity=20> >>> mt.events = [noteOn] >>> mt.updateEvents() >>> noteOn <music21.midi.MidiEvent NOTE_ON, track=2, channel=1, pitch=60, velocity=20> >>> noteOn.track is mt True ''' for e in self.events: e.track = self
[docs] def hasNotes(self) -> bool: ''' Return True/False if this track has any note-ons defined. >>> mt = midi.MidiTrack(index=2) >>> mt.hasNotes() False >>> noteOn = midi.MidiEvent(type=midi.ChannelVoiceMessages.NOTE_ON, channel=1) >>> mt.events = [noteOn] >>> mt.hasNotes() True ''' for e in self.events: if e.isNoteOn(): return True return False
[docs] def setChannel(self, value: int) -> None: ''' Set the one-indexed channel of all events in this Track. >>> mt = midi.MidiTrack(index=2) >>> noteOn = midi.MidiEvent(type=midi.ChannelVoiceMessages.NOTE_ON, channel=1) >>> mt.events = [noteOn] >>> mt.setChannel(11) >>> noteOn.channel 11 Channel must be a value from 1-16 >>> mt.setChannel(22) Traceback (most recent call last): music21.midi.base.MidiException: bad channel value: 22 ''' if value not in range(1, 17): # count from 1 raise MidiException(f'bad channel value: {value}') for e in self.events: e.channel = value
[docs] def getChannels(self) -> list[int]: ''' Get all channels used in this Track (sorted) excepting events like DeltaTime or end events where the channel does not matter >>> mt = midi.MidiTrack(index=2) >>> noteOn = midi.MidiEvent(type=midi.ChannelVoiceMessages.NOTE_ON, channel=14) >>> noteOn2 = midi.MidiEvent(type=midi.ChannelVoiceMessages.NOTE_ON, channel=5) >>> dt = midi.DeltaTime(1000, channel=2) # channel does not matter >>> noteOn3 = midi.MidiEvent(type=midi.ChannelVoiceMessages.NOTE_ON, channel=14) >>> noteOn4 = midi.MidiEvent(type=midi.ChannelVoiceMessages.PROGRAM_CHANGE, channel=5) >>> mt.events = [noteOn, noteOn2, dt, noteOn3, noteOn4] >>> mt.getChannels() [5, 14] ''' post: set[int] = set() for e in self.events: if e.isChannelEvent(): post.add(e.channel) return sorted(post)
[docs] def getProgramChanges(self) -> list[int]: ''' Get all unique program changes used in this Track, in order they appear. >>> mt = midi.MidiTrack(index=2) >>> pc1 = midi.MidiEvent(type=midi.ChannelVoiceMessages.PROGRAM_CHANGE) >>> pc1.data = 14 >>> noteOn = midi.MidiEvent(type=midi.ChannelVoiceMessages.NOTE_ON, channel=14) >>> pc2 = midi.MidiEvent(type=midi.ChannelVoiceMessages.PROGRAM_CHANGE) >>> pc2.data = 1 >>> mt.events = [pc1, noteOn, pc2] >>> mt.getProgramChanges() [14, 1] ''' post: list[int] = [] for e in self.events: if (e.type == ChannelVoiceMessages.PROGRAM_CHANGE and isinstance(e.data, int) and e.data not in post): # O(127) = O(1), faster than a set lookup + append post.append(e.data) return post
[docs] class MidiFile(prebase.ProtoM21Object): ''' Low-level MIDI file writing, emulating methods from normal Python files. For most users, do not go here, simply use: score = converter.parse('path/to/file/in.mid') midi_out = score.write('midi', fp='path/to/file/out.mid') The `ticksPerQuarterNote` attribute must be set before writing. 1024 is a common value. This object is returned by some properties for directly writing files of midi representations. >>> mf = midi.MidiFile() >>> mf <music21.midi.MidiFile 0 tracks> Music21 can read format 0 and format 1, and writes format 1. Format 2 files are not parsable. >>> mf.format 1 After loading or before writing, tracks are stored in this list. >>> mf.tracks [] Most midi files store `ticksPerQuarterNote` and not `ticksPerSecond` >>> mf.ticksPerQuarterNote 10080 >>> mf.ticksPerSecond is None True All MidiFiles have the same `headerId` >>> midi.MidiFile.headerId b'MThd' ''' headerId = b'MThd' def __init__(self) -> None: self.file: t.BinaryIO|None = None self.format: int = 1 self.tracks: list[MidiTrack] = [] self.ticksPerQuarterNote: int = defaults.ticksPerQuarter self.ticksPerSecond: int|None = None
[docs] def open(self, filename: str|pathlib.Path, attrib: str = 'rb') -> None: ''' Open a MIDI file path for reading or writing. For writing to a MIDI file, `attrib` should be "wb". ''' if attrib not in ['rb', 'wb']: raise MidiException('cannot read or write unless in binary mode, not:', attrib) # pylint: disable-next=consider-using-with, unspecified-encoding self.file = t.cast(t.BinaryIO, open(filename, attrib))
[docs] def openFileLike(self, fileLike: t.BinaryIO) -> None: ''' Assign a file-like object, such as those provided by BytesIO, as an open file object. >>> from io import BytesIO >>> fileLikeOpen = BytesIO() >>> mf = midi.MidiFile() >>> mf.openFileLike(fileLikeOpen) >>> mf.close() ''' self.file = fileLike
def _reprInternal(self) -> str: lenTracks = len(self.tracks) plural = 's' if lenTracks != 1 else '' return f'{lenTracks} track{plural}'
[docs] def close(self) -> None: ''' Close the file. ''' if not self.file: # nothing to do return self.file.close()
[docs] def read(self) -> None: ''' Read and parse MIDI data stored in a file. ''' if not self.file: raise TypeError('No file is open.') self.readstr(self.file.read())
[docs] def readstr(self, midiBytes: bytes) -> None: ''' Read and parse MIDI data as a bytes, putting the data in `.ticksPerQuarterNote` and a list of `MidiTrack` objects in the attribute `.tracks`. The name readstr is a carryover from Python 2. It works on bytes objects, not strings ''' if not midiBytes[:4] == b'MThd': raise MidiException(f'badly formatted midi bytes, got: {midiBytes[:20]!r}') # we step through the str src, chopping off characters as we go # and reassigning to str length, midiBytes = getNumber(midiBytes[4:], 4) if length != 6: raise MidiException('badly formatted midi bytes') midiFormatType, midiBytes = getNumber(midiBytes, 2) self.format = midiFormatType if midiFormatType not in (0, 1): raise MidiException(f'cannot handle midi file format: {format}') numTracks, midiBytes = getNumber(midiBytes, 2) division, midiBytes = getNumber(midiBytes, 2) # very few midi files seem to define ticksPerSecond if division & 0x8000: framesPerSecond = -((division >> 8) | -0x80) ticksPerFrame = division & 0xFF if ticksPerFrame not in [24, 25, 29, 30]: raise MidiException(f'cannot handle ticks per frame: {ticksPerFrame}') if ticksPerFrame == 29: ticksPerFrame = 30 # drop frame self.ticksPerSecond = ticksPerFrame * framesPerSecond else: self.ticksPerQuarterNote = division & 0x7FFF # environLocal.printDebug(['MidiFile.readstr(): got midi file format:', self.format, # 'with specified number of tracks:', numTracks, 'ticksPerSecond:', self.ticksPerSecond, # 'ticksPerQuarterNote:', self.ticksPerQuarterNote]) for i in range(numTracks): trk = MidiTrack(i) # sets the MidiTrack index parameters midiBytes = trk.read(midiBytes) # pass all the remaining bytes, reassigning self.tracks.append(trk)
[docs] def write(self) -> None: ''' Write MIDI data as a file to the file opened with `.open()`. ''' if not self.file: raise TypeError('No file is open.') ws = self.writestr() self.file.write(ws)
[docs] def writestr(self) -> bytes: ''' Generate the MIDI data header and convert the list of MidiTrack objects in self.tracks into MIDI data and return it as bytes. The name `writestr` is a carry-over from Python 2. It works on bytes, not strings. ''' midiBytes = self.writeMThdStr() for trk in self.tracks: midiBytes = midiBytes + trk.getBytes() return midiBytes
[docs] def writeMThdStr(self) -> bytes: ''' Convert the information in self.ticksPerQuarterNote into MIDI data header and return it as bytes. The name `writeMThdStr` is a carry-over from Python 2. It works on bytes, not strings. ''' division = self.ticksPerQuarterNote # Don't handle ticksPerSecond yet, too confusing if (division & 0x8000) != 0: raise MidiException( 'Cannot write midi bytes unless self.ticksPerQuarterNote is a multiple of 1024') midiBytes = self.headerId + putNumber(6, 4) + putNumber(self.format, 2) midiBytes = midiBytes + putNumber(len(self.tracks), 2) midiBytes = midiBytes + putNumber(division, 2) return midiBytes
# ------------------------------------------------------------------------------ # define presented order in documentation _DOC_ORDER: list[type] = [MidiEvent, DeltaTime, MidiTrack, MidiFile] if __name__ == '__main__': import music21 music21.mainTest()