diff --git a/partitura/io/exportmusicxml.py b/partitura/io/exportmusicxml.py index cbe9e50a..10c27d0c 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: @@ -240,12 +242,16 @@ 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") 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..9517970e 100644 --- a/partitura/io/importmusicxml.py +++ b/partitura/io/importmusicxml.py @@ -1273,6 +1273,17 @@ 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 + # 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: # this note starts at the same position as the previous note, and has @@ -1500,18 +1511,26 @@ 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 ) tuplet_normal_type = get_value_from_tag( tuplet_normal, "tuplet-type", str ) + tuplet_normal_dots = len(tuplet_normal.findall("tuplet-dot")) + # 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) + 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 @@ -1525,6 +1544,8 @@ 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 # this start @@ -1536,7 +1557,9 @@ 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, ) ongoing[start_tuplet_key] = tuplet @@ -1546,6 +1569,8 @@ 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 f9fcdec5..2a2fe696 100644 --- a/partitura/score.py +++ b/partitura/score.py @@ -2410,7 +2410,9 @@ def __init__( actual_notes=None, normal_notes=None, actual_type=None, + actual_dots=None, normal_type=None, + normal_dots=None, ): super().__init__() self._start_note = None @@ -2421,6 +2423,8 @@ 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"]) @@ -2467,13 +2471,20 @@ 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 self.actual_dots == 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.actual_dots): + actual_dur += actual_dur / 2 + 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 +2510,12 @@ def __str__(self): if self.normal_type is None else "normal_type={}".format(self.normal_type) ) + 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( 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..5d219b36 100755 --- a/tests/test_xml.py +++ b/tests/test_xml.py @@ -224,20 +224,27 @@ 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: + # (act_notes, norm_notes, act_type, norm_type, act_dots, norm_dots, dur_mult) + # 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, 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 ] - 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) + # fmt: on + 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_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):