diff --git a/partitura/io/exportmatch.py b/partitura/io/exportmatch.py index 4656c724..8afa8d64 100644 --- a/partitura/io/exportmatch.py +++ b/partitura/io/exportmatch.py @@ -312,6 +312,7 @@ def matchfile_from_alignment( staff = getattr(snote, "staff", None) ornaments = getattr(snote, "ornaments", None) fermata = getattr(snote, "fermata", None) + technical = getattr(snote, "technical", None) if voice is not None: score_attributes_list.append(f"v{voice}") @@ -328,6 +329,11 @@ def matchfile_from_alignment( if fermata is not None: score_attributes_list.append("fermata") + if technical is not None: + for tech_el in technical: + if isinstance(tech_el, score.Fingering): + score_attributes_list.append(f"fingering{tech_el.fingering}") + if isinstance(snote, score.GraceNote): score_attributes_list.append("grace") diff --git a/partitura/io/exportmei.py b/partitura/io/exportmei.py index a9754502..c4ae4b15 100644 --- a/partitura/io/exportmei.py +++ b/partitura/io/exportmei.py @@ -6,6 +6,7 @@ import math from collections import defaultdict from lxml import etree +import lxml import partitura.score as spt from operator import itemgetter from itertools import groupby @@ -225,6 +226,7 @@ def _handle_measure(self, measure, measure_el): self._handle_harmony(measure_el, start=measure.start.t, end=measure.end.t) self._handle_fermata(measure_el, start=measure.start.t, end=measure.end.t) self._handle_barline(measure_el, start=measure.start.t, end=measure.end.t) + self._handle_fingering(measure_el, start=measure.start.t, end=measure.end.t) return measure_el def _handle_chord(self, chord, xml_voice_el): @@ -295,7 +297,7 @@ def _handle_note(self, note, xml_voice_el): note_el.set("grace", "acc") return duration - def _handle_tuplets(self, measure_el, start, end): + def _handle_tuplets(self, measure_el: lxml.etree._Element, start: int, end: int): for tuplet in self.part.iter_all(spt.Tuplet, start=start, end=end): start_note = tuplet.start_note end_note = tuplet.end_note @@ -352,7 +354,7 @@ def _handle_tuplets(self, measure_el, start, end): for el in xml_el_within_tuplet: tuplet_el.append(el) - def _handle_beams(self, measure_el, start, end): + def _handle_beams(self, measure_el: lxml.etree._Element, start: int, end: int): for beam in self.part.iter_all(spt.Beam, start=start, end=end): # If the beam has only one note, skip it if len(beam.notes) < 2: @@ -400,7 +402,7 @@ def _handle_beams(self, measure_el, start, end): if note_el.getparent() != beam_el: beam_el.append(note_el) - def _handle_clef_changes(self, measure_el, start, end): + def _handle_clef_changes(self, measure_el: lxml.etree._Element, start: int, end: int): for clef in self.part.iter_all(spt.Clef, start=start, end=end): # Clef element is parent of the note element if clef.start.t == 0: @@ -421,7 +423,7 @@ def _handle_clef_changes(self, measure_el, start, end): clef_el.set("shape", str(clef.sign)) clef_el.set("line", str(clef.line)) - def _handle_ks_changes(self, measure_el, start, end): + def _handle_ks_changes(self, measure_el: lxml.etree._Element, start: int, end: int): # For key signature changes, we add a new scoreDef element at the beginning of the measure # and add the key signature element as attributes of the scoreDef element for key_sig in self.part.iter_all(spt.KeySignature, start=start, end=end): @@ -451,7 +453,7 @@ def _handle_ks_changes(self, measure_el, start, end): parent = measure_el.getparent() parent.insert(parent.index(measure_el), score_def_el) - def _handle_ts_changes(self, measure_el, start, end): + def _handle_ts_changes(self, measure_el: lxml.etree._Element, start: int, end: int): # For key signature changes, we add a new scoreDef element at the beginning of the measure # and add the key signature element as attributes of the scoreDef element for time_sig in self.part.iter_all(spt.TimeSignature, start=start, end=end): @@ -467,7 +469,7 @@ def _handle_ts_changes(self, measure_el, start, end): score_def_el.set("count", str(time_sig.beats)) score_def_el.set("unit", str(time_sig.beat_type)) - def _handle_harmony(self, measure_el, start, end): + def _handle_harmony(self, measure_el: lxml.etree._Element, start: int, end: int): """ For harmonies we add a new harm element at the beginning of the measure. The position doesn't really matter since the tstamp attribute will place it correctly @@ -508,7 +510,7 @@ def _handle_harmony(self, measure_el, start, end): # text is a child element of harmony but not a xml element harm_el.text = "|" + harmony.text - def _handle_fermata(self, measure_el, start, end): + def _handle_fermata(self, measure_el: lxml.etree._Element, start: int, end: int): for fermata in self.part.iter_all(spt.Fermata, start=start, end=end): if fermata.ref is not None: note = fermata.ref @@ -527,7 +529,7 @@ def _handle_fermata(self, measure_el, start, end): # Set the fermata to be above the staff (the highest staff) fermata_el.set("staff", "1") - def _handle_barline(self, measure_el, start, end): + def _handle_barline(self, measure_el: lxml.etree._Element, start: int, end: int): for end_barline in self.part.iter_all( spt.Ending, start=end, end=end + 1, mode="ending" ): @@ -546,6 +548,23 @@ def _handle_barline(self, measure_el, start, end): ): measure_el.set("left", "rptstart") + def _handle_fingering(self, measure_el: lxml.etree._Element, start: int, end: int): + """ + For fingering we add a new fing element at the end of the measure. + The position doesn't really matter since the startid attribute will place it correctly + """ + for note in self.part.iter_all(spt.Note, start=start, end=end): + if note.technical is not None: + for technical_notation in note.technical: + if isinstance(technical_notation, score.Fingering) and note.id is not None: + fing_el = etree.SubElement(measure_el, "fing") + fing_el.set(XMLNS_ID, "fing-" + self.elc_id()) + fing_el.set("startid", note.id) + # Naive way to place the fingering notation + fing_el.set("place", ("above" if note.staff == 1 else "below")) + # text is a child element of fingering but not a xml element + fing_el.text = technical_notation.fingering + @deprecated_alias(parts="score_data") def save_mei( diff --git a/partitura/io/exportmusicxml.py b/partitura/io/exportmusicxml.py index 9e349abd..1c7f8c6b 100644 --- a/partitura/io/exportmusicxml.py +++ b/partitura/io/exportmusicxml.py @@ -154,6 +154,19 @@ def make_note_el(note, dur, voice, counter, n_of_staves): articulations_e.extend(articulations) notations.append(articulations_e) + if note.technical: + technical = [] + for technical_notation in note.technical: + if isinstance(technical_notation, score.Fingering): + tech_el = etree.Element("fingering") + tech_el.text = str(technical_notation.fingering) + technical.append(tech_el) + + if technical: + technical_e = etree.Element("technical") + technical_e.extend(technical) + notations.append(technical_e) + sym_dur = note.symbolic_duration or {} if sym_dur.get("type") is not None: diff --git a/partitura/io/importmatch.py b/partitura/io/importmatch.py index a32cb812..6f79f5c9 100644 --- a/partitura/io/importmatch.py +++ b/partitura/io/importmatch.py @@ -40,6 +40,7 @@ Version, number_pattern, vnumber_pattern, + fingering_pattern, MatchTimeSignature, format_pnote_id, ) @@ -594,6 +595,7 @@ def part_from_matchfile( alter=note.Modifier, id=note.Anchor, articulations=articulations, + technical=[], ) staff_nr = next( @@ -622,6 +624,18 @@ def part_from_matchfile( None, ) + if any(a.startswith("fingering") for a in note.ScoreAttributesList): + note_attributes["technical"].append( + next( + ( + score.Fingering(int(a[9:])) + for a in note.ScoreAttributesList + if fingering_pattern.match(a) + ), + None, + ) + ) + # get rid of this if as soon as we have a way to iterate over the # duration components. For now we have to treat the cases simple # and compound durations separately. diff --git a/partitura/io/importmusicxml.py b/partitura/io/importmusicxml.py index ce10bf0f..69b7a04a 100644 --- a/partitura/io/importmusicxml.py +++ b/partitura/io/importmusicxml.py @@ -8,7 +8,7 @@ import warnings import zipfile -from typing import Union, Optional +from typing import Union, Optional, List import numpy as np from lxml import etree @@ -1244,6 +1244,12 @@ def _handle_note(e, position, part, ongoing, prev_note, doc_order, prev_beam=Non else: ornaments = {} + technical_e = e.find("notations/technical") + if technical_e is not None: + technical_notations = get_technical_notations(technical_e) + else: + technical_notations = {} + pitch = e.find("pitch") unpitch = e.find("unpitched") if pitch is not None: @@ -1271,6 +1277,7 @@ def _handle_note(e, position, part, ongoing, prev_note, doc_order, prev_beam=Non symbolic_duration=symbolic_duration, articulations=articulations, ornaments=ornaments, + technical=technical_notations, steal_proportion=steal_proportion, doc_order=doc_order, stem_direction=stem_dir, @@ -1309,6 +1316,7 @@ def _handle_note(e, position, part, ongoing, prev_note, doc_order, prev_beam=Non symbolic_duration=symbolic_duration, articulations=articulations, ornaments=ornaments, + technical=technical_notations, doc_order=doc_order, stem_direction=stem_dir, ) @@ -1338,6 +1346,7 @@ def _handle_note(e, position, part, ongoing, prev_note, doc_order, prev_beam=Non notehead=notehead, noteheadstyle=noteheadstylebool, articulations=articulations, + technical=technical_notations, symbolic_duration=symbolic_duration, doc_order=doc_order, stem_direction=stem_dir, @@ -1351,6 +1360,7 @@ def _handle_note(e, position, part, ongoing, prev_note, doc_order, prev_beam=Non staff=staff, symbolic_duration=symbolic_duration, articulations=articulations, + technical=technical_notations, doc_order=doc_order, ) @@ -1648,6 +1658,30 @@ def get_ornaments(e): return [a for a in ornaments if e.find(a) is not None] +def get_technical_notations(e: etree._Element) -> List[score.NoteTechnicalNotation]: + # For a full list of technical notations + # https://usermanuals.musicxml.com/MusicXML/Content/EL-MusicXML-technical.htm + # for now we only support fingering + technical_notation_parsers = { + "fingering": parse_fingering, + } + + technical_notations = [ + parser(e.find(a)) + for a, parser in technical_notation_parsers.items() + if e.find(a) is not None + ] + + return technical_notations + + +def parse_fingering(e: etree._Element) -> score.Fingering: + + fingering = score.Fingering(fingering=int(e.text)) + + return fingering + + @deprecated_alias(fn="filename") def musicxml_to_notearray( filename, diff --git a/partitura/io/matchfile_utils.py b/partitura/io/matchfile_utils.py index 9fe76745..2f7340f7 100644 --- a/partitura/io/matchfile_utils.py +++ b/partitura/io/matchfile_utils.py @@ -47,6 +47,7 @@ number_pattern = re.compile(r"\d+") vnumber_pattern = re.compile(r"v\d+") +fingering_pattern = re.compile(r"fingering\d+") # For matchfiles before 1.0.0. old_version_pattern = re.compile(r"^(?P[0-9]+)\.(?P[0-9]+)") diff --git a/partitura/musicanalysis/performance_codec.py b/partitura/musicanalysis/performance_codec.py index 434767de..d6c2d737 100644 --- a/partitura/musicanalysis/performance_codec.py +++ b/partitura/musicanalysis/performance_codec.py @@ -608,8 +608,8 @@ def tempo_by_derivative( @deprecated_alias(part="score", ppart="performance") def to_matched_score( - score: ScoreLike, - performance: PerformanceLike, + score: Union[ScoreLike, np.ndarray], + performance: Union[PerformanceLike, np.ndarray], alignment: list, include_score_markings=False, ): @@ -635,7 +635,7 @@ def to_matched_score( a["score_id"] = str(a["score_id"]) feature_functions = None - if include_score_markings: + if include_score_markings and not isinstance(score, np.ndarray): feature_functions = [ "loudness_direction_feature", "articulation_feature", @@ -643,8 +643,18 @@ def to_matched_score( "slur_feature", ] - na = note_features.compute_note_array(score, feature_functions=feature_functions) - p_na = performance.note_array() + if isinstance(score, np.ndarray): + na = score + else: + na = note_features.compute_note_array( + score, + feature_functions=feature_functions, + ) + + if isinstance(performance, np.ndarray): + p_na = performance + else: + p_na = performance.note_array() part_by_id = dict((n["id"], na[na["id"] == n["id"]]) for n in na) ppart_by_id = dict((n["id"], p_na[p_na["id"] == n["id"]]) for n in p_na) @@ -682,7 +692,7 @@ def to_matched_score( ("p_duration", "f4"), ("velocity", "i4"), ] - if include_score_markings: + if include_score_markings and not isinstance(score, np.ndarray): fields += [("voice", "i4")] fields += [ (field, sn.dtype.fields[field][0]) @@ -763,7 +773,7 @@ def get_time_maps_from_alignment( # representing the "performeance time" of the position of the score # onsets eq_perf_onsets = np.array( - [np.mean(perf_onsets[u]) for u in score_unique_onset_idxs] + [np.mean(perf_onsets[u.astype(int)]) for u in score_unique_onset_idxs] ) # Get maps diff --git a/partitura/score.py b/partitura/score.py index 26fb49f6..acbd1507 100644 --- a/partitura/score.py +++ b/partitura/score.py @@ -21,7 +21,7 @@ import numpy as np import re from scipy.interpolate import PPoly -from typing import Union, List, Optional, Iterator, Iterable as Itertype +from typing import Union, List, Optional, Iterator, Any, Iterable as Itertype import difflib from partitura.utils import ( ComparableMixin, @@ -1701,6 +1701,8 @@ class GenericNote(TimedObject): stem_direction : str, optional The stem direction of the note. Can be 'up', 'down', or None. Defaults to None. + technical: list, optional + Technical notation elements. """ @@ -1714,6 +1716,7 @@ def __init__( ornaments=None, doc_order=None, stem_direction=None, + technical=None, **kwargs, ): self._sym_dur = None @@ -1724,6 +1727,7 @@ def __init__( self.symbolic_duration = symbolic_duration self.articulations = articulations self.ornaments = ornaments + self.technical = technical self.doc_order = doc_order self.stem_direction = ( stem_direction if stem_direction in ("up", "down") else None @@ -3379,6 +3383,27 @@ def reference_tempo(self): return direction +class NoteTechnicalNotation(object): + """ + This object represents technical notations that + are part of a GenericNote object (e.g., fingering,) + These elements depend on a note, but can have their own properties + """ + + def __init__(self, type: str, info: Optional[Any] = None) -> None: + self.type = type + self.info = info + + +class Fingering(NoteTechnicalNotation): + def __init__(self, fingering: int) -> None: + super().__init__( + type="fingering", + info=fingering, + ) + self.fingering = fingering + + class PartGroup(object): """Represents a grouping of several instruments, usually named, and expressed in the score with a group symbol such as a brace or