From af6aeab9c5af9990504c07883ba95efdd0757b52 Mon Sep 17 00:00:00 2001 From: leleogere Date: Tue, 26 Nov 2024 17:20:09 +0100 Subject: [PATCH 1/3] Add a parameter that will ignore objects with the attribute `print-object` set to "no" when parsing MusicXML files --- partitura/io/importmusicxml.py | 38 ++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/partitura/io/importmusicxml.py b/partitura/io/importmusicxml.py index a359fbe3..5194ad8f 100644 --- a/partitura/io/importmusicxml.py +++ b/partitura/io/importmusicxml.py @@ -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). @@ -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 ------- @@ -252,7 +256,7 @@ def load_musicxml( partlist, part_dict = _parse_partlist(partlist_el) # Go through each 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 = [] @@ -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. @@ -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"): @@ -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 @@ -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 ... element, adding it and its contents to the part. Parameters @@ -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 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 ------- @@ -536,6 +552,15 @@ 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 + if ignore_invisible_objects and get_value_from_attribute(e, "print-object", str) == "no": + # 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": # The backup and forward elements are required # to coordinate multiple voices in one part, including music on @@ -1348,6 +1373,11 @@ def _handle_note(e, position, part, ongoing, prev_note, doc_order, prev_beam=Non doc_order=doc_order, ) + # Deal with invisible notes + print_object = get_value_from_attribute(e, "print-object", str) + if print_object == "no": + note.print_object = False + part.add(note, position, position + duration) # After note is assigned to part we can assign the beam to the note if it exists From 27a0e1680fc3cb10738662600847c1cf95983ff4 Mon Sep 17 00:00:00 2001 From: leleogere Date: Fri, 13 Dec 2024 17:50:09 +0100 Subject: [PATCH 2/3] Remove an old attempt, support MuseScore invisible notes, add tests --- partitura/io/importmusicxml.py | 24 +- tests/__init__.py | 5 + .../test_ignore_invisible_objects.musicxml | 211 ++++++++++++++++++ tests/test_xml.py | 24 ++ 4 files changed, 251 insertions(+), 13 deletions(-) create mode 100644 tests/data/musicxml/test_ignore_invisible_objects.musicxml diff --git a/partitura/io/importmusicxml.py b/partitura/io/importmusicxml.py index 5194ad8f..411f975f 100644 --- a/partitura/io/importmusicxml.py +++ b/partitura/io/importmusicxml.py @@ -553,13 +553,17 @@ def _handle_measure( trailing_children = [] for i, e in enumerate(measure_el): # If the object is invisible and the user wants it, skip the object - if ignore_invisible_objects and get_value_from_attribute(e, "print-object", str) == "no": - # 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 + # Will probably not skip everything, but works at least for notes and rests + 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": # The backup and forward elements are required @@ -1372,12 +1376,6 @@ def _handle_note(e, position, part, ongoing, prev_note, doc_order, prev_beam=Non articulations=articulations, doc_order=doc_order, ) - - # Deal with invisible notes - print_object = get_value_from_attribute(e, "print-object", str) - if print_object == "no": - note.print_object = False - part.add(note, position, position + duration) # After note is assigned to part we can assign the beam to the note if it exists diff --git a/tests/__init__.py b/tests/__init__.py index 32824bcc..11c9e3b3 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -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"] diff --git a/tests/data/musicxml/test_ignore_invisible_objects.musicxml b/tests/data/musicxml/test_ignore_invisible_objects.musicxml new file mode 100644 index 00000000..af4f42b4 --- /dev/null +++ b/tests/data/musicxml/test_ignore_invisible_objects.musicxml @@ -0,0 +1,211 @@ + + + + + Partition sans titre + + + Compositeur / Arrangeur + + MuseScore 4.4.4 + 2024-12-13 + + + + + + + + + + Piano + Pno. + + Piano + keyboard.piano + + + + 1 + 1 + 78.7402 + 0 + + + + + + + 4 + + 0 + + + 2 + + G + 2 + + + F + 4 + + + + + F + 4 + + 4 + 1 + quarter + up + 1 + + + + E + 4 + + 4 + 1 + quarter + up + 1 + + + + D + 4 + + 4 + 1 + quarter + up + 1 + + + + C + 4 + + 4 + 1 + quarter + up + 1 + + + 4 + + + + C + 4 + + 1 + 2 + 16th + none + none + 1 + begin + begin + + + + D + 4 + + 1 + 2 + 16th + none + none + 1 + continue + continue + + + + C + 4 + + 1 + 2 + 16th + none + none + 1 + continue + continue + + + + D + 4 + + 1 + 2 + 16th + none + none + 1 + end + end + + + 16 + + + + C + 3 + + 4 + 5 + quarter + up + 2 + + + + D + 3 + + 4 + 5 + quarter + none + none + 2 + + + + 4 + 5 + quarter + 2 + + + + B + -1 + 2 + + 4 + 5 + quarter + flat + up + 2 + + + light-heavy + + + + diff --git a/tests/test_xml.py b/tests/test_xml.py index 05959d3f..4ebf5c0b 100755 --- a/tests/test_xml.py +++ b/tests/test_xml.py @@ -15,6 +15,7 @@ MUSICXML_UNFOLD_COMPLEX, MUSICXML_UNFOLD_VOLTA, MUSICXML_UNFOLD_DACAPO, + MUSICXML_IGNORE_INVISIBLE_OBJECTS, ) from partitura import load_musicxml, save_musicxml @@ -251,6 +252,29 @@ 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] + + score_w_invisible_objs = set(map(str, score_w_invisible.iter_all())) + score_wo_invisible_objs = set(map(str, score_wo_invisible.iter_all())) + + self.assertTrue(score_wo_invisible_objs.issubset(score_w_invisible_objs)) + + diff = score_w_invisible_objs - score_wo_invisible_objs + invisible_objects = { + "4--8 Note id=None voice=5 staff=2 type=quarter pitch=D3", + "8--12 Rest id=None voice=5 staff=2 type=quarter", + "12--13 Note id=None voice=2 staff=1 type=16th pitch=C4", + "13--14 Note id=None voice=2 staff=1 type=16th pitch=D4", + "14--15 Note id=None voice=2 staff=1 type=16th pitch=C4", + "15--16 Note id=None voice=2 staff=1 type=16th pitch=D4", + "12--16 Beam", + } + self.assertEqual(diff, invisible_objects) + + + def make_part_slur(): # create a part From aa582c9eefe466eee986f12020c5ffd5e2e9ec02 Mon Sep 17 00:00:00 2001 From: leleogere Date: Mon, 20 Jan 2025 17:01:54 +0100 Subject: [PATCH 3/3] Avoid using a diff on the string-formatted score when testing --- tests/test_xml.py | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/tests/test_xml.py b/tests/test_xml.py index 4ebf5c0b..57e7b368 100755 --- a/tests/test_xml.py +++ b/tests/test_xml.py @@ -256,24 +256,27 @@ 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] - score_w_invisible_objs = set(map(str, score_w_invisible.iter_all())) - score_wo_invisible_objs = set(map(str, score_wo_invisible.iter_all())) - - self.assertTrue(score_wo_invisible_objs.issubset(score_w_invisible_objs)) - - diff = score_w_invisible_objs - score_wo_invisible_objs - invisible_objects = { - "4--8 Note id=None voice=5 staff=2 type=quarter pitch=D3", - "8--12 Rest id=None voice=5 staff=2 type=quarter", - "12--13 Note id=None voice=2 staff=1 type=16th pitch=C4", - "13--14 Note id=None voice=2 staff=1 type=16th pitch=D4", - "14--15 Note id=None voice=2 staff=1 type=16th pitch=C4", - "15--16 Note id=None voice=2 staff=1 type=16th pitch=D4", - "12--16 Beam", - } - self.assertEqual(diff, invisible_objects) - - + 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():