diff --git a/partitura/io/exportmusicxml.py b/partitura/io/exportmusicxml.py index 0f065fcd..cbe9e50a 100644 --- a/partitura/io/exportmusicxml.py +++ b/partitura/io/exportmusicxml.py @@ -227,9 +227,26 @@ 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.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.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.normal_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 dc56ae00..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: @@ -1484,16 +1487,49 @@ 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 = 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) + # 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 + + # 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_actual_type, tuplet_normal_type): + tuplet_actual_notes = None + tuplet_normal_notes = 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) + 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.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 d7ebd2f6..f5ff59f5 100644 --- a/partitura/score.py +++ b/partitura/score.py @@ -11,11 +11,13 @@ 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, INTERVALCLASSES, INTERVAL_TO_SEMITONES, + LABEL_DURS, ) import warnings, sys import numpy as np @@ -2401,12 +2403,24 @@ 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, + actual_type=None, + normal_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.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"]) @@ -2444,10 +2458,34 @@ def end_note(self, note): note.tuplet_stops.append(self) self._end_note = note + @property + 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 + """ + 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) + 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)).strip() + return " ".join((super().__str__(), start, end, n_actual, n_normal, t_actual, t_normal)).strip() class Repeat(TimedObject): diff --git a/tests/__init__.py b/tests/__init__.py index d052fbe2..91f1e7ed 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..72d44dd9 --- /dev/null +++ b/tests/data/musicxml/test_tuplet_attributes.musicxml @@ -0,0 +1,525 @@ + + + + + Partition sans titre + + + Compositeur / Arrangeur + + MuseScore 4.4.4 + 2025-01-22 + + + + + + + + + + Piano + Pno. + + Piano + keyboard.piano + + + + 1 + 1 + 78.7402 + 0 + + + + + + + 180 + + 0 + + + + G + 2 + + + + + C + 4 + + 60 + 1 + eighth + + 3 + 2 + + up + begin + + + + + + + D + 4 + + 60 + 1 + eighth + + 3 + 2 + + up + continue + + + + E + 4 + + 60 + 1 + eighth + + 3 + 2 + + up + end + + + + + + + C + 4 + + 72 + 1 + eighth + + 5 + 4 + eighth + + up + begin + + + + + + + D + 4 + + 72 + 1 + eighth + + 5 + 4 + eighth + + up + continue + + + + E + 4 + + 24 + 1 + 16th + + 15 + 8 + + up + continue + begin + + + + 3 + 16th + + + 2 + 16th + + + + + + + F + 4 + + 24 + 1 + 16th + + 15 + 8 + + up + continue + continue + + + + G + 4 + + 24 + 1 + 16th + + 15 + 8 + + up + continue + end + + + + + + + A + 4 + + 72 + 1 + eighth + + 5 + 4 + eighth + + up + continue + + + + B + 4 + + 72 + 1 + eighth + + 5 + 4 + eighth + + up + end + + + + + + + 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 + + + + + + + + + + + + C + 4 + + 135 + 1 + eighth + + 2 + 3 + + up + begin + + + + + + + D + 4 + + 135 + 1 + eighth + + 2 + 3 + + up + end + + + + + + + C + 4 + + 54 + 1 + eighth + + 5 + 3 + + up + begin + + + + + + + D + 4 + + 54 + 1 + eighth + + 5 + 3 + + up + continue + + + + E + 4 + + 54 + 1 + eighth + + 5 + 3 + + up + continue + + + + F + 4 + + 54 + 1 + eighth + + 5 + 3 + + up + continue + + + + G + 4 + + 54 + 1 + eighth + + 5 + 3 + + up + end + + + + + + light-heavy + + + + diff --git a/tests/test_xml.py b/tests/test_xml.py index ce904ff1..25203d05 100755 --- a/tests/test_xml.py +++ b/tests/test_xml.py @@ -7,10 +7,12 @@ import logging import unittest from tempfile import TemporaryFile +from fractions import Fraction from tests import ( MUSICXML_IMPORT_EXPORT_TESTFILES, MUSICXML_SCORE_OBJECT_TESTFILES, + MUSICXML_TUPLET_ATTRIBUTES_TESTFILES, MUSICXML_UNFOLD_TESTPAIRS, MUSICXML_UNFOLD_COMPLEX, MUSICXML_UNFOLD_VOLTA, @@ -219,6 +221,25 @@ 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)) + # Each tuple consists of (actual_notes, normal_notes, type, note_type, duration_multiplier) + 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)), + ] + 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 pstring1 = part1.pretty()