Home

Awesome

umidiparser - low footprint MIDI file parser for Micropython, CircuitPython and Python

DESCRIPTION

This module reads MIDI files (SMF files) and gets all the MIDI events contained in the file. It also can return each event at the corresponding time.

Example:

    import umidiparser
    import utime
    for event in umidiparser.MidiFile("example.mid").play():
        # .play will sleep, avoiding time drift, before returning the event on time
        # Process the event according to type
        if event.status == umidiparser.NOTE_ON:
            ... start the note with midi number event.note 
		on channel event.channel with event.velocity
        elif event.status == umidiparser.NOTE_OFF :
            ... stop the note event.note .
        elif event.status == umidiparser.PROGRAM_CHANGE:
            ... change midi program to event.program on event.channel ....
        else:
           # Show all events not processed
           print("other event", event )

This module does not contain a sound synthesizer, only the capabilities to read and interpret a MIDI file.

Memory and CPU usage are optimized for a microcontroller with limited resources with Micropython or CircuitPython. On CPython this module is very fast.

CPU and memory usage can be lowered even more by reusing the same MidiEvent over and over during the process:

    for event in MidiFile("example.mid", reuse_event_object=True ):
        ... process event....

Normally, only a small portion of the file will be buffered in memory. The default is 100 bytes per track. This allows to process a files much larger than the available heap or RAM. See "CPU AND MEMORY" below.

If there is enough RAM available to buffer the complete file, you can use for event in MidiFile("example.mid", buffer_size=0). This will load the complete file in RAM and uses a bit less of CPU for the parsing of each event.

This module will calculate the time between events, using the parameters and events available in the MIDI file (pulses per beat field in the file header and set tempo events).

INSTALLATION

CPython (regular Python): pip install umidiparser

Micropython: Install directly to your microcontroller with mpremote mip install github:bixb922/umidiparser

CircuitPython: get umidiparser.py and copy it to the /lib folder of the microcontroller.

Important notice for CircuitPython

On many boards, the @micropython.native decorator is disabled, and you will get the following error message: SyntaxError: invalid micropython decorator. If that is the case, please edit your copy of umidiparser.py and comment or delete all @micropython.native statements. This decorator speeds up execution, but there is no functional difference without this decorator. umidiparser is quite fast without the decorator.

Important notice for MicroPython

If you use mpremote mount and the MIDI files are accessed on the PC, you will get the following error message: AttributeError: 'RemoteFile' object has no attribute 'tell'. You have two options:

MIDI FILE COMPATIBILITY

The parser will parse MIDI files, also known as SMF files, with format type 0, 1 and 2. Running status events are recognized. All standard MIDI channel events (note on, note off, program change, control change, etc) and meta and sysex events (track name, key signature, time signature, etc) recognized. This module is based on the MIDI file standard version 1.1

For multitrack file type 1 files, tracks are automatically merged during playback.

MIDI file format errors such as running status event at the beginning of a track or incorrect file header fields are detected insofar they could lead to wrong results. Non standard data chunks are ignored. Missing end of track events are inserted on the fly. Running status events may have a meta or sysex event in between, as this is non standard but common in MIDI files. Non standard or custom meta messages are recognized. Values of fields out of range, such as note values between 128 and 255 are passed to the calling program.

There are no capabilities to modify or create MIDI files.

CPU AND MEMORY (RAM) USAGE

Parsing on a ESP32 at 200 Mhz CPUclock with Micropython takes about one millisecond per event, and even less if you use the reuse_event_object of the MidiFile object. Times with CircuitPython on a RP2040 are similar.

With the reuse_event_object parameter set to true, CPU overhead should be about 15% lower, and less heap is used.

On the ESP32 with MicroPython, parsing a MIDI file with buffer_size=100 and reuse_event_object=True uses about 20kbytes of heap. Heap usage on a RP2040 (Raspberry Pi Pico) is similar. Each MIDI event parsed will allocate and free less than 100 bytes of heap, so garbage collections are less frequent if little RAM is available. Most of the processing such as parsing event data into individual fields is delayed until the information is really needed or asked for.

Frequently used methods have the @micropython.native decorator for speed.

PORTABILITY

The code is optimized for Micropython or CircuitPython. It also works with same functionality under regular Python (CPython). This allows to develop or test on Linux, Mac or Windows and then port to Micropython.

CLASSES

The starting point is the MidiFile object, allowing to iterate through the events. Events are returned as MidiEvent objects, and the fields of the event are visible as attributes of the MidiEvent.

DEPENDENCIES

Imports time to compute time differences. MidiFile.play() will use the time.sleep or time.sleep_us function.

Imports asyncio for async version of MidiFile.play()

Imports sys to get sys.name.implementation.

Class MidiFile

