From 39fb45d48d4c28f37b87344fa17cfd3f64af62fb Mon Sep 17 00:00:00 2001 From: leleogere Date: Wed, 5 Feb 2025 17:29:27 +0100 Subject: [PATCH 1/6] Add support for tags `` and `` for tuplets --- partitura/io/exportmusicxml.py | 4 ++ partitura/io/importmusicxml.py | 25 +++++++- partitura/score.py | 8 ++- .../musicxml/test_tuplet_attributes.musicxml | 63 ++++++++++++------- tests/test_xml.py | 22 ++++--- 5 files changed, 89 insertions(+), 33 deletions(-) diff --git a/partitura/io/exportmusicxml.py b/partitura/io/exportmusicxml.py index cbe9e50a..28d740df 100644 --- a/partitura/io/exportmusicxml.py +++ b/partitura/io/exportmusicxml.py @@ -184,6 +184,8 @@ def make_note_el(note, dur, voice, counter, n_of_staves): actual_e.text = str(sym_dur["actual_notes"]) normal_e = etree.SubElement(time_mod_e, "normal-notes") normal_e.text = str(sym_dur["normal_notes"]) + for _ in range(sym_dur.get("normal_dots", 0)): + etree.SubElement(time_mod_e, "normal-dot") if note.staff is not None: if note.staff != 1 or n_of_staves > 1: @@ -246,6 +248,8 @@ def make_note_el(note, dur, voice, counter, n_of_staves): 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.normal_type) + for _ in range(tuplet.normal_dots): + etree.SubElement(tuplet_normal_e, "tuplet-dot") notations.append(tuplet_e) if notations: diff --git a/partitura/io/importmusicxml.py b/partitura/io/importmusicxml.py index c73c57ae..5d307344 100644 --- a/partitura/io/importmusicxml.py +++ b/partitura/io/importmusicxml.py @@ -1273,6 +1273,14 @@ def _handle_note(e, position, part, ongoing, prev_note, doc_order, prev_beam=Non if normal_notes: symbolic_duration["normal_notes"] = normal_notes + normal_type = get_value_from_tag(e, "time-modification/normal-type", str) + if normal_type: + symbolic_duration["normal_type"] = normal_type + + normal_dots = len(e.findall("time-modification/normal-dot")) + if normal_dots: + symbolic_duration["normal_dots"] = normal_dots + chord = e.find("chord") if chord is not None: # this note starts at the same position as the previous note, and has @@ -1506,12 +1514,22 @@ def handle_tuplets(notations, ongoing, note): tuplet_normal_type = get_value_from_tag( tuplet_normal, "tuplet-type", str ) + tuplet_normal_dots = len(tuplet_normal.findall("tuplet-dot")) + # While MusicXML theoretically accept a in , I have not + # been able to find a single example online where this happens, so I don't think + # that we need to support it (until proven wrong). However, it can happen in + # so we support it there. + # 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_actual_type = note.symbolic_duration.get("type", None) - tuplet_normal_type = tuplet_actual_type + tuplet_normal_type = note.symbolic_duration.get("normal_type", None) + # If normal_type is not available, it means that the note type is the tuplet type + if tuplet_normal_type is None: + tuplet_normal_type = note.symbolic_duration.get("type", None) + tuplet_actual_type = tuplet_normal_type + tuplet_normal_dots = note.symbolic_duration.get("normal_dots", 0) # 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 @@ -1525,6 +1543,7 @@ def handle_tuplets(notations, ongoing, note): tuplet_normal_notes = None tuplet_actual_type = None tuplet_normal_type = None + tuplet_normal_dots = None # check if we have a stopped_tuplet in ongoing that corresponds to # this start @@ -1537,6 +1556,7 @@ def handle_tuplets(notations, ongoing, note): normal_notes=tuplet_normal_notes, actual_type=tuplet_actual_type, normal_type=tuplet_normal_type, + normal_dots=tuplet_normal_dots, ) ongoing[start_tuplet_key] = tuplet @@ -1546,6 +1566,7 @@ def handle_tuplets(notations, ongoing, note): tuplet.normal_notes = tuplet_normal_notes tuplet.actual_type = tuplet_actual_type tuplet.normal_type = tuplet_normal_type + tuplet.normal_dots = tuplet_normal_dots starting_tuplets.append(tuplet) diff --git a/partitura/score.py b/partitura/score.py index f9fcdec5..7d2fccde 100644 --- a/partitura/score.py +++ b/partitura/score.py @@ -2411,6 +2411,7 @@ def __init__( normal_notes=None, actual_type=None, normal_type=None, + normal_dots=None, ): super().__init__() self._start_note = None @@ -2421,6 +2422,7 @@ def __init__( self.normal_notes = normal_notes self.actual_type = actual_type self.normal_type = normal_type + self.normal_dots = normal_dots # maintain a list of attributes to update when cloning this instance self._ref_attrs.extend(["start_note", "end_note"]) @@ -2467,13 +2469,15 @@ 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 """ - if self.actual_type == self.normal_type: + if self.actual_type == self.normal_type and not self.normal_dots: 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]) + for _ in range(self.normal_dots): + normal_dur += normal_dur / 2 return ( Fraction(self.normal_notes, self.actual_notes) * normal_dur / actual_dur ) @@ -2499,6 +2503,8 @@ def __str__(self): if self.normal_type is None else "normal_type={}".format(self.normal_type) ) + for _ in range(self.normal_dots): + t_normal += "." 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( diff --git a/tests/data/musicxml/test_tuplet_attributes.musicxml b/tests/data/musicxml/test_tuplet_attributes.musicxml index 72d44dd9..88688df6 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-22 + 2025-02-05 @@ -111,7 +111,6 @@ 5 4 - eighth up begin @@ -130,7 +129,6 @@ 5 4 - eighth up continue @@ -209,7 +207,6 @@ 5 4 - eighth up continue @@ -225,7 +222,6 @@ 5 4 - eighth up end @@ -396,7 +392,7 @@ @@ -446,12 +442,14 @@ eighth 5 - 3 + 2 + quarter + up begin - + @@ -464,55 +462,76 @@ eighth 5 - 3 + 2 + quarter + up - continue + end E 4 - 54 + 108 1 - eighth + quarter 5 - 3 + 2 + quarter + up - continue F 4 - 54 + 108 1 - eighth + quarter 5 - 3 + 2 + quarter + up - continue G 4 - 54 + 108 1 - eighth + quarter 5 - 3 + 2 + quarter + + + up + + + + A + 4 + + 108 + 1 + quarter + + 5 + 2 + quarter + up - end diff --git a/tests/test_xml.py b/tests/test_xml.py index 25203d05..ab28a694 100755 --- a/tests/test_xml.py +++ b/tests/test_xml.py @@ -224,20 +224,26 @@ 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) + # Each tuple consists of: + # (actual_notes, normal_notes, actual_type, normal_type, normal_dots, duration_multiplier) + # fmt: off real_values = [ - (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)), + (3, 2, "eighth", "eighth", 0, Fraction(2, 3)), # classic 3:2 eighth notes tuplet + (5, 4, "eighth", "eighth", 0, Fraction(4, 5)), # 5:4 eighth notes tuplet + (3, 2, "16th", "16th", 0, Fraction(2, 3)), # 3:2 16th notes tuplet + (9, 2, "16th", "quarter", 0, Fraction(8, 9)), # 9 16th notes against 2 quarter notes + (2, 3, "eighth", "eighth", 0, Fraction(3, 2)), # classic 2:3 duolet + (5, 2, "quarter", "quarter", 1, Fraction(3, 5)) # 5 quarter notes in the time of 2 dotted quarter notes ] - for tuplet, (n_actual, n_normal, t_actual, t_normal, dur_mult) in zip(tuplets, real_values): + # fmt: on + for tuplet, (n_actual, n_normal, t_actual, t_normal, d_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.normal_dots, d_normal) self.assertEqual(tuplet.duration_multiplier, dur_mult) def _pretty_export_import_pretty_test(self, part1): From f6452ab2e6fd972d6a4f2832a6961509fb927307 Mon Sep 17 00:00:00 2001 From: leleogere Date: Wed, 5 Feb 2025 17:50:05 +0100 Subject: [PATCH 2/6] Support `` in `` too --- partitura/io/exportmusicxml.py | 2 ++ partitura/io/importmusicxml.py | 12 ++++++++---- partitura/score.py | 11 ++++++++++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/partitura/io/exportmusicxml.py b/partitura/io/exportmusicxml.py index 28d740df..10c27d0c 100644 --- a/partitura/io/exportmusicxml.py +++ b/partitura/io/exportmusicxml.py @@ -242,6 +242,8 @@ def make_note_el(note, dur, voice, counter, n_of_staves): 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.actual_type) + for _ in range(tuplet.actual_dots): + etree.SubElement(tuplet_actual_e, "tuplet-dot") # tuplet-normal tag tuplet_normal_e = etree.SubElement(tuplet_e, "tuplet-normal") tuplet_normal_notes_e = etree.SubElement(tuplet_normal_e, "tuplet-number") diff --git a/partitura/io/importmusicxml.py b/partitura/io/importmusicxml.py index 5d307344..9517970e 100644 --- a/partitura/io/importmusicxml.py +++ b/partitura/io/importmusicxml.py @@ -1280,6 +1280,9 @@ def _handle_note(e, position, part, ongoing, prev_note, doc_order, prev_beam=Non normal_dots = len(e.findall("time-modification/normal-dot")) if normal_dots: symbolic_duration["normal_dots"] = normal_dots + # Note: MusicXML does not have "" elements in "" tags, but + # the actual type of a tuplet *can* have dots if specified in the tags (see related + # code in handle_tuplet function) chord = e.find("chord") if chord is not None: @@ -1508,6 +1511,7 @@ def handle_tuplets(notations, ongoing, note): tuplet_actual_type = get_value_from_tag( tuplet_actual, "tuplet-type", str ) + tuplet_actual_dots = len(tuplet_actual.findall("tuplet-dot")) tuplet_normal_notes = get_value_from_tag( tuplet_normal, "tuplet-number", int ) @@ -1515,10 +1519,6 @@ def handle_tuplets(notations, ongoing, note): tuplet_normal, "tuplet-type", str ) tuplet_normal_dots = len(tuplet_normal.findall("tuplet-dot")) - # While MusicXML theoretically accept a in , I have not - # been able to find a single example online where this happens, so I don't think - # that we need to support it (until proven wrong). However, it can happen in - # so we support it there. # If no information, try to infer it from the note else: @@ -1530,6 +1530,7 @@ def handle_tuplets(notations, ongoing, note): tuplet_normal_type = note.symbolic_duration.get("type", None) tuplet_actual_type = tuplet_normal_type tuplet_normal_dots = note.symbolic_duration.get("normal_dots", 0) + tuplet_actual_dots = 0 # see comment in _handle_note # 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 @@ -1543,6 +1544,7 @@ def handle_tuplets(notations, ongoing, note): tuplet_normal_notes = None tuplet_actual_type = None tuplet_normal_type = None + tuplet_actual_dots = None tuplet_normal_dots = None # check if we have a stopped_tuplet in ongoing that corresponds to @@ -1555,6 +1557,7 @@ def handle_tuplets(notations, ongoing, note): actual_notes=tuplet_actual_notes, normal_notes=tuplet_normal_notes, actual_type=tuplet_actual_type, + actual_dots=tuplet_actual_dots, normal_type=tuplet_normal_type, normal_dots=tuplet_normal_dots, ) @@ -1566,6 +1569,7 @@ def handle_tuplets(notations, ongoing, note): tuplet.normal_notes = tuplet_normal_notes tuplet.actual_type = tuplet_actual_type tuplet.normal_type = tuplet_normal_type + tuplet.actual_dots = tuplet_actual_dots tuplet.normal_dots = tuplet_normal_dots starting_tuplets.append(tuplet) diff --git a/partitura/score.py b/partitura/score.py index 7d2fccde..2bb0daaa 100644 --- a/partitura/score.py +++ b/partitura/score.py @@ -2410,6 +2410,7 @@ def __init__( actual_notes=None, normal_notes=None, actual_type=None, + actual_dots=None, normal_type=None, normal_dots=None, ): @@ -2422,6 +2423,7 @@ def __init__( self.normal_notes = normal_notes self.actual_type = actual_type self.normal_type = normal_type + self.actual_dots = actual_dots self.normal_dots = normal_dots # maintain a list of attributes to update when cloning this instance self._ref_attrs.extend(["start_note", "end_note"]) @@ -2469,13 +2471,18 @@ 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 """ - if self.actual_type == self.normal_type and not self.normal_dots: + if ( + self.actual_type == self.normal_type + and self.actual_notes == self.normal_notes + ): 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]) + for _ in range(self.actual_dots): + actual_dur += actual_dur / 2 for _ in range(self.normal_dots): normal_dur += normal_dur / 2 return ( @@ -2503,6 +2510,8 @@ def __str__(self): if self.normal_type is None else "normal_type={}".format(self.normal_type) ) + for _ in range(self.actual_dots): + t_actual += "." for _ in range(self.normal_dots): t_normal += "." start = "" if self.start_note is None else "start={}".format(self.start_note.id) From d93ef56a3d041284c0b1738f21c84b2c0b9f812c Mon Sep 17 00:00:00 2001 From: leleogere Date: Wed, 5 Feb 2025 17:56:47 +0100 Subject: [PATCH 3/6] Fix a bug in tuplet duration_multiplier --- partitura/score.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/partitura/score.py b/partitura/score.py index 2bb0daaa..6789ad0f 100644 --- a/partitura/score.py +++ b/partitura/score.py @@ -2473,7 +2473,7 @@ def duration_multiplier(self) -> Fraction: """ if ( self.actual_type == self.normal_type - and self.actual_notes == self.normal_notes + and self.actual_dots == self.normal_dots ): return Fraction(self.normal_notes, self.actual_notes) else: From b8d8936f6cc4fc78945292f9547aa5ef5ca353ec Mon Sep 17 00:00:00 2001 From: leleogere Date: Wed, 5 Feb 2025 17:58:04 +0100 Subject: [PATCH 4/6] Formatting --- tests/test_xml.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_xml.py b/tests/test_xml.py index ab28a694..6f5cf0b2 100755 --- a/tests/test_xml.py +++ b/tests/test_xml.py @@ -228,12 +228,12 @@ def test_tuplet_attributes(self): # (actual_notes, normal_notes, actual_type, normal_type, normal_dots, duration_multiplier) # fmt: off real_values = [ - (3, 2, "eighth", "eighth", 0, Fraction(2, 3)), # classic 3:2 eighth notes tuplet - (5, 4, "eighth", "eighth", 0, Fraction(4, 5)), # 5:4 eighth notes tuplet - (3, 2, "16th", "16th", 0, Fraction(2, 3)), # 3:2 16th notes tuplet - (9, 2, "16th", "quarter", 0, Fraction(8, 9)), # 9 16th notes against 2 quarter notes - (2, 3, "eighth", "eighth", 0, Fraction(3, 2)), # classic 2:3 duolet - (5, 2, "quarter", "quarter", 1, Fraction(3, 5)) # 5 quarter notes in the time of 2 dotted quarter notes + (3, 2, "eighth", "eighth", 0, Fraction(2, 3)), # classic 3:2 eighth notes tuplet + (5, 4, "eighth", "eighth", 0, Fraction(4, 5)), # 5:4 eighth notes tuplet + (3, 2, "16th", "16th", 0, Fraction(2, 3)), # 3:2 16th notes tuplet + (9, 2, "16th", "quarter", 0, Fraction(8, 9)), # 9 16th notes against 2 quarter notes + (2, 3, "eighth", "eighth", 0, Fraction(3, 2)), # classic 2:3 duolet + (5, 2, "quarter", "quarter", 1, Fraction(3, 5)), # 5 quarter notes in the time of 2 dotted quarter notes ] # fmt: on for tuplet, (n_actual, n_normal, t_actual, t_normal, d_normal, dur_mult) in zip( From 419b2e4b0700eba057d37b59f14297810a139741 Mon Sep 17 00:00:00 2001 From: leleogere Date: Wed, 5 Feb 2025 18:01:22 +0100 Subject: [PATCH 5/6] Fix tests --- tests/test_xml.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/tests/test_xml.py b/tests/test_xml.py index 6f5cf0b2..5d219b36 100755 --- a/tests/test_xml.py +++ b/tests/test_xml.py @@ -225,25 +225,26 @@ 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, actual_type, normal_type, normal_dots, duration_multiplier) + # (act_notes, norm_notes, act_type, norm_type, act_dots, norm_dots, dur_mult) # fmt: off real_values = [ - (3, 2, "eighth", "eighth", 0, Fraction(2, 3)), # classic 3:2 eighth notes tuplet - (5, 4, "eighth", "eighth", 0, Fraction(4, 5)), # 5:4 eighth notes tuplet - (3, 2, "16th", "16th", 0, Fraction(2, 3)), # 3:2 16th notes tuplet - (9, 2, "16th", "quarter", 0, Fraction(8, 9)), # 9 16th notes against 2 quarter notes - (2, 3, "eighth", "eighth", 0, Fraction(3, 2)), # classic 2:3 duolet - (5, 2, "quarter", "quarter", 1, Fraction(3, 5)), # 5 quarter notes in the time of 2 dotted quarter notes + (3, 2, "eighth", "eighth", 0, 0, Fraction(2, 3)), # classic 3:2 eighth notes tuplet + (5, 4, "eighth", "eighth", 0, 0, Fraction(4, 5)), # 5:4 eighth notes tuplet + (3, 2, "16th", "16th", 0, 0, Fraction(2, 3)), # 3:2 16th notes tuplet + (9, 2, "16th", "quarter", 0, 0, Fraction(8, 9)), # 9 16th notes against 2 quarter notes + (2, 3, "eighth", "eighth", 0, 0, Fraction(3, 2)), # classic 2:3 duolet + (5, 2, "quarter", "quarter", 0, 1, Fraction(3, 5)), # 5 quarter notes in the time of 2 dotted quarter notes ] # fmt: on - for tuplet, (n_actual, n_normal, t_actual, t_normal, d_normal, dur_mult) in zip( + for tuplet, (n_act, n_norm, t_act, t_norm, d_act, d_norm, 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.normal_dots, d_normal) + self.assertEqual(tuplet.actual_notes, n_act) + self.assertEqual(tuplet.normal_notes, n_norm) + self.assertEqual(tuplet.actual_type, t_act) + self.assertEqual(tuplet.normal_type, t_norm) + self.assertEqual(tuplet.actual_dots, d_act) + self.assertEqual(tuplet.normal_dots, d_norm) self.assertEqual(tuplet.duration_multiplier, dur_mult) def _pretty_export_import_pretty_test(self, part1): From 200548ffe7699be565eca63bc85538a1881db4c4 Mon Sep 17 00:00:00 2001 From: leleogere Date: Thu, 13 Feb 2025 16:43:23 +0100 Subject: [PATCH 6/6] Fix a bug when actual/normal_dots was None --- partitura/score.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/partitura/score.py b/partitura/score.py index 6789ad0f..2a2fe696 100644 --- a/partitura/score.py +++ b/partitura/score.py @@ -2510,10 +2510,12 @@ def __str__(self): if self.normal_type is None else "normal_type={}".format(self.normal_type) ) - for _ in range(self.actual_dots): - t_actual += "." - for _ in range(self.normal_dots): - t_normal += "." + if self.actual_dots: + for _ in range(self.actual_dots): + t_actual += "." + if self.normal_dots: + for _ in range(self.normal_dots): + t_normal += "." 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(