From b00ad74532b27d0a13931da1d4f0b993a16ce35c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Wed, 6 Apr 2022 07:33:40 +0200 Subject: [PATCH 01/88] add new match format (wip) --- partitura/io/importmatch_new.py | 106 ++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 partitura/io/importmatch_new.py diff --git a/partitura/io/importmatch_new.py b/partitura/io/importmatch_new.py new file mode 100644 index 00000000..e84ede5c --- /dev/null +++ b/partitura/io/importmatch_new.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains methods for parsing matchfiles +""" +import re + +from typing import Union + +import numpy as np + +MAX_VERSION = "1.0.0" + +rational_pattern = re.compile(r"^([0-9]+)/([0-9]+)$") +double_rational_pattern = re.compile(r"^([0-9]+)/([0-9]+)/([0-9]+)$") + + +class MatchError(Exception): + pass + + +class MatchLine(object): + + field_names = tuple() + pattern = None + + def __str__(self): + r = [self.__class__.__name__] + for fn in self.field_names: + r.append(" {0}: {1}".format(fn, self.__dict__[fn])) + return "\n".join(r) + "\n" + + @property + def matchline(self) -> str: + raise NotImplementedError + + @classmethod + def from_matchline(cls, matchline: str, *args, **kwargs): + raise NotImplementedError + + def check_types(self): + raise NotImplementedError + + +class MatchInfo(MatchLine): + + field_names = ('Attribute', 'Value') + + pattern = re.compile(r"info\(\s*([^,]+)\s*,\s*(.+)\s*\)\.") + + def __init__(self, attribute: str, value: Union[str, int]): + self.attribute = attribute + self.value = value + + @property + def matchline(self): + matchline = f"info({self.attribute},{self.value})." + return matchline + + @classmethod + def from_matchline(cls, matchline: str): + re_info = cls.pattern.search(matchline) + + if re_info is not None: + attribute, value_str = re_info.groups() + + if attribute in ('matchFileVersion', 'composer', 'piece'): + value = value_str + elif attribute in ('midiClockRate', 'midiClockUnites'): + value = int(value_str) + else: + raise ValueError('Invalid attribute name!') + + return cls(attribute, value) + + +class MatchScoreProp(MatchLine): + + field_names = ('Attribute', 'Value', 'Bar', '') + + pattern = re.compile(r"info\(\s*([^,]+)\s*,\s*(.+)\s*\)\.") + + def __init__(self, attribute: str, value: str, bar: int, beat: float): + self.attribute = attribute + self.value = value + self.bar = bar + self.beat = beat + + @property + def matchline(self): + matchline = f"scoreprop({self.attribute},{self.value},{self.bar},{self.beat})." + return matchline + + @classmethod + def from_matchline(cls, matchline: str): + pass + + + + + + + + +def load_match(fn, create_part=False, pedal_threshold=64, first_note_at_zero=False): + pass From e0078dff5b1d3514d23cfceb9de4760e2c875267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Thu, 7 Apr 2022 01:00:43 +0200 Subject: [PATCH 02/88] test variable matchlines with line dictionaries --- partitura/io/importmatch_new.py | 205 +++++++++++++++++++++++++++----- 1 file changed, 174 insertions(+), 31 deletions(-) diff --git a/partitura/io/importmatch_new.py b/partitura/io/importmatch_new.py index e84ede5c..6924f98e 100644 --- a/partitura/io/importmatch_new.py +++ b/partitura/io/importmatch_new.py @@ -5,29 +5,114 @@ """ import re -from typing import Union +from collections import namedtuple +from typing import Union, Tuple + +# from packaging import version import numpy as np -MAX_VERSION = "1.0.0" +# Define current version of the match file format +CURRENT_MAJOR_VERSION = 1 +CURRENT_MINOR_VERSION = 0 +CURRENT_PATCH_VERSION = 0 + +Version = namedtuple("Version", ["major", "minor", "patch"]) + +CURRENT_VERSION = Version( + CURRENT_MAJOR_VERSION, + CURRENT_MINOR_VERSION, + CURRENT_PATCH_VERSION, +) +# General patterns rational_pattern = re.compile(r"^([0-9]+)/([0-9]+)$") double_rational_pattern = re.compile(r"^([0-9]+)/([0-9]+)/([0-9]+)$") +version_pattern = re.compile(r"^([0-9]+)\.([0-9]+)\.([0-9]+)") class MatchError(Exception): pass -class MatchLine(object): +def interpret_version(version_string: str) -> Version: + version_info = version_pattern.search(version_string) + + if version_info is not None: + ma, mi, pa = version_info.groups() + version = Version(int(ma), int(mi), int(pa)) + + return version + else: + raise ValueError(f"The version '{version_string}' is incorrectly formatted!") + + +def format_version(version: Version) -> str: + ma, mi, pa = version + return f"{ma}.{mi}.{pa}" + + +def interpret_as_int(value: str) -> int: + return int(value) + + +def format_int(value: int) -> str: + return f"{value}" + + +def interpret_as_float(value: str) -> float: + return float(value) + + +def format_float(value: float) -> str: + return f"{value:.4f}" + + +def interpret_as_string(value: str) -> str: + return value + - field_names = tuple() - pattern = None +def format_string(value: str) -> str: + """ + For completeness + """ + return value.strip() - def __str__(self): + +class MatchLine(object): + + version: Version + field_names: tuple + pattern: re.Pattern + out_pattern: str + line_dict: dict + + def __init__( + self, + version: Version, + **kwargs + ): + # set version + self.version = version + # Get pattern + self.pattern = self.line_dict[self.version]["pattern"] + # Get field names + self.field_names = self.line_dict[self.version]["field_names"] + # Get out pattern + self.out_pattern = self.line_dict[self.version]["matchline"] + + # set field names + # TODO: Add custom error if field is not provided? + for field in self.field_names: + setattr(self, field, kwargs[field.lower()]) + + def __str__(self) -> str: + """ + For printing the Method + """ r = [self.__class__.__name__] for fn in self.field_names: - r.append(" {0}: {1}".format(fn, self.__dict__[fn])) + r.append(" {0}: {1}".format(fn, self.__dict__[fn.lower()])) return "\n".join(r) + "\n" @property @@ -35,48 +120,102 @@ def matchline(self) -> str: raise NotImplementedError @classmethod - def from_matchline(cls, matchline: str, *args, **kwargs): + def from_matchline(cls, matchline: str, version: Version = CURRENT_MAJOR_VERSION): raise NotImplementedError - def check_types(self): + def check_types(self) -> bool: + """ + Check whether the values of the attributes are of the correct type. + """ raise NotImplementedError +# Dictionary of interpreter, formatters and datatypes +INFO_LINE_INTERPRETERS_V_1_0_0 = { + "matchFileVersion": (interpret_version, format_version, Version), + "piece": (interpret_as_string, format_string, str), + "scoreFileName": (interpret_as_string, format_string, str), + "scoreFilePath": (interpret_as_string, format_string, str), + "midiFileName": (interpret_as_string, format_string, str), + "midiFilePath": (interpret_as_string, format_string, str), + "audioFileName": (interpret_as_string, format_string, str), + "audioFilePath": (interpret_as_string, format_string, str), + "audioFirstNote": (interpret_as_float, format_float, float), + "audioLastNote": (interpret_as_float, format_float, float), + "performer": (interpret_as_string, format_string, str), + "composer": (interpret_as_string, format_string, str), + "midiClockUnits": (interpret_as_int, format_int, int), + "midiClockRate": (interpret_as_int, format_int, int), + "approximateTempo": (interpret_as_float, format_float), + "subtitle": (interpret_as_string, format_string, str), +} + +INFO_LINE = { + Version(1, 0, 0): { + "pattern": re.compile( + r"info\(\s*(?P[^,]+)\s*,\s*(?P.+)\s*\)\." + ), + "field_names": ("attribute", "value"), + "matchline": "info({attribute},{value}).", + "value": INFO_LINE_INTERPRETERS_V_1_0_0, + } +} + + class MatchInfo(MatchLine): - field_names = ('Attribute', 'Value') + line_dict = INFO_LINE - pattern = re.compile(r"info\(\s*([^,]+)\s*,\s*(.+)\s*\)\.") + def __init__(self, version: Version, **kwargs): + super().__init__(version, **kwargs) + + self.interpret_fun = self.line_dict[self.version]["value"][self.attribute][0] + self.value_type = self.line_dict[self.version]["value"][self.attribute][2] + self.format_fun = { + "attribute": format_string, + "value": self.line_dict[self.version]["value"][self.attribute][1] + } - def __init__(self, attribute: str, value: Union[str, int]): - self.attribute = attribute - self.value = value @property def matchline(self): - matchline = f"info({self.attribute},{self.value})." + matchline = self.out_pattern.format( + **dict( + [ + (field, self.format_fun[field](getattr(self, field))) for field in self.field_names + ] + ) + ) + return matchline @classmethod - def from_matchline(cls, matchline: str): - re_info = cls.pattern.search(matchline) + def from_matchline(cls, matchline: str, pos: int = 0, version=CURRENT_VERSION): + + class_dict = INFO_LINE[version] + + match_pattern = class_dict['pattern'].search(matchline, pos=pos) + + if match_pattern is not None: + attribute, value_str = match_pattern.groups() + if attribute not in class_dict["value"].keys(): + raise ValueError( + f"Attribute {attribute} is not specified in version {version}" + ) - if re_info is not None: - attribute, value_str = re_info.groups() + value = class_dict["value"][attribute][0](value_str) - if attribute in ('matchFileVersion', 'composer', 'piece'): - value = value_str - elif attribute in ('midiClockRate', 'midiClockUnites'): - value = int(value_str) - else: - raise ValueError('Invalid attribute name!') + return cls(version=version, attribute=attribute, value=value) - return cls(attribute, value) + else: + raise MatchError("Input match line does not fit the expected pattern.") + +# class MatchInfoMatchFileVersion(MatchInfo): class MatchScoreProp(MatchLine): - field_names = ('Attribute', 'Value', 'Bar', '') + field_names = ("Attribute", "Value", "Bar", "") pattern = re.compile(r"info\(\s*([^,]+)\s*,\s*(.+)\s*\)\.") @@ -96,11 +235,15 @@ def from_matchline(cls, matchline: str): pass +def load_match(fn, create_part=False, pedal_threshold=64, first_note_at_zero=False): + pass - - +if __name__ == "__main__": + matchfile_version_line_str = "info(matchFileVersion,1.0.0)." -def load_match(fn, create_part=False, pedal_threshold=64, first_note_at_zero=False): - pass + matchfile_version_line = MatchInfo.from_matchline(matchfile_version_line_str) + + print(matchfile_version_line) + print(matchfile_version_line.matchline) From 8aada568e4c402dd0c57f992850b45df105c9ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Thu, 7 Apr 2022 07:41:54 +0200 Subject: [PATCH 03/88] re-structure match line (again) --- partitura/io/importmatch_new.py | 21 +++--- partitura/io/matchfile_fields.py | 121 +++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 partitura/io/matchfile_fields.py diff --git a/partitura/io/importmatch_new.py b/partitura/io/importmatch_new.py index 6924f98e..ba8f6e10 100644 --- a/partitura/io/importmatch_new.py +++ b/partitura/io/importmatch_new.py @@ -87,11 +87,7 @@ class MatchLine(object): out_pattern: str line_dict: dict - def __init__( - self, - version: Version, - **kwargs - ): + def __init__(self, version: Version, **kwargs): # set version self.version = version # Get pattern @@ -173,16 +169,16 @@ def __init__(self, version: Version, **kwargs): self.value_type = self.line_dict[self.version]["value"][self.attribute][2] self.format_fun = { "attribute": format_string, - "value": self.line_dict[self.version]["value"][self.attribute][1] + "value": self.line_dict[self.version]["value"][self.attribute][1], } - @property def matchline(self): matchline = self.out_pattern.format( **dict( [ - (field, self.format_fun[field](getattr(self, field))) for field in self.field_names + (field, self.format_fun[field](getattr(self, field))) + for field in self.field_names ] ) ) @@ -191,10 +187,10 @@ def matchline(self): @classmethod def from_matchline(cls, matchline: str, pos: int = 0, version=CURRENT_VERSION): - + class_dict = INFO_LINE[version] - - match_pattern = class_dict['pattern'].search(matchline, pos=pos) + + match_pattern = class_dict["pattern"].search(matchline, pos=pos) if match_pattern is not None: attribute, value_str = match_pattern.groups() @@ -210,6 +206,7 @@ def from_matchline(cls, matchline: str, pos: int = 0, version=CURRENT_VERSION): else: raise MatchError("Input match line does not fit the expected pattern.") + # class MatchInfoMatchFileVersion(MatchInfo): @@ -247,3 +244,5 @@ def load_match(fn, create_part=False, pedal_threshold=64, first_note_at_zero=Fal print(matchfile_version_line) print(matchfile_version_line.matchline) + + assert matchfile_version_line.matchline == matchfile_version_line_str diff --git a/partitura/io/matchfile_fields.py b/partitura/io/matchfile_fields.py new file mode 100644 index 00000000..1bebc51d --- /dev/null +++ b/partitura/io/matchfile_fields.py @@ -0,0 +1,121 @@ +import numpy as np + + +class FractionalSymbolicDuration(object): + """ + A class to represent symbolic duration information + """ + + def __init__(self, numerator, denominator=1, tuple_div=None, add_components=None): + + self.numerator = numerator + self.denominator = denominator + self.tuple_div = tuple_div + self.add_components = add_components + self.bound_integers(1024) + + def _str(self, numerator, denominator, tuple_div): + if denominator == 1 and tuple_div is None: + return str(numerator) + else: + if tuple_div is None: + return "{0}/{1}".format(numerator, denominator) + else: + return "{0}/{1}/{2}".format(numerator, denominator, tuple_div) + + def bound_integers(self, bound): + denominators = [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 12, + 14, + 16, + 18, + 20, + 22, + 24, + 28, + 32, + 48, + 64, + 96, + 128, + ] + sign = np.sign(self.numerator) * np.sign(self.denominator) + self.numerator = np.abs(self.numerator) + self.denominator = np.abs(self.denominator) + + if self.numerator > bound or self.denominator > bound: + val = float(self.numerator / self.denominator) + dif = [] + for den in denominators: + if np.round(val * den) > 0.9: + dif.append(np.abs(np.round(val * den) - val * den)) + else: + dif.append(np.abs(1 - val * den)) + + difn = np.array(dif) + min_idx = int(np.argmin(difn)) + + self.denominator = denominators[min_idx] + if int(np.round(val * self.denominator)) < 1: + self.numerator = sign * 1 + else: + self.numerator = sign * int(np.round(val * self.denominator)) + + def __str__(self): + + if self.add_components is None: + return self._str(self.numerator, self.denominator, self.tuple_div) + else: + r = [self._str(*i) for i in self.add_components] + return "+".join(r) + + def __add__(self, sd): + if isinstance(sd, int): + sd = FractionalSymbolicDuration(sd, 1) + + dens = np.array([self.denominator, sd.denominator], dtype=int) + new_den = np.lcm(dens[0], dens[1]) + a_mult = new_den // dens + new_num = np.dot(a_mult, [self.numerator, sd.numerator]) + + if self.add_components is None and sd.add_components is None: + add_components = [ + (self.numerator, self.denominator, self.tuple_div), + (sd.numerator, sd.denominator, sd.tuple_div), + ] + + elif self.add_components is not None and sd.add_components is None: + add_components = self.add_components + [ + (sd.numerator, sd.denominator, sd.tuple_div) + ] + elif self.add_components is None and sd.add_components is not None: + add_components = [ + (self.numerator, self.denominator, self.tuple_div) + ] + sd.add_components + else: + add_components = self.add_components + sd.add_components + + # Remove spurious components with 0 in the numerator + add_components = [c for c in add_components if c[0] != 0] + + return FractionalSymbolicDuration( + numerator=new_num, denominator=new_den, add_components=add_components + ) + + def __radd__(self, sd): + return self.__add__(sd) + + def __float__(self): + # Cast as float since the ability to return an instance of a strict + # subclass of float is deprecated, and may be removed in a future + # version of Python. (following a deprecation warning) + return float(self.numerator / (self.denominator * (self.tuple_div or 1))) From 1e403188199fce89bb67473e842582e4e2a7a17a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Mon, 13 Jun 2022 13:59:28 +0200 Subject: [PATCH 04/88] minor formatting --- partitura/io/importmatch_new.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/partitura/io/importmatch_new.py b/partitura/io/importmatch_new.py index ba8f6e10..26a24f59 100644 --- a/partitura/io/importmatch_new.py +++ b/partitura/io/importmatch_new.py @@ -209,10 +209,26 @@ def from_matchline(cls, matchline: str, pos: int = 0, version=CURRENT_VERSION): # class MatchInfoMatchFileVersion(MatchInfo): +SCOREPROP_LINE = { + Version(1, 0, 0): { + "pattern": None, + "field_names": ( + "attribute", + "value", + "measure", + "beat", + "offset", + "onset_in_beats", + ), + "matchline": "scoreProp({attribute},{value},{measure}:{beat},{offset},{onset_in_beats}).", + "value": None, + } +} + class MatchScoreProp(MatchLine): - field_names = ("Attribute", "Value", "Bar", "") + field_names = ("Attribute", "Value", "Measure", "") pattern = re.compile(r"info\(\s*([^,]+)\s*,\s*(.+)\s*\)\.") From 34537221df9fcb3bd0ee4298f7067f89af9c184c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Fri, 17 Jun 2022 16:08:10 +0200 Subject: [PATCH 05/88] minor documentation --- partitura/io/importmatch_new.py | 66 +++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/partitura/io/importmatch_new.py b/partitura/io/importmatch_new.py index 26a24f59..248d88f6 100644 --- a/partitura/io/importmatch_new.py +++ b/partitura/io/importmatch_new.py @@ -80,6 +80,11 @@ def format_string(value: str) -> str: class MatchLine(object): + """ + Main class representing a match line. + + This class should be subclassed for each different match lines. + """ version: Version field_names: tuple @@ -104,7 +109,7 @@ def __init__(self, version: Version, **kwargs): def __str__(self) -> str: """ - For printing the Method + Prints the printing the match line """ r = [self.__class__.__name__] for fn in self.field_names: @@ -113,10 +118,27 @@ def __str__(self) -> str: @property def matchline(self) -> str: + """ + Generate matchline as a string. + """ raise NotImplementedError @classmethod def from_matchline(cls, matchline: str, version: Version = CURRENT_MAJOR_VERSION): + """ + Create a new MatchLine object from a string + + Parameters + ---------- + matchline : str + String with a matchline + version : Version + Version of the matchline + + Returns + ------- + a MatchLine instance + """ raise NotImplementedError def check_types(self) -> bool: @@ -126,7 +148,7 @@ def check_types(self) -> bool: raise NotImplementedError -# Dictionary of interpreter, formatters and datatypes +# Dictionary of interpreter, formatters and datatypes for version 1.0.0 INFO_LINE_INTERPRETERS_V_1_0_0 = { "matchFileVersion": (interpret_version, format_version, Version), "piece": (interpret_as_string, format_string, str), @@ -146,10 +168,14 @@ def check_types(self) -> bool: "subtitle": (interpret_as_string, format_string, str), } +# Dictionary containing the definition of all versions of the MatchInfo line +# starting from version 1.0.0 INFO_LINE = { Version(1, 0, 0): { "pattern": re.compile( - r"info\(\s*(?P[^,]+)\s*,\s*(?P.+)\s*\)\." + # CC Allow for spaces? I think we should be strict and do not do this. + # r"info\(\s*(?P[^,]+)\s*,\s*(?P.+)\s*\)\." + r"info\((?P[^,]+),(?P.+)\)\." ), "field_names": ("attribute", "value"), "matchline": "info({attribute},{value}).", @@ -186,8 +212,30 @@ def matchline(self): return matchline @classmethod - def from_matchline(cls, matchline: str, pos: int = 0, version=CURRENT_VERSION): - + def from_matchline( + cls, + matchline: str, + pos: int = 0, + version=CURRENT_VERSION, + ) -> MatchLine: + """ + Create a new MatchLine object from a string + + Parameters + ---------- + matchline : str + String with a matchline + pos : int (optional) + Position of the matchline in the input string. By default it is + assumed that the matchline starts at the beginning of the input + string. + version : Version (optional) + Version of the matchline. By default it is the latest version. + + Returns + ------- + a MatchLine instance + """ class_dict = INFO_LINE[version] match_pattern = class_dict["pattern"].search(matchline, pos=pos) @@ -206,8 +254,10 @@ def from_matchline(cls, matchline: str, pos: int = 0, version=CURRENT_VERSION): else: raise MatchError("Input match line does not fit the expected pattern.") - -# class MatchInfoMatchFileVersion(MatchInfo): +SCOREPROP_LINE_INTERPRETERS_V_1_0_0 = { + "keySignature": (interpret_as_string, format_string, str), + "timeSignature": (interpret_as_string, format_string, str), +} SCOREPROP_LINE = { Version(1, 0, 0): { @@ -221,7 +271,7 @@ def from_matchline(cls, matchline: str, pos: int = 0, version=CURRENT_VERSION): "onset_in_beats", ), "matchline": "scoreProp({attribute},{value},{measure}:{beat},{offset},{onset_in_beats}).", - "value": None, + "value": SCOREPROP_LINE_INTERPRETERS_V_1_0_0, } } From 00e774f0076c722b89db26e3a008b0e31db24f4c Mon Sep 17 00:00:00 2001 From: fosfrancesco Date: Wed, 27 Jul 2022 17:22:41 +0200 Subject: [PATCH 06/88] add basic parameter and verovio --- partitura/io/importmei.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/partitura/io/importmei.py b/partitura/io/importmei.py index af4455f3..29a3b2f9 100644 --- a/partitura/io/importmei.py +++ b/partitura/io/importmei.py @@ -8,6 +8,8 @@ estimate_symbolic_duration, ) +import verovio + import re import logging import warnings @@ -105,7 +107,7 @@ def _ns_name(self, name, ns=None, all=False): else: return ".//{" + ns + "}" + name - def _parse_mei(self, mei_path): + def _parse_mei(self, mei_path, use_verovio = True): """ Parses an MEI file from path to an lxml tree. @@ -124,11 +126,21 @@ def _parse_mei(self, mei_path): remove_comments=True, remove_blank_text=True, ) - document = etree.parse(mei_path, parser) + + if use_verovio: + tk = verovio.toolkit(True) + tk.loadFile(mei_path) + mei_score = tk.getMEI("basic") + # document = etree.parse(mei_score, parser) + root = etree.fromstring(mei_score.encode('utf-8'), parser) + tree = etree.ElementTree(root) + else: + tree = etree.parse(mei_path,parser) + root = tree.get_root() # find the namespace - ns = document.getroot().nsmap[None] + ns = root.nsmap[None] # --> nsmap fetches a dict of the namespace Map, generally for root the key `None` fetches the namespace of the document. - return document, ns + return tree, ns # functions to parse staves info From 33437e5b66ef060e78fd892d20104de94e699fc5 Mon Sep 17 00:00:00 2001 From: fosfrancesco Date: Thu, 28 Jul 2022 16:43:55 +0200 Subject: [PATCH 07/88] handling multiple staff groups inside the main staffGroup, staff is always 1 in MEI import, since only parts with a single staff exist added a test for voices and staves --- partitura/io/importmei.py | 16 ++-- tests/__init__.py | 1 + tests/data/mei/test_merge_voices2.mei | 107 ++++++++++++++++++++++++++ tests/test_mei.py | 17 +++- 4 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 tests/data/mei/test_merge_voices2.mei diff --git a/partitura/io/importmei.py b/partitura/io/importmei.py index 29a3b2f9..7cf5c79a 100644 --- a/partitura/io/importmei.py +++ b/partitura/io/importmei.py @@ -416,6 +416,10 @@ def _handle_staffgroup(self, staffgroup_el): for s_el in staves_el: new_part = self._handle_initial_staffdef(s_el) staff_group.children.append(new_part) + staff_groups_el = staffgroup_el.findall(self._ns_name("staffGrp")) + for sg_el in staff_groups_el: + new_staffgroup = self._handle_staffgroup(sg_el) + staff_group.children.append(new_staffgroup) return staff_group def _handle_main_staff_group(self, main_staffgrp_el): @@ -577,7 +581,7 @@ def _handle_note(self, note_el, position, voice, staff, part) -> int: alter=alter, id=note_id, voice=voice, - staff=staff, + staff=1, symbolic_duration=symbolic_duration, articulations=None, # TODO : add articulation ) @@ -596,7 +600,7 @@ def _handle_note(self, note_el, position, voice, staff, part) -> int: alter=alter, id=note_id, voice=voice, - staff=staff, + staff=1, symbolic_duration=symbolic_duration, articulations=None, # TODO : add articulation ) @@ -634,7 +638,7 @@ def _handle_rest(self, rest_el, position, voice, staff, part): rest = score.Rest( id=rest_id, voice=voice, - staff=staff, + staff=1, symbolic_duration=symbolic_duration, articulations=None, ) @@ -677,7 +681,7 @@ def _handle_mrest(self, mrest_el, position, voice, staff, part): rest = score.Rest( id=mrest_id, voice=voice, - staff=staff, + staff=1, symbolic_duration=estimate_symbolic_duration(parts_per_measure, ppq), articulations=None, ) @@ -724,7 +728,7 @@ def _handle_chord(self, chord_el, position, voice, staff, part): alter=alter, id=note_id, voice=voice, - staff=staff, + staff=1, symbolic_duration=symbolic_duration, articulations=None, # TODO : add articulation ) @@ -898,7 +902,7 @@ def _handle_section(self, section_el, parts, position: int): # handle staves staves_el = element.findall(self._ns_name("staff")) if len(list(staves_el)) != len(list(parts)): - raise Exception("Not all parts are specified in measure" + i_el) + raise Exception(f"Not all parts are specified in measure {i_el}") end_positions = [] for i_s, (part, staff_el) in enumerate(zip(parts, staves_el)): end_positions.append( diff --git a/tests/__init__.py b/tests/__init__.py index 0280a48b..779c7b51 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -126,6 +126,7 @@ "test_barline.mei", "test_unfold_complex.mei", "test_articulation.mei", + "test_merge_voices2.mei", ] ] diff --git a/tests/data/mei/test_merge_voices2.mei b/tests/data/mei/test_merge_voices2.mei new file mode 100644 index 00000000..1e13d719 --- /dev/null +++ b/tests/data/mei/test_merge_voices2.mei @@ -0,0 +1,107 @@ + + + + + + + + test merge voices + + + 2022-07-14 + + + + + + Verovio +

Transcoded from MusicXML

+
+
+
+
+ + + + + + + test merge voices + + + + + + + + + Vo. 1 + + + + + + + + Vo. 2 + + + + + + + + + + Pno. + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
diff --git a/tests/test_mei.py b/tests/test_mei.py index f823eb8d..2c7c74f7 100644 --- a/tests/test_mei.py +++ b/tests/test_mei.py @@ -253,10 +253,19 @@ def test_parse_mei(self): part_list = load_mei(mei) self.assertTrue(True) - # def test_parse_all(self): - # for mei in Path("C:/Users/fosca/Desktop/CNAM/MEI dataset").iterdir(): - # part_list = load_mei(str(mei)) - + def test_voice(self): + parts = load_mei(MEI_TESTFILES[19]) + merged_part = score.merge_parts(parts, reassign="voice") + voices = merged_part.note_array()["voice"] + expected_voices = [5,4,3,2,1,1] + self.assertTrue(np.array_equal(voices, expected_voices) ) + + def test_staff(self): + parts = load_mei(MEI_TESTFILES[19]) + merged_part = score.merge_parts(parts, reassign="staff") + staves = merged_part.note_array(include_staff =True)["staff"] + expected_staves = [4,3,2,1,1,1] + self.assertTrue(np.array_equal(staves, expected_staves) ) if __name__ == "__main__": unittest.main() From 316c3138b02ddbae7c60100575f7bfc17d992ad6 Mon Sep 17 00:00:00 2001 From: fosfrancesco Date: Thu, 28 Jul 2022 16:47:44 +0200 Subject: [PATCH 08/88] clef has also always staff 1 in MEI import --- partitura/io/importmei.py | 6 ++++-- tests/test_mei.py | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/partitura/io/importmei.py b/partitura/io/importmei.py index 7cf5c79a..12c46114 100644 --- a/partitura/io/importmei.py +++ b/partitura/io/importmei.py @@ -294,9 +294,11 @@ def _handle_clef(self, element, position, part): # find the staff number parent = element.getparent() if parent.tag == self._ns_name("staffDef"): - number = parent.attrib["n"] + # number = parent.attrib["n"] + number = 1 else: # go back another level to staff element - number = parent.getparent().attrib["n"] + # number = parent.getparent().attrib["n"] + number = 1 sign = element.attrib["shape"] line = element.attrib["line"] octave = self._compute_clef_octave( diff --git a/tests/test_mei.py b/tests/test_mei.py index 2c7c74f7..61799483 100644 --- a/tests/test_mei.py +++ b/tests/test_mei.py @@ -125,12 +125,12 @@ def test_clef(self): self.assertTrue(clefs2[0].start.t == 0) self.assertTrue(clefs2[0].sign == "C") self.assertTrue(clefs2[0].line == 3) - self.assertTrue(clefs2[0].number == 3) + self.assertTrue(clefs2[0].number == 1) self.assertTrue(clefs2[0].octave_change == 0) self.assertTrue(clefs2[1].start.t == 8) self.assertTrue(clefs2[1].sign == "F") self.assertTrue(clefs2[1].line == 4) - self.assertTrue(clefs2[1].number == 3) + self.assertTrue(clefs2[1].number == 1) self.assertTrue(clefs2[1].octave_change == 0) # test on part 3 part3 = list(score.iter_parts(part_list))[3] @@ -140,7 +140,7 @@ def test_clef(self): self.assertTrue(clefs3[1].start.t == 4) self.assertTrue(clefs3[1].sign == "G") self.assertTrue(clefs3[1].line == 2) - self.assertTrue(clefs3[1].number == 4) + self.assertTrue(clefs3[1].number == 1) self.assertTrue(clefs3[1].octave_change == -1) def test_key_signature1(self): From b7fe1221bc5eb6d11afcf22b94868249114facdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Mon, 5 Sep 2022 17:07:43 +0200 Subject: [PATCH 09/88] add scoreProp lines (wip) --- partitura/io/importmatch_new.py | 80 +++++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/partitura/io/importmatch_new.py b/partitura/io/importmatch_new.py index 248d88f6..598ac01d 100644 --- a/partitura/io/importmatch_new.py +++ b/partitura/io/importmatch_new.py @@ -11,6 +11,7 @@ # from packaging import version import numpy as np +from matchfile_fields import FractionalSymbolicDuration # Define current version of the match file format CURRENT_MAJOR_VERSION = 1 @@ -83,7 +84,7 @@ class MatchLine(object): """ Main class representing a match line. - This class should be subclassed for each different match lines. + This class should be subclassed for the different match lines. """ version: Version @@ -92,7 +93,7 @@ class MatchLine(object): out_pattern: str line_dict: dict - def __init__(self, version: Version, **kwargs): + def __init__(self, version: Version, **kwargs) -> None: # set version self.version = version # Get pattern @@ -121,10 +122,23 @@ def matchline(self) -> str: """ Generate matchline as a string. """ - raise NotImplementedError + matchline = self.out_pattern.format( + **dict( + [ + (field, self.format_fun[field](getattr(self, field))) + for field in self.field_names + ] + ) + ) + + return matchline @classmethod - def from_matchline(cls, matchline: str, version: Version = CURRENT_MAJOR_VERSION): + def from_matchline( + cls, + matchline: str, + version: Version = CURRENT_MAJOR_VERSION, + ): """ Create a new MatchLine object from a string @@ -149,6 +163,9 @@ def check_types(self) -> bool: # Dictionary of interpreter, formatters and datatypes for version 1.0.0 +# each entry in the dictionary is a tuple with +# an intepreter (to parse the input), a formatter (for the output matchline) +# and type INFO_LINE_INTERPRETERS_V_1_0_0 = { "matchFileVersion": (interpret_version, format_version, Version), "piece": (interpret_as_string, format_string, str), @@ -173,9 +190,9 @@ def check_types(self) -> bool: INFO_LINE = { Version(1, 0, 0): { "pattern": re.compile( - # CC Allow for spaces? I think we should be strict and do not do this. + # CC: Allow spaces? I think we should be strict and do not do this. # r"info\(\s*(?P[^,]+)\s*,\s*(?P.+)\s*\)\." - r"info\((?P[^,]+),(?P.+)\)\." + r"info\((?P[^,]+),(?P.+)\)\." ), "field_names": ("attribute", "value"), "matchline": "info({attribute},{value}).", @@ -188,7 +205,7 @@ class MatchInfo(MatchLine): line_dict = INFO_LINE - def __init__(self, version: Version, **kwargs): + def __init__(self, version: Version, **kwargs) -> None: super().__init__(version, **kwargs) self.interpret_fun = self.line_dict[self.version]["value"][self.attribute][0] @@ -199,7 +216,7 @@ def __init__(self, version: Version, **kwargs): } @property - def matchline(self): + def matchline(self) -> str: matchline = self.out_pattern.format( **dict( [ @@ -254,6 +271,7 @@ def from_matchline( else: raise MatchError("Input match line does not fit the expected pattern.") + SCOREPROP_LINE_INTERPRETERS_V_1_0_0 = { "keySignature": (interpret_as_string, format_string, str), "timeSignature": (interpret_as_string, format_string, str), @@ -261,7 +279,9 @@ def from_matchline( SCOREPROP_LINE = { Version(1, 0, 0): { - "pattern": None, + "pattern": re.compile( + r"scoreProp\((?P[^,]+),(?P[^,]+),(?P\d+):(?P[\d\/]*)\)\." + ), "field_names": ( "attribute", "value", @@ -278,24 +298,38 @@ def from_matchline( class MatchScoreProp(MatchLine): - field_names = ("Attribute", "Value", "Measure", "") + line_dict = SCOREPROP_LINE - pattern = re.compile(r"info\(\s*([^,]+)\s*,\s*(.+)\s*\)\.") + def __init__(self, version: Version, **kwargs) -> None: + super().__init__(version, **kwargs) - def __init__(self, attribute: str, value: str, bar: int, beat: float): - self.attribute = attribute - self.value = value - self.bar = bar - self.beat = beat + self.interpret_fun = self.line_dict[self.version]["value"] + + +class KeySignatureLine(MatchScoreProp): + + + def __init__( + self, + version: Version, + key_signature: str, + measure: int, + beat: int, + offset: Union[int, FractionalSymbolicDuration], + onset_in_beats: float + ) -> None: + super().__init__( + version=version, + attribute='keySignature', + value=key_signature, + measure=measure, + beat=beat, + offset=offset, + onset_in_beats=onset_in_beats + ) - @property - def matchline(self): - matchline = f"scoreprop({self.attribute},{self.value},{self.bar},{self.beat})." - return matchline + - @classmethod - def from_matchline(cls, matchline: str): - pass def load_match(fn, create_part=False, pedal_threshold=64, first_note_at_zero=False): From f605cd2c8f788c89ddfe0e3d6c933919992c666f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Wed, 2 Nov 2022 07:38:45 +0100 Subject: [PATCH 10/88] add option to binarize piano roll --- partitura/utils/music.py | 72 +++++++++++++++++++++++++--------------- tests/test_pianoroll.py | 48 +++++++++++++++++++++++++-- 2 files changed, 92 insertions(+), 28 deletions(-) diff --git a/partitura/utils/music.py b/partitura/utils/music.py index c7c73361..b9a4cc48 100644 --- a/partitura/utils/music.py +++ b/partitura/utils/music.py @@ -3,15 +3,25 @@ """ This module contains music related utilities """ +from __future__ import annotations + from collections import defaultdict import re import warnings import numpy as np from scipy.interpolate import interp1d from scipy.sparse import csc_matrix -from typing import Union, Callable +from typing import Union, Callable, Optional, TYPE_CHECKING from partitura.utils.generic import find_nearest, search, iter_current_next +if TYPE_CHECKING: + # Import typing info for typing annotations. + # For this to work we need to import annotations from __future__ + # Solution from + # https://medium.com/quick-code/python-type-hinting-eliminating-importerror-due-to-circular-imports-265dfb0580f8 + from partitura.score import ScoreLike + from partitura.performance import PerformanceLike + MIDI_BASE_CLASS = {"c": 0, "d": 2, "e": 4, "f": 5, "g": 7, "a": 9, "b": 11} # _MORPHETIC_BASE_CLASS = {'c': 0, 'd': 1, 'e': 2, 'f': 3, 'g': 4, 'a': 5, 'b': 6} # _MORPHETIC_OCTAVE = {0: 32, 1: 39, 2: 46, 3: 53, 4: 60, 5: 67, 6: 74, 7: 81, 8: 89} @@ -930,18 +940,19 @@ def estimate_clef_properties(pitches): def compute_pianoroll( - note_info, - time_unit="auto", - time_div="auto", - onset_only=False, - note_separation=False, - pitch_margin=-1, - time_margin=0, - return_idxs=False, - piano_range=False, - remove_drums=True, - remove_silence=True, - end_time=None, + note_info: Union[np.ndarray, ScoreLike, PerformanceLike], + time_unit: str = "auto", + time_div: Union[str, int] = "auto", + onset_only: bool = False, + note_separation: bool = False, + pitch_margin: int = -1, + time_margin: int = 0, + return_idxs: bool = False, + piano_range: bool = False, + remove_drums: bool = True, + remove_silence: bool = True, + end_time: Optional[int] = None, + binary: bool = False, ): """Computes a piano roll from a structured note array (as generated by the `note_array` methods in `partitura.score.Part` @@ -949,7 +960,7 @@ def compute_pianoroll( Parameters ---------- - note_info : structured array, `Part`, `PartGroup`, `PerformedPart` + note_info : np.ndarray, ScoreLike, PerformanceLike Note information time_unit : ('auto', 'beat', 'quarter', 'div', 'second') time_div : int, optional @@ -987,6 +998,8 @@ def compute_pianoroll( The time corresponding to the ending of the last pianoroll frame (in time_unit). If None this is set to the last note offset. + binary: bool, optional + Ensure a strictly binary piano roll. Returns ------- @@ -1071,28 +1084,29 @@ def compute_pianoroll( piano_range=piano_range, remove_silence=remove_silence, end_time=end_time, + binary=binary, ) def _make_pianoroll( - note_info, - onset_only=False, - pitch_margin=-1, - time_margin=0, - time_div=8, - note_separation=True, - return_idxs=False, - piano_range=False, - remove_silence=True, - min_time=None, - end_time=None, + note_info: np.ndarray, + onset_only: bool = False, + pitch_margin: int = -1, + time_margin: int = 0, + time_div: int = 8, + note_separation: bool = True, + return_idxs: bool = False, + piano_range: bool = False, + remove_silence: bool = True, + min_time: Optional[float] = None, + end_time: Optional[int] = None, + binary: bool = False, ): # non-public """Computes a piano roll from a numpy array with MIDI pitch, onset, duration and (optionally) MIDI velocity information. See `compute_pianoroll` for a complete description of the arguments of this function. - """ # Get pitch, onset, offset from the note_info array @@ -1200,6 +1214,12 @@ def _make_pianoroll( for i, ((row, column), vel) in enumerate(fill_dict.items()): idx_fill[i] = np.array([row, column, max(vel)]) + if binary: + idx_fill[:, 2] = np.clip( + idx_fill[:, 2], + a_min=0, + a_max=1 + ) # Fill piano roll pianoroll = csc_matrix( (idx_fill[:, 2], (idx_fill[:, 0], idx_fill[:, 1])), shape=(M, N), dtype=int diff --git a/tests/test_pianoroll.py b/tests/test_pianoroll.py index 33361a34..8aa7aa3f 100644 --- a/tests/test_pianoroll.py +++ b/tests/test_pianoroll.py @@ -9,10 +9,15 @@ from functools import partial from partitura.utils.music import compute_pianoroll, pianoroll_to_notearray -from partitura import load_musicxml, load_score, load_kern +from partitura import load_musicxml, load_score, load_kern, load_performance import partitura -from tests import MUSICXML_IMPORT_EXPORT_TESTFILES, PIANOROLL_TESTFILES, KERN_TESTFILES +from tests import ( + MUSICXML_IMPORT_EXPORT_TESTFILES, + PIANOROLL_TESTFILES, + KERN_TESTFILES, + MOZART_VARIATION_FILES, +) LOGGER = logging.getLogger(__name__) @@ -120,6 +125,45 @@ def test_time_margin_pianoroll(self): equal = np.all(pr.toarray() == expected_pr) self.assertTrue(equal) + def test_binary_pianoroll(self): + """ + Test `binary` parameter in `compute_pianoroll`. + """ + # Test with a performance since they include MIDI velocity + # in the piano roll. + performance_fn = MOZART_VARIATION_FILES["midi"] + + performance = load_performance(performance_fn) + + note_array = performance.note_array() + + piano_roll_non_binary, idx_non_binary = compute_pianoroll( + note_info=performance, binary=False, return_idxs=True + ) + + piano_roll_binary, idx_binary = compute_pianoroll( + note_info=performance, binary=True, return_idxs=True + ) + + # assert that the maximal value of the binary piano roll is 1 + self.assertTrue(piano_roll_binary.max() == 1) + # assert that the opposite is true for the non_binary piano roll + # (this is only the case for performances where there is MIDI velocity) + self.assertTrue(piano_roll_non_binary.max() == note_array["velocity"].max()) + + # assert that indices in both piano rolls are the same + self.assertTrue(np.all(idx_non_binary == idx_binary)) + + # Test that the binary piano roll has only values in 0 and one + unique_values_binary = np.unique(piano_roll_binary.toarray()) + self.assertTrue(set(unique_values_binary) == set([0, 1])) + + # Assert that the binary piano roll is equivalent to binarizing + # the original piano roll + binarized_pr = piano_roll_non_binary.toarray().copy() + binarized_pr[binarized_pr != 0] = 1 + self.assertTrue(np.all(binarized_pr == piano_roll_binary.toarray())) + class TestNotesFromPianoroll(unittest.TestCase): """ From 539c887e266705a7fded695c488ea38d26ac09b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Wed, 2 Nov 2022 08:03:46 +0100 Subject: [PATCH 11/88] add compute_pitch_class_pianoroll --- partitura/utils/music.py | 138 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 130 insertions(+), 8 deletions(-) diff --git a/partitura/utils/music.py b/partitura/utils/music.py index b9a4cc48..3fd3db14 100644 --- a/partitura/utils/music.py +++ b/partitura/utils/music.py @@ -954,9 +954,19 @@ def compute_pianoroll( end_time: Optional[int] = None, binary: bool = False, ): - """Computes a piano roll from a structured note array (as - generated by the `note_array` methods in `partitura.score.Part` - and `partitura.performance.PerformedPart` instances). + """ + Computes a piano roll from a score-like, performance-like or a + note array. + + A piano roll is a 2D matrix of size (`pitch_range`, `num_time_steps`), where each + row represents a MIDI pitch and each column represents a time step. The (i,j)-th + element specifies whether pitch i is active (i.e., non-zero) at time step j. + + The `pitch_range` is specified by the parameters `piano_range` and `pitch_margin`, + (see below), but it defaults to 128 (the standard range of MIDI note numbers), + or 88 if `piano_range` is True. The `num_time_steps` are specified by the temporal + resolution of the piano roll and the length of the piece, and can be controlled + with parameters `time_div`, `time_unit` and `time_margin` below. Parameters ---------- @@ -1215,11 +1225,9 @@ def _make_pianoroll( idx_fill[i] = np.array([row, column, max(vel)]) if binary: - idx_fill[:, 2] = np.clip( - idx_fill[:, 2], - a_min=0, - a_max=1 - ) + # binarize piano roll + idx_fill[idx_fill[:, 2] != 0, 2] = 1 + # Fill piano roll pianoroll = csc_matrix( (idx_fill[:, 2], (idx_fill[:, 0], idx_fill[:, 1])), shape=(M, N), dtype=int @@ -1240,6 +1248,120 @@ def _make_pianoroll( return pianoroll +def compute_pitch_class_pianoroll( + note_info: Union[ScoreLike, PerformanceLike, np.ndarray, csc_matrix], + normalize: bool = True, + time_unit: str = "auto", + time_div: int = "auto", + onset_only: bool = False, + note_separation: bool = False, + time_margin: int = 0, + remove_silence: bool = True, + end_time: Optional[float] = None, + binary: bool = False, +) -> np.ndarray: + """ + Compute a pitch class piano roll from a score-like or performance-like objects, or + from a note array as a structured numpy array. + + A pitch class piano roll is a 2D matrix of size (12, num_time_steps), where each + row represents a pitch class (C=0, C#=1, D, etc.) and each column represents a time + step. The (i,j)-th element specifies whether pitch class i is active at time step + j. + + Parameters + ---------- + note_info : np.ndarray, ScoreLike, PerformanceLike + Note information + normalize: bool + Normalize the piano roll. If True, each slice (i.e., time-step) + will be normalized to sum to one. The resulting output is + a piano roll where each time step is the pitch class distribution. + time_unit : ('auto', 'beat', 'quarter', 'div', 'second') + time_div : int, optional + How many sub-divisions for each time unit (beats for a score + or seconds for a performance. See `is_performance` below). + onset_only : bool, optional + If True, code only the onsets of the notes, otherwise code + onset and duration. + pitch_margin : int, optional + If `pitch_margin` > -1, the resulting array will have + `pitch_margin` empty rows above and below the highest and + lowest pitches, respectively; if `pitch_margin` == -1, the + resulting pianoroll will have span the fixed pitch range + between (and including) 1 and 127. + time_margin : int, optional + The resulting array will have `time_margin` * `time_div` empty + columns before and after the piano roll + return_idxs : bool, optional + If True, return the indices (i.e., the coordinates) of each + note in the piano roll. + piano_range : bool, optional + If True, the pitch axis of the piano roll is in piano keys + instead of MIDI note numbers (and there are only 88 pitches). + This is equivalent as slicing `piano_range_pianoroll = + pianoroll[21:109, :]`. + remove_drums : bool, optional + If True, removes the drum track (i.e., channel 9) from the + notes to be considered in the piano roll. This option is only + relevant for piano rolls generated from a `PerformedPart`. + Default is True. + remove_silence : bool, optional + If True, the first frame of the pianoroll starts at the onset + of the first note, not at time 0 of the timeline. + end_time : int, optional + The time corresponding to the ending of the last + pianoroll frame (in time_unit). + If None this is set to the last note offset. + binary: bool, optional + Ensure a strictly binary piano roll. + + + Returns + ------- + pc_pianoroll : np.ndarray + The pitch class piano roll + """ + pianoroll = None + if isinstance(note_info, csc_matrix): + # if input is a pianoroll as a sparse matrix + pianoroll = note_info + + if pianoroll is None: + + pianoroll = compute_pianoroll( + note_info=note_info, + time_unit=time_unit, + time_div=time_div, + onset_only=onset_only, + note_separation=note_separation, + pitch_margin=-1, + time_margin=time_margin, + return_idxs=False, + piano_range=False, + remove_drums=True, + remove_silence=remove_silence, + end_time=end_time, + ) + + pc_pianoroll = np.zeros((12, pianoroll.shape[1]), dtype=float) + for i in range(int(np.ceil(128 / 12))): + pr_slice = pianoroll[i * 12 : (i + 1) * 12, :].toarray().astype(float) + pc_pianoroll[: pr_slice.shape[0], :] += pr_slice + + if binary: + # only show active pitch classes + pc_pianoroll[pc_pianoroll > 0] = 1 + + if normalize: + norm_term = pc_pianoroll.sum(0) + # avoid dividing by 0 if a slice is empty + norm_term[np.isclose(norm_term, 0)] = 1 + pc_pianoroll /= norm_term + + return pc_pianoroll + + def pianoroll_to_notearray(pianoroll, time_div=8, time_unit="sec"): """Extract a structured note array from a piano roll. From c26dc6f90c5a82a22cb94072789d17686181d95a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Wed, 2 Nov 2022 08:15:22 +0100 Subject: [PATCH 12/88] update documentation and fix typo --- partitura/utils/music.py | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/partitura/utils/music.py b/partitura/utils/music.py index 3fd3db14..51728807 100644 --- a/partitura/utils/music.py +++ b/partitura/utils/music.py @@ -972,7 +972,10 @@ def compute_pianoroll( ---------- note_info : np.ndarray, ScoreLike, PerformanceLike Note information - time_unit : ('auto', 'beat', 'quarter', 'div', 'second') + time_unit : ('auto', 'beat', 'quarter', 'div', 'sec') + The time unit to use for computing the piano roll. If "auto", + the time unit defaults to "beat" for score-like objects and + "sec" for performance-like objects. time_div : int, optional How many sub-divisions for each time unit (beats for a score or seconds for a performance. See `is_performance` below). @@ -1113,7 +1116,8 @@ def _make_pianoroll( binary: bool = False, ): # non-public - """Computes a piano roll from a numpy array with MIDI pitch, + """ + Computes a piano roll from a numpy array with MIDI pitch, onset, duration and (optionally) MIDI velocity information. See `compute_pianoroll` for a complete description of the arguments of this function. @@ -1265,19 +1269,24 @@ def compute_pitch_class_pianoroll( from a note array as a structured numpy array. A pitch class piano roll is a 2D matrix of size (12, num_time_steps), where each - row represents a pitch class (C=0, C#=1, D, etc.) and each column represents a time - step. The (i,j)-th element specifies whether pitch class i is active at time step - j. + row represents a pitch class (C=0, C#=1, D=2, etc.) and each column represents a + time step. The (i,j)-th element specifies whether pitch class i is active at time + step j. + + See `compute_pianoroll` for more details. Parameters ---------- note_info : np.ndarray, ScoreLike, PerformanceLike - Note information + Note information. normalize: bool Normalize the piano roll. If True, each slice (i.e., time-step) will be normalized to sum to one. The resulting output is a piano roll where each time step is the pitch class distribution. - time_unit : ('auto', 'beat', 'quarter', 'div', 'second') + time_unit : ('auto', 'beat', 'quarter', 'div', 'sec') + The time unit to use for computing the piano roll. If "auto", + the time unit defaults to "beat" for score-like objects and + "sec" for performance-like objects. time_div : int, optional How many sub-divisions for each time unit (beats for a score or seconds for a performance. See `is_performance` below). @@ -1310,8 +1319,8 @@ def compute_pitch_class_pianoroll( If True, the first frame of the pianoroll starts at the onset of the first note, not at time 0 of the timeline. end_time : int, optional - The time corresponding to the ending of the last - pianoroll frame (in time_unit). + The time corresponding to the ending of the last + pianoroll frame (in time_unit). If None this is set to the last note offset. binary: bool, optional Ensure a strictly binary piano roll. @@ -1320,7 +1329,13 @@ def compute_pitch_class_pianoroll( Returns ------- pc_pianoroll : np.ndarray - The pitch class piano roll + The pitch class piano roll. The sizes of the + dimensions vary with the parameters `pitch_margin`, + `time_margin`, `time_div`, `remove silence`, and `end_time`. + pr_idx : ndarray + Indices of the onsets and offsets of the notes in the piano + roll (in the same order as the input note_array). This is only + returned if `return_idxs` is `True`. """ pianoroll = None if isinstance(note_info, csc_matrix): From 3fe9e07496ac1ea64246d8dfe2495de5aa0a96ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Wed, 2 Nov 2022 13:17:18 +0100 Subject: [PATCH 13/88] add tests for pitch class piano roll --- partitura/utils/music.py | 192 ++++++++++++++++++++++++++++++++------- tests/test_pianoroll.py | 78 +++++++++++++++- tests/test_utils.py | 54 ++++++++++- 3 files changed, 288 insertions(+), 36 deletions(-) diff --git a/partitura/utils/music.py b/partitura/utils/music.py index 51728807..42aa43d3 100644 --- a/partitura/utils/music.py +++ b/partitura/utils/music.py @@ -20,7 +20,7 @@ # Solution from # https://medium.com/quick-code/python-type-hinting-eliminating-importerror-due-to-circular-imports-265dfb0580f8 from partitura.score import ScoreLike - from partitura.performance import PerformanceLike + from partitura.performance import PerformanceLike, Performance MIDI_BASE_CLASS = {"c": 0, "d": 2, "e": 4, "f": 5, "g": 7, "a": 9, "b": 11} # _MORPHETIC_BASE_CLASS = {'c': 0, 'd': 1, 'e': 2, 'f': 3, 'g': 4, 'a': 5, 'b': 6} @@ -1023,8 +1023,13 @@ def compute_pianoroll( `time_margin`, `time_div`, `remove silence`, and `end_time`. pr_idx : ndarray Indices of the onsets and offsets of the notes in the piano - roll (in the same order as the input note_array). This is only - returned if `return_idxs` is `True`. + roll (in the same order as the input note_array). This is only` + returned if `return_idxs` is True. The indices have 4 columns + (`vertical_position_in_piano_roll`, `onset`, `offset`, `original_midi_pitch`). + The `vertical_position_in_piano_roll` might be different from + `original_midi_pitch` depending on the `pitch_margin` and `piano_range` + arguments. + Examples -------- @@ -1245,7 +1250,7 @@ def _make_pianoroll( if return_idxs: # indices of each note in the piano roll pr_idx = np.column_stack( - [pr_pitch - pr_idx_pitch_start, pr_onset, pr_offset] + [pr_pitch - pr_idx_pitch_start, pr_onset, pr_offset, note_info[idx, 0]] ).astype(int) return pianoroll, pr_idx[idx.argsort()] else: @@ -1253,13 +1258,14 @@ def _make_pianoroll( def compute_pitch_class_pianoroll( - note_info: Union[ScoreLike, PerformanceLike, np.ndarray, csc_matrix], + note_info: Union[ScoreLike, PerformanceLike, np.ndarray], normalize: bool = True, time_unit: str = "auto", time_div: int = "auto", onset_only: bool = False, note_separation: bool = False, time_margin: int = 0, + return_idxs: int = False, remove_silence: bool = True, end_time: Optional[float] = None, binary: bool = False, @@ -1293,12 +1299,6 @@ def compute_pitch_class_pianoroll( onset_only : bool, optional If True, code only the onsets of the notes, otherwise code onset and duration. - pitch_margin : int, optional - If `pitch_margin` > -1, the resulting array will have - `pitch_margin` empty rows above and below the highest and - lowest pitches, respectively; if `pitch_margin` == -1, the - resulting pianoroll will have span the fixed pitch range - between (and including) 1 and 127. time_margin : int, optional The resulting array will have `time_margin` * `time_div` empty columns before and after the piano roll @@ -1335,29 +1335,30 @@ def compute_pitch_class_pianoroll( pr_idx : ndarray Indices of the onsets and offsets of the notes in the piano roll (in the same order as the input note_array). This is only - returned if `return_idxs` is `True`. + returned if `return_idxs` is `True`. The indices have 4 columns + (pitch_class, onset, offset, original_midi_pitch). """ - pianoroll = None - if isinstance(note_info, csc_matrix): - # if input is a pianoroll as a sparse matrix - pianoroll = note_info - - if pianoroll is None: - - pianoroll = compute_pianoroll( - note_info=note_info, - time_unit=time_unit, - time_div=time_div, - onset_only=onset_only, - note_separation=note_separation, - pitch_margin=-1, - time_margin=time_margin, - return_idxs=False, - piano_range=False, - remove_drums=True, - remove_silence=remove_silence, - end_time=end_time, - ) + + pianoroll = compute_pianoroll( + note_info=note_info, + time_unit=time_unit, + time_div=time_div, + onset_only=onset_only, + note_separation=note_separation, + pitch_margin=-1, + time_margin=time_margin, + return_idxs=return_idxs, + piano_range=False, + remove_drums=True, + remove_silence=remove_silence, + end_time=end_time, + binary=False, + ) + + if return_idxs: + pianoroll, pr_idxs = pianoroll + # update indices by converting MIDI pitch to pitch class + pr_idxs[:, 0] = np.mod(pr_idxs[:, 0], 12) pc_pianoroll = np.zeros((12, pianoroll.shape[1]), dtype=float) for i in range(int(np.ceil(128 / 12))): @@ -1374,6 +1375,8 @@ def compute_pitch_class_pianoroll( norm_term[np.isclose(norm_term, 0)] = 1 pc_pianoroll /= norm_term + if return_idxs: + return pc_pianoroll, pr_idxs return pc_pianoroll @@ -3028,6 +3031,129 @@ def get_matched_notes(spart_note_array, ppart_note_array, alignment): return np.array(matched_idxs) +def generate_random_performance_note_array( + num_notes: int = 20, + rng: Union[int, np.random.RandomState] = np.random.RandomState(1984), + duration: float = 10, + max_note_duration: float = 2, + min_note_duration: float = 0.1, + max_velocity: int = 90, + min_velocity: int = 20, + return_performance: bool = False, +) -> Union[np.ndarray, Performance]: + """ + Create a random performance note array. + + Parameters + ---------- + num_notes : int + Number of notes + rng : int or np.random.RandomState + State for the random number generator. If an integer is given + a new random number generator with that state will be created. + duration : float + Total duration of the note array in seconds. Default is 10. + max_note_duration : float + Maximum duration of a note in seconds. Note that since the durations + are randomly sampled from a uniform distribution, it could happen + that no notes actually have this duration. + min_note_duration: float + Minimum duration of a note in seconds. Note that since the durations + are randomly sampled from a uniform distribution, it could happen + that no notes actually have this duration. + max_velocity : int + Maximal MIDI velocity. Note that since the MIDI velocities + are randomly sampled from a uniform distribution, it could happen + that no notes actually have this velocity. + min_velocity : int + Maximal MIDI velocity. Note that since the MIDI velocities + are randomly sampled from a uniform distribution, it could happen + that no notes actually have this velocity. + return_performance : bool + If True, returns a `Performance` object. + + Returns + ------- + note_array or performance : np.ndarray or Performance + If `return_performance` is True, the output is a `Performance` instance. + Otherwise, it returns a structured note array with note information. + """ + # Generate a random piano roll + + if isinstance(rng, int): + rng = np.random.RandomState(rng) + + note_array = np.empty( + num_notes, + dtype=[ + ("pitch", "i4"), + ("onset_sec", "f4"), + ("duration_sec", "f4"), + ("velocity", "i4"), + ("id", "U256"), + ], + ) + + if max_note_duration >= duration: + warnings.warn( + message=( + "`duration` is smaller than `max_note_duration`! " + "The `max_note_duration` has been adjusted to be equal to " + "`0.5 * duration`." + ) + ) + max_note_duration = 0.5 * duration + + note_array["pitch"] = rng.randint(1, 128, num_notes) + + note_duration = rng.uniform( + low=min_note_duration, + high=max_note_duration, + size=num_notes, + ) + + onset = rng.uniform( + low=0, + high=1, + size=num_notes + ) + + # Onsets start at 0 and end at duration - the smalles note duration + onset = (duration - note_duration.min()) * (onset - onset.min()) / onset.max() + + # Ensure that the offsets end at the specified duration. + offset = np.clip( + onset + note_duration, + a_min=min_note_duration, + a_max=duration + ) + + note_array["duration_sec"] = offset - onset + + sort_idxs = onset.argsort() + + # Note ids are sorted by onset. + note_array["id"] = np.array([f"n{i}" for i in sort_idxs]) + + note_array["onset_sec"] = onset + + note_array["velocity"] = rng.randint( + min_velocity, + max_velocity + 1, + num_notes, + ) + + if return_performance: + from partitura.performance import Performance, PerformedPart + + performed_part = PerformedPart.from_note_array(note_array) + performance = Performance(performed_part, performer=str(rng)) + + return performance + + return note_array + + if __name__ == "__main__": import doctest diff --git a/tests/test_pianoroll.py b/tests/test_pianoroll.py index 8aa7aa3f..18eed529 100644 --- a/tests/test_pianoroll.py +++ b/tests/test_pianoroll.py @@ -8,8 +8,12 @@ import unittest from functools import partial -from partitura.utils.music import compute_pianoroll, pianoroll_to_notearray -from partitura import load_musicxml, load_score, load_kern, load_performance +from partitura.utils.music import ( + compute_pianoroll, + pianoroll_to_notearray, + compute_pitch_class_pianoroll, +) +from partitura import load_musicxml, load_score, load_performance import partitura from tests import ( @@ -21,6 +25,8 @@ LOGGER = logging.getLogger(__name__) +RNG = np.random.RandomState(1984) + class TestPianorollFromNotes(unittest.TestCase): """ @@ -368,3 +374,71 @@ def test_pianoroll_length(self): # compute pianorolls for all separated voices prs = [get_pianoroll(part) for part in parts] self.assertTrue(pr.shape == prs[0].shape for pr in prs) + + +class TestPitchClassPianoroll(unittest.TestCase): + """ + Test pitch class piano roll + """ + + def test_midi_pitch_to_pitch_class(self): + """ + Test that all MIDI pitches would be correctly represented + in the pitch class piano roll + """ + for pitch in range(128): + note_array = np.array( + [(pitch, 0, 1)], + dtype=[ + ("pitch", "i4"), + ("onset_beat", "f4"), + ("duration_beat", "f4"), + ], + ) + + time_div = 2 + pr = compute_pitch_class_pianoroll(note_array, time_div=time_div) + + expected_pr = np.zeros((12, time_div)) + + expected_pr[pitch % 12] = 1 + + equal = np.all(pr == expected_pr) + + self.assertEqual(equal, True) + + def test_indices(self): + """ + Test indices from the piano roll + """ + # Generate a random piano roll + note_array = partitura.utils.music.generate_random_performance_note_array(100) + pianoroll, pr_idxs = compute_pianoroll( + note_info=note_array, + return_idxs=True, + time_unit="sec", + time_div=10, + ) + + pc_pianoroll, pcr_idxs = compute_pitch_class_pianoroll( + note_info=note_array, + return_idxs=True, + time_unit="sec", + time_div=10, + ) + + # Assert that there is an index for each note + self.assertTrue(len(pcr_idxs) == len(note_array)) + self.assertTrue(len(pcr_idxs) == len(pr_idxs)) + + # Assert that the indices correspond to the same notes as in the piano roll + self.assertTrue(np.all(pcr_idxs[:, 3] == note_array["pitch"])) + + # Test that MIDI pitch and pitch class are correct + self.assertTrue(np.all(np.mod(pr_idxs[:, 3], 12) == pcr_idxs[:, 0])) + # Assert that MIDI pitch info is identical for pc_pianoroll and + # regular piano rolls + self.assertTrue(np.all(pr_idxs[:, 3] == pcr_idxs[:, 3])) + + # Onsets and offsets should be identical + self.assertTrue(np.all(pr_idxs[:, 2:4] == pcr_idxs[:, 2:4])) diff --git a/tests/test_utils.py b/tests/test_utils.py index 5af2bf01..6636ebdb 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -10,7 +10,6 @@ from partitura.utils import music from tests import MATCH_IMPORT_EXPORT_TESTFILES, VOSA_TESTFILES, MOZART_VARIATION_FILES - RNG = np.random.RandomState(1984) @@ -359,3 +358,56 @@ def vel_fun(onset): except ValueError: # We are expecting the previous code to trigger an error self.assertTrue(True) + + def test_generate_random_performance_note_array(self): + """ + Test `generate_random_performance_note_array` method + """ + n_notes = 100 + duration = 15 + max_note_duration = 9 + min_note_duration = 1 + max_velocity = 75 + min_velocity = 30 + random_note_array = music.generate_random_performance_note_array( + num_notes=n_notes, + rng=1234, + duration=duration, + max_note_duration=max_note_duration, + min_note_duration=min_note_duration, + max_velocity=max_velocity, + min_velocity=min_velocity, + return_performance=False, + ) + + # Assert that the output is a numpy array + self.assertTrue(isinstance(random_note_array, np.ndarray)) + # Test that the generated array has the specified number of notes + self.assertTrue(len(random_note_array) == n_notes) + + offsets = random_note_array["onset_sec"] + random_note_array["duration_sec"] + + # Test that the note array has the specified duration + self.assertTrue(np.isclose(offsets.max(), duration)) + + # Test that the generated durations and velocities are within the + # specified bounds + self.assertTrue(np.all(random_note_array["duration_sec"] <= max_note_duration)) + self.assertTrue(np.all(random_note_array["duration_sec"] >= min_note_duration)) + self.assertTrue(np.all(random_note_array["velocity"] >= min_velocity)) + self.assertTrue(np.all(random_note_array["velocity"] <= max_velocity)) + + # Test that the output is a Performance instance + random_performance = music.generate_random_performance_note_array( + num_notes=n_notes, + duration=duration, + max_note_duration=max_note_duration, + min_note_duration=min_note_duration, + max_velocity=max_velocity, + min_velocity=min_velocity, + return_performance=True, + ) + + self.assertTrue( + isinstance(random_performance, partitura.performance.Performance) + ) From 3c2f16c493cf1f6f67ce283f64bf3d677b0a7132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Wed, 2 Nov 2022 13:28:15 +0100 Subject: [PATCH 14/88] update utils/__init__.py --- partitura/utils/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/partitura/utils/__init__.py b/partitura/utils/__init__.py index e43b6280..2a222963 100644 --- a/partitura/utils/__init__.py +++ b/partitura/utils/__init__.py @@ -33,6 +33,7 @@ ensure_pitch_spelling_format, ensure_notearray, compute_pianoroll, + compute_pitch_class_pianoroll, pianoroll_to_notearray, match_note_arrays, key_mode_to_int, @@ -67,6 +68,7 @@ "ensure_notearray", "ensure_rest_array", "compute_pianoroll", + "compute_pitch_class_pianoroll", "pianoroll_to_notearray", "slice_notearray_by_time", "key_name_to_fifths_mode", From c56ca630e14869dca9a1937531afe0bd9f3109fc Mon Sep 17 00:00:00 2001 From: sildater <41552783+sildater@users.noreply.github.com> Date: Wed, 9 Nov 2022 10:47:31 +0100 Subject: [PATCH 15/88] remove a dangling print statement --- partitura/musicanalysis/meter.py | 1 - 1 file changed, 1 deletion(-) diff --git a/partitura/musicanalysis/meter.py b/partitura/musicanalysis/meter.py index eff61c8c..db70be27 100644 --- a/partitura/musicanalysis/meter.py +++ b/partitura/musicanalysis/meter.py @@ -334,7 +334,6 @@ def estimate_time(note_info): else: aggregated_notes.append((note_on, 1)) - print(aggregated_notes) onsets, saliences = list(zip(*aggregated_notes)) ma = MultipleAgents() From 04069df8ee77c99aef2898a48161645a343637d4 Mon Sep 17 00:00:00 2001 From: melkisedeath Date: Fri, 11 Nov 2022 18:09:09 +0100 Subject: [PATCH 16/88] fixed readme reference to master. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8c0f2621..e11e31a8 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ renders the part to an image and displays it: ```python pt.render(part) ``` -![Score example](https://raw.githubusercontent.com/CPJKU/partitura/master/docs/images/score_example.png) +![Score example](https://raw.githubusercontent.com/CPJKU/partitura/main/docs/images/score_example.png) The notes in this part can be accessed through the property From 0323032456704d4634d93292f0a29b3ebb4b8e6c Mon Sep 17 00:00:00 2001 From: melkisedeath Date: Fri, 11 Nov 2022 18:13:42 +0100 Subject: [PATCH 17/88] fixed readme reference to master. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e11e31a8..15a312fd 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ renders the part to an image and displays it: ```python pt.render(part) ``` -![Score example](https://raw.githubusercontent.com/CPJKU/partitura/main/docs/images/score_example.png) +![Score example](https://raw.githubusercontent.com/CPJKU/partitura/main/docs/source/images/score_example.png) The notes in this part can be accessed through the property From 820149491fae7ff435fc6c8b982b3bebe260f0ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Fri, 11 Nov 2022 20:15:36 +0100 Subject: [PATCH 18/88] test info lines --- partitura/io/importmatch_new.py | 95 ++++++++++++++++++++++++++------- tests/test_match_import_new.py | 90 +++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 20 deletions(-) create mode 100644 tests/test_match_import_new.py diff --git a/partitura/io/importmatch_new.py b/partitura/io/importmatch_new.py index 598ac01d..a4fdc497 100644 --- a/partitura/io/importmatch_new.py +++ b/partitura/io/importmatch_new.py @@ -11,7 +11,7 @@ # from packaging import version import numpy as np -from matchfile_fields import FractionalSymbolicDuration +from partitura.io.matchfile_fields import FractionalSymbolicDuration # Define current version of the match file format CURRENT_MAJOR_VERSION = 1 @@ -19,6 +19,8 @@ CURRENT_PATCH_VERSION = 0 Version = namedtuple("Version", ["major", "minor", "patch"]) +VersionOld = namedtuple("Version", ["major", "minor"]) + CURRENT_VERSION = Version( CURRENT_MAJOR_VERSION, @@ -37,6 +39,22 @@ class MatchError(Exception): def interpret_version(version_string: str) -> Version: + """ + Parse matchfile format version from a string. This method + parses a string like "1.0.0" and returns a Version instance. + + Parameters + ---------- + version_string : str + The string containg the version. The version string should b + in the form "{major}.{minor}.{patch}". Incorrectly formatted strings + will result in an error. + + Returns + ------- + version : Version + A named tuple specifying the version + """ version_info = version_pattern.search(version_string) if version_info is not None: @@ -49,11 +67,39 @@ def interpret_version(version_string: str) -> Version: def format_version(version: Version) -> str: + """ + Format version as a string. + + Parameters + ---------- + version : Version + A Version instance. + + Returns + ------- + version_str : str + A string representation of the version. + """ ma, mi, pa = version - return f"{ma}.{mi}.{pa}" + + version_str = f"{ma}.{mi}.{pa}" + return version_str def interpret_as_int(value: str) -> int: + """ + Interpret value as an integer + + Parameters + ---------- + value : str + The value to interpret as integer. + + Returns + ------- + int + The value cast as an integer. + """ return int(value) @@ -104,8 +150,9 @@ def __init__(self, version: Version, **kwargs) -> None: self.out_pattern = self.line_dict[self.version]["matchline"] # set field names - # TODO: Add custom error if field is not provided? for field in self.field_names: + if field.lower() not in kwargs: + raise ValueError(f"{field.lower()} is not given in keyword arguments") setattr(self, field, kwargs[field.lower()]) def __str__(self) -> str: @@ -181,7 +228,7 @@ def check_types(self) -> bool: "composer": (interpret_as_string, format_string, str), "midiClockUnits": (interpret_as_int, format_int, int), "midiClockRate": (interpret_as_int, format_int, int), - "approximateTempo": (interpret_as_float, format_float), + "approximateTempo": (interpret_as_float, format_float, float), "subtitle": (interpret_as_string, format_string, str), } @@ -190,9 +237,8 @@ def check_types(self) -> bool: INFO_LINE = { Version(1, 0, 0): { "pattern": re.compile( - # CC: Allow spaces? I think we should be strict and do not do this. # r"info\(\s*(?P[^,]+)\s*,\s*(?P.+)\s*\)\." - r"info\((?P[^,]+),(?P.+)\)\." + r"info\((?P[^,]+),(?P.+)\)\." ), "field_names": ("attribute", "value"), "matchline": "info({attribute},{value}).", @@ -202,6 +248,20 @@ def check_types(self) -> bool: class MatchInfo(MatchLine): + """ + Main class specifying global information lines. + + For version 1.0.0, these lines have the general structure: + + `info(attribute,value).` + + Parameters + ---------- + version : Version + The version of the info line. + kwargs : keyword arguments + Keyword arguments specifying the type of line and its value. + """ line_dict = INFO_LINE @@ -307,30 +367,25 @@ def __init__(self, version: Version, **kwargs) -> None: class KeySignatureLine(MatchScoreProp): - - def __init__( - self, - version: Version, - key_signature: str, - measure: int, - beat: int, - offset: Union[int, FractionalSymbolicDuration], - onset_in_beats: float + self, + version: Version, + key_signature: str, + measure: int, + beat: int, + offset: Union[int, FractionalSymbolicDuration], + onset_in_beats: float, ) -> None: super().__init__( version=version, - attribute='keySignature', + attribute="keySignature", value=key_signature, measure=measure, beat=beat, offset=offset, - onset_in_beats=onset_in_beats + onset_in_beats=onset_in_beats, ) - - - def load_match(fn, create_part=False, pedal_threshold=64, first_note_at_zero=False): pass diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py new file mode 100644 index 00000000..60dce7c0 --- /dev/null +++ b/tests/test_match_import_new.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains test functions for Matchfile import +""" +import unittest +import numpy as np + +from tests import MATCH_IMPORT_EXPORT_TESTFILES, MOZART_VARIATION_FILES + +from partitura.io.importmatch_new import ( + MatchInfo, +) + + +class TestMatchLines(unittest.TestCase): + def test_info_lines(self): + """ + Test parsing and generating global info lines. + """ + + # The following lines are correctly specified, and the parser + # should be able to 1) parse them without errors and 2) reconstruct + # exactly same line. + version_line = "info(matchFileVersion,1.0.0)." + piece_line = "info(piece,Etude Op. 10 No. 3)." + scoreFileName_line = "info(scoreFileName,Chopin_op10_no3.musicxml)." + scoreFilePath_line = ( + "info(scoreFilePath,/path/to/dataset/Chopin_op10_no3.musicxml)." + ) + midiFileName_line = "info(midiFileName,Chopin_op10_no3_p01.mid)." + midiFilePath_line = ( + "info(midiFilePath,/path/to/dataset/Chopin_op10_no3_p01.mid)." + ) + audioFileName_line = "info(audioFileName,Chopin_op10_no3_p01.wav)." + audioFilePath_line = ( + "info(audioFilePath,/path/to/dataset/Chopin_op10_no3_p01.wav)." + ) + audioFirstNote_line = "info(audioFirstNote,1.2345)." + audioLastNote_line = "info(audioLastNote,9.8372)." + composer_line = "info(composer,Frèdéryk Chopin)." + performer_line = "info(performer,A. Human Pianist)." + midiClockUnits_line = "info(midiClockUnits,4000)." + midiClockRate_line = "info(midiClockRate,500000)." + approximateTempo_line = "info(approximateTempo,98.2902)." + subtitle_line = "info(subtitle,Subtitle)." + + matchlines = [ + version_line, + piece_line, + scoreFileName_line, + scoreFilePath_line, + midiFileName_line, + midiFilePath_line, + audioFileName_line, + audioFilePath_line, + audioFirstNote_line, + audioLastNote_line, + composer_line, + performer_line, + midiClockUnits_line, + midiClockRate_line, + approximateTempo_line, + subtitle_line, + ] + + for ml in matchlines: + mo = MatchInfo.from_matchline(ml) + self.assertTrue(mo.matchline == ml) + + # The following lines should result in an error + try: + # This line is not defined as an info line and should raise an error + notSpecified_line = "info(notSpecified,value)." + + mo = MatchInfo.from_matchline(notSpecified_line) + self.assertTrue(False) + except ValueError: + # assert that the error was raised + self.assertTrue(True) + + try: + # wrong value (string instead of integer) + midiClockUnits_line = "info(midiClockUnits,wrong_value)." + + mo = MatchInfo.from_matchline(midiClockUnits_line) + self.assertTrue(False) + except ValueError: + # assert that the error was raised + self.assertTrue(True) From ed78c64bb67c85fd5e906fa0cd05c7aa924b1cfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Mon, 14 Nov 2022 09:52:08 +0100 Subject: [PATCH 19/88] re-structure matchfile related stuff (wip) --- partitura/io/importmatch_new.py | 11 +- partitura/io/matchfile_base.py | 293 +++++++++++++++++++++++++++++++ partitura/io/matchlines_1_0_0.py | 103 +++++++++++ tests/test_match_import_new.py | 60 +++++++ 4 files changed, 464 insertions(+), 3 deletions(-) create mode 100644 partitura/io/matchfile_base.py create mode 100644 partitura/io/matchlines_1_0_0.py diff --git a/partitura/io/importmatch_new.py b/partitura/io/importmatch_new.py index a4fdc497..b0b61f22 100644 --- a/partitura/io/importmatch_new.py +++ b/partitura/io/importmatch_new.py @@ -8,11 +8,11 @@ from collections import namedtuple from typing import Union, Tuple -# from packaging import version - import numpy as np from partitura.io.matchfile_fields import FractionalSymbolicDuration +__all__ = ["load_match"] + # Define current version of the match file format CURRENT_MAJOR_VERSION = 1 CURRENT_MINOR_VERSION = 0 @@ -340,7 +340,11 @@ def from_matchline( SCOREPROP_LINE = { Version(1, 0, 0): { "pattern": re.compile( - r"scoreProp\((?P[^,]+),(?P[^,]+),(?P\d+):(?P[\d\/]*)\)\." + r"scoreProp\(" + r"(?P[^,]+),(?P[^,]+)," + r"(?P[^,]+):(?P[^,]+)," + r"(?P[^,]+):(?P[^,]+)" + r"\)\." ), "field_names": ( "attribute", @@ -366,6 +370,7 @@ def __init__(self, version: Version, **kwargs) -> None: self.interpret_fun = self.line_dict[self.version]["value"] + class KeySignatureLine(MatchScoreProp): def __init__( self, diff --git a/partitura/io/matchfile_base.py b/partitura/io/matchfile_base.py new file mode 100644 index 00000000..67be60ba --- /dev/null +++ b/partitura/io/matchfile_base.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains base classes for Match lines and utilities for +parsing and formatting match lines. +""" +from __future__ import annotations + +from typing import Callable, Tuple, Any +import re + +from collections import namedtuple + +Version = namedtuple("Version", ["major", "minor", "patch"]) + +# General patterns +rational_pattern = re.compile(r"^([0-9]+)/([0-9]+)$") +double_rational_pattern = re.compile(r"^([0-9]+)/([0-9]+)/([0-9]+)$") +version_pattern = re.compile(r"^([0-9]+)\.([0-9]+)\.([0-9]+)") + +# For matchfiles before 1.0.0. +old_version_pattern = re.compile(r"^([0-9]+)\.([0-9]+)") + + +class MatchError(Exception): + """ + Base exception for parsing match files. + """ + + pass + + +class MatchLine(object): + """ + Base class for representing match lines. + + This class should be subclassed for the different match lines. + + Parameters + ---------- + version : Version + Indicate the version of the match line. + + Attributes + ---------- + version: Version + The version of the match line. + field_names: Tuple[str] + The names of the different fields with information in the match line. + field_types : Tuple[type] + The data type of the different fields. + out_pattern : str + The output pattern for the match line (i.e., how the match line looks like + in a match file). + pattern : re.Pattern + Regular expression to parse information from a string. + """ + + # Version of the match line + version: Version + + # Field names that appear in the match line + # A match line will generally have these + # field names as attributes. + field_names: Tuple[str] + + # type of the information in the fields + field_types: Tuple[type] + + # Output pattern + out_pattern: str + + # Regular expression to parse + # information from a string. + pattern = re.Pattern + + def __init__(self, version: Version) -> None: + self.version = version + + def __str__(self) -> str: + """ + Prints the printing the match line + """ + r = [self.__class__.__name__] + for fn in self.field_names: + r.append(" {0}: {1}".format(fn, self.__dict__[fn.lower()])) + return "\n".join(r) + "\n" + + @property + def matchline(self) -> str: + """ + Generate matchline as a string + """ + raise NotImplementedError + + @classmethod + def from_matchline( + cls, + matchline: str, + version: Version, + ) -> MatchLine: + """ + Create a new MatchLine object from a string + + Parameters + ---------- + matchline : str + String with a matchline + version : Version + Version of the matchline + + Returns + ------- + a MatchLine instance + """ + raise NotImplementedError + + def check_types(self) -> bool: + """ + Check whether the values of the fields are of the correct type. + + Returns + ------- + types_are_correct : bool + True if the values of all fields in the match line have the + correct type. + """ + types_are_correct = all( + [ + isinstance(getattr(self, field), self.field_types[field]) + for field in self.field_names + ] + ) + + return types_are_correct + + +## The following classes define match lines that appear in all matchfile versions +## These classes need to be subclassed in the corresponding module for each version. + + +class BaseInfoLine(MatchLine): + """ + Base class specifying global information lines. + + These lines have the general structure "info(,)." + Which attributes are valid depending on the version of the match line. + + Parameters + ---------- + version : Version + The version of the info line. + kwargs : keyword arguments + Keyword arguments specifying the type of line and its value. + """ + + # Base field names (can be updated in subclasses). + # "attribute" will have type str, but the type of value needs to be specified + # during initialization. + field_names: Tuple[str] = ("attribute", "value") + + out_pattern: str = "info({attribute},{value})." + + pattern = re.Pattern = r"info\((?P[^,]+),(?P.+)\)\." + + def __init__( + self, + version: Version, + attribute: str, + value: Any, + value_type: type, + format_fun: Callable[Any, str], + ) -> None: + super().__init__(version) + + self.field_types = (str, value_type) + self.format_fun = dict(attribute=format_string, value=format_fun) + + setattr(attribute, value) + + @property + def matchline(self) -> str: + matchline = self.out_pattern.format( + **dict( + [ + (field, self.format_fun[field](getattr(self, field))) + for field in self.field_names + ] + ) + ) + + return matchline + + +## The following methods are helpers for interpretting and formatting +## information from match lines. + + +def interpret_version(version_string: str) -> Version: + """ + Parse matchfile format version from a string. This method + parses a string like "1.0.0" and returns a Version instance. + + Parameters + ---------- + version_string : str + The string containg the version. The version string should be + in the form "{major}.{minor}.{patch}" or "{minor}.{patch}" for versions + previous to 1.0.0. Incorrectly formatted strings + will result in an error. + + Returns + ------- + version : Version + A named tuple specifying the version + """ + version_info = version_pattern.search(version_string) + + if version_info is not None: + ma, mi, pa = version_info.groups() + version = Version(int(ma), int(mi), int(pa)) + return version + + # If using the first pattern fails, try with old version + version_info = old_version_pattern.search(version_string) + + if version_info is not None: + mi, pa = version_info.groups() + version = Version(0, int(mi), int(pa)) + return version + + else: + raise ValueError(f"The version '{version_string}' is incorrectly formatted!") + + +def format_version(version: Version) -> str: + """ + Format version as a string. + + Parameters + ---------- + version : Version + A Version instance. + + Returns + ------- + version_str : str + A string representation of the version. + """ + ma, mi, pa = version + + version_str = f"{ma}.{mi}.{pa}" + return version_str + + +def interpret_as_int(value: str) -> int: + """ + Interpret value as an integer + + Parameters + ---------- + value : str + The value to interpret as integer. + + Returns + ------- + int + The value cast as an integer. + """ + return int(value) + + +def format_int(value: int) -> str: + return f"{value}" + + +def interpret_as_float(value: str) -> float: + return float(value) + + +def format_float(value: float) -> str: + return f"{value:.4f}" + + +def interpret_as_string(value: str) -> str: + return value + + +def format_string(value: str) -> str: + """ + For completeness + """ + return value.strip() diff --git a/partitura/io/matchlines_1_0_0.py b/partitura/io/matchlines_1_0_0.py new file mode 100644 index 00000000..60471acf --- /dev/null +++ b/partitura/io/matchlines_1_0_0.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains definitions for Matchfile lines for version >1.0.0 +""" + +from partitura.io.matchfile_base import MatchLine, Version + +# Define current version of the match file format +CURRENT_MAJOR_VERSION = 1 +CURRENT_MINOR_VERSION = 0 +CURRENT_PATCH_VERSION = 0 + +CURRENT_VERSION = Version( + CURRENT_MAJOR_VERSION, + CURRENT_MINOR_VERSION, + CURRENT_PATCH_VERSION, +) + + +class MatchInfo(MatchLine): + """ + Main class specifying global information lines. + + For version 1.0.0, these lines have the general structure: + + `info(attribute,value).` + + Parameters + ---------- + version : Version + The version of the info line. + kwargs : keyword arguments + Keyword arguments specifying the type of line and its value. + """ + + line_dict = INFO_LINE + + def __init__(self, version: Version = CURRENT_VERSION, **kwargs) -> None: + super().__init__(version, **kwargs) + + self.interpret_fun = self.line_dict[self.version]["value"][self.attribute][0] + self.value_type = self.line_dict[self.version]["value"][self.attribute][2] + self.format_fun = { + "attribute": format_string, + "value": self.line_dict[self.version]["value"][self.attribute][1], + } + + @property + def matchline(self) -> str: + matchline = self.out_pattern.format( + **dict( + [ + (field, self.format_fun[field](getattr(self, field))) + for field in self.field_names + ] + ) + ) + + return matchline + + @classmethod + def from_matchline( + cls, + matchline: str, + pos: int = 0, + version=CURRENT_VERSION, + ) -> MatchLine: + """ + Create a new MatchLine object from a string + + Parameters + ---------- + matchline : str + String with a matchline + pos : int (optional) + Position of the matchline in the input string. By default it is + assumed that the matchline starts at the beginning of the input + string. + version : Version (optional) + Version of the matchline. By default it is the latest version. + + Returns + ------- + a MatchLine instance + """ + class_dict = INFO_LINE[version] + + match_pattern = class_dict["pattern"].search(matchline, pos=pos) + + if match_pattern is not None: + attribute, value_str = match_pattern.groups() + if attribute not in class_dict["value"].keys(): + raise ValueError( + f"Attribute {attribute} is not specified in version {version}" + ) + + value = class_dict["value"][attribute][0](value_str) + + return cls(version=version, attribute=attribute, value=value) + + else: + raise MatchError("Input match line does not fit the expected pattern.") diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index 60dce7c0..c1851672 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -12,6 +12,8 @@ MatchInfo, ) +from partitura.io.matchfile_base import interpret_version, Version + class TestMatchLines(unittest.TestCase): def test_info_lines(self): @@ -88,3 +90,61 @@ def test_info_lines(self): except ValueError: # assert that the error was raised self.assertTrue(True) + + +class TestMatchUtils(unittest.TestCase): + """ + Test utilities for handling match files + """ + + def test_interpret_version(self): + """ + Test `interpret_version` + """ + # new version format + version_string = "1.2.3" + + version = interpret_version(version_string) + + # Check that output is the correct type + self.assertTrue(isinstance(version, Version)) + + # Check that output is correct + self.assertTrue( + all( + [ + version.major == 1, + version.minor == 2, + version.patch == 3, + ] + ) + ) + + # old version format + version_string = "5.7" + + version = interpret_version(version_string) + # Check that output is the correct type + self.assertTrue(isinstance(version, Version)) + + # Check that output is correct + self.assertTrue( + all( + [ + version.major == 0, + version.minor == 5, + version.patch == 7, + ] + ) + ) + + # Wrongly formatted version (test that it raises a ValueError) + + version_string = "4.n.9.0" + + try: + version = interpret_version(version_string) + # The test should fail if the exception is not raised + self.assertTrue(False) + except ValueError: + self.assertTrue(True) From 0ed75c016fadd6208366a7b38ea89330a050f441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Mon, 14 Nov 2022 18:08:43 +0100 Subject: [PATCH 20/88] fix parsing new info lines --- partitura/io/matchfile_base.py | 10 +-- partitura/io/matchlines_1_0_0.py | 119 ++++++++++++++++++++++--------- tests/test_match_import_new.py | 13 +++- 3 files changed, 100 insertions(+), 42 deletions(-) diff --git a/partitura/io/matchfile_base.py b/partitura/io/matchfile_base.py index 67be60ba..842dc702 100644 --- a/partitura/io/matchfile_base.py +++ b/partitura/io/matchfile_base.py @@ -127,8 +127,8 @@ def check_types(self) -> bool: """ types_are_correct = all( [ - isinstance(getattr(self, field), self.field_types[field]) - for field in self.field_names + isinstance(getattr(self, field), field_type) + for field, field_type in zip(self.field_names, self.field_types) ] ) @@ -161,7 +161,7 @@ class BaseInfoLine(MatchLine): out_pattern: str = "info({attribute},{value})." - pattern = re.Pattern = r"info\((?P[^,]+),(?P.+)\)\." + pattern: re.Pattern = re.compile(r"info\((?P[^,]+),(?P.+)\)\.") def __init__( self, @@ -175,8 +175,8 @@ def __init__( self.field_types = (str, value_type) self.format_fun = dict(attribute=format_string, value=format_fun) - - setattr(attribute, value) + self.attribute = attribute + self.value = value @property def matchline(self) -> str: diff --git a/partitura/io/matchlines_1_0_0.py b/partitura/io/matchlines_1_0_0.py index 60471acf..98b8778c 100644 --- a/partitura/io/matchlines_1_0_0.py +++ b/partitura/io/matchlines_1_0_0.py @@ -3,8 +3,24 @@ """ This module contains definitions for Matchfile lines for version >1.0.0 """ - -from partitura.io.matchfile_base import MatchLine, Version +from __future__ import annotations + +from typing import Any, Callable + +from partitura.io.matchfile_base import ( + MatchLine, + Version, + BaseInfoLine, + MatchError, + interpret_version, + format_version, + interpret_as_string, + format_string, + interpret_as_float, + format_float, + interpret_as_int, + format_int, +) # Define current version of the match file format CURRENT_MAJOR_VERSION = 1 @@ -18,7 +34,34 @@ ) -class MatchInfo(MatchLine): +# Dictionary of interpreter, formatters and datatypes for info lines +# each entry in the dictionary is a tuple with +# an intepreter (to parse the input), a formatter (for the output matchline) +# and type + +INFO_LINE = { + Version(1, 0, 0): { + "matchFileVersion": (interpret_version, format_version, Version), + "piece": (interpret_as_string, format_string, str), + "scoreFileName": (interpret_as_string, format_string, str), + "scoreFilePath": (interpret_as_string, format_string, str), + "midiFileName": (interpret_as_string, format_string, str), + "midiFilePath": (interpret_as_string, format_string, str), + "audioFileName": (interpret_as_string, format_string, str), + "audioFilePath": (interpret_as_string, format_string, str), + "audioFirstNote": (interpret_as_float, format_float, float), + "audioLastNote": (interpret_as_float, format_float, float), + "performer": (interpret_as_string, format_string, str), + "composer": (interpret_as_string, format_string, str), + "midiClockUnits": (interpret_as_int, format_int, int), + "midiClockRate": (interpret_as_int, format_int, int), + "approximateTempo": (interpret_as_float, format_float, float), + "subtitle": (interpret_as_string, format_string, str), + } +} + + +class MatchInfo(BaseInfoLine): """ Main class specifying global information lines. @@ -34,38 +77,33 @@ class MatchInfo(MatchLine): Keyword arguments specifying the type of line and its value. """ - line_dict = INFO_LINE - - def __init__(self, version: Version = CURRENT_VERSION, **kwargs) -> None: - super().__init__(version, **kwargs) - - self.interpret_fun = self.line_dict[self.version]["value"][self.attribute][0] - self.value_type = self.line_dict[self.version]["value"][self.attribute][2] - self.format_fun = { - "attribute": format_string, - "value": self.line_dict[self.version]["value"][self.attribute][1], - } - - @property - def matchline(self) -> str: - matchline = self.out_pattern.format( - **dict( - [ - (field, self.format_fun[field](getattr(self, field))) - for field in self.field_names - ] - ) + def __init__( + self, + version: Version, + attribute: str, + value: Any, + value_type: type, + format_fun: Callable[Any, str], + ) -> None: + + if version < Version(1, 0, 0): + raise MatchError("The version must be >= 1.0.0") + + super().__init__( + version=version, + attribute=attribute, + value=value, + value_type=value_type, + format_fun=format_fun, ) - return matchline - @classmethod def from_matchline( cls, matchline: str, pos: int = 0, - version=CURRENT_VERSION, - ) -> MatchLine: + version: Version = CURRENT_VERSION, + ) -> MatchInfo: """ Create a new MatchLine object from a string @@ -82,22 +120,33 @@ def from_matchline( Returns ------- - a MatchLine instance + a Mat instance """ - class_dict = INFO_LINE[version] - match_pattern = class_dict["pattern"].search(matchline, pos=pos) + match_pattern = cls.pattern.search(matchline, pos=pos) + + if version not in INFO_LINE: + raise MatchError(f"{version} is not specified for this class.") + class_dict = INFO_LINE[version] if match_pattern is not None: attribute, value_str = match_pattern.groups() - if attribute not in class_dict["value"].keys(): + if attribute not in class_dict: raise ValueError( - f"Attribute {attribute} is not specified in version {version}" + f"Attribute {attribute} is not specified in {version}" ) - value = class_dict["value"][attribute][0](value_str) + interpret_fun, format_fun, value_type = class_dict[attribute] - return cls(version=version, attribute=attribute, value=value) + value = interpret_fun(value_str) + + return cls( + version=version, + attribute=attribute, + value=value, + value_type=value_type, + format_fun=format_fun, + ) else: raise MatchError("Input match line does not fit the expected pattern.") diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index c1851672..986a6451 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -8,14 +8,17 @@ from tests import MATCH_IMPORT_EXPORT_TESTFILES, MOZART_VARIATION_FILES -from partitura.io.importmatch_new import ( +from partitura.io.matchlines_1_0_0 import ( MatchInfo, ) from partitura.io.matchfile_base import interpret_version, Version -class TestMatchLines(unittest.TestCase): +class TestMatchLinesV1_0_0(unittest.TestCase): + """ + Test matchlines for version 1.0.0 + """ def test_info_lines(self): """ Test parsing and generating global info lines. @@ -68,8 +71,14 @@ def test_info_lines(self): for ml in matchlines: mo = MatchInfo.from_matchline(ml) + # assert that the information from the matchline + # is parsed correctly and results in an identical line + # to the input match line self.assertTrue(mo.matchline == ml) + # assert that the data types of the match line are correct + self.assertTrue(mo.check_types()) + # The following lines should result in an error try: # This line is not defined as an info line and should raise an error From fcff8063d99078cd3acdfcf5f0f1896d2010060d Mon Sep 17 00:00:00 2001 From: melkisedeath Date: Wed, 16 Nov 2022 18:54:29 +0100 Subject: [PATCH 21/88] Added missing symbol for consecutive ties. --- partitura/io/importkern.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/partitura/io/importkern.py b/partitura/io/importkern.py index 361b32a4..f5a20e87 100644 --- a/partitura/io/importkern.py +++ b/partitura/io/importkern.py @@ -321,6 +321,9 @@ def _search_slurs_and_ties(self, note, note_id): note = note[n:] if "]" in note: self.tie_dict["close"].append(note_id) + elif "_" in note: + self.tie_dict["open"].append(note_id) + self.tie_dict["close"].append(note_id) if note.startswith("["): self.tie_dict["open"].append(note_id) note = note[1:] From 90588584c5a60944f47df89c2d18c9ebda0ab415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Thu, 17 Nov 2022 15:53:18 +0100 Subject: [PATCH 22/88] scoreprop lines --- partitura/io/importmatch_new.py | 1 - partitura/io/matchfile_base.py | 380 ++++++++++++++++++++++++++++--- partitura/io/matchlines_1_0_0.py | 164 ++++++++++++- tests/test_match_import_new.py | 28 +++ 4 files changed, 538 insertions(+), 35 deletions(-) diff --git a/partitura/io/importmatch_new.py b/partitura/io/importmatch_new.py index b0b61f22..d8c69e4b 100644 --- a/partitura/io/importmatch_new.py +++ b/partitura/io/importmatch_new.py @@ -370,7 +370,6 @@ def __init__(self, version: Version, **kwargs) -> None: self.interpret_fun = self.line_dict[self.version]["value"] - class KeySignatureLine(MatchScoreProp): def __init__( self, diff --git a/partitura/io/matchfile_base.py b/partitura/io/matchfile_base.py index 842dc702..c1f60690 100644 --- a/partitura/io/matchfile_base.py +++ b/partitura/io/matchfile_base.py @@ -6,20 +6,27 @@ """ from __future__ import annotations -from typing import Callable, Tuple, Any +from typing import Callable, Tuple, Any, Optional, Union, List import re +import numpy as np + from collections import namedtuple Version = namedtuple("Version", ["major", "minor", "patch"]) # General patterns -rational_pattern = re.compile(r"^([0-9]+)/([0-9]+)$") -double_rational_pattern = re.compile(r"^([0-9]+)/([0-9]+)/([0-9]+)$") -version_pattern = re.compile(r"^([0-9]+)\.([0-9]+)\.([0-9]+)") +rational_pattern = re.compile(r"^(?P[0-9]+)/(?P[0-9]+)$") +double_rational_pattern = re.compile( + r"^(?P[0-9]+)/(?P[0-9]+)/(?P[0-9]+)$" +) +version_pattern = re.compile( + r"^(?P[0-9]+)\.(?P[0-9]+)\.(?P[0-9]+)" +) +attribute_list_pattern = re.compile(r"^\[(?P.+)\]") # For matchfiles before 1.0.0. -old_version_pattern = re.compile(r"^([0-9]+)\.([0-9]+)") +old_version_pattern = re.compile(r"^(?P[0-9]+)\.(?P[0-9]+)") class MatchError(Exception): @@ -62,6 +69,8 @@ class MatchLine(object): # Field names that appear in the match line # A match line will generally have these # field names as attributes. + # Following the original Prolog-based specification + # the names of the attributes start with upper-case letters field_names: Tuple[str] # type of the information in the fields @@ -89,9 +98,20 @@ def __str__(self) -> str: @property def matchline(self) -> str: """ - Generate matchline as a string + Generate matchline as a string. + + This method can be adapted as needed by subclasses. """ - raise NotImplementedError + matchline = self.out_pattern.format( + **dict( + [ + (field, self.format_fun[field](getattr(self, field))) + for field in self.field_names + ] + ) + ) + + return matchline @classmethod def from_matchline( @@ -143,7 +163,7 @@ class BaseInfoLine(MatchLine): """ Base class specifying global information lines. - These lines have the general structure "info(,)." + These lines have the general structure "info(,)." Which attributes are valid depending on the version of the match line. Parameters @@ -157,11 +177,11 @@ class BaseInfoLine(MatchLine): # Base field names (can be updated in subclasses). # "attribute" will have type str, but the type of value needs to be specified # during initialization. - field_names: Tuple[str] = ("attribute", "value") + field_names: Tuple[str] = ("Attribute", "Value") - out_pattern: str = "info({attribute},{value})." + out_pattern: str = "info({Attribute},{Value})." - pattern: re.Pattern = re.compile(r"info\((?P[^,]+),(?P.+)\)\.") + pattern: re.Pattern = re.compile(r"info\((?P[^,]+),(?P.+)\)\.") def __init__( self, @@ -174,22 +194,9 @@ def __init__( super().__init__(version) self.field_types = (str, value_type) - self.format_fun = dict(attribute=format_string, value=format_fun) - self.attribute = attribute - self.value = value - - @property - def matchline(self) -> str: - matchline = self.out_pattern.format( - **dict( - [ - (field, self.format_fun[field](getattr(self, field))) - for field in self.field_names - ] - ) - ) - - return matchline + self.format_fun = dict(Attribute=format_string, Value=format_fun) + self.Attribute = attribute + self.Value = value ## The following methods are helpers for interpretting and formatting @@ -271,23 +278,336 @@ def interpret_as_int(value: str) -> int: def format_int(value: int) -> str: + """ + Format a string from an integer + + Parameters + ---------- + value : int + The value to be converted to format as a string. + + Returns + ------- + str + The value formatted as a string. + """ return f"{value}" def interpret_as_float(value: str) -> float: + """ + Interpret value as a float + + Parameters + ---------- + value : str + The string to interpret as float. + + Returns + ------- + int + The value cast as an float. + """ return float(value) def format_float(value: float) -> str: + """ + Format a string from an integer + + Parameters + ---------- + value : int + The value to be converted to format as a string. + + Returns + ------- + str + The value formatted as a string. + """ return f"{value:.4f}" -def interpret_as_string(value: str) -> str: - return value +def interpret_as_string(value: Any) -> str: + """ + Interpret value as a string + + Parameters + ---------- + value : Any + The value to be interpreted as a string. + + Returns + ------- + int + The string representation of the value. + """ + return str(value) def format_string(value: str) -> str: """ - For completeness + Format a string as a string (for completeness ;). + + Parameters + ---------- + value : int + The value to be converted to format as a string. + + Returns + ------- + str + The value formatted as a string. """ return value.strip() + + +class FractionalSymbolicDuration(object): + """ + A class to represent symbolic duration information. + + Parameters + ---------- + numerator : int + The value of the numerator. + denominator: int + The denominator of the duration (whole notes = 1, half notes = 2, etc.) + tuple_div : int (optional) + Tuple divisor (for triplets, etc.). For example a single note in a quintuplet + with a total duration of one quarter could be specified as + `duration = FractionalSymbolicDuration(1, 4, 5)`. + add_components : List[Tuple[int, int, Optional[int]]] (optional) + additive components (to express durations like 1/4+1/16+1/32). The components + are a list of tuples, each of which contains its own numerator, denominator + and tuple_div (or None). To represent the components 1/16+1/32 + in the example above, this variable would look like + `add_components = [(1, 16, None), (1, 32, None)]`. + """ + + def __init__( + self, + numerator: int, + denominator: int = 1, + tuple_div: Optional[int] = None, + add_components: Optional[List[Tuple[int, int, Optional[int]]]] = None, + ) -> None: + + self.numerator = numerator + self.denominator = denominator + self.tuple_div = tuple_div + self.add_components = add_components + self.bound_integers(1024) + + def _str( + self, + numerator: int, + denominator: int, + tuple_div: Optional[int], + ) -> str: + """ + Helper for representing an instance as a string. + """ + if denominator == 1 and tuple_div is None: + return str(numerator) + else: + if tuple_div is None: + return "{0}/{1}".format(numerator, denominator) + else: + return "{0}/{1}/{2}".format(numerator, denominator, tuple_div) + + def bound_integers(self, bound: int) -> None: + """ + Bound numerator and denominator + """ + denominators = [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 12, + 14, + 16, + 18, + 20, + 22, + 24, + 28, + 32, + 48, + 64, + 96, + 128, + ] + sign = np.sign(self.numerator) * np.sign(self.denominator) + self.numerator = np.abs(self.numerator) + self.denominator = np.abs(self.denominator) + + if self.numerator > bound or self.denominator > bound: + val = float(self.numerator / self.denominator) + dif = [] + for den in denominators: + if np.round(val * den) > 0.9: + dif.append(np.abs(np.round(val * den) - val * den)) + else: + dif.append(np.abs(1 - val * den)) + + difn = np.array(dif) + min_idx = int(np.argmin(difn)) + + self.denominator = denominators[min_idx] + if int(np.round(val * self.denominator)) < 1: + self.numerator = sign * 1 + else: + self.numerator = sign * int(np.round(val * self.denominator)) + + def __str__(self) -> str: + """ + Represent an instance as a string. + """ + if self.add_components is None: + return self._str(self.numerator, self.denominator, self.tuple_div) + else: + r = [self._str(*i) for i in self.add_components] + return "+".join(r) + + def __add__( + self, sd: Union[FractionalSymbolicDuration, int] + ) -> FractionalSymbolicDuration: + """ + Define addition between FractionalSymbolicDuration instances. + + Parameters + ---------- + sd : Union[FractionalSymbolicDuration, int] + A FractionalSymbolicDuration instance or an integer to add + to the current instance (self). + + Returns + ------- + FractionalSymbolicDuration + A new instance with the value equal to the sum + of `sd` + `self`. + """ + if isinstance(sd, int): + sd = FractionalSymbolicDuration(sd, 1) + + dens = np.array([self.denominator, sd.denominator], dtype=int) + new_den = np.lcm(dens[0], dens[1]) + a_mult = new_den // dens + new_num = np.dot(a_mult, [self.numerator, sd.numerator]) + + if self.add_components is None and sd.add_components is None: + add_components = [ + (self.numerator, self.denominator, self.tuple_div), + (sd.numerator, sd.denominator, sd.tuple_div), + ] + + elif self.add_components is not None and sd.add_components is None: + add_components = self.add_components + [ + (sd.numerator, sd.denominator, sd.tuple_div) + ] + elif self.add_components is None and sd.add_components is not None: + add_components = [ + (self.numerator, self.denominator, self.tuple_div) + ] + sd.add_components + else: + add_components = self.add_components + sd.add_components + + # Remove spurious components with 0 in the numerator + add_components = [c for c in add_components if c[0] != 0] + + return FractionalSymbolicDuration( + numerator=new_num, + denominator=new_den, + add_components=add_components, + ) + + def __radd__( + self, sd: Union[FractionalSymbolicDuration, int] + ) -> FractionalSymbolicDuration: + return self.__add__(sd) + + def __float__(self) -> float: + # Cast as float since the ability to return an instance of a strict + # subclass of float is deprecated, and may be removed in a future + # version of Python. (following a deprecation warning) + return float(self.numerator / (self.denominator * (self.tuple_div or 1))) + + @classmethod + def from_string(cls, string: str, allow_additions: bool = True): + + m = rational_pattern.match(string) + m2 = double_rational_pattern.match(string) + + if m: + groups = m.groups() + return cls(*[int(g) for g in groups]) + elif m2: + groups = m2.groups() + return cls(*[int(g) for g in groups]) + else: + if allow_additions: + parts = string.split("+") + + if len(parts) > 1: + iparts = [ + cls.from_string( + i, + allow_additions=False, + ) + for i in parts + ] + + # to be replaced with isinstance(i,numbers.Number) + if all(type(i) in (int, float, cls) for i in iparts): + if any([isinstance(i, cls) for i in iparts]): + iparts = [ + cls(i) if not isinstance(i, cls) else i for i in iparts + ] + return sum(iparts) + + raise ValueError( + f"{string} cannot be interpreted as FractionalSymbolicDuration" + ) + + +def interpret_as_fractional(value: str) -> FractionalSymbolicDuration: + return FractionalSymbolicDuration.from_string(value, allow_additions=True) + + +def format_fractional(value: FractionalSymbolicDuration) -> str: + return str(value) + + +def interpret_as_list(value: str) -> List[str]: + """ + Interpret string as list of values. + + Parameters + ---------- + value: str + + Returns + ------- + content_list : List[str] + """ + content = attribute_list_pattern.search(value) + + if content is not None: + vals_string = content.group("attributes") + content_list = [v.strip() for v in vals_string.split(",")] + + return content_list + + else: + ValueError(f"{value} cannot be parsed as a list") + + +def format_list(value: List[Any]) -> str: + formatted_string = f"[{','.join([str(v) for v in value])}]" + return formatted_string diff --git a/partitura/io/matchlines_1_0_0.py b/partitura/io/matchlines_1_0_0.py index 98b8778c..f05492af 100644 --- a/partitura/io/matchlines_1_0_0.py +++ b/partitura/io/matchlines_1_0_0.py @@ -5,6 +5,8 @@ """ from __future__ import annotations +import re + from typing import Any, Callable from partitura.io.matchfile_base import ( @@ -20,6 +22,11 @@ format_float, interpret_as_int, format_int, + FractionalSymbolicDuration, + format_fractional, + interpret_as_fractional, + interpret_as_list, + format_list, ) # Define current version of the match file format @@ -120,7 +127,7 @@ def from_matchline( Returns ------- - a Mat instance + a MatchInfo instance """ match_pattern = cls.pattern.search(matchline, pos=pos) @@ -132,9 +139,7 @@ def from_matchline( if match_pattern is not None: attribute, value_str = match_pattern.groups() if attribute not in class_dict: - raise ValueError( - f"Attribute {attribute} is not specified in {version}" - ) + raise ValueError(f"Attribute {attribute} is not specified in {version}") interpret_fun, format_fun, value_type = class_dict[attribute] @@ -150,3 +155,154 @@ def from_matchline( else: raise MatchError("Input match line does not fit the expected pattern.") + + +SCOREPROP_LINE = { + Version(1, 0, 0): { + "timeSignature": ( + interpret_as_fractional, + format_fractional, + FractionalSymbolicDuration, + ), + "keySignature": (interpret_as_string, format_string, str), + "beatSubDivision": (interpret_as_int, format_int, int), + "directions": (interpret_as_list, format_list, list), + } +} + + +class MatchScoreProp(MatchLine): + + field_names = [ + "Attribute", + "Value", + "Measure", + "Beat", + "Offset", + "TimeInBeats", + ] + + out_pattern = ( + "scoreprop({Attribute},{Value},{Measure}:{Beat},{Offset},{TimeInBeats})." + ) + + pattern = re.compile( + r"scoreprop\(([^,]+),([^,]+),([^,]+):([^,]+),([^,]+),([^,]+)\)\." + ) + + def __init__( + self, + version: Version, + attribute: str, + value: Any, + value_type: type, + format_fun: Callable[Any, str], + measure: int, + beat: int, + offset: FractionalSymbolicDuration, + time_in_beats: float, + ) -> None: + + if version < Version(1, 0, 0): + raise MatchError("The version must be >= 1.0.0") + super().__init__(version) + + self.field_types = ( + str, + value_type, + int, + int, + FractionalSymbolicDuration, + float, + ) + + self.format_fun = dict( + Attribute=format_string, + Value=format_fun, + Measure=format_int, + Beat=format_int, + Offset=format_fractional, + TimeInBeats=format_float, + ) + + # set class attributes + self.Attribute = attribute + self.Value = value + self.Measure = measure + self.Beat = beat + self.Offset = offset + self.TimeInBeats = time_in_beats + + @classmethod + def from_matchline( + cls, + matchline: str, + pos: int = 0, + version: Version = CURRENT_VERSION, + ) -> MatchInfo: + """ + Create a new MatchScoreProp object from a string + + Parameters + ---------- + matchline : str + String with a matchline + pos : int (optional) + Position of the matchline in the input string. By default it is + assumed that the matchline starts at the beginning of the input + string. + version : Version (optional) + Version of the matchline. By default it is the latest version. + + Returns + ------- + a MatchScoreProp object + """ + + if version not in SCOREPROP_LINE: + raise MatchError(f"{version} is not specified for this class.") + + match_pattern = cls.pattern.search(matchline, pos=pos) + + class_dict = SCOREPROP_LINE[version] + + if match_pattern is not None: + + ( + attribute, + value_str, + measure_str, + beat_str, + offset_str, + time_in_beats_str, + ) = match_pattern.groups() + + if attribute not in class_dict: + raise ValueError(f"Attribute {attribute} is not specified in {version}") + + interpret_fun, format_fun, value_type = class_dict[attribute] + + value = interpret_fun(value_str) + + measure = interpret_as_int(measure_str) + + beat = interpret_as_int(beat_str) + + offset = interpret_as_fractional(offset_str) + + time_in_beats = interpret_as_float(time_in_beats_str) + + return cls( + version=version, + attribute=attribute, + value=value, + value_type=value_type, + format_fun=format_fun, + measure=measure, + beat=beat, + offset=offset, + time_in_beats=time_in_beats, + ) + + else: + raise MatchError("Input match line does not fit the expected pattern.") diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index 986a6451..ed6b6101 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -10,6 +10,7 @@ from partitura.io.matchlines_1_0_0 import ( MatchInfo, + MatchScoreProp, ) from partitura.io.matchfile_base import interpret_version, Version @@ -19,6 +20,7 @@ class TestMatchLinesV1_0_0(unittest.TestCase): """ Test matchlines for version 1.0.0 """ + def test_info_lines(self): """ Test parsing and generating global info lines. @@ -100,6 +102,32 @@ def test_info_lines(self): # assert that the error was raised self.assertTrue(True) + def test_score_prop_lines(self): + + keysig_line = "scoreprop(keySignature,E,0:2,1/8,-0.5000)." + + timesig_line = "scoreprop(timeSignature,2/4,0:2,1/8,-0.5000)." + + directions_line = "scoreprop(directions,[Allegro],0:2,1/8,-0.5000)." + + + + matchlines = [ + keysig_line, + timesig_line, + directions_line, + ] + + for ml in matchlines: + # assert that the information from the matchline + # is parsed correctly and results in an identical line + # to the input match line + mo = MatchScoreProp.from_matchline(ml) + self.assertTrue(mo.matchline == ml) + + # assert that the data types of the match line are correct + self.assertTrue(mo.check_types()) + class TestMatchUtils(unittest.TestCase): """ From 7b68b1aa890d026c11409b5074abceac00a6e671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Thu, 17 Nov 2022 21:32:15 +0100 Subject: [PATCH 23/88] fix issues #184 and #185 --- partitura/score.py | 41 +++++++------ partitura/utils/generic.py | 121 +++++++++++++++++++++++++++++++++++++ tests/test_note_array.py | 54 ++++++++++++++++- tests/test_utils.py | 105 ++++++++++++++++++++++++++++++++ 4 files changed, 300 insertions(+), 21 deletions(-) diff --git a/partitura/score.py b/partitura/score.py index 95f533ad..23caaa44 100644 --- a/partitura/score.py +++ b/partitura/score.py @@ -18,7 +18,7 @@ from partitura.utils.music import MUSICAL_BEATS import warnings import numpy as np -from scipy.interpolate import interp1d, PPoly +from scipy.interpolate import PPoly from typing import Union, List, Optional, Iterator, Iterable as Itertype from partitura.utils import ( @@ -45,6 +45,8 @@ update_note_ids_after_unfolding, ) +from partitura.utils.generic import interp1d + class Part(object): """Represents a score part, e.g. all notes of one single instrument @@ -201,9 +203,6 @@ def key_signature_map(self): kss = np.array([(t0, fifths, mode), (tN, fifths, mode)]) - elif len(kss) == 1: - # if there is only a single key signature - return lambda x: np.array([kss[0, 1], kss[0, 2]]) elif kss[0, 0] > self.first_point.t: kss = np.vstack(((self.first_point.t, kss[0, 1], kss[0, 2]), kss)) @@ -259,12 +258,10 @@ def measure_map(self): kind="previous", axis=0, fill_value="extrapolate", + dtype=int, ) - def int_interp1d(input): - return inter_function(input).astype(int) - - return int_interp1d + return inter_function @property def measure_number_map(self): @@ -314,13 +311,14 @@ def measure_number_map(self): measures = np.array([(t0, tN, 1)]) inter_function = interp1d( - measures[:, 0], measures[:, 2], kind="previous", fill_value="extrapolate" + measures[:, 0], + measures[:, 2], + kind="previous", + fill_value="extrapolate", + dtype=int, ) - def int_interp1d(input): - return inter_function(input).astype(int) - - return int_interp1d + return inter_function @property def metrical_position_map(self): @@ -346,12 +344,10 @@ def metrical_position_map(self): axis=0, kind="linear", fill_value="extrapolate", + dtype=int, ) - def zero_fun(input): - return zero_interpolator(input).astype(int) - - return zero_fun + return zero_interpolator else: barlines = np.array(ms + me[-1:]) bar_durations = np.diff(barlines) @@ -2495,7 +2491,7 @@ def microseconds_per_quarter(self): """ return int( - np.round(60 * (10 ** 6 / to_quarter_tempo(self.unit or "q", self.bpm))) + np.round(60 * (10**6 / to_quarter_tempo(self.unit or "q", self.bpm))) ) def __str__(self): @@ -2944,6 +2940,7 @@ def note_array( include_key_signature=include_key_signature, include_time_signature=include_time_signature, include_grace_notes=include_grace_notes, + include_metrical_position=include_metrical_position, include_staff=include_staff, include_divs_per_quarter=include_divs_per_quarter, **kwargs, @@ -4586,12 +4583,16 @@ def merge_parts(parts, reassign="voice"): note_arrays = [part.note_array(include_staff=True) for part in parts] # find the maximum number of voices for each part (voice number start from 1) maximum_voices = [ - max(note_array["voice"], default=0) if max(note_array["voice"], default=0) != 0 else 1 + max(note_array["voice"], default=0) + if max(note_array["voice"], default=0) != 0 + else 1 for note_array in note_arrays ] # find the maximum number of staves for each part (staff number start from 0 but we force them to 1) maximum_staves = [ - max(note_array["staff"], default=0) if max(note_array["staff"], default=0) != 0 else 1 + max(note_array["staff"], default=0) + if max(note_array["staff"], default=0) != 0 + else 1 for note_array in note_arrays ] diff --git a/partitura/utils/generic.py b/partitura/utils/generic.py index 6ac2e919..cf7196a8 100644 --- a/partitura/utils/generic.py +++ b/partitura/utils/generic.py @@ -5,8 +5,12 @@ """ import warnings from collections import defaultdict + +from typing import Union, Callable, Optional + from textwrap import dedent import numpy as np +from scipy.interpolate import interp1d as sc_interp1d __all__ = ["find_nearest", "iter_current_next", "partition", "iter_subclasses"] @@ -472,6 +476,123 @@ def search(states, success, expand, combine): states = combine(expand(state), states) +def interp1d( + x: np.ndarray, + y: np.ndarray, + dtype: Optional[type] = None, + axis: int = -1, + kind: Union[str, int] = "linear", + copy=True, + bounds_error=None, + fill_value=np.nan, + assume_sorted=False, +) -> Callable: + """ + Interpolate a 1-D function using scipy's interp1d method. This utility allows for + handling the case where `x` and `y` are only a single value (i.e. have length one, + which results in a ValueError if using scipy's version directly). It also allows for + specifying the dtype of the output. + + `x` and `y` are arrays of values used to approximate some function f: + ``y = f(x)``. This class returns a function whose call method uses + interpolation to find the value of new points. + + The description of the parameters has been taken from `scipy.interpolate.interp1d`. + + Parameters + ---------- + x : (N,) array_like + A 1-D array of real values. + y : (...,N,...) array_like + A N-D array of real values. The length of `y` along the interpolation + axis must be equal to the length of `x`. + dtype : type, optional + Type of the output array (e.g., `float`, `int`). By default it is set to + None (i.e., the array will have the same type as the outputs from + scipy's interp1d method. + axis : int, optional + Specifies the axis of `y` along which to interpolate. + Interpolation defaults to the last axis of `y`. + kind : str or int, optional + Specifies the kind of interpolation as a string or as an integer + specifying the order of the spline interpolator to use. + The string has to be one of 'linear', 'nearest', 'nearest-up', 'zero', + 'slinear', 'quadratic', 'cubic', 'previous', or 'next'. 'zero', + 'slinear', 'quadratic' and 'cubic' refer to a spline interpolation of + zeroth, first, second or third order; 'previous' and 'next' simply + return the previous or next value of the point; 'nearest-up' and + 'nearest' differ when interpolating half-integers (e.g. 0.5, 1.5) + in that 'nearest-up' rounds up and 'nearest' rounds down. Default + is 'linear'. + copy : bool, optional + If True, the class makes internal copies of x and y. + If False, references to `x` and `y` are used. The default is to copy. + bounds_error : bool, optional + If True, a ValueError is raised any time interpolation is attempted on + a value outside of the range of x (where extrapolation is + necessary). If False, out of bounds values are assigned `fill_value`. + By default, an error is raised unless ``fill_value="extrapolate"``. + fill_value : array-like or (array-like, array_like) or "extrapolate", optional + - if a ndarray (or float), this value will be used to fill in for + requested points outside of the data range. If not provided, then + the default is NaN. The array-like must broadcast properly to the + dimensions of the non-interpolation axes. + - If a two-element tuple, then the first element is used as a + fill value for ``x_new < x[0]`` and the second element is used for + ``x_new > x[-1]``. Anything that is not a 2-element tuple (e.g., + list or ndarray, regardless of shape) is taken to be a single + array-like argument meant to be used for both bounds as + ``below, above = fill_value, fill_value``. + - If "extrapolate", then points outside the data range will be + extrapolated. + assume_sorted : bool, optional + If False, values of `x` can be in any order and they are sorted first. + If True, `x` has to be an array of monotonically increasing values. + """ + if len(x) > 1: + interp_fun = sc_interp1d( + x=x, + y=y, + kind=kind, + axis=axis, + copy=copy, + bounds_error=bounds_error, + fill_value=fill_value, + assume_sorted=assume_sorted, + ) + + else: + + # If there is only one value for x and y, assume that the method + # will always return the same value for any input. + + def interp_fun( + input_var: Union[float, int, np.ndarray] + ) -> Callable[[Union[float, int, np.ndarray]], np.ndarray]: + + if y.ndim > 1: + result = np.broadcast_to(y, (len(np.atleast_1d(input_var)), y.shape[1])) + else: + result = np.broadcast_to(y, (len(np.atleast_1d(input_var)),)) + + if not isinstance(input_var, np.ndarray): + # the output of scipy's interp1d is always an array + result = np.array(result[0]) + + return result + + if dtype is not None: + + def typed_interp( + input_var: Union[float, int, np.ndarray] + ) -> Callable[[Union[float, int, np.ndarray]], np.ndarray]: + return interp_fun(input_var).astype(dtype) + + return typed_interp + else: + return interp_fun + + # def search_recursive(states, success, expand, combine): # try: # if not states: diff --git a/tests/test_note_array.py b/tests/test_note_array.py index c96c9ce8..8941e1a2 100644 --- a/tests/test_note_array.py +++ b/tests/test_note_array.py @@ -8,7 +8,7 @@ import unittest import partitura.score as score -from partitura import load_musicxml, load_kern +from partitura import load_musicxml, load_kern, load_score from partitura.utils.music import note_array_from_part, ensure_notearray import numpy as np @@ -96,6 +96,58 @@ def test_ensure_na_different_divs(self): self.assertTrue(note["duration_div"] == 4) self.assertTrue(note["divs_pq"] == 4) + def test_score_notearray_method(self): + """ + Test that note array generated from the Score class method + include all relevant information. + """ + + for fn in NOTE_ARRAY_TESTFILES: + + scr = load_score(fn) + + na = scr.note_array( + include_pitch_spelling=True, + include_key_signature=True, + include_time_signature=True, + include_grace_notes=True, + include_metrical_position=True, + include_staff=True, + include_divs_per_quarter=True, + ) + + expected_field_names = [ + "onset_beat", + "duration_beat", + "onset_quarter", + "duration_quarter", + "onset_div", + "duration_div", + "pitch", + "voice", + "id", + "step", + "alter", + "octave", + "is_grace", + "grace_type", + "ks_fifths", + "ks_mode", + "ts_beats", + "ts_beat_type", + "ts_mus_beats", + "is_downbeat", + "rel_onset_div", + "tot_measure_div", + "staff", + "divs_pq", + ] + + for field_name in expected_field_names: + # check that the note array contain the relevant + # field. + self.assertTrue(field_name in na.dtype.names) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_utils.py b/tests/test_utils.py index 6636ebdb..46afe0c9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -10,6 +10,9 @@ from partitura.utils import music from tests import MATCH_IMPORT_EXPORT_TESTFILES, VOSA_TESTFILES, MOZART_VARIATION_FILES +from scipy.interpolate import interp1d as scinterp1d +from partitura.utils.generic import interp1d as pinterp1d + RNG = np.random.RandomState(1984) @@ -411,3 +414,105 @@ def test_generate_random_performance_note_array(self): self.assertTrue( isinstance(random_performance, partitura.performance.Performance) ) + + +class TestGenericUtils(unittest.TestCase): + def test_interp1d(self): + """ + Test `interp1d` + """ + + # Test that the we get the same results as with + # scipy + rng = np.random.RandomState(1984) + + x = rng.randn(100) + y = 3 * x + 1 + + sinterp = scinterp1d(x=x, y=y) + + pinterp = pinterp1d(x=x, y=y) + + y_scipy = sinterp(x) + y_partitura = pinterp(x) + + self.assertTrue(np.all(y_scipy == y_partitura)) + + # Test that we don't get an error with inputs + # with length 1 + + x = rng.randn(1) + y = rng.randn(1) + + pinterp = pinterp1d(x=x, y=y) + + x_test = rng.randn(1000) + + y_partitura = pinterp(x_test) + + self.assertTrue(y_partitura.shape == x_test.shape) + self.assertTrue(np.all(y_partitura == y)) + + # setting the axis when the input has length 1 + x = rng.randn(1) + y = rng.randn(1, 5) + pinterp = pinterp1d(x=x, y=y) + + y_partitura = pinterp(x_test) + + self.assertTrue(y_partitura.shape == (len(x_test), y.shape[1])) + self.assertTrue(np.all(y_partitura == y)) + + # Test setting dtype of the output + + dtypes = ( + float, + int, + np.int8, + np.int16, + np.float32, + np.int64, + np.float16, + np.float32, + np.float64, + np.float128, + ) + + for dtype in dtypes: + + x = rng.randn(100) + y = rng.randn(100) + + pinterp = pinterp1d(x=x, y=y, dtype=dtype) + + y_partitura = pinterp(x) + # assert that the dtype of the array is correct + self.assertTrue(y_partitura.dtype == dtype) + # assert that the result is the same as casting the expected + # output as the specified dtype + self.assertTrue(np.allclose(y_partitura, y.astype(dtype))) + + # Test setting outputs of sizes larger than 1 + + x = rng.randn(100) + y = rng.randn(100, 2) + + sinterp = scinterp1d( + x, + y, + axis=0, + kind="previous", + bounds_error=False, + fill_value="extrapolate", + ) + + pinterp = pinterp1d( + x, + y, + axis=0, + kind="previous", + bounds_error=False, + fill_value="extrapolate", + ) + + self.assertTrue(np.all(sinterp(x) == pinterp(x))) From 981da23bcf03086a549d7bc3cedca75fd7f4939a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Thu, 17 Nov 2022 21:42:47 +0100 Subject: [PATCH 24/88] update documentation --- partitura/utils/generic.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/partitura/utils/generic.py b/partitura/utils/generic.py index cf7196a8..794c3332 100644 --- a/partitura/utils/generic.py +++ b/partitura/utils/generic.py @@ -548,6 +548,13 @@ def interp1d( assume_sorted : bool, optional If False, values of `x` can be in any order and they are sorted first. If True, `x` has to be an array of monotonically increasing values. + + Returns + ------- + interp_fun : callable + The interpolator instance. This method takes an input array, float + or integer and returns an array with the specified dtype (if `dtype` + is not None). """ if len(x) > 1: interp_fun = sc_interp1d( From ae136aabfdd712976fa6c11dc9440756e4abb2d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Fri, 18 Nov 2022 09:42:29 +0100 Subject: [PATCH 25/88] update documentation --- partitura/utils/generic.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/partitura/utils/generic.py b/partitura/utils/generic.py index 794c3332..d4130a5f 100644 --- a/partitura/utils/generic.py +++ b/partitura/utils/generic.py @@ -486,24 +486,25 @@ def interp1d( bounds_error=None, fill_value=np.nan, assume_sorted=False, -) -> Callable: +) -> Callable[[Union[float, int, np.ndarray]], np.ndarray]: """ Interpolate a 1-D function using scipy's interp1d method. This utility allows for handling the case where `x` and `y` are only a single value (i.e. have length one, which results in a ValueError if using scipy's version directly). It also allows for specifying the dtype of the output. + The description of the parameters has been taken from `scipy.interpolate.interp1d`. + `x` and `y` are arrays of values used to approximate some function f: ``y = f(x)``. This class returns a function whose call method uses interpolation to find the value of new points. - The description of the parameters has been taken from `scipy.interpolate.interp1d`. Parameters ---------- - x : (N,) array_like + x : (N,) np.ndarray A 1-D array of real values. - y : (...,N,...) array_like + y : (...,N,...) np.ndarray A N-D array of real values. The length of `y` along the interpolation axis must be equal to the length of `x`. dtype : type, optional From 36041eb59f20f8a7b59f0026dedd5b9134ec7280 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Sat, 19 Nov 2022 14:32:32 +0100 Subject: [PATCH 26/88] Add MatchSnote --- partitura/io/matchfile_base.py | 128 +++++++++++-- partitura/io/matchlines_v0.py | 172 ++++++++++++++++++ .../{matchlines_1_0_0.py => matchlines_v1.py} | 160 +++++++++++++++- tests/test_match_import_new.py | 20 +- 4 files changed, 462 insertions(+), 18 deletions(-) create mode 100644 partitura/io/matchlines_v0.py rename partitura/io/{matchlines_1_0_0.py => matchlines_v1.py} (66%) diff --git a/partitura/io/matchfile_base.py b/partitura/io/matchfile_base.py index c1f60690..6beb64ae 100644 --- a/partitura/io/matchfile_base.py +++ b/partitura/io/matchfile_base.py @@ -6,11 +6,15 @@ """ from __future__ import annotations -from typing import Callable, Tuple, Any, Optional, Union, List +from typing import Callable, Tuple, Any, Optional, Union, List, Dict import re import numpy as np +from partitura.utils.music import ( + pitch_spelling_to_midi_pitch, +) + from collections import namedtuple Version = namedtuple("Version", ["major", "minor", "patch"]) @@ -23,7 +27,7 @@ version_pattern = re.compile( r"^(?P[0-9]+)\.(?P[0-9]+)\.(?P[0-9]+)" ) -attribute_list_pattern = re.compile(r"^\[(?P.+)\]") +attribute_list_pattern = re.compile(r"^\[(?P.*)\]") # For matchfiles before 1.0.0. old_version_pattern = re.compile(r"^(?P[0-9]+)\.(?P[0-9]+)") @@ -74,16 +78,23 @@ class MatchLine(object): field_names: Tuple[str] # type of the information in the fields - field_types: Tuple[type] + field_types: Tuple[Union[type, Tuple[type]]] # Output pattern out_pattern: str + # A dictionary of callables for each field name + # the callables should get the value of the input + # and return a string formatted for the matchfile. + format_fun: Dict[str, Callable[Any, str]] + # Regular expression to parse # information from a string. pattern = re.Pattern def __init__(self, version: Version) -> None: + # Subclasses need to initialize the other + # default field names self.version = version def __str__(self) -> str: @@ -135,23 +146,30 @@ def from_matchline( """ raise NotImplementedError - def check_types(self) -> bool: + def check_types(self, verbose: bool = False) -> bool: """ Check whether the values of the fields are of the correct type. + Parameters + ---------- + verbose : bool + Prints whether each of the attributes in field_names has the correct dtype. + values are + Returns ------- types_are_correct : bool True if the values of all fields in the match line have the correct type. """ - types_are_correct = all( - [ + types_are_correct_list = [ isinstance(getattr(self, field), field_type) for field, field_type in zip(self.field_names, self.field_types) ] - ) + if verbose: + print(list(zip(self.field_names, types_are_correct_list))) + types_are_correct = all(types_are_correct_list) return types_are_correct @@ -177,11 +195,11 @@ class BaseInfoLine(MatchLine): # Base field names (can be updated in subclasses). # "attribute" will have type str, but the type of value needs to be specified # during initialization. - field_names: Tuple[str] = ("Attribute", "Value") + field_names = ("Attribute", "Value") - out_pattern: str = "info({Attribute},{Value})." + out_pattern = "info({Attribute},{Value})." - pattern: re.Pattern = re.compile(r"info\((?P[^,]+),(?P.+)\)\.") + pattern = re.compile(r"info\((?P[^,]+),(?P.+)\)\.") def __init__( self, @@ -199,6 +217,73 @@ def __init__( self.Value = value +# deprecate bar for measure +class BaseSnoteLine(MatchLine): + + # All derived classes should include + # at least these field names + field_names = ( + "Anchor", + "NoteName", + "Modifier", + "Octave", + "Measure", + "Beat", + "Offset", + "Duration", + "OnsetInBeats", + "OffsetInBeats", + ) + + def __init__( + self, + version: Version, + anchor: str, + note_name: str, + modifier: str, + octave: Optional[int], + measure: int, + beat: int, + offset: FractionalSymbolicDuration, + duration: FractionalSymbolicDuration, + onset_in_beats: float, + offset_in_beats: float, + ): + super().__init__(version) + + # All of these attributes should have the + # correct dtype (otherwise we need to be constantly + # checking the types). + self.Anchor = anchor + self.NoteName = note_name + self.Modifier = modifier + self.Octave = octave + self.Measure = measure + self.Beat = beat + self.Offset = offset + self.Duration = duration + self.OnsetInBeats = onset_in_beats + self.OffsetInBeats = offset_in_beats + + @property + def DurationInBeats(self): + return self.OffsetInBeats - self.OnsetInBeats + + @property + def DurationSymbolic(self): + # Duration should always be a FractionalSymbolicDuration + return str(self.Duration) + + @property + def MidiPitch(self): + if isinstance(self.Octave, int): + return pitch_spelling_to_midi_pitch( + step=self.NoteName, octave=self.Octave, alter=self.Modifier + ) + else: + return None + + ## The following methods are helpers for interpretting and formatting ## information from match lines. @@ -313,11 +398,11 @@ def interpret_as_float(value: str) -> float: def format_float(value: float) -> str: """ - Format a string from an integer + Format a float as a string (with 4 digits of precision). Parameters ---------- - value : int + value : float The value to be converted to format as a string. Returns @@ -328,9 +413,26 @@ def format_float(value: float) -> str: return f"{value:.4f}" +def format_float_unconstrained(value: float) -> str: + """ + Format a float as a string. + + Parameters + ---------- + value : float + The value to be converted to format as a string. + + Returns + ------- + str + The value formatted as a string. + """ + return str(value) + + def interpret_as_string(value: Any) -> str: """ - Interpret value as a string + Interpret value as a string. This method is for completeness. Parameters ---------- diff --git a/partitura/io/matchlines_v0.py b/partitura/io/matchlines_v0.py new file mode 100644 index 00000000..43a3001a --- /dev/null +++ b/partitura/io/matchlines_v0.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains definitions for Matchfile lines for version <1.0.0 +""" +from __future__ import annotations + +from collections import defaultdict + +import re + +from typing import Any, Callable, Tuple + +from partitura.io.matchfile_base import ( + MatchLine, + Version, + BaseInfoLine, + BaseSnoteLine, + MatchError, + interpret_version, + format_version, + interpret_as_string, + format_string, + interpret_as_float, + format_float, + format_float_unconstrained, + interpret_as_int, + format_int, + FractionalSymbolicDuration, + format_fractional, + interpret_as_fractional, + interpret_as_list, + format_list, +) + +# Define last supported version of the match file format in this module +# other modules might include different versions. +LAST_MAJOR_VERSION = 0 +LAST_MINOR_VERSION = 5 +LAST_PATCH_VERSION = 0 + +LAST_VERSION = Version( + LAST_MAJOR_VERSION, + LAST_MINOR_VERSION, + LAST_PATCH_VERSION, +) + + +# Dictionary of interpreter, formatters and datatypes for info lines +# each entry in the dictionary is a tuple with +# an intepreter (to parse the input), a formatter (for the output matchline) +# and type + + +default_infoline_attributes = { + "matchFileVersion": (interpret_version, format_version, Version), + "piece": (interpret_as_string, format_string, str), + "scoreFileName": (interpret_as_string, format_string, str), + "scoreFilePath": (interpret_as_string, format_string, str), + "midiFileName": (interpret_as_string, format_string, str), + "midiFilePath": (interpret_as_string, format_string, str), + "audioFileName": (interpret_as_string, format_string, str), + "audioFilePath": (interpret_as_string, format_string, str), + "audioFirstNote": (interpret_as_float, format_float_unconstrained, float), + "audioLastNote": (interpret_as_float, format_float_unconstrained, float), + "performer": (interpret_as_string, format_string, str), + "composer": (interpret_as_string, format_string, str), + "midiClockUnits": (interpret_as_int, format_int, int), + "midiClockRate": (interpret_as_int, format_int, int), + "approximateTempo": (interpret_as_float, format_float_unconstrained, float), + "subtitle": (interpret_as_string, format_string, str), + "keySignature": (interpret_as_list, format_list, list), + "timeSignature": ( + interpret_as_fractional, + format_fractional, + FractionalSymbolicDuration, + ), + "tempoIndication": (interpret_as_list, format_list, list), + "beatSubDivision": (interpret_as_list, format_list, list), +} + +INFO_LINE = defaultdict(lambda: default_infoline_attributes.copy()) + + +class MatchInfo(BaseInfoLine): + """ + Main class specifying global information lines. + + For version 0.x.0, these lines have the general structure: + + `info(attribute,value).` + + Parameters + ---------- + version : Version + The version of the info line. + kwargs : keyword arguments + Keyword arguments specifying the type of line and its value. + """ + + def __init__( + self, + version: Version, + attribute: str, + value: Any, + value_type: type, + format_fun: Callable[Any, str], + ) -> None: + + if version >= Version(1, 0, 0): + raise MatchError("The version must be < 1.0.0") + + super().__init__( + version=version, + attribute=attribute, + value=value, + value_type=value_type, + format_fun=format_fun, + ) + + @classmethod + def from_matchline( + cls, + matchline: str, + pos: int = 0, + version: Version = LAST_VERSION, + ) -> MatchInfo: + """ + Create a new MatchLine object from a string + + Parameters + ---------- + matchline : str + String with a matchline + pos : int (optional) + Position of the matchline in the input string. By default it is + assumed that the matchline starts at the beginning of the input + string. + version : Version (optional) + Version of the matchline. By default it is the latest version. + + Returns + ------- + a MatchInfo instance + """ + + if version >= Version(1, 0, 0): + raise MatchError("The version must be < 1.0.0") + + match_pattern = cls.pattern.search(matchline, pos=pos) + + class_dict = INFO_LINE[version] + + if match_pattern is not None: + attribute, value_str = match_pattern.groups() + if attribute not in class_dict: + raise ValueError(f"Attribute {attribute} is not specified in {version}") + + interpret_fun, format_fun, value_type = class_dict[attribute] + + value = interpret_fun(value_str) + + return cls( + version=version, + attribute=attribute, + value=value, + value_type=value_type, + format_fun=format_fun, + ) + + else: + raise MatchError("Input match line does not fit the expected pattern.") diff --git a/partitura/io/matchlines_1_0_0.py b/partitura/io/matchlines_v1.py similarity index 66% rename from partitura/io/matchlines_1_0_0.py rename to partitura/io/matchlines_v1.py index f05492af..a94fde05 100644 --- a/partitura/io/matchlines_1_0_0.py +++ b/partitura/io/matchlines_v1.py @@ -7,12 +7,18 @@ import re -from typing import Any, Callable +from typing import Any, Callable, Tuple, Union, List + +from partitura.utils.music import ( + ALTER_SIGNS, + ensure_pitch_spelling_format, +) from partitura.io.matchfile_base import ( MatchLine, Version, BaseInfoLine, + BaseSnoteLine, MatchError, interpret_version, format_version, @@ -129,11 +135,11 @@ def from_matchline( ------- a MatchInfo instance """ + if version not in INFO_LINE: + raise MatchError(f"{version} is not specified for this class.") match_pattern = cls.pattern.search(matchline, pos=pos) - if version not in INFO_LINE: - raise MatchError(f"{version} is not specified for this class.") class_dict = INFO_LINE[version] if match_pattern is not None: @@ -306,3 +312,151 @@ def from_matchline( else: raise MatchError("Input match line does not fit the expected pattern.") + + +class MatchSnote(BaseSnoteLine): + + field_names = ( + "Anchor", + "NoteName", + "Modifier", + "Octave", + "Measure", + "Beat", + "Offset", + "Duration", + "OnsetInBeats", + "OffsetInBeats", + "ScoreAttributesList", + ) + + field_types = ( + str, + str, + (int, type(None)), + int, + int, + int, + FractionalSymbolicDuration, + FractionalSymbolicDuration, + float, + float, + list, + ) + + out_pattern = ( + "snote({Anchor},[{NoteName},{Modifier}],{Octave}," + "{Measure}:{Beat},{Offset},{Duration},{OnsetInBeats}," + "{OffsetInBeats},{ScoreAttributesList})" + ) + + pattern = re.compile( + # r"snote\(([^,]+),\[([^,]+),([^,]+)\],([^,]+)," + # r"([^,]+):([^,]+),([^,]+),([^,]+),([^,]+),([^,]+),\[(.*)\]\)" + r"snote\(" + r"(?P[^,]+)," + r"\[(?P[^,]+),(?P[^,]+)\]," + r"(?P[^,]+)," + r"(?P[^,]+):(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)\)" + ) + + format_fun = dict( + Anchor=format_string, + NoteName=lambda x: str(x.upper()), + Modifier=lambda x: "n" if x == 0 else ALTER_SIGNS[x], + Octave=format_int, + Measure=format_int, + Beat=format_int, + Offset=format_fractional, + Duration=format_fractional, + OnsetInBeats=format_float, + OffsetInBeats=format_float, + ScoreAttributesList=format_list, + ) + + def __init__( + self, + version: Version, + anchor: str, + note_name: str, + modifier: str, + octave: Union[int, str], + measure: int, + beat: int, + offset: FractionalSymbolicDuration, + duration: FractionalSymbolicDuration, + onset_in_beats: float, + offset_in_beats: float, + score_attributes_list: List[str], + ): + super().__init__( + version=version, + anchor=anchor, + note_name=note_name, + modifier=modifier, + octave=octave, + measure=measure, + beat=beat, + offset=offset, + duration=duration, + onset_in_beats=onset_in_beats, + offset_in_beats=offset_in_beats, + ) + + self.ScoreAttributesList = score_attributes_list + + @classmethod + def from_matchline( + cls, + matchline: str, + pos: int = 0, + version: Version = CURRENT_VERSION, + ) -> MatchLine: + + if version < Version(1, 0, 0): + raise ValueError(f"{version} < Version(1, 0, 0)") + + match_pattern = cls.pattern.search(matchline, pos) + + if match_pattern is not None: + + ( + anchor_str, + note_name_str, + modifier_str, + octave_str, + measure_str, + beat_str, + offset_str, + duration_str, + onset_in_beats_str, + offset_in_beats_str, + score_attributes_list_str, + ) = match_pattern.groups() + + anchor = interpret_as_string(anchor_str) + note_name, modifier, octave = ensure_pitch_spelling_format( + step=note_name_str, + alter=modifier_str, + octave=octave_str, + ) + + return cls( + version=version, + anchor=interpret_as_string(anchor), + note_name=note_name, + modifier=modifier, + octave=octave, + measure=interpret_as_int(measure_str), + beat=interpret_as_int(beat_str), + offset=interpret_as_fractional(offset_str), + duration=interpret_as_fractional(duration_str), + onset_in_beats=interpret_as_float(onset_in_beats_str), + offset_in_beats=interpret_as_float(offset_in_beats_str), + score_attributes_list=interpret_as_list(score_attributes_list_str), + ) diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index ed6b6101..444fe92e 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -8,9 +8,10 @@ from tests import MATCH_IMPORT_EXPORT_TESTFILES, MOZART_VARIATION_FILES -from partitura.io.matchlines_1_0_0 import ( +from partitura.io.matchlines_v1 import ( MatchInfo, MatchScoreProp, + MatchSnote, ) from partitura.io.matchfile_base import interpret_version, Version @@ -110,12 +111,13 @@ def test_score_prop_lines(self): directions_line = "scoreprop(directions,[Allegro],0:2,1/8,-0.5000)." - + beatsubdivision_line = "scoreprop(beatSubDivision,2,0:2,1/8,-0.5000)." matchlines = [ keysig_line, timesig_line, directions_line, + beatsubdivision_line, ] for ml in matchlines: @@ -128,6 +130,20 @@ def test_score_prop_lines(self): # assert that the data types of the match line are correct self.assertTrue(mo.check_types()) + def test_snote_lines(self): + + snote_lines = ["snote(n1,[B,n],3,0:2,1/8,1/8,-0.5000,0.0000,[v1])"] + + for ml in snote_lines: + # assert that the information from the matchline + # is parsed correctly and results in an identical line + # to the input match line + mo = MatchSnote.from_matchline(ml) + self.assertTrue(mo.matchline == ml) + + # assert that the data types of the match line are correct + self.assertTrue(mo.check_types()) + class TestMatchUtils(unittest.TestCase): """ From 663066cf2735e06e14cdff138d28ce6f0ff4324d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Sat, 19 Nov 2022 15:01:03 +0100 Subject: [PATCH 27/88] fix issue parsing 0 as FractionalSymbolicDuration --- partitura/io/matchfile_base.py | 20 +++++++++++++------- partitura/io/matchlines_v1.py | 7 +++++-- tests/test_match_import_new.py | 8 +++++++- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/partitura/io/matchfile_base.py b/partitura/io/matchfile_base.py index 6beb64ae..819c14c8 100644 --- a/partitura/io/matchfile_base.py +++ b/partitura/io/matchfile_base.py @@ -24,6 +24,7 @@ double_rational_pattern = re.compile( r"^(?P[0-9]+)/(?P[0-9]+)/(?P[0-9]+)$" ) +integer_pattern = re.compile(r"^(?P[0-9]+)$") version_pattern = re.compile( r"^(?P[0-9]+)\.(?P[0-9]+)\.(?P[0-9]+)" ) @@ -163,9 +164,9 @@ def check_types(self, verbose: bool = False) -> bool: correct type. """ types_are_correct_list = [ - isinstance(getattr(self, field), field_type) - for field, field_type in zip(self.field_names, self.field_types) - ] + isinstance(getattr(self, field), field_type) + for field, field_type in zip(self.field_names, self.field_types) + ] if verbose: print(list(zip(self.field_names, types_are_correct_list))) @@ -645,13 +646,16 @@ def from_string(cls, string: str, allow_additions: bool = True): m = rational_pattern.match(string) m2 = double_rational_pattern.match(string) - + m3 = integer_pattern.match(string) if m: groups = m.groups() return cls(*[int(g) for g in groups]) elif m2: groups = m2.groups() return cls(*[int(g) for g in groups]) + elif m3: + return cls(numerator=int(m3.group("integer"))) + else: if allow_additions: parts = string.split("+") @@ -701,13 +705,15 @@ def interpret_as_list(value: str) -> List[str]: content = attribute_list_pattern.search(value) if content is not None: + # string includes square brackets vals_string = content.group("attributes") content_list = [v.strip() for v in vals_string.split(",")] - return content_list - else: - ValueError(f"{value} cannot be parsed as a list") + # value is not inside square brackets + content_list = [v.strip() for v in value.split(",")] + + return content_list def format_list(value: List[Any]) -> str: diff --git a/partitura/io/matchlines_v1.py b/partitura/io/matchlines_v1.py index a94fde05..a0e5b4cc 100644 --- a/partitura/io/matchlines_v1.py +++ b/partitura/io/matchlines_v1.py @@ -362,7 +362,7 @@ class MatchSnote(BaseSnoteLine): r"(?P[^,]+)," r"(?P[^,]+)," r"(?P[^,]+)," - r"(?P[^,]+)\)" + r"\[(?P.*)\]\)" ) format_fun = dict( @@ -416,7 +416,7 @@ def from_matchline( matchline: str, pos: int = 0, version: Version = CURRENT_VERSION, - ) -> MatchLine: + ) -> MatchSnote: if version < Version(1, 0, 0): raise ValueError(f"{version} < Version(1, 0, 0)") @@ -460,3 +460,6 @@ def from_matchline( offset_in_beats=interpret_as_float(offset_in_beats_str), score_attributes_list=interpret_as_list(score_attributes_list_str), ) + + else: + raise MatchError("Input match line does not fit the expected pattern.") diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index 444fe92e..96bbd759 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -132,13 +132,19 @@ def test_score_prop_lines(self): def test_snote_lines(self): - snote_lines = ["snote(n1,[B,n],3,0:2,1/8,1/8,-0.5000,0.0000,[v1])"] + snote_lines = [ + "snote(n1,[B,n],3,0:2,1/8,1/8,-0.5000,0.0000,[v1])", + "snote(n3,[G,#],3,1:1,0,1/16,0.0000,0.2500,[v3])", + "snote(n1,[E,n],4,1:1,0,1/4,0.0000,1.0000,[arp])", + "snote(n143,[B,b],5,7:2,2/16,1/8,25.5000,26.0000,[s,stacc])", + ] for ml in snote_lines: # assert that the information from the matchline # is parsed correctly and results in an identical line # to the input match line mo = MatchSnote.from_matchline(ml) + # print(mo.matchline, ml) self.assertTrue(mo.matchline == ml) # assert that the data types of the match line are correct From 9f5c6cf667e3be4732d8045ca39a7e130d3d0584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Sun, 20 Nov 2022 06:48:24 +0100 Subject: [PATCH 28/88] add section lines --- partitura/io/matchfile_base.py | 632 +++++++++---------------------- partitura/io/matchfile_fields.py | 121 ------ partitura/io/matchfile_utils.py | 477 +++++++++++++++++++++++ partitura/io/matchlines_v0.py | 75 +++- partitura/io/matchlines_v1.py | 231 ++++++----- tests/test_match_import_new.py | 91 ++++- 6 files changed, 972 insertions(+), 655 deletions(-) delete mode 100644 partitura/io/matchfile_fields.py create mode 100644 partitura/io/matchfile_utils.py diff --git a/partitura/io/matchfile_base.py b/partitura/io/matchfile_base.py index 819c14c8..f01be6f6 100644 --- a/partitura/io/matchfile_base.py +++ b/partitura/io/matchfile_base.py @@ -6,32 +6,35 @@ """ from __future__ import annotations -from typing import Callable, Tuple, Any, Optional, Union, List, Dict +from typing import Callable, Tuple, Any, Optional, Union, Dict, List import re import numpy as np from partitura.utils.music import ( pitch_spelling_to_midi_pitch, + ensure_pitch_spelling_format, + ALTER_SIGNS, ) -from collections import namedtuple - -Version = namedtuple("Version", ["major", "minor", "patch"]) - -# General patterns -rational_pattern = re.compile(r"^(?P[0-9]+)/(?P[0-9]+)$") -double_rational_pattern = re.compile( - r"^(?P[0-9]+)/(?P[0-9]+)/(?P[0-9]+)$" -) -integer_pattern = re.compile(r"^(?P[0-9]+)$") -version_pattern = re.compile( - r"^(?P[0-9]+)\.(?P[0-9]+)\.(?P[0-9]+)" +from partitura.io.matchfile_utils import ( + Version, + interpret_version, + interpret_version, + format_version, + interpret_as_string, + format_string, + interpret_as_float, + format_float, + format_float_unconstrained, + interpret_as_int, + format_int, + FractionalSymbolicDuration, + format_fractional, + interpret_as_fractional, + interpret_as_list, + format_list, ) -attribute_list_pattern = re.compile(r"^\[(?P.*)\]") - -# For matchfiles before 1.0.0. -old_version_pattern = re.compile(r"^(?P[0-9]+)\.(?P[0-9]+)") class MatchError(Exception): @@ -66,6 +69,8 @@ class MatchLine(object): in a match file). pattern : re.Pattern Regular expression to parse information from a string. + format_fun: Dict[str, Callable] + A dictionary of methods for formatting the values of each field name. """ # Version of the match line @@ -104,7 +109,7 @@ def __str__(self) -> str: """ r = [self.__class__.__name__] for fn in self.field_names: - r.append(" {0}: {1}".format(fn, self.__dict__[fn.lower()])) + r.append(" {0}: {1}".format(fn, self.__dict__[fn])) return "\n".join(r) + "\n" @property @@ -220,6 +225,37 @@ def __init__( # deprecate bar for measure class BaseSnoteLine(MatchLine): + """ + Base class to represent score notes. + + Parameters + ---------- + version: Version + anchor: str + note_name: str + modifier: str + octave: Optional[int] + measure: int + beat: int + offset: FractionalSymbolicDuration + duration: FractionalSymbolicDuration + onset_in_beats: float + offset_in_beats: float + score_attributes_list: List[str] + + Attributes + ---------- + DurationInBeats : float + DurationSymbolic : float + MidiPitch : float + + Notes + ----- + * The snote line has not changed much since the first version of + the Match file format. New versions are just more explicit in the + the formatting of the attributes (field names), e.g., NoteName + should always be uppercase starting from version 1.0.0, etc. + """ # All derived classes should include # at least these field names @@ -234,6 +270,54 @@ class BaseSnoteLine(MatchLine): "Duration", "OnsetInBeats", "OffsetInBeats", + "ScoreAttributesList", + ) + + field_types = ( + str, + str, + (int, type(None)), + int, + int, + int, + FractionalSymbolicDuration, + FractionalSymbolicDuration, + float, + float, + list, + ) + + out_pattern = ( + "snote({Anchor},[{NoteName},{Modifier}],{Octave}," + "{Measure}:{Beat},{Offset},{Duration},{OnsetInBeats}," + "{OffsetInBeats},{ScoreAttributesList})" + ) + + pattern = re.compile( + r"snote\(" + r"(?P[^,]+)," + r"\[(?P[^,]+),(?P[^,]+)\]," + r"(?P[^,]+)," + r"(?P[^,]+):(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"\[(?P.*)\]\)" + ) + + format_fun = dict( + Anchor=format_string, + NoteName=lambda x: str(x.upper()), + Modifier=lambda x: "n" if x == 0 else ALTER_SIGNS[x], + Octave=format_int, + Measure=format_int, + Beat=format_int, + Offset=format_fractional, + Duration=format_fractional, + OnsetInBeats=format_float_unconstrained, + OffsetInBeats=format_float_unconstrained, + ScoreAttributesList=format_list, ) def __init__( @@ -249,7 +333,8 @@ def __init__( duration: FractionalSymbolicDuration, onset_in_beats: float, offset_in_beats: float, - ): + score_attributes_list: List[str], + ) -> None: super().__init__(version) # All of these attributes should have the @@ -265,18 +350,19 @@ def __init__( self.Duration = duration self.OnsetInBeats = onset_in_beats self.OffsetInBeats = offset_in_beats + self.ScoreAttributesList = score_attributes_list @property - def DurationInBeats(self): + def DurationInBeats(self) -> float: return self.OffsetInBeats - self.OnsetInBeats @property - def DurationSymbolic(self): + def DurationSymbolic(self) -> str: # Duration should always be a FractionalSymbolicDuration return str(self.Duration) @property - def MidiPitch(self): + def MidiPitch(self) -> Optional[int]: if isinstance(self.Octave, int): return pitch_spelling_to_midi_pitch( step=self.NoteName, octave=self.Octave, alter=self.Modifier @@ -284,438 +370,92 @@ def MidiPitch(self): else: return None + @classmethod + def prepare_kwargs_from_matchline( + cls, + matchline: str, + pos: int = 0, + ) -> Dict: + + match_pattern = cls.pattern.search(matchline, pos) + + if match_pattern is not None: + + ( + anchor_str, + note_name_str, + modifier_str, + octave_str, + measure_str, + beat_str, + offset_str, + duration_str, + onset_in_beats_str, + offset_in_beats_str, + score_attributes_list_str, + ) = match_pattern.groups() + + anchor = interpret_as_string(anchor_str) + note_name, modifier, octave = ensure_pitch_spelling_format( + step=note_name_str, + alter=modifier_str, + octave=octave_str, + ) -## The following methods are helpers for interpretting and formatting -## information from match lines. - - -def interpret_version(version_string: str) -> Version: - """ - Parse matchfile format version from a string. This method - parses a string like "1.0.0" and returns a Version instance. - - Parameters - ---------- - version_string : str - The string containg the version. The version string should be - in the form "{major}.{minor}.{patch}" or "{minor}.{patch}" for versions - previous to 1.0.0. Incorrectly formatted strings - will result in an error. - - Returns - ------- - version : Version - A named tuple specifying the version - """ - version_info = version_pattern.search(version_string) - - if version_info is not None: - ma, mi, pa = version_info.groups() - version = Version(int(ma), int(mi), int(pa)) - return version - - # If using the first pattern fails, try with old version - version_info = old_version_pattern.search(version_string) - - if version_info is not None: - mi, pa = version_info.groups() - version = Version(0, int(mi), int(pa)) - return version - - else: - raise ValueError(f"The version '{version_string}' is incorrectly formatted!") - - -def format_version(version: Version) -> str: - """ - Format version as a string. - - Parameters - ---------- - version : Version - A Version instance. - - Returns - ------- - version_str : str - A string representation of the version. - """ - ma, mi, pa = version - - version_str = f"{ma}.{mi}.{pa}" - return version_str - - -def interpret_as_int(value: str) -> int: - """ - Interpret value as an integer - - Parameters - ---------- - value : str - The value to interpret as integer. - - Returns - ------- - int - The value cast as an integer. - """ - return int(value) - - -def format_int(value: int) -> str: - """ - Format a string from an integer - - Parameters - ---------- - value : int - The value to be converted to format as a string. - - Returns - ------- - str - The value formatted as a string. - """ - return f"{value}" - - -def interpret_as_float(value: str) -> float: - """ - Interpret value as a float - - Parameters - ---------- - value : str - The string to interpret as float. - - Returns - ------- - int - The value cast as an float. - """ - return float(value) - - -def format_float(value: float) -> str: - """ - Format a float as a string (with 4 digits of precision). - - Parameters - ---------- - value : float - The value to be converted to format as a string. - - Returns - ------- - str - The value formatted as a string. - """ - return f"{value:.4f}" - - -def format_float_unconstrained(value: float) -> str: - """ - Format a float as a string. - - Parameters - ---------- - value : float - The value to be converted to format as a string. - - Returns - ------- - str - The value formatted as a string. - """ - return str(value) - - -def interpret_as_string(value: Any) -> str: - """ - Interpret value as a string. This method is for completeness. - - Parameters - ---------- - value : Any - The value to be interpreted as a string. - - Returns - ------- - int - The string representation of the value. - """ - return str(value) - - -def format_string(value: str) -> str: - """ - Format a string as a string (for completeness ;). + return dict( + anchor=interpret_as_string(anchor), + note_name=note_name, + modifier=modifier, + octave=octave, + measure=interpret_as_int(measure_str), + beat=interpret_as_int(beat_str), + offset=interpret_as_fractional(offset_str), + duration=interpret_as_fractional(duration_str), + onset_in_beats=interpret_as_float(onset_in_beats_str), + offset_in_beats=interpret_as_float(offset_in_beats_str), + score_attributes_list=interpret_as_list(score_attributes_list_str), + ) - Parameters - ---------- - value : int - The value to be converted to format as a string. + else: + raise MatchError("Input match line does not fit the expected pattern.") - Returns - ------- - str - The value formatted as a string. - """ - return value.strip() +class BaseNoteLine(MatchLine): -class FractionalSymbolicDuration(object): - """ - A class to represent symbolic duration information. + # All derived classes should include at least + # these field names + field_names = ( + "ID", + "MidiPitch", + "Onset", + "Offset", + "Velocity", + ) - Parameters - ---------- - numerator : int - The value of the numerator. - denominator: int - The denominator of the duration (whole notes = 1, half notes = 2, etc.) - tuple_div : int (optional) - Tuple divisor (for triplets, etc.). For example a single note in a quintuplet - with a total duration of one quarter could be specified as - `duration = FractionalSymbolicDuration(1, 4, 5)`. - add_components : List[Tuple[int, int, Optional[int]]] (optional) - additive components (to express durations like 1/4+1/16+1/32). The components - are a list of tuples, each of which contains its own numerator, denominator - and tuple_div (or None). To represent the components 1/16+1/32 - in the example above, this variable would look like - `add_components = [(1, 16, None), (1, 32, None)]`. - """ + field_types = ( + str, + int, + float, + float, + int, + ) def __init__( self, - numerator: int, - denominator: int = 1, - tuple_div: Optional[int] = None, - add_components: Optional[List[Tuple[int, int, Optional[int]]]] = None, + version: Version, + note_id: str, + midi_pitch: int, + onset: float, + offset: float, + velocity: int, ) -> None: + super().__init__(version) + self.ID = note_id + self.MIDIPitch = midi_pitch + self.Onset = onset + self.Offset = offset + self.velocity = velocity - self.numerator = numerator - self.denominator = denominator - self.tuple_div = tuple_div - self.add_components = add_components - self.bound_integers(1024) - - def _str( - self, - numerator: int, - denominator: int, - tuple_div: Optional[int], - ) -> str: - """ - Helper for representing an instance as a string. - """ - if denominator == 1 and tuple_div is None: - return str(numerator) - else: - if tuple_div is None: - return "{0}/{1}".format(numerator, denominator) - else: - return "{0}/{1}/{2}".format(numerator, denominator, tuple_div) - - def bound_integers(self, bound: int) -> None: - """ - Bound numerator and denominator - """ - denominators = [ - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 12, - 14, - 16, - 18, - 20, - 22, - 24, - 28, - 32, - 48, - 64, - 96, - 128, - ] - sign = np.sign(self.numerator) * np.sign(self.denominator) - self.numerator = np.abs(self.numerator) - self.denominator = np.abs(self.denominator) - - if self.numerator > bound or self.denominator > bound: - val = float(self.numerator / self.denominator) - dif = [] - for den in denominators: - if np.round(val * den) > 0.9: - dif.append(np.abs(np.round(val * den) - val * den)) - else: - dif.append(np.abs(1 - val * den)) - - difn = np.array(dif) - min_idx = int(np.argmin(difn)) - - self.denominator = denominators[min_idx] - if int(np.round(val * self.denominator)) < 1: - self.numerator = sign * 1 - else: - self.numerator = sign * int(np.round(val * self.denominator)) - - def __str__(self) -> str: - """ - Represent an instance as a string. - """ - if self.add_components is None: - return self._str(self.numerator, self.denominator, self.tuple_div) - else: - r = [self._str(*i) for i in self.add_components] - return "+".join(r) - - def __add__( - self, sd: Union[FractionalSymbolicDuration, int] - ) -> FractionalSymbolicDuration: - """ - Define addition between FractionalSymbolicDuration instances. - - Parameters - ---------- - sd : Union[FractionalSymbolicDuration, int] - A FractionalSymbolicDuration instance or an integer to add - to the current instance (self). - - Returns - ------- - FractionalSymbolicDuration - A new instance with the value equal to the sum - of `sd` + `self`. - """ - if isinstance(sd, int): - sd = FractionalSymbolicDuration(sd, 1) - - dens = np.array([self.denominator, sd.denominator], dtype=int) - new_den = np.lcm(dens[0], dens[1]) - a_mult = new_den // dens - new_num = np.dot(a_mult, [self.numerator, sd.numerator]) - - if self.add_components is None and sd.add_components is None: - add_components = [ - (self.numerator, self.denominator, self.tuple_div), - (sd.numerator, sd.denominator, sd.tuple_div), - ] - - elif self.add_components is not None and sd.add_components is None: - add_components = self.add_components + [ - (sd.numerator, sd.denominator, sd.tuple_div) - ] - elif self.add_components is None and sd.add_components is not None: - add_components = [ - (self.numerator, self.denominator, self.tuple_div) - ] + sd.add_components - else: - add_components = self.add_components + sd.add_components - - # Remove spurious components with 0 in the numerator - add_components = [c for c in add_components if c[0] != 0] - - return FractionalSymbolicDuration( - numerator=new_num, - denominator=new_den, - add_components=add_components, - ) - - def __radd__( - self, sd: Union[FractionalSymbolicDuration, int] - ) -> FractionalSymbolicDuration: - return self.__add__(sd) - - def __float__(self) -> float: - # Cast as float since the ability to return an instance of a strict - # subclass of float is deprecated, and may be removed in a future - # version of Python. (following a deprecation warning) - return float(self.numerator / (self.denominator * (self.tuple_div or 1))) - - @classmethod - def from_string(cls, string: str, allow_additions: bool = True): - - m = rational_pattern.match(string) - m2 = double_rational_pattern.match(string) - m3 = integer_pattern.match(string) - if m: - groups = m.groups() - return cls(*[int(g) for g in groups]) - elif m2: - groups = m2.groups() - return cls(*[int(g) for g in groups]) - elif m3: - return cls(numerator=int(m3.group("integer"))) - - else: - if allow_additions: - parts = string.split("+") - - if len(parts) > 1: - iparts = [ - cls.from_string( - i, - allow_additions=False, - ) - for i in parts - ] - - # to be replaced with isinstance(i,numbers.Number) - if all(type(i) in (int, float, cls) for i in iparts): - if any([isinstance(i, cls) for i in iparts]): - iparts = [ - cls(i) if not isinstance(i, cls) else i for i in iparts - ] - return sum(iparts) - - raise ValueError( - f"{string} cannot be interpreted as FractionalSymbolicDuration" - ) - - -def interpret_as_fractional(value: str) -> FractionalSymbolicDuration: - return FractionalSymbolicDuration.from_string(value, allow_additions=True) - - -def format_fractional(value: FractionalSymbolicDuration) -> str: - return str(value) - - -def interpret_as_list(value: str) -> List[str]: - """ - Interpret string as list of values. - - Parameters - ---------- - value: str - - Returns - ------- - content_list : List[str] - """ - content = attribute_list_pattern.search(value) - - if content is not None: - # string includes square brackets - vals_string = content.group("attributes") - content_list = [v.strip() for v in vals_string.split(",")] - - else: - # value is not inside square brackets - content_list = [v.strip() for v in value.split(",")] - - return content_list - - -def format_list(value: List[Any]) -> str: - formatted_string = f"[{','.join([str(v) for v in value])}]" - return formatted_string + @property + def Duration(self): + return self.Offset - self.Onset diff --git a/partitura/io/matchfile_fields.py b/partitura/io/matchfile_fields.py deleted file mode 100644 index 1bebc51d..00000000 --- a/partitura/io/matchfile_fields.py +++ /dev/null @@ -1,121 +0,0 @@ -import numpy as np - - -class FractionalSymbolicDuration(object): - """ - A class to represent symbolic duration information - """ - - def __init__(self, numerator, denominator=1, tuple_div=None, add_components=None): - - self.numerator = numerator - self.denominator = denominator - self.tuple_div = tuple_div - self.add_components = add_components - self.bound_integers(1024) - - def _str(self, numerator, denominator, tuple_div): - if denominator == 1 and tuple_div is None: - return str(numerator) - else: - if tuple_div is None: - return "{0}/{1}".format(numerator, denominator) - else: - return "{0}/{1}/{2}".format(numerator, denominator, tuple_div) - - def bound_integers(self, bound): - denominators = [ - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 12, - 14, - 16, - 18, - 20, - 22, - 24, - 28, - 32, - 48, - 64, - 96, - 128, - ] - sign = np.sign(self.numerator) * np.sign(self.denominator) - self.numerator = np.abs(self.numerator) - self.denominator = np.abs(self.denominator) - - if self.numerator > bound or self.denominator > bound: - val = float(self.numerator / self.denominator) - dif = [] - for den in denominators: - if np.round(val * den) > 0.9: - dif.append(np.abs(np.round(val * den) - val * den)) - else: - dif.append(np.abs(1 - val * den)) - - difn = np.array(dif) - min_idx = int(np.argmin(difn)) - - self.denominator = denominators[min_idx] - if int(np.round(val * self.denominator)) < 1: - self.numerator = sign * 1 - else: - self.numerator = sign * int(np.round(val * self.denominator)) - - def __str__(self): - - if self.add_components is None: - return self._str(self.numerator, self.denominator, self.tuple_div) - else: - r = [self._str(*i) for i in self.add_components] - return "+".join(r) - - def __add__(self, sd): - if isinstance(sd, int): - sd = FractionalSymbolicDuration(sd, 1) - - dens = np.array([self.denominator, sd.denominator], dtype=int) - new_den = np.lcm(dens[0], dens[1]) - a_mult = new_den // dens - new_num = np.dot(a_mult, [self.numerator, sd.numerator]) - - if self.add_components is None and sd.add_components is None: - add_components = [ - (self.numerator, self.denominator, self.tuple_div), - (sd.numerator, sd.denominator, sd.tuple_div), - ] - - elif self.add_components is not None and sd.add_components is None: - add_components = self.add_components + [ - (sd.numerator, sd.denominator, sd.tuple_div) - ] - elif self.add_components is None and sd.add_components is not None: - add_components = [ - (self.numerator, self.denominator, self.tuple_div) - ] + sd.add_components - else: - add_components = self.add_components + sd.add_components - - # Remove spurious components with 0 in the numerator - add_components = [c for c in add_components if c[0] != 0] - - return FractionalSymbolicDuration( - numerator=new_num, denominator=new_den, add_components=add_components - ) - - def __radd__(self, sd): - return self.__add__(sd) - - def __float__(self): - # Cast as float since the ability to return an instance of a strict - # subclass of float is deprecated, and may be removed in a future - # version of Python. (following a deprecation warning) - return float(self.numerator / (self.denominator * (self.tuple_div or 1))) diff --git a/partitura/io/matchfile_utils.py b/partitura/io/matchfile_utils.py new file mode 100644 index 00000000..ceb321dd --- /dev/null +++ b/partitura/io/matchfile_utils.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains utilities for parsing and formatting match lines. +""" +from __future__ import annotations + +from typing import Tuple, Any, Optional, Union, List +import re + +import numpy as np + +from collections import namedtuple + +Version = namedtuple("Version", ["major", "minor", "patch"]) + +# General patterns +rational_pattern = re.compile(r"^(?P[0-9]+)/(?P[0-9]+)$") +double_rational_pattern = re.compile( + r"^(?P[0-9]+)/(?P[0-9]+)/(?P[0-9]+)$" +) +integer_pattern = re.compile(r"^(?P[0-9]+)$") +version_pattern = re.compile( + r"^(?P[0-9]+)\.(?P[0-9]+)\.(?P[0-9]+)" +) +attribute_list_pattern = re.compile(r"^\[(?P.*)\]") + +# For matchfiles before 1.0.0. +old_version_pattern = re.compile(r"^(?P[0-9]+)\.(?P[0-9]+)") + + +def interpret_version(version_string: str) -> Version: + """ + Parse matchfile format version from a string. This method + parses a string like "1.0.0" and returns a Version instance. + + Parameters + ---------- + version_string : str + The string containg the version. The version string should be + in the form "{major}.{minor}.{patch}" or "{minor}.{patch}" for versions + previous to 1.0.0. Incorrectly formatted strings + will result in an error. + + Returns + ------- + version : Version + A named tuple specifying the version + """ + version_info = version_pattern.search(version_string) + + if version_info is not None: + ma, mi, pa = version_info.groups() + version = Version(int(ma), int(mi), int(pa)) + return version + + # If using the first pattern fails, try with old version + version_info = old_version_pattern.search(version_string) + + if version_info is not None: + mi, pa = version_info.groups() + version = Version(0, int(mi), int(pa)) + return version + + else: + raise ValueError(f"The version '{version_string}' is incorrectly formatted!") + + +def format_version(version: Version) -> str: + """ + Format version as a string. + + Parameters + ---------- + version : Version + A Version instance. + + Returns + ------- + version_str : str + A string representation of the version. + """ + ma, mi, pa = version + + version_str = f"{ma}.{mi}.{pa}" + return version_str + + +def interpret_as_int(value: str) -> int: + """ + Interpret value as an integer + + Parameters + ---------- + value : str + The value to interpret as integer. + + Returns + ------- + int + The value cast as an integer. + """ + return int(value) + + +def format_int(value: int) -> str: + """ + Format a string from an integer + + Parameters + ---------- + value : int + The value to be converted to format as a string. + + Returns + ------- + str + The value formatted as a string. + """ + return f"{value}" + + +def interpret_as_float(value: str) -> float: + """ + Interpret value as a float + + Parameters + ---------- + value : str + The string to interpret as float. + + Returns + ------- + int + The value cast as an float. + """ + return float(value) + + +def format_float(value: float) -> str: + """ + Format a float as a string (with 4 digits of precision). + + Parameters + ---------- + value : float + The value to be converted to format as a string. + + Returns + ------- + str + The value formatted as a string. + """ + return f"{value:.4f}" + + +def format_float_unconstrained(value: float) -> str: + """ + Format a float as a string. + + Parameters + ---------- + value : float + The value to be converted to format as a string. + + Returns + ------- + str + The value formatted as a string. + """ + return str(value) + + +def interpret_as_string(value: Any) -> str: + """ + Interpret value as a string. This method is for completeness. + + Parameters + ---------- + value : Any + The value to be interpreted as a string. + + Returns + ------- + int + The string representation of the value. + """ + return str(value) + + +def format_string(value: str) -> str: + """ + Format a string as a string (for completeness ;). + + Parameters + ---------- + value : int + The value to be converted to format as a string. + + Returns + ------- + str + The value formatted as a string. + """ + return value.strip() + + +class FractionalSymbolicDuration(object): + """ + A class to represent symbolic duration information. + + Parameters + ---------- + numerator : int + The value of the numerator. + denominator: int + The denominator of the duration (whole notes = 1, half notes = 2, etc.) + tuple_div : int (optional) + Tuple divisor (for triplets, etc.). For example a single note in a quintuplet + with a total duration of one quarter could be specified as + `duration = FractionalSymbolicDuration(1, 4, 5)`. + add_components : List[Tuple[int, int, Optional[int]]] (optional) + additive components (to express durations like 1/4+1/16+1/32). The components + are a list of tuples, each of which contains its own numerator, denominator + and tuple_div (or None). To represent the components 1/16+1/32 + in the example above, this variable would look like + `add_components = [(1, 16, None), (1, 32, None)]`. + """ + + def __init__( + self, + numerator: int, + denominator: int = 1, + tuple_div: Optional[int] = None, + add_components: Optional[List[Tuple[int, int, Optional[int]]]] = None, + ) -> None: + + self.numerator = numerator + self.denominator = denominator + self.tuple_div = tuple_div + self.add_components = add_components + self.bound_integers(1024) + + def _str( + self, + numerator: int, + denominator: int, + tuple_div: Optional[int], + ) -> str: + """ + Helper for representing an instance as a string. + """ + if denominator == 1 and tuple_div is None: + return str(numerator) + else: + if tuple_div is None: + return "{0}/{1}".format(numerator, denominator) + else: + return "{0}/{1}/{2}".format(numerator, denominator, tuple_div) + + def bound_integers(self, bound: int) -> None: + """ + Bound numerator and denominator + """ + denominators = [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 12, + 14, + 16, + 18, + 20, + 22, + 24, + 28, + 32, + 48, + 64, + 96, + 128, + ] + sign = np.sign(self.numerator) * np.sign(self.denominator) + self.numerator = np.abs(self.numerator) + self.denominator = np.abs(self.denominator) + + if self.numerator > bound or self.denominator > bound: + val = float(self.numerator / self.denominator) + dif = [] + for den in denominators: + if np.round(val * den) > 0.9: + dif.append(np.abs(np.round(val * den) - val * den)) + else: + dif.append(np.abs(1 - val * den)) + + difn = np.array(dif) + min_idx = int(np.argmin(difn)) + + self.denominator = denominators[min_idx] + if int(np.round(val * self.denominator)) < 1: + self.numerator = sign * 1 + else: + self.numerator = sign * int(np.round(val * self.denominator)) + + def __str__(self) -> str: + """ + Represent an instance as a string. + """ + if self.add_components is None: + return self._str(self.numerator, self.denominator, self.tuple_div) + else: + r = [self._str(*i) for i in self.add_components] + return "+".join(r) + + def __add__( + self, sd: Union[FractionalSymbolicDuration, int] + ) -> FractionalSymbolicDuration: + """ + Define addition between FractionalSymbolicDuration instances. + + Parameters + ---------- + sd : Union[FractionalSymbolicDuration, int] + A FractionalSymbolicDuration instance or an integer to add + to the current instance (self). + + Returns + ------- + FractionalSymbolicDuration + A new instance with the value equal to the sum + of `sd` + `self`. + """ + if isinstance(sd, int): + sd = FractionalSymbolicDuration(sd, 1) + + dens = np.array([self.denominator, sd.denominator], dtype=int) + new_den = np.lcm(dens[0], dens[1]) + a_mult = new_den // dens + new_num = np.dot(a_mult, [self.numerator, sd.numerator]) + + if self.add_components is None and sd.add_components is None: + add_components = [ + (self.numerator, self.denominator, self.tuple_div), + (sd.numerator, sd.denominator, sd.tuple_div), + ] + + elif self.add_components is not None and sd.add_components is None: + add_components = self.add_components + [ + (sd.numerator, sd.denominator, sd.tuple_div) + ] + elif self.add_components is None and sd.add_components is not None: + add_components = [ + (self.numerator, self.denominator, self.tuple_div) + ] + sd.add_components + else: + add_components = self.add_components + sd.add_components + + # Remove spurious components with 0 in the numerator + add_components = [c for c in add_components if c[0] != 0] + + return FractionalSymbolicDuration( + numerator=new_num, + denominator=new_den, + add_components=add_components, + ) + + def __radd__( + self, sd: Union[FractionalSymbolicDuration, int] + ) -> FractionalSymbolicDuration: + return self.__add__(sd) + + def __float__(self) -> float: + # Cast as float since the ability to return an instance of a strict + # subclass of float is deprecated, and may be removed in a future + # version of Python. (following a deprecation warning) + return float(self.numerator / (self.denominator * (self.tuple_div or 1))) + + @classmethod + def from_string(cls, string: str, allow_additions: bool = True): + + m = rational_pattern.match(string) + m2 = double_rational_pattern.match(string) + m3 = integer_pattern.match(string) + if m: + groups = m.groups() + return cls(*[int(g) for g in groups]) + elif m2: + groups = m2.groups() + return cls(*[int(g) for g in groups]) + elif m3: + return cls(numerator=int(m3.group("integer"))) + + else: + if allow_additions: + parts = string.split("+") + + if len(parts) > 1: + iparts = [ + cls.from_string( + i, + allow_additions=False, + ) + for i in parts + ] + + # to be replaced with isinstance(i,numbers.Number) + if all(type(i) in (int, float, cls) for i in iparts): + if any([isinstance(i, cls) for i in iparts]): + iparts = [ + cls(i) if not isinstance(i, cls) else i for i in iparts + ] + return sum(iparts) + + raise ValueError( + f"{string} cannot be interpreted as FractionalSymbolicDuration" + ) + + +def interpret_as_fractional(value: str) -> FractionalSymbolicDuration: + """ + Interpret string as FractionalSymbolicDuration + """ + return FractionalSymbolicDuration.from_string(value, allow_additions=True) + + +def format_fractional(value: FractionalSymbolicDuration) -> str: + """ + Format fractional symbolic duration as string + """ + return str(value) + + +def interpret_as_list(value: str) -> List[str]: + """ + Interpret string as list of values. + + Parameters + ---------- + value: str + + Returns + ------- + content_list : List[str] + """ + content = attribute_list_pattern.search(value) + + if content is not None: + # string includes square brackets + vals_string = content.group("attributes") + content_list = [v.strip() for v in vals_string.split(",")] + + else: + # value is not inside square brackets + content_list = [v.strip() for v in value.split(",")] + + return content_list + + +def format_list(value: List[Any]) -> str: + formatted_string = f"[{','.join([str(v) for v in value])}]" + return formatted_string + + +def to_camel_case(field_name: str) -> str: + + camel_case = "".join([f"_{fn.lower()}" if fn.isupper() else fn for fn in field_name]) + + if camel_case.startswith('_'): + camel_case = camel_case[1:] + + return camel_case diff --git a/partitura/io/matchlines_v0.py b/partitura/io/matchlines_v0.py index 43a3001a..381a69f9 100644 --- a/partitura/io/matchlines_v0.py +++ b/partitura/io/matchlines_v0.py @@ -9,14 +9,17 @@ import re -from typing import Any, Callable, Tuple +from typing import Any, Callable, Tuple, Union, List from partitura.io.matchfile_base import ( MatchLine, - Version, BaseInfoLine, BaseSnoteLine, MatchError, +) + +from partitura.io.matchfile_utils import ( + Version, interpret_version, format_version, interpret_as_string, @@ -170,3 +173,71 @@ def from_matchline( else: raise MatchError("Input match line does not fit the expected pattern.") + + +class MatchSnote(BaseSnoteLine): + def __init__( + self, + version: Version, + anchor: str, + note_name: str, + modifier: str, + octave: Union[int, str], + measure: int, + beat: int, + offset: FractionalSymbolicDuration, + duration: FractionalSymbolicDuration, + onset_in_beats: float, + offset_in_beats: float, + score_attributes_list: List[str], + ): + super().__init__( + version=version, + anchor=anchor, + note_name=note_name, + modifier=modifier, + octave=octave, + measure=measure, + beat=beat, + offset=offset, + duration=duration, + onset_in_beats=onset_in_beats, + offset_in_beats=offset_in_beats, + score_attributes_list=score_attributes_list, + ) + + @classmethod + def from_matchline( + cls, + matchline: str, + pos: int = 0, + version: Version = LAST_VERSION, + ) -> MatchSnote: + """ + Create a new MatchLine object from a string + + Parameters + ---------- + matchline : str + String with a matchline + pos : int (optional) + Position of the matchline in the input string. By default it is + assumed that the matchline starts at the beginning of the input + string. + version : Version (optional) + Version of the matchline. By default it is the latest version. + + Returns + ------- + a MatchSnote object + """ + + if version >= Version(1, 0, 0): + raise ValueError(f"{version} > Version(1, 0, 0)") + + kwargs = cls.prepare_kwargs_from_matchline( + matchline=matchline, + pos=pos, + ) + + return cls(version=version, **kwargs) diff --git a/partitura/io/matchlines_v1.py b/partitura/io/matchlines_v1.py index a0e5b4cc..14cbd235 100644 --- a/partitura/io/matchlines_v1.py +++ b/partitura/io/matchlines_v1.py @@ -16,10 +16,14 @@ from partitura.io.matchfile_base import ( MatchLine, + MatchError, Version, BaseInfoLine, BaseSnoteLine, - MatchError, + BaseNoteLine, +) + +from partitura.io.matchfile_utils import ( interpret_version, format_version, interpret_as_string, @@ -33,6 +37,7 @@ interpret_as_fractional, interpret_as_list, format_list, + to_camel_case, ) # Define current version of the match file format @@ -193,7 +198,12 @@ class MatchScoreProp(MatchLine): ) pattern = re.compile( - r"scoreprop\(([^,]+),([^,]+),([^,]+):([^,]+),([^,]+),([^,]+)\)\." + r"scoreprop\(" + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+):(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)\)\." ) def __init__( @@ -314,57 +324,116 @@ def from_matchline( raise MatchError("Input match line does not fit the expected pattern.") -class MatchSnote(BaseSnoteLine): +SECTION_LINE = { + Version(1, 0, 0): { + "StartInBeatsUnfolded": (interpret_as_float, format_float, float), + "EndInBeatsUnfolded": (interpret_as_float, format_float, float), + "StartInBeatsOriginal": (interpret_as_float, format_float, float), + "EndInBeatsOriginal": (interpret_as_float, format_float, float), + "RepeatEndType": (interpret_as_list, format_list, list), + } +} - field_names = ( - "Anchor", - "NoteName", - "Modifier", - "Octave", - "Measure", - "Beat", - "Offset", - "Duration", - "OnsetInBeats", - "OffsetInBeats", - "ScoreAttributesList", - ) - field_types = ( - str, - str, - (int, type(None)), - int, - int, - int, - FractionalSymbolicDuration, - FractionalSymbolicDuration, - float, - float, - list, +class MatchSection(MatchLine): + """ + Class for specifiying structural information (i.e., sections). + + section(StartInBeatsUnfolded,EndInBeatsUnfolded,StartInBeatsOriginal,EndInBeatsOriginal,RepeatEndType). + + Parameters + ---------- + version: Version, + start_in_beats_unfolded: float, + end_in_beats_unfolded: float, + start_in_beats_original: float, + end_in_beats_original: float, + repeat_end_type: List[str] + """ + + field_names = ( + "StartInBeatsUnfolded", + "EndInBeatsUnfolded", + "StartInBeatsOriginal", + "EndInBeatsOriginal", + "RepeatEndType", ) out_pattern = ( - "snote({Anchor},[{NoteName},{Modifier}],{Octave}," - "{Measure}:{Beat},{Offset},{Duration},{OnsetInBeats}," - "{OffsetInBeats},{ScoreAttributesList})" + "section({StartInBeatsUnfolded}," + "{EndInBeatsUnfolded},{StartInBeatsOriginal}," + "{EndInBeatsOriginal},{RepeatEndType})." ) - pattern = re.compile( - # r"snote\(([^,]+),\[([^,]+),([^,]+)\],([^,]+)," - # r"([^,]+):([^,]+),([^,]+),([^,]+),([^,]+),([^,]+),\[(.*)\]\)" - r"snote\(" - r"(?P[^,]+)," - r"\[(?P[^,]+),(?P[^,]+)\]," - r"(?P[^,]+)," - r"(?P[^,]+):(?P[^,]+)," - r"(?P[^,]+)," - r"(?P[^,]+)," - r"(?P[^,]+)," - r"(?P[^,]+)," - r"\[(?P.*)\]\)" + r"section\(" + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"\[(?P.*)\]\)." ) + def __init__( + self, + version: Version, + start_in_beats_unfolded: float, + end_in_beats_unfolded: float, + start_in_beats_original: float, + end_in_beats_original: float, + repeat_end_type: List[str], + ) -> None: + + if version not in SECTION_LINE: + raise ValueError( + f"Unknown version {version}!. " + f"Supported versions are {list(SECTION_LINE.keys())}" + ) + super().__init__(version) + + self.field_types = (ft[-1] for _, ft in SECTION_LINE[version].items()) + self.format_fun = dict( + [(fn, ft[1]) for fn, ft in SECTION_LINE[version].items()] + ) + + self.StartInBeatsUnfolded = start_in_beats_unfolded + self.EndInBeatsUnfolded = end_in_beats_unfolded + self.StartInBeatsOriginal = start_in_beats_original + self.EndInBeatsOriginal = end_in_beats_original + self.RepeatEndType = repeat_end_type + + @classmethod + def from_matchline( + cls, + matchline: str, + pos: int = 0, + version: Version = CURRENT_VERSION, + ) -> MatchSection: + if version not in SECTION_LINE: + raise ValueError( + f"Unknown version {version}!. " + f"Supported versions are {list(SECTION_LINE.keys())}" + ) + + match_pattern = cls.pattern.search(matchline, pos=pos) + class_dict = SECTION_LINE[version] + + if match_pattern is not None: + + kwargs = dict( + [ + (to_camel_case(fn), class_dict[fn][0](match_pattern.group(fn))) + for fn in cls.field_names + ] + ) + + return cls(version=version, **kwargs) + + else: + raise MatchError("Input match line does not fit the expected pattern.") + + +class MatchSnote(BaseSnoteLine): + format_fun = dict( Anchor=format_string, NoteName=lambda x: str(x.upper()), @@ -393,7 +462,7 @@ def __init__( onset_in_beats: float, offset_in_beats: float, score_attributes_list: List[str], - ): + ) -> None: super().__init__( version=version, anchor=anchor, @@ -406,10 +475,9 @@ def __init__( duration=duration, onset_in_beats=onset_in_beats, offset_in_beats=offset_in_beats, + score_attributes_list=score_attributes_list, ) - self.ScoreAttributesList = score_attributes_list - @classmethod def from_matchline( cls, @@ -417,49 +485,42 @@ def from_matchline( pos: int = 0, version: Version = CURRENT_VERSION, ) -> MatchSnote: + """ + Create a new MatchLine object from a string + + Parameters + ---------- + matchline : str + String with a matchline + pos : int (optional) + Position of the matchline in the input string. By default it is + assumed that the matchline starts at the beginning of the input + string. + version : Version (optional) + Version of the matchline. By default it is the latest version. + + Returns + ------- + a MatchSnote object + """ if version < Version(1, 0, 0): raise ValueError(f"{version} < Version(1, 0, 0)") - match_pattern = cls.pattern.search(matchline, pos) + kwargs = cls.prepare_kwargs_from_matchline( + matchline=matchline, + pos=pos, + ) - if match_pattern is not None: + return cls(version=version, **kwargs) - ( - anchor_str, - note_name_str, - modifier_str, - octave_str, - measure_str, - beat_str, - offset_str, - duration_str, - onset_in_beats_str, - offset_in_beats_str, - score_attributes_list_str, - ) = match_pattern.groups() - - anchor = interpret_as_string(anchor_str) - note_name, modifier, octave = ensure_pitch_spelling_format( - step=note_name_str, - alter=modifier_str, - octave=octave_str, - ) - return cls( - version=version, - anchor=interpret_as_string(anchor), - note_name=note_name, - modifier=modifier, - octave=octave, - measure=interpret_as_int(measure_str), - beat=interpret_as_int(beat_str), - offset=interpret_as_fractional(offset_str), - duration=interpret_as_fractional(duration_str), - onset_in_beats=interpret_as_float(onset_in_beats_str), - offset_in_beats=interpret_as_float(offset_in_beats_str), - score_attributes_list=interpret_as_list(score_attributes_list_str), - ) +class MatchNote(BaseNoteLine): - else: - raise MatchError("Input match line does not fit the expected pattern.") + field_names = ( + "ID", + "MidiPitch", + "Onset", + "Offset", + "Velocity", + ) diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index 96bbd759..f9ca46d6 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -11,6 +11,7 @@ from partitura.io.matchlines_v1 import ( MatchInfo, MatchScoreProp, + MatchSection, MatchSnote, ) @@ -130,6 +131,26 @@ def test_score_prop_lines(self): # assert that the data types of the match line are correct self.assertTrue(mo.check_types()) + def test_section_lines(self): + + section_lines = [ + "section(0.0000,100.0000,0.0000,100.0000,[end]).", + "section(100.0000,200.0000,0.0000,100.0000,[fine]).", + "section(100.0000,200.0000,0.0000,100.0000,[volta end]).", + "section(100.0000,200.0000,0.0000,100.0000,[repeat left]).", + ] + + for ml in section_lines: + # assert that the information from the matchline + # is parsed correctly and results in an identical line + # to the input match line + mo = MatchSection.from_matchline(ml) + # print(mo.matchline, ml, [(g == t, g, t) for g, t in zip(mo.matchline, ml)]) + self.assertTrue(mo.matchline == ml) + + # assert that the data types of the match line are correct + self.assertTrue(mo.check_types()) + def test_snote_lines(self): snote_lines = [ @@ -139,11 +160,79 @@ def test_snote_lines(self): "snote(n143,[B,b],5,7:2,2/16,1/8,25.5000,26.0000,[s,stacc])", ] - for ml in snote_lines: + output_strings = [ + ( + "MatchSnote\n" + " Anchor: n1\n" + " NoteName: B\n" + " Modifier: 0\n" + " Octave: 3\n" + " Measure: 0\n" + " Beat: 2\n" + " Offset: 1/8\n" + " Duration: 1/8\n" + " OnsetInBeats: -0.5\n" + " OffsetInBeats: 0.0\n" + " ScoreAttributesList: ['v1']" + ), + ( + "MatchSnote\n" + " Anchor: n3\n" + " NoteName: G\n" + " Modifier: 1\n" + " Octave: 3\n" + " Measure: 1\n" + " Beat: 1\n" + " Offset: 0\n" + " Duration: 1/16\n" + " OnsetInBeats: 0.0\n" + " OffsetInBeats: 0.25\n" + " ScoreAttributesList: ['v3']" + ), + ( + "MatchSnote\n" + " Anchor: n1\n" + " NoteName: E\n" + " Modifier: 0\n" + " Octave: 4\n" + " Measure: 1\n" + " Beat: 1\n" + " Offset: 0\n" + " Duration: 1/4\n" + " OnsetInBeats: 0.0\n" + " OffsetInBeats: 1.0\n" + " ScoreAttributesList: ['arp']" + ), + ( + "MatchSnote\n" + " Anchor: n143\n" + " NoteName: B\n" + " Modifier: -1\n" + " Octave: 5\n" + " Measure: 7\n" + " Beat: 2\n" + " Offset: 2/16\n" + " Duration: 1/8\n" + " OnsetInBeats: 25.5\n" + " OffsetInBeats: 26.0\n" + " ScoreAttributesList: ['s', 'stacc']" + ), + ] + + for ml, strl in zip(snote_lines, output_strings): # assert that the information from the matchline # is parsed correctly and results in an identical line # to the input match line mo = MatchSnote.from_matchline(ml) + # test __str__ method + self.assertTrue( + all( + [ + ll.strip() == sl.strip() + for ll, sl in zip(str(mo).splitlines(), strl.splitlines()) + ] + ) + ) # print(mo.matchline, ml) self.assertTrue(mo.matchline == ml) From fffec020700d5beea1a62c65d9e430a2344ae962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Sun, 20 Nov 2022 09:16:20 +0100 Subject: [PATCH 29/88] match notes --- partitura/io/matchfile_base.py | 10 +-- partitura/io/matchfile_utils.py | 51 ++++++++++++- partitura/io/matchlines_v1.py | 125 ++++++++++++++++++++++++++++---- tests/test_match_import_new.py | 64 +++++++++++++++- 4 files changed, 224 insertions(+), 26 deletions(-) diff --git a/partitura/io/matchfile_base.py b/partitura/io/matchfile_base.py index f01be6f6..b15c041d 100644 --- a/partitura/io/matchfile_base.py +++ b/partitura/io/matchfile_base.py @@ -425,7 +425,7 @@ class BaseNoteLine(MatchLine): # All derived classes should include at least # these field names field_names = ( - "ID", + "Id", "MidiPitch", "Onset", "Offset", @@ -443,18 +443,18 @@ class BaseNoteLine(MatchLine): def __init__( self, version: Version, - note_id: str, + id: str, midi_pitch: int, onset: float, offset: float, velocity: int, ) -> None: super().__init__(version) - self.ID = note_id - self.MIDIPitch = midi_pitch + self.Id = id + self.MidiPitch = midi_pitch self.Onset = onset self.Offset = offset - self.velocity = velocity + self.Velocity = velocity @property def Duration(self): diff --git a/partitura/io/matchfile_utils.py b/partitura/io/matchfile_utils.py index ceb321dd..b4b4b196 100644 --- a/partitura/io/matchfile_utils.py +++ b/partitura/io/matchfile_utils.py @@ -5,7 +5,7 @@ """ from __future__ import annotations -from typing import Tuple, Any, Optional, Union, List +from typing import Tuple, Any, Optional, Union, List, Dict, Callable import re import numpy as np @@ -467,11 +467,54 @@ def format_list(value: List[Any]) -> str: return formatted_string -def to_camel_case(field_name: str) -> str: +## Miscellaneous utils + - camel_case = "".join([f"_{fn.lower()}" if fn.isupper() else fn for fn in field_name]) +def to_camel_case(field_name: str) -> str: + """ + To camel case + """ + camel_case = "".join( + [f"_{fn.lower()}" if fn.isupper() else fn for fn in field_name] + ) - if camel_case.startswith('_'): + if camel_case.startswith("_"): camel_case = camel_case[1:] return camel_case + + +def get_kwargs_from_matchline( + matchline: str, + pattern: re.Pattern, + field_names: Tuple[str], + class_dict: Dict[str, Tuple[Callable, Callable, type]], + pos: int = 0, +) -> Optional[Dict[str, Any]]: + """ + Parameters + ---------- + matchline: str + pattern: re.Pattern + field_names: Tuple[str] + class_dict: Dict[str, Tuple[Callable, Callable, type]] + pos: int + + Returns + ------- + kwargs : dict + + """ + kwargs = None + match_pattern = pattern.search(matchline, pos=pos) + + if match_pattern is not None: + + kwargs = dict( + [ + (to_camel_case(fn), class_dict[fn][0](match_pattern.group(fn))) + for fn in field_names + ] + ) + + return kwargs diff --git a/partitura/io/matchlines_v1.py b/partitura/io/matchlines_v1.py index 14cbd235..13fd3e06 100644 --- a/partitura/io/matchlines_v1.py +++ b/partitura/io/matchlines_v1.py @@ -38,17 +38,18 @@ interpret_as_list, format_list, to_camel_case, + get_kwargs_from_matchline, ) # Define current version of the match file format -CURRENT_MAJOR_VERSION = 1 -CURRENT_MINOR_VERSION = 0 -CURRENT_PATCH_VERSION = 0 - -CURRENT_VERSION = Version( - CURRENT_MAJOR_VERSION, - CURRENT_MINOR_VERSION, - CURRENT_PATCH_VERSION, +LATEST_MAJOR_VERSION = 1 +LATEST_MINOR_VERSION = 0 +LATEST_PATCH_VERSION = 0 + +LATEST_VERSION = Version( + LATEST_MAJOR_VERSION, + LATEST_MINOR_VERSION, + LATEST_PATCH_VERSION, ) @@ -120,7 +121,7 @@ def from_matchline( cls, matchline: str, pos: int = 0, - version: Version = CURRENT_VERSION, + version: Version = LATEST_VERSION, ) -> MatchInfo: """ Create a new MatchLine object from a string @@ -184,14 +185,14 @@ def from_matchline( class MatchScoreProp(MatchLine): - field_names = [ + field_names = ( "Attribute", "Value", "Measure", "Beat", "Offset", "TimeInBeats", - ] + ) out_pattern = ( "scoreprop({Attribute},{Value},{Measure}:{Beat},{Offset},{TimeInBeats})." @@ -254,7 +255,7 @@ def from_matchline( cls, matchline: str, pos: int = 0, - version: Version = CURRENT_VERSION, + version: Version = LATEST_VERSION, ) -> MatchInfo: """ Create a new MatchScoreProp object from a string @@ -390,7 +391,9 @@ def __init__( ) super().__init__(version) - self.field_types = (ft[-1] for _, ft in SECTION_LINE[version].items()) + self.field_types = tuple( + SECTION_LINE[version][fn][2] for fn in self.field_names + ) self.format_fun = dict( [(fn, ft[1]) for fn, ft in SECTION_LINE[version].items()] ) @@ -406,7 +409,7 @@ def from_matchline( cls, matchline: str, pos: int = 0, - version: Version = CURRENT_VERSION, + version: Version = LATEST_VERSION, ) -> MatchSection: if version not in SECTION_LINE: raise ValueError( @@ -483,7 +486,7 @@ def from_matchline( cls, matchline: str, pos: int = 0, - version: Version = CURRENT_VERSION, + version: Version = LATEST_VERSION, ) -> MatchSnote: """ Create a new MatchLine object from a string @@ -515,12 +518,102 @@ def from_matchline( return cls(version=version, **kwargs) +NOTE_LINE = { + Version(1, 0, 0): { + "Id": (interpret_as_string, format_string, str), + "MidiPitch": (interpret_as_int, format_int, int), + "Onset": (interpret_as_int, format_int, int), + "Offset": (interpret_as_int, format_int, int), + "Velocity": (interpret_as_int, format_int, int), + "Channel": (interpret_as_int, format_int, int), + "Track": (interpret_as_int, format_int, int), + } +} + + class MatchNote(BaseNoteLine): field_names = ( - "ID", + "Id", "MidiPitch", "Onset", "Offset", "Velocity", + "Channel", + "Track", + ) + + out_pattern = ( + "note({Id},{MidiPitch},{Onset},{Offset},{Velocity},{Channel},{Track})." ) + + pattern = re.compile( + # r"note\(([^,]+),([^,]+),([^,]+),([^,]+),([^,]+),([^,]+),([^,]+)\)" + r"note\((?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)\)" + ) + + def __init__( + self, + version: Version, + id: str, + midi_pitch: int, + onset: int, + offset: int, + velocity: int, + channel: int, + track: int, + ) -> None: + + if version not in NOTE_LINE: + raise ValueError( + f"Unknown version {version}!. " + f"Supported versions are {list(NOTE_LINE.keys())}" + ) + + super().__init__( + version=version, + id=id, + midi_pitch=midi_pitch, + onset=onset, + offset=offset, + velocity=velocity, + ) + + self.Channel = channel + self.Track = track + + self.field_types = tuple(NOTE_LINE[version][fn][2] for fn in self.field_names) + self.format_fun = dict( + [(fn, NOTE_LINE[version][fn][1]) for fn in self.field_names] + ) + + @classmethod + def from_matchline( + cls, + matchline: str, + pos: int = 0, + version: Version = LATEST_VERSION, + ) -> MatchNote: + + if version < Version(1, 0, 0): + ValueError(f"{version} < Version(1, 0, 0)") + + kwargs = get_kwargs_from_matchline( + matchline=matchline, + pattern=cls.pattern, + field_names=cls.field_names, + class_dict=NOTE_LINE[version], + pos=pos, + ) + + if kwargs is not None: + return cls(version=version, **kwargs) + + else: + raise MatchError("Input match line does not fit the expected pattern.") diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index f9ca46d6..330cd178 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -13,9 +13,10 @@ MatchScoreProp, MatchSection, MatchSnote, + MatchNote, ) -from partitura.io.matchfile_base import interpret_version, Version +from partitura.io.matchfile_base import interpret_version, Version, MatchError class TestMatchLinesV1_0_0(unittest.TestCase): @@ -104,6 +105,14 @@ def test_info_lines(self): # assert that the error was raised self.assertTrue(True) + try: + # This is not a valid line and should result in a MatchError + wrong_line = "wrong_line" + mo = MatchInfo.from_matchline(wrong_line) + self.assertTrue(False) + except MatchError: + self.assertTrue(True) + def test_score_prop_lines(self): keysig_line = "scoreprop(keySignature,E,0:2,1/8,-0.5000)." @@ -131,6 +140,14 @@ def test_score_prop_lines(self): # assert that the data types of the match line are correct self.assertTrue(mo.check_types()) + try: + # This is not a valid line and should result in a MatchError + wrong_line = "wrong_line" + mo = MatchScoreProp.from_matchline(wrong_line) + self.assertTrue(False) + except MatchError: + self.assertTrue(True) + def test_section_lines(self): section_lines = [ @@ -151,6 +168,23 @@ def test_section_lines(self): # assert that the data types of the match line are correct self.assertTrue(mo.check_types()) + # Check version (an error should be raised for old versions) + try: + mo = MatchSection.from_matchline(section_lines[0], version=Version(0, 5, 0)) + self.assertTrue(False) + + except ValueError: + self.assertTrue(True) + + # Check that incorrectly formatted line results in a match error + try: + # Line does not have [] for the end annotations + wrong_line = "section(0.0000,100.0000,0.0000,100.0000,end)." + mo = MatchSection.from_matchline(wrong_line) + self.assertTrue(False) + except MatchError: + self.assertTrue(True) + def test_snote_lines(self): snote_lines = [ @@ -239,6 +273,34 @@ def test_snote_lines(self): # assert that the data types of the match line are correct self.assertTrue(mo.check_types()) + try: + # This is not a valid line and should result in a MatchError + wrong_line = "wrong_line" + mo = MatchSnote.from_matchline(wrong_line) + self.assertTrue(False) + except MatchError: + self.assertTrue(True) + + def test_note_lines(self): + + note_lines = [ + "note(0,47,46710,58040,26,0,0).", + "note(13,51,72850,88210,45,0,0).", + "note(32,28,103220,114320,37,0,0).", + "note(65,51,153250,157060,60,0,0).", + ] + + for ml in note_lines: + # assert that the information from the matchline + # is parsed correctly and results in an identical line + # to the input match line + mo = MatchNote.from_matchline(ml) + # print(mo.matchline, ml, [(g == t, g, t) for g, t in zip(mo.matchline, ml)]) + self.assertTrue(mo.matchline == ml) + + # assert that the data types of the match line are correct + self.assertTrue(mo.check_types()) + class TestMatchUtils(unittest.TestCase): """ From ee9e07daf565f728c3007f7467ab73f399895afd Mon Sep 17 00:00:00 2001 From: sildater <41552783+sildater@users.noreply.github.com> Date: Mon, 21 Nov 2022 14:41:28 +0100 Subject: [PATCH 30/88] add docstring to to_matched_score --- partitura/musicanalysis/performance_codec.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/partitura/musicanalysis/performance_codec.py b/partitura/musicanalysis/performance_codec.py index 59cebae1..b5b12ccb 100644 --- a/partitura/musicanalysis/performance_codec.py +++ b/partitura/musicanalysis/performance_codec.py @@ -485,11 +485,23 @@ def get_unique_onset_idxs( def to_matched_score(part, ppart, alignment): + """ + Returns a mixed score-performance note array + consisting of matched notes in the alignment. + + Args: + part (partitura.Part): a part object + ppart (partitura.PerformedPart): a performedpart object + alignment (List(Dict)): an alignment + + Returns: + np.ndarray: a minimal, aligned + score-performance note array + """ + # remove repetitions from aligment note ids for a in alignment: if a["label"] == "match": - # FOR MAGALOFF/ZEILINGER DO SPLIT: - # a['score_id'] = str(a['score_id']).split('-')[0] a["score_id"] = str(a["score_id"]) part_by_id = dict((n.id, n) for n in part.notes_tied) From 7903fbe99c2859734b8217619bc27f559ac6d005 Mon Sep 17 00:00:00 2001 From: melkisedeath Date: Mon, 21 Nov 2022 17:10:00 +0100 Subject: [PATCH 31/88] Degraded Slur Mismatch to a warning from a Value error in Kern Import. --- partitura/io/importkern.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/partitura/io/importkern.py b/partitura/io/importkern.py index f5a20e87..36f0d0b2 100644 --- a/partitura/io/importkern.py +++ b/partitura/io/importkern.py @@ -189,9 +189,12 @@ def _handle_ties(self): def _handle_slurs(self): if len(self.slur_dict["open"]) != len(self.slur_dict["close"]): - raise ValueError( - "Slur Mismatch! Uneven amount of closing to open slur brackets." + warnings.warn( + "Slur Mismatch! Uneven amount of closing to open slur brackets. Skipping slur parsing.", ImportWarning ) + # raise ValueError( + # "Slur Mismatch! Uneven amount of closing to open slur brackets." + # ) else: for (oid, cid) in list( zip(self.slur_dict["open"], self.slur_dict["close"]) From 3e14fd13aa5ded1ead36cfd823a6b787c0997ba9 Mon Sep 17 00:00:00 2001 From: fosfrancesco Date: Mon, 21 Nov 2022 19:07:04 +0100 Subject: [PATCH 32/88] bug correction in MEI and verovio support --- partitura/io/importmei.py | 57 +- partitura/utils/music.py | 1 + tests/__init__.py | 2 + tests/data/mei/CRIM_Mass_0004_3.mei | 16504 ++++++++++++++++++++++++++ tests/data/mei/CRIM_Mass_0030_4.mei | 6758 +++++++++++ tests/test_mei.py | 16 +- 6 files changed, 23321 insertions(+), 17 deletions(-) create mode 100644 tests/data/mei/CRIM_Mass_0004_3.mei create mode 100644 tests/data/mei/CRIM_Mass_0030_4.mei diff --git a/partitura/io/importmei.py b/partitura/io/importmei.py index f3ceb4e2..9b5e3e16 100644 --- a/partitura/io/importmei.py +++ b/partitura/io/importmei.py @@ -14,8 +14,13 @@ ) from partitura.utils import PathLike, get_document_name from partitura.utils.misc import deprecated_alias +import partitura as pt -import verovio +try: + import verovio + VEROVIO_AVAILABLE = True +except : + VEROVIO_AVAILABLE = False import re import warnings @@ -56,7 +61,7 @@ def load_mei(filename: PathLike) -> score.Score: class MeiParser(object): def __init__(self, mei_path: PathLike) -> None: - document, ns = self._parse_mei(mei_path) + document, ns = self._parse_mei(mei_path, use_verovio = VEROVIO_AVAILABLE) self.document = document self.ns = ns # the namespace in the MEI file self.parts = ( @@ -138,6 +143,7 @@ def _parse_mei(self, mei_path, use_verovio = True): huge_tree=False, remove_comments=True, remove_blank_text=True, + recover = True ) if use_verovio: @@ -149,7 +155,7 @@ def _parse_mei(self, mei_path, use_verovio = True): tree = etree.ElementTree(root) else: tree = etree.parse(mei_path,parser) - root = tree.get_root() + root = tree.getroot() # find the namespace ns = root.nsmap[None] # --> nsmap fetches a dict of the namespace Map, generally for root the key `None` fetches the namespace of the document. @@ -361,19 +367,30 @@ def _find_ppq(self): """Finds the ppq for MEI filed that do not explicitely encode this information""" els_with_dur = self.document.xpath(".//*[@dur]") durs = [] + durs_ppq = [] for el in els_with_dur: symbolic_duration = self._get_symbolic_duration(el) intsymdur, dots = self._intsymdur_from_symbolic(symbolic_duration) # double the value if we have dots, to be sure be able to encode that with integers in partitura durs.append(intsymdur * (2 ** dots)) + durs_ppq.append(None if el.get("dur.ppq") is None else int(el.get("dur.ppq"))) - # add 4 to be sure to not go under 1 ppq - durs.append(4) + if any([dppq is not None for dppq in durs_ppq]): + # there is at least one element with both dur and dur.ppq + for dur, dppq in zip(durs,durs_ppq): + if dppq is not None: + return dppq*dur/4 + else: + # compute the ppq from the durations + # add 4 to be sure to not go under 1 ppq + durs.append(4) + durs= np.array(durs) + # remove elements smaller than 1 + durs = durs[durs >= 1] - # TODO : check if this can create problems with rounding of float durations - least_common_multiple = np.lcm.reduce(np.array(durs, dtype=int)) + least_common_multiple = np.lcm.reduce(durs.astype(int)) - return least_common_multiple / 4 + return least_common_multiple / 4 def _handle_initial_staffdef(self, staffdef_el): """ @@ -475,7 +492,10 @@ def _note_el_to_accid_int(self, note_el) -> int: elif note_el.get("accid.ges") is not None: return SIGN_TO_ALTER[note_el.get("accid.ges")] elif note_el.find(self._ns_name("accid")) is not None: - return SIGN_TO_ALTER[note_el.find(self._ns_name("accid")).get("accid")] + if note_el.find(self._ns_name("accid")).get("accid") is not None: + return SIGN_TO_ALTER[note_el.find(self._ns_name("accid")).get("accid")] + else: + return SIGN_TO_ALTER[note_el.find(self._ns_name("accid")).get("accid.ges")] else: return None @@ -858,6 +878,9 @@ def _handle_staff_in_measure(self, staff_el, staff_ind, position: int, part): f"Warning: voices have different durations in staff {staff_el.attrib[self._ns_name('id',XML_NAMESPACE)]}" ) + + if len(end_positions) == 0: #if a measure contains no elements (e.g., a forgotten rest) + end_positions.append(position) # add end time of measure part.add(measure, None, max(end_positions)) return max(end_positions) @@ -923,15 +946,21 @@ def _handle_section(self, section_el, parts, position: int): end_positions.append( self._handle_staff_in_measure(staff_el, i_s + 1, position, part) ) + # handle directives (dir elements) + self._handle_directives(element, position) # sanity check that all layers have equal duration - if not all([e == end_positions[0] for e in end_positions]): + max_position = max(end_positions) + if not all([e == max_position for e in end_positions]): warnings.warn( f"Warning : parts have measures of different duration in measure {element.attrib[self._ns_name('id',XML_NAMESPACE)]}" ) - # handle directives (dir elements) - self._handle_directives(element, position) - # move the position at the end of the bar - position = max(end_positions) + # enlarge measures to the max + for part in parts: + last_measure = list(part.iter_all(pt.score.Measure))[-1] + if last_measure.end.t != max_position: + part.add(pt.score.Measure(number = last_measure.number), position, max_position) + part.remove(last_measure) + position = max_position # handle right barline symbol self._handle_barline_symbols(element, position, "right") # handle staffDef elements diff --git a/partitura/utils/music.py b/partitura/utils/music.py index 42aa43d3..4b59db58 100644 --- a/partitura/utils/music.py +++ b/partitura/utils/music.py @@ -45,6 +45,7 @@ MEI_DURS_TO_SYMBOLIC = { "long": "long", "0": "breve", + "breve" : "breve", "1": "whole", "2": "half", "4": "quarter", diff --git a/tests/__init__.py b/tests/__init__.py index 7f469478..7b29a8f2 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -150,6 +150,8 @@ "test_unfold_complex.mei", "test_articulation.mei", "test_merge_voices2.mei", + "CRIM_Mass_0030_4.mei", + "CRIM_Mass_0004_3.mei" ] ] diff --git a/tests/data/mei/CRIM_Mass_0004_3.mei b/tests/data/mei/CRIM_Mass_0004_3.mei new file mode 100644 index 00000000..ac099532 --- /dev/null +++ b/tests/data/mei/CRIM_Mass_0004_3.mei @@ -0,0 +1,16504 @@ + + + + + + + + + Missa Virginis Mariae: Credo + + + Pierre Clereau + + + + Pierre Clereau + + + Vincent Besson + + + Marco Gurrieri + + + Richard Freedman + + + + + + Citations: The Renaissance Imitation Mass Project + + + Centre d'Études Supérieures de la Renaissance + + + Haverford College + + + + This work is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License + + + + + + + + Mac OS X Mountain Lion + + + + + Sibelius to MEI Exporter (2.1.0) + + + + + mei30To40.xsl + + + + + add_metadata.py + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + Pa + + + + + + + + + + + + Pa + + + + + + + + + + + Pa + + + + + + + + + + + + Pa + + + + + + + + + + + + + + + trem + + + + + + + o + + + + + + + mni + + + + + + + + + + + trem + + + + + + + o + + + + + + + + + + + trem + + + + + + + + o + + + + + + + mni + + + + + + + + + + + trem + + + + + + + + o + + + + + + + mni + + + + + + + + + + + + + + + po + + + + + + + + + + + + + + + mni + + + + + + + po + + + + + + + + + + + + + po + + + + + + + ten + + + + + + + + + + + po + + + + + + + + ten + + + + + + + + + + + + + + + + ten + + + + + + + tem, + + + + + + + fa + + + + + + + + + + + ten + + + + + + + tem, + + + + + + + fa + + + + + + + + + + + + + + + + + tem, + + + + + + + + + + + + tem, + + + + + + + fa + + + + + + + + + + + + + + + + cto + + + + + + + rem + + + + + + + + + + + cto + + + + + + + rem + + + + + + + cae + + + + + + + + + + + fa + + + + + + + + + + + + + + cto + + + + + + + rem + + + + + + + cae + + + + + + + + + + + + + + cae + + + + + + + + + + + + + li + + + + + + + + + + + cto + + + + + + + rem + + + + + + + cae + + + + + + + + + + + li + + + + + + + + + + + + + + + + + + + li + + + + + + + et + + + + + + + + + + + et + + + + + + + ter + + + + + + + + + + + + li + + + + + + + et + + + + + + + + ter + + + + + + + + + + + + et + + + + + + + ter + + + + + + + + + + + + + + ter + + + + + + + rae, + + + + + + + + + + + + rae, + + + + + + + vi + + + + + + + + + + + + + + + + + rae, + + + + + + + + + + + + rae, + + + + + + + + + + + + + + + + + + + + si + + + + + + + bi + + + + + + + + + + + vi + + + + + + + + si + + + + + + + + + + + vi + + + + + + + si + + + + + + + + + + + + + vi + + + + + + + + si + + + + + + + + + + + + li + + + + + + + um + + + + + + + + + + + + + bi + + + + + + + li + + + + + + + um + + + + + + + + + + + bi + + + + + + + + + li + + + + + + + + + + + + + + bi + + + + + + + li + + + + + + + um + + + + + + + + + + + + o + + + + + + + mni + + + + + + + um, + + + + + + + + + + + o + + + + + + + mni + + + + + + + + um, + + + + + + + + + + + + um + + + + + + + o + + + + + + + + + + + + + + + + + + o + + + + + + + + + + + + + et + + + + + + + + + + + + + + + + + + + + + + + + + + + mni + + + + + + + + + + + + + + + + ni + + + + + + + + + + um, + + + + + + + + + + + + in + + + + + + + + vi + + + + + + + + + + + + + + + + + um, + + + + + + + + o + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + si + + + + + + + bi + + + + + + + li + + + + + + + + + + + + + + et + + + + + + + + + + + + + + + mni + + + + + + + + + + + + + + + + + + + um. + + + + + + + + + + + + + + + + + + um, + + + + + + + et + + + + + + + in + + + + + + + + + + + + + + et + + + + + + + + + + + + + + + + + + + + + + + in + + + + + + + + vi + + + + + + + si + + + + + + + + + + + vi + + + + + + + si + + + + + + + + + + + + + + + in + + + + + + + + vi + + + + + + + si + + + + + + + + + + + + + + + + + bi + + + + + + + + + + + + + bi + + + + + + + li + + + + + + + um. + + + + + + + + + + + + + + + bi + + + + + + + + + + + + + + + + + + + + + + li + + + + + + + um. + + + + + + + + + + + + + + + + + + + + + + li + + + + + + + + + + um. + + + + + + + Et + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + in + + + + + + + u + + + + + + + num + + + + + + + + + + + Et + + + + + + + in + + + + + + + + + + + + Et + + + + + + + + + + + Et + + + + + + + in + + + + + + + + + + + + + + + + Do + + + + + + + mi + + + + + + + num + + + + + + + + + + + + u + + + + + + + num + + + + + + + + + + + + + + in + + + + + + + u + + + + + + + + + + + u + + + + + + + num + + + + + + + + + + + + + + + + + + + + + + + + + + Do + + + + + + + mi + + + + + + + + + + + + num + + + + + + + + + + + + Do + + + + + + + mi + + + + + + + + + + + + + + + + Ie + + + + + + + + + + + num + + + + + + + + + + + Do + + + + + + + + mi + + + + + + + + + + + num + + + + + + + + Ie + + + + + + + + + + + + + + + sum + + + + + + + Chri + + + + + + + + + + + Ie + + + + + + + sum + + + + + + + + + + + num + + + + + + + + Ie + + + + + + + + + + + sum + + + + + + + + + + + + + + + + + + + + + + + + + Chri + + + + + + + stum, + + + + + + + + + + + + + + + sum + + + + + + + Chri + + + + + + + + + + + Chri + + + + + + + + + + + + + + + + + + + + + + stum, + + + + + + + Fi + + + + + + + + + + + + Fi + + + + + + + li + + + + + + + + um + + + + + + + + + + + stum, + + + + + + + Fi + + + + + + + + + + + stum, + + + + + + + + Fi + + + + + + + + + + + + + + + li + + + + + + + um + + + + + + + + De + + + + + + + + + + + + + + + + + + + + + + li + + + + + + + um + + + + + + + + + + + + li + + + + + + + + um + + + + + + + + + + + + + + + + + + + + + i + + + + + + + + + + + De + + + + + + + + + + + + + + + + De + + + + + + + + + + + + + + De + + + + + + + + + + + + + + + + u + + + + + + + + + + + i + + + + + + + u + + + + + + + + + + + i + + + + + + + u + + + + + + + + + + + i + + + + + + + u + + + + + + + + + + + + + + + ni + + + + + + + ge + + + + + + + + + + + + ni + + + + + + + ge + + + + + + + + + + + ni + + + + + + + ge + + + + + + + + + + + ni + + + + + + + ge + + + + + + + + + + + + + + ni + + + + + + + tum. + + + + + + + + + + + ni + + + + + + + tum. + + + + + + + + + + + + ni + + + + + + + + + + tum. + + + + + + + + + + + ni + + + + + + + tum. + + + + + + + Et + + + + + + + + + + + + + + Et + + + + + + + + + + + Et + + + + + + + ex + + + + + + + + + + + + + + + + + + ex + + + + + + + Pa + + + + + + + + + + + + + + + + ex + + + + + + + Pa + + + + + + + + + + + Pa + + + + + + + tre + + + + + + + + + + + + + + + + tre + + + + + + + na + + + + + + + + + + + + + + tre + + + + + + + na + + + + + + + + + + + + na + + + + + + + + + + + + + + + Et + + + + + + + + + + + + + + + + + + + + + + tum + + + + + + + + + + + + + + + + + + + + + ex + + + + + + + Pa + + + + + + + + + + + + + + + + + + + + + an + + + + + + + + + + + + + tum + + + + + + + + + + + tre + + + + + + + na + + + + + + + + + + + tum + + + + + + + an + + + + + + + + + + + + + + + + te + + + + + + + o + + + + + + + + + + + + an + + + + + + + te + + + + + + + + o + + + + + + + + + + + tum + + + + + + + an + + + + + + + + + + + te + + + + + + + o + + + + + + + + + + + + + + mni + + + + + + + a + + + + + + + sae + + + + + + + + + + + + + + + + + mni + + + + + + + + + + + + te + + + + + + + + + + + + mni + + + + + + + + a + + + + + + + + + + + + + + + + cu + + + + + + + la. + + + + + + + + + + + + a, + + + + + + + an + + + + + + + te + + + + + + + o + + + + + + + + + + + o + + + + + + + + mni + + + + + + + a + + + + + + + + + + + + + sae + + + + + + + cu + + + + + + + + + + + + + + + + + + + + + + + + + + + + mni + + + + + + + a + + + + + + + + + + + sae + + + + + + + + cu + + + + + + + + + + + la. + + + + + + + + + + + + + + + + + + + + + + + + + sae + + + + + + + cu + + + + + + + + + + + la. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + la. + + + + + + + De + + + + + + + + + + + + + + + + + + De + + + + + + + um + + + + + + + de + + + + + + + + + + + + + De + + + + + + + um + + + + + + + + + + + um + + + + + + + de + + + + + + + + + + + De + + + + + + + + um + + + + + + + + + + + + De + + + + + + + o, + + + + + + + + + + + + + + de + + + + + + + + + + + + + + + + + + + + + + + + de + + + + + + + De + + + + + + + + + + + + + + + + + + + + + De + + + + + + + o, + + + + + + + + + + + + De + + + + + + + o, + + + + + + + lu + + + + + + + + + + + o, + + + + + + + lu + + + + + + + + + + + + lu + + + + + + + + + + + + + + + lu + + + + + + + + men + + + + + + + + de + + + + + + + + + + + men + + + + + + + de + + + + + + + lu + + + + + + + + + + + men + + + + + + + de + + + + + + + + + + + men + + + + + + + de + + + + + + + + lu + + + + + + + + + + + + + + + + lu + + + + + + + + mi + + + + + + + + + + + + + + + mi + + + + + + + ne, + + + + + + + + + + + lu + + + + + + + + mi + + + + + + + ne, + + + + + + + + + + + + mi + + + + + + + ne, + + + + + + + + + + + + + ne, + + + + + + + De + + + + + + + um + + + + + + + + + + + + De + + + + + + + + um + + + + + + + + + + + + De + + + + + + + um + + + + + + + + + + + De + + + + + + + + + um + + + + + + + + + + + + + + ve + + + + + + + rum + + + + + + + de + + + + + + + + + + + ve + + + + + + + + + + + + ve + + + + + + + rum + + + + + + + + + + + ve + + + + + + + rum + + + + + + + + + + + + + + + + De + + + + + + + o + + + + + + + + + + + + + + + rum + + + + + + + de + + + + + + + + + + + de + + + + + + + De + + + + + + + + + + + de + + + + + + + De + + + + + + + + + + + + + + + + + + + + + + + + + + De + + + + + + + o + + + + + + + ve + + + + + + + + + + + + o + + + + + + + ve + + + + + + + + + + + + + + + o + + + + + + + ve + + + + + + + + + + + + + + + + + ve + + + + + + + + + + ro. + + + + + + + + + + + + + + ro. + + + + + + + + + + + + ro. + + + + + + + + + + + + ro. + + + + + + + + + + + + + + + + + + + Ge + + + + + + + ni + + + + + + + tum, + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + non + + + + + + + fa + + + + + + + ctum, + + + + + + + + + + + Ge + + + + + + + + ni + + + + + + + tum + + + + + + + + + + + + + + + + + + + Ge + + + + + + + + ni + + + + + + + tum, + + + + + + + + + + + con + + + + + + + sub + + + + + + + stan + + + + + + + ti + + + + + + + + + + + non + + + + + + + fa + + + + + + + ctum + + + + + + + + + + + + + + + + + + + non + + + + + + + fa + + + + + + + + + + + + a + + + + + + + lem + + + + + + + + + + + + con + + + + + + + sub + + + + + + + stan + + + + + + + + ti + + + + + + + + + + + + + + + + + + + + ctum, + + + + + + + + con + + + + + + + + + + + + Pa + + + + + + + + + + + + a + + + + + + + lem + + + + + + + + Pa + + + + + + + tri: + + + + + + + + + + + + + + + + + + sub + + + + + + + stan + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ti + + + + + + + a + + + + + + + lem + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pa + + + + + + + + + + tri: + + + + + + + + + + + tri: + + + + + + + + + + + + + per + + + + + + + + + + + Per + + + + + + + + + + + + + + + + + + + + per + + + + + + + + + + + + per + + + + + + + + + + + + + + + + + + + quem + + + + + + + + + + + quem + + + + + + + o + + + + + + + mni + + + + + + + a + + + + + + + + + + + + + + + + + + quem + + + + + + + + + + + + + + quem + + + + + + + + + + + + + + + + + o + + + + + + + mni + + + + + + + a, + + + + + + + + + + + fa + + + + + + + tca + + + + + + + sunt, + + + + + + + + + + + + + + o + + + + + + + + mni + + + + + + + a + + + + + + + + + + + + + o + + + + + + + + + + + + + per + + + + + + + + + + + + + + + + + per + + + + + + + quem + + + + + + + o + + + + + + + + + + + + + + fa + + + + + + + + + + + + + mni + + + + + + + a + + + + + + + + + + + quem + + + + + + + o + + + + + + + + mni + + + + + + + a + + + + + + + + + + + + mni + + + + + + + a + + + + + + + + fa + + + + + + + cta + + + + + + + + + + + + + + cta + + + + + + + + + + sunt. + + + + + + + + + + + fa + + + + + + + cta + + + + + + + sunt. + + + + + + + Qui + + + + + + + + + + + fa + + + + + + + cta + + + + + + + + + + + + + + + sunt. + + + + + + + + + + + + + + + + + + + pro + + + + + + + pter + + + + + + + + + + + + sunt. + + + + + + + + + + + Qui + + + + + + + pro + + + + + + + + + + + + + Qui + + + + + + + + pro + + + + + + + + + + + nos + + + + + + + ho + + + + + + + + + + + + + + + + + pter + + + + + + + nos + + + + + + + + + + + + + pter + + + + + + + nos + + + + + + + + + + + + mi + + + + + + + + + + + Qui + + + + + + + + pro + + + + + + + + + + + ho + + + + + + + + mi + + + + + + + + + + + + + + ho + + + + + + + + + + + + + + + + + + + nes, + + + + + + + + + + + pter + + + + + + + nos + + + + + + + + + + + nes, + + + + + + + et + + + + + + + pro + + + + + + + + + + + + + + + + + mi + + + + + + + nes, + + + + + + + + + + + + et + + + + + + + pro + + + + + + + pter + + + + + + + + + + + ho + + + + + + + mi + + + + + + + + + + + pter + + + + + + + no + + + + + + + + stram, + + + + + + + + + + + + + + + et + + + + + + + + + + + no + + + + + + + stram + + + + + + + + + + + + + + + nes, + + + + + + + + et + + + + + + + + + + + + et + + + + + + + pro + + + + + + + pter + + + + + + + + + + + + + + pro + + + + + + + pter + + + + + + + + + + + + sa + + + + + + + lu + + + + + + + + + + + + pro + + + + + + + + pter + + + + + + + + + + + no + + + + + + + stram + + + + + + + sa + + + + + + + + + + + + + + + no + + + + + + + stram + + + + + + + sa + + + + + + + + + + + + + + + + + + + + no + + + + + + + stram + + + + + + + + + + + lu + + + + + + + tem + + + + + + + + + + + + + + + + + + + lu + + + + + + + + + + + + + + + + tem + + + + + + + + + + + + sa + + + + + + + lu + + + + + + + + + + + + + + + + + + + + + + tem + + + + + + + de + + + + + + + + + + + + + de + + + + + + + + scen + + + + + + + + + + + + tem + + + + + + + + + + + + + de + + + + + + + + + + + + + + + + + scen + + + + + + + + + + + + + + + + + dit, + + + + + + + + + + + + + de + + + + + + + + + + + + + + + scen + + + + + + + + + + + + + dit + + + + + + + de + + + + + + + + + + + + + + + + + + + + + + + + + + + + dit + + + + + + + + de + + + + + + + + + + + + + + + + + + + + + + + + de + + + + + + + + scen + + + + + + + dit + + + + + + + + + + + scen + + + + + + + + dit + + + + + + + + + + + + cae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + de + + + + + + + cae + + + + + + + + + + + de + + + + + + + + + + + + + + + + + + + + + + + cae + + + + + + + + + + + + + + + + + + + + + + cae + + + + + + + + + + + + + + + + + + + + + + + + lis. + + + + + + + + + + + lis. + + + + + + + + + + + lis. + + + + + + + + + + + lis. + + + + + + + + + + + + + + Et + + + + + + + + + + + Et + + + + + + + + + + + Et + + + + + + + + + + + Et + + + + + + + in + + + + + + + car + + + + + + + + + + + + + in + + + + + + + + + + + in + + + + + + + car + + + + + + + + + + + + in + + + + + + + + + + + na + + + + + + + tus + + + + + + + + + + + + + + + + + car + + + + + + + na + + + + + + + tus + + + + + + + + + + + na + + + + + + + tus + + + + + + + + + + + + + car + + + + + + + na + + + + + + + + + + + est + + + + + + + de + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tus + + + + + + + est + + + + + + + + + + + + + + + + + + est + + + + + + + + + de + + + + + + + + + + + est + + + + + + + de + + + + + + + Spi + + + + + + + ri + + + + + + + + + + + de + + + + + + + + Spi + + + + + + + + + + + Spi + + + + + + + + ri + + + + + + + + + + + + + + + Spi + + + + + + + ri + + + + + + + tu + + + + + + + + + + + tu + + + + + + + San + + + + + + + + + + + + + ri + + + + + + + + tu + + + + + + + + + + + tu + + + + + + + San + + + + + + + + + + + + + + + + + San + + + + + + + + cto + + + + + + + + + + + + + + + + + cto + + + + + + + + + + + San + + + + + + + cto + + + + + + + + + + + + cto + + + + + + + + + + + + + + + + + + + + + ex + + + + + + + + + + + + ex + + + + + + + + + + + + ex + + + + + + + + + + + + ex + + + + + + + + + + + + + + + + + Ma + + + + + + + + + + + + + Ma + + + + + + + + + + + + Ma + + + + + + + + + + + + Ma + + + + + + + + + + + + + + ri + + + + + + + + + a + + + + + + + + + + + + + + + ria + + + + + + + a + + + + + + + + + + + + ri + + + + + + + a + + + + + + + Vir + + + + + + + + + + + + ri + + + + + + + a + + + + + + + + + + + + + + Vir + + + + + + + gi + + + + + + + + + + + Vir + + + + + + + + + + + + + + + + gi + + + + + + + + + + + Vir + + + + + + + + + gi + + + + + + + + + + + + + ne: + + + + + + + + Et + + + + + + + + + + + + gi + + + + + + + ne: + + + + + + + + + + + ne: + + + + + + + + + + + ne: + + + + + + + + + + + + + + ho + + + + + + + + + + + + + mo + + + + + + + + + + + Et + + + + + + + + ho + + + + + + + + + + + + + Et + + + + + + + + + + + + + + + + + Et + + + + + + + ho + + + + + + + mo + + + + + + + + + + + + + + fa + + + + + + + + + + + + + + + mo + + + + + + + + + + + + + + + + + ho + + + + + + + + mo + + + + + + + + + + + + fa + + + + + + + ctus + + + + + + + + + + + + + + + + + + + + + ctus + + + + + + + + + + + fa + + + + + + + ctus + + + + + + + + + + + + + fa + + + + + + + + + + + + + est, + + + + + + + + + + + + + + + est, + + + + + + + fa + + + + + + + + + + + + + + + + + + + + + + + + ctus + + + + + + + + + + + + + + + et + + + + + + + ho + + + + + + + mo + + + + + + + + + + + + + + + + + ctus + + + + + + + + + + + + + + + + est. + + + + + + + + + + + + + + + + + fa + + + + + + + ctus + + + + + + + + + + + + + + est. + + + + + + + + + + + + + + + + est. + + + + + + + + + + + est. + + + + + + + + + + + + + + Cru + + + + + + + ci + + + + + + + + + + + + Cru + + + + + + + + + + + + + + + + + + + + + + + fi + + + + + + + xus + + + + + + + + e + + + + + + + ti + + + + + + + + + + + ci + + + + + + + fi + + + + + + + xus + + + + + + + + + + + + + + + + + + + + + + + + am + + + + + + + + pro + + + + + + + no + + + + + + + + + + + e + + + + + + + ti + + + + + + + am + + + + + + + pro + + + + + + + + + + + + + + + + + + + + + + + bis: + + + + + + + + + + + + no + + + + + + + + + + + + + + + + + + + + + + + + + + sub + + + + + + + + + + + + + + bis: + + + + + + + + + + + + + + + + + + + + + + + Pon + + + + + + + + ti + + + + + + + o + + + + + + + + + + + + sub + + + + + + + Pon + + + + + + + ti + + + + + + + + + + + + + + + + + + + + + + + + Pi + + + + + + + + la + + + + + + + + + + + + o + + + + + + + Pi + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + la + + + + + + + + + + + + + + + + + + + + + + + + + to + + + + + + + pas + + + + + + + + + + + to + + + + + + + + pas + + + + + + + + + + + + + + + + + + + + + + + + + sus, + + + + + + + + + + + + sus, + + + + + + + + et + + + + + + + + + + + + + + + + + + + + + + + + + + + + + et + + + + + + + + + + + + se + + + + + + + pul + + + + + + + + + + + + + + + + + + + + + + + + + + se + + + + + + + + + + + + + + + + + tus + + + + + + + + + + + + + + + + + + + + + + + + + pul + + + + + + + + + + + + + + + + + + est, + + + + + + + se + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tus + + + + + + + + + + + + pul + + + + + + + tus + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + est. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + est. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Et + + + + + + + re + + + + + + + + + + + + + + + + + + + + + + + + + + + + sur + + + + + + + re + + + + + + + + + + + + Et + + + + + + + + + + + + + + + + + + + + + + + + xit + + + + + + + ter + + + + + + + + + + + re + + + + + + + sur + + + + + + + + + + + + + + + + + + Et + + + + + + + re + + + + + + + + + + + + + ti + + + + + + + a + + + + + + + + + + + re + + + + + + + xit + + + + + + + + + + + + + + + + + + + sur + + + + + + + re + + + + + + + + + + + di + + + + + + + + + + + + + + + ter + + + + + + + ti + + + + + + + a + + + + + + + + + + + + + + + + + + + + + + xit + + + + + + + + ter + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ti + + + + + + + + a + + + + + + + + + + + + + + + + + e, + + + + + + + se + + + + + + + + + + + + + di + + + + + + + + + + + + + + + + + + di + + + + + + + e, + + + + + + + + + + + cun + + + + + + + dum + + + + + + + + + + + + e, + + + + + + + + + + + + + + + + + + + + + + se + + + + + + + + + + + + Scri + + + + + + + ptu + + + + + + + + + + + + + + + + + + se + + + + + + + + + + + + + + + + + + + + + + cun + + + + + + + + dum + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Scri + + + + + + + + + + + + + + + + + + cun + + + + + + + dum + + + + + + + + + + + + + + + + + + + + ptu + + + + + + + + + + + + + + + + + + + + + + + + + + + + Scri + + + + + + + ptu + + + + + + + + + + + + + + + + + + + ras. + + + + + + + Et + + + + + + + + + + + ras. + + + + + + + Et + + + + + + + a + + + + + + + + + + + ras. + + + + + + + Et + + + + + + + + + + + + + + + + + + + a + + + + + + + scen + + + + + + + + + + + + scen + + + + + + + dit + + + + + + + + + + + + a + + + + + + + scen + + + + + + + + + + + + + + + + + + + dit + + + + + + + in + + + + + + + + + + + + in + + + + + + + cae + + + + + + + + + + + dit + + + + + + + + in + + + + + + + + + + + + + + + + + + + + + cae + + + + + + + + + + + + + + lum: + + + + + + + se + + + + + + + + + + + cae + + + + + + + lum: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + se + + + + + + + + + + + + + + + + + + + + + lum: + + + + + + + + se + + + + + + + + + + + + + + det + + + + + + + + + + + + + + + det + + + + + + + ad + + + + + + + dex + + + + + + + + + + + + + + + + + + det + + + + + + + ad + + + + + + + dex + + + + + + + te + + + + + + + + + + + + + ad + + + + + + + + dex + + + + + + + te + + + + + + + + + + + + + + te + + + + + + + + + + + + + + + + + + + + ram + + + + + + + Pa + + + + + + + + + + + + + ram + + + + + + + Pa + + + + + + + + + + + + + ram + + + + + + + Pa + + + + + + + + + + + + + + + + + + + + + + + + + + + + tris. + + + + + + + + + + + + + tris. + + + + + + + + + + + + tris. + + + + + + + + + + + + + + + + + + + + Et + + + + + + + + + + + + Et + + + + + + + i + + + + + + + + + + + Et + + + + + + + + i + + + + + + + + + + + + + + + + + + + + + i + + + + + + + + + + + te + + + + + + + rum + + + + + + + + + + + + + te + + + + + + + rum + + + + + + + + + + + + + + + + + + + + + + + + te + + + + + + + + + + + + + + ven + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + rum + + + + + + + ven + + + + + + + + + + + + + + + + + + + + + + ven + + + + + + + + + + + + + + + + + + + + + tu + + + + + + + rus + + + + + + + + + + + + tu + + + + + + + rus + + + + + + + + + + + + + tu + + + + + + + rus + + + + + + + + + + + + + + + + + + est + + + + + + + cum + + + + + + + + + + + est + + + + + + + + cum + + + + + + + + + + + est + + + + + + + + cum + + + + + + + + + + + + + + + + + + + glo + + + + + + + + ri + + + + + + + a, + + + + + + + + + + + glo + + + + + + + ri + + + + + + + + + + + + glo + + + + + + + ri + + + + + + + a, + + + + + + + + + + + + + + + + + + + + + + iu + + + + + + + + di + + + + + + + + + + + a, + + + + + + + iu + + + + + + + di + + + + + + + + + + + + iu + + + + + + + di + + + + + + + ca + + + + + + + + + + + + + + + + + + + + ca + + + + + + + re + + + + + + + + + + + ca + + + + + + + + + re + + + + + + + + + + + + + re + + + + + + + vi + + + + + + + + + + + + + + + + + + + + vi + + + + + + + + + + + + + vi + + + + + + + vos + + + + + + + + et + + + + + + + + + + + + + + + + + + + vos + + + + + + + + + + + + + + + + + + + vos + + + + + + + + + + + + + + + + + mor + + + + + + + + + + + + + + + + et + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + et + + + + + + + + + + + + + + + + + + + + + mor + + + + + + + + + + + + + + + + + + + + + + mor + + + + + + + tu + + + + + + + + + + + + + + + + + + tu + + + + + + + + + + + + + + tu + + + + + + + os: + + + + + + + + + + + + + + + + + + + + os: + + + + + + + cu + + + + + + + + + + + os: + + + + + + + cu + + + + + + + ius + + + + + + + + + + + + cu + + + + + + + ius + + + + + + + + + + + + + + + + + + + + + + + + ius + + + + + + + re + + + + + + + + + + + + re + + + + + + + + + + + + + + re + + + + + + + + + + + + + + + + + + + + gni + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + non + + + + + + + + e + + + + + + + + + + + gni + + + + + + + + non + + + + + + + + + + + gni + + + + + + + non + + + + + + + e + + + + + + + + + + + + + + + + + + + + + + + + + rit + + + + + + + fi + + + + + + + + + + + + e + + + + + + + rit + + + + + + + + + + + + rit + + + + + + + fi + + + + + + + + + + + + + + + + + + + + + + + + + + + + + fi + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + nis. + + + + + + + + + + + + nis. + + + + + + + + + + + + nis. + + + + + + + + + + + + + + + + + + + Et + + + + + + + in + + + + + + + + + + + Et + + + + + + + in + + + + + + + + + + + Et + + + + + + + + in + + + + + + + + + + + Et + + + + + + + in + + + + + + + + + + + + + Spi + + + + + + + ri + + + + + + + tum + + + + + + + San + + + + + + + + + + + Spi + + + + + + + ri + + + + + + + tum + + + + + + + San + + + + + + + + + + + Spi + + + + + + + ri + + + + + + + tum + + + + + + + San + + + + + + + + + + + Spi + + + + + + + ri + + + + + + + tum + + + + + + + San + + + + + + + + + + + + + + + ctum, + + + + + + + + Do + + + + + + + + + + + ctum, + + + + + + + Do + + + + + + + + + + + + ctum, + + + + + + + Do + + + + + + + + mi + + + + + + + + + + + + ctum, + + + + + + + Do + + + + + + + + + + + + + + + + mi + + + + + + + + + + num, + + + + + + + + + + + mi + + + + + + + + + + + num, + + + + + + + + + + + + mi + + + + + + + num, + + + + + + + + + + + + + + + + + et + + + + + + + + + + + + num, + + + + + + + + + + + et + + + + + + + + + + + + et + + + + + + + + + + + + + + + + + vi + + + + + + + vi + + + + + + + + + + + et + + + + + + + vi + + + + + + + vi + + + + + + + + + + + vi + + + + + + + + vi + + + + + + + fi + + + + + + + can + + + + + + + + + + + vi + + + + + + + vi + + + + + + + + + + + + + + + fi + + + + + + + can + + + + + + + + + + + fi + + + + + + + can + + + + + + + tem: + + + + + + + + + + + + + + + + + + + + + + + + fi + + + + + + + can + + + + + + + + + + + + + tem: + + + + + + + qui + + + + + + + ex + + + + + + + + + + + qui + + + + + + + ex + + + + + + + Pa + + + + + + + + + + + tem: + + + + + + + qui + + + + + + + + ex + + + + + + + + + + + tem: + + + + + + + qui + + + + + + + ex + + + + + + + + + + + + + Pa + + + + + + + tre + + + + + + + Fi + + + + + + + + + + + tre + + + + + + + Fi + + + + + + + li + + + + + + + + + + + Pa + + + + + + + tre + + + + + + + Fi + + + + + + + + + + + Pa + + + + + + + tre + + + + + + + Fi + + + + + + + + + + + + + + li + + + + + + + o + + + + + + + que + + + + + + + pro + + + + + + + + + + + o + + + + + + + que + + + + + + + pro + + + + + + + + + + + li + + + + + + + o + + + + + + + que + + + + + + + + + + + + + + li + + + + + + + o + + + + + + + que + + + + + + + + + + + + + + + + + ce + + + + + + + + + + + + ce + + + + + + + + + + + + + + pro + + + + + + + ce + + + + + + + + + + + + pro + + + + + + + + + + ce + + + + + + + + + + + + + + + + + + + + dit. + + + + + + + + + + + + dit. + + + + + + + Qui + + + + + + + + + + + + + + dit. + + + + + + + + + + + + dit. + + + + + + + + + + + + + + + + + + + + cum + + + + + + + Pa + + + + + + + + + + + Qui + + + + + + + + cum + + + + + + + + + + + Qui + + + + + + + cum + + + + + + + + + + + + + + + + + + + + tre + + + + + + + et + + + + + + + + + + + Pa + + + + + + + tre + + + + + + + + + + + Pa + + + + + + + tre + + + + + + + + + + + + + + + + + + + Fi + + + + + + + li + + + + + + + + + + + et + + + + + + + Fi + + + + + + + + li + + + + + + + + + + + + et + + + + + + + Fi + + + + + + + li + + + + + + + + + + + + + + + + + + o + + + + + + + si + + + + + + + mul + + + + + + + a + + + + + + + + + + + o + + + + + + + + si + + + + + + + mul + + + + + + + + + + + + o + + + + + + + + + + + + + + + + + Si + + + + + + + mul + + + + + + + a + + + + + + + + + + + do + + + + + + + ra + + + + + + + + + + + + + a + + + + + + + + do + + + + + + + ra + + + + + + + + + + + + + si + + + + + + + + + + + + + + + + do + + + + + + + ra + + + + + + + + + + + + + + + + + + tur, + + + + + + + + a + + + + + + + + + + + + mul + + + + + + + a + + + + + + + do + + + + + + + + + + + + + + + tur, + + + + + + + + + + + + + + + + + + + + + + do + + + + + + + ra + + + + + + + + + + + ra + + + + + + + + + + + + + + + + + + + + + et + + + + + + + + + + + tur, + + + + + + + et + + + + + + + + + + + tur, + + + + + + + et + + + + + + + + + + + tur, + + + + + + + et + + + + + + + + + + + + + + + + + con + + + + + + + glo + + + + + + + + + + + + con + + + + + + + + + + + + con + + + + + + + glo + + + + + + + + + + + con + + + + + + + glo + + + + + + + + + + + + + + + ri + + + + + + + fi + + + + + + + + + + + + + + glo + + + + + + + ri + + + + + + + + + + + + + + ri + + + + + + + fi + + + + + + + ca + + + + + + + + + + + + ri + + + + + + + + + + + + + + ca + + + + + + + tur: + + + + + + + + + + + fi + + + + + + + ca + + + + + + + tur: + + + + + + + + + + + + + + + + + + + + + + + + + + + + fi + + + + + + + ca + + + + + + + + + + + + + qui + + + + + + + lo + + + + + + + + + + + + + + + + + + + tur: + + + + + + + qui + + + + + + + + lo + + + + + + + + + + + tur: + + + + + + + + + + + + + + + + cu + + + + + + + tus + + + + + + + est + + + + + + + per + + + + + + + + + + + + + + + + cu + + + + + + + tus + + + + + + + est + + + + + + + + + + + + qui + + + + + + + lo + + + + + + + + + + + + + + + + + + + Pro + + + + + + + + + + + + qui + + + + + + + lo + + + + + + + + + + + + + + + + cu + + + + + + + tus + + + + + + + est + + + + + + + + + + + + + + + + + + + + + + + cu + + + + + + + tus + + + + + + + est + + + + + + + + per + + + + + + + + + + + + + per + + + + + + + Pro + + + + + + + + + + + per + + + + + + + Pro + + + + + + + + + + + + + + + phe + + + + + + + tas. + + + + + + + + + + + Pro + + + + + + + phe + + + + + + + + + + + + phe + + + + + + + + + + + + + + + + phe + + + + + + + + + + + + + + + + Et + + + + + + + u + + + + + + + nam + + + + + + + + + + + tas. + + + + + + + Et + + + + + + + u + + + + + + + + + + + tas. + + + + + + + Et + + + + + + + + + + + tas. + + + + + + + Et + + + + + + + + + + + + + + + + + san + + + + + + + + ctam + + + + + + + + + + + + nam + + + + + + + san + + + + + + + + + + + u + + + + + + + nam + + + + + + + san + + + + + + + + + + + + u + + + + + + + nam + + + + + + + san + + + + + + + + + + + + + + + + + + + ctam + + + + + + + ca + + + + + + + + + + + + + + + + ctam + + + + + + + ca + + + + + + + + + + + + ctam + + + + + + + ca + + + + + + + + + + + + + + + + + + tho + + + + + + + + li + + + + + + + + + + + tho + + + + + + + + li + + + + + + + + + + + tho + + + + + + + + li + + + + + + + + + + + + + + + et + + + + + + + + + + + + + + + + cam + + + + + + + + et + + + + + + + + + + + cam + + + + + + + + + + + + + + + + + + cam + + + + + + + + et + + + + + + + a + + + + + + + + + + + + + + a + + + + + + + po + + + + + + + sto + + + + + + + + + + + a + + + + + + + po + + + + + + + + + + + + et + + + + + + + + + + + po + + + + + + + sto + + + + + + + + + + + + + li + + + + + + + cam + + + + + + + + + + + + + + sto + + + + + + + li + + + + + + + cam + + + + + + + + + + + + a + + + + + + + po + + + + + + + + + + + li + + + + + + + cam. + + + + + + + + + + + + + + + Ec + + + + + + + cle + + + + + + + + + + + Ec + + + + + + + cle + + + + + + + + + + + sto + + + + + + + li + + + + + + + cam + + + + + + + Ec + + + + + + + + + + + Ec + + + + + + + cle + + + + + + + si + + + + + + + + + + + + + + + + + + + + si + + + + + + + + + + am. + + + + + + + + + + + + + + si + + + + + + + am. + + + + + + + + + + + cle + + + + + + + + + si + + + + + + + am. + + + + + + + + + + + am. + + + + + + + + + + + + + + + + + + + + + Con + + + + + + + + + + + Con + + + + + + + fi + + + + + + + + + + + + + Con + + + + + + + + + + + Con + + + + + + + fi + + + + + + + + + + + + + + fi + + + + + + + + + + + + te + + + + + + + + + + + + + fi + + + + + + + + + + + + te + + + + + + + + + + + + + te + + + + + + + or + + + + + + + + + + + or + + + + + + + + + + + te + + + + + + + or + + + + + + + + + + + or + + + + + + + + + + + + + u + + + + + + + num + + + + + + + + + + + u + + + + + + + num + + + + + + + + + + + u + + + + + + + num + + + + + + + + + + + u + + + + + + + num + + + + + + + + + + + + + + + ba + + + + + + + + + + + + ba + + + + + + + + + + + + + + + + ba + + + + + + + + + + + ba + + + + + + + pti + + + + + + + + + + + + + + + pti + + + + + + + + + + + + pti + + + + + + + + + + + pti + + + + + + + + + + + + sma + + + + + + + + + + + + + sma + + + + + + + + + + + + sma + + + + + + + + + + + + sma + + + + + + + + in + + + + + + + re + + + + + + + + + + + in + + + + + + + re + + + + + + + mis + + + + + + + + + + + + + + + in + + + + + + + re + + + + + + + + + + + in + + + + + + + re + + + + + + + mis + + + + + + + + + + + mis + + + + + + + + si + + + + + + + o + + + + + + + + + + + si + + + + + + + o + + + + + + + nem, + + + + + + + + + + + + + mis + + + + + + + + si + + + + + + + o + + + + + + + + + + + si + + + + + + + o + + + + + + + nem, + + + + + + + + + + + nem + + + + + + + + pec + + + + + + + ca + + + + + + + + + + + + + + + + + + nem, + + + + + + + + in + + + + + + + re + + + + + + + + + + + + + + + + to + + + + + + + rum, + + + + + + + + + + + + in + + + + + + + re + + + + + + + mis + + + + + + + + + + + + + mis + + + + + + + + si + + + + + + + o + + + + + + + + + + + + + in + + + + + + + re + + + + + + + + + + + in + + + + + + + re + + + + + + + mis + + + + + + + + + + + si + + + + + + + o + + + + + + + nem, + + + + + + + + + + + + + nem, + + + + + + + in + + + + + + + re + + + + + + + mis + + + + + + + + + + + mis + + + + + + + + si + + + + + + + o + + + + + + + + + + + si + + + + + + + o + + + + + + + nem + + + + + + + + + + + + + + + + + + + si + + + + + + + o + + + + + + + nem + + + + + + + + + + + nem, + + + + + + + + in + + + + + + + re + + + + + + + + + + + pec + + + + + + + ca + + + + + + + to + + + + + + + + + + + + in + + + + + + + re + + + + + + + mis + + + + + + + + + + + + + pec + + + + + + + ca + + + + + + + + + + + mis + + + + + + + si + + + + + + + o + + + + + + + + + + + + + + + + + + + + si + + + + + + + o + + + + + + + nem + + + + + + + + + + + + + + + + to + + + + + + + + + + + + nem + + + + + + + pec + + + + + + + + + + + + + + + + + + + + + + pec + + + + + + + ca + + + + + + + to + + + + + + + + + + + + + + + + + + + ca + + + + + + + to + + + + + + + + + + + + + + + + + + + + + + + + + rum. + + + + + + + Et + + + + + + + + + + + rum. + + + + + + + Et + + + + + + + + + + + rum. + + + + + + + Et + + + + + + + + + + + rum. + + + + + + + Et + + + + + + + + + + + + + + ex + + + + + + + pe + + + + + + + + + + + ex + + + + + + + pe + + + + + + + + + + + ex + + + + + + + pe + + + + + + + + + + + + ex + + + + + + + pe + + + + + + + + + + + + + cto + + + + + + + re + + + + + + + sur + + + + + + + + + + + cto + + + + + + + re + + + + + + + + sur + + + + + + + + + + + cto + + + + + + + + re + + + + + + + sur + + + + + + + + + + + cto + + + + + + + + re + + + + + + + sur + + + + + + + + + + + + + + + + re + + + + + + + cti + + + + + + + o + + + + + + + + + + + re + + + + + + + cti + + + + + + + o + + + + + + + + + + + + re + + + + + + + + cti + + + + + + + o + + + + + + + + + + + re + + + + + + + + cti + + + + + + + o + + + + + + + + + + + + + + nem + + + + + + + + + + + nem + + + + + + + + + mor + + + + + + + + + + + nem + + + + + + + + mor + + + + + + + + + + + + nem + + + + + + + + mor + + + + + + + tu + + + + + + + + + + + + + + mor + + + + + + + tu + + + + + + + + + + + tu + + + + + + + o + + + + + + + + + + + + + + tu + + + + + + + o + + + + + + + + + + + + + o + + + + + + + rum. + + + + + + + Et + + + + + + + + + + + + + o + + + + + + + rum. + + + + + + + Et + + + + + + + + + + + + rum. + + + + + + + Et + + + + + + + + + + + + + + + + + rum. + + + + + + + + + + + vi + + + + + + + tam + + + + + + + ven + + + + + + + + + + + + + + + + vi + + + + + + + tam + + + + + + + ven + + + + + + + + + + + vi + + + + + + + tam + + + + + + + ven + + + + + + + + + + + + Et + + + + + + + vi + + + + + + + + + + + tu + + + + + + + ri, + + + + + + + et + + + + + + + + + + + + + + tu + + + + + + + + + + + + + + + + tu + + + + + + + ri + + + + + + + + + + + tam + + + + + + + ven + + + + + + + + + + + + vi + + + + + + + tam + + + + + + + + + + + + + + + + + + + + + + + + + + + sae + + + + + + + + + + + + tu + + + + + + + ri + + + + + + + + + + + ven + + + + + + + + tu + + + + + + + + + + + + + + ri + + + + + + + sae + + + + + + + + + + + + + + + + + + + cu + + + + + + + + + + + sae + + + + + + + + + + + + ri + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + cu + + + + + + + + + + + + + + + + + + + + + + cu + + + + + + + + + + + + + + li, + + + + + + + + + + + li, + + + + + + + + + + + + + + + sae + + + + + + + + + + + + + + + li, + + + + + + + + + + + + sae + + + + + + + + + + + + + + + + + + + + + + + + cu + + + + + + + + + + + + + + + sae + + + + + + + + + + + + + + + + cu + + + + + + + + + + + + + + + + + li, + + + + + + + + + + + + + + + + + + + + + + cu + + + + + + + + + + + + li. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + li, + + + + + + + + + + + + + + + + + + + + + + + + + + sae + + + + + + + + + + + + + + + + + + + + + + + + + sae + + + + + + + + + + + + + + + + + + + + + + + + cu + + + + + + + + + + + + + sae + + + + + + + + cu + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + li. + + + + + + + + + + + + li. + + + + + + + + + + + + + + + + + + + + cu + + + + + + + + li. + + + + + + + + + + + + A + + + + + + + + + + + + + A + + + + + + + + + + + + + + + + + + + + + + + + + + + A + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + men, + + + + + + + + + + + men, + + + + + + + + A + + + + + + + + + + + A + + + + + + + + + + + + + + + + + + men, + + + + + + + A + + + + + + + + + + + + A + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + men, + + + + + + + + A + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + men, + + + + + + + + + + + + + + + + + + + men, + + + + + + + A + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + men. + + + + + + + + + + + + + + + + + + + + men, + + + + + + + A + + + + + + + + + + + + + + + + + + + + + + + + + + + A + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + men. + + + + + + + + + + + + men. + + + + + + + + + + + + + + + men. + + + + + + +
+
+
+ +
+
\ No newline at end of file diff --git a/tests/data/mei/CRIM_Mass_0030_4.mei b/tests/data/mei/CRIM_Mass_0030_4.mei new file mode 100644 index 00000000..3e650a6a --- /dev/null +++ b/tests/data/mei/CRIM_Mass_0030_4.mei @@ -0,0 +1,6758 @@ + + + + + + + + + Missa Hic est vere martir: Sanctus + + + Nicolle des Celliers de Hesdin + + + Crisitna Cassia + + + + Nicole des Celliers de Hesdin + + + Christina Cassia + + + Vincent Besson + + + + + + Citations: The Renaissance Imitation Mass Project + + + + + + + + + + + Mac OS X Mountain Lion + + + + + Sibelius to MEI Exporter (2.3.0) + + + + + mei30To40.xsl + + + + + add_metadata.py + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + Sanc + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tus, + + + + + + + + + + + + + + + + + + + + + + + + + Sanc + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tus, + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [Sanc + + + + + + + + + + + + + + + + + + + + + + + + + [Sanc + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tus,] + + + + + + + + Sanc + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tus, + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [Sanc + + + + + + + + + + , + + + + + + + + + + + tus,] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sanc + + + + + + + tus, + + + + + + + + + + + + + + + + + + + + + + + tus,] + + + + + + + + Sanc + + + + + + + + + + + + + [Sanc + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tus,] + + + + + + + + + + + Sanc + + + + + + + + + + + + + + + + + + + tus + + + + + + + + + + + + Sanc + + + + + + + + + + + + + tus, + + + + + + + + + + + + Sanc + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tus, + + + + + + + + + + + + + + + + + + + + + + + + + + [Sanc + + + + + + + + + + + + + + + + + + + + + + + + + + + + tus, + + + + + + + + + + + tus,] + + + + + + + + Sanc + + + + + + + + + + + Sanc + + + + + + + + + + + + + + + + + + + + + + + + + + tus, + + + + + + + + + + + + + + + + + + + + + + + + + + + Do + + + + + + + + + + + + + + + + [Sanc + + + + + + + + + + + + + + + + + + + + + + + + + + + + mi + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + nus + + + + + + + + De + + + + + + + + + + + + Do + + + + + + + mi + + + + + + + + + + + + tus] + + + + + + + + + + + + + + + + + tus + + + + + + + + + + + + + + + + + + + + + + + + nus + + + + + + + + + + + + + Do + + + + + + + + + + + Do + + + + + + + + + + + + + + + + + + + + + us, + + + + + + + + + + + De + + + + + + + us + + + + + + + sa + + + + + + + + + + + + mi + + + + + + + nus + + + + + + + + + + + mi + + + + + + + nus + + + + + + + De + + + + + + + + + + + + + + + Do + + + + + + + mi + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + nus + + + + + + + + + + + ba + + + + + + + + + + + + De + + + + + + + + + + + + + + + + + + + + + + + De + + + + + + + + + + + oth, + + + + + + + + Do + + + + + + + + + + + us, + + + + + + + + + + + + + + + + + + + us, + + + + + + + + + + + + mi + + + + + + + nus + + + + + + + + + + + + + Do + + + + + + + + + + + + + us, + + + + + + + + Do + + + + + + + + + + + + + + + Do + + + + + + + mi + + + + + + + + + + + De + + + + + + + + + + + + + + mi + + + + + + + nus + + + + + + + + + + + + + + + + + + + + + + nus + + + + + + + + + De + + + + + + + + + + + + + + + + + + + + De + + + + + + + + + + + + + mi + + + + + + + + nus + + + + + + + + + + + + + + + + + + + + + + + + + + + us + + + + + + + + + + + + + + + + + De + + + + + + + + + + + + + + + + + us + + + + + + + + sa + + + + + + + + + + + + sa + + + + + + + + + + + + us + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ba + + + + + + + + oth, + + + + + + + + + + + + + + + + + us + + + + + + + + + + + + + + + ba + + + + + + + + + + + + + + + + + + + + + sa + + + + + + + + + + + + sa + + + + + + + + + + + + + + + + + + + + + + + + sa + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + oth, + + + + + + + + sa + + + + + + + + + + + + + ba + + + + + + + + + + + + + + + + + + + + ba + + + + + + + + + + + + + + + + + + + + + + + + + + + + oth + + + + + + + + + + + + ba + + + + + + + oth, + + + + + + + + + + + + oth, + + + + + + + + sa + + + + + + + + + + + + + + + + + + + + + + [sa + + + + + + + + + + + + + + + + + sa + + + + + + + + + + + + + + + + + + + + + + + + + + ba + + + + + + + + + + + + + + ba + + + + + + + + + + + + + + ba + + + + + + + + + + + + + ba + + + + + + + + + + + + + oth. + + + + + + + + + + + . + + + + + oth]. + + + + + + + + + + + oth. + + + + + + + + + + + oth. + + + + + + + + + + + + + + Ple + + + + + + + ni + + + + + + + + + + + + + + + + + + + + + + + + + + + + sunt + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + cæ + + + + + + + + + + + + + + + Ple + + + + + + + ni + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + sunt + + + + + + + + + + + + + + + + + + + + + + + + + li, + + + + + + + + [ple + + + + + + + + + + + cæ + + + + + + + + + + + + + + + + + + + + + + + + + + + + ni + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + sunt + + + + + + + + + cæ + + + + + + + + + + + + li + + + + + + + + [ple + + + + + + + + + + + + + + + + + + + + + + + + li,] + + + + + + + + + + + + + ni + + + + + + + + + + + + + + + + + + + + + + + + ple + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ni + + + + + + + sunt + + + + + + + + + + + + sunt + + + + + + + cæ + + + + + + + + + + + + + + + + + + + + + + + + + + cæ + + + + + + + + + + + + + + + li, + + + + + + + + + + + + + + + + + + + + + + + + + + + li + + + + + + + + + + + cæ + + + + + + + + + + + + + + + + + + + + + + + + + + + [cæ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + li] + + + + + + + + + + + li] + + + + + + + + et + + + + + + + + + + + + + + + + + + + + + + + + + et + + + + + + + + + + + ter + + + + + + + + + + + + + + + + + + + + + + + + + ter + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ra + + + + + + + + + + + + + + + ra + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + glo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ri + + + + + + + + a + + + + + + + + + + + + + + + + + + + + + + + + + glo + + + + + + + + + + + + + tu + + + + + + + + + + + + + + + + + + + + + + + + + + ri + + + + + + + + a + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tu + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + a, + + + + + + + + + + + + + + + + + + + + + + + a, + + + + + + + + + + + + + glo + + + + + + + + + + + + + + + + + + + + + + + + + + glo + + + + + + + + + + ri + + + + + + + + + + + + + ri + + + + + + + + + + + + + + + + + + + + + + + + + a + + + + + + + tu + + + + + + + + + + + + + + + + + a + + + + + + + tu + + + + + + + + + + + + + + + + + + + + + + + a. + + + + + + + + + + + a. + + + + + + + + + + + + + + + + + + + + + + + + + + + + Ho + + + + + + + + + + + + Ho + + + + + + + + + + + + + Ho + + + + + + + + + + + + + + + Ho + + + + + + + san + + + + + + + + + + + + san + + + + + + + + + + + + + + + + san + + + + + + + + + + + + + + san + + + + + + + + + + + + + + + + + na + + + + + + + + + + + + + + + + + + + + + + + + + + + na, + + + + + + + + + + + + + in + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [in + + + + + + + + + + + + + ex + + + + + + + + + + + + na, + + + + + + + + + + + + + + + na + + + + + + + + + + + ex + + + + + + + + + + + + + + + cel + + + + + + + + + + + + + + + + + in + + + + + + + + + + ex + + + + + + + + + + + cel + + + + + + + + + + + + + + + + + + + + + + + + + + + cel + + + + + + + + + + + + + + + + + + + + sis, + + + + + + + + + + + + + in + + + + + + + + + + + sis, + + + + + + + + + + + sis, + + + + + + + + ho + + + + + + + + + + + + + + + [ho + + + + + + + + + + + + + + ex + + + + + + + cel + + + + + + + + + + + + + + + + + san + + + + + + + + + + + + + + + + + san + + + + + + + + + + + + + + + + sis, + + + + + + + + + + + + ho + + + + + + + + + + + + + + + + + + + + + + + + + + + + na + + + + + + + + + + + + [in + + + + + + + + + + + san + + + + + + + + + + + + + + + na] + + + + + + + in + + + + + + + + + + + + + + + in + + + + + + + + + + + + ex + + + + + + + + + + + + + + + + na + + + + + + + + + + + + + ex + + + + + + + + + + + + + + + + ex + + + + + + + + + + + + + cel + + + + + + + + + + + + [in + + + + + + + + ex + + + + + + + + + + + cel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + cel + + + + + + + + + + + + sis,] + + + + + + + + + + + cel + + + + + + + + + + + + + + + + + + + + sis,] + + + + + + + + + + + + + + + + sis,] + + + + + + + + + + + sis, + + + + + + + + + + + + + + + + ho + + + + + + + + + + + + ho + + + + + + + + + + + + + + + + + + ho + + + + + + + + + + + + + + + + + + + + + + + + + san + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + san + + + + + + + + + + + + + + + + san + + + + + + + + + + + + + + + + + + + + + + + + + + + + na + + + + + + + + + + + + + + na + + + + + + + + + + + + + + + + + + + + in + + + + + + + + + + + + in + + + + + + + + ex + + + + + + + + + + + + + + + in + + + + + + + + + + + + na + + + + + + + + [in + + + + + + + + + + + ex + + + + + + + + cel + + + + + + + + + + + + + + + + + + + + ex + + + + + + + + + + + + + + + ex + + + + + + + cel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + cel + + + + + + + + + + + + sis,] + + + + + + + + in + + + + + + + + + + + sis, + + + + + + + + + + [in + + + + + + + + + + + cel + + + + + + + sis, + + + + + + + + + + + + + + + + + + + + + + ex + + + + + + + cel + + + + + + + + + + + + + + ex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + cel + + + + + + + + + + + + + + + + + + + + + + + + + sis, + + + + + + + + + + + sis, + + + + + + + + [in + + + + + + + + + + + + sis,] + + + + + + + + + + + + ho + + + + + + + + + + + + + + + + + + + + + + + + ex + + + + + + + + + + + + + + in + + + + + + + + + + + + + + + + + + + + + + + + + + + cel + + + + + + + + + + + + + + + + + + ex + + + + + + + + + + + + san + + + + + + + + + + + + + + + + + + in + + + + + + + + ex + + + + + + + + + + + sis,] + + + + + + + + + + + cel + + + + + + + + + + + + + + + na + + + + + + + + in + + + + + + + + + + + + + + + cel + + + + + + + + + + + + + + + + + in + + + + + + + + + + + + + + + + + + + + + + + ex + + + + + + + + + + + + + + + + + sis. + + + + + + + + + + + ex + + + + + + + + + + + + + + sis. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + cel + + + + + + + + + + + + + + + + + + + + cel + + + + + + + + + + + + + + + + + + sis. + + + + + + + + + + + + + + + + sis. + + + + + + + + + + + + + + + + + + Be + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ne + + + + + + + + + + + + + + + + + + + + + + + + + + + + + dic + + + + + + + + + + + + + + + + + + + + Be + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ne + + + + + + + + + + + + + + + + + + + + + + + + + + + Be + + + + + + + + + + + dic + + + + + + + + + + + + + + + + + + + + + tus, + + + + + + + + be + + + + + + + + + + + + ne + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + dic + + + + + + + + + + + + + + + tus, + + + + + + + + + + + + + + + + + + ne + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + dic + + + + + + + + + + + + + + + + + + + + be + + + + + + + + + + + + + + + + + + + + + tus + + + + + + + + + + + + tus + + + + + + + [be + + + + + + + + + + + + + + + ne + + + + + + + + + + + + + + + + + + + + + + + + [be + + + + + + + + + + + + + + ne + + + + + + + + + + + + + + + + + + + + + + + + + + ne + + + + + + + + dic + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + dic + + + + + + + + + + + + + + dic + + + + + + + + + + + + + + + + + + + tus] + + + + + + + + + + + tus] + + + + + + + + + + + tus + + + + + + + + + + + + + + + + + + + + + + + + + + qui + + + + + + + + + + + + qui + + + + + + + + + + + + + + + + + + + + qui + + + + + + + + + + + + + + ve + + + + + + + + + + + + + + ve + + + + + + + + + + + + + + + + + + + + + + ve + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + nit, + + + + + + + + + + + + + + + + + nit, + + + + + + + + + + + + + + + + + + + + + + + + nit, + + + + + + + + + + + + qui + + + + + + + + + + + + + + [qui + + + + + + + + + + + + + + + + + + + + + + + + + + ve + + + + + + + + + + + + + + + + + ve + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + qui + + + + + + + + + + + + + + + nit + + + + + + + + + + + nit,] + + + + + + + + qui + + + + + + + + + + + + + + + + + + + ve + + + + + + + + + + + + + + + + + + + ve + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + nit + + + + + + + + + + + + in + + + + + + + no + + + + + + + mi + + + + + + + + + + + nit + + + + + + + + in + + + + + + + + + + + + + + + + + + + + + + + + ne + + + + + + + Do + + + + + + + + + + + + + + no + + + + + + + mi + + + + + + + ne + + + + + + + + + + + + + + + + + + + + + in + + + + + + + no + + + + + + + mi + + + + + + + + + + + + + mi + + + + + + + + + + + + + + Do + + + + + + + + + + + + + + + + + + + ne + + + + + + + + + + + + + + ni, + + + + + + + + + + + + + mi + + + + + + + ni + + + + + + + + + + + + + + + + + + Do + + + + + + + + mi + + + + + + + + + + + + + in + + + + + + + + + + + + + [in + + + + + + + + + + + + + + + + + + + ni, + + + + + + + + in + + + + + + + + + + + no + + + + + + + mi + + + + + + + ne + + + + + + + + + + + + no + + + + + + + + + + + + + + + + + + + + no + + + + + + + mi + + + + + + + + + + + + + Do + + + + + + + + + + + + mi + + + + + + + ne + + + + + + + + + + + + + + + + + + + + + ne + + + + + + + + Do + + + + + + + + + + + + + mi + + + + + + + ni, + + + + + + + + + + + Do + + + + + + + + + + + + + + + + + + + + + + mi + + + + + + + + + + + + + + in + + + + + + + + + + + + mi + + + + + + + ni,] + + + + + + + + + + + + + + + + + + ni, + + + + + + + + in + + + + + + + + + + + no + + + + + + + mi + + + + + + + + + + + + in + + + + + + + no + + + + + + + + + + + + + + + + + + + + no + + + + + + + mi + + + + + + + ne + + + + + + + + + + + + ne + + + + + + + + Do + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Do + + + + + + + + + + + + + + + + + + + + mi + + + + + + + + ne + + + + + + + + + + + + + + + + + + + + mi + + + + + + + + + + + + + mi + + + + + + + + + + + Do + + + + + + + mi + + + + + + + + + + + + + + ni. + + + + + + + + + + + ni. + + + + + + + + + + + ni. + + + + + + +
+
+ + 724.8s + +
+ +
+
\ No newline at end of file diff --git a/tests/test_mei.py b/tests/test_mei.py index ed102495..c5d6b959 100644 --- a/tests/test_mei.py +++ b/tests/test_mei.py @@ -129,12 +129,12 @@ def test_clef(self): self.assertTrue(clefs2[0].start.t == 0) self.assertTrue(clefs2[0].sign == "C") self.assertTrue(clefs2[0].line == 3) - self.assertTrue(clefs2[0].staff == 3) + self.assertTrue(clefs2[0].staff == 1) self.assertTrue(clefs2[0].octave_change == 0) self.assertTrue(clefs2[1].start.t == 8) self.assertTrue(clefs2[1].sign == "F") self.assertTrue(clefs2[1].line == 4) - self.assertTrue(clefs2[1].staff == 3) + self.assertTrue(clefs2[1].staff == 1) self.assertTrue(clefs2[1].octave_change == 0) # test on part 3 part3 = list(score.iter_parts(part_list))[3] @@ -144,7 +144,7 @@ def test_clef(self): self.assertTrue(clefs3[1].start.t == 4) self.assertTrue(clefs3[1].sign == "G") self.assertTrue(clefs3[1].line == 2) - self.assertTrue(clefs3[1].staff == 4) + self.assertTrue(clefs3[1].staff == 1) self.assertTrue(clefs3[1].octave_change == -1) def test_key_signature1(self): @@ -271,5 +271,15 @@ def test_staff(self): expected_staves = [4,3,2,1,1,1] self.assertTrue(np.array_equal(staves, expected_staves) ) + def test_nopart_but(self): + parts = load_mei(MEI_TESTFILES[20]) + last_measure_duration = [list(p.iter_all(score.Measure))[-1].end.t- list(p.iter_all(score.Measure))[-1].start.t for p in parts] + self.assertTrue(all([d == 4096 for d in last_measure_duration])) + + # def test_nopart_but2(self): + # parts = load_mei(MEI_TESTFILES[21]) + # last_measure_duration = [list(p.iter_all(score.Measure))[-1].end.t- list(p.iter_all(score.Measure))[-1].start.t for p in parts] + # self.assertTrue(False) + if __name__ == "__main__": unittest.main() From acba26bc4323a7fbdeec3561b9bc503d18595f34 Mon Sep 17 00:00:00 2001 From: sildater <41552783+sildater@users.noreply.github.com> Date: Tue, 22 Nov 2022 12:35:19 +0100 Subject: [PATCH 33/88] added field onset_div with MIDI divs / ticks to performance note_array --- partitura/io/importmidi.py | 34 +++++++++++------------------- partitura/performance.py | 17 +++++++++++---- tests/test_load_performance.py | 38 +++++++++++++++++++++------------- 3 files changed, 49 insertions(+), 40 deletions(-) diff --git a/partitura/io/importmidi.py b/partitura/io/importmidi.py index ffa6e97f..d85a1d87 100644 --- a/partitura/io/importmidi.py +++ b/partitura/io/importmidi.py @@ -71,8 +71,7 @@ def midi_to_notearray(filename: PathLike) -> np.ndarray: def load_performance_midi( filename: Union[PathLike, mido.MidiFile], default_bpm: Union[int, float] = 120, - merge_tracks: bool = False, - time_in_divs: bool = False, + merge_tracks: bool = False ) -> performance.Performance: """Load a musical performance from a MIDI file. @@ -116,10 +115,7 @@ def load_performance_midi( mpq = 60 * (10 ** 6 / default_bpm) # convert MIDI ticks in seconds - if time_in_divs: - time_conversion_factor = 1 - else: - time_conversion_factor = mpq / (ppq * 10 ** 6) + time_conversion_factor = mpq / (ppq * 10 ** 6) notes = [] controls = [] @@ -132,35 +128,27 @@ def load_performance_midi( for i, track in tracks: t = 0 + tdiv = 0 + sounding_notes = {} for msg in track: # update time deltas when they arrive t = t + msg.time * time_conversion_factor + tdiv = tdiv + msg.time if msg.type == "set_tempo": mpq = msg.tempo - - if time_in_divs: - time_conversion_factor = 1 - else: - time_conversion_factor = mpq / (ppq * 10 ** 6) - - warnings.warn( - ( - "change of Tempo to mpq = {0} " - " and resulting seconds per tick = {1}" - "at time: {2}" - ).format(mpq, time_conversion_factor, t) - ) + time_conversion_factor = mpq / (ppq * 10 ** 6) elif msg.type == "control_change": controls.append( dict( time=t, + time_div=tdiv, number=msg.control, value=msg.value, track=i, @@ -173,6 +161,7 @@ def load_performance_midi( programs.append( dict( time=t, + time_div=tdiv, program=msg.program, track=i, channel=msg.channel, @@ -194,7 +183,7 @@ def load_performance_midi( if note_on and msg.velocity > 0: # save the onset time and velocity - sounding_notes[note] = (t, msg.velocity) + sounding_notes[note] = (t, tdiv, msg.velocity) # end note if it's a 'note off' event or 'note on' with velocity 0 elif note_off or (note_on and msg.velocity == 0): @@ -210,13 +199,14 @@ def load_performance_midi( # id=f"n{len(notes)}", midi_pitch=msg.note, note_on=(sounding_notes[note][0]), + note_on_div=(sounding_notes[note][1]), note_off=(t), + note_off_div=(tdiv), track=i, channel=msg.channel, - velocity=sounding_notes[note][1], + velocity=sounding_notes[note][2], ) ) - # remove hash from dict del sounding_notes[note] diff --git a/partitura/performance.py b/partitura/performance.py index c0482944..3b4ab964 100644 --- a/partitura/performance.py +++ b/partitura/performance.py @@ -108,10 +108,13 @@ def sustain_pedal_threshold(self) -> int: @sustain_pedal_threshold.setter def sustain_pedal_threshold(self, value: int) -> None: - # """ - # Set the pedal threshold and update the sound_off - # of the notes - # """ + """ + Set the pedal threshold and update the sound_off + of the notes. The threshold is a MIDI CC value + between 0 and 127. The higher the threshold, the + more restrained the pedal use and the drier the + performance. Set to 127 to deactivate pedal. + """ self._sustain_pedal_threshold = value adjust_offsets_w_sustain( self.notes, self.controls, self._sustain_pedal_threshold @@ -137,6 +140,8 @@ def note_array(self, *args, **kwargs) -> np.ndarray: fields = [ ("onset_sec", "f4"), ("duration_sec", "f4"), + ("onset_div", "i4"), + ("duration_div", "i4"), ("pitch", "i4"), ("velocity", "i4"), ("track", "i4"), @@ -146,12 +151,16 @@ def note_array(self, *args, **kwargs) -> np.ndarray: note_array = [] for n in self.notes: note_on_sec = n["note_on"] + note_on_div = n["note_on_div"] offset = n.get("sound_off", n["note_off"]) duration_sec = offset - note_on_sec + duration_div = n["note_off_div"] - note_on_div note_array.append( ( note_on_sec, duration_sec, + note_on_div, + duration_div, n["midi_pitch"], n["velocity"], n.get("track", 0), diff --git a/tests/test_load_performance.py b/tests/test_load_performance.py index 9319b561..0412bfa9 100644 --- a/tests/test_load_performance.py +++ b/tests/test_load_performance.py @@ -4,23 +4,33 @@ This module contains test functions for the `load_performance` method """ import unittest +import numpy as np +from tests import MOZART_VARIATION_FILES -from tests import MATCH_IMPORT_EXPORT_TESTFILES - -from partitura import load_performance, EXAMPLE_MIDI +from partitura import load_performance_midi, EXAMPLE_MIDI from partitura.io import NotSupportedFormatError from partitura.performance import Performance - -class TestLoadScore(unittest.TestCase): +class TestLoadPerformance(unittest.TestCase): def test_load_performance(self): - for fn in MATCH_IMPORT_EXPORT_TESTFILES + [EXAMPLE_MIDI]: - load_performance(fn) - - def load_performance(self, fn): - try: - performance = load_performance(fn) - self.assertTrue(isinstance(performance, Performance)) - except NotSupportedFormatError: - self.assertTrue(False) + for fn in [MOZART_VARIATION_FILES["midi"]] + [EXAMPLE_MIDI]: + try: + print(fn) + performance = load_performance_midi(fn) + self.assertTrue(isinstance(performance, Performance)) + except NotSupportedFormatError: + self.assertTrue(False) + + def test_array_performance(self): + for fn in [EXAMPLE_MIDI]: + performance = load_performance_midi(fn) + na = performance.note_array() + self.assertTrue(np.all(na["onset_sec"] * 24 == na["onset_div"])) + + + + +if __name__ == "__main__": + + unittest.main() \ No newline at end of file From 3db0d934df289c8ec82014bfbcb5f3290a9de945 Mon Sep 17 00:00:00 2001 From: sildater <41552783+sildater@users.noreply.github.com> Date: Tue, 22 Nov 2022 12:39:10 +0100 Subject: [PATCH 34/88] remove onset_div field checkign from time unit getter --- partitura/utils/music.py | 8 ++++++-- tests/test_load_performance.py | 1 - 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/partitura/utils/music.py b/partitura/utils/music.py index 42aa43d3..bec97d0a 100644 --- a/partitura/utils/music.py +++ b/partitura/utils/music.py @@ -363,7 +363,7 @@ def get_time_units_from_note_array(note_array): if fields is None: raise ValueError("`note_array` must be a structured numpy array") - score_units = set(("onset_beat", "onset_quarter", "onset_div")) + score_units = set(("onset_beat", "onset_quarter",)) performance_units = set(("onset_sec",)) if len(score_units.intersection(fields)) > 0: @@ -374,7 +374,11 @@ def get_time_units_from_note_array(note_array): elif "onset_div" in fields: return ("onset_div", "duration_div") elif len(performance_units.intersection(fields)) > 0: - return ("onset_sec", "duration_sec") + if "onset_sec" in fields: + return ("onset_sec", "duration_sec") + elif "onset_div" in fields: + return ("onset_div", "duration_div") + else: raise ValueError("Input array does not contain the expected " "time-units") diff --git a/tests/test_load_performance.py b/tests/test_load_performance.py index 0412bfa9..e64be48f 100644 --- a/tests/test_load_performance.py +++ b/tests/test_load_performance.py @@ -32,5 +32,4 @@ def test_array_performance(self): if __name__ == "__main__": - unittest.main() \ No newline at end of file From ccd56f5d30379128eef6c0e5e258355fd58772db Mon Sep 17 00:00:00 2001 From: sildater <41552783+sildater@users.noreply.github.com> Date: Tue, 22 Nov 2022 13:00:46 +0100 Subject: [PATCH 35/88] renamed all instances of div to tick in performances --- partitura/io/importmidi.py | 14 +++++++------- partitura/performance.py | 12 ++++++------ partitura/utils/music.py | 8 ++++---- tests/test_load_performance.py | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/partitura/io/importmidi.py b/partitura/io/importmidi.py index d85a1d87..457d4b59 100644 --- a/partitura/io/importmidi.py +++ b/partitura/io/importmidi.py @@ -128,7 +128,7 @@ def load_performance_midi( for i, track in tracks: t = 0 - tdiv = 0 + ttick = 0 sounding_notes = {} @@ -136,7 +136,7 @@ def load_performance_midi( # update time deltas when they arrive t = t + msg.time * time_conversion_factor - tdiv = tdiv + msg.time + ttick = ttick + msg.time if msg.type == "set_tempo": @@ -148,7 +148,7 @@ def load_performance_midi( controls.append( dict( time=t, - time_div=tdiv, + time_tick=ttick, number=msg.control, value=msg.value, track=i, @@ -161,7 +161,7 @@ def load_performance_midi( programs.append( dict( time=t, - time_div=tdiv, + time_tick=ttick, program=msg.program, track=i, channel=msg.channel, @@ -183,7 +183,7 @@ def load_performance_midi( if note_on and msg.velocity > 0: # save the onset time and velocity - sounding_notes[note] = (t, tdiv, msg.velocity) + sounding_notes[note] = (t, ttick, msg.velocity) # end note if it's a 'note off' event or 'note on' with velocity 0 elif note_off or (note_on and msg.velocity == 0): @@ -199,9 +199,9 @@ def load_performance_midi( # id=f"n{len(notes)}", midi_pitch=msg.note, note_on=(sounding_notes[note][0]), - note_on_div=(sounding_notes[note][1]), + note_on_tick=(sounding_notes[note][1]), note_off=(t), - note_off_div=(tdiv), + note_off_tick=(ttick), track=i, channel=msg.channel, velocity=sounding_notes[note][2], diff --git a/partitura/performance.py b/partitura/performance.py index 3b4ab964..9ffc0f6e 100644 --- a/partitura/performance.py +++ b/partitura/performance.py @@ -140,8 +140,8 @@ def note_array(self, *args, **kwargs) -> np.ndarray: fields = [ ("onset_sec", "f4"), ("duration_sec", "f4"), - ("onset_div", "i4"), - ("duration_div", "i4"), + ("onset_tick", "i4"), + ("duration_tick", "i4"), ("pitch", "i4"), ("velocity", "i4"), ("track", "i4"), @@ -151,16 +151,16 @@ def note_array(self, *args, **kwargs) -> np.ndarray: note_array = [] for n in self.notes: note_on_sec = n["note_on"] - note_on_div = n["note_on_div"] + note_on_tick = n.get("note_on_tick", n["note_on"]) offset = n.get("sound_off", n["note_off"]) duration_sec = offset - note_on_sec - duration_div = n["note_off_div"] - note_on_div + duration_tick = n.get("note_off_tick", n["note_off"]) - note_on_tick note_array.append( ( note_on_sec, duration_sec, - note_on_div, - duration_div, + note_on_tick, + duration_tick, n["midi_pitch"], n["velocity"], n.get("track", 0), diff --git a/partitura/utils/music.py b/partitura/utils/music.py index bec97d0a..2ed00a81 100644 --- a/partitura/utils/music.py +++ b/partitura/utils/music.py @@ -363,8 +363,8 @@ def get_time_units_from_note_array(note_array): if fields is None: raise ValueError("`note_array` must be a structured numpy array") - score_units = set(("onset_beat", "onset_quarter",)) - performance_units = set(("onset_sec",)) + score_units = set(("onset_beat", "onset_quarter","onset_div")) + performance_units = set(("onset_sec","onset_tick")) if len(score_units.intersection(fields)) > 0: if "onset_beat" in fields: @@ -376,8 +376,8 @@ def get_time_units_from_note_array(note_array): elif len(performance_units.intersection(fields)) > 0: if "onset_sec" in fields: return ("onset_sec", "duration_sec") - elif "onset_div" in fields: - return ("onset_div", "duration_div") + elif "onset_tick" in fields: + return ("onset_tick", "duration_tick") else: raise ValueError("Input array does not contain the expected " "time-units") diff --git a/tests/test_load_performance.py b/tests/test_load_performance.py index e64be48f..19085bc7 100644 --- a/tests/test_load_performance.py +++ b/tests/test_load_performance.py @@ -26,7 +26,7 @@ def test_array_performance(self): for fn in [EXAMPLE_MIDI]: performance = load_performance_midi(fn) na = performance.note_array() - self.assertTrue(np.all(na["onset_sec"] * 24 == na["onset_div"])) + self.assertTrue(np.all(na["onset_sec"] * 24 == na["onset_tick"])) From 78941f24f05055d8c81830fb513edca5396b7b66 Mon Sep 17 00:00:00 2001 From: fosfrancesco Date: Tue, 22 Nov 2022 16:09:35 +0100 Subject: [PATCH 36/88] removed wrongly encoded mei file --- tests/__init__.py | 1 - tests/test_mei.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 7b29a8f2..b5712548 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -151,7 +151,6 @@ "test_articulation.mei", "test_merge_voices2.mei", "CRIM_Mass_0030_4.mei", - "CRIM_Mass_0004_3.mei" ] ] diff --git a/tests/test_mei.py b/tests/test_mei.py index c5d6b959..cb986ad5 100644 --- a/tests/test_mei.py +++ b/tests/test_mei.py @@ -271,7 +271,7 @@ def test_staff(self): expected_staves = [4,3,2,1,1,1] self.assertTrue(np.array_equal(staves, expected_staves) ) - def test_nopart_but(self): + def test_nopart(self): parts = load_mei(MEI_TESTFILES[20]) last_measure_duration = [list(p.iter_all(score.Measure))[-1].end.t- list(p.iter_all(score.Measure))[-1].start.t for p in parts] self.assertTrue(all([d == 4096 for d in last_measure_duration])) From 302fe5a3182480d5afe9e5f4598ac83dbdb6b471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Wed, 23 Nov 2022 05:50:40 +0100 Subject: [PATCH 37/88] test parsers for old snote and note lines --- partitura/io/matchfile_base.py | 6 +- partitura/io/matchfile_utils.py | 16 +++ partitura/io/matchlines_v0.py | 243 +++++++++++++++++++++++++++++++- tests/test_match_import_new.py | 186 +++++++++++++++++++++--- 4 files changed, 427 insertions(+), 24 deletions(-) diff --git a/partitura/io/matchfile_base.py b/partitura/io/matchfile_base.py index b15c041d..6fa0b73a 100644 --- a/partitura/io/matchfile_base.py +++ b/partitura/io/matchfile_base.py @@ -308,7 +308,7 @@ class BaseSnoteLine(MatchLine): format_fun = dict( Anchor=format_string, - NoteName=lambda x: str(x.upper()), + NoteName=lambda x: str(x).upper(), Modifier=lambda x: "n" if x == 0 else ALTER_SIGNS[x], Octave=format_int, Measure=format_int, @@ -426,7 +426,6 @@ class BaseNoteLine(MatchLine): # these field names field_names = ( "Id", - "MidiPitch", "Onset", "Offset", "Velocity", @@ -451,6 +450,9 @@ def __init__( ) -> None: super().__init__(version) self.Id = id + # The MIDI pitch is not a part of all + # note versions. For versions < 1.0.0 + # it needs to be inferred from pitch spelling. self.MidiPitch = midi_pitch self.Onset = onset self.Offset = offset diff --git a/partitura/io/matchfile_utils.py b/partitura/io/matchfile_utils.py index b4b4b196..29229895 100644 --- a/partitura/io/matchfile_utils.py +++ b/partitura/io/matchfile_utils.py @@ -436,6 +436,22 @@ def format_fractional(value: FractionalSymbolicDuration) -> str: return str(value) +def format_fractional_rational(value: FractionalSymbolicDuration) -> str: + """ + Format fractional symbolic duration as string and ensure that the output + is always rational ("a/b") + """ + + if value.denominator == 1 and value.tuple_div is None: + + out = f"{value.numerator}/1" + + else: + out = str(value) + + return out + + def interpret_as_list(value: str) -> List[str]: """ Interpret string as list of values. diff --git a/partitura/io/matchlines_v0.py b/partitura/io/matchlines_v0.py index 381a69f9..8347c21b 100644 --- a/partitura/io/matchlines_v0.py +++ b/partitura/io/matchlines_v0.py @@ -9,13 +9,14 @@ import re -from typing import Any, Callable, Tuple, Union, List +from typing import Any, Callable, Tuple, Union, List, Dict from partitura.io.matchfile_base import ( MatchLine, + MatchError, BaseInfoLine, BaseSnoteLine, - MatchError, + BaseNoteLine, ) from partitura.io.matchfile_utils import ( @@ -31,9 +32,17 @@ format_int, FractionalSymbolicDuration, format_fractional, + format_fractional_rational, interpret_as_fractional, interpret_as_list, format_list, + get_kwargs_from_matchline, +) + +from partitura.utils.music import ( + ALTER_SIGNS, + pitch_spelling_to_midi_pitch, + ensure_pitch_spelling_format, ) # Define last supported version of the match file format in this module @@ -175,6 +184,55 @@ def from_matchline( raise MatchError("Input match line does not fit the expected pattern.") +SNOTE_LINE_Vgeq0_4_0 = dict( + Anchor=format_string, + NoteName=lambda x: str(x).upper(), + Modifier=lambda x: "n" if x == 0 else ALTER_SIGNS[x], + Octave=format_int, + Measure=format_int, + Beat=format_int, + Offset=format_fractional, + Duration=format_fractional, + OnsetInBeats=format_float_unconstrained, + OffsetInBeats=format_float_unconstrained, + ScoreAttributesList=format_list, +) + +SNOTE_LINE_Vlt0_3_0 = dict( + Anchor=format_string, + NoteName=lambda x: str(x).lower(), + Modifier=lambda x: "n" if x == 0 else ALTER_SIGNS[x], + Octave=format_int, + Measure=format_int, + Beat=format_int, + Offset=format_fractional_rational, + Duration=format_fractional_rational, + OnsetInBeats=lambda x: f"{x:.5f}", + OffsetInBeats=lambda x: f"{x:.5f}", + ScoreAttributesList=format_list, +) + +SNOTE_LINE = { + Version(0, 5, 0): SNOTE_LINE_Vgeq0_4_0, + Version(0, 4, 0): SNOTE_LINE_Vgeq0_4_0, + Version(0, 3, 0): dict( + Anchor=format_string, + NoteName=lambda x: str(x).lower(), + Modifier=lambda x: "n" if x == 0 else ALTER_SIGNS[x], + Octave=format_int, + Measure=format_int, + Beat=format_int, + Offset=format_fractional, + Duration=format_fractional, + OnsetInBeats=format_float_unconstrained, + OffsetInBeats=format_float_unconstrained, + ScoreAttributesList=format_list, + ), + Version(0, 2, 0): SNOTE_LINE_Vlt0_3_0, + Version(0, 1, 0): SNOTE_LINE_Vlt0_3_0, +} + + class MatchSnote(BaseSnoteLine): def __init__( self, @@ -190,7 +248,12 @@ def __init__( onset_in_beats: float, offset_in_beats: float, score_attributes_list: List[str], - ): + ) -> None: + if version not in SNOTE_LINE: + raise ValueError( + f"Unknown version {version}!. " + f"Supported versions are {list(SNOTE_LINE.keys())}" + ) super().__init__( version=version, anchor=anchor, @@ -206,6 +269,8 @@ def __init__( score_attributes_list=score_attributes_list, ) + self.format_fun = SNOTE_LINE[version] + @classmethod def from_matchline( cls, @@ -241,3 +306,175 @@ def from_matchline( ) return cls(version=version, **kwargs) + + +# Note lines for versions larger than 3.0 +NOTE_LINE_Vgeq0_3_0 = { + "field_names": ( + "Id", + "NoteName", + "Modifier", + "Octave", + "Onset", + "Offset", + "AdjOffset", + "Velocity", + ), + "out_pattern": ( + "note({Id},[{NoteName},{Modifier}],{Octave},{Onset},{Offset}," + "{AdjOffset},{Velocity})." + ), + "pattern": re.compile( + r"note\((?P[^,]+)," + r"\[(?P[^,]+),(?P[^,]+)\]," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)\)" + ), + "field_interpreters": { + "Id": (interpret_as_string, format_string, str), + "NoteName": (interpret_as_string, lambda x: str(x).upper(), str), + "Modifier": ( + interpret_as_string, + lambda x: "n" if x == 0 else ALTER_SIGNS[x], + (int, type(None)), + ), + "Octave": (interpret_as_int, format_int, int), + "Onset": (interpret_as_int, format_int, int), + "Offset": (interpret_as_int, format_int, int), + "AdjOffset": (interpret_as_int, format_int, int), + "Velocity": (interpret_as_int, format_int, int), + }, +} + +NOTE_LINE_Vlt0_3_0 = { + "field_names": ( + "Id", + "NoteName", + "Modifier", + "Octave", + "Onset", + "Offset", + "Velocity", + ), + "out_pattern": ( + "note({Id},[{NoteName},{Modifier}],{Octave},{Onset},{Offset},{Velocity})." + ), + "pattern": re.compile( + r"note\((?P[^,]+)," + r"\[(?P[^,]+),(?P[^,]+)\]," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)\)" + ), + "field_interpreters": { + "Id": (interpret_as_string, format_string, str), + "NoteName": (interpret_as_string, lambda x: str(x).lower(), str), + "Modifier": ( + interpret_as_string, + lambda x: "n" if x == 0 else ALTER_SIGNS[x], + (int, type(None)), + ), + "Octave": (interpret_as_int, format_int, int), + "Onset": (interpret_as_float, lambda x: f"{x:.2f}", float), + "Offset": (interpret_as_float, lambda x: f"{x:.2f}", float), + "Velocity": (interpret_as_int, format_int, int), + }, +} + + +NOTE_LINE = { + Version(0, 5, 0): NOTE_LINE_Vgeq0_3_0, + Version(0, 4, 0): NOTE_LINE_Vgeq0_3_0, + Version(0, 3, 0): NOTE_LINE_Vgeq0_3_0, + Version(0, 2, 0): NOTE_LINE_Vlt0_3_0, + Version(0, 1, 0): NOTE_LINE_Vlt0_3_0, +} + + +class MatchNote(BaseNoteLine): + def __init__( + self, + version: Version, + id: str, + note_name: str, + modifier: int, + octave: int, + onset: int, + offset: int, + velocity: int, + **kwargs, + ) -> None: + + if version not in NOTE_LINE: + raise ValueError( + f"Unknown version {version}!. " + f"Supported versions are {list(NOTE_LINE.keys())}" + ) + + step, alter, octave = ensure_pitch_spelling_format(note_name, modifier, octave) + midi_pitch = pitch_spelling_to_midi_pitch(step, alter, octave) + + super().__init__( + version=version, + id=id, + midi_pitch=midi_pitch, + onset=onset, + offset=offset, + velocity=velocity, + ) + + self.field_names = NOTE_LINE[version]["field_names"] + self.field_types = tuple( + NOTE_LINE[version]["field_interpreters"][fn][2] for fn in self.field_names + ) + self.format_fun = dict( + [ + (fn, NOTE_LINE[version]["field_interpreters"][fn][1]) + for fn in self.field_names + ] + ) + + self.pattern = NOTE_LINE[version]["pattern"] + self.out_pattern = NOTE_LINE[version]["out_pattern"] + + self.NoteName = step + self.Modifier = alter + self.Octave = octave + self.AdjOffset = offset + + if "adj_offset" in kwargs: + + self.AdjOffset = kwargs["adj_offset"] + + @property + def AdjDuration(self): + return self.AdjOffset - self.Onset + + @classmethod + def from_matchline( + cls, + matchline: str, + pos: int = 0, + version: Version = LAST_VERSION, + ) -> MatchNote: + + if version >= Version(1, 0, 0): + ValueError(f"{version} >= Version(1, 0, 0)") + + kwargs = get_kwargs_from_matchline( + matchline=matchline, + pattern=NOTE_LINE[version]["pattern"], + field_names=NOTE_LINE[version]["field_names"], + class_dict=NOTE_LINE[version]["field_interpreters"], + pos=pos, + ) + + if kwargs is not None: + return cls(version=version, **kwargs) + + else: + raise MatchError("Input match line does not fit the expected pattern.") diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index 330cd178..d2497ede 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -9,17 +9,23 @@ from tests import MATCH_IMPORT_EXPORT_TESTFILES, MOZART_VARIATION_FILES from partitura.io.matchlines_v1 import ( - MatchInfo, - MatchScoreProp, - MatchSection, - MatchSnote, - MatchNote, + MatchInfo as MatchInfoV1, + MatchScoreProp as MatchScorePropV1, + MatchSection as MatchSectionV1, + MatchSnote as MatchSnoteV1, + MatchNote as MatchNoteV1, +) + +from partitura.io.matchlines_v0 import ( + MatchInfo as MatchInfoV0, + MatchSnote as MatchSnoteV0, + MatchNote as MatchNoteV0, ) from partitura.io.matchfile_base import interpret_version, Version, MatchError -class TestMatchLinesV1_0_0(unittest.TestCase): +class TestMatchLinesV1(unittest.TestCase): """ Test matchlines for version 1.0.0 """ @@ -75,7 +81,7 @@ def test_info_lines(self): ] for ml in matchlines: - mo = MatchInfo.from_matchline(ml) + mo = MatchInfoV1.from_matchline(ml) # assert that the information from the matchline # is parsed correctly and results in an identical line # to the input match line @@ -89,7 +95,7 @@ def test_info_lines(self): # This line is not defined as an info line and should raise an error notSpecified_line = "info(notSpecified,value)." - mo = MatchInfo.from_matchline(notSpecified_line) + mo = MatchInfoV1.from_matchline(notSpecified_line) self.assertTrue(False) except ValueError: # assert that the error was raised @@ -99,7 +105,7 @@ def test_info_lines(self): # wrong value (string instead of integer) midiClockUnits_line = "info(midiClockUnits,wrong_value)." - mo = MatchInfo.from_matchline(midiClockUnits_line) + mo = MatchInfoV1.from_matchline(midiClockUnits_line) self.assertTrue(False) except ValueError: # assert that the error was raised @@ -108,7 +114,7 @@ def test_info_lines(self): try: # This is not a valid line and should result in a MatchError wrong_line = "wrong_line" - mo = MatchInfo.from_matchline(wrong_line) + mo = MatchInfoV1.from_matchline(wrong_line) self.assertTrue(False) except MatchError: self.assertTrue(True) @@ -134,7 +140,7 @@ def test_score_prop_lines(self): # assert that the information from the matchline # is parsed correctly and results in an identical line # to the input match line - mo = MatchScoreProp.from_matchline(ml) + mo = MatchScorePropV1.from_matchline(ml) self.assertTrue(mo.matchline == ml) # assert that the data types of the match line are correct @@ -143,7 +149,7 @@ def test_score_prop_lines(self): try: # This is not a valid line and should result in a MatchError wrong_line = "wrong_line" - mo = MatchScoreProp.from_matchline(wrong_line) + mo = MatchScorePropV1.from_matchline(wrong_line) self.assertTrue(False) except MatchError: self.assertTrue(True) @@ -161,8 +167,8 @@ def test_section_lines(self): # assert that the information from the matchline # is parsed correctly and results in an identical line # to the input match line - mo = MatchSection.from_matchline(ml) - # print(mo.matchline, ml, [(g == t, g, t) for g, t in zip(mo.matchline, ml)]) + mo = MatchSectionV1.from_matchline(ml) + self.assertTrue(mo.matchline == ml) # assert that the data types of the match line are correct @@ -170,7 +176,9 @@ def test_section_lines(self): # Check version (an error should be raised for old versions) try: - mo = MatchSection.from_matchline(section_lines[0], version=Version(0, 5, 0)) + mo = MatchSectionV1.from_matchline( + section_lines[0], version=Version(0, 5, 0) + ) self.assertTrue(False) except ValueError: @@ -180,7 +188,7 @@ def test_section_lines(self): try: # Line does not have [] for the end annotations wrong_line = "section(0.0000,100.0000,0.0000,100.0000,end)." - mo = MatchSection.from_matchline(wrong_line) + mo = MatchSectionV1.from_matchline(wrong_line) self.assertTrue(False) except MatchError: self.assertTrue(True) @@ -257,7 +265,7 @@ def test_snote_lines(self): # assert that the information from the matchline # is parsed correctly and results in an identical line # to the input match line - mo = MatchSnote.from_matchline(ml) + mo = MatchSnoteV1.from_matchline(ml) # test __str__ method self.assertTrue( all( @@ -276,7 +284,7 @@ def test_snote_lines(self): try: # This is not a valid line and should result in a MatchError wrong_line = "wrong_line" - mo = MatchSnote.from_matchline(wrong_line) + mo = MatchSnoteV1.from_matchline(wrong_line) self.assertTrue(False) except MatchError: self.assertTrue(True) @@ -294,7 +302,7 @@ def test_note_lines(self): # assert that the information from the matchline # is parsed correctly and results in an identical line # to the input match line - mo = MatchNote.from_matchline(ml) + mo = MatchNoteV1.from_matchline(ml) # print(mo.matchline, ml, [(g == t, g, t) for g, t in zip(mo.matchline, ml)]) self.assertTrue(mo.matchline == ml) @@ -302,6 +310,146 @@ def test_note_lines(self): self.assertTrue(mo.check_types()) +class TestMatchLinesV0(unittest.TestCase): + def test_snote_lines_v0_1_0(self): + + snote_lines = [ + "snote(n1,[c,n],6,0:3,0/1,1/8,-4.00000,-3.00000,[1])", + "snote(n726,[f,n],3,45:1,0/1,0/8,264.00000,264.00000,[5,arp])", + "snote(n714,[a,n],5,44:6,0/1,1/8,263.00000,264.00000,[1])", + "snote(n1,[b,n],4,0:2,1/8,1/8,-0.50000,0.00000,[1])", + "snote(n445,[e,n],4,20:2,1/16,1/16,39.25000,39.50000,[4])", + ] + + for ml in snote_lines: + + for minor_version in (1, 2): + # assert that the information from the matchline + # is parsed correctly and results in an identical line + # to the input match line + mo = MatchSnoteV0.from_matchline( + ml, + version=Version(0, minor_version, 0), + ) + # print(mo.matchline, ml) + self.assertTrue(mo.matchline == ml) + + # assert that the data types of the match line are correct + self.assertTrue(mo.check_types()) + + try: + # This is not a valid line and should result in a MatchError + wrong_line = "wrong_line" + mo = MatchSnoteV0.from_matchline(wrong_line, version=Version(0, 1, 0)) + self.assertTrue(False) + except MatchError: + self.assertTrue(True) + + def test_snote_lines_v0_3_0(self): + + snote_lines = [ + "snote(n1,[e,n],4,1:1,0,1/4,0.0,1.0,[arp])", + "snote(n16,[e,n],5,1:4,1/16,1/16,3.25,3.5,[s])", + "snote(n29,[c,n],5,2:3,0,1/16,6.0,6.25,[s,trill])", + "snote(n155,[a,n],5,8:1,0,0,28.0,28.0,[s,grace])", + "snote(n187,[g,n],5,9:2,3/16,1/16,33.75,34.0,[s,stacc])", + ] + + for ml in snote_lines: + + for minor_version in (3,): + # assert that the information from the matchline + # is parsed correctly and results in an identical line + # to the input match line + mo = MatchSnoteV0.from_matchline( + ml, + version=Version(0, minor_version, 0), + ) + # print(mo.matchline, ml) + self.assertTrue(mo.matchline == ml) + + # assert that the data types of the match line are correct + self.assertTrue(mo.check_types()) + + def test_snote_lines_v0_5_0(self): + + snote_lines = [ + "snote(n211-1,[E,b],5,20:2,0,1/8,58.0,58.5,[staff1,trill])", + "snote(n218-1,[A,b],2,20:3,0,1/4,59.0,60.0,[staff2])", + "snote(n224-2,[A,b],5,36:3,0,1/8,107.0,107.5,[staff1])", + "snote(n256-2,[E,b],4,38:3,0,1/4,113.0,114.0,[staff2])", + ] + + for ml in snote_lines: + + for minor_version in (4, 5): + # assert that the information from the matchline + # is parsed correctly and results in an identical line + # to the input match line + mo = MatchSnoteV0.from_matchline( + ml, + version=Version(0, minor_version, 0), + ) + # print(mo.matchline, ml) + self.assertTrue(mo.matchline == ml) + + # assert that the data types of the match line are correct + self.assertTrue(mo.check_types()) + + def test_note_lines_v_0_3_0(self): + + note_lines = [ + "note(0,[A,n],2,500,684,684,41).", + "note(1,[C,#],3,704,798,798,48).", + "note(11,[E,n],6,1543,1562,1562,72).", + "note(25,[E,n],4,3763,4020,4020,40).", + "note(102,[F,#],4,13812,14740,14740,61).", + "note(194,[D,n],5,27214,27272,27272,69).", + ] + + for ml in note_lines: + # assert that the information from the matchline + # is parsed correctly and results in an identical line + # to the input match line + + for minor_version in range(3, 6): + mo = MatchNoteV0.from_matchline( + ml, version=Version(0, minor_version, 0) + ) + self.assertTrue(mo.matchline == ml) + + # assert that the data types of the match line are correct + self.assertTrue(mo.check_types()) + + def test_note_lines_v_0_1_0(self): + + # Lines taken from original version of + # Chopin Op. 38 in old Vienna4x22 + note_lines = [ + "note(1,[c,n],6,39060.00,39890.00,38).", + "note(6,[c,n],5,48840.00,49870.00,26).", + "note(17,[c,n],5,72600.00,75380.00,26).", + "note(32,[b,b],5,93030.00,95050.00,32).", + "note(85,[b,b],3,162600.00,164950.00,27).", + "note(132,[c,n],5,226690.00,227220.00,34).", + "note(179,[b,b],4,280360.00,282310.00,35).", + ] + + for ml in note_lines: + # assert that the information from the matchline + # is parsed correctly and results in an identical line + # to the input match line + + for minor_version in range(1, 3): + mo = MatchNoteV0.from_matchline( + ml, version=Version(0, minor_version, 0) + ) + self.assertTrue(mo.matchline == ml) + + # assert that the data types of the match line are correct + self.assertTrue(mo.check_types()) + + class TestMatchUtils(unittest.TestCase): """ Test utilities for handling match files From c9c7fd9ae7204e012cfc7a84e544014c58ef43ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Wed, 23 Nov 2022 07:34:17 +0100 Subject: [PATCH 38/88] fix addition of FractionalSymbolicDuration instances --- partitura/io/matchfile_base.py | 2 +- partitura/io/matchfile_utils.py | 15 ++++-- partitura/io/matchlines_v0.py | 2 +- tests/test_match_import_new.py | 86 +++++++++++++++++++++++++++++++-- 4 files changed, 96 insertions(+), 9 deletions(-) diff --git a/partitura/io/matchfile_base.py b/partitura/io/matchfile_base.py index 6fa0b73a..44d91476 100644 --- a/partitura/io/matchfile_base.py +++ b/partitura/io/matchfile_base.py @@ -150,7 +150,7 @@ def from_matchline( ------- a MatchLine instance """ - raise NotImplementedError + raise NotImplementedError # pragma: no cover def check_types(self, verbose: bool = False) -> bool: """ diff --git a/partitura/io/matchfile_utils.py b/partitura/io/matchfile_utils.py index 29229895..72b9b304 100644 --- a/partitura/io/matchfile_utils.py +++ b/partitura/io/matchfile_utils.py @@ -222,9 +222,9 @@ class FractionalSymbolicDuration(object): add_components : List[Tuple[int, int, Optional[int]]] (optional) additive components (to express durations like 1/4+1/16+1/32). The components are a list of tuples, each of which contains its own numerator, denominator - and tuple_div (or None). To represent the components 1/16+1/32 - in the example above, this variable would look like - `add_components = [(1, 16, None), (1, 32, None)]`. + and tuple_div (or None). To represent the components in the example above + this variable would look like + `add_components = [(1, 4, None), (1, 16, None), (1, 32, None)]`. """ def __init__( @@ -339,7 +339,14 @@ def __add__( if isinstance(sd, int): sd = FractionalSymbolicDuration(sd, 1) - dens = np.array([self.denominator, sd.denominator], dtype=int) + dens = np.array( + [ + self.denominator + * (self.tuple_div if self.tuple_div is not None else 1), + sd.denominator * (sd.tuple_div if sd.tuple_div is not None else 1), + ], + dtype=int, + ) new_den = np.lcm(dens[0], dens[1]) a_mult = new_den // dens new_num = np.dot(a_mult, [self.numerator, sd.numerator]) diff --git a/partitura/io/matchlines_v0.py b/partitura/io/matchlines_v0.py index 8347c21b..a9b2218d 100644 --- a/partitura/io/matchlines_v0.py +++ b/partitura/io/matchlines_v0.py @@ -80,7 +80,7 @@ "midiClockUnits": (interpret_as_int, format_int, int), "midiClockRate": (interpret_as_int, format_int, int), "approximateTempo": (interpret_as_float, format_float_unconstrained, float), - "subtitle": (interpret_as_string, format_string, str), + "subtitle": (interpret_as_list, format_list, str), "keySignature": (interpret_as_list, format_list, list), "timeSignature": ( interpret_as_fractional, diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index d2497ede..6179f074 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -22,7 +22,15 @@ MatchNote as MatchNoteV0, ) -from partitura.io.matchfile_base import interpret_version, Version, MatchError +from partitura.io.matchfile_base import MatchError + +from partitura.io.matchfile_utils import ( + FractionalSymbolicDuration, + Version, + interpret_version, +) + +RNG = np.random.RandomState(1984) class TestMatchLinesV1(unittest.TestCase): @@ -80,7 +88,7 @@ def test_info_lines(self): subtitle_line, ] - for ml in matchlines: + for i, ml in enumerate(matchlines): mo = MatchInfoV1.from_matchline(ml) # assert that the information from the matchline # is parsed correctly and results in an identical line @@ -88,7 +96,11 @@ def test_info_lines(self): self.assertTrue(mo.matchline == ml) # assert that the data types of the match line are correct - self.assertTrue(mo.check_types()) + if i == 0: + # Test verbose output + self.assertTrue(mo.check_types(verbose=True)) + else: + self.assertTrue(mo.check_types()) # The following lines should result in an error try: @@ -278,6 +290,14 @@ def test_snote_lines(self): # print(mo.matchline, ml) self.assertTrue(mo.matchline == ml) + # These notes were taken from a piece in 2/4 + # so the symbolic durations can be converted to beats + # by multiplying by 4 + + dur_from_symbolic = float(mo.Duration) * 4 + + self.assertTrue(np.isclose(dur_from_symbolic, mo.DurationInBeats)) + # assert that the data types of the match line are correct self.assertTrue(mo.check_types()) @@ -506,3 +526,63 @@ def test_interpret_version(self): self.assertTrue(False) except ValueError: self.assertTrue(True) + + def test_fractional_symbolic_duration(self): + + # Test string and float methods + numerators = RNG.randint(0, 1000, 100) + denominators = RNG.randint(2, 1000, 100) + tuple_divs = RNG.randint(0, 5, 100) + + for num, den, td in zip(numerators, denominators, tuple_divs): + + fsd = FractionalSymbolicDuration(num, den, td if td > 0 else None) + + if den > 1 and td > 0: + expected_string = f"{num}/{den}/{td}" + elif td == 0: + expected_string = f"{num}/{den}" + self.assertTrue(str(fsd) == expected_string) + self.assertTrue(float(fsd) == num / (den * (td if td > 0 else 1))) + + # Test bound + numerators = RNG.randint(0, 10, 100) + denominators = RNG.randint(1024, 2000, 100) + + for num, den in zip(numerators, denominators): + fsd = FractionalSymbolicDuration(num, den) + self.assertTrue(fsd.denominator <= 128) + + # Test addition + numerators1 = RNG.randint(1, 10, 100) + denominators1 = RNG.randint(2, 10, 100) + tuple_divs1 = RNG.randint(0, 5, 100) + + # Test string and float methods + numerators2 = RNG.randint(0, 10, 100) + denominators2 = RNG.randint(2, 10, 100) + tuple_divs2 = RNG.randint(0, 5, 100) + + for num1, den1, td1, num2, den2, td2 in zip( + numerators1, + denominators1, + tuple_divs1, + numerators2, + denominators2, + tuple_divs2, + ): + + fsd1 = FractionalSymbolicDuration(num1, den1, td1 if td1 > 0 else None) + fsd2 = FractionalSymbolicDuration(num2, den2, td2 if td2 > 0 else None) + + fsd3 = fsd1 + fsd2 + + if num1 > 0 and num2 > 0: + self.assertTrue(str(fsd3) == f"{str(fsd1)}+{str(fsd2)}") + elif num1 > 0: + self.assertTrue(str(fsd3) == str(fsd1)) + elif num2 > 0: + self.assertTrue(str(fsd3) == str(fsd2)) + + self.assertTrue(isinstance(fsd3, FractionalSymbolicDuration)) + self.assertTrue(np.isclose(float(fsd1) + float(fsd2), float(fsd3))) From de2202111773cff46d63791c18823fcdb1a527a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Wed, 23 Nov 2022 10:53:22 +0100 Subject: [PATCH 39/88] test edge cases in FractionalSymbolicDuration --- partitura/io/matchfile_utils.py | 33 +++++++++- partitura/io/matchlines_v0.py | 4 +- partitura/io/matchlines_v1.py | 3 + tests/test_match_import_new.py | 113 +++++++++++++++++++++++++++++--- 4 files changed, 140 insertions(+), 13 deletions(-) diff --git a/partitura/io/matchfile_utils.py b/partitura/io/matchfile_utils.py index 72b9b304..84ea804d 100644 --- a/partitura/io/matchfile_utils.py +++ b/partitura/io/matchfile_utils.py @@ -286,9 +286,14 @@ def bound_integers(self, bound: int) -> None: 96, 128, ] - sign = np.sign(self.numerator) * np.sign(self.denominator) + sign = ( + np.sign(self.numerator) + * np.sign(self.denominator) + * (np.sign(self.tuple_div) if self.tuple_div is not None else 1) + ) self.numerator = np.abs(self.numerator) self.denominator = np.abs(self.denominator) + self.tuple_div = np.abs(self.tuple_div) if self.tuple_div is not None else None if self.numerator > bound or self.denominator > bound: val = float(self.numerator / self.denominator) @@ -377,6 +382,32 @@ def __add__( add_components=add_components, ) + def __eq__(self, sd: FractionalSymbolicDuration) -> bool: + """ + Equal magic method + """ + is_equal = all( + [ + getattr(self, attr, None) == getattr(sd, attr, None) + for attr in ("numerator", "denominator", "tuple_div", "add_components") + ] + ) + + return is_equal + + def __ne__(self, sd: FractionalSymbolicDuration) -> bool: + """ + Not equal magic method + """ + not_equal = any( + [ + getattr(self, attr, None) != getattr(sd, attr, None) + for attr in ("numerator", "denominator", "tuple_div", "add_components") + ] + ) + + return not_equal + def __radd__( self, sd: Union[FractionalSymbolicDuration, int] ) -> FractionalSymbolicDuration: diff --git a/partitura/io/matchlines_v0.py b/partitura/io/matchlines_v0.py index a9b2218d..c631d7ff 100644 --- a/partitura/io/matchlines_v0.py +++ b/partitura/io/matchlines_v0.py @@ -120,7 +120,7 @@ def __init__( ) -> None: if version >= Version(1, 0, 0): - raise MatchError("The version must be < 1.0.0") + raise ValueError("The version must be < 1.0.0") super().__init__( version=version, @@ -157,7 +157,7 @@ def from_matchline( """ if version >= Version(1, 0, 0): - raise MatchError("The version must be < 1.0.0") + raise ValueError("The version must be < 1.0.0") match_pattern = cls.pattern.search(matchline, pos=pos) diff --git a/partitura/io/matchlines_v1.py b/partitura/io/matchlines_v1.py index 13fd3e06..b1e857bc 100644 --- a/partitura/io/matchlines_v1.py +++ b/partitura/io/matchlines_v1.py @@ -466,6 +466,9 @@ def __init__( offset_in_beats: float, score_attributes_list: List[str], ) -> None: + + if version < Version(1, 0, 0): + raise ValueError(f"{version} < Version(1, 0, 0)") super().__init__( version=version, anchor=anchor, diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index 6179f074..4688fa7d 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -108,7 +108,7 @@ def test_info_lines(self): notSpecified_line = "info(notSpecified,value)." mo = MatchInfoV1.from_matchline(notSpecified_line) - self.assertTrue(False) + self.assertTrue(False) # pragma: no cover except ValueError: # assert that the error was raised self.assertTrue(True) @@ -118,7 +118,7 @@ def test_info_lines(self): midiClockUnits_line = "info(midiClockUnits,wrong_value)." mo = MatchInfoV1.from_matchline(midiClockUnits_line) - self.assertTrue(False) + self.assertTrue(False) # pragma: no cover except ValueError: # assert that the error was raised self.assertTrue(True) @@ -127,7 +127,7 @@ def test_info_lines(self): # This is not a valid line and should result in a MatchError wrong_line = "wrong_line" mo = MatchInfoV1.from_matchline(wrong_line) - self.assertTrue(False) + self.assertTrue(False) # pragma: no cover except MatchError: self.assertTrue(True) @@ -162,7 +162,7 @@ def test_score_prop_lines(self): # This is not a valid line and should result in a MatchError wrong_line = "wrong_line" mo = MatchScorePropV1.from_matchline(wrong_line) - self.assertTrue(False) + self.assertTrue(False) # pragma: no cover except MatchError: self.assertTrue(True) @@ -191,7 +191,7 @@ def test_section_lines(self): mo = MatchSectionV1.from_matchline( section_lines[0], version=Version(0, 5, 0) ) - self.assertTrue(False) + self.assertTrue(False) # pragma: no cover except ValueError: self.assertTrue(True) @@ -201,7 +201,7 @@ def test_section_lines(self): # Line does not have [] for the end annotations wrong_line = "section(0.0000,100.0000,0.0000,100.0000,end)." mo = MatchSectionV1.from_matchline(wrong_line) - self.assertTrue(False) + self.assertTrue(False) # pragma: no cover except MatchError: self.assertTrue(True) @@ -298,6 +298,14 @@ def test_snote_lines(self): self.assertTrue(np.isclose(dur_from_symbolic, mo.DurationInBeats)) + # Test that the DurationSymbolic string produces the same duration + self.assertTrue( + FractionalSymbolicDuration.from_string(mo.DurationSymbolic) + == mo.Duration + ) + + self.assertTrue(mo.MidiPitch < 128) + # assert that the data types of the match line are correct self.assertTrue(mo.check_types()) @@ -305,10 +313,30 @@ def test_snote_lines(self): # This is not a valid line and should result in a MatchError wrong_line = "wrong_line" mo = MatchSnoteV1.from_matchline(wrong_line) - self.assertTrue(False) + self.assertTrue(False) # pragma: no cover except MatchError: self.assertTrue(True) + # Wrong version + try: + mo = MatchSnoteV1( + version=Version(0, 5, 0), + anchor="n0", + note_name="C", + modifier="n", + octave=4, + measure=1, + beat=0, + offset=FractionalSymbolicDuration(0), + duration=FractionalSymbolicDuration(1), + onset_in_beats=0, + offset_in_beats=1, + score_attributes_list=[], + ) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + def test_note_lines(self): note_lines = [ @@ -361,10 +389,30 @@ def test_snote_lines_v0_1_0(self): # This is not a valid line and should result in a MatchError wrong_line = "wrong_line" mo = MatchSnoteV0.from_matchline(wrong_line, version=Version(0, 1, 0)) - self.assertTrue(False) + self.assertTrue(False) # pragma: no cover except MatchError: self.assertTrue(True) + # Wrong version + try: + mo = MatchSnoteV0( + version=Version(1, 0, 0), + anchor="n0", + note_name="c", + modifier="n", + octave=4, + measure=1, + beat=0, + offset=FractionalSymbolicDuration(0), + duration=FractionalSymbolicDuration(1), + onset_in_beats=0, + offset_in_beats=1, + score_attributes_list=[], + ) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + def test_snote_lines_v0_3_0(self): snote_lines = [ @@ -425,6 +473,8 @@ def test_note_lines_v_0_3_0(self): "note(25,[E,n],4,3763,4020,4020,40).", "note(102,[F,#],4,13812,14740,14740,61).", "note(194,[D,n],5,27214,27272,27272,69).", + "note(n207,[F,n],5,20557,20635,20682,72).", + "note(n214,[G,n],5,21296,21543,21543,75).", ] for ml in note_lines: @@ -437,6 +487,8 @@ def test_note_lines_v_0_3_0(self): ml, version=Version(0, minor_version, 0) ) self.assertTrue(mo.matchline == ml) + # check duration and adjusted duration + self.assertTrue(mo.AdjDuration >= mo.Duration) # assert that the data types of the match line are correct self.assertTrue(mo.check_types()) @@ -523,13 +575,13 @@ def test_interpret_version(self): try: version = interpret_version(version_string) # The test should fail if the exception is not raised - self.assertTrue(False) + self.assertTrue(False) # pragma: no cover except ValueError: self.assertTrue(True) def test_fractional_symbolic_duration(self): - # Test string and float methods + # Test string and float methods and from_string methods numerators = RNG.randint(0, 1000, 100) denominators = RNG.randint(2, 1000, 100) tuple_divs = RNG.randint(0, 5, 100) @@ -542,9 +594,21 @@ def test_fractional_symbolic_duration(self): expected_string = f"{num}/{den}/{td}" elif td == 0: expected_string = f"{num}/{den}" + + fsd_from_string = FractionalSymbolicDuration.from_string(expected_string) + + self.assertTrue(fsd_from_string == fsd) self.assertTrue(str(fsd) == expected_string) self.assertTrue(float(fsd) == num / (den * (td if td > 0 else 1))) + # The following string should raise an error + wrong_string = "wrong_string" + try: + fsd = FractionalSymbolicDuration.from_string(wrong_string) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + # Test bound numerators = RNG.randint(0, 10, 100) denominators = RNG.randint(1024, 2000, 100) @@ -574,11 +638,40 @@ def test_fractional_symbolic_duration(self): fsd1 = FractionalSymbolicDuration(num1, den1, td1 if td1 > 0 else None) fsd2 = FractionalSymbolicDuration(num2, den2, td2 if td2 > 0 else None) + fsd3_from_radd = FractionalSymbolicDuration( + num1, den1, td1 if td1 > 0 else None + ) fsd3 = fsd1 + fsd2 + fsd3_from_radd += fsd2 + + self.assertTrue(fsd3 == fsd3_from_radd) if num1 > 0 and num2 > 0: self.assertTrue(str(fsd3) == f"{str(fsd1)}+{str(fsd2)}") + # Test allow_additions option in from_string method + fsd_from_string = FractionalSymbolicDuration.from_string( + str(fsd3), allow_additions=True + ) + self.assertTrue(fsd_from_string == fsd3) + # check addition when the two of them have add_components + fsd3_t_2 = fsd3 + fsd3_from_radd + self.assertTrue(2 * float(fsd3) == float(fsd3_t_2)) + + # check_addition when only one of them has add_components + fsd4 = fsd1 + fsd3 + + self.assertTrue(np.isclose(float(fsd4), float(fsd1) + float(fsd3))) + + fsd3_from_radd += fsd1 + self.assertTrue( + np.isclose(float(fsd3_from_radd), float(fsd1) + float(fsd3)) + ) + + # They must be different because the order of the + # additive components would be inverted + self.assertTrue(fsd3_from_radd != fsd4) + elif num1 > 0: self.assertTrue(str(fsd3) == str(fsd1)) elif num2 > 0: From 7c01afaeb99307f85e019ffcf5f54ee74cdb8d02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Wed, 23 Nov 2022 18:56:37 +0100 Subject: [PATCH 40/88] add SnoteNote lines --- partitura/io/importmatch_new.py | 386 +------------------------------- partitura/io/matchfile_base.py | 351 ++++++++++++++++++++++++++++- partitura/io/matchlines_v0.py | 37 ++- partitura/io/matchlines_v1.py | 35 +++ tests/test_match_import_new.py | 20 ++ 5 files changed, 440 insertions(+), 389 deletions(-) diff --git a/partitura/io/importmatch_new.py b/partitura/io/importmatch_new.py index d8c69e4b..cae00d7d 100644 --- a/partitura/io/importmatch_new.py +++ b/partitura/io/importmatch_new.py @@ -13,383 +13,6 @@ __all__ = ["load_match"] -# Define current version of the match file format -CURRENT_MAJOR_VERSION = 1 -CURRENT_MINOR_VERSION = 0 -CURRENT_PATCH_VERSION = 0 - -Version = namedtuple("Version", ["major", "minor", "patch"]) -VersionOld = namedtuple("Version", ["major", "minor"]) - - -CURRENT_VERSION = Version( - CURRENT_MAJOR_VERSION, - CURRENT_MINOR_VERSION, - CURRENT_PATCH_VERSION, -) - -# General patterns -rational_pattern = re.compile(r"^([0-9]+)/([0-9]+)$") -double_rational_pattern = re.compile(r"^([0-9]+)/([0-9]+)/([0-9]+)$") -version_pattern = re.compile(r"^([0-9]+)\.([0-9]+)\.([0-9]+)") - - -class MatchError(Exception): - pass - - -def interpret_version(version_string: str) -> Version: - """ - Parse matchfile format version from a string. This method - parses a string like "1.0.0" and returns a Version instance. - - Parameters - ---------- - version_string : str - The string containg the version. The version string should b - in the form "{major}.{minor}.{patch}". Incorrectly formatted strings - will result in an error. - - Returns - ------- - version : Version - A named tuple specifying the version - """ - version_info = version_pattern.search(version_string) - - if version_info is not None: - ma, mi, pa = version_info.groups() - version = Version(int(ma), int(mi), int(pa)) - - return version - else: - raise ValueError(f"The version '{version_string}' is incorrectly formatted!") - - -def format_version(version: Version) -> str: - """ - Format version as a string. - - Parameters - ---------- - version : Version - A Version instance. - - Returns - ------- - version_str : str - A string representation of the version. - """ - ma, mi, pa = version - - version_str = f"{ma}.{mi}.{pa}" - return version_str - - -def interpret_as_int(value: str) -> int: - """ - Interpret value as an integer - - Parameters - ---------- - value : str - The value to interpret as integer. - - Returns - ------- - int - The value cast as an integer. - """ - return int(value) - - -def format_int(value: int) -> str: - return f"{value}" - - -def interpret_as_float(value: str) -> float: - return float(value) - - -def format_float(value: float) -> str: - return f"{value:.4f}" - - -def interpret_as_string(value: str) -> str: - return value - - -def format_string(value: str) -> str: - """ - For completeness - """ - return value.strip() - - -class MatchLine(object): - """ - Main class representing a match line. - - This class should be subclassed for the different match lines. - """ - - version: Version - field_names: tuple - pattern: re.Pattern - out_pattern: str - line_dict: dict - - def __init__(self, version: Version, **kwargs) -> None: - # set version - self.version = version - # Get pattern - self.pattern = self.line_dict[self.version]["pattern"] - # Get field names - self.field_names = self.line_dict[self.version]["field_names"] - # Get out pattern - self.out_pattern = self.line_dict[self.version]["matchline"] - - # set field names - for field in self.field_names: - if field.lower() not in kwargs: - raise ValueError(f"{field.lower()} is not given in keyword arguments") - setattr(self, field, kwargs[field.lower()]) - - def __str__(self) -> str: - """ - Prints the printing the match line - """ - r = [self.__class__.__name__] - for fn in self.field_names: - r.append(" {0}: {1}".format(fn, self.__dict__[fn.lower()])) - return "\n".join(r) + "\n" - - @property - def matchline(self) -> str: - """ - Generate matchline as a string. - """ - matchline = self.out_pattern.format( - **dict( - [ - (field, self.format_fun[field](getattr(self, field))) - for field in self.field_names - ] - ) - ) - - return matchline - - @classmethod - def from_matchline( - cls, - matchline: str, - version: Version = CURRENT_MAJOR_VERSION, - ): - """ - Create a new MatchLine object from a string - - Parameters - ---------- - matchline : str - String with a matchline - version : Version - Version of the matchline - - Returns - ------- - a MatchLine instance - """ - raise NotImplementedError - - def check_types(self) -> bool: - """ - Check whether the values of the attributes are of the correct type. - """ - raise NotImplementedError - - -# Dictionary of interpreter, formatters and datatypes for version 1.0.0 -# each entry in the dictionary is a tuple with -# an intepreter (to parse the input), a formatter (for the output matchline) -# and type -INFO_LINE_INTERPRETERS_V_1_0_0 = { - "matchFileVersion": (interpret_version, format_version, Version), - "piece": (interpret_as_string, format_string, str), - "scoreFileName": (interpret_as_string, format_string, str), - "scoreFilePath": (interpret_as_string, format_string, str), - "midiFileName": (interpret_as_string, format_string, str), - "midiFilePath": (interpret_as_string, format_string, str), - "audioFileName": (interpret_as_string, format_string, str), - "audioFilePath": (interpret_as_string, format_string, str), - "audioFirstNote": (interpret_as_float, format_float, float), - "audioLastNote": (interpret_as_float, format_float, float), - "performer": (interpret_as_string, format_string, str), - "composer": (interpret_as_string, format_string, str), - "midiClockUnits": (interpret_as_int, format_int, int), - "midiClockRate": (interpret_as_int, format_int, int), - "approximateTempo": (interpret_as_float, format_float, float), - "subtitle": (interpret_as_string, format_string, str), -} - -# Dictionary containing the definition of all versions of the MatchInfo line -# starting from version 1.0.0 -INFO_LINE = { - Version(1, 0, 0): { - "pattern": re.compile( - # r"info\(\s*(?P[^,]+)\s*,\s*(?P.+)\s*\)\." - r"info\((?P[^,]+),(?P.+)\)\." - ), - "field_names": ("attribute", "value"), - "matchline": "info({attribute},{value}).", - "value": INFO_LINE_INTERPRETERS_V_1_0_0, - } -} - - -class MatchInfo(MatchLine): - """ - Main class specifying global information lines. - - For version 1.0.0, these lines have the general structure: - - `info(attribute,value).` - - Parameters - ---------- - version : Version - The version of the info line. - kwargs : keyword arguments - Keyword arguments specifying the type of line and its value. - """ - - line_dict = INFO_LINE - - def __init__(self, version: Version, **kwargs) -> None: - super().__init__(version, **kwargs) - - self.interpret_fun = self.line_dict[self.version]["value"][self.attribute][0] - self.value_type = self.line_dict[self.version]["value"][self.attribute][2] - self.format_fun = { - "attribute": format_string, - "value": self.line_dict[self.version]["value"][self.attribute][1], - } - - @property - def matchline(self) -> str: - matchline = self.out_pattern.format( - **dict( - [ - (field, self.format_fun[field](getattr(self, field))) - for field in self.field_names - ] - ) - ) - - return matchline - - @classmethod - def from_matchline( - cls, - matchline: str, - pos: int = 0, - version=CURRENT_VERSION, - ) -> MatchLine: - """ - Create a new MatchLine object from a string - - Parameters - ---------- - matchline : str - String with a matchline - pos : int (optional) - Position of the matchline in the input string. By default it is - assumed that the matchline starts at the beginning of the input - string. - version : Version (optional) - Version of the matchline. By default it is the latest version. - - Returns - ------- - a MatchLine instance - """ - class_dict = INFO_LINE[version] - - match_pattern = class_dict["pattern"].search(matchline, pos=pos) - - if match_pattern is not None: - attribute, value_str = match_pattern.groups() - if attribute not in class_dict["value"].keys(): - raise ValueError( - f"Attribute {attribute} is not specified in version {version}" - ) - - value = class_dict["value"][attribute][0](value_str) - - return cls(version=version, attribute=attribute, value=value) - - else: - raise MatchError("Input match line does not fit the expected pattern.") - - -SCOREPROP_LINE_INTERPRETERS_V_1_0_0 = { - "keySignature": (interpret_as_string, format_string, str), - "timeSignature": (interpret_as_string, format_string, str), -} - -SCOREPROP_LINE = { - Version(1, 0, 0): { - "pattern": re.compile( - r"scoreProp\(" - r"(?P[^,]+),(?P[^,]+)," - r"(?P[^,]+):(?P[^,]+)," - r"(?P[^,]+):(?P[^,]+)" - r"\)\." - ), - "field_names": ( - "attribute", - "value", - "measure", - "beat", - "offset", - "onset_in_beats", - ), - "matchline": "scoreProp({attribute},{value},{measure}:{beat},{offset},{onset_in_beats}).", - "value": SCOREPROP_LINE_INTERPRETERS_V_1_0_0, - } -} - - -class MatchScoreProp(MatchLine): - - line_dict = SCOREPROP_LINE - - def __init__(self, version: Version, **kwargs) -> None: - super().__init__(version, **kwargs) - - self.interpret_fun = self.line_dict[self.version]["value"] - - -class KeySignatureLine(MatchScoreProp): - def __init__( - self, - version: Version, - key_signature: str, - measure: int, - beat: int, - offset: Union[int, FractionalSymbolicDuration], - onset_in_beats: float, - ) -> None: - super().__init__( - version=version, - attribute="keySignature", - value=key_signature, - measure=measure, - beat=beat, - offset=offset, - onset_in_beats=onset_in_beats, - ) - def load_match(fn, create_part=False, pedal_threshold=64, first_note_at_zero=False): pass @@ -397,11 +20,4 @@ def load_match(fn, create_part=False, pedal_threshold=64, first_note_at_zero=Fal if __name__ == "__main__": - matchfile_version_line_str = "info(matchFileVersion,1.0.0)." - - matchfile_version_line = MatchInfo.from_matchline(matchfile_version_line_str) - - print(matchfile_version_line) - print(matchfile_version_line.matchline) - - assert matchfile_version_line.matchline == matchfile_version_line_str + pass diff --git a/partitura/io/matchfile_base.py b/partitura/io/matchfile_base.py index 44d91476..f4b0b4bb 100644 --- a/partitura/io/matchfile_base.py +++ b/partitura/io/matchfile_base.py @@ -6,7 +6,7 @@ """ from __future__ import annotations -from typing import Callable, Tuple, Any, Optional, Union, Dict, List +from typing import Callable, Tuple, Any, Optional, Union, Dict, List, Iterable import re import numpy as np @@ -92,11 +92,14 @@ class MatchLine(object): # A dictionary of callables for each field name # the callables should get the value of the input # and return a string formatted for the matchfile. - format_fun: Dict[str, Callable[Any, str]] + format_fun: Union[ + Dict[str, Callable[Any, str]], + Tuple[Dict[str, Callable[Any, str]]], + ] # Regular expression to parse # information from a string. - pattern = re.Pattern + pattern: Union[re.Pattern, Tuple[re.Pattern]] def __init__(self, version: Version) -> None: # Subclasses need to initialize the other @@ -461,3 +464,345 @@ def __init__( @property def Duration(self): return self.Offset - self.Onset + + +class BaseSnoteNoteLine(MatchLine): + + out_pattern = "{SnoteLine}-{NoteLine}" + + def __init__( + self, + version: Version, + snote: BaseSnoteLine, + note: BaseNoteLine, + ) -> None: + + super().__init__(version) + + self.snote = snote + self.note = note + + self.field_names = self.snote.field_names + self.note.field_names + + self.field_types = self.snote.field_types + self.snote.field_types + + self.pattern = (self.snote.pattern, self.note.pattern) + + self.format_fun = (self.snote.format_fun, self.note.format_fun) + + @property + def matchline(self) -> str: + return self.out_pattern.format( + SnoteLine=self.snote.matchline, NoteLine=self.note.matchline + ) + + def __str__(self) -> str: + return str(self.snote) + "\n" + str(self.note) + + def check_types(self, verbose: bool = False) -> bool: + """ + Check whether the values of the fields are of the correct type. + + Parameters + ---------- + verbose : bool + Prints whether each of the attributes in field_names has the correct dtype. + values are + + Returns + ------- + types_are_correct : bool + True if the values of all fields in the match line have the + correct type. + """ + snote_types_are_correct = self.snote.check_types(verbose) + note_types_are_correct = self.note.check_types(verbose) + + types_are_correct = snote_types_are_correct and note_types_are_correct + + return types_are_correct + + @classmethod + def prepare_kwargs_from_matchline( + cls, + matchline: str, + snote_class: BaseSnoteLine, + note_class: BaseNoteLine, + version: Version, + ) -> Dict: + snote = snote_class.from_matchline(matchline, version=version) + note = note_class.from_matchline(matchline, version=version) + + kwargs = dict( + version=version, + snote=snote, + note=note, + ) + + return kwargs + + +snote_classes = (BaseSnoteLine, BaseSnoteNoteLine) +note_classes = (BaseNoteLine, BaseSnoteNoteLine) + + +class MatchFile(object): + """ + Class for representing MatchFiles + """ + + version: Version + lines: np.ndarray + + def __init__(self, lines: Iterable[MatchLine]) -> None: + + # check that all lines have the same version + same_version = all([line.version == lines[0].version for line in lines]) + + if not same_version: + raise ValueError("All lines should have the same version") + + self.lines = np.array(lines) + + @property + def note_pairs(self): + """ + Return all(snote, note) tuples + + """ + return [ + (x.snote, x.note) for x in self.lines if isinstance(x, BaseSnoteNoteLine) + ] + + @property + def notes(self): + """ + Return all performed notes (as MatchNote objects) + """ + return [x.note for x in self.lines if isinstance(x, note_classes)] + + def iter_notes(self): + """ + Iterate over all performed notes (as MatchNote objects) + """ + for x in self.lines: + if isinstance(x, note_classes): + # if hasattr(x, "note"): + yield x.note + + @property + def snotes(self): + """ + Return all score notes (as MatchSnote objects) + """ + return [x.snote for x in self.lines if isinstance(x, snote_classes)] + # return [x.snote for x in self.lines if hasattr(x, "snote")] + + def iter_snotes(self): + """ + Iterate over all performed notes (as MatchNote objects) + """ + for x in self.lines: + if hasattr(x, "snote"): + yield x.snote + + @property + def sustain_pedal(self): + return [line for line in self.lines if isinstance(line, MatchSustainPedal)] + + @property + def insertions(self): + return [x.note for x in self.lines if isinstance(x, MatchInsertionNote)] + + @property + def deletions(self): + return [x.snote for x in self.lines if isinstance(x, MatchSnoteDeletion)] + + @property + def _info(self): + """ + Return all InfoLine objects + + """ + return [i for i in self.lines if isinstance(i, BaseInfoLine)] + + def info(self, attribute=None): + """ + Return the value of the MatchInfo object corresponding to + attribute, or None if there is no such object + + : param attribute: the name of the attribute to return the value for + + """ + if attribute: + try: + idx = [i.Attribute for i in self._info].index(attribute) + return self._info[idx].Value + except ValueError: + return None + else: + return self._info + + @property + def first_onset(self): + return min([n.OnsetInBeats for n in self.snotes]) + + @property + def first_bar(self): + return min([n.Bar for n in self.snotes]) + + @property + def time_signatures(self): + """ + A list of tuples(t, b, (n, d)), indicating a time signature of + n over v, starting at t in bar b + + """ + tspat = re.compile("([0-9]+)/([0-9]*)") + m = [(int(x[0]), int(x[1])) for x in tspat.findall(self.info("timeSignature"))] + _timeSigs = [] + if len(m) > 0: + + _timeSigs.append((self.first_onset, self.first_bar, m[0])) + for line in self.time_sig_lines: + + _timeSigs.append( + ( + float(line.TimeInBeats), + int(line.Bar), + [(int(x[0]), int(x[1])) for x in tspat.findall(line.Value)][0], + ) + ) + _timeSigs = list(set(_timeSigs)) + _timeSigs.sort(key=lambda x: [x[0], x[1]]) + + # ensure that all consecutive time signatures are different + timeSigs = [_timeSigs[0]] + + for ts in _timeSigs: + ts_on, bar, (ts_num, ts_den) = ts + ts_on_prev, ts_bar_prev, (ts_num_prev, ts_den_prev) = timeSigs[-1] + if ts_num != ts_num_prev or ts_den != ts_den_prev: + timeSigs.append(ts) + + return timeSigs + + def _time_sig_lines(self): + return [ + i + for i in self.lines + if isinstance(i, MatchMeta) + and hasattr(i, "Attribute") + and i.Attribute == "timeSignature" + ] + + @property + def time_sig_lines(self): + ml = self._time_sig_lines() + if len(ml) == 0: + ts = self.info("timeSignature") + ml = [ + parse_matchline( + "meta(timeSignature,{0},{1},{2}).".format( + ts, self.first_bar, self.first_onset + ) + ) + ] + return ml + + @property + def key_signatures(self): + """ + A list of tuples (t, b, (f,m)) or (t, b, (f1, m1, f2, m2)) + """ + kspat = re.compile( + ( + "(?P[A-G])(?P[#b]*) " + "(?P[a-zA-z]+)(?P/*)" + "(?P[A-G]*)(?P[#b]*)" + "(?P *)(?P[a-zA-z]*)" + ) + ) + + _keysigs = [] + for ksl in self.key_sig_lines: + if isinstance(ksl, MatchInfo): + # remove brackets and only take + # the first key signature + ks = ksl.Value.replace("[", "").replace("]", "").split(",")[0] + t = self.first_onset + b = self.first_bar + elif isinstance(ksl, MatchMeta): + ks = ksl.Value + t = ksl.TimeInBeats + b = ksl.Bar + + ks_info = kspat.search(ks) + + keysig = ( + self._format_key_signature( + step=ks_info.group("step1"), + alter_sign=ks_info.group("alter1"), + mode=ks_info.group("mode1"), + ), + ) + + if ks_info.group("step2") != "": + keysig += ( + self._format_key_signature( + step=ks_info.group("step2"), + alter_sign=ks_info.group("alter2"), + mode=ks_info.group("mode2"), + ), + ) + + _keysigs.append((t, b, keysig)) + + keysigs = [] + if len(_keysigs) > 0: + keysigs.append(_keysigs[0]) + + for k in _keysigs: + if k[2] != keysigs[-1][2]: + keysigs.append(k) + + return keysigs + + def _format_key_signature(self, step, alter_sign, mode): + + if mode.lower() in ("maj", "", "major"): + mode = "" + elif mode.lower() in ("min", "m", "minor"): + mode = "m" + else: + raise ValueError( + 'Invalid mode. Expected "major" or "minor" but got {0}'.format(mode) + ) + + return step + alter_sign + mode + + @property + def key_sig_lines(self): + + ks_info = [line for line in self.info() if line.Attribute == "keySignature"] + ml = ks_info + [ + i + for i in self.lines + if isinstance(i, MatchMeta) + and hasattr(i, "Attribute") + and i.Attribute == "keySignature" + ] + + return ml + + def write(self, filename): + with open(filename, "w") as f: + for line in self.lines: + f.write(line.matchline + "\n") + + @classmethod + def from_lines(cls, lines, name=""): + matchfile = cls(None) + matchfile.lines = np.array(lines) + matchfile.name = name + return matchfile diff --git a/partitura/io/matchlines_v0.py b/partitura/io/matchlines_v0.py index c631d7ff..e34362d2 100644 --- a/partitura/io/matchlines_v0.py +++ b/partitura/io/matchlines_v0.py @@ -17,6 +17,7 @@ BaseInfoLine, BaseSnoteLine, BaseNoteLine, + BaseSnoteNoteLine, ) from partitura.io.matchfile_utils import ( @@ -451,7 +452,7 @@ def __init__( self.AdjOffset = kwargs["adj_offset"] @property - def AdjDuration(self): + def AdjDuration(self) -> float: return self.AdjOffset - self.Onset @classmethod @@ -478,3 +479,37 @@ def from_matchline( else: raise MatchError("Input match line does not fit the expected pattern.") + + +class MatchSnoteNote(BaseSnoteNoteLine): + def __init__( + self, + version: Version, + snote: MatchSnote, + note: MatchNote, + ) -> None: + + super().__init__( + version=version, + snote=snote, + note=note, + ) + + @classmethod + def from_matchline( + cls, + matchline: str, + version: Version = LAST_VERSION, + ) -> MatchSnoteNote: + + if version < Version(1, 0, 0): + ValueError(f"{version} >= Version(1, 0, 0)") + + kwargs = cls.prepare_kwargs_from_matchline( + matchline=matchline, + snote_class=MatchSnote, + note_class=MatchNote, + version=version, + ) + + return cls(**kwargs) diff --git a/partitura/io/matchlines_v1.py b/partitura/io/matchlines_v1.py index b1e857bc..88bed47f 100644 --- a/partitura/io/matchlines_v1.py +++ b/partitura/io/matchlines_v1.py @@ -21,6 +21,7 @@ BaseInfoLine, BaseSnoteLine, BaseNoteLine, + BaseSnoteNoteLine, ) from partitura.io.matchfile_utils import ( @@ -620,3 +621,37 @@ def from_matchline( else: raise MatchError("Input match line does not fit the expected pattern.") + + +class MatchSnoteNote(BaseSnoteNoteLine): + def __init__( + self, + version: Version, + snote: BaseSnoteLine, + note: BaseNoteLine, + ) -> None: + + super().__init__( + version=version, + snote=snote, + note=note, + ) + + @classmethod + def from_matchline( + cls, + matchline: str, + version: Version = LATEST_VERSION, + ) -> MatchSnoteNote: + + if version < Version(1, 0, 0): + ValueError(f"{version} < Version(1, 0, 0)") + + kwargs = cls.prepare_kwargs_from_matchline( + matchline=matchline, + snote_class=MatchSnote, + note_class=MatchNote, + version=version, + ) + + return cls(**kwargs) diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index 4688fa7d..a4198de5 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -14,6 +14,7 @@ MatchSection as MatchSectionV1, MatchSnote as MatchSnoteV1, MatchNote as MatchNoteV1, + MatchSnoteNote as MatchSnoteNoteV1, ) from partitura.io.matchlines_v0 import ( @@ -357,6 +358,25 @@ def test_note_lines(self): # assert that the data types of the match line are correct self.assertTrue(mo.check_types()) + def test_snotenote_lines(self): + + snotenote_lines = [ + "snote(n1,[B,n],3,0:2,1/8,1/8,-0.5000,0.0000,[1])-note(0,47,39940,42140,44,0,0).", + ] + + for i, ml in enumerate(snotenote_lines): + # snote = MatchSnoteV1.from_matchline(ml) + # note = MatchNoteV1.from_matchline(ml) + + mo = MatchSnoteNoteV1.from_matchline(ml, version=Version(1, 0, 0)) + + self.assertTrue(mo.matchline == ml) + + if i == 0: + self.assertTrue(mo.check_types(verbose=True)) + else: + self.assertTrue(mo.check_types()) + class TestMatchLinesV0(unittest.TestCase): def test_snote_lines_v0_1_0(self): From 592a0561e7c75e52311656ae30fa5e5be464750b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Wed, 23 Nov 2022 19:27:40 +0100 Subject: [PATCH 41/88] test snote note lines --- partitura/io/matchlines_v0.py | 46 +++++++++++-- tests/test_match_import_new.py | 118 ++++++++++++++++++++++++++++++++- 2 files changed, 158 insertions(+), 6 deletions(-) diff --git a/partitura/io/matchlines_v0.py b/partitura/io/matchlines_v0.py index e34362d2..542ffcdd 100644 --- a/partitura/io/matchlines_v0.py +++ b/partitura/io/matchlines_v0.py @@ -310,7 +310,7 @@ def from_matchline( # Note lines for versions larger than 3.0 -NOTE_LINE_Vgeq0_3_0 = { +NOTE_LINE_Vge0_3_0 = { "field_names": ( "Id", "NoteName", @@ -388,9 +388,47 @@ def from_matchline( NOTE_LINE = { - Version(0, 5, 0): NOTE_LINE_Vgeq0_3_0, - Version(0, 4, 0): NOTE_LINE_Vgeq0_3_0, - Version(0, 3, 0): NOTE_LINE_Vgeq0_3_0, + Version(0, 5, 0): NOTE_LINE_Vge0_3_0, + Version(0, 4, 0): NOTE_LINE_Vge0_3_0, + Version(0, 3, 0): { + "field_names": ( + "Id", + "NoteName", + "Modifier", + "Octave", + "Onset", + "Offset", + "AdjOffset", + "Velocity", + ), + "out_pattern": ( + "note({Id},[{NoteName},{Modifier}],{Octave},{Onset},{Offset}," + "{AdjOffset},{Velocity})." + ), + "pattern": re.compile( + r"note\((?P[^,]+)," + r"\[(?P[^,]+),(?P[^,]+)\]," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)\)" + ), + "field_interpreters": { + "Id": (interpret_as_string, format_string, str), + "NoteName": (interpret_as_string, lambda x: str(x).lower(), str), + "Modifier": ( + interpret_as_string, + lambda x: "n" if x == 0 else ALTER_SIGNS[x], + (int, type(None)), + ), + "Octave": (interpret_as_int, format_int, int), + "Onset": (interpret_as_int, format_int, int), + "Offset": (interpret_as_int, format_int, int), + "AdjOffset": (interpret_as_int, format_int, int), + "Velocity": (interpret_as_int, format_int, int), + }, + }, Version(0, 2, 0): NOTE_LINE_Vlt0_3_0, Version(0, 1, 0): NOTE_LINE_Vlt0_3_0, } diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index a4198de5..2c19562f 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -21,6 +21,7 @@ MatchInfo as MatchInfoV0, MatchSnote as MatchSnoteV0, MatchNote as MatchNoteV0, + MatchSnoteNote as MatchSnoteNoteV0, ) from partitura.io.matchfile_base import MatchError @@ -377,6 +378,13 @@ def test_snotenote_lines(self): else: self.assertTrue(mo.check_types()) + # An error is raised if parsing the wrong version + try: + mo = MatchSnoteNoteV1.from_matchline(ml, version=Version(0, 5, 0)) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + class TestMatchLinesV0(unittest.TestCase): def test_snote_lines_v0_1_0(self): @@ -484,7 +492,7 @@ def test_snote_lines_v0_5_0(self): # assert that the data types of the match line are correct self.assertTrue(mo.check_types()) - def test_note_lines_v_0_3_0(self): + def test_note_lines_v_0_4_0(self): note_lines = [ "note(0,[A,n],2,500,684,684,41).", @@ -502,7 +510,34 @@ def test_note_lines_v_0_3_0(self): # is parsed correctly and results in an identical line # to the input match line - for minor_version in range(3, 6): + for minor_version in (4, 5): + mo = MatchNoteV0.from_matchline( + ml, version=Version(0, minor_version, 0) + ) + self.assertTrue(mo.matchline == ml) + # check duration and adjusted duration + self.assertTrue(mo.AdjDuration >= mo.Duration) + + # assert that the data types of the match line are correct + self.assertTrue(mo.check_types()) + + def test_note_lines_v_0_3_0(self): + + note_lines = [ + "note(14,[d,n],5,2239,2355,2355,76).", + "note(16,[e,n],5,2457,2564,2564,79).", + "note(29,[c,n],5,3871,3908,3908,62).", + "note(71,[c,n],5,7942,7983,7983,66).", + "note(98,[c,#],5,9927,10298,10352,78).", + "note(964,[a,#],5,91792,91835,91835,69).", + ] + + for ml in note_lines: + # assert that the information from the matchline + # is parsed correctly and results in an identical line + # to the input match line + + for minor_version in (3,): mo = MatchNoteV0.from_matchline( ml, version=Version(0, minor_version, 0) ) @@ -541,6 +576,85 @@ def test_note_lines_v_0_1_0(self): # assert that the data types of the match line are correct self.assertTrue(mo.check_types()) + def test_snotenote_lines_v_0_1_0(self): + + snotenote_lines = [ + "snote(n1,[c,n],6,0:3,0/1,1/8,-4.00000,-3.00000,[1])-note(1,[c,n],6,39060.00,39890.00,38).", + "snote(n2,[c,n],5,0:3,0/1,1/8,-4.00000,-3.00000,[4])-note(2,[c,n],5,39120.00,40240.00,34).", + "snote(n3,[c,n],6,0:4,0/1,2/8,-3.00000,-1.00000,[1])-note(3,[c,n],6,42580.00,44410.00,37).", + "snote(n4,[c,n],5,0:4,0/1,2/8,-3.00000,-1.00000,[4])-note(4,[c,n],5,42700.00,44250.00,31).", + "snote(n661,[b,b],4,41:4,0/1,2/8,243.00000,245.00000,[3])-note(661,[b,b],4,943540.00,945410.00,19).", + "snote(n662,[c,n],4,41:4,0/1,3/8,243.00000,246.00000,[4])-note(662,[c,n],4,943630.00,945900.00,26).", + "snote(n663,[c,n],3,41:4,0/1,2/8,243.00000,245.00000,[5])-note(663,[c,n],3,943380.00,951590.00,28).", + "snote(n664,[e,n],5,41:6,0/1,1/8,245.00000,246.00000,[2])-note(664,[e,n],5,950010.00,950840.00,33).", + "snote(n665,[b,b],4,41:6,0/1,1/8,245.00000,246.00000,[3])-note(665,[b,b],4,950130.00,951570.00,28).", + ] + + for i, ml in enumerate(snotenote_lines): + # snote = MatchSnoteV1.from_matchline(ml) + # note = MatchNoteV1.from_matchline(ml) + + for minor_version in (1, 2): + mo = MatchSnoteNoteV0.from_matchline( + ml, version=Version(0, minor_version, 0) + ) + + self.assertTrue(mo.matchline == ml) + + self.assertTrue(mo.check_types()) + + # An error is raised if parsing the wrong version + try: + mo = MatchSnoteNoteV0.from_matchline(ml, version=Version(1, 0, 0)) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + + def test_snotenote_lines_v_0_3_0(self): + + snotenote_lines = [ + "snote(n1,[e,n],4,1:1,0,1/4,0.0,1.0,[arp])-note(1,[e,n],4,761,1351,1351,60).", + "snote(n2,[g,n],4,1:1,0,1/4,0.0,1.0,[arp])-note(2,[g,n],4,814,1332,1332,74).", + "snote(n3,[c,n],3,1:1,0,1/16,0.0,0.25,[])-note(3,[c,n],3,885,943,943,65).", + "snote(n4,[c,n],5,1:1,0,1/4,0.0,1.0,[s,arp])-note(4,[c,n],5,886,1358,1358,85).", + "snote(n5,[c,n],4,1:1,1/16,1/16,0.25,0.5,[])-note(5,[c,n],4,1028,1182,1182,67).", + "snote(n6,[b,n],3,1:1,2/16,1/16,0.5,0.75,[])-note(6,[b,n],3,1151,1199,1199,63).", + "snote(n7,[c,n],4,1:1,3/16,1/16,0.75,1.0,[])-note(7,[c,n],4,1276,1325,1325,56).", + "snote(n8,[c,n],3,1:2,0,1/8,1.0,1.5,[])-note(8,[c,n],3,1400,1611,1700,62).", + ] + + for i, ml in enumerate(snotenote_lines): + + for minor_version in (3,): + mo = MatchSnoteNoteV0.from_matchline( + ml, version=Version(0, minor_version, 0) + ) + self.assertTrue(mo.matchline == ml) + + self.assertTrue(mo.check_types()) + + def test_snote_lines_v_0_4_0(self): + + snotenote_lines = [ + "snote(n1-1,[A,b],4,1:1,0,1/4,0.0,1.0,[staff1,accent])-note(0,[G,#],4,388411,388465,388465,65).", + "snote(n2-1,[G,n],4,1:2,0,1/8,1.0,1.5,[staff1])-note(1,[G,n],4,389336,389595,389595,35).", + "snote(n3-1,[A,b],4,1:2,1/8,1/8,1.5,2.0,[staff1])-note(2,[G,#],4,389628,389822,389822,34).", + "snote(n4-1,[C,n],5,1:3,0,1/8,2.0,2.5,[staff1])-note(3,[C,n],5,389804,389911,389911,44).", + "snote(n5-1,[B,b],4,1:3,1/8,1/8,2.5,3.0,[staff1])-note(4,[A,#],4,389932,389999,389999,50).", + "snote(n7-1,[G,n],4,2:1,0,1/8,3.0,3.5,[staff1])-note(5,[G,n],4,390054,390109,390109,49).", + "snote(n8-1,[A,b],4,2:1,1/8,1/8,3.5,4.0,[staff1])-note(6,[G,#],4,390168,390222,390222,46).", + ] + + for i, ml in enumerate(snotenote_lines): + + for minor_version in (4, 5): + mo = MatchSnoteNoteV0.from_matchline( + ml, version=Version(0, minor_version, 0) + ) + self.assertTrue(mo.matchline == ml) + + self.assertTrue(mo.check_types()) + class TestMatchUtils(unittest.TestCase): """ From 7682f7038d2e08388fb00f3be44bd21fc8e007d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Wed, 23 Nov 2022 22:53:56 +0100 Subject: [PATCH 42/88] test insertion lines and info lines for old versions --- partitura/io/matchfile_base.py | 72 ++++++++++++++- partitura/io/matchfile_utils.py | 35 ++++++- partitura/io/matchlines_v0.py | 82 ++++++++++++----- partitura/io/matchlines_v1.py | 34 ++++++- tests/test_match_import_new.py | 156 +++++++++++++++++++++++++++++--- 5 files changed, 334 insertions(+), 45 deletions(-) diff --git a/partitura/io/matchfile_base.py b/partitura/io/matchfile_base.py index f4b0b4bb..859b64b9 100644 --- a/partitura/io/matchfile_base.py +++ b/partitura/io/matchfile_base.py @@ -280,7 +280,7 @@ class BaseSnoteLine(MatchLine): str, str, (int, type(None)), - int, + (int, type(None)), int, int, FractionalSymbolicDuration, @@ -484,20 +484,37 @@ def __init__( self.field_names = self.snote.field_names + self.note.field_names - self.field_types = self.snote.field_types + self.snote.field_types + self.field_types = self.snote.field_types + self.note.field_types self.pattern = (self.snote.pattern, self.note.pattern) + # self.pattern = re.compile(f"{self.snote.pattern.pattern}-{self.note.pattern.pattern}") self.format_fun = (self.snote.format_fun, self.note.format_fun) @property def matchline(self) -> str: return self.out_pattern.format( - SnoteLine=self.snote.matchline, NoteLine=self.note.matchline + SnoteLine=self.snote.matchline, + NoteLine=self.note.matchline, ) def __str__(self) -> str: - return str(self.snote) + "\n" + str(self.note) + + """ + Prints the printing the match line + """ + r = [self.__class__.__name__] + r += [" Snote"] + [ + " {0}: {1}".format(fn, getattr(self.snote, fn, None)) + for fn in self.snote.field_names + ] + + r += [" Note"] + [ + " {0}: {1}".format(fn, getattr(self.note, fn, None)) + for fn in self.note.field_names + ] + + return "\n".join(r) + "\n" def check_types(self, verbose: bool = False) -> bool: """ @@ -542,8 +559,53 @@ def prepare_kwargs_from_matchline( return kwargs +class BaseInsertionLine(MatchLine): + + out_pattern = "insertion-{NoteLine}" + + def __init__(self, version: Version, note: BaseNoteLine) -> None: + + super().__init__(version) + + self.note = note + + self.field_names = self.note.field_names + + self.field_types = self.note.field_types + + self.pattern = re.compile(f"insertion-{self.note.pattern.pattern}") + + self.format_fun = self.note.format_fun + + for fn in self.field_names: + setattr(self, fn, getattr(self.note, fn)) + + @property + def matchline(self) -> str: + return self.out_pattern.format( + NoteLine=self.note.matchline, + ) + + @classmethod + def prepare_kwargs_from_matchline( + cls, + matchline: str, + note_class: BaseNoteLine, + version: Version, + ) -> Dict: + + note = note_class.from_matchline(matchline, version=version) + + kwargs = dict( + version=version, + note=note, + ) + + return kwargs + + snote_classes = (BaseSnoteLine, BaseSnoteNoteLine) -note_classes = (BaseNoteLine, BaseSnoteNoteLine) +note_classes = (BaseNoteLine, BaseSnoteNoteLine, BaseInsertionLine) class MatchFile(object): diff --git a/partitura/io/matchfile_utils.py b/partitura/io/matchfile_utils.py index 84ea804d..ee2ac064 100644 --- a/partitura/io/matchfile_utils.py +++ b/partitura/io/matchfile_utils.py @@ -12,6 +12,10 @@ from collections import namedtuple +from partitura.utils.music import ( + ALTER_SIGNS, +) + Version = namedtuple("Version", ["major", "minor", "patch"]) # General patterns @@ -103,7 +107,7 @@ def interpret_as_int(value: str) -> int: return int(value) -def format_int(value: int) -> str: +def format_int(value: Optional[int]) -> str: """ Format a string from an integer @@ -117,7 +121,7 @@ def format_int(value: int) -> str: str The value formatted as a string. """ - return f"{value}" + return f"{value}" if value is not None else "-" def interpret_as_float(value: str) -> float: @@ -188,6 +192,14 @@ def interpret_as_string(value: Any) -> str: return str(value) +old_string_pat = re.compile(r"'(?P.+)'") + + +def interpret_as_string_old(value: str) -> str: + val = old_string_pat.match(value) + return val.group("value") + + def format_string(value: str) -> str: """ Format a string as a string (for completeness ;). @@ -205,6 +217,10 @@ def format_string(value: str) -> str: return value.strip() +def format_string_old(value: str) -> str: + return f"'{value.strip()}'" + + class FractionalSymbolicDuration(object): """ A class to represent symbolic duration information. @@ -521,6 +537,21 @@ def format_list(value: List[Any]) -> str: return formatted_string +def format_accidental(value: Optional[int]) -> str: + + alter = "n" if value == 0 else ALTER_SIGNS[value] + + return alter + + +def format_accidental_old(value: Optional[int]) -> str: + + if value is None: + return "-" + else: + return format_accidental(value) + + ## Miscellaneous utils diff --git a/partitura/io/matchlines_v0.py b/partitura/io/matchlines_v0.py index 542ffcdd..b948330e 100644 --- a/partitura/io/matchlines_v0.py +++ b/partitura/io/matchlines_v0.py @@ -18,6 +18,7 @@ BaseSnoteLine, BaseNoteLine, BaseSnoteNoteLine, + BaseInsertionLine, ) from partitura.io.matchfile_utils import ( @@ -25,7 +26,9 @@ interpret_version, format_version, interpret_as_string, + interpret_as_string_old, format_string, + format_string_old, interpret_as_float, format_float, format_float_unconstrained, @@ -37,6 +40,7 @@ interpret_as_fractional, interpret_as_list, format_list, + format_accidental_old, get_kwargs_from_matchline, ) @@ -67,21 +71,21 @@ default_infoline_attributes = { "matchFileVersion": (interpret_version, format_version, Version), - "piece": (interpret_as_string, format_string, str), - "scoreFileName": (interpret_as_string, format_string, str), - "scoreFilePath": (interpret_as_string, format_string, str), - "midiFileName": (interpret_as_string, format_string, str), - "midiFilePath": (interpret_as_string, format_string, str), - "audioFileName": (interpret_as_string, format_string, str), - "audioFilePath": (interpret_as_string, format_string, str), + "piece": (interpret_as_string_old, format_string_old, str), + "scoreFileName": (interpret_as_string_old, format_string_old, str), + "scoreFilePath": (interpret_as_string_old, format_string_old, str), + "midiFileName": (interpret_as_string_old, format_string_old, str), + "midiFilePath": (interpret_as_string_old, format_string_old, str), + "audioFileName": (interpret_as_string_old, format_string_old, str), + "audioFilePath": (interpret_as_string_old, format_string_old, str), "audioFirstNote": (interpret_as_float, format_float_unconstrained, float), "audioLastNote": (interpret_as_float, format_float_unconstrained, float), - "performer": (interpret_as_string, format_string, str), - "composer": (interpret_as_string, format_string, str), + "performer": (interpret_as_string_old, format_string_old, str), + "composer": (interpret_as_string_old, format_string_old, str), "midiClockUnits": (interpret_as_int, format_int, int), "midiClockRate": (interpret_as_int, format_int, int), "approximateTempo": (interpret_as_float, format_float_unconstrained, float), - "subtitle": (interpret_as_list, format_list, str), + "subtitle": (interpret_as_list, format_list, list), "keySignature": (interpret_as_list, format_list, list), "timeSignature": ( interpret_as_fractional, @@ -89,7 +93,8 @@ FractionalSymbolicDuration, ), "tempoIndication": (interpret_as_list, format_list, list), - "beatSubDivision": (interpret_as_list, format_list, list), + # "beatSubDivision": (interpret_as_list, format_list, list), + "beatSubdivision": (interpret_as_list, format_list, list), } INFO_LINE = defaultdict(lambda: default_infoline_attributes.copy()) @@ -188,7 +193,7 @@ def from_matchline( SNOTE_LINE_Vgeq0_4_0 = dict( Anchor=format_string, NoteName=lambda x: str(x).upper(), - Modifier=lambda x: "n" if x == 0 else ALTER_SIGNS[x], + Modifier=format_accidental_old, Octave=format_int, Measure=format_int, Beat=format_int, @@ -202,7 +207,7 @@ def from_matchline( SNOTE_LINE_Vlt0_3_0 = dict( Anchor=format_string, NoteName=lambda x: str(x).lower(), - Modifier=lambda x: "n" if x == 0 else ALTER_SIGNS[x], + Modifier=format_accidental_old, Octave=format_int, Measure=format_int, Beat=format_int, @@ -219,7 +224,7 @@ def from_matchline( Version(0, 3, 0): dict( Anchor=format_string, NoteName=lambda x: str(x).lower(), - Modifier=lambda x: "n" if x == 0 else ALTER_SIGNS[x], + Modifier=format_accidental_old, Octave=format_int, Measure=format_int, Beat=format_int, @@ -339,10 +344,10 @@ def from_matchline( "NoteName": (interpret_as_string, lambda x: str(x).upper(), str), "Modifier": ( interpret_as_string, - lambda x: "n" if x == 0 else ALTER_SIGNS[x], + format_accidental_old, (int, type(None)), ), - "Octave": (interpret_as_int, format_int, int), + "Octave": (interpret_as_int, format_int, (int, type(None))), "Onset": (interpret_as_int, format_int, int), "Offset": (interpret_as_int, format_int, int), "AdjOffset": (interpret_as_int, format_int, int), @@ -376,10 +381,10 @@ def from_matchline( "NoteName": (interpret_as_string, lambda x: str(x).lower(), str), "Modifier": ( interpret_as_string, - lambda x: "n" if x == 0 else ALTER_SIGNS[x], + format_accidental_old, (int, type(None)), ), - "Octave": (interpret_as_int, format_int, int), + "Octave": (interpret_as_int, format_int, (int, type(None))), "Onset": (interpret_as_float, lambda x: f"{x:.2f}", float), "Offset": (interpret_as_float, lambda x: f"{x:.2f}", float), "Velocity": (interpret_as_int, format_int, int), @@ -419,10 +424,14 @@ def from_matchline( "NoteName": (interpret_as_string, lambda x: str(x).lower(), str), "Modifier": ( interpret_as_string, - lambda x: "n" if x == 0 else ALTER_SIGNS[x], + format_accidental_old, + (int, type(None)), + ), + "Octave": ( + interpret_as_int, + format_int, (int, type(None)), ), - "Octave": (interpret_as_int, format_int, int), "Onset": (interpret_as_int, format_int, int), "Offset": (interpret_as_int, format_int, int), "AdjOffset": (interpret_as_int, format_int, int), @@ -502,7 +511,7 @@ def from_matchline( ) -> MatchNote: if version >= Version(1, 0, 0): - ValueError(f"{version} >= Version(1, 0, 0)") + raise ValueError(f"{version} >= Version(1, 0, 0)") kwargs = get_kwargs_from_matchline( matchline=matchline, @@ -540,8 +549,8 @@ def from_matchline( version: Version = LAST_VERSION, ) -> MatchSnoteNote: - if version < Version(1, 0, 0): - ValueError(f"{version} >= Version(1, 0, 0)") + if version >= Version(1, 0, 0): + raise ValueError(f"{version} >= Version(1, 0, 0)") kwargs = cls.prepare_kwargs_from_matchline( matchline=matchline, @@ -551,3 +560,30 @@ def from_matchline( ) return cls(**kwargs) + + +class MatchInsertion(BaseInsertionLine): + def __init__(self, version: Version, note: MatchNote) -> None: + + super().__init__( + version=version, + note=note, + ) + + @classmethod + def from_matchline( + cls, + matchline: str, + version: Version = LAST_VERSION, + ) -> MatchInsertion: + + if version >= Version(1, 0, 0): + raise ValueError(f"{version} >= Version(1, 0, 0)") + + kwargs = cls.prepare_kwargs_from_matchline( + matchline=matchline, + note_class=MatchNote, + version=version, + ) + + return cls(**kwargs) diff --git a/partitura/io/matchlines_v1.py b/partitura/io/matchlines_v1.py index 88bed47f..c272b169 100644 --- a/partitura/io/matchlines_v1.py +++ b/partitura/io/matchlines_v1.py @@ -22,6 +22,7 @@ BaseSnoteLine, BaseNoteLine, BaseSnoteNoteLine, + BaseInsertionLine, ) from partitura.io.matchfile_utils import ( @@ -606,7 +607,7 @@ def from_matchline( ) -> MatchNote: if version < Version(1, 0, 0): - ValueError(f"{version} < Version(1, 0, 0)") + raise ValueError(f"{version} < Version(1, 0, 0)") kwargs = get_kwargs_from_matchline( matchline=matchline, @@ -645,8 +646,8 @@ def from_matchline( ) -> MatchSnoteNote: if version < Version(1, 0, 0): - ValueError(f"{version} < Version(1, 0, 0)") - + raise ValueError(f"{version} < Version(1, 0, 0)") + kwargs = cls.prepare_kwargs_from_matchline( matchline=matchline, snote_class=MatchSnote, @@ -655,3 +656,30 @@ def from_matchline( ) return cls(**kwargs) + + +class MatchInsertion(BaseInsertionLine): + def __init__(self, version: Version, note: MatchNote) -> None: + + super().__init__( + version=version, + note=note, + ) + + @classmethod + def from_matchline( + cls, + matchline: str, + version: Version = LATEST_VERSION, + ) -> MatchInsertion: + + if version < Version(1, 0, 0): + raise ValueError(f"{version} < Version(1, 0, 0)") + + kwargs = cls.prepare_kwargs_from_matchline( + matchline=matchline, + note_class=MatchNote, + version=version, + ) + + return cls(**kwargs) diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index 2c19562f..decdb43a 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -15,6 +15,7 @@ MatchSnote as MatchSnoteV1, MatchNote as MatchNoteV1, MatchSnoteNote as MatchSnoteNoteV1, + MatchInsertion as MatchInsertionV1, ) from partitura.io.matchlines_v0 import ( @@ -24,7 +25,7 @@ MatchSnoteNote as MatchSnoteNoteV0, ) -from partitura.io.matchfile_base import MatchError +from partitura.io.matchfile_base import MatchError, MatchLine from partitura.io.matchfile_utils import ( FractionalSymbolicDuration, @@ -35,6 +36,27 @@ RNG = np.random.RandomState(1984) +def basic_line_test(ml: MatchLine) -> bool: + """ + Test that the matchline has the correct number and type + of the mandatory attributes. + """ + assert len(ml.field_names) == len(ml.field_types) + + assert isinstance(ml.field_names, tuple) + assert all([isinstance(fn, str) for fn in ml.field_names]) + assert all([isinstance(dt, (type, tuple)) for dt in ml.field_types]) + + if isinstance(ml.format_fun, dict): + assert len(ml.format_fun) == len(ml.field_names) + assert all([callable(ff) for _, ff in ml.format_fun.items()]) + elif isinstance(ml.format_fun, tuple): + assert sum([len(ff) for ff in ml.format_fun]) == len(ml.field_names) + + for ff in ml.format_fun: + assert all([callable(fff) for _, fff in ff.items()]) + + class TestMatchLinesV1(unittest.TestCase): """ Test matchlines for version 1.0.0 @@ -95,6 +117,7 @@ def test_info_lines(self): # assert that the information from the matchline # is parsed correctly and results in an identical line # to the input match line + basic_line_test(mo) self.assertTrue(mo.matchline == ml) # assert that the data types of the match line are correct @@ -155,6 +178,7 @@ def test_score_prop_lines(self): # is parsed correctly and results in an identical line # to the input match line mo = MatchScorePropV1.from_matchline(ml) + basic_line_test(mo) self.assertTrue(mo.matchline == ml) # assert that the data types of the match line are correct @@ -182,7 +206,7 @@ def test_section_lines(self): # is parsed correctly and results in an identical line # to the input match line mo = MatchSectionV1.from_matchline(ml) - + basic_line_test(mo) self.assertTrue(mo.matchline == ml) # assert that the data types of the match line are correct @@ -289,7 +313,7 @@ def test_snote_lines(self): ] ) ) - # print(mo.matchline, ml) + basic_line_test(mo) self.assertTrue(mo.matchline == ml) # These notes were taken from a piece in 2/4 @@ -353,7 +377,7 @@ def test_note_lines(self): # is parsed correctly and results in an identical line # to the input match line mo = MatchNoteV1.from_matchline(ml) - # print(mo.matchline, ml, [(g == t, g, t) for g, t in zip(mo.matchline, ml)]) + basic_line_test(mo) self.assertTrue(mo.matchline == ml) # assert that the data types of the match line are correct @@ -362,19 +386,29 @@ def test_note_lines(self): def test_snotenote_lines(self): snotenote_lines = [ - "snote(n1,[B,n],3,0:2,1/8,1/8,-0.5000,0.0000,[1])-note(0,47,39940,42140,44,0,0).", + "snote(n1,[B,n],3,0:2,1/8,1/8,-0.5000,0.0000,[v1])-note(0,47,39940,42140,44,0,0).", + "snote(n443,[B,n],2,20:2,0,1/16,39.0000,39.2500,[v7])-note(439,35,669610,679190,28,0,0).", + "snote(n444,[B,n],3,20:2,1/16,1/16,39.2500,39.5000,[v3])-note(441,47,673620,678870,27,0,0).", + "snote(n445,[E,n],3,20:2,1/16,1/16,39.2500,39.5000,[v4])-note(442,40,673980,678130,19,0,0).", + "snote(n446,[G,#],3,20:2,1/8,1/16,39.5000,39.7500,[v3])-note(443,44,678140,683800,23,0,0).", + "snote(n447,[E,n],2,20:2,1/8,3/8,39.5000,41.0000,[v7])-note(444,28,678170,704670,22,0,0).", + "snote(n448,[B,n],3,20:2,3/16,1/16,39.7500,40.0000,[v3])-note(445,47,683550,685070,30,0,0).", + "snote(n449,[B,n],2,20:2,3/16,5/16,39.7500,41.0000,[v6])-note(446,35,683590,705800,18,0,0).", + "snote(n450,[G,#],4,21:1,0,0,40.0000,40.0000,[v1,grace])-note(447,56,691330,694180,38,0,0).", + "snote(n451,[F,#],4,21:1,0,0,40.0000,40.0000,[v1,grace])-note(450,54,693140,695700,44,0,0).", + "snote(n452,[E,n],4,21:1,0,1/4,40.0000,41.0000,[v1])-note(451,52,695050,705530,40,0,0).", + "snote(n453,[G,#],3,21:1,0,1/4,40.0000,41.0000,[v3])-note(449,44,691800,703570,28,0,0).", ] for i, ml in enumerate(snotenote_lines): - # snote = MatchSnoteV1.from_matchline(ml) - # note = MatchNoteV1.from_matchline(ml) mo = MatchSnoteNoteV1.from_matchline(ml, version=Version(1, 0, 0)) - + basic_line_test(mo) self.assertTrue(mo.matchline == ml) if i == 0: self.assertTrue(mo.check_types(verbose=True)) + self.assertTrue(isinstance(str(mo), str)) else: self.assertTrue(mo.check_types()) @@ -385,8 +419,97 @@ def test_snotenote_lines(self): except ValueError: self.assertTrue(True) + def test_insertion_lines(self): + + insertion_lines = [ + "insertion-note(50,40,109820,118820,1,0,0).", + "insertion-note(51,57,109940,113160,1,0,0).", + "insertion-note(82,47,164500,164830,1,0,0).", + "insertion-note(125,44,240630,243190,1,0,0).", + "insertion-note(172,53,230380,230950,1,0,0).", + "insertion-note(263,26,322180,322800,81,0,0).", + "insertion-note(292,61,344730,347960,116,0,0).", + "insertion-note(241,56,328460,333340,17,0,0).", + "insertion-note(101,56,210170,211690,1,0,0).", + "insertion-note(231,45,482420,485320,1,0,0).", + "insertion-note(307,56,636010,637570,1,0,0).", + "insertion-note(340,56,693470,696110,1,0,0).", + "insertion-note(445,58,914370,917360,1,0,0).", + "insertion-note(193,56,235830,236270,98,0,0).", + "insertion-note(50,40,143130,156020,1,0,0).", + "insertion-note(156,40,424930,437570,1,0,0).", + ] + + for i, ml in enumerate(insertion_lines): + + mo = MatchInsertionV1.from_matchline(ml, version=Version(1, 0, 0)) + basic_line_test(mo) + self.assertTrue(mo.matchline == ml) + + self.assertTrue(mo.check_types()) + + # An error is raised if parsing the wrong version + try: + mo = MatchInsertionV1.from_matchline(ml, version=Version(0, 5, 0)) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + class TestMatchLinesV0(unittest.TestCase): + def test_info_lines(self): + matchlines = [ + r"info(scoreFileName,'op10_3_1.scr').", + r"info(midiFileName,'op10_3_1#18.mid').", + "info(midiClockUnits,4000).", + "info(midiClockRate,500000).", + "info(keySignature,[en,major]).", + "info(timeSignature,2/4).", + "info(beatSubdivision,[2,4]).", + "info(tempoIndication,[lento,ma,non,troppo]).", + "info(approximateTempo,34.0).", + "info(subtitle,[]).", + ] + + for i, ml in enumerate(matchlines): + mo = MatchInfoV0.from_matchline(ml, version=Version(0, 1, 0)) + # assert that the information from the matchline + # is parsed correctly and results in an identical line + # to the input match line + basic_line_test(mo) + self.assertTrue(mo.matchline == ml) + + self.assertTrue(mo.check_types()) + + # The following lines should result in an error + try: + # This line is not defined as an info line and should raise an error + notSpecified_line = "info(notSpecified,value)." + + mo = MatchInfoV0.from_matchline(notSpecified_line) + self.assertTrue(False) # pragma: no cover + except ValueError: + # assert that the error was raised + self.assertTrue(True) + + try: + # wrong value (string instead of integer) + midiClockUnits_line = "info(midiClockUnits,wrong_value)." + + mo = MatchInfoV0.from_matchline(midiClockUnits_line) + self.assertTrue(False) # pragma: no cover + except ValueError: + # assert that the error was raised + self.assertTrue(True) + + try: + # This is not a valid line and should result in a MatchError + wrong_line = "wrong_line" + mo = MatchInfoV0.from_matchline(wrong_line) + self.assertTrue(False) # pragma: no cover + except MatchError: + self.assertTrue(True) + def test_snote_lines_v0_1_0(self): snote_lines = [ @@ -407,6 +530,7 @@ def test_snote_lines_v0_1_0(self): ml, version=Version(0, minor_version, 0), ) + basic_line_test(mo) # print(mo.matchline, ml) self.assertTrue(mo.matchline == ml) @@ -449,6 +573,8 @@ def test_snote_lines_v0_3_0(self): "snote(n29,[c,n],5,2:3,0,1/16,6.0,6.25,[s,trill])", "snote(n155,[a,n],5,8:1,0,0,28.0,28.0,[s,grace])", "snote(n187,[g,n],5,9:2,3/16,1/16,33.75,34.0,[s,stacc])", + # example of rest included in the original Batik dataset + "snote(n84,[r,-],-,8:6,0,1/8,47.0,48.0,[fermata])", ] for ml in snote_lines: @@ -461,10 +587,12 @@ def test_snote_lines_v0_3_0(self): ml, version=Version(0, minor_version, 0), ) - # print(mo.matchline, ml) + basic_line_test(mo) + self.assertTrue(mo.matchline == ml) # assert that the data types of the match line are correct + self.assertTrue(mo.check_types()) def test_snote_lines_v0_5_0(self): @@ -486,6 +614,7 @@ def test_snote_lines_v0_5_0(self): ml, version=Version(0, minor_version, 0), ) + basic_line_test(mo) # print(mo.matchline, ml) self.assertTrue(mo.matchline == ml) @@ -514,6 +643,7 @@ def test_note_lines_v_0_4_0(self): mo = MatchNoteV0.from_matchline( ml, version=Version(0, minor_version, 0) ) + basic_line_test(mo) self.assertTrue(mo.matchline == ml) # check duration and adjusted duration self.assertTrue(mo.AdjDuration >= mo.Duration) @@ -541,6 +671,7 @@ def test_note_lines_v_0_3_0(self): mo = MatchNoteV0.from_matchline( ml, version=Version(0, minor_version, 0) ) + basic_line_test(mo) self.assertTrue(mo.matchline == ml) # check duration and adjusted duration self.assertTrue(mo.AdjDuration >= mo.Duration) @@ -571,6 +702,7 @@ def test_note_lines_v_0_1_0(self): mo = MatchNoteV0.from_matchline( ml, version=Version(0, minor_version, 0) ) + basic_line_test(mo) self.assertTrue(mo.matchline == ml) # assert that the data types of the match line are correct @@ -598,7 +730,7 @@ def test_snotenote_lines_v_0_1_0(self): mo = MatchSnoteNoteV0.from_matchline( ml, version=Version(0, minor_version, 0) ) - + basic_line_test(mo) self.assertTrue(mo.matchline == ml) self.assertTrue(mo.check_types()) @@ -629,6 +761,7 @@ def test_snotenote_lines_v_0_3_0(self): mo = MatchSnoteNoteV0.from_matchline( ml, version=Version(0, minor_version, 0) ) + basic_line_test(mo) self.assertTrue(mo.matchline == ml) self.assertTrue(mo.check_types()) @@ -651,6 +784,7 @@ def test_snote_lines_v_0_4_0(self): mo = MatchSnoteNoteV0.from_matchline( ml, version=Version(0, minor_version, 0) ) + basic_line_test(mo) self.assertTrue(mo.matchline == ml) self.assertTrue(mo.check_types()) @@ -808,8 +942,6 @@ def test_fractional_symbolic_duration(self): elif num1 > 0: self.assertTrue(str(fsd3) == str(fsd1)) - elif num2 > 0: - self.assertTrue(str(fsd3) == str(fsd2)) self.assertTrue(isinstance(fsd3, FractionalSymbolicDuration)) self.assertTrue(np.isclose(float(fsd1) + float(fsd2), float(fsd3))) From b4bc8538308604420f90c06ae9797df34f2dd542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Thu, 24 Nov 2022 06:33:48 +0100 Subject: [PATCH 43/88] test insertion lines for v< 1.0.0 --- partitura/io/matchlines_v0.py | 22 ++++++++++- tests/test_match_import_new.py | 70 ++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/partitura/io/matchlines_v0.py b/partitura/io/matchlines_v0.py index b948330e..07ba2563 100644 --- a/partitura/io/matchlines_v0.py +++ b/partitura/io/matchlines_v0.py @@ -562,7 +562,7 @@ def from_matchline( return cls(**kwargs) -class MatchInsertion(BaseInsertionLine): +class MatchInsertionNote(BaseInsertionLine): def __init__(self, version: Version, note: MatchNote) -> None: super().__init__( @@ -575,7 +575,7 @@ def from_matchline( cls, matchline: str, version: Version = LAST_VERSION, - ) -> MatchInsertion: + ) -> MatchInsertionNote: if version >= Version(1, 0, 0): raise ValueError(f"{version} >= Version(1, 0, 0)") @@ -587,3 +587,21 @@ def from_matchline( ) return cls(**kwargs) + + +class MatchHammerBounceNote(MatchInsertionNote): + + out_pattern = "" + pattern = re.compile("") + + def __init__(self, version: Version, note: MatchNote) -> None: + super(version=version, note=note) + + +class MatchHammerTrailingPlayedNote(MatchInsertionNote): + + out_pattern = "" + pattern = re.compile("") + + def __init__(self, version: Version, note: MatchNote) -> None: + super(version=version, note=note) diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index decdb43a..4027261b 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -23,6 +23,7 @@ MatchSnote as MatchSnoteV0, MatchNote as MatchNoteV0, MatchSnoteNote as MatchSnoteNoteV0, + MatchInsertionNote as MatchInsertionNoteV0, ) from partitura.io.matchfile_base import MatchError, MatchLine @@ -789,6 +790,75 @@ def test_snote_lines_v_0_4_0(self): self.assertTrue(mo.check_types()) + def test_insertion_lines_v_0_3_0(self): + + insertion_lines = [ + "insertion-note(178,[e,n],4,42982,43198,43535,5).", + "insertion-note(411,[b,n],4,98186,98537,98898,1).", + "insertion-note(583,[e,n],4,128488,129055,129436,12).", + "insertion-note(599,[c,#],5,130298,130348,130348,62).", + "insertion-note(603,[c,#],5,130452,130536,130536,68).", + "insertion-note(604,[b,n],4,130541,130575,130575,63).", + "insertion-note(740,[e,n],4,148300,149097,149097,6).", + "insertion-note(756,[c,#],5,150152,150220,150220,72).", + "insertion-note(759,[c,#],5,150308,150380,150380,70).", + "insertion-note(761,[b,n],4,150388,150443,150443,71).", + ] + + for ml in insertion_lines: + + mo = MatchInsertionNoteV0.from_matchline(ml, version=Version(0, 3, 0)) + + basic_line_test(mo) + self.assertTrue(mo.matchline == ml) + + def test_insertion_lines_v_0_4_0(self): + + insertion_lines = [ + "insertion-note(171,[A,n],5,13216,13248,13248,63).", + "insertion-note(187,[C,#],5,14089,14132,14132,46).", + "insertion-note(276,[G,n],4,20555,21144,21144,51).", + "insertion-note(1038,[E,n],5,70496,70526,70526,55).", + "insertion-note(1091,[F,#],5,73018,73062,73062,40).", + "insertion-note(1247,[E,n],2,81885,81920,81920,57).", + "insertion-note(1252,[F,#],2,82061,82130,82130,17).", + "insertion-note(1316,[F,#],6,86084,86122,86122,38).", + "insertion-note(1546,[G,#],1,99495,99536,99536,16).", + "insertion-note(1557,[B,n],5,100300,100496,100496,80).", + "insertion-note(1572,[B,n],1,104377,104460,104460,61).", + ] + + for ml in insertion_lines: + + for minor_version in (4, 5): + mo = MatchInsertionNoteV0.from_matchline( + ml, version=Version(0, minor_version, 0) + ) + + basic_line_test(mo) + self.assertTrue(mo.matchline == ml) + + def test_insertion_lines_v_0_1_0(self): + insertion_lines = [ + "insertion-note(1,[c,n],6,39060.00,39890.00,38).", + "insertion-note(6,[c,n],5,48840.00,49870.00,26).", + "insertion-note(17,[c,n],5,72600.00,75380.00,26).", + "insertion-note(32,[b,b],5,93030.00,95050.00,32).", + "insertion-note(85,[b,b],3,162600.00,164950.00,27).", + "insertion-note(132,[c,n],5,226690.00,227220.00,34).", + "insertion-note(179,[b,b],4,280360.00,282310.00,35).", + ] + + for ml in insertion_lines: + + for minor_version in (1, 2): + mo = MatchInsertionNoteV0.from_matchline( + ml, version=Version(0, minor_version, 0) + ) + + basic_line_test(mo) + self.assertTrue(mo.matchline == ml) + class TestMatchUtils(unittest.TestCase): """ From 26b5f09d22bd0f2d6a765913fb07147ee6c59a9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Thu, 24 Nov 2022 06:54:33 +0100 Subject: [PATCH 44/88] test hammer_bounce and trailing_played_note lines --- partitura/io/matchlines_v0.py | 14 ++++----- tests/test_match_import_new.py | 57 ++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/partitura/io/matchlines_v0.py b/partitura/io/matchlines_v0.py index 07ba2563..27695bde 100644 --- a/partitura/io/matchlines_v0.py +++ b/partitura/io/matchlines_v0.py @@ -591,17 +591,17 @@ def from_matchline( class MatchHammerBounceNote(MatchInsertionNote): - out_pattern = "" - pattern = re.compile("") + out_pattern = "hammer_bounce-{NoteLine}" def __init__(self, version: Version, note: MatchNote) -> None: - super(version=version, note=note) + super().__init__(version=version, note=note) + self.pattern = re.compile(f"hammer_bounce-{self.note.pattern.pattern}") -class MatchHammerTrailingPlayedNote(MatchInsertionNote): +class MatchTrailingPlayedNote(MatchInsertionNote): - out_pattern = "" - pattern = re.compile("") + out_pattern = "trailing_played_note-{NoteLine}" def __init__(self, version: Version, note: MatchNote) -> None: - super(version=version, note=note) + super().__init__(version=version, note=note) + self.pattern = re.compile(f"trailing_played_note-{self.note.pattern.pattern}") diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index 4027261b..217af9a7 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -24,6 +24,8 @@ MatchNote as MatchNoteV0, MatchSnoteNote as MatchSnoteNoteV0, MatchInsertionNote as MatchInsertionNoteV0, + MatchHammerBounceNote as MatchHammerBounceNoteV0, + MatchTrailingPlayedNote as MatchTrailingPlayedNoteV0, ) from partitura.io.matchfile_base import MatchError, MatchLine @@ -859,6 +861,61 @@ def test_insertion_lines_v_0_1_0(self): basic_line_test(mo) self.assertTrue(mo.matchline == ml) + # An error is raised if parsing the wrong version + try: + mo = MatchInsertionNoteV0.from_matchline(ml, version=Version(1, 0, 0)) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + + def test_hammer_bounce_lines(self): + + # These lines do not exist in any dataset that we have access to + # for now the tests use lines replacing insertion with hammer_bounce + hammer_bounce_lines = [ + "hammer_bounce-note(1,[c,n],6,39060.00,39890.00,38).", + "hammer_bounce-note(6,[c,n],5,48840.00,49870.00,26).", + "hammer_bounce-note(17,[c,n],5,72600.00,75380.00,26).", + "hammer_bounce-note(32,[b,b],5,93030.00,95050.00,32).", + "hammer_bounce-note(85,[b,b],3,162600.00,164950.00,27).", + "hammer_bounce-note(132,[c,n],5,226690.00,227220.00,34).", + "hammer_bounce-note(179,[b,b],4,280360.00,282310.00,35).", + ] + + for ml in hammer_bounce_lines: + + for minor_version in (1, 2): + mo = MatchHammerBounceNoteV0.from_matchline( + ml, version=Version(0, minor_version, 0) + ) + + basic_line_test(mo) + self.assertTrue(mo.matchline == ml) + + def test_trailing_played_lines(self): + + # These lines do not exist in any dataset that we have access to + # for now the tests use lines replacing insertion with trailing_played_note + trailing_played_note_lines = [ + "trailing_played_note-note(1,[c,n],6,39060.00,39890.00,38).", + "trailing_played_note-note(6,[c,n],5,48840.00,49870.00,26).", + "trailing_played_note-note(17,[c,n],5,72600.00,75380.00,26).", + "trailing_played_note-note(32,[b,b],5,93030.00,95050.00,32).", + "trailing_played_note-note(85,[b,b],3,162600.00,164950.00,27).", + "trailing_played_note-note(132,[c,n],5,226690.00,227220.00,34).", + "trailing_played_note-note(179,[b,b],4,280360.00,282310.00,35).", + ] + + for ml in trailing_played_note_lines: + + for minor_version in (1, 2): + mo = MatchTrailingPlayedNoteV0.from_matchline( + ml, version=Version(0, minor_version, 0) + ) + + basic_line_test(mo) + self.assertTrue(mo.matchline == ml) + class TestMatchUtils(unittest.TestCase): """ From dd2387ef36ba6a2ca8d1e616eeb0a12adaa1d162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Thu, 24 Nov 2022 07:02:21 +0100 Subject: [PATCH 45/88] fix typo in message in importmusicxml.py --- partitura/io/importmusicxml.py | 6 +++--- tests/test_match_import_new.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/partitura/io/importmusicxml.py b/partitura/io/importmusicxml.py index f4235e0c..4f2bd42b 100644 --- a/partitura/io/importmusicxml.py +++ b/partitura/io/importmusicxml.py @@ -367,7 +367,7 @@ def _parse_parts(document, part_dict): part.add(o, None, end_times[end_time_id]) warnings.warn( "Found repeat without end\n" - "Ending point {} is assumend".format(end_times[end_time_id]) + "Ending point {} is assumed".format(end_times[end_time_id]) ) # complete unstarted repeats @@ -387,7 +387,7 @@ def _parse_parts(document, part_dict): part.add(o, start_times[start_time_id], None) warnings.warn( "Found repeat without start\n" - "Starting point {} is assumend".format(start_times[start_time_id]) + "Starting point {} is assumed".format(start_times[start_time_id]) ) # complete unstarted repeats in volta with start time of first repeat @@ -397,7 +397,7 @@ def _parse_parts(document, part_dict): part.add(o, start_times[start_time_id], None) warnings.warn( "Found repeat without start\n" - "Starting point {} is assumend".format(start_times[start_time_id]) + "Starting point {} is assumed".format(start_times[start_time_id]) ) # remove unfinished elements from the timeline diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index 217af9a7..824f0a85 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -505,6 +505,14 @@ def test_info_lines(self): # assert that the error was raised self.assertTrue(True) + # An error is raised if parsing the wrong version + try: + mo = MatchInfoV0.from_matchline(ml, version=Version(1, 0, 0)) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + + try: # This is not a valid line and should result in a MatchError wrong_line = "wrong_line" @@ -540,6 +548,14 @@ def test_snote_lines_v0_1_0(self): # assert that the data types of the match line are correct self.assertTrue(mo.check_types()) + # An error is raised if parsing the wrong version + try: + mo = MatchSnoteV0.from_matchline(ml, version=Version(1, 0, 0)) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + + try: # This is not a valid line and should result in a MatchError wrong_line = "wrong_line" From 4d5a4cbfff7db413739f518995c60d2f8907b8f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Thu, 24 Nov 2022 08:52:07 +0100 Subject: [PATCH 46/88] add deletion lines (wip) --- partitura/io/matchfile_base.py | 45 +++++++++ partitura/io/matchlines_v1.py | 41 ++++++-- tests/test_match_import_new.py | 169 ++++++++++++++++++++++----------- 3 files changed, 192 insertions(+), 63 deletions(-) diff --git a/partitura/io/matchfile_base.py b/partitura/io/matchfile_base.py index 859b64b9..540a5ae5 100644 --- a/partitura/io/matchfile_base.py +++ b/partitura/io/matchfile_base.py @@ -559,6 +559,51 @@ def prepare_kwargs_from_matchline( return kwargs +class BaseDeletionLine(MatchLine): + + out_pattern = "{SnoteLine}-deletion." + + def __init__(self, version: Version, snote: BaseSnoteLine) -> None: + + super().__init__(version) + + self.snote = snote + + self.field_names = self.snote.field_names + + self.field_types = self.snote.field_types + + self.pattern = re.compile(rf"{self.snote.pattern.pattern}-deletion\.") + + self.format_fun = self.snote.format_fun + + for fn in self.field_names: + setattr(self, fn, getattr(self.snote, fn)) + + @property + def matchline(self) -> str: + return self.out_pattern.format( + SnoteLine=self.snote.matchline, + ) + + @classmethod + def prepare_kwargs_from_matchline( + cls, + matchline: str, + snote_class: BaseSnoteLine, + version: Version, + ) -> Dict: + + snote = snote_class.from_matchline(matchline, version=version) + + kwargs = dict( + version=version, + snote=snote, + ) + + return kwargs + + class BaseInsertionLine(MatchLine): out_pattern = "insertion-{NoteLine}" diff --git a/partitura/io/matchlines_v1.py b/partitura/io/matchlines_v1.py index c272b169..d8c4ed3c 100644 --- a/partitura/io/matchlines_v1.py +++ b/partitura/io/matchlines_v1.py @@ -22,6 +22,7 @@ BaseSnoteLine, BaseNoteLine, BaseSnoteNoteLine, + BaseDeletionLine, BaseInsertionLine, ) @@ -108,7 +109,7 @@ def __init__( ) -> None: if version < Version(1, 0, 0): - raise MatchError("The version must be >= 1.0.0") + raise ValueError("The version must be >= 1.0.0") super().__init__( version=version, @@ -144,7 +145,7 @@ def from_matchline( a MatchInfo instance """ if version not in INFO_LINE: - raise MatchError(f"{version} is not specified for this class.") + raise ValueError(f"{version} is not specified for this class.") match_pattern = cls.pattern.search(matchline, pos=pos) @@ -223,7 +224,8 @@ def __init__( ) -> None: if version < Version(1, 0, 0): - raise MatchError("The version must be >= 1.0.0") + raise ValueError("The version must be >= 1.0.0") + super().__init__(version) self.field_types = ( @@ -279,7 +281,7 @@ def from_matchline( """ if version not in SCOREPROP_LINE: - raise MatchError(f"{version} is not specified for this class.") + raise ValueError(f"{version} is not specified for this class.") match_pattern = cls.pattern.search(matchline, pos=pos) @@ -658,7 +660,34 @@ def from_matchline( return cls(**kwargs) -class MatchInsertion(BaseInsertionLine): +class MatchSnoteDeletion(BaseDeletionLine): + def __init__(self, version: Version, snote: MatchSnote) -> None: + + super().__init__( + version=version, + snote=snote, + ) + + @classmethod + def from_matchline( + cls, + matchline: str, + version: Version = LATEST_VERSION, + ) -> MatchSnoteDeletion: + + if version < Version(1, 0, 0): + raise ValueError(f"{version} < Version(1, 0, 0)") + + kwargs = cls.prepare_kwargs_from_matchline( + matchline=matchline, + note_class=MatchNote, + version=version, + ) + + return cls(**kwargs) + + +class MatchInsertionNote(BaseInsertionLine): def __init__(self, version: Version, note: MatchNote) -> None: super().__init__( @@ -671,7 +700,7 @@ def from_matchline( cls, matchline: str, version: Version = LATEST_VERSION, - ) -> MatchInsertion: + ) -> MatchInsertionNote: if version < Version(1, 0, 0): raise ValueError(f"{version} < Version(1, 0, 0)") diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index 824f0a85..63920d9c 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -5,6 +5,7 @@ """ import unittest import numpy as np +import re from tests import MATCH_IMPORT_EXPORT_TESTFILES, MOZART_VARIATION_FILES @@ -15,7 +16,8 @@ MatchSnote as MatchSnoteV1, MatchNote as MatchNoteV1, MatchSnoteNote as MatchSnoteNoteV1, - MatchInsertion as MatchInsertionV1, + MatchSnoteDeletion as MatchSnoteDeletionV1, + MatchInsertionNote as MatchInsertionNoteV1, ) from partitura.io.matchlines_v0 import ( @@ -39,23 +41,49 @@ RNG = np.random.RandomState(1984) -def basic_line_test(ml: MatchLine) -> bool: +def basic_line_test(ml: MatchLine, verbose: bool = False) -> None: """ Test that the matchline has the correct number and type of the mandatory attributes. """ + + # check that field names and field types have the same number of elements assert len(ml.field_names) == len(ml.field_types) + # assert that field names have the correct type assert isinstance(ml.field_names, tuple) assert all([isinstance(fn, str) for fn in ml.field_names]) + + # assert that field types have the correct type + assert isinstance(ml.field_types, tuple) assert all([isinstance(dt, (type, tuple)) for dt in ml.field_types]) + # assert that the string and matchline methods have the correct type + assert isinstance(str(ml), str) + assert isinstance(ml.matchline, str) + + # assert that a new created MatchLine from the same `matchline` + # will result in the same `matchline` + new_ml = ml.from_matchline(ml.matchline, version=ml.version) + assert new_ml.matchline == ml.matchline + + # assert that the data types of the match line are correct + assert ml.check_types(verbose) + + # assert that the pattern has the correct type + assert isinstance(ml.pattern, (re.Pattern, tuple)) + + if isinstance(ml.pattern, tuple): + assert all([isinstance(pt, re.Pattern) for pt in ml.pattern]) + + # assert that format fun has the correct type and number of elements + assert isinstance(ml.format_fun, (dict, tuple)) + if isinstance(ml.format_fun, dict): assert len(ml.format_fun) == len(ml.field_names) assert all([callable(ff) for _, ff in ml.format_fun.items()]) elif isinstance(ml.format_fun, tuple): assert sum([len(ff) for ff in ml.format_fun]) == len(ml.field_names) - for ff in ml.format_fun: assert all([callable(fff) for _, fff in ff.items()]) @@ -120,15 +148,15 @@ def test_info_lines(self): # assert that the information from the matchline # is parsed correctly and results in an identical line # to the input match line - basic_line_test(mo) + basic_line_test(mo, verbose=i == 0) self.assertTrue(mo.matchline == ml) - # assert that the data types of the match line are correct - if i == 0: - # Test verbose output - self.assertTrue(mo.check_types(verbose=True)) - else: - self.assertTrue(mo.check_types()) + # An error is raised if parsing the wrong version + try: + mo = MatchInfoV1.from_matchline(ml, version=Version(0, 1, 0)) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) # The following lines should result in an error try: @@ -159,6 +187,18 @@ def test_info_lines(self): except MatchError: self.assertTrue(True) + try: + mo = MatchInfoV1( + version=Version(0, 5, 0), + attribute="scoreFileName", + value="score_file.musicxml", + value_type=str, + format_fun=lambda x: str(x), + ) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + def test_score_prop_lines(self): keysig_line = "scoreprop(keySignature,E,0:2,1/8,-0.5000)." @@ -184,9 +224,6 @@ def test_score_prop_lines(self): basic_line_test(mo) self.assertTrue(mo.matchline == ml) - # assert that the data types of the match line are correct - self.assertTrue(mo.check_types()) - try: # This is not a valid line and should result in a MatchError wrong_line = "wrong_line" @@ -195,6 +232,22 @@ def test_score_prop_lines(self): except MatchError: self.assertTrue(True) + try: + mo = MatchScorePropV1( + version=Version(0, 5, 0), + attribute="keySignature", + value="E", + value_type=str, + format_fun=lambda x: str(x), + measure=1, + beat=0, + offset=FractionalSymbolicDuration(0), + time_in_beats=0.0, + ) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + def test_section_lines(self): section_lines = [ @@ -212,9 +265,6 @@ def test_section_lines(self): basic_line_test(mo) self.assertTrue(mo.matchline == ml) - # assert that the data types of the match line are correct - self.assertTrue(mo.check_types()) - # Check version (an error should be raised for old versions) try: mo = MatchSectionV1.from_matchline( @@ -335,9 +385,6 @@ def test_snote_lines(self): self.assertTrue(mo.MidiPitch < 128) - # assert that the data types of the match line are correct - self.assertTrue(mo.check_types()) - try: # This is not a valid line and should result in a MatchError wrong_line = "wrong_line" @@ -406,15 +453,9 @@ def test_snotenote_lines(self): for i, ml in enumerate(snotenote_lines): mo = MatchSnoteNoteV1.from_matchline(ml, version=Version(1, 0, 0)) - basic_line_test(mo) + basic_line_test(mo, verbose=i == 0) self.assertTrue(mo.matchline == ml) - if i == 0: - self.assertTrue(mo.check_types(verbose=True)) - self.assertTrue(isinstance(str(mo), str)) - else: - self.assertTrue(mo.check_types()) - # An error is raised if parsing the wrong version try: mo = MatchSnoteNoteV1.from_matchline(ml, version=Version(0, 5, 0)) @@ -445,15 +486,13 @@ def test_insertion_lines(self): for i, ml in enumerate(insertion_lines): - mo = MatchInsertionV1.from_matchline(ml, version=Version(1, 0, 0)) + mo = MatchInsertionNoteV1.from_matchline(ml, version=Version(1, 0, 0)) basic_line_test(mo) self.assertTrue(mo.matchline == ml) - self.assertTrue(mo.check_types()) - # An error is raised if parsing the wrong version try: - mo = MatchInsertionV1.from_matchline(ml, version=Version(0, 5, 0)) + mo = MatchInsertionNoteV1.from_matchline(ml, version=Version(0, 5, 0)) self.assertTrue(False) # pragma: no cover except ValueError: self.assertTrue(True) @@ -479,11 +518,9 @@ def test_info_lines(self): # assert that the information from the matchline # is parsed correctly and results in an identical line # to the input match line - basic_line_test(mo) + basic_line_test(mo, verbose=i == 0) self.assertTrue(mo.matchline == ml) - self.assertTrue(mo.check_types()) - # The following lines should result in an error try: # This line is not defined as an info line and should raise an error @@ -512,7 +549,6 @@ def test_info_lines(self): except ValueError: self.assertTrue(True) - try: # This is not a valid line and should result in a MatchError wrong_line = "wrong_line" @@ -521,6 +557,18 @@ def test_info_lines(self): except MatchError: self.assertTrue(True) + try: + mo = MatchInfoV0( + version=Version(1, 0, 0), + attribute="scoreFileName", + value="'score_file.musicxml'", + value_type=str, + format_fun=lambda x: str(x), + ) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + def test_snote_lines_v0_1_0(self): snote_lines = [ @@ -545,9 +593,6 @@ def test_snote_lines_v0_1_0(self): # print(mo.matchline, ml) self.assertTrue(mo.matchline == ml) - # assert that the data types of the match line are correct - self.assertTrue(mo.check_types()) - # An error is raised if parsing the wrong version try: mo = MatchSnoteV0.from_matchline(ml, version=Version(1, 0, 0)) @@ -555,7 +600,6 @@ def test_snote_lines_v0_1_0(self): except ValueError: self.assertTrue(True) - try: # This is not a valid line and should result in a MatchError wrong_line = "wrong_line" @@ -610,10 +654,6 @@ def test_snote_lines_v0_3_0(self): self.assertTrue(mo.matchline == ml) - # assert that the data types of the match line are correct - - self.assertTrue(mo.check_types()) - def test_snote_lines_v0_5_0(self): snote_lines = [ @@ -637,9 +677,6 @@ def test_snote_lines_v0_5_0(self): # print(mo.matchline, ml) self.assertTrue(mo.matchline == ml) - # assert that the data types of the match line are correct - self.assertTrue(mo.check_types()) - def test_note_lines_v_0_4_0(self): note_lines = [ @@ -667,8 +704,36 @@ def test_note_lines_v_0_4_0(self): # check duration and adjusted duration self.assertTrue(mo.AdjDuration >= mo.Duration) - # assert that the data types of the match line are correct - self.assertTrue(mo.check_types()) + # An error is raised if parsing the wrong version + try: + mo = MatchNoteV0.from_matchline(ml, version=Version(1, 0, 0)) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + + # Wrong version + try: + mo = MatchNoteV0( + version=Version(1, 0, 0), + id="n0", + note_name="C", + modifier=0, + octave=4, + onset=0, + offset=400, + velocity=90, + ) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + + try: + # This is not a valid line and should result in a MatchError + wrong_line = "wrong_line" + mo = MatchNoteV0.from_matchline(wrong_line, version=Version(0, 1, 0)) + self.assertTrue(False) # pragma: no cover + except MatchError: + self.assertTrue(True) def test_note_lines_v_0_3_0(self): @@ -695,9 +760,6 @@ def test_note_lines_v_0_3_0(self): # check duration and adjusted duration self.assertTrue(mo.AdjDuration >= mo.Duration) - # assert that the data types of the match line are correct - self.assertTrue(mo.check_types()) - def test_note_lines_v_0_1_0(self): # Lines taken from original version of @@ -724,9 +786,6 @@ def test_note_lines_v_0_1_0(self): basic_line_test(mo) self.assertTrue(mo.matchline == ml) - # assert that the data types of the match line are correct - self.assertTrue(mo.check_types()) - def test_snotenote_lines_v_0_1_0(self): snotenote_lines = [ @@ -752,8 +811,6 @@ def test_snotenote_lines_v_0_1_0(self): basic_line_test(mo) self.assertTrue(mo.matchline == ml) - self.assertTrue(mo.check_types()) - # An error is raised if parsing the wrong version try: mo = MatchSnoteNoteV0.from_matchline(ml, version=Version(1, 0, 0)) @@ -806,8 +863,6 @@ def test_snote_lines_v_0_4_0(self): basic_line_test(mo) self.assertTrue(mo.matchline == ml) - self.assertTrue(mo.check_types()) - def test_insertion_lines_v_0_3_0(self): insertion_lines = [ From 5fc3714437aec6b2bad89fe9396570017fefabd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Thu, 24 Nov 2022 09:05:07 +0100 Subject: [PATCH 47/88] test deletion lines for 1.0.0 --- partitura/io/matchlines_v1.py | 2 +- tests/test_match_import_new.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/partitura/io/matchlines_v1.py b/partitura/io/matchlines_v1.py index d8c4ed3c..f3af5748 100644 --- a/partitura/io/matchlines_v1.py +++ b/partitura/io/matchlines_v1.py @@ -680,7 +680,7 @@ def from_matchline( kwargs = cls.prepare_kwargs_from_matchline( matchline=matchline, - note_class=MatchNote, + snote_class=MatchSnote, version=version, ) diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index 63920d9c..df47a342 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -463,6 +463,29 @@ def test_snotenote_lines(self): except ValueError: self.assertTrue(True) + def test_deletion_lines(self): + + deletion_lines = [ + "snote(n158,[A,n],3,8:2,0,1/16,15.0000,15.2500,[v3])-deletion.", + "snote(n270,[F,#],4,14:1,0,1/16,26.0000,26.2500,[v2])-deletion.", + "snote(n323,[A,#],3,16:1,0,1/16,30.0000,30.2500,[v4])-deletion.", + "snote(n325,[E,n],3,16:1,0,1/16,30.0000,30.2500,[v6])-deletion.", + "snote(n328,[A,#],4,16:1,1/16,1/16,30.2500,30.5000,[v2])-deletion.", + "snote(n331,[F,#],3,16:1,1/16,1/16,30.2500,30.5000,[v5])-deletion.", + "snote(n99-1,[E,n],4,8:3,0,1/8,44.0000,45.0000,[staff1])-deletion.", + "snote(n99-2,[E,n],4,16:3,0,1/8,92.0000,93.0000,[staff1])-deletion.", + "snote(n238-1,[A,n],4,26:4,0,1/4,153.0000,155.0000,[staff1])-deletion.", + "snote(n238-2,[A,n],4,36:4,0,1/4,213.0000,215.0000,[staff1])-deletion.", + ] + + for ml in deletion_lines: + + mo = MatchSnoteDeletionV1.from_matchline(ml, version=Version(1, 0, 0)) + + basic_line_test(mo) + + self.assertTrue(mo.matchline == ml) + def test_insertion_lines(self): insertion_lines = [ From 838dbb6dfe3e28efed2ec15b9d2271e87f391e1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Thu, 24 Nov 2022 09:51:32 +0100 Subject: [PATCH 48/88] add deletion lines for version < 1.0.0 --- partitura/io/matchlines_v0.py | 38 +++++++++++ tests/test_match_import_new.py | 120 ++++++++++++++++++++++++++++++--- 2 files changed, 150 insertions(+), 8 deletions(-) diff --git a/partitura/io/matchlines_v0.py b/partitura/io/matchlines_v0.py index 27695bde..4b2f936f 100644 --- a/partitura/io/matchlines_v0.py +++ b/partitura/io/matchlines_v0.py @@ -18,6 +18,7 @@ BaseSnoteLine, BaseNoteLine, BaseSnoteNoteLine, + BaseDeletionLine, BaseInsertionLine, ) @@ -562,6 +563,43 @@ def from_matchline( return cls(**kwargs) +class MatchSnoteDeletion(BaseDeletionLine): + def __init__(self, version: Version, snote: MatchSnote) -> None: + + super().__init__( + version=version, + snote=snote, + ) + + @classmethod + def from_matchline( + cls, + matchline: str, + version: Version = LAST_VERSION, + ) -> MatchSnoteDeletion: + + if version >= Version(1, 0, 0): + raise ValueError(f"{version} >= Version(1, 0, 0)") + + kwargs = cls.prepare_kwargs_from_matchline( + matchline=matchline, + snote_class=MatchSnote, + version=version, + ) + + return cls(**kwargs) + + +class MatchSnoteTrailingScore(MatchSnoteDeletion): + out_pattern = "{SnoteLine}-trailing_score_note." + + def __init__(self, version: Version, snote: MatchSnote) -> None: + super().__init__(version=version, snote=snote) + self.pattern = re.compile( + rf"{self.snote.pattern.pattern}-trailing_score_note\." + ) + + class MatchInsertionNote(BaseInsertionLine): def __init__(self, version: Version, note: MatchNote) -> None: diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index df47a342..34cf6223 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -25,6 +25,8 @@ MatchSnote as MatchSnoteV0, MatchNote as MatchNoteV0, MatchSnoteNote as MatchSnoteNoteV0, + MatchSnoteDeletion as MatchSnoteDeletionV0, + MatchSnoteTrailingScore as MatchSnoteTrailingScoreV0, MatchInsertionNote as MatchInsertionNoteV0, MatchHammerBounceNote as MatchHammerBounceNoteV0, MatchTrailingPlayedNote as MatchTrailingPlayedNoteV0, @@ -45,6 +47,14 @@ def basic_line_test(ml: MatchLine, verbose: bool = False) -> None: """ Test that the matchline has the correct number and type of the mandatory attributes. + + Parameters + ---------- + ml : MatchLine + The MatchLine to be tested + verbose: bool + Print whether each of the attributes of the match line have the correct + data type """ # check that field names and field types have the same number of elements @@ -220,10 +230,17 @@ def test_score_prop_lines(self): # assert that the information from the matchline # is parsed correctly and results in an identical line # to the input match line - mo = MatchScorePropV1.from_matchline(ml) + mo = MatchScorePropV1.from_matchline(ml, version=Version(1, 0, 0)) basic_line_test(mo) self.assertTrue(mo.matchline == ml) + # An error is raised if parsing the wrong version + try: + mo = MatchScorePropV1.from_matchline(ml, version=Version(0, 5, 0)) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + try: # This is not a valid line and should result in a MatchError wrong_line = "wrong_line" @@ -267,9 +284,7 @@ def test_section_lines(self): # Check version (an error should be raised for old versions) try: - mo = MatchSectionV1.from_matchline( - section_lines[0], version=Version(0, 5, 0) - ) + mo = MatchSectionV1.from_matchline(ml, version=Version(0, 5, 0)) self.assertTrue(False) # pragma: no cover except ValueError: @@ -356,7 +371,7 @@ def test_snote_lines(self): # assert that the information from the matchline # is parsed correctly and results in an identical line # to the input match line - mo = MatchSnoteV1.from_matchline(ml) + mo = MatchSnoteV1.from_matchline(ml, version=Version(1, 0, 0)) # test __str__ method self.assertTrue( all( @@ -393,6 +408,13 @@ def test_snote_lines(self): except MatchError: self.assertTrue(True) + # An error is raised if parsing the wrong version + try: + mo = MatchSnoteV1.from_matchline(ml, version=Version(0, 5, 0)) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + # Wrong version try: mo = MatchSnoteV1( @@ -426,13 +448,20 @@ def test_note_lines(self): # assert that the information from the matchline # is parsed correctly and results in an identical line # to the input match line - mo = MatchNoteV1.from_matchline(ml) + mo = MatchNoteV1.from_matchline(ml, version=Version(1, 0, 0)) basic_line_test(mo) self.assertTrue(mo.matchline == ml) # assert that the data types of the match line are correct self.assertTrue(mo.check_types()) + # An error is raised if parsing the wrong version + try: + mo = MatchNoteV1.from_matchline(ml, version=Version(0, 5, 0)) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + def test_snotenote_lines(self): snotenote_lines = [ @@ -486,6 +515,13 @@ def test_deletion_lines(self): self.assertTrue(mo.matchline == ml) + # An error is raised if parsing the wrong version + try: + mo = MatchSnoteDeletionV1.from_matchline(ml, version=Version(0, 5, 0)) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + def test_insertion_lines(self): insertion_lines = [ @@ -863,8 +899,6 @@ def test_snotenote_lines_v_0_3_0(self): basic_line_test(mo) self.assertTrue(mo.matchline == ml) - self.assertTrue(mo.check_types()) - def test_snote_lines_v_0_4_0(self): snotenote_lines = [ @@ -886,6 +920,76 @@ def test_snote_lines_v_0_4_0(self): basic_line_test(mo) self.assertTrue(mo.matchline == ml) + def test_deletion_lines_v_0_4_0(self): + + deletion_lines = [ + "snote(61-2,[E,n],4,13:3,0,1/8,74.0,75.0,[staff2])-deletion.", + "snote(99-2,[E,n],4,16:3,0,1/8,92.0,93.0,[staff1])-deletion.", + "snote(167-1,[E,n],4,21:3,0,1/8,122.0,123.0,[staff2])-deletion.", + "snote(244-1,[E,n],3,26:3,0,1/8,152.0,153.0,[staff2])-deletion.", + "snote(238-1,[A,n],4,26:4,0,2/8,153.0,155.0,[staff1])-deletion.", + "snote(167-2,[E,n],4,31:3,0,1/8,182.0,183.0,[staff2])-deletion.", + "snote(244-2,[E,n],3,36:3,0,1/8,212.0,213.0,[staff2])-deletion.", + "snote(238-2,[A,n],4,36:4,0,2/8,213.0,215.0,[staff1])-deletion.", + ] + + for ml in deletion_lines: + + for minor_version in (4, 5): + mo = MatchSnoteDeletionV0.from_matchline( + ml, + version=Version(0, minor_version, 0), + ) + basic_line_test(mo) + self.assertTrue(mo.matchline == ml) + + # An error is raised if parsing the wrong version + try: + mo = MatchSnoteDeletionV0.from_matchline(ml, version=Version(1, 0, 0)) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + + def test_trailing_score_lines(self): + + # These are all trailing_score_note lines in the Batik dataset + trailing_score_lines = [ + "snote(n5283,[e,n],4,216:5,1/16/3,1/16/3,1294.1666666666667,1294.3333333333333,[s,stacc])-trailing_score_note.", + "snote(n5284,[f,#],4,216:5,2/16/3,1/16/3,1294.3333333333333,1294.5,[s,stacc])-trailing_score_note.", + "snote(n5285,[g,n],4,216:5,3/16/3,1/16/3,1294.5,1294.6666666666667,[s,stacc])-trailing_score_note.", + "snote(n5286,[g,#],4,216:5,4/16/3,1/16/3,1294.6666666666667,1294.8333333333333,[s,stacc])-trailing_score_note.", + "snote(n5287,[a,n],4,216:5,5/16/3,1/16/3,1294.8333333333333,1295.0,[s,stacc])-trailing_score_note.", + "snote(n5288,[a,#],4,216:6,0,1/16/3,1295.0,1295.1666666666667,[s,stacc])-trailing_score_note.", + "snote(n5289,[b,n],4,216:6,1/16/3,1/16/3,1295.1666666666667,1295.3333333333333,[s,stacc])-trailing_score_note.", + "snote(n5290,[b,#],4,216:6,2/16/3,1/16/3,1295.3333333333333,1295.5,[s,stacc])-trailing_score_note.", + "snote(n5291,[c,#],5,216:6,3/16/3,1/16/3,1295.5,1295.6666666666667,[s,stacc])-trailing_score_note.", + "snote(n5292,[d,n],5,216:6,4/16/3,1/16/3,1295.6666666666667,1295.8333333333333,[s,stacc])-trailing_score_note.", + "snote(n5293,[d,#],5,216:6,5/16/3,1/16/3,1295.8333333333333,1296.0,[s,stacc])-trailing_score_note.", + "snote(n2233,[c,#],4,200:1,2/16,1/8,597.5,598.0,[s])-trailing_score_note.", + "snote(n2234,[d,n],4,200:2,0,1/8,598.0,598.5,[s])-trailing_score_note.", + "snote(n2235,[e,n],4,200:2,2/16,1/8,598.5,599.0,[s])-trailing_score_note.", + "snote(n2236,[f,#],4,200:3,0,1/8,599.0,599.5,[s])-trailing_score_note.", + "snote(n2237,[g,n],4,200:3,2/16,1/8,599.5,600.0,[s])-trailing_score_note.", + "snote(n781,[r,-],-,36:3,0,1/4,107.0,108.0,[fermata])-trailing_score_note.", + "snote(n1304,[r,-],-,45:4,0,1/4,179.0,180.0,[fermata])-trailing_score_note.", + ] + + for ml in trailing_score_lines: + + mo = MatchSnoteTrailingScoreV0.from_matchline( + ml, + version=Version(0, 3, 0), + ) + basic_line_test(mo) + self.assertTrue(mo.matchline == ml) + + # An error is raised if parsing the wrong version + try: + mo = MatchSnoteDeletionV0.from_matchline(ml, version=Version(1, 0, 0)) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + def test_insertion_lines_v_0_3_0(self): insertion_lines = [ From 1263b6e665101c649e16c808ba0da427638dcc32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Thu, 24 Nov 2022 10:52:07 +0100 Subject: [PATCH 49/88] minor --- partitura/io/matchfile_base.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/partitura/io/matchfile_base.py b/partitura/io/matchfile_base.py index 540a5ae5..1f969fbb 100644 --- a/partitura/io/matchfile_base.py +++ b/partitura/io/matchfile_base.py @@ -649,7 +649,12 @@ def prepare_kwargs_from_matchline( return kwargs -snote_classes = (BaseSnoteLine, BaseSnoteNoteLine) +## MatchFile + +# classes that contain score notes +snote_classes = (BaseSnoteLine, BaseSnoteNoteLine, BaseDeletionLine) + +# classes that contain performed notes. note_classes = (BaseNoteLine, BaseSnoteNoteLine, BaseInsertionLine) From 16c329c170368a86decbb7b92517c0b3b2e0cedd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Thu, 24 Nov 2022 16:11:08 +0100 Subject: [PATCH 50/88] add StimePtime lines --- partitura/io/matchfile_base.py | 147 +++++++++++++++++++++++++++- partitura/io/matchfile_utils.py | 5 + partitura/io/matchlines_v1.py | 163 ++++++++++++++++++++++++++++++++ tests/test_match_import_new.py | 117 +++++++++++++++++++++++ 4 files changed, 431 insertions(+), 1 deletion(-) diff --git a/partitura/io/matchfile_base.py b/partitura/io/matchfile_base.py index 1f969fbb..22aad9e7 100644 --- a/partitura/io/matchfile_base.py +++ b/partitura/io/matchfile_base.py @@ -33,6 +33,7 @@ format_fractional, interpret_as_fractional, interpret_as_list, + interpret_as_list_int, format_list, ) @@ -182,7 +183,7 @@ def check_types(self, verbose: bool = False) -> bool: return types_are_correct -## The following classes define match lines that appear in all matchfile versions +## The following classes define base information for match lines ## These classes need to be subclassed in the corresponding module for each version. @@ -226,6 +227,150 @@ def __init__( self.Value = value +class BaseStimeLine(MatchLine): + """ + stime(Measure:Beat,Offset,OnsetInBeats,AnnotationType) + """ + + field_names = ("Measure", "Beat", "Offset", "OnsetInBeats", "AnnotationType") + + field_types = (int, int, FractionalSymbolicDuration, float, list) + + format_fun = dict( + Measure=format_int, + Beat=format_int, + Offset=format_fractional, + OnsetInBeats=format_float, + AnnotationType=format_list, + ) + + out_pattern = "stime({Measure}:{Beat},{Offset},{OnsetInBeats},{AnnotationType})" + + pattern = re.compile( + r"stime\(" + r"(?P[^,]+):(?P[^,]+)," + r"(?P[^,]+)," + r"(?P[^,]+)," + r"\[(?P[a-z,]*)\]\)" + ) + + def __init__( + self, + version: Version, + measure: int, + beat: int, + offset: FractionalSymbolicDuration, + onset_in_beats: float, + annotation_type: List[str], + ) -> None: + super().__init__(version) + + self.Measure = measure + self.Beat = beat + self.Offset = offset + self.OnsetInBeats = onset_in_beats + self.AnnotationType = annotation_type + + +class BasePtimeLine(MatchLine): + """ + ptime(Onsets) + """ + + field_names = ("Onsets",) + field_types = (list,) + + out_pattern = "ptime({Onsets})." + + pattern = re.compile(r"ptime\(\[(?P[0-9,]+)\]\)\.") + + format_fun = dict(Onsets=format_list) + + def __init__(self, version: Version, onsets: List[int]) -> None: + super().__init__(version) + self.Onsets = onsets + + @property + def Onset(self): + return np.mean(self.Onsets) + + +class BaseStimePtimeLine(MatchLine): + + out_pattern = "{StimeLine}-{PtimeLine}" + + def __init__( + self, + version: Version, + stime: BaseStimeLine, + ptime: BasePtimeLine, + ) -> None: + super().__init__(version) + + self.stime = stime + self.ptime = ptime + + self.field_names = self.stime.field_names + self.ptime.field_names + self.field_types = self.stime.field_types + self.ptime.field_types + + self.pattern = (self.stime.pattern, self.ptime.pattern) + + self.format_fun = (self.stime.format_fun, self.ptime.format_fun) + + + @property + def matchline(self) -> str: + return self.out_pattern.format( + StimeLine=self.stime.matchline, + PtimeLine=self.ptime.matchline, + ) + + def __str__(self) -> str: + """ + String magic method + """ + r = [self.__class__.__name__] + r += [" Stime"] + [ + " {0}: {1}".format(fn, getattr(self.stime, fn, None)) + for fn in self.stime.field_names + ] + + r += [" Ptime"] + [ + " {0}: {1}".format(fn, getattr(self.ptime, fn, None)) + for fn in self.ptime.field_names + ] + + return "\n".join(r) + "\n" + + def check_types(self, verbose: bool) -> bool: + + stime_types_are_correct = self.stime.check_types(verbose) + ptime_types_are_correct = self.ptime.check_types(verbose) + + types_are_correct = stime_types_are_correct and ptime_types_are_correct + + return types_are_correct + + @classmethod + def prepare_kwargs_from_matchline( + cls, + matchline: str, + stime_class: BaseStimeLine, + ptime_class: BaseNoteLine, + version: Version, + ) -> Dict: + stime = stime_class.from_matchline(matchline, version=version) + ptime = ptime_class.from_matchline(matchline, version=version) + + kwargs = dict( + version=version, + stime=stime, + ptime=ptime, + ) + + return kwargs + + # deprecate bar for measure class BaseSnoteLine(MatchLine): """ diff --git a/partitura/io/matchfile_utils.py b/partitura/io/matchfile_utils.py index ee2ac064..b0c22d36 100644 --- a/partitura/io/matchfile_utils.py +++ b/partitura/io/matchfile_utils.py @@ -532,6 +532,11 @@ def interpret_as_list(value: str) -> List[str]: return content_list +def interpret_as_list_int(value: str) -> List[int]: + string_list = interpret_as_list(value) + return [int(v) for v in string_list] + + def format_list(value: List[Any]) -> str: formatted_string = f"[{','.join([str(v) for v in value])}]" return formatted_string diff --git a/partitura/io/matchlines_v1.py b/partitura/io/matchlines_v1.py index f3af5748..613241f3 100644 --- a/partitura/io/matchlines_v1.py +++ b/partitura/io/matchlines_v1.py @@ -20,6 +20,9 @@ Version, BaseInfoLine, BaseSnoteLine, + BaseStimeLine, + BasePtimeLine, + BaseStimePtimeLine, BaseNoteLine, BaseSnoteNoteLine, BaseDeletionLine, @@ -39,6 +42,7 @@ format_fractional, interpret_as_fractional, interpret_as_list, + interpret_as_list_int, format_list, to_camel_case, get_kwargs_from_matchline, @@ -439,6 +443,132 @@ def from_matchline( raise MatchError("Input match line does not fit the expected pattern.") +STIME_LINE = { + Version(1, 0, 0): { + "Measure": (interpret_as_int, format_int, int), + "Beat": (interpret_as_int, format_int, int), + "Offset": ( + interpret_as_fractional, + format_fractional, + FractionalSymbolicDuration, + ), + "OnsetInBeats": (interpret_as_float, format_float, float), + "AnnotationType": (interpret_as_list, format_list, list), + } +} + + +class MatchStime(BaseStimeLine): + def __init__( + self, + version: Version, + measure: int, + beat: int, + offset: FractionalSymbolicDuration, + onset_in_beats: float, + annotation_type: List[str], + ) -> None: + + if version < Version(1, 0, 0): + raise ValueError(f"{version} < Version(1, 0, 0)") + + super().__init__( + version=version, + measure=measure, + beat=beat, + offset=offset, + onset_in_beats=onset_in_beats, + annotation_type=annotation_type, + ) + + self.field_types = tuple(STIME_LINE[version][fn][2] for fn in self.field_names) + self.format_fun = dict( + [(fn, STIME_LINE[version][fn][1]) for fn in self.field_names] + ) + + @classmethod + def from_matchline( + cls, + matchline: str, + pos: int = 0, + version: Version = LATEST_VERSION, + ) -> MatchStime: + + if version not in STIME_LINE: + raise ValueError( + f"Unknown version {version}!. " + f"Supported versions are {list(STIME_LINE.keys())}" + ) + + kwargs = get_kwargs_from_matchline( + matchline=matchline, + pattern=cls.pattern, + field_names=cls.field_names, + class_dict=STIME_LINE[version], + pos=pos, + ) + + if kwargs is None: + raise MatchError("Input match line does not fit the expected pattern.") + + return cls(version=version, **kwargs) + + +PTIME_LINE = { + Version(1, 0, 0): { + "Onsets": (interpret_as_list_int, format_list, list), + } +} + + +class MatchPtime(BasePtimeLine): + def __init__( + self, + version: Version, + onsets: List[int], + ) -> None: + + if version < Version(1, 0, 0): + raise ValueError(f"{version} < Version(1, 0, 0)") + + super().__init__( + version=version, + onsets=onsets, + ) + + self.field_types = tuple(PTIME_LINE[version][fn][2] for fn in self.field_names) + self.format_fun = dict( + [(fn, PTIME_LINE[version][fn][1]) for fn in self.field_names] + ) + + @classmethod + def from_matchline( + cls, + matchline: str, + pos: int = 0, + version: Version = LATEST_VERSION, + ) -> MatchStime: + + if version not in PTIME_LINE: + raise ValueError( + f"Unknown version {version}!. " + f"Supported versions are {list(STIME_LINE.keys())}" + ) + + kwargs = get_kwargs_from_matchline( + matchline=matchline, + pattern=cls.pattern, + field_names=cls.field_names, + class_dict=PTIME_LINE[version], + pos=pos, + ) + + if kwargs is None: + raise MatchError("Input match line does not fit the expected pattern.") + + return cls(version=version, **kwargs) + + class MatchSnote(BaseSnoteLine): format_fun = dict( @@ -626,6 +756,39 @@ def from_matchline( raise MatchError("Input match line does not fit the expected pattern.") +class MatchStimePtime(BaseStimePtimeLine): + def __init__( + self, + version: Version, + stime: MatchStime, + ptime: MatchPtime, + ) -> None: + super().__init__( + version=version, + stime=stime, + ptime=ptime, + ) + + @classmethod + def from_matchline( + cls, + matchline: str, + version: Version = LATEST_VERSION, + ) -> MatchSnoteNote: + + if version < Version(1, 0, 0): + raise ValueError(f"{version} < Version(1, 0, 0)") + + kwargs = cls.prepare_kwargs_from_matchline( + matchline=matchline, + stime_class=MatchStime, + ptime_class=MatchPtime, + version=version, + ) + + return cls(**kwargs) + + class MatchSnoteNote(BaseSnoteNoteLine): def __init__( self, diff --git a/tests/test_match_import_new.py b/tests/test_match_import_new.py index 34cf6223..29f54d1f 100644 --- a/tests/test_match_import_new.py +++ b/tests/test_match_import_new.py @@ -13,6 +13,9 @@ MatchInfo as MatchInfoV1, MatchScoreProp as MatchScorePropV1, MatchSection as MatchSectionV1, + MatchStime as MatchStimeV1, + MatchPtime as MatchPtimeV1, + MatchStimePtime as MatchStimePtimeV1, MatchSnote as MatchSnoteV1, MatchNote as MatchNoteV1, MatchSnoteNote as MatchSnoteNoteV1, @@ -299,6 +302,120 @@ def test_section_lines(self): except MatchError: self.assertTrue(True) + def test_stime_lines(self): + + stime_lines = [ + "stime(1:1,0,0.0000,[beat])", + "stime(2:1,0,4.0000,[downbeat,beat])", + "stime(0:3,0,-1.0000,[beat])", + ] + + for ml in stime_lines: + + mo = MatchStimeV1.from_matchline(ml, version=Version(1, 0, 0)) + basic_line_test(mo) + + self.assertTrue(mo.matchline == ml) + + try: + # This is not a valid line and should result in a MatchError + wrong_line = "wrong_line" + mo = MatchStimeV1.from_matchline(wrong_line) + self.assertTrue(False) # pragma: no cover + except MatchError: + self.assertTrue(True) + + # An error is raised if parsing the wrong version + try: + mo = MatchStimeV1.from_matchline(ml, version=Version(0, 5, 0)) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + + # Wrong version + try: + mo = MatchStimeV1( + version=Version(0, 5, 0), + measure=1, + beat=1, + offset=FractionalSymbolicDuration(0), + onset_in_beats=0.0, + annotation_type=["beat", "downbeat"], + ) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + + def test_ptime_lines(self): + + stime_lines = [ + "ptime([1000,1001,999]).", + "ptime([765]).", + "ptime([3141592]).", + ] + + for ml in stime_lines: + + mo = MatchPtimeV1.from_matchline(ml, version=Version(1, 0, 0)) + basic_line_test(mo) + + self.assertTrue(mo.matchline == ml) + + try: + # This is not a valid line and should result in a MatchError + wrong_line = "wrong_line" + mo = MatchPtimeV1.from_matchline(wrong_line) + self.assertTrue(False) # pragma: no cover + except MatchError: + self.assertTrue(True) + + # An error is raised if parsing the wrong version + try: + mo = MatchPtimeV1.from_matchline(ml, version=Version(0, 5, 0)) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + + # Wrong version + try: + mo = MatchPtimeV1( + version=Version(0, 5, 0), + onsets=[8765, 8754], + ) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + + def test_stimeptime_lines(self): + + stime_lines = [ + "stime(1:1,0,0.0000,[beat])-ptime([1000,1001,999]).", + "stime(2:1,0,4.0000,[downbeat,beat])-ptime([765]).", + "stime(0:3,0,-1.0000,[beat])-ptime([3141592]).", + ] + + for ml in stime_lines: + + mo = MatchStimePtimeV1.from_matchline(ml, version=Version(1, 0, 0)) + basic_line_test(mo) + + self.assertTrue(mo.matchline == ml) + + try: + # This is not a valid line and should result in a MatchError + wrong_line = "wrong_line" + mo = MatchStimePtimeV1.from_matchline(wrong_line) + self.assertTrue(False) # pragma: no cover + except MatchError: + self.assertTrue(True) + + # An error is raised if parsing the wrong version + try: + mo = MatchStimePtimeV1.from_matchline(ml, version=Version(0, 5, 0)) + self.assertTrue(False) # pragma: no cover + except ValueError: + self.assertTrue(True) + def test_snote_lines(self): snote_lines = [ From 564d24b0c999d2c5c6be6a8e928dad5ecc7f9fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cancino-Chac=C3=B3n?= Date: Fri, 25 Nov 2022 01:18:34 +0100 Subject: [PATCH 51/88] add pedal lines --- partitura/io/matchfile_base.py | 137 ++++++++++++++++++++++-- partitura/io/matchlines_v0.py | 76 ++++++++++++++ partitura/io/matchlines_v1.py | 76 ++++++++++++++ tests/test_match_import_new.py | 184 +++++++++++++++++++++++++-------- 4 files changed, 421 insertions(+), 52 deletions(-) diff --git a/partitura/io/matchfile_base.py b/partitura/io/matchfile_base.py index 22aad9e7..802057c8 100644 --- a/partitura/io/matchfile_base.py +++ b/partitura/io/matchfile_base.py @@ -317,7 +317,6 @@ def __init__( self.format_fun = (self.stime.format_fun, self.ptime.format_fun) - @property def matchline(self) -> str: return self.out_pattern.format( @@ -794,6 +793,108 @@ def prepare_kwargs_from_matchline( return kwargs +class BasePedalLine(MatchLine): + """ + Class for representing a sustain pedal line + """ + + field_names = ("Time", "Value") + field_types = (int, int) + base_pattern: str = r"pedal\((?P