From 16ec2f95ea5c96e629473bf225616a61d4ff5cf8 Mon Sep 17 00:00:00 2001 From: Louis Date: Tue, 23 Jun 2026 19:18:54 +0000 Subject: [PATCH] fix dupe notes --- ariautils/midi.py | 61 ++++++++++++++++++++++++++++++++++++---------- tests/test_midi.py | 36 +++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/ariautils/midi.py b/ariautils/midi.py index a01c64e..517778b 100644 --- a/ariautils/midi.py +++ b/ariautils/midi.py @@ -280,12 +280,13 @@ def _build_pedal_intervals(self) -> dict[int, list[list[int]]]: return channel_to_pedal_intervals - # TODO: This function might not behave correctly when acting on degenerate - # MidiDict objects. def resolve_overlaps(self) -> "MidiDict": """Resolves any note overlaps (inplace) between notes with the same - pitch and channel. This is achieved by converting a pair of notes with - the same pitch (a0): + pitch and channel. Duplicate same-start notes are collapsed first so + overlap truncation cannot create zero-duration notes. + + This is achieved by converting a pair of notes with the same pitch + (a0): [a, b+x], [b-y, c] -> [a, b-y], [b-y, c] @@ -302,20 +303,54 @@ def resolve_overlaps(self) -> "MidiDict": _pitch = msg["data"]["pitch"] note_msgs_c[_channel][_pitch].append(msg) - # We can modify notes by reference as they are dictionaries + # We can modify notes by reference as they are dictionaries. + msg_ids_to_remove = set() for channel, msgs_by_pitch in note_msgs_c.items(): for pitch, msgs in msgs_by_pitch.items(): msgs.sort( key=lambda msg: (msg["data"]["start"], msg["data"]["end"]) ) - prev_off_tick = -1 - for idx, msg in enumerate(msgs): - on_tick = msg["data"]["start"] - off_tick = msg["data"]["end"] - if prev_off_tick > on_tick: - # Adjust end of previous (idx - 1) msg to remove overlap - msgs[idx - 1]["data"]["end"] = on_tick - prev_off_tick = off_tick + + deduped_msgs = [] + for msg in msgs: + if ( + deduped_msgs + and deduped_msgs[-1]["data"]["start"] + == msg["data"]["start"] + ): + prev_msg = deduped_msgs[-1] + prev_data = prev_msg["data"] + msg_data = msg["data"] + + if msg_data["end"] > prev_data["end"]: + prev_data["end"] = msg_data["end"] + prev_data["velocity"] = msg_data["velocity"] + elif msg_data["end"] == prev_data["end"]: + prev_data["velocity"] = max( + prev_data["velocity"], msg_data["velocity"] + ) + + msg_ids_to_remove.add(id(msg)) + continue + + deduped_msgs.append(msg) + + prev_msg = None + for msg in deduped_msgs: + if ( + prev_msg is not None + and prev_msg["data"]["end"] > msg["data"]["start"] + ): + # Adjust end of previous msg to remove overlap. + prev_msg["data"]["end"] = msg["data"]["start"] + prev_msg = msg + + self.note_msgs = [ + msg + for msg in self.note_msgs + if id(msg) not in msg_ids_to_remove + and msg["data"]["end"] > msg["data"]["start"] + ] return self diff --git a/tests/test_midi.py b/tests/test_midi.py index fbde482..f528521 100644 --- a/tests/test_midi.py +++ b/tests/test_midi.py @@ -211,6 +211,42 @@ def test_apply_pedal_threshold_buffer(self) -> None: [msg["data"] for msg in midi_dict.pedal_msgs], [1, 0, 1, 0] ) + def test_resolve_overlaps_does_not_create_zero_duration_notes( + self, + ) -> None: + note_data = [ + {"pitch": 60, "start": 100, "end": 110, "velocity": 64}, + {"pitch": 60, "start": 100, "end": 130, "velocity": 80}, + ] + midi_dict = MidiDict.from_msg_dict( + { + "meta_msgs": [], + "tempo_msgs": [], + "pedal_msgs": [], + "instrument_msgs": [], + "note_msgs": [ + { + "type": "note", + "data": note, + "tick": note["start"], + "channel": 0, + } + for note in note_data + ], + "ticks_per_beat": 480, + "metadata": {}, + } + ) + + result = midi_dict.resolve_overlaps() + + self.assertIs(result, midi_dict) + self.assertEqual(len(midi_dict.note_msgs), 1) + self.assertEqual( + midi_dict.note_msgs[0]["data"], + {"pitch": 60, "start": 100, "end": 130, "velocity": 80}, + ) + def test_resolve_pedal(self) -> None: load_path = TEST_DATA_DIRECTORY.joinpath("arabesque.mid") save_path = RESULTS_DATA_DIRECTORY.joinpath(