From 35e11027cfbcac3446c38d58c8871f7938fccbd7 Mon Sep 17 00:00:00 2001 From: Samuel Janas Date: Wed, 22 Nov 2023 17:41:59 +0100 Subject: [PATCH 01/12] initial try --- .gitignore | 1 + fortepyan/midi/structures.py | 73 +++++++++++++++++++++--------------- 2 files changed, 44 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index 35c94c7..5db749d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ tmp/ dist/ *.pyc *.ipynb +venv/ diff --git a/fortepyan/midi/structures.py b/fortepyan/midi/structures.py index 615068a..5dd1af7 100644 --- a/fortepyan/midi/structures.py +++ b/fortepyan/midi/structures.py @@ -1,6 +1,7 @@ from warnings import showwarning from dataclasses import field, dataclass +import mido import numpy as np import pretty_midi import pandas as pd @@ -396,7 +397,7 @@ class MidiFile: raw_df: pd.DataFrame = field(init=False) sustain: pd.DataFrame = field(init=False) control_frame: pd.DataFrame = field(init=False, repr=False) - _midi: pretty_midi.PrettyMIDI = field(init=False, repr=False) + _midi_data: mido.MidiFile = field(init=False, repr=False) def __rich_repr__(self): yield "MidiFile" @@ -406,8 +407,9 @@ def __rich_repr__(self): yield "minutes", round(self.duration / 60, 2) @property - def duration(self) -> float: - return self._midi.get_end_time() + def duration(self): + # Calculate the total duration of the MIDI file + return max(msg.time for track in self._midi_data.tracks for msg in track if hasattr(msg, "time")) @property def notes(self): @@ -423,42 +425,53 @@ def control_changes(self): return ccs def __post_init__(self): - # Read the MIDI object - self._midi = pretty_midi.PrettyMIDI(self.path) - - # Extract CC data - self.control_frame = pd.DataFrame( - { - "time": [cc.time for cc in self.control_changes], - "value": [cc.value for cc in self.control_changes], - "number": [cc.number for cc in self.control_changes], - } - ) + # Load the MIDI file + self._midi_data = mido.MidiFile(self.path) + + # Extract notes and control changes + notes, control_changes = self._extract_notes_and_controls() + + # Create dataframes for notes and control changes + self._create_dataframes(notes, control_changes) # Sustain CC is 64 ids = self.control_frame.number == 64 self.sustain = self.control_frame[ids].reset_index(drop=True) - # Extract notes - raw_df = pd.DataFrame( - { - "pitch": [note.pitch for note in self.notes], - "velocity": [note.velocity for note in self.notes], - "start": [note.start for note in self.notes], - "end": [note.end for note in self.notes], - } - ) - self.raw_df = raw_df.sort_values("start", ignore_index=True) - + # Apply sustain if needed if self.apply_sustain: - self.df = midi_tools.apply_sustain( - df=self.raw_df, - sustain=self.sustain, - sustain_threshold=self.sustain_threshold, - ) + self.df = self._apply_sustain() else: self.df = self.raw_df + def _extract_notes_and_controls(self): + notes = [] + control_changes = [] + current_time = 0 + + for track in self._midi_data.tracks: + for msg in track: + current_time += msg.time + if msg.type == "note_on" and msg.velocity > 0: + notes.append({"pitch": msg.note, "start": current_time, "velocity": msg.velocity}) + elif msg.type == "note_off" or (msg.type == "note_on" and msg.velocity == 0): + notes.append({"pitch": msg.note, "end": current_time}) + elif msg.type == "control_change": + control_changes.append({"time": current_time, "value": msg.value, "number": msg.control}) + + return notes, control_changes + + def _create_dataframes(self, notes, control_changes): + self.raw_df = pd.DataFrame(notes) + self.control_frame = pd.DataFrame(control_changes) + + def _apply_sustain(self): + self.df = midi_tools.apply_sustain( + df=self.raw_df, + sustain=self.sustain, + sustain_threshold=self.sustain_threshold, + ) + def __getitem__(self, index: slice) -> MidiPiece: return self.piece[index] if not isinstance(index, slice): From 29110342872c054bc768b65edd2a7d550910c17f Mon Sep 17 00:00:00 2001 From: Samuel Janas Date: Wed, 22 Nov 2023 17:57:53 +0100 Subject: [PATCH 02/12] midi for testing --- test_midi.mid | Bin 0 -> 381 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 test_midi.mid diff --git a/test_midi.mid b/test_midi.mid new file mode 100644 index 0000000000000000000000000000000000000000..50f730fada25abf2257cfabf4a12b6fc0f9993e0 GIT binary patch literal 381 zcmXw!%}&BV6os#){Dipd##J}nbkVeF+K{FtG)hFMgIGsHTuH?E7bJkL8GM5KD1F0v zCWghyH|NgWd*=xGwj`n!bV5%;Za;B&rMh7~d#lCAdh6|ecHfKlFK_v>T2nnYO~dIN zdDw8!pQzx*;4m#!ct;!!uQ)z8RU``)KM?o&Q%*!j^~RT+UK=WrQ0SqTU5*qOxN6|F>h$apu0{U^G@i(d2M&%h5$cg>uAkhQQ@S#rFs-5O|8fB?1c* WfoGgO8VJ2t+3Z(V`Cc!f+NVFlHEeqT literal 0 HcmV?d00001 From 491bd6faa0a96198d1a5667de75a96054fa735fb Mon Sep 17 00:00:00 2001 From: Samuel Janas Date: Wed, 22 Nov 2023 18:50:17 +0100 Subject: [PATCH 03/12] container classes from prettyMIDI --- fortepyan/midi/structures.py | 149 +++++++++++++++++++++++++---------- 1 file changed, 106 insertions(+), 43 deletions(-) diff --git a/fortepyan/midi/structures.py b/fortepyan/midi/structures.py index 5dd1af7..dcc30ba 100644 --- a/fortepyan/midi/structures.py +++ b/fortepyan/midi/structures.py @@ -1,7 +1,6 @@ from warnings import showwarning from dataclasses import field, dataclass -import mido import numpy as np import pretty_midi import pandas as pd @@ -397,7 +396,7 @@ class MidiFile: raw_df: pd.DataFrame = field(init=False) sustain: pd.DataFrame = field(init=False) control_frame: pd.DataFrame = field(init=False, repr=False) - _midi_data: mido.MidiFile = field(init=False, repr=False) + _midi: pretty_midi.PrettyMIDI = field(init=False, repr=False) def __rich_repr__(self): yield "MidiFile" @@ -407,9 +406,8 @@ def __rich_repr__(self): yield "minutes", round(self.duration / 60, 2) @property - def duration(self): - # Calculate the total duration of the MIDI file - return max(msg.time for track in self._midi_data.tracks for msg in track if hasattr(msg, "time")) + def duration(self) -> float: + return self._midi.get_end_time() @property def notes(self): @@ -425,53 +423,42 @@ def control_changes(self): return ccs def __post_init__(self): - # Load the MIDI file - self._midi_data = mido.MidiFile(self.path) - - # Extract notes and control changes - notes, control_changes = self._extract_notes_and_controls() - - # Create dataframes for notes and control changes - self._create_dataframes(notes, control_changes) + # Read the MIDI object + self._midi = pretty_midi.PrettyMIDI(self.path) + + # Extract CC data + self.control_frame = pd.DataFrame( + { + "time": [cc.time for cc in self.control_changes], + "value": [cc.value for cc in self.control_changes], + "number": [cc.number for cc in self.control_changes], + } + ) # Sustain CC is 64 ids = self.control_frame.number == 64 self.sustain = self.control_frame[ids].reset_index(drop=True) - # Apply sustain if needed + # Extract notes + raw_df = pd.DataFrame( + { + "pitch": [note.pitch for note in self.notes], + "velocity": [note.velocity for note in self.notes], + "start": [note.start for note in self.notes], + "end": [note.end for note in self.notes], + } + ) + self.raw_df = raw_df.sort_values("start", ignore_index=True) + if self.apply_sustain: - self.df = self._apply_sustain() + self.df = midi_tools.apply_sustain( + df=self.raw_df, + sustain=self.sustain, + sustain_threshold=self.sustain_threshold, + ) else: self.df = self.raw_df - def _extract_notes_and_controls(self): - notes = [] - control_changes = [] - current_time = 0 - - for track in self._midi_data.tracks: - for msg in track: - current_time += msg.time - if msg.type == "note_on" and msg.velocity > 0: - notes.append({"pitch": msg.note, "start": current_time, "velocity": msg.velocity}) - elif msg.type == "note_off" or (msg.type == "note_on" and msg.velocity == 0): - notes.append({"pitch": msg.note, "end": current_time}) - elif msg.type == "control_change": - control_changes.append({"time": current_time, "value": msg.value, "number": msg.control}) - - return notes, control_changes - - def _create_dataframes(self, notes, control_changes): - self.raw_df = pd.DataFrame(notes) - self.control_frame = pd.DataFrame(control_changes) - - def _apply_sustain(self): - self.df = midi_tools.apply_sustain( - df=self.raw_df, - sustain=self.sustain, - sustain_threshold=self.sustain_threshold, - ) - def __getitem__(self, index: slice) -> MidiPiece: return self.piece[index] if not isinstance(index, slice): @@ -510,3 +497,79 @@ def piece(self) -> MidiPiece: source=source, ) return out + + +# Container classes from PrettyMIDI +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.notes = [] + self.control_changes = [] + + +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. + + """ + + 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. + + """ + + 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) From 310955a4e33dac40f499cf380bbb321767c6f68f Mon Sep 17 00:00:00 2001 From: Samuel Janas Date: Wed, 22 Nov 2023 19:11:22 +0100 Subject: [PATCH 04/12] most of new init --- fortepyan/midi/structures.py | 92 ++++++++++++++++++++++++++++-------- 1 file changed, 72 insertions(+), 20 deletions(-) diff --git a/fortepyan/midi/structures.py b/fortepyan/midi/structures.py index dcc30ba..4b65046 100644 --- a/fortepyan/midi/structures.py +++ b/fortepyan/midi/structures.py @@ -1,12 +1,16 @@ from warnings import showwarning from dataclasses import field, dataclass +import mido import numpy as np import pretty_midi import pandas as pd from fortepyan.midi import tools as midi_tools +# The largest we'd ever expect a tick to be +MAX_TICK = 1e7 + @dataclass class MidiPiece: @@ -392,11 +396,13 @@ class MidiFile: path: str apply_sustain: bool = True sustain_threshold: int = 62 + resolution: int = field(init=False) 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) _midi: pretty_midi.PrettyMIDI = field(init=False, repr=False) + _instruments: list = field(init=False, repr=False) def __rich_repr__(self): yield "MidiFile" @@ -424,8 +430,49 @@ def control_changes(self): def __post_init__(self): # Read the MIDI object - self._midi = pretty_midi.PrettyMIDI(self.path) + midi_data = mido.MidiFile(filename=self.path) + self._midi = pretty_midi.PrettyMIDI(self.path) # TODO remove this + + # Convert tick values in midi_data to absolute, a useful thing. + 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 = max([max([e.time for e in t]) for t in midi_data.tracks]) + 1 + # 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(max_tick) + + # 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): + showwarning( + "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.", + RuntimeWarning, + "fortepyan", + lineno=1, + ) + # Populate the list of instruments + self._load_instruments(midi_data) + + # TODO: change according to the new structure + # MIDIFILE FUNCTIONS BELOW # Extract CC data self.control_frame = pd.DataFrame( { @@ -461,30 +508,30 @@ def __post_init__(self): 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") + # 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() + # 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 + # # 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 + # # Move the notes + # part.start -= first_sound + # part.end -= first_sound - source = { - "type": "MidiFile", - "path": self.path, - } - out = MidiPiece(df=part, source=source) + # source = { + # "type": "MidiFile", + # "path": self.path, + # } + # out = MidiPiece(df=part, source=source) - return out + # return out @property def piece(self) -> MidiPiece: @@ -530,6 +577,9 @@ class Note(object): 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): @@ -564,6 +614,8 @@ class ControlChange(object): 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): From 1b869e049d6979af74295df7e653a24d45672ef0 Mon Sep 17 00:00:00 2001 From: Samuel Janas Date: Wed, 22 Nov 2023 19:24:00 +0100 Subject: [PATCH 05/12] migrated functions --- fortepyan/midi/structures.py | 210 +++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) diff --git a/fortepyan/midi/structures.py b/fortepyan/midi/structures.py index 4b65046..c6c5265 100644 --- a/fortepyan/midi/structures.py +++ b/fortepyan/midi/structures.py @@ -1,3 +1,4 @@ +import collections from warnings import showwarning from dataclasses import field, dataclass @@ -533,6 +534,215 @@ def __getitem__(self, index: slice) -> MidiPiece: # 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 _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 = 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 = 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 = 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 = 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 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 + @property def piece(self) -> MidiPiece: source = { From 8ed343773a551aafa75c91b04f8909f7d3093812 Mon Sep 17 00:00:00 2001 From: Samuel Janas Date: Wed, 22 Nov 2023 19:27:53 +0100 Subject: [PATCH 06/12] working midi loading --- fortepyan/midi/structures.py | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/fortepyan/midi/structures.py b/fortepyan/midi/structures.py index c6c5265..bc79183 100644 --- a/fortepyan/midi/structures.py +++ b/fortepyan/midi/structures.py @@ -403,7 +403,7 @@ class MidiFile: sustain: pd.DataFrame = field(init=False) control_frame: pd.DataFrame = field(init=False, repr=False) _midi: pretty_midi.PrettyMIDI = field(init=False, repr=False) - _instruments: list = field(init=False, repr=False) + instruments: list = field(init=False, repr=False) def __rich_repr__(self): yield "MidiFile" @@ -414,25 +414,25 @@ def __rich_repr__(self): @property def duration(self) -> float: - return self._midi.get_end_time() + return self.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._midi.instruments], []) + notes = sum([inst.notes for inst in self.instruments], []) return notes @property def control_changes(self): # See the note for notes ^^ - ccs = sum([inst.control_changes for inst in self._midi.instruments], []) + ccs = sum([inst.control_changes for inst in self.instruments], []) return ccs def __post_init__(self): # Read the MIDI object midi_data = mido.MidiFile(filename=self.path) - self._midi = pretty_midi.PrettyMIDI(self.path) # TODO remove this + # self._midi = pretty_midi.PrettyMIDI(self.path) # TODO remove this # Convert tick values in midi_data to absolute, a useful thing. for track in midi_data.tracks: @@ -743,6 +743,29 @@ def get_tempo_changes(self): 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: source = { From e029e3b6153dcabcb791b97dc9b6840ea9fae0f6 Mon Sep 17 00:00:00 2001 From: Samuel Janas Date: Wed, 22 Nov 2023 19:29:33 +0100 Subject: [PATCH 07/12] remove _midi from MidiFile --- fortepyan/midi/structures.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/fortepyan/midi/structures.py b/fortepyan/midi/structures.py index bc79183..6d3b754 100644 --- a/fortepyan/midi/structures.py +++ b/fortepyan/midi/structures.py @@ -402,7 +402,6 @@ class MidiFile: raw_df: pd.DataFrame = field(init=False) sustain: pd.DataFrame = field(init=False) control_frame: pd.DataFrame = field(init=False, repr=False) - _midi: pretty_midi.PrettyMIDI = field(init=False, repr=False) instruments: list = field(init=False, repr=False) def __rich_repr__(self): @@ -432,7 +431,6 @@ def control_changes(self): def __post_init__(self): # Read the MIDI object midi_data = mido.MidiFile(filename=self.path) - # self._midi = pretty_midi.PrettyMIDI(self.path) # TODO remove this # Convert tick values in midi_data to absolute, a useful thing. for track in midi_data.tracks: From 3eebeffc49028b02298851990a2fd227aaa51872 Mon Sep 17 00:00:00 2001 From: Samuel Janas Date: Fri, 24 Nov 2023 10:05:16 +0100 Subject: [PATCH 08/12] change max_ticks --- fortepyan/midi/structures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fortepyan/midi/structures.py b/fortepyan/midi/structures.py index 6d3b754..44425f3 100644 --- a/fortepyan/midi/structures.py +++ b/fortepyan/midi/structures.py @@ -10,7 +10,7 @@ from fortepyan.midi import tools as midi_tools # The largest we'd ever expect a tick to be -MAX_TICK = 1e7 +MAX_TICK = 1e10 @dataclass From 0c0a1c16c7e469ef5bcb6f7e771f056e973cd36c Mon Sep 17 00:00:00 2001 From: Samuel Janas Date: Fri, 24 Nov 2023 11:01:47 +0100 Subject: [PATCH 09/12] Further MidiFile adaptation --- fortepyan/midi/structures.py | 323 +++++++++++++++++- tests/midi/test_structures.py | 85 ++++- .../resources/test_midi.mid | Bin 3 files changed, 404 insertions(+), 4 deletions(-) rename test_midi.mid => tests/resources/test_midi.mid (100%) diff --git a/fortepyan/midi/structures.py b/fortepyan/midi/structures.py index 44425f3..1bc4cde 100644 --- a/fortepyan/midi/structures.py +++ b/fortepyan/midi/structures.py @@ -1,4 +1,6 @@ +import re import collections +from heapq import merge from warnings import showwarning from dataclasses import field, dataclass @@ -403,6 +405,11 @@ class MidiFile: 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) def __rich_repr__(self): yield "MidiFile" @@ -455,6 +462,9 @@ def __post_init__(self): # Create list that maps ticks to time in seconds self._update_tick_to_time(max_tick) + # 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): @@ -470,9 +480,6 @@ def __post_init__(self): # Populate the list of instruments self._load_instruments(midi_data) - # TODO: change according to the new structure - # MIDIFILE FUNCTIONS BELOW - # Extract CC data self.control_frame = pd.DataFrame( { "time": [cc.time for cc in self.control_changes], @@ -566,6 +573,57 @@ def _load_tempo_changes(self, midi_data): 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 = 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 = 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(Lyric(event.text, self.__tick_to_time[event.time])) + elif event.type == "text": + text_events.append(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 @@ -727,6 +785,33 @@ def __get_instrument(program, channel, track, create_new): # 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. @@ -798,6 +883,23 @@ def __init__(self, program, is_drum=False, name=""): 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. @@ -856,3 +958,218 @@ def __init__(self, number, value, 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. + + Examples + -------- + 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) diff --git a/tests/midi/test_structures.py b/tests/midi/test_structures.py index 34ee5fc..0b924d8 100644 --- a/tests/midi/test_structures.py +++ b/tests/midi/test_structures.py @@ -1,7 +1,11 @@ import pytest +import numpy as np import pandas as pd -from fortepyan.midi.structures import MidiPiece +from fortepyan.midi.structures import MidiFile, MidiPiece + +# constants +TEST_MIDI_PATH = "tests/resources/test_midi.mid" # Define a single comprehensive fixture @@ -200,3 +204,82 @@ def test_add_does_not_modify_originals(sample_midi_piece): # Check that the original pieces have not been modified pd.testing.assert_frame_equal(sample_midi_piece.df, original_df1) pd.testing.assert_frame_equal(midi_piece2.df, original_df2) + + +# === Tests for MidiFile === +# TODO: fill tests with assertions based on test_midi.mid + + +def test_midi_file_initialization(): + """ + Test the initialization of the MidiFile class. + """ + midi_file = MidiFile(path=TEST_MIDI_PATH) + + assert midi_file.path == TEST_MIDI_PATH + assert midi_file.apply_sustain is True + assert midi_file.sustain_threshold == 62 + + +def test_midi_file_duration_property(): + """ + Test the 'duration' property. + """ + midi_file = MidiFile(path=TEST_MIDI_PATH) + assert isinstance(midi_file.duration, float) + + +def test_midi_file_notes_property(): + """ + Test the 'notes' property. + """ + midi_file = MidiFile(path=TEST_MIDI_PATH) + notes = midi_file.notes + assert isinstance(notes, list) + + +def test_midi_file_control_changes_property(): + """ + Test the 'control_changes' property. + """ + midi_file = MidiFile(path=TEST_MIDI_PATH) + ccs = midi_file.control_changes + assert isinstance(ccs, list) + + +@pytest.mark.parametrize( + "index, expected_type", + [ + (slice(0, 10), MidiPiece), + # Add more test cases + ], +) +def test_midi_file_getitem(index, expected_type): + """ + Test the '__getitem__' method. + """ + midi_file = MidiFile(path=TEST_MIDI_PATH) + result = midi_file[index] + 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(): + """ + Test the 'get_end_time' method. + """ + midi_file = MidiFile(path=TEST_MIDI_PATH) + end_time = midi_file.get_end_time() + assert isinstance(end_time, float) + + +# Add more tests for other methods and properties as needed diff --git a/test_midi.mid b/tests/resources/test_midi.mid similarity index 100% rename from test_midi.mid rename to tests/resources/test_midi.mid From 78d48c4f3350c090cbd398ef02449619e68f463b Mon Sep 17 00:00:00 2001 From: Samuel Janas Date: Fri, 24 Nov 2023 11:22:59 +0100 Subject: [PATCH 10/12] move containers and helpers elsewhere --- fortepyan/midi/containers.py | 302 +++++++++++++++++++++++++++++ fortepyan/midi/structures.py | 359 +++-------------------------------- 2 files changed, 329 insertions(+), 332 deletions(-) create mode 100644 fortepyan/midi/containers.py diff --git a/fortepyan/midi/containers.py b/fortepyan/midi/containers.py new file mode 100644 index 0000000..af8b904 --- /dev/null +++ b/fortepyan/midi/containers.py @@ -0,0 +1,302 @@ +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.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) diff --git a/fortepyan/midi/structures.py b/fortepyan/midi/structures.py index 1bc4cde..ccc7e48 100644 --- a/fortepyan/midi/structures.py +++ b/fortepyan/midi/structures.py @@ -1,4 +1,3 @@ -import re import collections from heapq import merge from warnings import showwarning @@ -10,6 +9,8 @@ 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 @@ -594,11 +595,11 @@ def _load_metadata(self, midi_data): for event in midi_data.tracks[0]: if event.type == "key_signature": - key_obj = KeySignature(key_name_to_key_number(event.key), self.__tick_to_time[event.time]) + 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 = TimeSignature(event.numerator, event.denominator, self.__tick_to_time[event.time]) + 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 @@ -611,9 +612,9 @@ def _load_metadata(self, midi_data): text_events = [] for event in track: if event.type == "lyrics": - lyrics.append(Lyric(event.text, self.__tick_to_time[event.time])) + lyrics.append(midi_containers.Lyric(event.text, self.__tick_to_time[event.time])) elif event.type == "text": - text_events.append(Text(event.text, self.__tick_to_time[event.time])) + text_events.append(midi_containers.Text(event.text, self.__tick_to_time[event.time])) if lyrics: tracks_with_lyrics.append(lyrics) @@ -690,7 +691,7 @@ def __get_instrument(program, channel, track, create_new): # If we are told to, create a new instrument and store it if create_new: is_drum = channel == 9 - instrument = Instrument(program, is_drum, track_name_map[track_idx]) + 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: @@ -704,7 +705,7 @@ def __get_instrument(program, channel, track, create_new): # instrument else: # Create a "straggler" instrument - instrument = Instrument(program, track_name_map[track_idx]) + 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 @@ -754,7 +755,7 @@ def __get_instrument(program, channel, track, create_new): start_time = self.__tick_to_time[start_tick] end_time = self.__tick_to_time[end_tick] # Create the note event - note = Note(velocity, event.note, start_time, end_time) + 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] @@ -774,7 +775,7 @@ def __get_instrument(program, channel, track, create_new): del last_note_on[key] # Store control changes elif event.type == "control_change": - control_change = ControlChange(event.control, event.value, self.__tick_to_time[event.time]) + 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 @@ -786,18 +787,17 @@ def __get_instrument(program, channel, track, create_new): 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 + """ + Converts from an absolute tick to time in seconds using ``self.__tick_to_time``. - Parameters - ---------- - tick : int - Absolute tick to convert. + Parameters: + tick (int): + Absolute tick to convert. - Returns - ------- - time : float - Time in seconds of tick. + Returns: + time (float): + Time in seconds of tick. """ # Check that the tick isn't too big @@ -827,13 +827,13 @@ def get_tempo_changes(self): return tempo_change_times, tempi def get_end_time(self): - """Returns the time of the end of the MIDI object (time of the last + """ + 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. + Returns: + end_time (float): + Time, in seconds, where this MIDI file ends. """ # Get end times from all instruments, and times of all meta-events @@ -862,314 +862,9 @@ def piece(self) -> MidiPiece: return out -# Container classes from PrettyMIDI -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.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. - - Examples - -------- - 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 f"MidiFile({self.path})" - 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) +def __str__(self): + return f"MidiFile({self.path})" From 48a85f1867dfbdbf604e270da415c5607410061e Mon Sep 17 00:00:00 2001 From: Samuel Janas Date: Fri, 24 Nov 2023 13:45:05 +0100 Subject: [PATCH 11/12] segmentize init, write to midi --- fortepyan/midi/containers.py | 21 +++ fortepyan/midi/structures.py | 284 ++++++++++++++++++++++++++++++++++- 2 files changed, 298 insertions(+), 7 deletions(-) diff --git a/fortepyan/midi/containers.py b/fortepyan/midi/containers.py index af8b904..add9e2c 100644 --- a/fortepyan/midi/containers.py +++ b/fortepyan/midi/containers.py @@ -18,6 +18,7 @@ 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 = [] @@ -300,3 +301,23 @@ def __repr__(self): 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 ccc7e48..6248307 100644 --- a/fortepyan/midi/structures.py +++ b/fortepyan/midi/structures.py @@ -1,3 +1,5 @@ +import math +import functools import collections from heapq import merge from warnings import showwarning @@ -374,6 +376,21 @@ def to_midi(self, instrument_name: str = "Acoustic Grand Piano") -> pretty_midi. return track + def to_midi_midi_file(self, instrument_name: str = "Acoustic Grand Piano"): + track = MidiFile() + program = pretty_midi.instrument_name_to_program(instrument_name) + instrument = midi_containers.Instrument(program=program, name=instrument_name) + + note_data = self.df[["velocity", "pitch", "start", "end"]].to_records(index=False) + + 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 + @classmethod def from_huggingface(cls, record: dict) -> "MidiPiece": df = pd.DataFrame(record["notes"]) @@ -397,10 +414,11 @@ def from_file(cls, path: str) -> "MidiPiece": @dataclass class MidiFile: - path: str + path: str = None apply_sustain: bool = True sustain_threshold: int = 62 - resolution: int = field(init=False) + 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) @@ -437,10 +455,31 @@ def control_changes(self): return ccs def __post_init__(self): - # Read the MIDI object + 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 tick values in midi_data to absolute, a useful thing. + # Convert to absolute ticks for track in midi_data.tracks: tick = 0 for event in track: @@ -454,14 +493,14 @@ def __post_init__(self): self._load_tempo_changes(midi_data) # Update the array which maps ticks to time - max_tick = max([max([e.time for e in t]) for t in midi_data.tracks]) + 1 + 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(max_tick) + self._update_tick_to_time(self.get_max_tick(midi_data)) # Load the metadata self._load_metadata(midi_data) @@ -488,7 +527,6 @@ def __post_init__(self): "number": [cc.number for cc in self.control_changes], } ) - # Sustain CC is 64 ids = self.control_frame.number == 64 self.sustain = self.control_frame[ids].reset_index(drop=True) @@ -513,6 +551,9 @@ def __post_init__(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 __getitem__(self, index: slice) -> MidiPiece: return self.piece[index] # if not isinstance(index, slice): @@ -861,6 +902,235 @@ def piece(self) -> MidiPiece: ) return out + def time_to_tick(self, time): + """ + Converts from a time in seconds to absolute tick using + `self._tick_scales`. + + Parameters: + time (float): + Time, in seconds. + + Returns: + tick (int) + Absolute tick corresponding to the supplied time. + + """ + # 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 + + def write(self, filename: str): + """ + Write the MIDI data out to a .mid file. + + Parameters: + filename (str): Path to write .mid file to. + + """ + + 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 + ) + ) + # 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, + ) + ) + # 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 to a file, + mid.save(filename=filename) + def __repr__(self): return f"MidiFile({self.path})" From ab39d5323e9d98967f90b4a668e8d878a86ad6a8 Mon Sep 17 00:00:00 2001 From: Samuel Janas Date: Sat, 25 Nov 2023 17:29:43 +0100 Subject: [PATCH 12/12] Dependency removed(?) --- fortepyan/audio/render.py | 9 ++--- fortepyan/demo/diffusion/main.py | 2 +- fortepyan/main.py | 4 ++- fortepyan/midi/structures.py | 46 ++------------------------ fortepyan/midi/tools.py | 26 +++++++++++++++ fortepyan/view/pianoroll/structures.py | 2 +- 6 files changed, 38 insertions(+), 51 deletions(-) diff --git a/fortepyan/audio/render.py b/fortepyan/audio/render.py index 467df06..d817b9c 100644 --- a/fortepyan/audio/render.py +++ b/fortepyan/audio/render.py @@ -1,20 +1,21 @@ import tempfile -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: pretty_midi.PrettyMIDI, wavpath: str): +def midi_to_wav(midi: structures.MidiFile, wavpath: str): # This will be deleted tmp_midi_path = tempfile.mkstemp(suffix=".mid")[1] # 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 = pretty_midi.ControlChange(64, 0, end_time) + pedal_off = midi_containers.ControlChange(64, 0, end_time) midi.instruments[0].control_changes.append(pedal_off) midi.write(tmp_midi_path) @@ -24,7 +25,7 @@ def midi_to_wav(midi: pretty_midi.PrettyMIDI, wavpath: str): synth.midi_to_audio(tmp_midi_path, wavpath) -def midi_to_mp3(midi: pretty_midi.PrettyMIDI, mp3_path: str = None): +def midi_to_mp3(midi: structures.MidiFile, mp3_path: str = None): # This will be deleted tmp_wav_path = tempfile.mkstemp(suffix=".wav")[1] midi_to_wav(midi=midi, wavpath=tmp_wav_path) diff --git a/fortepyan/demo/diffusion/main.py b/fortepyan/demo/diffusion/main.py index cf6f2cf..5c22cbf 100644 --- a/fortepyan/demo/diffusion/main.py +++ b/fortepyan/demo/diffusion/main.py @@ -5,8 +5,8 @@ from fortepyan.audio.render import midi_to_mp3 from fortepyan.midi.structures import MidiPiece -from fortepyan.animation import evolution as evolution_animation from fortepyan.demo.diffusion import process as diffusion_process +from fortepyan.view.animation import evolution as evolution_animation def merge_diffused_pieces(pieces: list[MidiPiece]) -> MidiPiece: diff --git a/fortepyan/main.py b/fortepyan/main.py index b576b2a..f88cc52 100644 --- a/fortepyan/main.py +++ b/fortepyan/main.py @@ -4,6 +4,8 @@ import matplotlib.patches as patches from matplotlib import pyplot as plt +from fortepyan.midi.tools import note_number_to_name + def process_midi_file(path: str): pm = pretty_midi.PrettyMIDI(path) @@ -46,7 +48,7 @@ def draw_histograms(pitches, white, black): ax.bar(black.keys(), black.values(), color="teal", edgecolor="k") x_ticks = np.arange(0, 128, 12, dtype=float) - pitch_labels = [f"{pretty_midi.note_number_to_name(it)}" for it in x_ticks] + pitch_labels = [f"{note_number_to_name(it)}" for it in x_ticks] ax.set_xticks(x_ticks) ax.set_xticklabels(pitch_labels) diff --git a/fortepyan/midi/structures.py b/fortepyan/midi/structures.py index 6248307..594e983 100644 --- a/fortepyan/midi/structures.py +++ b/fortepyan/midi/structures.py @@ -7,7 +7,6 @@ import mido import numpy as np -import pretty_midi import pandas as pd from fortepyan.midi import tools as midi_tools @@ -335,50 +334,9 @@ def df_with_end(self) -> pd.DataFrame: df["end"] = df.start + df.duration return df - def to_midi(self, instrument_name: str = "Acoustic Grand Piano") -> pretty_midi.PrettyMIDI: - """ - Converts the note data stored in this object into a MIDI track using the specified instrument. - - This function creates a MIDI track with notes defined by the object's data. It uses the pretty_midi library - to construct the track and the notes within it. The instrument used for the MIDI track can be specified, - and defaults to "Acoustic Grand Piano" if not provided. - - Args: - instrument_name (str, optional): - The name of the instrument to be used for the MIDI track. This should be a valid instrument name - that can be interpreted by the pretty_midi library. Defaults to "Acoustic Grand Piano". See the note below for more information. - - Returns: - pretty_midi.PrettyMIDI: - A PrettyMIDI object representing the MIDI track created from the note data. This object can be - further manipulated or directly written to a MIDI file. - - Examples: - >>> track = my_object.to_midi("Violin") - This would create a MIDI track using the notes in 'my_object' with a Violin instrument. - - Note: - - See [this wikipedia article](https://en.wikipedia.org/wiki/General_MIDI#Parameter_interpretations) for instrument names - """ - track = pretty_midi.PrettyMIDI() - program = pretty_midi.instrument_name_to_program(instrument_name) - 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 = 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 = pretty_midi.Note(velocity=int(velocity), pitch=int(pitch), start=start, end=end) - instrument.notes.append(note) - - track.instruments.append(instrument) - - return track - - def to_midi_midi_file(self, instrument_name: str = "Acoustic Grand Piano"): + def to_midi(self, instrument_name: str = "Piano"): track = MidiFile() - program = pretty_midi.instrument_name_to_program(instrument_name) + program = 0 # 0 is piano instrument = midi_containers.Instrument(program=program, name=instrument_name) note_data = self.df[["velocity", "pitch", "start", "end"]].to_records(index=False) diff --git a/fortepyan/midi/tools.py b/fortepyan/midi/tools.py index 457f2d5..6ccc3b8 100644 --- a/fortepyan/midi/tools.py +++ b/fortepyan/midi/tools.py @@ -1,3 +1,4 @@ +import numpy as np import pandas as pd @@ -67,3 +68,28 @@ def sustain_notes( df.loc[ids, "end"] = end_times return df + + +def note_number_to_name(note_number): + """ + Convert a MIDI note number to its name, in the format + ``'(note)(accidental)(octave number)'`` (e.g. ``'C#4'``). + + Parameters: + note_number (int): + MIDI note number. If not an int, it will be rounded. + + Returns: + note_name (str): + Name of the supplied MIDI note number. + + """ + + # Note names within one octave + semis = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] + + # Ensure the note is an int + note_number = int(np.round(note_number)) + + # Get the semitone and the octave, and concatenate to create the name + return semis[note_number % 12] + str(note_number // 12 - 1) diff --git a/fortepyan/view/pianoroll/structures.py b/fortepyan/view/pianoroll/structures.py index 27b1a6e..719b2dc 100644 --- a/fortepyan/view/pianoroll/structures.py +++ b/fortepyan/view/pianoroll/structures.py @@ -4,10 +4,10 @@ import matplotlib import numpy as np from cmcrameri import cm -from pretty_midi import note_number_to_name from matplotlib.colors import ListedColormap from fortepyan.midi.structures import MidiPiece +from fortepyan.midi.tools import note_number_to_name @dataclass