Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for <tuplet-dot/> and <normal-dot/> tags when parsing tuplets #429

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions partitura/io/exportmusicxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
29 changes: 27 additions & 2 deletions partitura/io/importmusicxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<actual_dot>" elements in "<time-modification>" tags, but
# the actual type of a tuplet *can* have dots if specified in the <tuplet> 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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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)

Expand Down
19 changes: 18 additions & 1 deletion partitura/score.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"])

Expand Down Expand Up @@ -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
)
Expand All @@ -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(
Expand Down
63 changes: 41 additions & 22 deletions tests/data/musicxml/test_tuplet_attributes.musicxml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<creator type="composer">Compositeur / Arrangeur</creator>
<encoding>
<software>MuseScore 4.4.4</software>
<encoding-date>2025-01-22</encoding-date>
<encoding-date>2025-02-05</encoding-date>
<supports element="accidental" type="yes"/>
<supports element="beam" type="yes"/>
<supports element="print" attribute="new-page" type="no"/>
Expand Down Expand Up @@ -111,7 +111,6 @@
<time-modification>
<actual-notes>5</actual-notes>
<normal-notes>4</normal-notes>
<normal-type>eighth</normal-type>
</time-modification>
<stem>up</stem>
<beam number="1">begin</beam>
Expand All @@ -130,7 +129,6 @@
<time-modification>
<actual-notes>5</actual-notes>
<normal-notes>4</normal-notes>
<normal-type>eighth</normal-type>
</time-modification>
<stem>up</stem>
<beam number="1">continue</beam>
Expand Down Expand Up @@ -209,7 +207,6 @@
<time-modification>
<actual-notes>5</actual-notes>
<normal-notes>4</normal-notes>
<normal-type>eighth</normal-type>
</time-modification>
<stem>up</stem>
<beam number="1">continue</beam>
Expand All @@ -225,7 +222,6 @@
<time-modification>
<actual-notes>5</actual-notes>
<normal-notes>4</normal-notes>
<normal-type>eighth</normal-type>
</time-modification>
<stem>up</stem>
<beam number="1">end</beam>
Expand Down Expand Up @@ -396,7 +392,7 @@
<measure number="2">
<attributes>
<time>
<beats>6</beats>
<beats>9</beats>
<beat-type>8</beat-type>
</time>
</attributes>
Expand Down Expand Up @@ -446,12 +442,14 @@
<type>eighth</type>
<time-modification>
<actual-notes>5</actual-notes>
<normal-notes>3</normal-notes>
<normal-notes>2</normal-notes>
<normal-type>quarter</normal-type>
<normal-dot/>
</time-modification>
<stem>up</stem>
<beam number="1">begin</beam>
<notations>
<tuplet type="start" bracket="yes" show-number="both"/>
<tuplet type="start" bracket="yes"/>
</notations>
</note>
<note>
Expand All @@ -464,55 +462,76 @@
<type>eighth</type>
<time-modification>
<actual-notes>5</actual-notes>
<normal-notes>3</normal-notes>
<normal-notes>2</normal-notes>
<normal-type>quarter</normal-type>
<normal-dot/>
</time-modification>
<stem>up</stem>
<beam number="1">continue</beam>
<beam number="1">end</beam>
</note>
<note>
<pitch>
<step>E</step>
<octave>4</octave>
</pitch>
<duration>54</duration>
<duration>108</duration>
<voice>1</voice>
<type>eighth</type>
<type>quarter</type>
<time-modification>
<actual-notes>5</actual-notes>
<normal-notes>3</normal-notes>
<normal-notes>2</normal-notes>
<normal-type>quarter</normal-type>
<normal-dot/>
</time-modification>
<stem>up</stem>
<beam number="1">continue</beam>
</note>
<note>
<pitch>
<step>F</step>
<octave>4</octave>
</pitch>
<duration>54</duration>
<duration>108</duration>
<voice>1</voice>
<type>eighth</type>
<type>quarter</type>
<time-modification>
<actual-notes>5</actual-notes>
<normal-notes>3</normal-notes>
<normal-notes>2</normal-notes>
<normal-type>quarter</normal-type>
<normal-dot/>
</time-modification>
<stem>up</stem>
<beam number="1">continue</beam>
</note>
<note>
<pitch>
<step>G</step>
<octave>4</octave>
</pitch>
<duration>54</duration>
<duration>108</duration>
<voice>1</voice>
<type>eighth</type>
<type>quarter</type>
<time-modification>
<actual-notes>5</actual-notes>
<normal-notes>3</normal-notes>
<normal-notes>2</normal-notes>
<normal-type>quarter</normal-type>
<normal-dot/>
</time-modification>
<stem>up</stem>
</note>
<note>
<pitch>
<step>A</step>
<octave>4</octave>
</pitch>
<duration>108</duration>
<voice>1</voice>
<type>quarter</type>
<time-modification>
<actual-notes>5</actual-notes>
<normal-notes>2</normal-notes>
<normal-type>quarter</normal-type>
<normal-dot/>
</time-modification>
<stem>up</stem>
<beam number="1">end</beam>
<notations>
<tuplet type="stop"/>
</notations>
Expand Down
31 changes: 19 additions & 12 deletions tests/test_xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading