Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 48 additions & 13 deletions ariautils/midi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (a<b<c, x,y>0):
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
(a<b<c, x,y>0):

[a, b+x], [b-y, c] -> [a, b-y], [b-y, c]

Expand All @@ -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

Expand Down
36 changes: 36 additions & 0 deletions tests/test_midi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading