def merge_tracks( self, track_indices=None, mode="sum", program=0, is_drum=False, name="merged", remove_merged=False, ): """ Merge pianorolls of the tracks specified by `track_indices`. The merged track will have program number as given by `program` and drum indicator as given by `is_drum`. The merged track will be appended at the end of the track list. Parameters ---------- track_indices : list The indices of tracks to be merged. Defaults to all the tracks. mode : {'sum', 'max', 'any'} A string that indicates the merging strategy to apply along the track axis. Default to 'sum'. - In 'sum' mode, the merged pianoroll is the sum of the collected pianorolls. Note that for binarized pianorolls, integer summation is performed. - In 'max' mode, for each pixel, the maximum value among the collected pianorolls is assigned to the merged pianoroll. - In 'any' mode, the value of a pixel in the merged pianoroll is True if any of the collected pianorolls has nonzero value at that pixel; False if all the collected pianorolls are inactive (zero-valued) at that pixel. program: int A program number according to General MIDI specification [1]. Available values are 0 to 127. Defaults to 0 (Acoustic Grand Piano). is_drum : bool A boolean number that indicates whether it is a percussion track. Defaults to False. name : str A name to be assigned to the merged track. Defaults to 'merged'. remove_merged : bool True to remove the source tracks from the track list. False to keep them. Defaults to False. References ---------- [1] https://www.midi.org/specifications/item/gm-level-1-sound-set """ if mode not in ("max", "sum", "any"): raise ValueError("`mode` must be one of {'max', 'sum', 'any'}.") merged = self[track_indices].get_merged_pianoroll(mode) merged_track = Track(merged, program, is_drum, name) self.append_track(merged_track) if remove_merged: self.remove_tracks(track_indices)
def load(self, filename): """ Load a npz file. Supports only files previously saved by :meth:`pypianoroll.Multitrack.save`. Notes ----- Attribute values will all be overwritten. Parameters ---------- filename : str The name of the npz file to be loaded. """ def reconstruct_sparse(target_dict, name): """Return a reconstructed instance of `scipy.sparse.csc_matrix`.""" return csc_matrix( ( target_dict[name + "_csc_data"], target_dict[name + "_csc_indices"], target_dict[name + "_csc_indptr"], ), shape=target_dict[name + "_csc_shape"], ).toarray() with np.load(filename) as loaded: if "info.json" not in loaded: raise ValueError("Cannot find 'info.json' in the npz file.") info_dict = json.loads(loaded["info.json"].decode("utf-8")) self.name = info_dict["name"] self.beat_resolution = info_dict["beat_resolution"] self.tempo = loaded["tempo"] if "downbeat" in loaded.files: self.downbeat = loaded["downbeat"] else: self.downbeat = None idx = 0 self.tracks = [] while str(idx) in info_dict: pianoroll = reconstruct_sparse(loaded, "pianoroll_{}".format(idx)) track = Track( pianoroll, info_dict[str(idx)]["program"], info_dict[str(idx)]["is_drum"], info_dict[str(idx)]["name"], ) self.tracks.append(track) idx += 1 self.check_validity()
def append_track(self, track=None, pianoroll=None, program=0, is_drum=False, name='unknown'): """ Append a multitrack.Track instance to the track list or create a new multitrack.Track object and append it to the track list. Parameters ---------- track : pianoroll.Track A :class:`pypianoroll.Track` instance to be appended to the track list. pianoroll : np.ndarray, shape=(n_time_steps, 128) A pianoroll matrix. The first and second dimension represents time and pitch, respectively. Available datatypes are bool, int and float. Only effective when `track` is None. program: int A program number according to General MIDI specification [1]. Available values are 0 to 127. Defaults to 0 (Acoustic Grand Piano). Only effective when `track` is None. is_drum : bool A boolean number that indicates whether it is a percussion track. Defaults to False. Only effective when `track` is None. name : str The name of the track. Defaults to 'unknown'. Only effective when `track` is None. References ---------- [1] https://www.midi.org/specifications/item/gm-level-1-sound-set """ if track is not None: if not isinstance(track, Track): raise TypeError( "`track` must be a pypianoroll.Track instance.") track.check_validity() else: track = Track(pianoroll, program, is_drum, name) self.tracks.append(track)
def parse_pretty_midi(self, pm, mode='max', algorithm='normal', binarized=False, skip_empty_tracks=True, collect_onsets_only=False, threshold=0, first_beat_time=None): """ Parse a :class:`pretty_midi.PrettyMIDI` object. The data type of the resulting pianorolls is automatically determined (int if 'mode' is 'sum', np.uint8 if `mode` is 'max' and `binarized` is False, bool if `mode` is 'max' and `binarized` is True). Parameters ---------- pm : `pretty_midi.PrettyMIDI` object A :class:`pretty_midi.PrettyMIDI` object to be parsed. mode : {'max', 'sum'} A string that indicates the merging strategy to apply to duplicate notes. Default to 'max'. algorithm : {'normal', 'strict', 'custom'} A string that indicates the method used to get the location of the first beat. Notes before it will be dropped unless an incomplete beat before it is found (see Notes for more information). Defaults to 'normal'. - The 'normal' algorithm estimates the location of the first beat by :meth:`pretty_midi.PrettyMIDI.estimate_beat_start`. - The 'strict' algorithm sets the first beat at the event time of the first time signature change. Raise a ValueError if no time signature change event is found. - The 'custom' algorithm takes argument `first_beat_time` as the location of the first beat. binarized : bool True to binarize the parsed pianorolls before merging duplicate notes. False to use the original parsed pianorolls. Defaults to False. skip_empty_tracks : bool True to remove tracks with empty pianorolls and compress the pitch range of the parsed pianorolls. False to retain the empty tracks and use the original parsed pianorolls. Deafault to True. collect_onsets_only : bool True to collect only the onset of the notes (i.e. note on events) in all tracks, where the note off and duration information are dropped. False to parse regular pianorolls. threshold : int or float A threshold used to binarize the parsed pianorolls. Only effective when `binarized` is True. Defaults to zero. first_beat_time : float The location (in sec) of the first beat. Required and only effective when using 'custom' algorithm. Notes ----- If an incomplete beat before the first beat is found, an additional beat will be added before the (estimated) beat starting time. However, notes before the (estimated) beat starting time for more than one beat are dropped. """ algorithm = 'custom' first_beat_time = 0 if mode not in ('max', 'sum'): raise ValueError("`mode` must be one of {'max', 'sum'}.") if algorithm not in ('strict', 'normal', 'custom'): raise ValueError("`algorithm` must be one of {'normal', 'strict', " " 'custom'}.") if algorithm == 'custom': if not isinstance(first_beat_time, (int, float)): raise TypeError("`first_beat_time` must be int or float when " "using 'custom' algorithm.") if first_beat_time < 0.0: raise ValueError("`first_beat_time` must be a positive number " "when using 'custom' algorithm.") # Set first_beat_time for 'normal' and 'strict' modes if algorithm == 'normal': if pm.time_signature_changes: pm.time_signature_changes.sort(key=lambda x: x.time) first_beat_time = pm.time_signature_changes[0].time else: first_beat_time = pm.estimate_beat_start() elif algorithm == 'strict': if not pm.time_signature_changes: raise ValueError( "No time signature change event found. Unable " "to set beat start time using 'strict' " "algorithm.") pm.time_signature_changes.sort(key=lambda x: x.time) first_beat_time = pm.time_signature_changes[0].time # get tempo change event times and contents tc_times, tempi = pm.get_tempo_changes() arg_sorted = np.argsort(tc_times) tc_times = tc_times[arg_sorted] tempi = tempi[arg_sorted] beat_times = pm.get_beats(first_beat_time) if not len(beat_times): raise ValueError("Cannot get beat timings to quantize pianoroll.") beat_times.sort() n_beats = len(beat_times) n_time_steps = self.beat_resolution * n_beats # Parse downbeat array if not pm.time_signature_changes: self.downbeat = None else: self.downbeat = np.zeros((n_time_steps, ), bool) self.downbeat[0] = True start = 0 end = start for idx, tsc in enumerate(pm.time_signature_changes[:-1]): end += np.searchsorted(beat_times[end:], pm.time_signature_changes[idx + 1].time) start_idx = start * self.beat_resolution end_idx = end * self.beat_resolution stride = tsc.numerator * self.beat_resolution self.downbeat[start_idx:end_idx:stride] = True start = end # Build tempo array one_more_beat = 2 * beat_times[-1] - beat_times[-2] beat_times_one_more = np.append(beat_times, one_more_beat) bpm = 60. / np.diff(beat_times_one_more) self.tempo = np.tile(bpm, (1, 24)).reshape(-1, ) # Parse pianoroll self.tracks = [] for instrument in pm.instruments: if binarized: pianoroll = np.zeros((n_time_steps, 128), bool) elif mode == 'max': pianoroll = np.zeros((n_time_steps, 128), np.uint8) else: pianoroll = np.zeros((n_time_steps, 128), int) pitches = np.array([ note.pitch for note in instrument.notes if note.end > first_beat_time ]) note_on_times = np.array([ note.start for note in instrument.notes if note.end > first_beat_time ]) beat_indices = np.searchsorted(beat_times, note_on_times) - 1 remained = note_on_times - beat_times[beat_indices] ratios = remained / (beat_times_one_more[beat_indices + 1] - beat_times[beat_indices]) rounded = np.round((beat_indices + ratios) * self.beat_resolution) note_ons = rounded.astype(int) if collect_onsets_only: pianoroll[note_ons, pitches] = True elif instrument.is_drum: if binarized: pianoroll[note_ons, pitches] = True else: velocities = [ note.velocity for note in instrument.notes if note.end > first_beat_time ] pianoroll[note_ons, pitches] = velocities else: note_off_times = np.array([ note.end for note in instrument.notes if note.end > first_beat_time ]) beat_indices = np.searchsorted(beat_times, note_off_times) - 1 remained = note_off_times - beat_times[beat_indices] ratios = remained / (beat_times_one_more[beat_indices + 1] - beat_times[beat_indices]) note_offs = ((beat_indices + ratios) * self.beat_resolution).astype(int) for idx, start in enumerate(note_ons): end = note_offs[idx] velocity = instrument.notes[idx].velocity if velocity < 1: continue if binarized and velocity <= threshold: continue if start > 0 and start < n_time_steps: if pianoroll[start - 1, pitches[idx]]: pianoroll[start - 1, pitches[idx]] = 0 if end < n_time_steps - 1: if pianoroll[end, pitches[idx]]: end -= 1 if binarized: if mode == 'sum': pianoroll[start:end, pitches[idx]] += 1 elif mode == 'max': pianoroll[start:end, pitches[idx]] = True elif mode == 'sum': pianoroll[start:end, pitches[idx]] += velocity elif mode == 'max': maximum = np.maximum( pianoroll[start:end, pitches[idx]], velocity) pianoroll[start:end, pitches[idx]] = maximum if skip_empty_tracks and not np.any(pianoroll): continue track = Track(pianoroll, int(instrument.program), instrument.is_drum, instrument.name) self.tracks.append(track) self.check_validity()
def __init__(self, filename=None, tracks=None, tempo=120.0, downbeat=None, beat_resolution=24, name='unknown'): """ Initialize the object by one of the following ways: - assigning values for attributes - loading a npz file - parsing a MIDI file Notes ----- When `filename` is given, the arguments `tracks`, `tempo`, `downbeat` and `name` are ignored. Parameters ---------- filename : str The file path to the npz file to be loaded or the MIDI file (.mid, .midi, .MID, .MIDI) to be parsed. beat_resolution : int The number of time steps used to represent a beat. Will be assigned to `beat_resolution` when `filename` is not provided. Defaults to 24. tracks : list of :class:`pypianoroll.Track` objects The track object list to be assigned to `tracks` when `filename` is not provided. tempo : int or np.ndarray, shape=(n_time_steps,), dtype=float An array that indicates the tempo value (in bpm) at each time step. The length is the total number of time steps. Will be assigned to `tempo` when `filename` is not provided. downbeat : list or np.ndarray, shape=(n_time_steps,), dtype=bool An array that indicates whether the time step contains a downbeat (i.e., the first time step of a bar). The length is the total number of time steps. Will be assigned to `downbeat` when `filename` is not given. If a list of indices is provided, it will be viewed as the time step indices of the down beats and converted to a numpy array. Default is None. name : str The name of the multitrack pianoroll. Defaults to 'unknown'. """ # parse input file if filename is not None: if filename.endswith(('.mid', '.midi', '.MID', '.MIDI')): self.beat_resolution = beat_resolution self.name = name self.parse_midi(filename) elif filename.endswith('.npz'): self.load(filename) else: raise ValueError( "Unsupported file type received. Expect a npz " "or MIDI file.") else: if tracks is not None: self.tracks = tracks else: self.tracks = [Track()] if isinstance(tempo, (int, float)): self.tempo = np.array([tempo]) else: self.tempo = tempo if isinstance(downbeat, list): self.downbeat = np.zeros((max(downbeat) + 1, ), bool) self.downbeat[downbeat] = True else: self.downbeat = downbeat self.beat_resolution = beat_resolution self.name = name self.check_validity()