From 0a96b591560d48e9c45f441359f2bb980a4d72de Mon Sep 17 00:00:00 2001 From: leleogere Date: Fri, 17 Jan 2025 14:32:09 +0100 Subject: [PATCH 1/6] Add optional attributes to tuplets rythmic modifications for MusicXML import/export related --- partitura/io/exportmusicxml.py | 18 +- partitura/io/importmusicxml.py | 34 +- partitura/score.py | 15 +- tests/__init__.py | 4 + .../musicxml/test_tuplet_attributes.musicxml | 362 ++++++++++++++++++ tests/test_xml.py | 16 + 6 files changed, 443 insertions(+), 6 deletions(-) create mode 100644 tests/data/musicxml/test_tuplet_attributes.musicxml diff --git a/partitura/io/exportmusicxml.py b/partitura/io/exportmusicxml.py index 0f065fcd..f250972d 100644 --- a/partitura/io/exportmusicxml.py +++ b/partitura/io/exportmusicxml.py @@ -227,9 +227,21 @@ def make_note_el(note, dur, voice, counter, n_of_staves): else: del counter[tuplet_key] - notations.append( - etree.Element("tuplet", number="{}".format(number), type="start") - ) + tuplet_e = etree.Element("tuplet", number="{}".format(number), type="start") + if tuplet.actual_notes is not None and tuplet.normal_notes is not None and tuplet.type is not None: + # tuplet-actual tag + tuplet_actual_e = etree.SubElement(tuplet_e, "tuplet-actual") + tuplet_actual_notes_e = etree.SubElement(tuplet_actual_e, "tuplet-number") + tuplet_actual_notes_e.text = str(tuplet.actual_notes) + tuplet_actual_type_e = etree.SubElement(tuplet_actual_e, "tuplet-type") + tuplet_actual_type_e.text = str(tuplet.type) + # tuplet-normal tag + tuplet_normal_e = etree.SubElement(tuplet_e, "tuplet-normal") + tuplet_normal_notes_e = etree.SubElement(tuplet_normal_e, "tuplet-number") + tuplet_normal_notes_e.text = str(tuplet.normal_notes) + tuplet_normal_type_e = etree.SubElement(tuplet_normal_e, "tuplet-type") + tuplet_normal_type_e.text = str(tuplet.type) + notations.append(tuplet_e) if notations: notations_e = etree.SubElement(note_e, "notations") diff --git a/partitura/io/importmusicxml.py b/partitura/io/importmusicxml.py index 69b7a04a..3e2da002 100644 --- a/partitura/io/importmusicxml.py +++ b/partitura/io/importmusicxml.py @@ -1441,16 +1441,48 @@ def handle_tuplets(notations, ongoing, note): stop_tuplet_key = ("stop_tuplet", tuplet_number) if tuplet_type == "start": + # Get information about the tuplet if present in the XML + tuplet_actual = tuplet_e.find("tuplet-actual") + tuplet_normal = tuplet_e.find("tuplet-normal") + if tuplet_actual is not None and tuplet_normal is not None: + tuplet_actual_notes = int(tuplet_actual.find("tuplet-number").text) + tuplet_actual_type = tuplet_actual.find("tuplet-type").text + tuplet_normal_notes = int(tuplet_normal.find("tuplet-number").text) + tuplet_normal_type = tuplet_normal.find("tuplet-type").text + # Types should always be the same I think? + assert tuplet_actual_type == tuplet_normal_type, "Tuplet types are not the same" + tuplet_type = tuplet_actual_type + + # If no information, try to infer it from the note + elif ( + "actual_notes" in note.symbolic_duration + and "normal_notes" in note.symbolic_duration + and "type" in note.symbolic_duration + ): + tuplet_actual_notes = note.symbolic_duration["actual_notes"] + tuplet_normal_notes = note.symbolic_duration["normal_notes"] + tuplet_type = note.symbolic_duration["type"] + + # If no information is present in the XML or the note, then set to None + else: + tuplet_actual_notes = None + tuplet_normal_notes = None + tuplet_type = None + + # check if we have a stopped_tuplet in ongoing that corresponds to # this start tuplet = ongoing.pop(stop_tuplet_key, None) if tuplet is None: - tuplet = score.Tuplet(note) + tuplet = score.Tuplet(note, actual_notes=tuplet_actual_notes, normal_notes=tuplet_normal_notes, type=tuplet_type) ongoing[start_tuplet_key] = tuplet else: tuplet.start_note = note + tuplet.actual_notes = tuplet_actual_notes + tuplet.normal_notes = tuplet_normal_notes + tuplet.type = tuplet_type starting_tuplets.append(tuplet) diff --git a/partitura/score.py b/partitura/score.py index 49477288..76c3d685 100644 --- a/partitura/score.py +++ b/partitura/score.py @@ -11,6 +11,7 @@ from copy import copy, deepcopy from collections import defaultdict from collections.abc import Iterable +from fractions import Fraction from numbers import Number from partitura.utils.globals import ( MUSICAL_BEATS, @@ -2401,12 +2402,15 @@ class Tuplet(TimedObject): """ - def __init__(self, start_note=None, end_note=None): + def __init__(self, start_note=None, end_note=None, actual_notes=None, normal_notes=None, type=None): super().__init__() self._start_note = None self._end_note = None self.start_note = start_note self.end_note = end_note + self.actual_notes = actual_notes + self.normal_notes = normal_notes + self.type = type # maintain a list of attributes to update when cloning this instance self._ref_attrs.extend(["start_note", "end_note"]) @@ -2444,10 +2448,17 @@ def end_note(self, note): note.tuplet_stops.append(self) self._end_note = note + @property + def duration_multipler(self) -> Fraction: + return Fraction(self.normal_notes, self.actual_notes) + def __str__(self): + n_actual = "" if self.actual_notes is None else "actual_notes={}".format(self.actual_notes) + n_normal = "" if self.normal_notes is None else "normal_notes={}".format(self.normal_notes) + type_ = "" if self.type is None else "type={}".format(self.type) start = "" if self.start_note is None else "start={}".format(self.start_note.id) end = "" if self.end_note is None else "end={}".format(self.end_note.id) - return " ".join((super().__str__(), start, end)).strip() + return " ".join((super().__str__(), start, end, n_actual, n_normal, type_)).strip() class Repeat(TimedObject): diff --git a/tests/__init__.py b/tests/__init__.py index 8ce30bb0..f540bdd7 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -45,6 +45,10 @@ ) ] ] +MUSICXML_TUPLET_ATTRIBUTES_TESTFILES = [ + os.path.join(MUSICXML_PATH, fn) + for fn in ["test_tuplet_attributes.musicxml"] +] MUSICXML_UNFOLD_COMPLEX = [ ( diff --git a/tests/data/musicxml/test_tuplet_attributes.musicxml b/tests/data/musicxml/test_tuplet_attributes.musicxml new file mode 100644 index 00000000..1fe6f6e4 --- /dev/null +++ b/tests/data/musicxml/test_tuplet_attributes.musicxml @@ -0,0 +1,362 @@ + + + + + Partition sans titre + + + Compositeur / Arrangeur + + MuseScore 4.4.4 + 2025-01-17 + + + + + + + + + + Piano + Pno. + + Piano + keyboard.piano + + + + 1 + 1 + 78.7402 + 0 + + + + + + + 60 + + 0 + + + + G + 2 + + + + + C + 4 + + 20 + 1 + eighth + + 3 + 2 + + up + begin + + + + + + + E + 4 + + 20 + 1 + eighth + + 3 + 2 + + up + continue + + + + G + 4 + + 20 + 1 + eighth + + 3 + 2 + + up + end + + + + + + + C + 4 + + 24 + 1 + eighth + + 5 + 4 + + up + begin + + + + + + + D + 4 + + 24 + 1 + eighth + + 5 + 4 + + up + continue + + + + E + 4 + + 8 + 1 + 16th + + 15 + 8 + + up + continue + begin + + + + 3 + 16th + + + 2 + 16th + + + + + + + F + 4 + + 8 + 1 + 16th + + 15 + 8 + + up + continue + continue + + + + G + 4 + + 8 + 1 + 16th + + 15 + 8 + + up + continue + end + + + + + + + A + 4 + + 24 + 1 + eighth + + 5 + 4 + + up + continue + + + + B + 4 + + 24 + 1 + eighth + + 5 + 4 + + up + end + + + + + + + + + + + + C + 4 + + 45 + 1 + eighth + + 2 + 3 + + up + begin + + + + + + + G + 4 + + 45 + 1 + eighth + + 2 + 3 + + up + end + + + + + + + C + 4 + + 18 + 1 + eighth + + 5 + 3 + + up + begin + + + + + + + D + 4 + + 18 + 1 + eighth + + 5 + 3 + + up + continue + + + + E + 4 + + 18 + 1 + eighth + + 5 + 3 + + up + continue + + + + F + 4 + + 18 + 1 + eighth + + 5 + 3 + + up + continue + + + + G + 4 + + 18 + 1 + eighth + + 5 + 3 + + up + end + + + + + + light-heavy + + + + diff --git a/tests/test_xml.py b/tests/test_xml.py index 0de6da8d..332d1d7e 100755 --- a/tests/test_xml.py +++ b/tests/test_xml.py @@ -11,6 +11,7 @@ from tests import ( MUSICXML_IMPORT_EXPORT_TESTFILES, MUSICXML_SCORE_OBJECT_TESTFILES, + MUSICXML_TUPLET_ATTRIBUTES_TESTFILES, MUSICXML_UNFOLD_TESTPAIRS, MUSICXML_UNFOLD_COMPLEX, MUSICXML_UNFOLD_VOLTA, @@ -217,6 +218,21 @@ def test_stem_direction_import(self): part = load_musicxml(MUSICXML_IMPORT_EXPORT_TESTFILES[0])[0] self.assertEqual(part.notes_tied[0].stem_direction, "up") + def test_tuplet_attributes(self): + part = load_musicxml(MUSICXML_TUPLET_ATTRIBUTES_TESTFILES[0])[0] + tuplets = list(part.iter_all(cls=score.Tuplet)) + real_values = [ + (3, 2, "eighth"), + (5, 4, "eighth"), + (3, 2, "16th"), + (2, 3, "eighth"), + (5, 3, "eighth"), + ] + for tuplet, (actual, normal, note_type) in zip(tuplets, real_values): + self.assertEqual(tuplet.actual_notes, actual) + self.assertEqual(tuplet.normal_notes, normal) + self.assertEqual(tuplet.type, note_type) + def _pretty_export_import_pretty_test(self, part1): # pretty print the part pstring1 = part1.pretty() From 5b7c0111b5f1f69121caf15ad10a83667d46f9c7 Mon Sep 17 00:00:00 2001 From: leleogere Date: Mon, 20 Jan 2025 11:14:54 +0100 Subject: [PATCH 2/6] Fix typo and add documentation to duration_multiplier --- partitura/score.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/partitura/score.py b/partitura/score.py index 76c3d685..d374180e 100644 --- a/partitura/score.py +++ b/partitura/score.py @@ -2449,7 +2449,14 @@ def end_note(self, note): self._end_note = note @property - def duration_multipler(self) -> Fraction: + def duration_multiplier(self) -> Fraction: + """Ratio by which the durations are scaled with this tuplet, as a python Fraction object. + This property is similar to `.tupletMultiplier` in music21: + https://www.music21.org/music21docs/moduleReference/moduleDuration.html#music21.duration.Tuplet.tupletMultiplier + + For example, in a triplet of eighth notes, each eighth note would have a duration of + duration_multiplier * normal_eighth_duration = 2/3 * normal_eighth_duration + """ return Fraction(self.normal_notes, self.actual_notes) def __str__(self): From 856f68640efbf2308b1b6cbb57c92b0d6eae9146 Mon Sep 17 00:00:00 2001 From: leleogere Date: Mon, 20 Jan 2025 11:28:32 +0100 Subject: [PATCH 3/6] Use `get_value_from_tag` when parsing tag --- partitura/io/importmusicxml.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/partitura/io/importmusicxml.py b/partitura/io/importmusicxml.py index 3e2da002..7975e1fd 100644 --- a/partitura/io/importmusicxml.py +++ b/partitura/io/importmusicxml.py @@ -1445,10 +1445,10 @@ def handle_tuplets(notations, ongoing, note): tuplet_actual = tuplet_e.find("tuplet-actual") tuplet_normal = tuplet_e.find("tuplet-normal") if tuplet_actual is not None and tuplet_normal is not None: - tuplet_actual_notes = int(tuplet_actual.find("tuplet-number").text) - tuplet_actual_type = tuplet_actual.find("tuplet-type").text - tuplet_normal_notes = int(tuplet_normal.find("tuplet-number").text) - tuplet_normal_type = tuplet_normal.find("tuplet-type").text + tuplet_actual_notes = get_value_from_tag(tuplet_actual, "tuplet-number", int) + tuplet_actual_type = get_value_from_tag(tuplet_actual, "tuplet-type", str) + tuplet_normal_notes = get_value_from_tag(tuplet_normal, "tuplet-number", int) + tuplet_normal_type = get_value_from_tag(tuplet_normal, "tuplet-type", str) # Types should always be the same I think? assert tuplet_actual_type == tuplet_normal_type, "Tuplet types are not the same" tuplet_type = tuplet_actual_type From ab426ee43cb957b2a23ab17f8c5deb60a55b5fed Mon Sep 17 00:00:00 2001 From: leleogere Date: Mon, 20 Jan 2025 11:52:06 +0100 Subject: [PATCH 4/6] Documentation about why we want every single attribute filled or none at all --- partitura/io/importmusicxml.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/partitura/io/importmusicxml.py b/partitura/io/importmusicxml.py index 7975e1fd..0510afda 100644 --- a/partitura/io/importmusicxml.py +++ b/partitura/io/importmusicxml.py @@ -1452,19 +1452,15 @@ def handle_tuplets(notations, ongoing, note): # Types should always be the same I think? assert tuplet_actual_type == tuplet_normal_type, "Tuplet types are not the same" tuplet_type = tuplet_actual_type - # If no information, try to infer it from the note - elif ( - "actual_notes" in note.symbolic_duration - and "normal_notes" in note.symbolic_duration - and "type" in note.symbolic_duration - ): - tuplet_actual_notes = note.symbolic_duration["actual_notes"] - tuplet_normal_notes = note.symbolic_duration["normal_notes"] - tuplet_type = note.symbolic_duration["type"] - - # If no information is present in the XML or the note, then set to None else: + tuplet_actual_notes = note.symbolic_duration.get("actual_notes", None) + tuplet_normal_notes = note.symbolic_duration.get("normal_notes", None) + tuplet_type = note.symbolic_duration.get("type", None) + + # If anyone of the attributes is not set, we set them all to None as we can't really + # do anything useful with only partial information about the tuplet + if None in (tuplet_actual_notes, tuplet_normal_notes, tuplet_type): tuplet_actual_notes = None tuplet_normal_notes = None tuplet_type = None From cd0e30868306fc4ab8b71dab8d2484b1ccb03571 Mon Sep 17 00:00:00 2001 From: leleogere Date: Wed, 22 Jan 2025 16:58:05 +0100 Subject: [PATCH 5/6] Fix the tuplet attribute parser in case of actual-type != normal-type --- partitura/io/exportmusicxml.py | 11 +- partitura/io/importmusicxml.py | 23 +- partitura/score.py | 30 ++- .../musicxml/test_tuplet_attributes.musicxml | 219 +++++++++++++++--- tests/test_xml.py | 23 +- 5 files changed, 252 insertions(+), 54 deletions(-) diff --git a/partitura/io/exportmusicxml.py b/partitura/io/exportmusicxml.py index f250972d..cbe9e50a 100644 --- a/partitura/io/exportmusicxml.py +++ b/partitura/io/exportmusicxml.py @@ -228,19 +228,24 @@ def make_note_el(note, dur, voice, counter, n_of_staves): del counter[tuplet_key] tuplet_e = etree.Element("tuplet", number="{}".format(number), type="start") - if tuplet.actual_notes is not None and tuplet.normal_notes is not None and tuplet.type is not None: + if ( + tuplet.actual_notes is not None + and tuplet.normal_notes is not None + and tuplet.actual_type is not None + and tuplet.normal_type is not None + ): # tuplet-actual tag tuplet_actual_e = etree.SubElement(tuplet_e, "tuplet-actual") tuplet_actual_notes_e = etree.SubElement(tuplet_actual_e, "tuplet-number") tuplet_actual_notes_e.text = str(tuplet.actual_notes) tuplet_actual_type_e = etree.SubElement(tuplet_actual_e, "tuplet-type") - tuplet_actual_type_e.text = str(tuplet.type) + tuplet_actual_type_e.text = str(tuplet.actual_type) # tuplet-normal tag tuplet_normal_e = etree.SubElement(tuplet_e, "tuplet-normal") tuplet_normal_notes_e = etree.SubElement(tuplet_normal_e, "tuplet-number") tuplet_normal_notes_e.text = str(tuplet.normal_notes) tuplet_normal_type_e = etree.SubElement(tuplet_normal_e, "tuplet-type") - tuplet_normal_type_e.text = str(tuplet.type) + tuplet_normal_type_e.text = str(tuplet.normal_type) notations.append(tuplet_e) if notations: diff --git a/partitura/io/importmusicxml.py b/partitura/io/importmusicxml.py index 098faf13..2b480fd2 100644 --- a/partitura/io/importmusicxml.py +++ b/partitura/io/importmusicxml.py @@ -1492,36 +1492,41 @@ def handle_tuplets(notations, ongoing, note): tuplet_actual_type = get_value_from_tag(tuplet_actual, "tuplet-type", str) tuplet_normal_notes = get_value_from_tag(tuplet_normal, "tuplet-number", int) tuplet_normal_type = get_value_from_tag(tuplet_normal, "tuplet-type", str) - # Types should always be the same I think? - assert tuplet_actual_type == tuplet_normal_type, "Tuplet types are not the same" - tuplet_type = tuplet_actual_type # If no information, try to infer it from the note else: tuplet_actual_notes = note.symbolic_duration.get("actual_notes", None) tuplet_normal_notes = note.symbolic_duration.get("normal_notes", None) - tuplet_type = note.symbolic_duration.get("type", None) + tuplet_actual_type = note.symbolic_duration.get("type", None) + tuplet_normal_type = tuplet_actual_type # If anyone of the attributes is not set, we set them all to None as we can't really # do anything useful with only partial information about the tuplet - if None in (tuplet_actual_notes, tuplet_normal_notes, tuplet_type): + if None in (tuplet_actual_notes, tuplet_normal_notes, tuplet_actual_type, tuplet_normal_type): tuplet_actual_notes = None tuplet_normal_notes = None - tuplet_type = None - + tuplet_actual_type = None + tuplet_normal_type = None # check if we have a stopped_tuplet in ongoing that corresponds to # this start tuplet = ongoing.pop(stop_tuplet_key, None) if tuplet is None: - tuplet = score.Tuplet(note, actual_notes=tuplet_actual_notes, normal_notes=tuplet_normal_notes, type=tuplet_type) + tuplet = score.Tuplet( + note, + actual_notes=tuplet_actual_notes, + normal_notes=tuplet_normal_notes, + actual_type=tuplet_actual_type, + normal_type=tuplet_normal_type, + ) ongoing[start_tuplet_key] = tuplet else: tuplet.start_note = note tuplet.actual_notes = tuplet_actual_notes tuplet.normal_notes = tuplet_normal_notes - tuplet.type = tuplet_type + tuplet.actual_type = tuplet_actual_type + tuplet.normal_type = tuplet_normal_type starting_tuplets.append(tuplet) diff --git a/partitura/score.py b/partitura/score.py index d2b0b8d3..f5ff59f5 100644 --- a/partitura/score.py +++ b/partitura/score.py @@ -17,6 +17,7 @@ MUSICAL_BEATS, INTERVALCLASSES, INTERVAL_TO_SEMITONES, + LABEL_DURS, ) import warnings, sys import numpy as np @@ -2402,7 +2403,15 @@ class Tuplet(TimedObject): """ - def __init__(self, start_note=None, end_note=None, actual_notes=None, normal_notes=None, type=None): + def __init__( + self, + start_note=None, + end_note=None, + actual_notes=None, + normal_notes=None, + actual_type=None, + normal_type=None + ): super().__init__() self._start_note = None self._end_note = None @@ -2410,7 +2419,8 @@ def __init__(self, start_note=None, end_note=None, actual_notes=None, normal_not self.end_note = end_note self.actual_notes = actual_notes self.normal_notes = normal_notes - self.type = type + self.actual_type = actual_type + self.normal_type = normal_type # maintain a list of attributes to update when cloning this instance self._ref_attrs.extend(["start_note", "end_note"]) @@ -2457,15 +2467,25 @@ def duration_multiplier(self) -> Fraction: For example, in a triplet of eighth notes, each eighth note would have a duration of duration_multiplier * normal_eighth_duration = 2/3 * normal_eighth_duration """ - return Fraction(self.normal_notes, self.actual_notes) + if self.actual_type == self.normal_type: + return Fraction(self.normal_notes, self.actual_notes) + else: + # In that case, we need to convert the normal_type into the actual_type, therefore + # adapting normal_notes + actual_dur = Fraction(LABEL_DURS[self.actual_type]) + normal_dur = Fraction(LABEL_DURS[self.normal_type]) + return Fraction(self.normal_notes, self.actual_notes) * normal_dur / actual_dur + + def __str__(self): n_actual = "" if self.actual_notes is None else "actual_notes={}".format(self.actual_notes) n_normal = "" if self.normal_notes is None else "normal_notes={}".format(self.normal_notes) - type_ = "" if self.type is None else "type={}".format(self.type) + t_actual = "" if self.actual_type is None else "actual_type={}".format(self.actual_type) + t_normal = "" if self.normal_type is None else "normal_type={}".format(self.normal_type) start = "" if self.start_note is None else "start={}".format(self.start_note.id) end = "" if self.end_note is None else "end={}".format(self.end_note.id) - return " ".join((super().__str__(), start, end, n_actual, n_normal, type_)).strip() + return " ".join((super().__str__(), start, end, n_actual, n_normal, t_actual, t_normal)).strip() class Repeat(TimedObject): diff --git a/tests/data/musicxml/test_tuplet_attributes.musicxml b/tests/data/musicxml/test_tuplet_attributes.musicxml index 1fe6f6e4..72d44dd9 100644 --- a/tests/data/musicxml/test_tuplet_attributes.musicxml +++ b/tests/data/musicxml/test_tuplet_attributes.musicxml @@ -8,7 +8,7 @@ Compositeur / Arrangeur MuseScore 4.4.4 - 2025-01-17 + 2025-01-22 @@ -36,12 +36,12 @@ - 60 + 180 0 @@ -54,7 +54,7 @@ C 4 - 20 + 60 1 eighth @@ -64,15 +64,15 @@ up begin - + - E + D 4 - 20 + 60 1 eighth @@ -84,10 +84,10 @@ - G + E 4 - 20 + 60 1 eighth @@ -105,17 +105,18 @@ C 4 - 24 + 72 1 eighth 5 4 + eighth up begin - + @@ -123,12 +124,13 @@ D 4 - 24 + 72 1 eighth 5 4 + eighth up continue @@ -138,7 +140,7 @@ E 4 - 8 + 24 1 16th @@ -149,7 +151,7 @@ continue begin - + 3 16th @@ -166,7 +168,7 @@ F 4 - 8 + 24 1 16th @@ -182,7 +184,7 @@ G 4 - 8 + 24 1 16th @@ -201,12 +203,13 @@ A 4 - 24 + 72 1 eighth 5 4 + eighth up continue @@ -216,12 +219,13 @@ B 4 - 24 + 72 1 eighth 5 4 + eighth up end @@ -229,6 +233,165 @@ + + + C + 4 + + 40 + 1 + 16th + + 9 + 8 + + up + begin + begin + + + + 9 + 16th + + + 2 + quarter + + + + + + + D + 4 + + 40 + 1 + 16th + + 9 + 8 + + up + continue + continue + + + + E + 4 + + 40 + 1 + 16th + + 9 + 8 + + up + continue + continue + + + + F + 4 + + 40 + 1 + 16th + + 9 + 8 + + up + continue + continue + + + + G + 4 + + 40 + 1 + 16th + + 9 + 8 + + up + continue + continue + + + + A + 4 + + 40 + 1 + 16th + + 9 + 8 + + up + continue + continue + + + + B + 4 + + 40 + 1 + 16th + + 9 + 8 + + up + continue + continue + + + + C + 5 + + 40 + 1 + 16th + + 9 + 8 + + up + continue + continue + + + + D + 5 + + 40 + 1 + 16th + + 9 + 8 + + up + end + end + + + + @@ -242,7 +405,7 @@ C 4 - 45 + 135 1 eighth @@ -252,15 +415,15 @@ up begin - + - G + D 4 - 45 + 135 1 eighth @@ -278,7 +441,7 @@ C 4 - 18 + 54 1 eighth @@ -288,7 +451,7 @@ up begin - + @@ -296,7 +459,7 @@ D 4 - 18 + 54 1 eighth @@ -311,7 +474,7 @@ E 4 - 18 + 54 1 eighth @@ -326,7 +489,7 @@ F 4 - 18 + 54 1 eighth @@ -341,7 +504,7 @@ G 4 - 18 + 54 1 eighth diff --git a/tests/test_xml.py b/tests/test_xml.py index 92d62b04..25203d05 100755 --- a/tests/test_xml.py +++ b/tests/test_xml.py @@ -7,6 +7,7 @@ import logging import unittest from tempfile import TemporaryFile +from fractions import Fraction from tests import ( MUSICXML_IMPORT_EXPORT_TESTFILES, @@ -223,17 +224,21 @@ def test_stem_direction_import(self): def test_tuplet_attributes(self): part = load_musicxml(MUSICXML_TUPLET_ATTRIBUTES_TESTFILES[0])[0] tuplets = list(part.iter_all(cls=score.Tuplet)) + # Each tuple consists of (actual_notes, normal_notes, type, note_type, duration_multiplier) real_values = [ - (3, 2, "eighth"), - (5, 4, "eighth"), - (3, 2, "16th"), - (2, 3, "eighth"), - (5, 3, "eighth"), + (3, 2, "eighth", "eighth", Fraction(2, 3)), + (5, 4, "eighth", "eighth", Fraction(4, 5)), + (3, 2, "16th", "16th", Fraction(2, 3)), + (9, 2, "16th", "quarter", Fraction(8, 9)), + (2, 3, "eighth", "eighth", Fraction(3, 2)), + (5, 3, "eighth", "eighth", Fraction(3, 5)), ] - for tuplet, (actual, normal, note_type) in zip(tuplets, real_values): - self.assertEqual(tuplet.actual_notes, actual) - self.assertEqual(tuplet.normal_notes, normal) - self.assertEqual(tuplet.type, note_type) + for tuplet, (n_actual, n_normal, t_actual, t_normal, dur_mult) in zip(tuplets, real_values): + self.assertEqual(tuplet.actual_notes, n_actual) + self.assertEqual(tuplet.normal_notes, n_normal) + self.assertEqual(tuplet.actual_type, t_actual) + self.assertEqual(tuplet.normal_type, t_normal) + self.assertEqual(tuplet.duration_multiplier, dur_mult) def _pretty_export_import_pretty_test(self, part1): # pretty print the part From 72ed3778fed4f89b724f9047e6c62658bc61e11e Mon Sep 17 00:00:00 2001 From: melkisedeath Date: Thu, 23 Jan 2025 11:55:54 +0100 Subject: [PATCH 6/6] Fixed unrelated cadence parsing error. Found a problematic cadence parsing while parsing large corpora for testing. I incorporate the fix on this PR since it is very minor. --- partitura/io/importmusicxml.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/partitura/io/importmusicxml.py b/partitura/io/importmusicxml.py index 2b480fd2..65131b40 100644 --- a/partitura/io/importmusicxml.py +++ b/partitura/io/importmusicxml.py @@ -698,7 +698,10 @@ def _handle_harmony(e, position, part): text = e.find("function").text if text is not None: if "|" in text: - text, cadence_annotation = text.split("|") + text = text.split("|") + if len(text) > 2: + warnings.warn(f"Ignoring multiple cadence annotations {text[2:]}", stacklevel=2) + text, cadence_annotation = text[0], text[1] part.add(score.Cadence(cadence_annotation), position) part.add(score.RomanNumeral(text), position) elif e.find("kind") is not None and e.find("root") is not None: