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 a parameter to ignore invisible objects when parsing MusicXML files #401

Merged
merged 3 commits into from
Jan 21, 2025
Merged
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
38 changes: 33 additions & 5 deletions partitura/io/importmusicxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ def load_musicxml(
filename: PathLike,
validate: bool = False,
force_note_ids: Optional[Union[bool, str]] = None,
ignore_invisible_objects: bool = False,
) -> score.Score:
"""Parse a MusicXML file and build a composite score ontology
structure from it (see also scoreontology.py).
Expand All @@ -208,6 +209,9 @@ def load_musicxml(
assigned unique id attribute. Existing note id attributes in
the MusicXML will be discarded. If 'keep', only notes without
a note id will be assigned one.
ignore_invisible_objects : bool, optional
When True, objects that with the attribute `print-object="no"`
will be ignored. Defaults to False.

Returns
-------
Expand Down Expand Up @@ -252,7 +256,7 @@ def load_musicxml(
partlist, part_dict = _parse_partlist(partlist_el)
# Go through each <part> to obtain the content of the parts.
# The Part instances will be modified in place
_parse_parts(document, part_dict)
_parse_parts(document, part_dict, ignore_invisible_objects=ignore_invisible_objects)
else:
partlist = []

Expand Down Expand Up @@ -341,7 +345,7 @@ def load_musicxml(
return scr


def _parse_parts(document, part_dict):
def _parse_parts(document, part_dict, ignore_invisible_objects=False):
"""
Populate the Part instances that are the values of `part_dict` with the
musical content in document.
Expand All @@ -353,6 +357,8 @@ def _parse_parts(document, part_dict):
part_dict : dict
A dictionary with key--value pairs (part_id, Part instance), as returned
by the _parse_partlist() function.
ignore_invisible_objects : bool, optional
When True, objects that with the attribute `print-object="no"` will be ignored.
"""

for part_el in document.findall("part"):
Expand All @@ -368,7 +374,7 @@ def _parse_parts(document, part_dict):

for mc, measure_el in enumerate(part_el.xpath("measure")):
position, doc_order = _handle_measure(
measure_el, position, part, ongoing, doc_order, mc + 1
measure_el, position, part, ongoing, doc_order, mc + 1, ignore_invisible_objects
)

# complete unfinished endings
Expand Down Expand Up @@ -492,7 +498,15 @@ def _parse_parts(document, part_dict):
# shift.applied = True


def _handle_measure(measure_el, position, part, ongoing, doc_order, measure_counter):
def _handle_measure(
measure_el,
position,
part,
ongoing,
doc_order,
measure_counter,
ignore_invisible_objects=False,
):
"""Parse a <measure>...</measure> element, adding it and its contents to the part.

