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

Add optional attributes to tuplets related to their time modification #414

Merged
merged 7 commits into from
Jan 23, 2025
23 changes: 20 additions & 3 deletions partitura/io/exportmusicxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
40 changes: 38 additions & 2 deletions partitura/io/importmusicxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
42 changes: 40 additions & 2 deletions partitura/score.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"])

Expand Down Expand Up @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@
)
]
]
MUSICXML_TUPLET_ATTRIBUTES_TESTFILES = [
os.path.join(MUSICXML_PATH, fn)
for fn in ["test_tuplet_attributes.musicxml"]
]

MUSICXML_UNFOLD_COMPLEX = [
(
Expand Down
Loading
Loading