diff --git a/partitura/io/importmatch.py b/partitura/io/importmatch.py index 04e96f8f..a7202ebd 100644 --- a/partitura/io/importmatch.py +++ b/partitura/io/importmatch.py @@ -371,29 +371,38 @@ def performed_part_from_match( # PerformedNote instances for all MatchNotes notes = [] - first_note = next(mf.iter_notes(), None) - if first_note and first_note_at_zero: - offset = midi_ticks_to_seconds(first_note.Onset, mpq=mpq, ppq=ppq) - offset_tick = first_note.Onset - else: - offset = 0 - offset_tick = 0 - - notes = [ - dict( - id=format_pnote_id(note.Id), - midi_pitch=note.MidiPitch, - note_on=midi_ticks_to_seconds(note.Onset, mpq, ppq) - offset, - note_off=midi_ticks_to_seconds(note.Offset, mpq, ppq) - offset, - note_on_tick=note.Onset - offset_tick, - note_off_tick=note.Offset - offset_tick, - sound_off=midi_ticks_to_seconds(note.Offset, mpq, ppq) - offset, - velocity=note.Velocity, - track=getattr(note, "Track", 0), - channel=getattr(note, "Channel", 1), + notes = list() + note_onsets_in_secs = np.array(np.zeros(len(mf.notes)), dtype=float) + note_onsets_in_tick = np.array(np.zeros(len(mf.notes)), dtype=int) + for i, note in enumerate(mf.notes): + n_onset_sec = midi_ticks_to_seconds(note.Onset, mpq, ppq) + note_onsets_in_secs[i] = n_onset_sec + note_onsets_in_tick[i] = note.Onset + notes.append( + dict( + id=format_pnote_id(note.Id), + midi_pitch=note.MidiPitch, + note_on=n_onset_sec, + note_off=midi_ticks_to_seconds(note.Offset, mpq, ppq), + note_on_tick=note.Onset, + note_off_tick=note.Offset, + sound_off=midi_ticks_to_seconds(note.Offset, mpq, ppq), + velocity=note.Velocity, + track=getattr(note, "Track", 0), + channel=getattr(note, "Channel", 1), + ) ) - for note in mf.notes - ] + # Set first note_on to zero in ticks and seconds if first_note_at_zero + if first_note_at_zero and len(note_onsets_in_secs) > 0: + offset = note_onsets_in_secs.min() + offset_tick = note_onsets_in_tick.min() + if offset > 0 and offset_tick > 0: + for note in notes: + note["note_on"] -= offset + note["note_off"] -= offset + note["sound_off"] -= offset + note["note_on_tick"] -= offset_tick + note["note_off_tick"] -= offset_tick # SustainPedal instances for sustain pedal lines sustain_pedal = [ diff --git a/partitura/performance.py b/partitura/performance.py index f7ac86dc..0b0979bb 100644 --- a/partitura/performance.py +++ b/partitura/performance.py @@ -85,7 +85,7 @@ def __init__( super().__init__() self.id = id self.part_name = part_name - self.notes = notes + self.notes = list(map(lambda n: PerformedNote(n), notes)) self.controls = controls or [] self.programs = programs or [] self.ppq = ppq @@ -203,7 +203,11 @@ def from_note_array( if "id" not in note_array.dtype.names: n_ids = ["n{0}".format(i) for i in range(len(note_array))] else: - n_ids = note_array["id"] + # Check if all ids are the same + if np.all(note_array["id"] == note_array["id"][0]): + n_ids = ["n{0}".format(i) for i in range(len(note_array))] + else: + n_ids = note_array["id"] if "track" not in note_array.dtype.names: tracks = np.zeros(len(note_array), dtype=int) @@ -280,6 +284,109 @@ def adjust_offsets_w_sustain( note["sound_off"] = offset +class PerformedNote(dict): + """ + A dictionary-like object representing a performed note. + + Parameters + ---------- + pnote_dict : dict + A dictionary containing performed note information. + This information can contain the following fields: + "id", "pitch", "note_on", "note_off", "velocity", "track", "channel", "sound_off". + If not provided, the default values will be used. + Pitch, note_on, and note_off are required. + """ + def __init__(self, pnote_dict): + super().__init__(pnote_dict) + self["id"] = self.get("id", None) + self["pitch"] = self.get("pitch", self["midi_pitch"]) + self["note_on"] = self.get("note_on", -1) + self["note_off"] = self.get("note_off", -1) + self["sound_off"] = self.get("sound_off", self["note_off"]) + self["track"] = self.get("track", 0) + self["channel"] = self.get("channel", 1) + self["velocity"] = self.get("velocity", 60) + self._validate_values() + self._accepted_keys = ["id", "pitch", "note_on", "note_off", "velocity", "track", "channel", "sound_off"] + self.__setitem__ = self._setitem_new + + def __str__(self): + return f"PerformedNote: {self['id']}" + + def __eq__(self, other): + if not isinstance(PerformedNote): + return False + if not self.keys() == other.keys(): + return False + return np.all(np.array([self[k] == other[k] for k in self.keys() if k in other.keys()])) + + def __hash__(self): + return hash(self["id"]) + + def __lt__(self, other): + return self["note_on"] < other["note_on"] + + def __le__(self, other): + return self["note_on"] <= other["note_on"] + + def __gt__(self, other): + return self["note_on"] > other["note_on"] + + def __ge__(self, other): + return self["note_on"] >= other["note_on"] + + def __getitem__(self, key): + return self.get(key, None) + + def _setitem_new(self, key, value): + if key not in self._accepted_keys: + raise KeyError(f"Key {key} not in PerformedNote") + elif key == "note_off": + # Verify that the note_off is after the note_on + if value < self["note_on"]: + raise ValueError(f"note_off must be after or equal to note_on") + self["sound_off"] = value if self["sound_off"] < value else self["sound_off"] + self["note_off"] = value + elif key == "note_on": + # Verify that the note_on is before the note_off + if value > self["note_off"]: + raise ValueError(f"note_on must be before or equal to note_off") + + self["duration_sec"] = self["note_off"] - value + self["note_on"] = value + elif key == "sound_off": + # Verify that the sound_off is after the note_off + if value <= self["note_off"]: + raise ValueError(f"sound_off must be after or equal to note_off") + self["sound_off"] = value + else: + self[key] = value + + def __delitem__(self, key): + raise KeyError("Cannot delete items from PerformedNote") + + def __iter__(self): + return iter(self.keys()) + + def __len__(self): + return len(self.keys()) + + def __contains__(self, key): + return key in self.keys() + + def _validate_values(self): + if self["pitch"] > 127 or self["pitch"] < 0: + raise ValueError(f"pitch must be between 0 and 127") + if self["note_on"] < 0: + raise ValueError(f"Note on value provided is invalid, must be greater than or equal to 0") + if self["note_off"] < 0 or self["note_off"] < self["note_on"]: + raise ValueError(f"Note off value provided is invalid, " + f"must be greater than or equal to 0 and greater or equal to note_on") + if self["velocity"] > 127 or self["velocity"] < 0: + raise ValueError(f"velocity must be between 0 and 127") + + class Performance(object): """Main object for representing a performance. diff --git a/partitura/utils/music.py b/partitura/utils/music.py index a1469e50..743db632 100644 --- a/partitura/utils/music.py +++ b/partitura/utils/music.py @@ -3252,18 +3252,17 @@ def slice_ppart_by_time( # create a new (empty) instance of a PerformedPart # single dummy note added to be able to set sustain_pedal_threshold in __init__ # -> check `adjust_offsets_w_sustain` in partitura.performance - ppart_slice = PerformedPart([{"note_on": 0, "note_off": 0}]) + # ppart_slice = PerformedPart([{"note_on": 0, "note_off": 0, "pitch": 0}]) # get ppq if PerformedPart contains it, # else skip time_tick info when e.g. created with 'load_performance_midi' try: ppq = ppart.ppq - ppart_slice.ppq = ppq except AttributeError: ppq = None + controls_slice = [] if ppart.controls: - controls_slice = [] for cc in ppart.controls: if cc["time"] >= start_time and cc["time"] <= end_time: new_cc = cc.copy() @@ -3271,10 +3270,9 @@ def slice_ppart_by_time( if ppq: new_cc["time_tick"] = int(2 * ppq * cc["time"]) controls_slice.append(new_cc) - ppart_slice.controls = controls_slice + programs_slice = [] if ppart.programs: - programs_slice = [] for pr in ppart.programs: if pr["time"] >= start_time and pr["time"] <= end_time: new_pr = pr.copy() @@ -3282,7 +3280,7 @@ def slice_ppart_by_time( if ppq: new_pr["time_tick"] = int(2 * ppq * pr["time"]) programs_slice.append(new_pr) - ppart_slice.programs = programs_slice + notes_slice = [] note_id = 0 @@ -3326,7 +3324,8 @@ def slice_ppart_by_time( else: break - ppart_slice.notes = notes_slice + # Create slice PerformedPart + ppart_slice = PerformedPart(notes=notes_slice, programs=programs_slice, controls=controls_slice, ppq=ppq) # set threshold property after creating notes list to update 'sound_offset' values ppart_slice.sustain_pedal_threshold = ppart.sustain_pedal_threshold