ms3.score — ms3 0.1.2.post0.dev2+g9d887e1.dirty documentation

# Source code for ms3.score

    import os, re

    import pandas as pd

    from .utils import decode_harmonies, resolve_dir
    from .bs4_parser import _MSCX_bs4
    from .annotations import Annotations
    from .logger import get_logger

    class Score:
        """ Object representing a score.

        Attributes
        ----------
        infer_label_types : :obj:`list` or :obj:`dict`, optional
            Changing this value results in a call to :py:meth:`~ms3.annotations.Annotations.infer_types`.
        logger : :obj:`logging.Logger` or :obj:`logging.LoggerAdapter`
            Current logger that the object is using.
        parser : {'bs4'}
            The only XML parser currently implemented is BeautifulSoup 4.
        paths, files, fnames, fexts, logger_names : :obj:`dict`
            Dictionaries for keeping track of file information handled by :py:meth:`~ms3.score.Score.handle_path`.
        _annotations :
        _harmony_regex :
        _label_types :
        _mscx : :obj:`MSCX`
            After parsing, holds the `MSCX` object with the parsed score.
        _types_to_infer

        Methods
        -------
        handle_path(path, key)
            Puts the path into `paths, files, fnames, fexts` dicts with the given key.
        """

        abs_regex = r"^\(?[A-G|a-g](b*|#*).*?(/[A-G|a-g](b*|#*))?$"

        dcml_regex = re.compile(r"""
                                    ^(?P<first>
                                      (\.?
                                        ((?P<globalkey>[a-gA-G](b*|\#*))\.)?
                                        ((?P<localkey>(b*|\#*)(VII|VI|V|IV|III|II|I|vii|vi|v|iv|iii|ii|i))\.)?
                                        ((?P<pedal>(b*|\#*)(VII|VI|V|IV|III|II|I|vii|vi|v|iv|iii|ii|i))\[)?
                                        (?P<chord>
                                            (?P<numeral>(b*|\#*)(VII|VI|V|IV|III|II|I|vii|vi|v|iv|iii|ii|i|Ger|It|Fr|@none))
                                            (?P<form>(%|o|\+|M|\+M))?
                                            (?P<figbass>(7|65|43|42|2|64|6))?
                                            (\((?P<changes>((\+|-|\^)?(b*|\#*)\d)+)\))?
                                            (/(?P<relativeroot>((b*|\#*)(VII|VI|V|IV|III|II|I|vii|vi|v|iv|iii|ii|i)/?)*))?
                                        )
                                        (?P<pedalend>\])?
                                      )?
                                      (?P<phraseend>(\\\\|\{|\}|\}\{)
                                      )?
                                     )
                                     (?P<second>
                                      (-
                                        ((?P<globalkey2>[a-gA-G](b*|\#*))\.)?
                                        ((?P<localkey2>(b*|\#*)(VII|VI|V|IV|III|II|I|vii|vi|v|iv|iii|ii|i))\.)?
                                        ((?P<pedal2>(b*|\#*)(VII|VI|V|IV|III|II|I|vii|vi|v|iv|iii|ii|i))\[)?
                                        (?P<chord2>
                                            (?P<numeral2>(b*|\#*)(VII|VI|V|IV|III|II|I|vii|vi|v|iv|iii|ii|i|Ger|It|Fr|@none))
                                            (?P<form2>(%|o|\+|M|\+M))?
                                            (?P<figbass2>(7|65|43|42|2|64|6))?
                                            (\((?P<changes2>((\+|-|\^)?(b*|\#*)\d)+)\))?
                                            (/(?P<relativeroot2>((b*|\#*)(VII|VI|V|IV|III|II|I|vii|vi|v|iv|iii|ii|i)/?)*))?
                                        )
                                        (?P<pedalend2>\])?
                                      )?
                                      (?P<phraseend2>(\\\\|\{|\}|\}\{)
                                      )?
                                     )?
                                    $
                                    """,
                                re.VERBOSE)

        nashville_regex = r"^(b*|#*)(\d).*$"

        rn_regex = r"^$"

        def __init__(self, mscx_src=None, infer_label_types=['dcml'], read_only=False, logger_name='Score', level=None,
                     parser='bs4'):
            """

            Parameters
            ----------
            mscx_src : :obj:`str`, optional
                Path to the MuseScore file to be parsed.
            infer_label_types : :obj:`list` or :obj:`dict`, optional
                Determine which label types are determined automatically. Defaults to ['dcml'].
                Pass ``[]`` to infer only main types 0 - 3.
                Pass ``{'type_name': r"^(regular)(Expression)$"} to call :meth:`ms3.Score.new_type`.
            read_only : :obj:`bool`, optional
                Defaults to ``False``, meaning that the parsing is slower and uses more memory in order to allow for manipulations
                of the score, such as adding and deleting labels. Set to ``True`` if you're only extracting information.
            logger_name : :obj:`str`, optional
                If you have defined a logger, pass its name. Otherwise, the MSCX filename is used.
            level : {'W', 'D', 'I', 'E', 'C', 'WARNING', 'DEBUG', 'INFO', 'ERROR', 'CRITICAL'}, optional
                Pass a level name for which (and above which) you want to see log records.
            parser : {'bs4'}, optional
                The only XML parser currently implemented is BeautifulSoup 4.
            """
            self.logger = get_logger(logger_name, level)
            self.full_paths, self.paths, self.files, self.fnames, self.fexts, self.logger_names = {}, {}, {}, {}, {}, {}
            self._mscx = None
            self._annotations = {}
            self._types_to_infer = []
            self._label_types = {
                0: "Simple string (should not begin with a note name, otherwise MS3 will turn it into type 3; prevent through leading dot)",
                1: "MuseScore's Roman Numeral Annotation format",
                2: "MuseScore's Nashville Number format",
                3: "Absolute chord encoded by MuseScore",
                'dcml': "Latest version of the DCML harmonic annotation standard.",
            }
            self._harmony_regex = {
                1: self.rn_regex,
                2: self.nashville_regex,
                3: self.abs_regex,
                'dcml': self.dcml_regex,
            }
            self.infer_label_types = infer_label_types
            self.parser = parser
            if mscx_src is not None:
                self.parse_mscx(mscx_src, read_only=read_only)

        @property
        def infer_label_types(self):
            return self._types_to_infer

        @infer_label_types.setter
        def infer_label_types(self, val):
            if val is None:
                val = []
            before_inf, before_reg = self._types_to_infer, self.get_infer_regex()
            if isinstance(val, list):
                exist = [v for v in val if v in self._harmony_regex]
                if len(exist) < len(val):
                    logger.warning(f"The following harmony types have not been added via the new_type() method:\n{[v for v in val if v not in self._harmony_regex]}")
                self._types_to_infer = exist
            elif isinstance(val, dict):
                for k, v in val.items():
                    if k in self._harmony_regex:
                        if v is None:
                            val[k] = self._harmony_regex[k]
                        else:
                            self._harmony_regex[k] = v
                    else:
                        self.new_type(name=k, regex=v)
                self._types_to_infer = list(val.keys())
            after_reg = self.get_infer_regex()
            if before_inf != self._types_to_infer or before_reg != after_reg:
                for ann in self._annotations.values():
                    ann.infer_types(after_reg)

        def get_infer_regex(self):
            return {t: self._harmony_regex[t] for t in self._types_to_infer}

        def handle_path(self, path, key=None):
            full_path = resolve_dir(path)
            if os.path.isfile(full_path):
                file_path, file = os.path.split(full_path)
                file_name, file_ext = os.path.splitext(file)
                if key is None:
                    key = file_ext[1:]
                elif key == 'file':
                    key = file
                self.full_paths[key] = full_path
                self.paths[key] = file_path
                self.files[key] = file
                self.fnames[key] = file_name
                self.fexts[key] = file_ext
                self.logger_names[key] = file_name.replace('.', '')
                return key
            else:
                self.logger.error("No file found at this path: " + full_path)
                return None

        def parse_mscx(self, mscx_src, read_only=False, parser=None, logger_name=None):
            _ = self.handle_path(mscx_src)
            if parser is not None:
                self.parser = parser
            if 'mscx' in self.fnames:
                ln = self.logger_names['mscx'] if logger_name is None else logger_name
                self._mscx = MSCX(self.full_paths['mscx'], read_only=read_only, parser=self.parser, logger_name=ln)
                if self._mscx.has_annotations:
                    self._annotations['annotations'] = self._mscx._annotations
                    self._annotations['annotations'].infer_types(self.get_infer_regex())

            else:
                self.logger.error("No .mscx file specified.")

        def output_mscx(self, filepath):
            self._mscx.output_mscx(filepath)

        def detach_labels(self, key, staff=None, voice=None, label_type=None, delete=True):
            if 'annotations' not in self._annotations:
                self.logger.info("No annotations present in score.")
                return
            df = self.annotations.get_labels(staff=staff, voice=voice, label_type=label_type, drop=delete)
            if len(df) == 0:
                self.logger.info(f"No labels found for staff {staff}, voice {voice}, label_type {label_type}.")
                return
            self._annotations[key] = Annotations(df=df, infer_types=self.get_infer_regex(), logger_name=f"{self.logger.name}--{key}")
            if delete:
                self._mscx.delete_labels(df)
            if len(self._annotations['annotations'].df) == 0:
                self._mscx.has_annotations = False
                del (self._annotations['annotations'])
            return

        def attach_labels(self, key, staff=None, voice=None):
            if key not in self._annotations:
                self.logger.info(f"""Key '{key}' doesn't correspond to a detached set of annotations.
    Use on of the existing keys or load a new set with the method load_annotations().\nExisting keys: {list(self._annotations.keys())}""")
                return
            annotations = self._annotations[key]
            df = annotations.df.copy()
            if staff is not None:
                df.staff = staff
            if voice is not None:
                df.voice = voice
            if len(df) == 0:
                self.logger.warning(f"The annotation set '{key}' does not contain any labels meeting the criteria.")
                return
            self._mscx.add_labels(df)
            self._annotations['annotations'] = self._mscx._annotations
            if len(self._mscx._annotations.df) > 0:
                self._mscx.has_annotations = True

        @property
        def mscx(self):
            """ Returns the `MSCX` object with the parsed score.
            """
            if self._mscx is None:
                raise LookupError("No XML has been parsed so far. Use the method parse_mscx().")
            return self._mscx

        def load_annotations(self, tsv_path, key='file', infer=True):
            key = self.handle_path(tsv_path, key)
            inf_dict = self.get_infer_regex() if infer else {}
            self._annotations[key] = Annotations(tsv_path, infer_types=inf_dict, logger_name=self.logger_names[key])

        def store_annotations(self, key=None, tsv_path=None, **kwargs):
            if key is None:
                assert self._mscx.has_annotations, "Score has no labels attached."
                key = 'annotations'
            if tsv_path is None:
                path = self.paths['mscx']
                fname = self.fnames['mscx']
                tsv_path = os.path.join(path, fname + '_labels.tsv')
            assert key in self._annotations, f"Key '{key}' not found. Available keys: {list(self._annotations.keys())}"
            if self._annotations[key].output_tsv(tsv_path, **kwargs):
                new_key = self.handle_path(tsv_path, key=key)
                if key != 'annotations':
                    self._annotations[key].logger = get_logger(self.logger_names[new_key])

        def __repr__(self):
            msg = ''
            if 'mscx' in self.full_paths:
                msg = f"MuseScore file"
                if self._mscx.changed:
                    msg += " (CHANGED!!!)\n---------------!!!!!!!!!!!!"
                else:
                    msg += "\n--------------"
                msg += f"\n\n{self.full_paths['mscx']}\n\n"
            if 'annotations' in self._annotations:
                msg += f"Attached annotations\n--------------------\n\n{self.annotations}\n\n"
            else:
                msg += "No annotations attached.\n\n"
            if sum(True for key in self._annotations if key != 'annotations') > 0:
                msg += "Detached annotations\n--------------------\n\n"
                for key, obj in self._annotations.items():
                    if key != 'annotations':
                        key_info = key + f" (stored as {self.files[key]})" if key in self.files else key
                        msg += f"{key_info} -> {obj}\n\n"
            return msg

        @property
        def types(self):
            return self._label_types

        def new_type(self, name, regex, description='', infer=True):
            assert name not in self._label_types, f"'{name}' already added to types: {self._label_types[name]}"
            self._label_types[name] = description
            self._harmony_regex[name] = regex
            if infer:
                self._types_to_infer.insert(0, name)
                for ann in self._annotations.values():
                    ann.infer_types(self.get_infer_regex())

        def __getattr__(self, item):
            try:
                return self._annotations[item]
            except:
                AttributeError(item)

        def __getstate__(self):
            return self.__dict__

        def __setstate__(self, d):
            self.__dict__.update(d)

        # def __setattr__(self, key, value):
        #     assert key != 'annotations', "The key 'annotations' is managed automatically, please pick a different one."
        #     assert key.isidentifier(), "Please use an alphanumeric key without special characters."
        #     if key in self.__dict__:
        #         self.__dict__[key] = value
        #     else:
        #         self._annotations[key] = value

    class MSCX:
        """ Object for interacting with the XML structure of a MuseScore 3 file.

        Attributes
        ----------
        mscx_src : :obj:`str`
            MuseScore 3 file to parse.
        _parsed : :obj:`_MSCX_bs4`
            Holds the MSCX score parsed by the selected parser.
        parser : :obj:`str`, optional
            Which XML parser to use.
        infer_label_types :obj:`bool`, optional
            For label_type 0 (simple string), mark which ones
        logger_name : :obj:`str`, optional
            If you have defined a logger, pass its name.
        level : {'W', 'D', 'I', 'E', 'C', 'WARNING', 'DEBUG', 'INFO', 'ERROR', 'CRITICAL'}, optional
            Pass a level name for which (and above which) you want to see log records.

        Methods
        -------
        output_mscx(filepath)
            Write the internal score representation to a file.
        """

        def __init__(self, mscx_src=None, read_only=False, parser='bs4', logger_name='MSCX', level=None):
            self.logger = get_logger(logger_name, level=level)
            self.mscx_src = mscx_src
            self.read_only = read_only
            self._annotations = None
            if parser is not None:
                self.parser = parser
            self._parsed = None
            self.changed = False
            self.output_mscx = None
            self.get_chords = None
            self.get_harmonies = None
            self.get_metadata = None
            self.has_annotations = None

            if self.mscx_src is not None:
                self.parse_mscx()

        def parse_mscx(self, mscx_src=None):
            if mscx_src is not None:
                self.mscx_src = mscx_src
            assert self.mscx_src is not None, "No path specified for parsing MSCX."
            assert os.path.isfile(self.mscx_src), f"{self.mscx_src} does not exist."

            implemented_parsers = ['bs4']
            if self.parser == 'bs4':
                self._parsed = _MSCX_bs4(self.mscx_src, read_only=self.read_only, logger_name=self.logger.name)
            else:
                raise NotImplementedError(f"Only the following parsers are available: {', '.join(implemented_parsers)}")

            self.output_mscx = self._parsed.output_mscx
            self.get_chords = self._parsed.get_chords
            self.get_harmonies = self._parsed.get_annotations
            self.get_metadata = self._parsed.get_metadata
            self.has_annotations = self._parsed.has_annotations

            if self._parsed.has_annotations:
                self._annotations = Annotations(df=self._parsed.get_annotations(), logger_name=self.logger.name)

        def delete_labels(self, df):
            changed = pd.Series([self._parsed.delete_label(mc, staff, voice, onset)
                                   for mc, staff, voice, onset
                                   in df[['mc', 'staff', 'voice', 'onset']].itertuples(name=None, index=False)],
                                index=df.index)
            changes = changed.sum()
            if changes > 0:
                self.changed = True
                self._parsed.parse_measures()
                target = len(df)
                self.logger.debug(f"{changes}/{target} labels successfully deleted.")
                if changes < target:
                    self.logger.warning(f"{target - changes} labels have not been deleted:\n{df.loc[~changed]}")

        def add_labels(self, df, label='label', mc='mc', onset='onset', staff='staff', voice='voice', **kwargs):
            """

            Parameters
            ----------
            df : :obj:`pandas.DataFrame`
                DataFrame with labels to be added.
            label, mc, onset, staff, voice : :obj:`str`
                Names of the DataFrame columns for the five required parameters.
            kwargs:
                label_type, root, base, leftParen, rightParen, offset_x, offset_y, nashville
                    For these parameters, the standard column names are used automatically if the columns are present.
                    If the column names have changed, pass them as kwargs, e.g. ``base='name_of_the_base_column'``

            Returns
            -------
            None

            """
            if len(df) == 0:
                self.logger.info("Nothing to add.")
                return
            cols = dict(
                label_type='label_type',
                root='root',
                base='base',
                leftParen='leftParen',
                rightParen='rightParen',
                offset_x='offset:x',
                offset_y='offset:y',
                nashville='nashville',
                decoded='decoded'
            )
            missing_add = {k: v for k, v in kwargs.items() if v not in df.columns}
            if len(missing_add) > 0:
                self.logger.warning(f"The following specified columns could not be found:\n{missing_add}.")
            main_params = ['label', 'mc', 'onset', 'staff', 'voice']
            l = locals()
            missing_main = {k: l[k] for k in main_params if l[k] not in df.columns}
            assert len(missing_main) == 0, f"The specified columns for the following main parameters are missing:\n{missing_main}"
            main_cols = {k: l[k] for k in main_params}
            cols.update(kwargs)
            if cols['decoded'] not in df.columns:
                df[cols['decoded']] = decode_harmonies(df, return_series=True)
            add_cols = {k: v for k, v in cols.items() if v in df.columns}
            param2cols = {**main_cols, **add_cols}
            parameters = list(param2cols.keys())
            columns = list(param2cols.values())
            self.logger.debug(f"add_label() will be called with this param2col mapping:\n{param2cols}")
            tups = tuple(df[columns].itertuples(index=False, name=None))
            params = [{a: b for a, b in zip(parameters, t)} for t in tups]
            res = [self._parsed.add_label(**p) for p in params]
            changes = sum(res)
            # changes = sum(self.parsed.add_label(**{a: b for a, b in zip(parameters, t)})
            #               for t
            #               in df[columns].itertuples(index=False, name=None)
            #               )
            if changes > 0:
                self.changed = True
                self._parsed.parse_measures()
                self._annotations = Annotations(df=self._parsed.get_annotations(), logger_name=self.logger.name)
                self.logger.debug(f"{changes}/{len(df)} labels successfully added to score.")

        @property
        def parsed(self):
            if self._parsed is None:
                self.logger.error("Score has not been parsed yet.")
                return None
            return self._parsed

        @property
        def measures(self):
            return self._parsed.ml

        @property
        def events(self):
            return self._parsed._events

        @property
        def chords(self):
            return self._parsed.chords

        @property
        def labels(self):
            if self._annotations is None:
                return pd.DataFrame()
            return self._annotations.get_labels()

        @property
        def notes(self):
            return self._parsed.nl

        @property
        def rests(self):
            return self._parsed.rl

        @property
        def notes_and_rests(self):
            return self._parsed.notes_and_rests

        @property
        def expanded(self):
            return self._annotations.expanded

        @property
        def version(self):
            """MuseScore version with which the file was created (read-only)."""
            return self._parsed.version

        # def __getstate__(self):
        #     return self.__dict__