mf = MidiFile( filename, buffer_size=100, reuse_event_object=false)`

Parses a MIDI file, returning an iterator over the MIDI events in the file. You then can iterate through the events of the file, see the iterator and the play method.

It is also possible to get the tracks using the mf.track[] list and iterate through the events of one track only, useful for format type 2 files.

MidiFile constructor parameters

filename

The name of a MIDI file, usually a .mid, .kar or .rtx MIDI file. The MIDI file will always be opened read only.

buffer_size=100

The buffer size that will be allocated for each track. a value of zero means copy all tracks to RAM during processing.The default value is 100 bytes.

In order to process files larger than the available RAM, buffer_size=n will allocate n bytes of buffer for each track, and read each tracks in n byte portions during the processing of the file, i.e. while iterating through the events. One file descriptor (one open file) will be used for each track, but the total RAM needed will be about buffer_size multiplied by the number of tracks of the MIDI file.

MIDI file format 0 files have only one track, so only one buffer will be allocated.

A buffer size of less than 10 bytes will increase CPU overhead. A buffer size much larger than 100 will probably not give a relevant performance advantage, unless the device where the file resides is very slow.

If *buffer_size=0^, all tracks will be read to memory. This will need as much RAM as the file size of a complete MIDI file. In this case, the time needed to process each event will not depend on file access and will be both faster and more dependable than using a buffer_size different to 0, if the device where the file resides is slow.

The default value for buffer_size is 100 bytes.

With the default value of False, for each step of the iteration a new MidiEvent is returned. If you do:

    for event in MidiFile("example.mid"):
        ... process each event ...

then each iteration of the loop will yield a new MidiEvent object. This is the normal and expected behavior for Python iterators.

However, if you if you have little RAM or need higher processing speed use:

    for event in MidiFile("example.mid", reuse_event_object=True):
         ... process each event ....

In this case, for each iteration of the loop, the same MidiEvent object is returned over over and over for each step of the loop, but overwritten with the new data. However, if you want to store an event for later use, you'll have to make a deep copy using event.copy.

All combinations of reuse_event_object and buffer_size are valid.

Exceptions raised:

The file must start with a standard MIDI file header (MThd), if not, a ValueError is raised. The MIDI header chunk must be at least 6 bytes long, or a ValueError is raised. ValueError is raised if no header present, or too short. ValueError is raised if the header contains SMPTE time codes (not supported). Chunks after the header that are not tracks identified with the MTrk header are ignored.

MidiFile Methods

Iterating through the events of a MIDI file

To get all the events of a format type 0 or format type 1 MIDI file, iterate through the MidiFile object, for example:

    import umidiparser
    for event in MidiFile("example.mid"):
        print(event)

Events will be returned in ascending order in time. For format type 1 multitrack files, all tracks are merged.

The event.delta_us property is calculated as the time in microseconds between last and this event, taking into account both the set tempo meta events and the "MIDI ticks per quarter note" (also known as "pulses per beat") parameter in the MIDI file header. For type 1 files, all set tempo meta events may be in track 0 (as it is usually done), or they may occur in any track.

For a multitrack file, *event.delta_us^ is the time difference with the previous event, which may be in the same or a different track.

To get each event at the proper time of the event, use the MidiFile.play method. This method compensates for the delays during processing.

In all cases, only one end of track meta event is returned to the caller when the end of file is reached. Events beyond an end of track event are ignored.

Exceptions:

A RuntimeError is raised if format type 2 is processed with this method.

play

Iterate through the events of a MIDI file or a track, sleep until the event has to take place, and yield the event.

Example:

    for event in MidiFile("example.mid").play():
         .... process the event ...

The play function will wait the necessary time between iterations so that each event is yielded on time to be processed.

This function compensates for the accumulated error in the processing of each event. Since sleep functions will sleep AT LEAST the time specified, normally the time slept will be longer. This means, for a long file of several thousand events, events may get ever later. This is noticeable especially if the processing of each event takes significant time. Even so, since play uses the sleep_us function, sometimes you may get the event a bit later than the correct time.

For Micropython, time.sleep_us() is used. For CircuitPython and CPython time.sleep() is used.

There is also a asyncio version of MidiFile.play() available:

    async for event in MidiFile("example.mid").play():
        ... process the event ...

This will work exactly like the normal for, except it will pause for the next event with await, yielding control back to the asyncio loop.

length_us

Computes and returns the length of the MidiFile in microseconds.

Be aware that on a microcontroller, calculating the length of a large MIDI file might take a several seconds. This is due to the way MIDI files work. In order to get the playing length, this method needs to parse the entire file, compute and add the time differences of all events.

Exceptions:

RuntimeError for format type 2 files. It is not possible to calculate the playing time of a format 2 file, since for format 2 files, the tracks are not meant to be merged.

MidiFile properties

The properties cannot be changed, they are set with the MidiFile constructor.

buffer_size

Returns the buffer_size value.

filename

Returns the file name of the MIDI file.

format_type

Returns the MIDI format type as stored in the header of the MIDI file:

    for event in MidiFile("example.mid"):
        ...process each event
    for event in MidiFile("format2file.mid").tracks[n]:
         .... process event...

Or also:

    For event in MidiFile("format2file.mid").tracks[n].play():
         .... process event...

In fact, you could use the same methods on type 0 and type 1 files to access each track separately.

miditicks_per_quarter

Return the MIDI ticks per quarter note (also called pulses per beat) field of the MIDI file header.

reuse_event_object

Return the value of the reuse_event_object property.

MidiFile instance variables

tracks

List of MidiTrack objects, one for each track.

Class MidiTrack

The MidiFile object exposes the list of tracks.

Methods

Class MidiEvent

Represents a parsed MIDI event. You get MidiEvent objects iterating through a MidiFile.

Properties available for all events

status

This is the event type, such as note on, note off, set tempo, end of track, sysex. You can compare event.status with the constants umidiparser.NOTE_ON, umidiparser.NOTE_OFF, umidiparser.PROGRAM_CHANGE, umidiparser.LYRICS, umidiparser.SYSEX, etc.

Available for all events types.

Example:

    if event.status == umidiparser.NOTE_OFF:
        ... process note off event ...
    elif event.status == umidiparser.KEY_SIGNATURE:
        ... process key signature meta event ...

Possible values are constants in umidiparser:

delta_us

Time in microseconds since the last event for this event to start. delta_us is calculated using the delta in MIDI ticks, the pulses per quarter in the MIDI file header and the set tempo events in the file, using integer arithmetic to avoid floating point use.

delta_miditicks

Difference, in MIDI ticks (MIDI pulses) between last event and this event. This number is also known as "delta time" of the event. For single track files, the time difference is with the previous event of the same track. When parsing multitrack files, tracks are merged and this time is set during playback to the time difference with the previous event, which may or may not be in the same track.

data

Contains the raw event data.

Event specific properties

For each type of event, only the applicable properties are enabled. The following list shows the event status and properties that you can get.

For example: for a note_on event, event.status is umidiparser.NOTE_ON and the properties event.note, event.channel, and event.velocity return the values of the event.

Attributes that are not available throw an AttributeError on access.

MIDI channel event specific properties

Meta and Sysex event specific properties

Text strings are decoded as ASCII. Non ASCII characters are shown in hexa i.e. \x80.

MidiEvent methods

copy()

Returns a deep copy (a complete independent copy) of the event as a new object.

This can be useful to get a copy of the event in case of using the reuse_event_object=True option in the MidiFile.

Example 1: if you need a list of all MidiEvents in a file, then either use:

    event_list = [event.copy for event in MidiFile("example.mid",
        reuse_event_object=True) ]

or use:

    event_list = [ event for event in MidiFile("example.mid") ]

is_channel()

Returns True if this is a channel event such as note on, program change or pitchwheel.

Returns False for meta events, Sysex or Escape events.

is_meta()

Returns True if this is a Meta event, such as lyrics, set tempo or key signature.

Returns False if this is a MIDI channel event, or a Sysex or Escape event.

str(event)

Will translate the event information to a string, for example:

    print(event)    
    event_as_a_string = str(event)

Returns a string with a description of the MidiEvent, starting with the event name in lowercase (note_on, note_off, pitchwheel, set_tempo, end_of_track, etc), delta time in MIDI ticks, delta time in microseconds, first few bytes of the raw data for meta events, and all properties that are allowed for the event type. For example, a "note on" event might be shown as:

"note_on delta[miditicks]=10 delta[usec]=9500 note=60 velocity=64 channel=5"

The order of the properties in the string may vary.

MidiEvent properties

channel

Returns the channel number for the event, 0-15.

channel property is available for: NOTE_OFF NOTE_ON POLYTOUCH CONTROL_CHANGE PROGRAM_CHANGE AFTERTOUCH CHANNEL_PREFIX

clocks_per_click

Returns the clocks_per_click for the TIME_SIGNATURE meta messages, 0-255.

control

Returns the value for the controller 0-127 for a CONTROL_CHANGE event.

data

Returns the raw data for the underlying message, with no transofrmations, as a memoryview, without the event status byte or meta prefix.

For midi channel events, the length is either 1 or 2 bytes according to the event type, for example a "note on" event always has 2 bytes of data. For a meta or sysex event, "data" contains the payload of the message, that is, without meta prefix and length. For sysex and escape events, the status (0xf0, xf7) is not included.

The main purpose is to retrieve message data for sysex and escape events. Also, you can decode text and name fields, such as lyrics, with a encoding better suited for the file, if available.

denominator

Returns the denominator for the TIME_SIGNATURE meta messages, 0-255.

frame_rate

Returns the frame for the SMPTE_OFFSET meta messages, which can be 24, 25, 29.97 or 30.

An invalid value in the MIDI file will raise a IndexError

frames

Returns the frames for the SMPTE_OFFSET meta message, usually from 0 to 255.

hours

Returns the hour for the SMPTE_OFFSET meta message, usually from 0 to 23.

key

Returns the key, as string, for a KEY_SIGNATURE meta event.

For mayor keys: C, D, E, F, G, A, B, C#, F#, Cb, Db, Eb, Gb, Ab

For minor keys: Cm, Dm, Em, Fm, Gm, Am, Bm, C#m, F#m, Cbm, Dbm, Ebm, Gbm, Abm

If the midi message contains a value out of range, a ValueError is raised. The raw data can be accessed with the data property.

minutes

Returns the minutes for the SMPTE_OFFSET meta message, usually from 0 to 59.

name

Returns the name data field for certain meta events.

name property is available for: TRACK_NAME INSTRUMENT_NAME PROGRAM_NAME DEVICE_NAME

See text property for description of text decoding.

The raw data can be retrieved using the data property.

notated_32nd_notes_per_beat

Returns the notated_32nd_notes_per_beat for the TIME_SIGNATURE meta messages, 0-255.

note

Returns the note number for the event, usually 0-127.

note property available for: NOTE_OFF NOTE_ON POLYTOUCH

number

Returns number of a SEQUENCE_NUMBER meta event. Values range from 0 to 2**24.

numerator

Returns the numerator for the TIME_SIGNATURE meta messages, 0-255.

pitch

Returns the pitch for a PITCHWHEEL midi channel event.

-8192 is the lowest value possible, 0 (zero) means "no pitch bend" and 8191 is the highest value possible.

port

Returns the port number 0-256 for a meta MIDI_PORT message

program

Returns the program number 0-127 for a PROGRAM_CHANGE event.

seconds

Returns the seconds for the SMPTE_OFFSET meta message, usually from 0 to 59.

sub_frames

Returns the sub frames for the SMPTE_OFFSET meta message, usually from 0 to 59.

tempo

Returns the tempo in microseconds per quarter beat for a SET_TEMPO meta event. This module interprets the tempo event before returning it, so the following events returned will have their delta_us property calculated with the new tempo value.

text

Returns the text for a meta events.

text property is available for: TEXT COPYRIGHT LYRICS MARKER CUE_MARKER

Both event.text and event.name decode the data with ASCII. Unmapped characters are shown in hexadecimal, for example \x0d

There are many MIDI files where text and name fields are encoded with other encodings. The encoding used is not stored in the file.

to_midi

Returns the event as bytes, in a format that allows sending the data to a MIDI controller.

to_midi will raise AttributeError if the event is for MIDI meta messages, these occur in MIDI files and are not normally sent to MIDI controllers.

value

Returns the the value in the event.

value property available for: AFTERTOUCH, CONTROL_CHANGE, POLYTOUCH

velocity

Returns the velocity for the event, usually 0-127.

velocity property available for: NOTE_OFF NOTE_ON

MIDI event values as text: gmfunctions.py

This is a file with functions to convert Midi note numbers to note names in english notation (C, Db, D), doremi notation and german notation (C, Des, D). There are translations of program numbers, control changes and values according to the General Midi (GM) standard. There is also a function to convert midi note numbers to frequencies.

Get file gmfunctions.py, this is not part of the umidiparser package. The file is rather large (7kb), you might want to edit and prune on your PC before loading in your microcontroller. Also, you might want to change the names of notes with accidentals (sharp vs. flat) in the code on your PC.

Change log for version 1.1 and 1.2

Added CircuitPython compatibility Removed use of os.path.abspath, does not exist in micropython/circuitpython Renamed time functions "ticks_now_us", "ticks_diff_us" to "time_now_us", "time_diff_us" Changed decode from meta event data for compatibility with all Python versions, now only ascii is decoded. New method MidiTrack.play plays one single track. Corrected possible error if playing open file again. Added event.is_channel() to test for channel events. Allow MidiFile.play() used in async for (with asyncio.sleep instead of sleep). Requires asyncio. Play funcion computes event.timestamp_us for each event

Change log for version 1.3

Added support for installing with mpremote mip (Micropython). Corrected package name in README.md. Added gmfunctions.py in github. Fixed asyncio bug and typo for CircuitPython.

After version 1.3

Updated readme with restrictions. Improved code formatting.

AUTHOR

Hermann Paul von Borries

LICENSE

MIT, Copyright (c) 2022 Hermann Paul von Borries