diff --git a/partitura/io/importmusicxml.py b/partitura/io/importmusicxml.py index 4964cb78..77939b24 100644 --- a/partitura/io/importmusicxml.py +++ b/partitura/io/importmusicxml.py @@ -195,6 +195,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). @@ -213,6 +214,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 ------- @@ -257,7 +261,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 = [] @@ -346,7 +350,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. @@ -358,6 +362,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"): @@ -373,7 +379,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 @@ -497,7 +503,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 @@ -514,6 +528,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 ------- @@ -541,6 +557,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 + 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 # to coordinate multiple voices in one part, including music on @@ -1368,7 +1397,6 @@ def _handle_note(e, position, part, ongoing, prev_note, doc_order, prev_beam=Non technical=technical_notations, 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 diff --git a/tests/__init__.py b/tests/__init__.py index 8ce30bb0..ffeee128 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 0de6da8d..d3b116ee 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 @@ -256,6 +257,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