# SPDX-FileCopyrightText: Copyright (c) 2025 Liz Clark for Adafruit Industries # # SPDX-License-Identifier: MIT """ `adafruit_midi_parser` ================================================================================ CircuitPython helper for parsing MIDI files This library provides classes for parsing and playing Standard MIDI Files (SMF) in CircuitPython. It supports Format 0 (single track) and Format 1 (multiple tracks) MIDI files, and provides a flexible playback system that can be extended for custom MIDI applications. * Author(s): Liz Clark Implementation Notes -------------------- **Software and Dependencies:** * Adafruit CircuitPython firmware for the supported boards: https://circuitpython.org/downloads """ try: from typing import Any, BinaryIO, Dict, List, Optional, Tuple, Union except ImportError: pass import time __version__ = "0.0.0+auto.0" __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_MIDI_Parser.git" class MIDIParseError(Exception): """Exception raised when there's an error parsing a MIDI file.""" pass class MIDIParser: """ Class for parsing and playing Standard MIDI files. Supports Format 0 (single track) and Format 1 (multiple tracks) MIDI files. :param str filename: Path to the MIDI file """ def __init__(self) -> None: """ Initialize the MIDI parser. :param str filename: Path to the MIDI file """ self._filename: Optional[str] = None self._events: List[Dict[str, Any]] = [] self._tempo: int = 500000 # Default tempo (microseconds per quarter note) self._ticks_per_beat: int = 480 # Default time division self._current_event_index: int = 0 self._format_type: int = 0 self._num_tracks: int = 0 self._parsed: bool = False self._last_absolute_time: int = 0 @property def filename(self) -> str: """The filename of the MIDI file being parsed.""" return self._filename @property def events(self) -> List[Dict[str, Any]]: """List of all MIDI events in the file, sorted by absolute time.""" return self._events @property def tempo(self) -> int: """ Current tempo in microseconds per quarter note. Default is 500,000 (120 BPM). """ return self._tempo @property def ticks_per_beat(self) -> int: """ Number of ticks per quarter note (time division) from the MIDI file. """ return self._ticks_per_beat @property def format_type(self) -> int: """ MIDI file format type (0, 1, or 2). 0 = single track, 1 = multiple tracks, 2 = multiple songs """ return self._format_type @property def num_tracks(self) -> int: """Number of tracks in the MIDI file.""" return self._num_tracks @property def parsed(self) -> bool: """Whether the MIDI file has been successfully parsed.""" return self._parsed @property def current_event_index(self) -> int: """Index of the next event to be played.""" return self._current_event_index @property def current_event(self) -> Optional[Dict[str, Any]]: """ The current event to be played, or None if at the end. Does not advance the event index. :return: A dictionary containing event data or None if at the end of events :rtype: Optional[Dict[str, Any]] """ if self._current_event_index < len(self._events): return self._events[self._current_event_index] return None @property def next_event(self) -> Optional[Dict[str, Any]]: """ The next event to be played, or None if at the end. Advances the event index. :return: A dictionary containing event data or None if at the end of events :rtype: Optional[Dict[str, Any]] """ event = self.current_event if event: self._current_event_index += 1 return event @property def more_events(self) -> bool: """Whether there are more events to be played.""" return self._current_event_index < len(self._events) @property def bpm(self) -> float: """ Current tempo in beats per minute (BPM). :return: The current BPM calculated from the tempo :rtype: float """ return 60000000 / self._tempo @property def note_count(self) -> int: """ Number of note-on events in the MIDI file. :return: Count of note-on events :rtype: int """ return sum(1 for e in self._events if e["type"] == "note_on") @staticmethod def _read_variable_length(data: bytes, offset: int) -> Tuple[int, int]: """ Read a variable length quantity from the data starting at offset. :param bytes data: MIDI file data :param int offset: Current offset into the data :return: A tuple containing (value, new_offset) :rtype: Tuple[int, int] :raises MIDIParseError: If the variable length value is invalid or extends beyond the data """ value = 0 byte_count = 0 while byte_count < 4: # Prevent infinite loop if offset >= len(data): raise MIDIParseError( f"Variable length value extends beyond data at offset {offset}" ) byte = data[offset] offset += 1 value = (value << 7) | (byte & 0x7F) byte_count += 1 if not (byte & 0x80): break return value, offset def clear(self) -> None: """ Clear all parsed data and reset the parser state. """ self._filename = None self._events = [] self._tempo = 500000 # Default tempo self._ticks_per_beat = 480 # Default division self._current_event_index = 0 self._format_type = 0 self._num_tracks = 0 self._parsed = False self._last_absolute_time = 0 def parse(self, filename: str, debug: bool = False) -> bool: # noqa: PLR0912 PLR0915 PLR0914 """ Parse the MIDI file and extract events. :param bool debug: Whether to print detailed parsing information :return: True if parsing was successful :rtype: bool :raises MIDIParseError: If the file doesn't exist or is not a valid MIDI file """ self.clear() self._filename = filename try: # noqa: PLR1702 with open(self._filename, "rb") as file: data = file.read() if len(data) < 14: raise MIDIParseError("File too short to be a valid MIDI file") if data[0:4] != b"MThd": raise MIDIParseError("Not a valid MIDI file - missing MThd header") # header_length = (data[4] << 24) | (data[5] << 16) | (data[6] << 8) | data[7] self._format_type = (data[8] << 8) | data[9] self._num_tracks = (data[10] << 8) | data[11] self._ticks_per_beat = (data[12] << 8) | data[13] if debug: print( f"Format: {self._format_type}, Tracks: {self._num_tracks}," + f"Ticks per beat: {self._ticks_per_beat}" ) offset = 14 for track_num in range(self._num_tracks): if debug: print(f"Parsing track {track_num + 1}") if offset + 8 > len(data): raise MIDIParseError( f"Unexpected end of file while reading track {track_num + 1}" ) if data[offset : offset + 4] != b"MTrk": if debug: print(f"Track {track_num + 1} chunk not found at offset {offset}") next_track = data.find(b"MTrk", offset) if next_track >= 0: offset = next_track else: raise MIDIParseError(f"Could not find track {track_num + 1}") track_length = ( (data[offset + 4] << 24) | (data[offset + 5] << 16) | (data[offset + 6] << 8) | data[offset + 7] ) if debug: print(f"Track length: {track_length} bytes") track_start = offset + 8 track_end = track_start + track_length if track_end > len(data): raise MIDIParseError( f"Track {track_num + 1} extends beyond file end: {track_end} > {len(data)}" ) offset = track_start absolute_time = 0 last_status = 0 while offset < track_end: delta_time, offset = self._read_variable_length(data, offset) absolute_time += delta_time if offset >= len(data): raise MIDIParseError(f"Reached end of file unexpectedly at offset {offset}") if data[offset] & 0x80: status = data[offset] offset += 1 last_status = status else: status = last_status if offset >= len(data): raise MIDIParseError( f"Reached end of file after status byte at offset {offset}" ) event_type = status & 0xF0 channel = status & 0x0F if event_type == 0x80: if offset + 1 >= len(data): raise MIDIParseError(f"Incomplete Note Off event at offset {offset}") note = data[offset] velocity = data[offset + 1] offset += 2 self._events.append( { "type": "note_off", "delta": delta_time, "absolute": absolute_time, "track": track_num, "channel": channel, "note": note, "velocity": velocity, } ) if debug: print( f"Note Off: note={note}, velocity={velocity}, time={absolute_time}" ) elif event_type == 0x90: if offset + 1 >= len(data): raise MIDIParseError(f"Incomplete Note On event at offset {offset}") note = data[offset] velocity = data[offset + 1] offset += 2 if velocity == 0: self._events.append( { "type": "note_off", "delta": delta_time, "absolute": absolute_time, "track": track_num, "channel": channel, "note": note, "velocity": 0, } ) if debug: print( "Note Off (via note-on velocity=0):" + f"note={note}, time={absolute_time}" ) else: self._events.append( { "type": "note_on", "delta": delta_time, "absolute": absolute_time, "track": track_num, "channel": channel, "note": note, "velocity": velocity, } ) if debug: print( f"Note On: note={note}," + f"velocity={velocity}, time={absolute_time}" ) elif event_type == 0xB0: if offset + 1 >= len(data): raise MIDIParseError(f"Incomplete Controller event at offset {offset}") controller = data[offset] value = data[offset + 1] offset += 2 self._events.append( { "type": "controller", "delta": delta_time, "absolute": absolute_time, "track": track_num, "channel": channel, "controller": controller, "value": value, } ) elif event_type == 0xC0: if offset >= len(data): raise MIDIParseError( f"Incomplete Program Change event at offset {offset}" ) program = data[offset] offset += 1 self._events.append( { "type": "program_change", "delta": delta_time, "absolute": absolute_time, "track": track_num, "channel": channel, "program": program, } ) elif event_type == 0xE0: if offset + 1 >= len(data): raise MIDIParseError(f"Incomplete Pitch Bend event at offset {offset}") lsb = data[offset] msb = data[offset + 1] offset += 2 value = (msb << 7) | lsb self._events.append( { "type": "pitch_bend", "delta": delta_time, "absolute": absolute_time, "track": track_num, "channel": channel, "value": value, } ) elif status == 0xFF: if offset >= len(data): raise MIDIParseError(f"Incomplete Meta event at offset {offset}") meta_type = data[offset] offset += 1 if offset >= len(data): raise MIDIParseError(f"Incomplete Meta event length at offset {offset}") length, offset = self._read_variable_length(data, offset) if offset + length > len(data): raise MIDIParseError( "Meta event extends beyond file end:" + f"{offset + length} > {len(data)}" ) if meta_type == 0x51 and length == 3: self._tempo = ( (data[offset] << 16) | (data[offset + 1] << 8) | data[offset + 2] ) self._events.append( { "type": "tempo", "delta": delta_time, "absolute": absolute_time, "track": track_num, "tempo": self._tempo, } ) if debug: print(f"Tempo: {self._tempo} microseconds per quarter note") elif meta_type == 0x2F: self._events.append( { "type": "end_of_track", "delta": delta_time, "absolute": absolute_time, "track": track_num, } ) if debug: print(f"End of Track {track_num}") offset += length elif status in {0xF0, 0xF7}: if offset >= len(data): raise MIDIParseError(f"Incomplete SysEx event at offset {offset}") length, offset = self._read_variable_length(data, offset) if offset + length > len(data): raise MIDIParseError( "SysEx event extends beyond file end:" + f"{offset + length} > {len(data)}" ) offset += length else: raise MIDIParseError( f"Unknown event type: {hex(status)} at offset {offset-1}" ) offset = track_end self._events.sort(key=lambda x: x["absolute"]) self._parsed = len(self._events) > 0 if not self._parsed: raise MIDIParseError("No valid MIDI events found in file") except Exception as e: raise MIDIParseError(f"Error parsing MIDI file: {e}") from e def reset(self) -> None: """ Reset playback to the beginning of the file. This resets the current event index and absolute time tracking. """ self._current_event_index = 0 self._last_absolute_time = 0 def calculate_delay(self, event: Dict[str, Any]) -> float: """ Calculate the delay in seconds until the next event. :param dict event: The current event :return: Delay in seconds until the next event :rtype: float """ if self._current_event_index < len(self._events): next_event = self._events[self._current_event_index] time_diff = next_event["absolute"] - event["absolute"] if time_diff > 0: return (time_diff / self._ticks_per_beat) * (self._tempo / 1000000) return 0.1 class MIDIPlayer: """ Class for playing MIDI files with timing control. Subclass this and override the event handler methods to customize behavior. :param MIDIParser midi_parser: A MIDIParser instance with a parsed MIDI file """ def __init__(self, midi_parser: MIDIParser) -> None: """ Initialize the MIDI player. :param MIDIParser midi_parser: A MIDIParser instance :raises MIDIParseError: If the provided parser hasn't parsed a file yet """ if not midi_parser.parsed: raise MIDIParseError("MIDI parser must parse a file before creating a player") self._parser: MIDIParser = midi_parser self._playing: bool = False self._last_event_time: float = 0 self._next_event_delay: float = 0 self._loop_playback: bool = False self._finished: bool = False self._restart_delay: float = 3.0 @property def restart_delay(self) -> float: """ Delay in seconds before restarting playback when looping. Default is 3.0 seconds. """ return self._restart_delay @restart_delay.setter def restart_delay(self, delay: float) -> None: """ The delay in seconds before restarting playback when looping. :param float delay: Delay in seconds (minimum 0.1s) """ self._restart_delay = max(0.1, float(delay)) # Minimum 0.1 second @property def playing(self) -> bool: """Whether the player is currently playing.""" return self._playing @property def parser(self) -> MIDIParser: """The MIDIParser instance used by this player.""" return self._parser @property def loop_playback(self) -> bool: """Whether playback should automatically loop when finished.""" return self._loop_playback @loop_playback.setter def loop_playback(self, value: bool) -> None: """ :param bool value: True to enable looping, False to disable """ self._loop_playback = bool(value) @property def finished(self) -> bool: """Whether playback has completed (and not set to loop).""" return self._finished def stop(self) -> None: """ Stop playback. This halts playback completely. Use reset() on the parser to return to the beginning of the file. """ self._playing = False def pause(self) -> None: """ Pause playback. Temporarily stops playback, which can be resumed from the current position. """ self._playing = False def resume(self) -> None: """ Resume playback from current position. If playback has finished, call reset() on the parser first to restart from the beginning. """ if not self._playing and not self._finished: self._playing = True self._last_event_time = time.monotonic() def on_note_on(self, note: int, velocity: int, channel: int) -> None: """ Called when a note-on event is processed. Override this method in a subclass to handle note-on events. :param int note: MIDI note number (0-127) :param int velocity: Note velocity (0-127) :param int channel: MIDI channel (0-15) """ pass def on_note_off(self, note: int, velocity: int, channel: int) -> None: """ Called when a note-off event is processed. Override this method in a subclass to handle note-off events. :param int note: MIDI note number (0-127) :param int velocity: Note velocity (0-127) :param int channel: MIDI channel (0-15) """ pass def on_tempo_change(self, tempo: int) -> None: """ Called when a tempo change event is processed. Override this method in a subclass to handle tempo changes. :param int tempo: New tempo in microseconds per quarter note """ pass def on_end_of_track(self, track: int) -> None: """ Called when an end-of-track event is processed. Override this method in a subclass to handle end-of-track events. :param int track: Track number, or -1 for end of all tracks """ pass def on_playback_complete(self) -> None: """ Called when playback reaches the end of the file. Override this method in a subclass to handle playback completion. If loop_playback is True, playback will restart automatically after this method returns. """ pass def on_controller(self, controller: int, value: int, channel: int) -> None: """ Called when a controller event is processed. Override this method in a subclass to handle controller events. :param int controller: Controller number :param int value: Controller value :param int channel: MIDI channel (0-15) """ pass def on_program_change(self, program: int, channel: int) -> None: """ Called when a program change event is processed. Override this method in a subclass to handle program change events. :param int program: Program number :param int channel: MIDI channel (0-15) """ pass def play(self, loop: Optional[bool] = None) -> bool: # noqa: PLR0912 """ Play the MIDI file. If already playing, process the next event. Call this method in your main loop to continuously play the MIDI file. :param Optional[bool] loop: Set to True to enable looping, False to disable, or None :return: True if an event was processed, False otherwise :rtype: bool """ if loop is not None: self.loop_playback = bool(loop) if not self._playing: self._parser.reset() self._finished = False self._last_event_time = time.monotonic() self._next_event_delay = 0.05 self._playing = True if not self._parser.parsed: raise MIDIParseError("Cannot play: MIDI parser must parse a file first") current_time = time.monotonic() if current_time - self._last_event_time >= self._next_event_delay: event = self._parser.current_event if event: current_absolute_time = event["absolute"] while ( self._parser.current_event and self._parser.current_event["absolute"] == current_absolute_time ): event = self._parser.next_event if event["type"] == "note_on": self.on_note_on(event["note"], event["velocity"], event["channel"]) elif event["type"] == "note_off": self.on_note_off(event["note"], event["velocity"], event["channel"]) elif event["type"] == "tempo": self.on_tempo_change(event["tempo"]) elif event["type"] == "controller": self.on_controller(event["controller"], event["value"], event["channel"]) elif event["type"] == "program_change": self.on_program_change(event["program"], event["channel"]) elif event["type"] == "end_of_track": self.on_end_of_track(event["track"]) if self._parser.current_event: time_diff = self._parser.current_event["absolute"] - current_absolute_time self._next_event_delay = (time_diff / self._parser.ticks_per_beat) * ( self._parser.tempo / 1000000 ) else: self._next_event_delay = 0.1 self._last_event_time = current_time else: self._playing = False self._finished = True self.on_playback_complete() if self._loop_playback: self._parser.reset() self._playing = True self._finished = False self._last_event_time = current_time self._next_event_delay = self._restart_delay