From dd7859fd75b2b4018b7443304ec513ff261b864d Mon Sep 17 00:00:00 2001 From: huispaty Date: Tue, 4 Feb 2025 18:06:47 +0100 Subject: [PATCH 1/3] adjust timing of note events according to tempo changes --- partitura/io/importmidi.py | 49 +++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/partitura/io/importmidi.py b/partitura/io/importmidi.py index 50f8ad0f..d6ee1668 100644 --- a/partitura/io/importmidi.py +++ b/partitura/io/importmidi.py @@ -111,10 +111,12 @@ def load_performance_midi( # parts per quarter ppq = mid.ticks_per_beat # microseconds per quarter - mpq = 60 * (10**6 / default_bpm) + default_mpq = 60 * (10**6 / default_bpm) - # convert MIDI ticks in seconds - time_conversion_factor = mpq / (ppq * 10**6) + # Initialize tempo changes with the default tempo + tempo_changes = [(0, default_mpq)] + # Initialize time conversion factor + time_conversion_factor = default_mpq / (ppq * 10**6) pps = list() @@ -123,6 +125,7 @@ def load_performance_midi( tracks = [(0, mid_merge)] else: tracks = [(i, u) for i, u in enumerate(mid.tracks)] + for i, track in tracks: notes = [] controls = [] @@ -143,21 +146,15 @@ def load_performance_midi( sounding_notes = {} for msg in track: - # update time deltas when they arrive - t = t + msg.time * time_conversion_factor - ttick = ttick + msg.time + # Update time deltas + t += msg.time * time_conversion_factor + ttick += msg.time if isinstance(msg, mido.MetaMessage): - # Meta Messages apply to all channels in the track - - # The tempo is set globally in PerformedParts, - # i.e., the tempo_conversion_factor is adjusted - # with every tempo change, rather than creating new - # tempo events. if msg.type == "set_tempo": mpq = msg.tempo + tempo_changes.append((ttick, mpq)) time_conversion_factor = mpq / (ppq * 10**6) - elif msg.type == "time_signature": time_signatures.append( dict( @@ -287,11 +284,21 @@ def load_performance_midi( time_signatures=time_signatures, meta_other=meta_other, ppq=ppq, - mpq=mpq, + mpq=default_mpq, track=i, ) pps.append(pp) + + # adjust timing of events based on tempo changes + for pp in pps: + for note in pp.notes: + note["note_on"] = adjust_time(note["note_on_tick"], tempo_changes, ppq) + note["note_off"] = adjust_time(note["note_off_tick"], tempo_changes, ppq) + for control in pp.controls: + control["time"] = adjust_time(control["time_tick"], tempo_changes, ppq) + for program in pp.programs: + program["time"] = adjust_time(program["time_tick"], tempo_changes, ppq) perf = performance.Performance( id=doc_name, @@ -299,6 +306,20 @@ def load_performance_midi( ) return perf +def adjust_time(tick, tempo_changes, ppq): + """adjust the time of an event based on tempo changes.""" + time = 0 + last_tick = 0 + last_mpq = tempo_changes[0][1] + for change_tick, mpq in tempo_changes: + if tick < change_tick: + break + time += (change_tick - last_tick) * (last_mpq / (ppq * 10**6)) + last_tick = change_tick + last_mpq = mpq + time += (tick - last_tick) * (last_mpq / (ppq * 10**6)) + return time + @deprecated_parameter("ensure_list") @deprecated_alias(fn="filename") From f6f39b5486b6074d9209121a27d98225d9f65ddf Mon Sep 17 00:00:00 2001 From: huispaty Date: Tue, 4 Feb 2025 20:06:44 +0100 Subject: [PATCH 2/3] added test case --- tests/test_midi_import.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/tests/test_midi_import.py b/tests/test_midi_import.py index 9389a34b..9e4e4ed5 100644 --- a/tests/test_midi_import.py +++ b/tests/test_midi_import.py @@ -13,6 +13,7 @@ from partitura import load_score_midi from partitura.utils import partition import partitura.score as score +from partitura import load_performance_midi from tests import MIDIINPORT_TESTFILES LOGGER = logging.getLogger(__name__) @@ -318,4 +319,32 @@ def test_time_signature(self): self.assertEqual(score.note_array()["onset_beat"][2], 0.5) na = score.note_array(include_time_signature=True) self.assertTrue(all([n==3 for n in na["ts_beats"]])) - self.assertTrue(all([d==8 for d in na["ts_beat_type"]])) \ No newline at end of file + self.assertTrue(all([d==8 for d in na["ts_beat_type"]])) + +class TestLoadPerformanceMIDI(unittest.TestCase): + def setUp(self): + # create test MIDI file with tempo changes + self.midi_file = mido.MidiFile() + track = mido.MidiTrack() + self.midi_file.tracks.append(track) + + # set initial tempo (120 BPM) + track.append(mido.MetaMessage('set_tempo', tempo=mido.bpm2tempo(120), time=0)) + # add note + track.append(mido.Message('note_on', note=60, velocity=64, time=0)) + track.append(mido.Message('note_off', note=60, velocity=64, time=480)) + # change tempo to 60 BPM + track.append(mido.MetaMessage('set_tempo', tempo=mido.bpm2tempo(60), time=0)) + # add another note + track.append(mido.Message('note_on', note=62, velocity=64, time=0)) + track.append(mido.Message('note_off', note=62, velocity=64, time=480)) + + def test_adjust_time(self): + + performance = load_performance_midi(self.midi_file) + + notes = performance.performedparts[0].notes + self.assertAlmostEqual(notes[0]['note_on'], 0.0, places=6) + self.assertAlmostEqual(notes[0]['note_off'], 0.5, places=6) # 120 BPM -> 0.5 seconds + self.assertAlmostEqual(notes[1]['note_on'], 0.5, places=6) + self.assertAlmostEqual(notes[1]['note_off'], 1.5, places=6) # 60 BPM -> 1 second \ No newline at end of file From aef81a68efa4ae0844ff5b16845d56797f931a20 Mon Sep 17 00:00:00 2001 From: huispaty Date: Thu, 13 Feb 2025 15:08:15 +0100 Subject: [PATCH 3/3] added time shifts for key and time signatures, function annotations, moved to pt util function for converting ticks to seconds --- partitura/io/importmidi.py | 43 +++++++++++++++++++++++++++++--------- tests/test_midi_import.py | 33 +++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 12 deletions(-) diff --git a/partitura/io/importmidi.py b/partitura/io/importmidi.py index d6ee1668..82dcd90a 100644 --- a/partitura/io/importmidi.py +++ b/partitura/io/importmidi.py @@ -9,7 +9,6 @@ from typing import Union, Optional, List, Tuple, Dict import numpy as np - import mido import partitura.score as score @@ -23,8 +22,9 @@ deprecated_parameter, PathLike, get_document_name, - ensure_notearray, + ensure_notearray ) +from partitura.utils.music import midi_ticks_to_seconds import partitura.musicanalysis as analysis __all__ = ["load_score_midi", "load_performance_midi", "midi_to_notearray"] @@ -111,12 +111,12 @@ def load_performance_midi( # parts per quarter ppq = mid.ticks_per_beat # microseconds per quarter - default_mpq = 60 * (10**6 / default_bpm) - - # Initialize tempo changes with the default tempo - tempo_changes = [(0, default_mpq)] + default_mpq = int(60 * (10**6 / default_bpm)) # Initialize time conversion factor time_conversion_factor = default_mpq / (ppq * 10**6) + + # Initialize list of tempos + tempo_changes = [(0, default_mpq)] pps = list() @@ -153,7 +153,8 @@ def load_performance_midi( if isinstance(msg, mido.MetaMessage): if msg.type == "set_tempo": mpq = msg.tempo - tempo_changes.append((ttick, mpq)) + if tempo_changes[-1][1] != mpq: # only add new tempo if it's different from the last one + tempo_changes.append((ttick, mpq)) time_conversion_factor = mpq / (ppq * 10**6) elif msg.type == "time_signature": time_signatures.append( @@ -299,6 +300,12 @@ def load_performance_midi( control["time"] = adjust_time(control["time_tick"], tempo_changes, ppq) for program in pp.programs: program["time"] = adjust_time(program["time_tick"], tempo_changes, ppq) + for time_signature in pp.time_signatures: + time_signature["time"] = adjust_time(time_signature["time_tick"], tempo_changes, ppq) + for key_signature in pp.key_signatures: + key_signature["time"] = adjust_time(key_signature["time_tick"], tempo_changes, ppq) + for meta in pp.meta_other: + meta["time"] = adjust_time(meta["time_tick"], tempo_changes, ppq) perf = performance.Performance( id=doc_name, @@ -306,15 +313,31 @@ def load_performance_midi( ) return perf -def adjust_time(tick, tempo_changes, ppq): - """adjust the time of an event based on tempo changes.""" +def adjust_time(tick: int, tempo_changes: List[Tuple[int, int]], ppq: int) -> float: + """ + Adjust the time of an event based on tempo changes. + + Parameters + ---------- + tick : int + The tick position of the event. + tempo_changes : list of tuple[int, int] + A list of tuples where each tuple contains a tick position and the corresponding microseconds per quarter note (mpq). + ppq : int + Pulses (ticks) per quarter note. + + Returns + ---------- + float: The adjusted time of the event in seconds. + """ + time = 0 last_tick = 0 last_mpq = tempo_changes[0][1] for change_tick, mpq in tempo_changes: if tick < change_tick: break - time += (change_tick - last_tick) * (last_mpq / (ppq * 10**6)) + time += midi_ticks_to_seconds(midi_ticks=(change_tick - last_tick), mpq=last_mpq, ppq=ppq) last_tick = change_tick last_mpq = mpq time += (tick - last_tick) * (last_mpq / (ppq * 10**6)) diff --git a/tests/test_midi_import.py b/tests/test_midi_import.py index 9e4e4ed5..126b842f 100644 --- a/tests/test_midi_import.py +++ b/tests/test_midi_import.py @@ -323,18 +323,26 @@ def test_time_signature(self): class TestLoadPerformanceMIDI(unittest.TestCase): def setUp(self): - # create test MIDI file with tempo changes + # create test MIDI file with tempo changes, time signatures, and key signatures self.midi_file = mido.MidiFile() track = mido.MidiTrack() self.midi_file.tracks.append(track) # set initial tempo (120 BPM) track.append(mido.MetaMessage('set_tempo', tempo=mido.bpm2tempo(120), time=0)) + # set initial time signature (4/4) + track.append(mido.MetaMessage('time_signature', numerator=4, denominator=4, time=0)) + # set initial key signature (C major) + track.append(mido.MetaMessage('key_signature', key='C', time=0)) # add note track.append(mido.Message('note_on', note=60, velocity=64, time=0)) track.append(mido.Message('note_off', note=60, velocity=64, time=480)) # change tempo to 60 BPM track.append(mido.MetaMessage('set_tempo', tempo=mido.bpm2tempo(60), time=0)) + # change time signature to 3/4 + track.append(mido.MetaMessage('time_signature', numerator=3, denominator=4, time=0)) + # change key signature to G major + track.append(mido.MetaMessage('key_signature', key='G', time=0)) # add another note track.append(mido.Message('note_on', note=62, velocity=64, time=0)) track.append(mido.Message('note_off', note=62, velocity=64, time=480)) @@ -347,4 +355,25 @@ def test_adjust_time(self): self.assertAlmostEqual(notes[0]['note_on'], 0.0, places=6) self.assertAlmostEqual(notes[0]['note_off'], 0.5, places=6) # 120 BPM -> 0.5 seconds self.assertAlmostEqual(notes[1]['note_on'], 0.5, places=6) - self.assertAlmostEqual(notes[1]['note_off'], 1.5, places=6) # 60 BPM -> 1 second \ No newline at end of file + self.assertAlmostEqual(notes[1]['note_off'], 1.5, places=6) # 60 BPM -> 1 second + + def test_time_signature_and_key_signature(self): + + performance = load_performance_midi(self.midi_file).performedparts[0] + + time_signatures = performance.time_signatures + key_signatures = performance.key_signatures + + self.assertEqual(time_signatures[0]['beats'], 4) + self.assertEqual(time_signatures[0]['beat_type'], 4) + self.assertAlmostEqual(time_signatures[0]['time'], 0.0, places=6) + + self.assertEqual(time_signatures[1]['beats'], 3) + self.assertEqual(time_signatures[1]['beat_type'], 4) + self.assertAlmostEqual(time_signatures[1]['time'], 0.5, places=6) # 120 BPM -> 0.5 seconds + + self.assertEqual(key_signatures[0]['key_name'], 'C') + self.assertAlmostEqual(key_signatures[0]['time'], 0.0, places=6) + + self.assertEqual(key_signatures[1]['key_name'], 'G') + self.assertAlmostEqual(key_signatures[1]['time'], 0.5, places=6) # 120 BPM -> 0.5 seconds \ No newline at end of file