Parameters
Expand All @@ -509,6 +523,8 @@ def _handle_measure(measure_el, position, part, ongoing, doc_order, measure_coun
The index of the first note element in the current measure in the xml file.
measure_counter : int
The index of the <measure> tag in the xml file, starting from 1
ignore_invisible_objects : bool, optional
When True, objects that with the attribute `print-object="no"` will be ignored.

Returns
-------
Expand Down Expand Up @@ -536,6 +552,19 @@ def _handle_measure(measure_el, position, part, ongoing, doc_order, measure_coun
measure_maxtime = measure_start
trailing_children = []
for i, e in enumerate(measure_el):
# If the object is invisible and the user wants it, skip the object
# Will probably not skip everything, but works at least for notes and rests
Comment on lines +555 to +556
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is more of a high-level question, the implementation is very fine in my opinion.

I get the concept of invisible rests, or even invisible measures/time signatures, etc. but I am not sure about the utility of invisible notes. What would be a potential use-case for this and why should partitura support skipping some notes?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The most frequent case I've seen invisible notes is to simulate trills/mordents/tremolos for MIDI export (this is not needed in MuseScore as it automatically render those notes, but some other software may not? Or some people might want to end the trill in a custom way?). When parsing this kind of score, I would like to only have access to visible note (eventually with trill/mordent/tremolo information), but not to notes that have been added here for the sole purpose of MIDI export.

A lot of scores in ASAP have such invisible notes, for example in measures 21 and 23 of Chopin/Sonata_2/2nd_no_repeat/xml_score.musicxml.

Copy link
Member

@manoskary manoskary Jan 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, I hadn't thought of such an application. This would then definitely be a great addition to partitura.
Thanks again for all your work. I will resolve this review conversation once the PR is ready for merge.

if ignore_invisible_objects:
print_obj = get_value_from_attribute(e, "print-object", str)
notehead = e.find("notehead") # Musescore mask notes with notehead="none"
if print_obj == "no" or (notehead is not None and notehead.text == "none"):
# Still update position for invisible notes (to avoid problems with backups)
if e.tag == "note":
duration = get_value_from_tag(e, "duration", int) or 0
position += duration
# Skip the object
continue

if e.tag == "backup":
# <xs:documentation>The backup and forward elements are required
# to coordinate multiple voices in one part, including music on
Expand Down Expand Up @@ -1347,7 +1376,6 @@ def _handle_note(e, position, part, ongoing, prev_note, doc_order, prev_beam=Non
articulations=articulations,
doc_order=doc_order,
)

part.add(note, position, position + duration)

# After note is assigned to part we can assign the beam to the note if it exists
Expand Down
5 changes: 5 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@
for fn1, fn2 in [("test_unfold_dacapo.xml", "test_unfold_dacapo_result.xml")]
]

MUSICXML_IGNORE_INVISIBLE_OBJECTS = [
os.path.join(MUSICXML_PATH, fn)
for fn in ["test_ignore_invisible_objects.musicxml"]
]

# This is a list of files for testing Chew and Wu's VOSA. (More files to come?)
VOSA_TESTFILES = [
os.path.join(MUSICXML_PATH, fn) for fn in ["test_chew_vosa_example.xml"]
Expand Down
211 changes: 211 additions & 0 deletions tests/data/musicxml/test_ignore_invisible_objects.musicxml
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 4.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
<score-partwise version="4.0">
<work>
<work-title>Partition sans titre</work-title>
</work>
<identification>
<creator type="composer">Compositeur / Arrangeur</creator>
<encoding>
<software>MuseScore 4.4.4</software>
<encoding-date>2024-12-13</encoding-date>
<supports element="accidental" type="yes"/>
<supports element="beam" type="yes"/>
<supports element="print" attribute="new-page" type="no"/>
<supports element="print" attribute="new-system" type="no"/>
<supports element="stem" type="yes"/>
</encoding>
</identification>
<part-list>
<score-part id="P1">
<part-name>Piano</part-name>
<part-abbreviation>Pno.</part-abbreviation>
<score-instrument id="P1-I1">
<instrument-name>Piano</instrument-name>
<instrument-sound>keyboard.piano</instrument-sound>
</score-instrument>
<midi-device id="P1-I1" port="1"></midi-device>
<midi-instrument id="P1-I1">
<midi-channel>1</midi-channel>
<midi-program>1</midi-program>
<volume>78.7402</volume>
<pan>0</pan>
</midi-instrument>
</score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>4</divisions>
<key>
<fifths>0</fifths>
</key>
<time>
<beats>4</beats>
<beat-type>4</beat-type>
</time>
<staves>2</staves>
<clef number="1">
<sign>G</sign>
<line>2</line>
</clef>
<clef number="2">
<sign>F</sign>
<line>4</line>
</clef>
</attributes>
<note>
<pitch>
<step>F</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<stem>up</stem>
<staff>1</staff>
</note>
<note>
<pitch>
<step>E</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<stem>up</stem>
<staff>1</staff>
</note>
<note>
<pitch>
<step>D</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<stem>up</stem>
<staff>1</staff>
</note>
<note>
<pitch>
<step>C</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<voice>1</voice>
<type>quarter</type>
<stem>up</stem>
<staff>1</staff>
</note>
<backup>
<duration>4</duration>
</backup>
<note>
<pitch>
<step>C</step>
<octave>4</octave>
</pitch>
<duration>1</duration>
<voice>2</voice>
<type>16th</type>
<stem>none</stem>
<notehead>none</notehead>
<staff>1</staff>
<beam number="1">begin</beam>
<beam number="2">begin</beam>
</note>
<note>
<pitch>
<step>D</step>
<octave>4</octave>
</pitch>
<duration>1</duration>
<voice>2</voice>
<type>16th</type>
<stem>none</stem>
<notehead>none</notehead>
<staff>1</staff>
<beam number="1">continue</beam>
<beam number="2">continue</beam>
</note>
<note>
<pitch>
<step>C</step>
<octave>4</octave>
</pitch>
<duration>1</duration>
<voice>2</voice>
<type>16th</type>
<stem>none</stem>
<notehead>none</notehead>
<staff>1</staff>
<beam number="1">continue</beam>
<beam number="2">continue</beam>
</note>
<note>
<pitch>
<step>D</step>
<octave>4</octave>
</pitch>
<duration>1</duration>
<voice>2</voice>
<type>16th</type>
<stem>none</stem>
<notehead>none</notehead>
<staff>1</staff>
<beam number="1">end</beam>
<beam number="2">end</beam>
</note>
<backup>
<duration>16</duration>
</backup>
<note>
<pitch>
<step>C</step>
<octave>3</octave>
</pitch>
<duration>4</duration>
<voice>5</voice>
<type>quarter</type>
<stem>up</stem>
<staff>2</staff>
</note>
<note>
<pitch>
<step>D</step>
<octave>3</octave>
</pitch>
<duration>4</duration>
<voice>5</voice>
<type>quarter</type>
<stem>none</stem>
<notehead>none</notehead>
<staff>2</staff>
</note>
<note print-object="no">
<rest/>
<duration>4</duration>
<voice>5</voice>
<type>quarter</type>
<staff>2</staff>
</note>
<note>
<pitch>
<step>B</step>
<alter>-1</alter>
<octave>2</octave>
</pitch>
<duration>4</duration>
<voice>5</voice>
<type>quarter</type>
<accidental>flat</accidental>
<stem>up</stem>
<staff>2</staff>
</note>
<barline location="right">
<bar-style>light-heavy</bar-style>
</barline>
</measure>
</part>
</score-partwise>
27 changes: 27 additions & 0 deletions tests/test_xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
MUSICXML_UNFOLD_COMPLEX,
MUSICXML_UNFOLD_VOLTA,
MUSICXML_UNFOLD_DACAPO,
MUSICXML_IGNORE_INVISIBLE_OBJECTS,
)

from partitura import load_musicxml, save_musicxml
Expand Down Expand Up @@ -251,6 +252,32 @@ def test_score_attribute(self):
self.assertTrue(score.work_title == test_work_title)
self.assertTrue(score.work_number == test_work_number)

def test_import_ignore_invisible_objects(self):
score_w_invisible = load_musicxml(MUSICXML_IGNORE_INVISIBLE_OBJECTS[0])[0]
score_wo_invisible = load_musicxml(MUSICXML_IGNORE_INVISIBLE_OBJECTS[0], ignore_invisible_objects=True)[0]

note_w_invisible_objs = score_w_invisible.note_array()
note_wo_invisible_objs = score_wo_invisible.note_array()

# Convert back from structured array to simple tuples as hash problems with set otherwise
note_w_invisible_objs = set(
[(n["pitch"], n["onset_beat"], n["duration_beat"]) for n in note_w_invisible_objs]
)
note_wo_invisible_objs = set(
[(n["pitch"], n["onset_beat"], n["duration_beat"]) for n in note_wo_invisible_objs]
)
# Make sure all notes in the filtered score are also in the unfiltered score
self.assertTrue(note_wo_invisible_objs.issubset(note_w_invisible_objs))

self.assertTrue(len(note_w_invisible_objs) == 11)
self.assertTrue(len(note_wo_invisible_objs) == 6)

self.assertTrue(len(score_w_invisible.rests) == 1)
self.assertTrue(len(score_wo_invisible.rests) == 0)

self.assertTrue(len(list(score_w_invisible.iter_all(cls=score.Beam))) == 1)
self.assertTrue(len(list(score_wo_invisible.iter_all(cls=score.Beam))) == 0)


def make_part_slur():
# create a part
Expand Down
Loading