diff --git a/.gitignore b/.gitignore index 5db749d..09e4eb5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.pymon .plan .env tmp/ diff --git a/dashboards/merge_files.py b/dashboards/merge_files.py new file mode 100644 index 0000000..e4cd192 --- /dev/null +++ b/dashboards/merge_files.py @@ -0,0 +1,40 @@ +import streamlit as st +from streamlit_pianoroll import from_fortepyan + +from fortepyan import MidiFile + + +def main(): + st.write("# Test MidiFile merging") + uploaded_files = st.file_uploader( + label="Upload one or many MIDI files", + accept_multiple_files=True, + ) + + if not uploaded_files: + st.write("Waiting for files") + return + + midi_files = [] + for uploaded_file in uploaded_files: + midi_file = MidiFile.from_file(uploaded_file) + midi_files.append(midi_file) + + merge_spacing = st.number_input( + label="merge spacing [s] (time interval inserted between files)", + min_value=0.0, + max_value=30.0, + value=5.0, + ) + merged_midi_file = MidiFile.merge_files( + midi_files=midi_files, + space=merge_spacing, + ) + st.write("Duration after merge:", merged_midi_file.duration) + st.write("Number of notes after merge:", merged_midi_file.piece.size) + + from_fortepyan(merged_midi_file.piece) + + +if __name__ == "__main__": + main() diff --git a/fortepyan/__init__.py b/fortepyan/__init__.py index 67ce431..21a40f7 100644 --- a/fortepyan/__init__.py +++ b/fortepyan/__init__.py @@ -9,4 +9,4 @@ # Pretty MIDI will throw an unwanted error for large files with high PPQ # This is a workaround # https://github.com/craffel/pretty-midi/issues/112 -pretty_midi.pretty_midi.MAX_TICK = 1e10 +pretty_midi.MAX_TICK = 1e10 diff --git a/fortepyan/audio/render.py b/fortepyan/audio/render.py index e8f63a6..ab6eaf9 100644 --- a/fortepyan/audio/render.py +++ b/fortepyan/audio/render.py @@ -1,12 +1,12 @@ import tempfile from typing import Union +import pretty_midi from pydub import AudioSegment from midi2audio import FluidSynth from fortepyan.audio import soundfont from fortepyan.midi import structures -from fortepyan.midi import containers as midi_containers def midi_to_wav(midi: Union[structures.MidiFile, structures.MidiPiece], wavpath: str): @@ -41,7 +41,7 @@ def midi_to_wav(midi: Union[structures.MidiFile, structures.MidiPiece], wavpath: # Add an silent event to make sure the final notes # have time to ring out end_time = midi.get_end_time() + 0.2 - pedal_off = midi_containers.ControlChange(64, 0, end_time) + pedal_off = pretty_midi.ControlChange(64, 0, end_time) midi.instruments[0].control_changes.append(pedal_off) midi.write(tmp_midi_path) diff --git a/fortepyan/midi/containers.py b/fortepyan/midi/containers.py deleted file mode 100644 index 7a4c12f..0000000 --- a/fortepyan/midi/containers.py +++ /dev/null @@ -1,324 +0,0 @@ -import re - - -class Instrument(object): - """Object to hold event information for a single instrument. - - Parameters: - program (int): MIDI program number (instrument index), in ``[0, 127]``. - is_drum (bool, optinal): Is the instrument a drum instrument (channel 9)? - name (str, optional): Name of the instrument. - - Notes: - It's a container class used to store notes, and control changes. Adapted from [pretty_midi](https://github.com/craffel/pretty-midi). - - """ - - def __init__(self, program, is_drum=False, name=""): - self.program = program - self.is_drum = is_drum - self.name = name - self.pitch_bends = [] - self.notes = [] - self.control_changes = [] - - def get_end_time(self): - """Returns the time of the end of the events in this instrument. - - Returns - ------- - end_time : float - Time, in seconds, of the last event. - - """ - # Cycle through all note ends and all pitch bends and find the largest - events = [n.end for n in self.notes] + [c.time for c in self.control_changes] - # If there are no events, just return 0 - if len(events) == 0: - return 0.0 - else: - return max(events) - - -class Note(object): - """A note event. - - Parameters: - velocity (int): Note velocity. - pitch (int): Note pitch, as a MIDI note number. - start (float): Note on time, absolute, in seconds. - end (float): Note off time, absolute, in seconds. - - Notes: - It's a container class used to store a note. Adapted from [pretty_midi](https://github.com/craffel/pretty-midi). - - """ - - def __init__(self, velocity, pitch, start, end): - if end < start: - raise ValueError("Note end time must be greater than start time") - - self.velocity = velocity - self.pitch = pitch - self.start = start - self.end = end - - def get_duration(self): - """ - Get the duration of the note in seconds. - """ - return self.end - self.start - - @property - def duration(self): - return self.get_duration() - - def __repr__(self): - return "Note(start={:f}, end={:f}, pitch={}, velocity={})".format(self.start, self.end, self.pitch, self.velocity) - - -class ControlChange(object): - """ - A control change event. - - Parameters: - number (int): The control change number, in ``[0, 127]``. - value (int): The value of the control change, in ``[0, 127]``. - time (float): Time where the control change occurs. - - Notes: - It's a container class used to store a control change. Adapted from [pretty_midi](https://github.com/craffel/pretty-midi). - """ - - def __init__(self, number, value, time): - self.number = number - self.value = value - self.time = time - - def __repr__(self): - return "ControlChange(number={:d}, value={:d}, " "time={:f})".format(self.number, self.value, self.time) - - -class KeySignature(object): - """Contains the key signature and the event time in seconds. - Only supports major and minor keys. - - Attributes: - key_number (int): Key number according to ``[0, 11]`` Major, ``[12, 23]`` minor. - For example, 0 is C Major, 12 is C minor. - time (float): Time of event in seconds. - - Example: - Instantiate a C# minor KeySignature object at 3.14 seconds: - - >>> ks = KeySignature(13, 3.14) - >>> print(ks) - C# minor at 3.14 seconds - """ - - def __init__(self, key_number, time): - if not all((isinstance(key_number, int), key_number >= 0, key_number < 24)): - raise ValueError("{} is not a valid `key_number` type or value".format(key_number)) - if not (isinstance(time, (int, float)) and time >= 0): - raise ValueError("{} is not a valid `time` type or value".format(time)) - - self.key_number = key_number - self.time = time - - def __repr__(self): - return "KeySignature(key_number={}, time={})".format(self.key_number, self.time) - - def __str__(self): - return "{} at {:.2f} seconds".format(key_number_to_key_name(self.key_number), self.time) - - -class Lyric(object): - """ - Timestamped lyric text. - - """ - - def __init__(self, text, time): - self.text = text - self.time = time - - def __repr__(self): - return 'Lyric(text="{}", time={})'.format(self.text.replace('"', r"\""), self.time) - - def __str__(self): - return '"{}" at {:.2f} seconds'.format(self.text, self.time) - - -class Text(object): - """ - Timestamped text event. - """ - - def __init__(self, text, time): - self.text = text - self.time = time - - def __repr__(self): - return 'Text(text="{}", time={})'.format(self.text.replace('"', r"\""), self.time) - - def __str__(self): - return '"{}" at {:.2f} seconds'.format(self.text, self.time) - - -def key_number_to_key_name(key_number): - """ - Convert a key number to a key string. - - Parameters: - key_number (int): Uses pitch classes to represent major and minor keys. - For minor keys, adds a 12 offset. For example, C major is 0 and C minor is 12. - - Returns: - key_name (str): Key name in the format ``'(root) (mode)'``, e.g. ``'Gb minor'``. - Gives preference for keys with flats, with the exception of F#, G# and C# minor. - """ - - if not isinstance(key_number, int): - raise ValueError("`key_number` is not int!") - if not ((key_number >= 0) and (key_number < 24)): - raise ValueError("`key_number` is larger than 24") - - # preference to keys with flats - keys = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"] - - # circle around 12 pitch classes - key_idx = key_number % 12 - mode = key_number // 12 - - # check if mode is major or minor - if mode == 0: - return keys[key_idx] + " Major" - elif mode == 1: - # preference to C#, F# and G# minor - if key_idx in [1, 6, 8]: - return keys[key_idx - 1] + "# minor" - else: - return keys[key_idx] + " minor" - - -def key_name_to_key_number(key_string): - """ - Convert a key name string to key number. - - Parameters: - key_string (str): Format is ``'(root) (mode)'``, where: - * ``(root)`` is one of ABCDEFG or abcdefg. A lowercase root - indicates a minor key when no mode string is specified. Optionally - a # for sharp or b for flat can be specified. - - * ``(mode)`` is optionally specified either as one of 'M', 'Maj', - 'Major', 'maj', or 'major' for major or 'm', 'Min', 'Minor', 'min', - 'minor' for minor. If no mode is specified and the root is - uppercase, the mode is assumed to be major; if the root is - lowercase, the mode is assumed to be minor. - - Returns: - key_number (int): - Integer representing the key and its mode. Integers from 0 to 11 - represent major keys from C to B; 12 to 23 represent minor keys from C - to B. - """ - # Create lists of possible mode names (major or minor) - major_strs = ["M", "Maj", "Major", "maj", "major"] - minor_strs = ["m", "Min", "Minor", "min", "minor"] - # Construct regular expression for matching key - pattern = re.compile( - # Start with any of A-G, a-g - "^(?P[ABCDEFGabcdefg])" - # Next, look for #, b, or nothing - "(?P[#b]?)" - # Allow for a space between key and mode - " ?" - # Next, look for any of the mode strings - "(?P(?:(?:" - # Next, look for any of the major or minor mode strings - + ")|(?:".join(major_strs + minor_strs) - + "))?)$" - ) - # Match provided key string - result = re.match(pattern, key_string) - if result is None: - raise ValueError("Supplied key {} is not valid.".format(key_string)) - # Convert result to dictionary - result = result.groupdict() - - # Map from key string to pitch class number - key_number = {"c": 0, "d": 2, "e": 4, "f": 5, "g": 7, "a": 9, "b": 11}[result["key"].lower()] - # Increment or decrement pitch class if a flat or sharp was specified - if result["flatsharp"]: - if result["flatsharp"] == "#": - key_number += 1 - elif result["flatsharp"] == "b": - key_number -= 1 - # Circle around 12 pitch classes - key_number = key_number % 12 - # Offset if mode is minor, or the key name is lowercase - if result["mode"] in minor_strs or (result["key"].islower() and result["mode"] not in major_strs): - key_number += 12 - - return key_number - - -class TimeSignature(object): - """ - Container for a Time Signature event, which contains the time signature - numerator, denominator and the event time in seconds. - - Attributes: - numerator (int): - Numerator of time signature. - denominator (int): - Denominator of time signature. - time (float): - Time of event in seconds. - - Example: - Instantiate a TimeSignature object with 6/8 time signature at 3.14 seconds: - - >>> ts = TimeSignature(6, 8, 3.14) - >>> print(ts) - 6/8 at 3.14 seconds - """ - - def __init__(self, numerator, denominator, time): - if not (isinstance(numerator, int) and numerator > 0): - raise ValueError("{} is not a valid `numerator` type or value".format(numerator)) - if not (isinstance(denominator, int) and denominator > 0): - raise ValueError("{} is not a valid `denominator` type or value".format(denominator)) - if not (isinstance(time, (int, float)) and time >= 0): - raise ValueError("{} is not a valid `time` type or value".format(time)) - - self.numerator = numerator - self.denominator = denominator - self.time = time - - def __repr__(self): - return "TimeSignature(numerator={}, denominator={}, time={})".format(self.numerator, self.denominator, self.time) - - def __str__(self): - return "{}/{} at {:.2f} seconds".format(self.numerator, self.denominator, self.time) - - -class PitchBend(object): - """ - A pitch bend event. - - Parameters: - pitch (int) - MIDI pitch bend amount, in the range ``[-8192, 8191]``. - time (float) - Time where the pitch bend occurs. - - """ - - def __init__(self, pitch, time): - self.pitch = pitch - self.time = time - - def __repr__(self): - return "PitchBend(pitch={:d}, time={:f})".format(self.pitch, self.time) diff --git a/fortepyan/midi/structures.py b/fortepyan/midi/structures.py index 78f0c2d..c783b43 100644 --- a/fortepyan/midi/structures.py +++ b/fortepyan/midi/structures.py @@ -1,23 +1,12 @@ import json -import math -import pathlib -import functools -import collections -from heapq import merge -from warnings import showwarning +from typing import IO, Optional from dataclasses import field, dataclass -import six -import mido import numpy as np +import pretty_midi import pandas as pd from fortepyan.midi import tools as midi_tools -from fortepyan.midi import containers as midi_containers -from fortepyan.midi.containers import key_name_to_key_number - -# The largest we'd ever expect a tick to be -MAX_TICK = 1e10 @dataclass @@ -323,13 +312,15 @@ def __add__(self, other: "MidiPiece") -> "MidiPiece": out = MidiPiece(df=df) + # TODO Think of another way to track this information + # maybe add {"warnings": ["merged from multiple pieces"]} to .source? # Show warning as the piece might not be musically valid. - showwarning( - message="The resulting piece may not be musically valid.", - category=UserWarning, - filename="fortepyan/midi/structures.py", - lineno=280, - ) + # showwarning( + # message="The resulting piece may not be musically valid.", + # category=UserWarning, + # filename="fortepyan/midi/structures.py", + # lineno=280, + # ) return out @@ -372,20 +363,7 @@ def to_midi(self, instrument_name: str = "Piano") -> "MidiFile": This would create a MIDI track using the notes in 'my_object' and name it "Violin". """ - track = MidiFile() - program = 0 # 0 is piano - instrument = midi_containers.Instrument(program=program, name=instrument_name) - - # Convert the DataFrame to a list of tuples to avoid pandas overhead in the loop - note_data = self.df[["velocity", "pitch", "start", "end"]].to_records(index=False) - # Now we can iterate through this array which is more efficient than DataFrame iterrows - for velocity, pitch, start, end in note_data: - note = midi_containers.Note(velocity=int(velocity), pitch=int(pitch), start=start, end=end) - instrument.notes.append(note) - - track.instruments.append(instrument) - - return track + return MidiFile.from_piece(self) @classmethod def from_huggingface(cls, record: dict) -> "MidiPiece": @@ -404,21 +382,14 @@ def from_file(cls, path: str) -> "MidiPiece": @dataclass class MidiFile: - path: str = None + path: Optional[str] = None apply_sustain: bool = True sustain_threshold: int = 62 - resolution: int = 220 - initial_tempo: float = 120.0 df: pd.DataFrame = field(init=False) raw_df: pd.DataFrame = field(init=False) sustain: pd.DataFrame = field(init=False) control_frame: pd.DataFrame = field(init=False, repr=False) - instruments: list = field(init=False, repr=False) - key_signature_changes: list = field(init=False, repr=False) - time_signature_changes: list = field(init=False, repr=False) - lyrics: list = field(init=False, repr=False) - text_events: list = field(init=False, repr=False) - __tick_to_time: np.ndarray = field(init=False, repr=False) + _midi: pretty_midi.PrettyMIDI = field(init=True, repr=False, default=None) def __rich_repr__(self): yield "MidiFile" @@ -429,94 +400,23 @@ def __rich_repr__(self): @property def duration(self) -> float: - return self.get_end_time() + return self._midi.get_end_time() @property def notes(self): # This is not great/foolproof, but we already have files # where the piano track is present on multiple "programs"/"instruments - notes = sum([inst.notes for inst in self.instruments], []) + notes = sum([inst.notes for inst in self._midi.instruments], []) return notes @property def control_changes(self): # See the note for notes ^^ - ccs = sum([inst.control_changes for inst in self.instruments], []) + ccs = sum([inst.control_changes for inst in self._midi.instruments], []) return ccs - def __post_init__(self): - self._initialize_fields() - if self.path: - self._process_midi_file() - else: - self._setup_without_path() - - def _initialize_fields(self): - self.instruments = [] - self.key_signature_changes = [] - self.time_signature_changes = [] - self.lyrics = [] - self.text_events = [] - self.control_frame = pd.DataFrame() - self.sustain = pd.DataFrame() - self.df = pd.DataFrame() - self.raw_df = pd.DataFrame() - - def _setup_without_path(self): - self._tick_scales = [(0, 60.0 / (self.initial_tempo * self.resolution))] - self.__tick_to_time = [0] - - def _process_midi_file(self): - midi_data = mido.MidiFile(filename=self.path) - - # Convert to absolute ticks - for track in midi_data.tracks: - tick = 0 - for event in track: - event.time += tick - tick = event.time - - # Store the resolution for later use - self.resolution = midi_data.ticks_per_beat - - # Populate the list of tempo changes (tick scales) - self._load_tempo_changes(midi_data) - - # Update the array which maps ticks to time - max_tick = self.get_max_tick(midi_data) - # If max_tick is too big, the MIDI file is probably corrupt - # and creating the __tick_to_time array will thrash memory - if max_tick > MAX_TICK: - raise ValueError(("MIDI file has a largest tick of {}," " it is likely corrupt".format(max_tick))) - - # Create list that maps ticks to time in seconds - self._update_tick_to_time(self.get_max_tick(midi_data)) - - # Load the metadata - self._load_metadata(midi_data) - - # Check that there are tempo, key and time change events - # only on track 0 - if any(e.type in ("set_tempo", "key_signature", "time_signature") for track in midi_data.tracks[1:] for e in track): - message = ( - "Tempo, Key or Time signature change events found on " - "non-zero tracks. This is not a valid type 0 or type 1 " - "MIDI file. Tempo, Key or Time Signature may be wrong." - ) - showwarning( - message=message, - category=RuntimeWarning, - filename="fortepyan/midi/structures.py", - lineno=469, - ) - - # Populate the list of instruments - self._load_instruments(midi_data) - - self._build_dataframes() - - def _build_dataframes(self): - # Extract CCs + def _load_midi_file(self): + # Extract CC data self.control_frame = pd.DataFrame( { "time": [cc.time for cc in self.control_changes], @@ -525,7 +425,6 @@ def _build_dataframes(self): } ) - # TODO We need all 3 pedals # Sustain CC is 64 ids = self.control_frame.number == 64 self.sustain = self.control_frame[ids].reset_index(drop=True) @@ -550,344 +449,16 @@ def _build_dataframes(self): else: self.df = self.raw_df - def get_max_tick(self, midi_data): - return max([max([e.time for e in t]) for t in midi_data.tracks]) + 1 + def __post_init__(self): + if self.path: + # Read the MIDI object + self._midi = pretty_midi.PrettyMIDI(self.path) + + # Otherwise _midi had to be provided as an argument + self._load_midi_file() def __getitem__(self, index: slice) -> MidiPiece: return self.piece[index] - # if not isinstance(index, slice): - # raise TypeError("You can only get a part of MidiFile that has multiple notes: Index must be a slice") - - # part = self.df[index].reset_index(drop=True) - # first_sound = part.start.min() - - # # TODO: When you start working with pedal data, add this to the Piece structure - # if not self.apply_sustain: - # # +0.2 to make sure we get some sustain data at the end to ring out - # ids = (self.sustain.time >= part.start.min()) & (self.sustain.time <= part.end.max() + 0.2) - # sustain_part = self.sustain[ids].reset_index(drop=True) - # sustain_part.time -= first_sound - - # # Move the notes - # part.start -= first_sound - # part.end -= first_sound - - # source = { - # "type": "MidiFile", - # "path": self.path, - # } - # out = MidiPiece(df=part, source=source) - - # return out - - def _load_tempo_changes(self, midi_data): - """ - Populates `self._tick_scales` with tuples of - `(tick, tick_scale)` loaded from `midi_data`. - - Parameters: - midi_data (midi.FileReader): MIDI object from which data will be read. - """ - - # MIDI data is given in "ticks". - # We need to convert this to clock seconds. - # The conversion factor involves the BPM, which may change over time. - # So, create a list of tuples, (time, tempo) - # denoting a tempo change at a certain time. - # By default, set the tempo to 120 bpm, starting at time 0 - self._tick_scales = [(0, 60.0 / (120.0 * self.resolution))] - # For SMF file type 0, all events are on track 0. - # For type 1, all tempo events should be on track 1. - # Everyone ignores type 2. >>> :'( - # So, just look at events on track 0 - for event in midi_data.tracks[0]: - if event.type == "set_tempo": - # Only allow one tempo change event at the beginning - if event.time == 0: - bpm = 6e7 / event.tempo - self._tick_scales = [(0, 60.0 / (bpm * self.resolution))] - else: - # Get time and BPM up to this point - _, last_tick_scale = self._tick_scales[-1] - tick_scale = 60.0 / ((6e7 / event.tempo) * self.resolution) - # Ignore repetition of BPM, which happens often - if tick_scale != last_tick_scale: - self._tick_scales.append((event.time, tick_scale)) - - def _load_metadata(self, midi_data): - """Populates ``self.time_signature_changes`` with ``TimeSignature`` - objects, ``self.key_signature_changes`` with ``KeySignature`` objects, - ``self.lyrics`` with ``Lyric`` objects and ``self.text_events`` with - ``Text`` objects. - - Parameters - ---------- - midi_data : midi.FileReader - MIDI object from which data will be read. - """ - - # Initialize empty lists for storing key signature changes, time - # signature changes, and lyrics - self.key_signature_changes = [] - self.time_signature_changes = [] - self.lyrics = [] - self.text_events = [] - - for event in midi_data.tracks[0]: - if event.type == "key_signature": - key_obj = midi_containers.KeySignature(key_name_to_key_number(event.key), self.__tick_to_time[event.time]) - self.key_signature_changes.append(key_obj) - - elif event.type == "time_signature": - ts_obj = midi_containers.TimeSignature(event.numerator, event.denominator, self.__tick_to_time[event.time]) - self.time_signature_changes.append(ts_obj) - - # We search for lyrics and text events on all tracks - # Lists of lyrics and text events lists, for every track - tracks_with_lyrics = [] - tracks_with_text_events = [] - for track in midi_data.tracks: - # Track specific lists that get appended if not empty - lyrics = [] - text_events = [] - for event in track: - if event.type == "lyrics": - lyrics.append(midi_containers.Lyric(event.text, self.__tick_to_time[event.time])) - elif event.type == "text": - text_events.append(midi_containers.Text(event.text, self.__tick_to_time[event.time])) - - if lyrics: - tracks_with_lyrics.append(lyrics) - if text_events: - tracks_with_text_events.append(text_events) - - # We merge the already sorted lists for every track, based on time - self.lyrics = list(merge(*tracks_with_lyrics, key=lambda x: x.time)) - self.text_events = list(merge(*tracks_with_text_events, key=lambda x: x.time)) - - def _update_tick_to_time(self, max_tick): - """ - Creates ``self.__tick_to_time``, a class member array which maps - ticks to time starting from tick 0 and ending at ``max_tick``. - """ - # If max_tick is smaller than the largest tick in self._tick_scales, - # use this largest tick instead - max_scale_tick = max(ts[0] for ts in self._tick_scales) - max_tick = max_tick if max_tick > max_scale_tick else max_scale_tick - # Allocate tick to time array - indexed by tick from 0 to max_tick - self.__tick_to_time = np.zeros(max_tick + 1) - # Keep track of the end time of the last tick in the previous interval - last_end_time = 0 - # Cycle through intervals of different tempi - for (start_tick, tick_scale), (end_tick, _) in zip(self._tick_scales[:-1], self._tick_scales[1:]): - # Convert ticks in this interval to times - ticks = np.arange(end_tick - start_tick + 1) - self.__tick_to_time[start_tick : end_tick + 1] = last_end_time + tick_scale * ticks - # Update the time of the last tick in this interval - last_end_time = self.__tick_to_time[end_tick] - # For the final interval, use the final tempo setting - # and ticks from the final tempo setting until max_tick - start_tick, tick_scale = self._tick_scales[-1] - ticks = np.arange(max_tick + 1 - start_tick) - self.__tick_to_time[start_tick:] = last_end_time + tick_scale * ticks - - def _load_instruments(self, midi_data): - """Populates ``self.instruments`` using ``midi_data``. - - Parameters - ---------- - midi_data : midi.FileReader - MIDI object from which data will be read. - """ - # MIDI files can contain a collection of tracks; each track can have - # events occuring on one of sixteen channels, and events can correspond - # to different instruments according to the most recently occurring - # program number. So, we need a way to keep track of which instrument - # is playing on each track on each channel. This dict will map from - # program number, drum/not drum, channel, and track index to instrument - # indices, which we will retrieve/populate using the __get_instrument - # function below. - instrument_map = collections.OrderedDict() - # Store a similar mapping to instruments storing "straggler events", - # e.g. events which appear before we want to initialize an Instrument - stragglers = {} - # This dict will map track indices to any track names encountered - track_name_map = collections.defaultdict(str) - - def __get_instrument(program, channel, track, create_new): - """Gets the Instrument corresponding to the given program number, - drum/non-drum type, channel, and track index. If no such - instrument exists, one is created. - - """ - # If we have already created an instrument for this program - # number/track/channel, return it - if (program, channel, track) in instrument_map: - return instrument_map[(program, channel, track)] - # If there's a straggler instrument for this instrument and we - # aren't being requested to create a new instrument - if not create_new and (channel, track) in stragglers: - return stragglers[(channel, track)] - # If we are told to, create a new instrument and store it - if create_new: - is_drum = channel == 9 - instrument = midi_containers.Instrument(program, is_drum, track_name_map[track_idx]) - # If any events appeared for this instrument before now, - # include them in the new instrument - if (channel, track) in stragglers: - straggler = stragglers[(channel, track)] - instrument.control_changes = straggler.control_changes - instrument.pitch_bends = straggler.pitch_bends - # Add the instrument to the instrument map - instrument_map[(program, channel, track)] = instrument - # Otherwise, create a "straggler" instrument which holds events - # which appear before we actually want to create a proper new - # instrument - else: - # Create a "straggler" instrument - instrument = midi_containers.Instrument(program, track_name_map[track_idx]) - # Note that stragglers ignores program number, because we want - # to store all events on a track which appear before the first - # note-on, regardless of program - stragglers[(channel, track)] = instrument - return instrument - - for track_idx, track in enumerate(midi_data.tracks): - # Keep track of last note on location: - # key = (instrument, note), - # value = (note-on tick, velocity) - last_note_on = collections.defaultdict(list) - # Keep track of which instrument is playing in each channel - # initialize to program 0 for all channels - current_instrument = np.zeros(16, dtype=np.int32) - for event in track: - # Look for track name events - if event.type == "track_name": - # Set the track name for the current track - track_name_map[track_idx] = event.name - # Look for program change events - if event.type == "program_change": - # Update the instrument for this channel - current_instrument[event.channel] = event.program - # Note ons are note on events with velocity > 0 - elif event.type == "note_on" and event.velocity > 0: - # Store this as the last note-on location - note_on_index = (event.channel, event.note) - last_note_on[note_on_index].append((event.time, event.velocity)) - # Note offs can also be note on events with 0 velocity - elif event.type == "note_off" or (event.type == "note_on" and event.velocity == 0): - # Check that a note-on exists (ignore spurious note-offs) - key = (event.channel, event.note) - if key in last_note_on: - # Get the start/stop times and velocity of every note - # which was turned on with this instrument/drum/pitch. - # One note-off may close multiple note-on events from - # previous ticks. In case there's a note-off and then - # note-on at the same tick we keep the open note from - # this tick. - end_tick = event.time - open_notes = last_note_on[key] - - notes_to_close = [(start_tick, velocity) for start_tick, velocity in open_notes if start_tick != end_tick] - notes_to_keep = [(start_tick, velocity) for start_tick, velocity in open_notes if start_tick == end_tick] - - for start_tick, velocity in notes_to_close: - start_time = self.__tick_to_time[start_tick] - end_time = self.__tick_to_time[end_tick] - # Create the note event - note = midi_containers.Note(velocity, event.note, start_time, end_time) - # Get the program and drum type for the current - # instrument - program = current_instrument[event.channel] - # Retrieve the Instrument instance for the current - # instrument - # Create a new instrument if none exists - instrument = __get_instrument(program, event.channel, track_idx, 1) - # Add the note event - instrument.notes.append(note) - - if len(notes_to_close) > 0 and len(notes_to_keep) > 0: - # Note-on on the same tick but we already closed - # some previous notes -> it will continue, keep it. - last_note_on[key] = notes_to_keep - else: - # Remove the last note on for this instrument - del last_note_on[key] - # Store control changes - elif event.type == "control_change": - control_change = midi_containers.ControlChange(event.control, event.value, self.__tick_to_time[event.time]) - # Get the program for the current inst - program = current_instrument[event.channel] - # Retrieve the Instrument instance for the current inst - # Don't create a new instrument if none exists - instrument = __get_instrument(program, event.channel, track_idx, 0) - # Add the control change event - instrument.control_changes.append(control_change) - # Initialize list of instruments from instrument_map - self.instruments = [i for i in instrument_map.values()] - - def tick_to_time(self, tick): - """ - Converts from an absolute tick to time in seconds using - ``self.__tick_to_time``. - - Parameters: - tick (int): - Absolute tick to convert. - - Returns: - time (float): - Time in seconds of tick. - - """ - # Check that the tick isn't too big - if tick >= MAX_TICK: - raise IndexError("Supplied tick is too large.") - # If we haven't compute the mapping for a tick this large, compute it - if tick >= len(self.__tick_to_time): - self._update_tick_to_time(tick) - # Ticks should be integers - if not isinstance(tick, int): - showwarning("ticks should be integers", RuntimeWarning, "fortepyan", lineno=1) - # Otherwise just return the time - return self.__tick_to_time[int(tick)] - - def get_tempo_changes(self): - """Return arrays of tempo changes in quarter notes-per-minute and their - times. - """ - # Pre-allocate return arrays - tempo_change_times = np.zeros(len(self._tick_scales)) - tempi = np.zeros(len(self._tick_scales)) - for n, (tick, tick_scale) in enumerate(self._tick_scales): - # Convert tick of this tempo change to time in seconds - tempo_change_times[n] = self.tick_to_time(tick) - # Convert tick scale to a tempo - tempi[n] = 60.0 / (tick_scale * self.resolution) - return tempo_change_times, tempi - - def get_end_time(self): - """ - Returns the time of the end of the MIDI object (time of the last - event in all instruments/meta-events). - - Returns: - end_time (float): - Time, in seconds, where this MIDI file ends. - - """ - # Get end times from all instruments, and times of all meta-events - meta_events = [self.time_signature_changes, self.key_signature_changes, self.lyrics, self.text_events] - times = ( - [i.get_end_time() for i in self.instruments] - + [e.time for m in meta_events for e in m] - + self.get_tempo_changes()[0].tolist() - ) - # If there are no events, return 0 - if len(times) == 0: - return 0.0 - else: - return max(times) @property def piece(self) -> MidiPiece: @@ -901,255 +472,115 @@ def piece(self) -> MidiPiece: ) return out - def time_to_tick(self, time): + @classmethod + def from_file(cls, midi_file: IO) -> "MidiFile": """ - Converts from a time in seconds to absolute tick using - `self._tick_scales`. + Generic wrapper for the pretty_midi.PrettyMIDI interface. - Parameters: - time (float): - Time, in seconds. + Args: + midi_file (str or file): Path or file pointer to a MIDI file. Returns: - tick (int) - Absolute tick corresponding to the supplied time. - + MidiFile: A new `MidiFile` object containing the input file. """ - # Find the index of the ticktime which is smaller than time - tick = np.searchsorted(self.__tick_to_time, time, side="left") - # If the closest tick was the final tick in self.__tick_to_time... - if tick == len(self.__tick_to_time): - # start from time at end of __tick_to_time - tick -= 1 - # Add on ticks assuming the final tick_scale amount - _, final_tick_scale = self._tick_scales[-1] - tick += (time - self.__tick_to_time[tick]) / final_tick_scale - # Re-round/quantize - return int(round(tick)) - # If the tick is not 0 and the previous ticktime in a is closer to time - if tick and (math.fabs(time - self.__tick_to_time[tick - 1]) < math.fabs(time - self.__tick_to_time[tick])): - # Decrement index by 1 - return tick - 1 - else: - return tick + _midi = pretty_midi.PrettyMIDI(midi_file) + + midi_file = cls(_midi=_midi) + return midi_file + + @classmethod + def from_piece(cls, piece: MidiPiece) -> "MidiFile": + _midi = pretty_midi.PrettyMIDI() + + # 0 is piano + program = 0 + instrument_name = "fortepyan" + instrument = pretty_midi.Instrument(program=program, name=instrument_name) + + # Convert the DataFrame to a list of tuples to avoid pandas overhead in the loop + note_data = piece.df[["velocity", "pitch", "start", "end"]].to_records(index=False) + # Now we can iterate through this array which is more efficient than DataFrame iterrows + for velocity, pitch, start, end in note_data: + note = pretty_midi.Note( + velocity=int(velocity), + pitch=int(pitch), + start=start, + end=end, + ) + instrument.notes.append(note) + + _midi.instruments.append(instrument) + + midi_file = cls(_midi=_midi) - def write(self, filename: str): + return midi_file + + @classmethod + def merge_files(cls, midi_files: list["MidiFile"], space: float = 0.0) -> "MidiFile": """ - Write the MIDI data out to a .mid file. + Merges multiple MIDI files into a single MIDI file. - Parameters: - filename (str): Path to write .mid file to. + This method combines the notes and control changes from the input list of + `MidiFile` objects into a single MIDI track with an optional space between + each file's content. All input files are assumed to have a piano track + (`program=0`) as the first instrument. + Args: + midi_files (list[MidiFile]): List of `MidiFile` objects to be merged. + space (float, optional): Time (in seconds) to insert between the end of + one MIDI file and the start of the next. Defaults to 0.0. + + Returns: + MidiFile: A new `MidiFile` object containing the merged tracks. + + Note: + - Only the first instrument (assumed to be a piano track) from each file + is processed. + - The last control change time is considered to calculate the start offset + for the next file. If there are no control changes, the last note end + time is used. """ - # TODO argument has to also allow passing an io.Bytes so we can write in-memory - - def event_compare(event1, event2): - """ - Compares two events for sorting. - - Events are sorted by tick time ascending. Events with the same tick - time ares sorted by event type. Some events are sorted by - additional values. For example, Note On events are sorted by pitch - then velocity, ensuring that a Note Off (Note On with velocity 0) - will never follow a Note On with the same pitch. - - Parameters: - event1, event2 (mido.Message): - Two events to be compared. - """ - # Construct a dictionary which will map event names to numeric - # values which produce the correct sorting. Each dictionary value - # is a function which accepts an event and returns a score. - # The spacing for these scores is 256, which is larger than the - # largest value a MIDI value can take. - secondary_sort = { - "set_tempo": lambda e: (1 * 256 * 256), - "time_signature": lambda e: (2 * 256 * 256), - "key_signature": lambda e: (3 * 256 * 256), - "lyrics": lambda e: (4 * 256 * 256), - "text_events": lambda e: (5 * 256 * 256), - "program_change": lambda e: (6 * 256 * 256), - "pitchwheel": lambda e: ((7 * 256 * 256) + e.pitch), - "control_change": lambda e: ((8 * 256 * 256) + (e.control * 256) + e.value), - "note_off": lambda e: ((9 * 256 * 256) + (e.note * 256)), - "note_on": lambda e: ((10 * 256 * 256) + (e.note * 256) + e.velocity), - "end_of_track": lambda e: (11 * 256 * 256), - } - # If the events have the same tick, and both events have types - # which appear in the secondary_sort dictionary, use the dictionary - # to determine their ordering. - if event1.time == event2.time and event1.type in secondary_sort and event2.type in secondary_sort: - return secondary_sort[event1.type](event1) - secondary_sort[event2.type](event2) - - # Otherwise, just return the difference of their ticks. - return event1.time - event2.time - - # Initialize output MIDI object - mid = mido.MidiFile(ticks_per_beat=self.resolution) - - # Create track 0 with timing information - timing_track = mido.MidiTrack() - - # Add a default time signature only if there is not one at time 0. - add_ts = True - if self.time_signature_changes: - add_ts = min([ts.time for ts in self.time_signature_changes]) > 0.0 - if add_ts: - # Add time signature event with default values (4/4) - timing_track.append(mido.MetaMessage("time_signature", time=0, numerator=4, denominator=4)) - - # Add in each tempo change event - for tick, tick_scale in self._tick_scales: - timing_track.append( - mido.MetaMessage( - "set_tempo", - time=tick, - # Convert from microseconds per quarter note to BPM - tempo=int(6e7 / (60.0 / (tick_scale * self.resolution))), - ) - ) - # Add in each time signature - for ts in self.time_signature_changes: - timing_track.append( - mido.MetaMessage( - "time_signature", - time=self.time_to_tick(ts.time), - numerator=ts.numerator, - denominator=ts.denominator, + _midi = pretty_midi.PrettyMIDI() + + # 0 is piano + program = 0 + instrument_name = "fortepyan" + instrument = pretty_midi.Instrument(program=program, name=instrument_name) + + start_offset = 0 + notes = [] + control_changes = [] + for midi_file in midi_files: + piano_track = midi_file._midi.instruments[0] + for note in piano_track.notes: + new_note = pretty_midi.Note( + start=note.start + start_offset, + end=note.end + start_offset, + pitch=note.pitch, + velocity=note.velocity, ) - ) - # Add in each key signature - # Mido accepts key changes in a different format than pretty_midi, this - # list maps key number to mido key name - key_number_to_mido_key_name = [ - "C", - "Db", - "D", - "Eb", - "E", - "F", - "F#", - "G", - "Ab", - "A", - "Bb", - "B", - "Cm", - "C#m", - "Dm", - "D#m", - "Em", - "Fm", - "F#m", - "Gm", - "G#m", - "Am", - "Bbm", - "Bm", - ] - for ks in self.key_signature_changes: - timing_track.append( - mido.MetaMessage("key_signature", time=self.time_to_tick(ks.time), key=key_number_to_mido_key_name[ks.key_number]) - ) - # Add in all lyrics events - for lyr in self.lyrics: - timing_track.append(mido.MetaMessage("lyrics", time=self.time_to_tick(lyr.time), text=lyr.text)) - - # Add text events - for tex in self.text_events: - timing_track.append(mido.MetaMessage("text", time=self.time_to_tick(tex.time), text=tex.text)) - - # Sort the (absolute-tick-timed) events. - timing_track.sort(key=functools.cmp_to_key(event_compare)) - - # Add in an end of track event - timing_track.append(mido.MetaMessage("end_of_track", time=timing_track[-1].time + 1)) - mid.tracks.append(timing_track) - - # Create a list of possible channels to assign - this seems to matter - # for some synths. - channels = list(range(16)) - - # Don't assign the drum channel by mistake! - channels.remove(9) - for n, instrument in enumerate(self.instruments): - # Initialize track for this instrument - track = mido.MidiTrack() - # Add track name event if instrument has a name - if instrument.name: - track.append(mido.MetaMessage("track_name", time=0, name=instrument.name)) - # If it's a drum event, we need to set channel to 9 - if instrument.is_drum: - channel = 9 - # Otherwise, choose a channel from the possible channel list - else: - channel = channels[n % len(channels)] - # Set the program number - track.append(mido.Message("program_change", time=0, program=instrument.program, channel=channel)) - # Add all note events - for note in instrument.notes: - # Construct the note-on event - track.append( - mido.Message( - "note_on", time=self.time_to_tick(note.start), channel=channel, note=note.pitch, velocity=note.velocity - ) - ) - # Also need a note-off event (note on with velocity 0) - track.append( - mido.Message("note_on", time=self.time_to_tick(note.end), channel=channel, note=note.pitch, velocity=0) - ) - # Add all pitch bend events - for bend in instrument.pitch_bends: - track.append(mido.Message("pitchwheel", time=self.time_to_tick(bend.time), channel=channel, pitch=bend.pitch)) - # Add all control change events - for control_change in instrument.control_changes: - track.append( - mido.Message( - "control_change", - time=self.time_to_tick(control_change.time), - channel=channel, - control=control_change.number, - value=control_change.value, - ) + notes.append(new_note) + + for cc in piano_track.control_changes: + new_cc = pretty_midi.ControlChange( + number=cc.number, + value=cc.value, + time=cc.time + start_offset, ) - # Sort all the events using the event_compare comparator. - track = sorted(track, key=functools.cmp_to_key(event_compare)) - - # If there's a note off event and a note on event with the same - # tick and pitch, put the note off event first - for n, (event1, event2) in enumerate(zip(track[:-1], track[1:])): - if ( - event1.time == event2.time - and event1.type == "note_on" - and event2.type == "note_on" - and event1.note == event2.note - and event1.velocity != 0 - and event2.velocity == 0 - ): - track[n] = event2 - track[n + 1] = event1 - - # Finally, add in an end of track event - track.append(mido.MetaMessage("end_of_track", time=track[-1].time + 1)) - - # Add to the list of output tracks - mid.tracks.append(track) - - # Turn ticks to relative time from absolute - for track in mid.tracks: - tick = 0 - for event in track: - event.time -= tick - tick += event.time - - # Write it out - if isinstance(filename, six.string_types) or isinstance(filename, pathlib.PurePath): - # If a string or path was given, pass it as the filename - mid.save(filename=filename) - else: - # Otherwise, try passing it in as a file pointer - mid.save(file=filename) + control_changes.append(new_cc) + + # Events from the next file have to be shifted to start later + last_cc_time = control_changes[-1].time if control_changes else 0 + start_offset = max(notes[-1].end, last_cc_time) + space + + instrument.notes = notes + instrument.control_changes = control_changes + _midi.instruments.append(instrument) + + midi_file = cls(_midi=_midi) + + return midi_file def __repr__(self): diff --git a/fortepyan/view/pianoroll/main.py b/fortepyan/view/pianoroll/main.py index 7092035..9a505ad 100644 --- a/fortepyan/view/pianoroll/main.py +++ b/fortepyan/view/pianoroll/main.py @@ -81,9 +81,14 @@ def sanitize_midi_piece(piece: MidiPiece) -> MidiPiece: duration_threshold = 1200 if piece.duration > duration_threshold: # TODO Logger - showwarning("playtime too long! Showing after trim", RuntimeWarning, filename="", lineno=0) + showwarning( + message="playtime too long! Showing after trim", + category=RuntimeWarning, + filename="fortepyan/view/pianoroll/main.py", + lineno=88, + ) piece = piece.trim( - 0, duration_threshold, slice_type="by_end", shift_time=False + start=0, finish=duration_threshold, slice_type="by_end", shift_time=False ) # Added "by_end" to make sure a very long note doesn't cause an error return piece diff --git a/pyproject.toml b/pyproject.toml index 8c7f2b1..01a6427 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,8 +28,15 @@ dependencies = [ "Levenshtein>=0.20.9", "cmcrameri>=1.5", ] + requires-python = ">=3.9" +[project.optional-dependencies] +dev = [ + "pre-commit ~= 3.8.0", + "streamlit-pianoroll == 0.7.1" +] + [project.urls] Homepage = "https://github.com/Nospoko/fortepyan" diff --git a/tests/midi/test_structures.py b/tests/midi/test_structures.py index 0b924d8..fe4cc82 100644 --- a/tests/midi/test_structures.py +++ b/tests/midi/test_structures.py @@ -1,5 +1,4 @@ import pytest -import numpy as np import pandas as pd from fortepyan.midi.structures import MidiFile, MidiPiece @@ -37,6 +36,16 @@ def sample_midi_piece(): return MidiPiece(df) +def test_midi_file_merge(): + mfa = MidiFile(path=TEST_MIDI_PATH) + mfb = MidiFile(path=TEST_MIDI_PATH) + + mf_merged = MidiFile.merge_files([mfa, mfb]) + + assert len(mf_merged.notes) == len(mfa.notes) + len(mfb.notes) + assert mf_merged.duration == mfa.duration + mfb.duration + + def test_with_start_end_duration(sample_df): piece = MidiPiece(df=sample_df) assert piece.df.shape[0] == 5 @@ -143,13 +152,14 @@ def test_source_update_after_trimming(sample_midi_piece): def test_to_midi(sample_midi_piece): # Create the MIDI track - midi_track = sample_midi_piece.to_midi() + midi_file = sample_midi_piece.to_midi() # Set the expected end time according to the sample MIDI piece - expected_end_time = 5.5 + expected_end_time = sample_midi_piece.duration # Get the end time of the MIDI track - midi_end_time = midi_track.get_end_time() + midi_end_time = midi_file.duration assert midi_end_time == expected_end_time, f"MIDI end time {midi_end_time} does not match expected {expected_end_time}" + assert midi_file.df.shape == sample_midi_piece.df.shape def test_add_two_midi_pieces(sample_midi_piece): @@ -263,22 +273,12 @@ def test_midi_file_getitem(index, expected_type): assert isinstance(result, expected_type) -def test_midi_file_tempo_changes_method(): - """ - Test the 'get_tempo_changes' method. - """ - midi_file = MidiFile(path=TEST_MIDI_PATH) - tempos = midi_file.get_tempo_changes() - assert isinstance(tempos, tuple) - assert all(isinstance(arr, np.ndarray) for arr in tempos) - - -def test_midi_file_end_time_method(): +def test_midi_file_duration(): """ Test the 'get_end_time' method. """ midi_file = MidiFile(path=TEST_MIDI_PATH) - end_time = midi_file.get_end_time() + end_time = midi_file.duration assert isinstance(end_time, float) diff --git a/tests/view/pianoroll/test_main.py b/tests/view/pianoroll/test_main.py index 410ab08..e45e22b 100644 --- a/tests/view/pianoroll/test_main.py +++ b/tests/view/pianoroll/test_main.py @@ -26,8 +26,8 @@ def midi_piece_long(): "velocity": [80], } ) - piece = piece + MidiPiece(df) - return piece + midi_piece_long = piece + MidiPiece(df) + return midi_piece_long def test_sanitize_midi_piece(midi_piece): @@ -37,7 +37,8 @@ def test_sanitize_midi_piece(midi_piece): def test_sanitize_midi_piece_long(midi_piece_long): - sanitized_piece = sanitize_midi_piece(midi_piece_long) + with pytest.warns(RuntimeWarning, match="playtime too long! Showing after trim"): + sanitized_piece = sanitize_midi_piece(midi_piece_long) assert isinstance(sanitized_piece, MidiPiece) assert sanitized_piece.duration < 1200 @@ -62,7 +63,8 @@ def test_draw_pianoroll_with_velocities(midi_piece): def test_draw_pianoroll_with_velocities_long(midi_piece_long): - fig = draw_pianoroll_with_velocities(midi_piece_long) + with pytest.warns(RuntimeWarning, match="playtime too long! Showing after trim"): + fig = draw_pianoroll_with_velocities(midi_piece_long) assert isinstance(fig, plt.Figure) # Accessing the axes of the figure