from pathlib import Path from tempfile import NamedTemporaryFile from suno_utils.audio import Audio from ..utils.s3 import _download_s3_file from ..web.harvest import get_filename from typing import Literal import numpy as np import tempfile import os import copy try: import pretty_midi except ImportError: print("pretty_midi not found") try: from midi_player import MIDIPlayer from midi_player.stylers import dark except ImportError: print("midi_player not found") try: from miditok import REMI, TokenizerConfig, MusicTokenizer except ImportError: print("miditok not found") try: import symusic except ImportError: print("symusic not found") class Midi: """ A wrapper around pretty_midi.PrettyMIDI that provides a more user-friendly interface. """ def __init__(self, pmidi=None): if pmidi is None: pmidi = pretty_midi.PrettyMIDI() assert isinstance(pmidi, pretty_midi.PrettyMIDI), "pmidi must be a pretty_midi.PrettyMIDI object" self.pmidi = pmidi self.pmidi.remove_invalid_notes() # remove unused instruments self.pmidi.instruments = [ instrument for instrument in self.pmidi.instruments if len(instrument.notes) > 0 ] @classmethod def from_path(cls, path: Path | str): if isinstance(path, Path): path = str(path) import warnings with warnings.catch_warnings(): warnings.simplefilter("ignore") return cls(pretty_midi.PrettyMIDI(path)) @classmethod def from_s3(cls, path: str): assert path.startswith("s3://") with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: tmp_filepath = os.path.join(temp_dir, get_filename(path, keep_extension=True)) _ = _download_s3_file(path, tmp_filepath, allow_errors=False) return cls.from_path(tmp_filepath) @staticmethod def load_tokenizer(path: str = "/app2/suno/victor/midi_tokenizer_v1.json"): if path: return REMI(params=path) config = TokenizerConfig( pitch_range=(0, 127), use_rests=False, use_programs=True, one_token_stream_for_programs=True, use_tempos=False, num_velocities=32, ) return REMI(config) def show(self, viz_type: Literal["piano-roll", "waterfall", "staff"] = "piano-roll"): with NamedTemporaryFile(suffix=".mid") as f: self.pmidi.write(f.name) mp = MIDIPlayer(f.name, 500, styler=dark, viz_type=viz_type) from IPython import display display.display(mp) def __len__(self): return self.num_notes def __add__(self, other: "Midi"): if not isinstance(other, Midi): return NotImplemented new_pmidi = pretty_midi.PrettyMIDI() new_pmidi.instruments.extend(copy.deepcopy(self.pmidi.instruments)) new_pmidi.instruments.extend(copy.deepcopy(other.pmidi.instruments)) return Midi(new_pmidi) def __radd__(self, other): if other == 0: return self return self.__add__(other) @property def num_instruments(self): return len(self.pmidi.instruments) @property def num_notes(self): return sum(len(instrument.notes) for instrument in self.pmidi.instruments) @property def all_notes(self): return [note for instrument in self.pmidi.instruments for note in instrument.notes] def duration_s(self) -> float: return self.pmidi.get_end_time() def to_symusic(self): """symusic is much faster than pretty_midi, but I can't get the code examples to work""" # write to a temp file with NamedTemporaryFile(suffix=".mid") as f: self.pmidi.write(f.name) return symusic.Score(f.name) def to_audio( self, sample_rate: int = 48000, sf_path: str | None = "/home/victor/neon/sunoData/src/sunodata/midi/soundfonts/FatBoy-v0.786.sf2", ): if sf_path is not None and not os.path.exists(sf_path): raise FileNotFoundError(f"Soundfont file not found: {sf_path}") audio = self.pmidi.fluidsynth(sample_rate, sf2_path=sf_path) return Audio.from_array_float(audio, sample_rate) # synth = symusic.Synthesizer(sample_rate=sample_rate, sf_path=sf_path) # # audio is a 2D numpy array of float32, [channels, time] # audio = synth.render(self.to_symusic(), stereo=False) # if audio.shape[-1] == 0: # return Audio.from_silence(1.0, sample_rate) # audio = Audio.from_array_float(audio, sample_rate, max_allowed_val=100) # return audio def make_stereo_comparison(self, audio: Audio, sf_path: str | None = None): # resample to 48k so its a supported format audio = audio.resample(48000) midi_audio = self.to_audio(sample_rate=audio.sample_rate, sf_path=sf_path) midi_array = midi_audio.stereo().normalize_volume().mono().array_float audio_array = audio.stereo().normalize_volume().mono().array_float max_len = max(len(midi_array), len(audio_array)) midi_array = np.pad(midi_array, (0, max_len - len(midi_array))) audio_array = np.pad(audio_array, (0, max_len - len(audio_array))) stereo_array = np.stack([midi_array, audio_array], axis=0) return Audio.from_array_float(stereo_array, audio.sample_rate) def write(self, path: Path | str): self.pmidi.write(path) @classmethod def from_scale(cls, n_notes: int, duration: float = 1.0): pmidi = pretty_midi.PrettyMIDI() instrument = pretty_midi.Instrument(program=0) for i in range(n_notes): note = pretty_midi.Note( pitch=60 + (i % 12), velocity=80, start=i * duration, end=(i + 1) * duration, ) instrument.notes.append(note) pmidi.instruments.append(instrument) return cls(pmidi) @property def program_ids(self): """Returns a list of program IDs for all instruments in the MIDI file.""" return [(int(instrument.program), instrument.is_drum) for instrument in self.pmidi.instruments] def filter_instruments(self, instrument_ids: list[int] | int): if isinstance(instrument_ids, int): instrument_ids = [instrument_ids] filtered_midi = pretty_midi.PrettyMIDI() filtered_midi.instruments = [ instrument for instrument in self.pmidi.instruments if instrument.program in instrument_ids ] return Midi(filtered_midi) def filter_instruments_by_name(self, instrument_names: list[str] | str): if isinstance(instrument_names, str): instrument_names = [instrument_names] filtered_midi = pretty_midi.PrettyMIDI() filtered_midi.instruments = [ instrument for instrument in self.pmidi.instruments if instrument.name.lower() in [name.lower() for name in instrument_names] ] return Midi(filtered_midi) def transpose(self, semitones: int): pmidi = pretty_midi.PrettyMIDI() instruments = copy.deepcopy(self.pmidi.instruments) for instrument in instruments: for note in instrument.notes: note.pitch += semitones pmidi.instruments.append(instrument) return Midi(pmidi) def shift_time(self, shift_time: float): pmidi = copy.deepcopy(self.pmidi) for instrument in pmidi.instruments: for note in instrument.notes: note.start += shift_time note.end += shift_time return Midi(pmidi).clean_pmidi() def apply_sustain_pedals(self): """ Extends notes based on sustain pedal events (CC 64). A note's duration will be extended if it ends while the sustain pedal is on. The note will be extended until the pedal is released or until a new note of the same pitch is played. Returns a new Midi object. """ new_pmidi = copy.deepcopy(self.pmidi) for instrument in new_pmidi.instruments: # Get sustain pedal control changes sustain_pedals = [cc for cc in instrument.control_changes if cc.number == 64] if not sustain_pedals: continue sustain_pedals.sort(key=lambda cc: cc.time) # Find sustain intervals sustain_intervals = [] pedal_on_time = None for cc in sustain_pedals: if cc.value >= 64: if pedal_on_time is None: pedal_on_time = cc.time elif pedal_on_time is not None: sustain_intervals.append((pedal_on_time, cc.time)) pedal_on_time = None # If pedal is still on at the end if pedal_on_time is not None: sustain_intervals.append((pedal_on_time, new_pmidi.get_end_time())) if not sustain_intervals: continue # Sort notes by start time to find the next note of the same pitch easily instrument.notes.sort(key=lambda n: n.start) # Extend notes for i, note in enumerate(instrument.notes): # Find which sustain interval the note end falls into for sustain_start, sustain_end in sustain_intervals: if sustain_start <= note.end < sustain_end: # This note is sustained. Find its new end time. new_end_time = sustain_end # Find the start time of the next note with the same pitch for next_note in instrument.notes[i + 1 :]: if next_note.pitch == note.pitch: # stop sustain at the start of the next note of same pitch new_end_time = min(new_end_time, next_note.start) break note.end = new_end_time break return Midi(new_pmidi) def clean_pmidi(self): pmidi = copy.deepcopy(self.pmidi) pmidi.remove_invalid_notes() # remove unused instruments pmidi.instruments = [instrument for instrument in pmidi.instruments if len(instrument.notes) > 0] return Midi(pmidi).merge_overlapping_notes() def merge_overlapping_notes(self): """ Merges overlapping notes of the same pitch for each instrument. Returns a new Midi object. """ new_pmidi = copy.deepcopy(self.pmidi) for instrument in new_pmidi.instruments: if len(instrument.notes) < 2: continue # Sort notes by pitch, then by start time instrument.notes.sort(key=lambda n: (n.pitch, n.start)) merged_notes = [] current_merge_note = copy.copy(instrument.notes[0]) for i in range(1, len(instrument.notes)): next_note = instrument.notes[i] # Check for same pitch and overlap if ( next_note.pitch == current_merge_note.pitch and next_note.start < current_merge_note.end ): # Merge: extend the end time and take max velocity current_merge_note.end = max(current_merge_note.end, next_note.end) current_merge_note.velocity = max(current_merge_note.velocity, next_note.velocity) else: # No merge, add the last merged note to the list merged_notes.append(current_merge_note) # Start a new merge candidate current_merge_note = copy.copy(next_note) # Add the last merge candidate merged_notes.append(current_merge_note) instrument.notes = merged_notes return Midi(new_pmidi) def consolidate_programs(self): instrument_map = {} for instrument in self.pmidi.instruments: # check if it exists in pmidi if (instrument.program, instrument.is_drum) in instrument_map: instrument_map[(instrument.program, instrument.is_drum)].notes.extend(instrument.notes) else: instrument_map[(instrument.program, instrument.is_drum)] = instrument pmidi = copy.deepcopy(self.pmidi) pmidi.instruments = list(instrument_map.values()) return Midi(pmidi) def to_tokens(self, tokenizer): """ Tokenizes the MIDI file using the Structured tokenizer from miditok. """ with NamedTemporaryFile(suffix=".mid") as f: # we simplify time to force bars to be 2s self.simplify_time().pmidi.write(f.name) tokens = tokenizer(f.name) return tokens @classmethod def from_tokens(cls, tokens: list[int], tokenizer): """ Creates a MIDI file from a list of tokens using the Structured tokenizer from miditok. """ if len(tokens) == 0: return cls(pretty_midi.PrettyMIDI()) with NamedTemporaryFile(suffix=".mid") as f: midi = tokenizer.decode(tokens) midi.dump_midi(f.name) return cls.from_path(f.name) def get_segment(self, start_time: float = 0, end_time: float | None = None): """ Returns a new MIDI object with only the notes between start_time and end_time. """ if end_time is None: end_time = self.duration_s() pmidi = pretty_midi.PrettyMIDI() instruments = copy.deepcopy(self.pmidi.instruments) for instrument in instruments: instrument.notes = [ note for note in instrument.notes if note.end >= start_time and note.start <= end_time ] for note in instrument.notes: note.start = max(note.start, start_time) - start_time note.end = min(note.end, end_time) - start_time pmidi.instruments.append(instrument) return Midi(pmidi).clean_pmidi() def filter_notes_by_onset(self, start_time: float = 0, end_time: float | None = None): if end_time is None: end_time = self.duration_s() pmidi = pretty_midi.PrettyMIDI() instruments = copy.deepcopy(self.pmidi.instruments) for instrument in instruments: instrument.notes = [ note for note in instrument.notes if note.start >= start_time and note.start <= end_time ] pmidi.instruments.append(instrument) return Midi(pmidi) def simplify_time(self): """Makes a new midi with the same notes but without time signature. Default is 120 bpm, 4/4.""" pmidi = pretty_midi.PrettyMIDI() pmidi.instruments = copy.deepcopy(self.pmidi.instruments) assert ( abs(pmidi.get_end_time() - self.duration_s()) < 1e-3 ), f"{pmidi.get_end_time()} != {self.duration_s()}" return Midi(pmidi)