Source code for partitura.io.exportmatch

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
This module contains methods for exporting matchfiles.

Notes
-----
* The methods only export matchfiles version 1.0.0.
"""
import numpy as np

from typing import List, Optional, Iterable

from collections import defaultdict

from fractions import Fraction

from partitura.score import Score, Part, ScoreLike
from partitura.performance import Performance, PerformedPart, PerformanceLike

from partitura.io.matchlines_v1 import (
    make_info,
    make_scoreprop,
    make_section,
    MatchSnote,
    MatchNote,
    MatchSnoteNote,
    MatchSnoteDeletion,
    MatchInsertionNote,
    MatchSustainPedal,
    MatchSoftPedal,
    MatchOrnamentNote,
    LATEST_VERSION,
)

from partitura.io.matchfile_utils import (
    FractionalSymbolicDuration,
    MatchKeySignature,
    MatchTimeSignature,
    Version,
)

from partitura import score
from partitura.io.matchfile_base import MatchFile

from partitura.utils.music import (
    seconds_to_midi_ticks,
)

from partitura.utils.misc import (
    PathLike,
    deprecated_alias,
    deprecated_parameter,
)

from partitura.musicanalysis.performance_codec import get_time_maps_from_alignment

__all__ = ["save_match"]


@deprecated_parameter("magaloff_zeilinger_quirk")
def matchfile_from_alignment(
    alignment: List[dict],
    ppart: PerformedPart,
    spart: Part,
    mpq: int = 500000,
    ppq: int = 480,
    performer: Optional[str] = None,
    composer: Optional[str] = None,
    piece: Optional[str] = None,
    score_filename: Optional[PathLike] = None,
    performance_filename: Optional[PathLike] = None,
    assume_part_unfolded: bool = False,
    version: Version = LATEST_VERSION,
    debug: bool = False,
) -> MatchFile:
    """
    Generate a MatchFile object from an Alignment, a PerformedPart and
    a Part

    Parameters
    ----------
    alignment : list
        A list of dictionaries containing alignment information.
        See `partitura.io.importmatch.alignment_from_matchfile`.
    ppart : partitura.performance.PerformedPart
        An instance of `PerformedPart` containing performance information.
    spart : partitura.score.Part
        An instance of `Part` containing score information.
    mpq : int
        Microseconds per quarter note.
    ppq: int
        Parts per quarter note.
    performer : str or None
        Name(s) of the performer(s) of the `PerformedPart`.
    composer : str or None
        Name(s) of the composer(s) of the piece represented by `Part`.
    piece : str or None:
        Name of the piece represented by `Part`.
    score_filename: PathLike
        Name of the file containing the score.
    performance_filename: PathLike
        Name of the (MIDI) file containing the performance.
    assume_part_unfolded: bool
        Whether to assume that the part has been unfolded according to the
        repetitions in the alignment. If False, the part will be automatically
        unfolded to have maximal coverage of the notes in the alignment.
        See `partitura.score.unfold_part_alignment`.
    version: Version
        Version of the match file. For now only 1.0.0 is supported.
    Returns
    -------
    matchfile : MatchFile
        An instance of `partitura.io.importmatch.MatchFile`.
    """
    if version < Version(1, 0, 0):
        raise ValueError("Version should >= 1.0.0")

    if not assume_part_unfolded:
        # unfold score according to alignment
        spart = score.unfold_part_alignment(spart, alignment)

    # Info Header Lines
    header_lines = dict()

    header_lines["version"] = make_info(
        version=version,
        attribute="matchFileVersion",
        value=version,
    )

    header_lines["performer"] = make_info(
        version=version,
        attribute="performer",
        value="-" if performer is None else performer,
    )

    header_lines["piece"] = make_info(
        version=version,
        attribute="piece",
        value="-" if piece is None else piece,
    )

    header_lines["composer"] = make_info(
        version=version,
        attribute="composer",
        value="-" if composer is None else composer,
    )

    header_lines["score_filename"] = make_info(
        version=version,
        attribute="scoreFileName",
        value="-" if score_filename is None else score_filename,
    )

    header_lines["performance_filename"] = make_info(
        version=version,
        attribute="midiFileName",
        value="-" if performance_filename is None else performance_filename,
    )

    header_lines["clock_units"] = make_info(
        version=version,
        attribute="midiClockUnits",
        value=int(ppq),
    )

    header_lines["clock_rate"] = make_info(
        version=version,
        attribute="midiClockRate",
        value=int(mpq),
    )

    # Measure map (which measure corresponds to which time point in divs)
    beat_map = spart.beat_map

    ptime_to_stime_map, _ = get_time_maps_from_alignment(
        ppart_or_note_array=ppart.note_array(),
        spart_or_note_array=spart.note_array(),
        alignment=alignment,
        remove_ornaments=True,
    )

    measures = np.array(list(spart.iter_all(score.Measure)))
    measure_starts_divs = np.array([m.start.t for m in measures])
    measure_starts_beats = beat_map(measure_starts_divs)
    measure_sorting_idx = measure_starts_divs.argsort()
    measure_starts_divs = measure_starts_divs[measure_sorting_idx]
    measures = measures[measure_sorting_idx]

    start_measure_num = 0 if measure_starts_beats.min() < 0 else 1
    measure_starts = np.column_stack(
        (
            np.arange(start_measure_num, start_measure_num + len(measure_starts_divs)),
            measure_starts_divs,
            measure_starts_beats,
        )
    )

    # Score prop header lines
    scoreprop_lines = defaultdict(list)

    # For score notes
    score_info = dict()
    # Info for sorting lines
    snote_sort_info = dict()
    for (mnum, msd, msb), m in zip(measure_starts, measures):
        time_signatures = spart.iter_all(score.TimeSignature, m.start, m.end)

        for tsig in time_signatures:
            time_divs = int(tsig.start.t)
            time_beats = float(beat_map(time_divs))
            dpq = int(spart.quarter_duration_map(time_divs))
            beat = int((time_beats - msb) // 1)

            ts_num, ts_den, _ = spart.time_signature_map(tsig.start.t)

            moffset_divs = Fraction(
                int(time_divs - msd - beat * dpq), (int(ts_den) * dpq)
            )

            scoreprop_lines["time_signatures"].append(
                make_scoreprop(
                    version=version,
                    attribute="timeSignature",
                    value=MatchTimeSignature(
                        numerator=int(ts_num),
                        denominator=int(ts_den),
                        other_components=None,
                        is_list=False,
                    ),
                    measure=int(mnum),
                    beat=beat + 1,
                    offset=FractionalSymbolicDuration(
                        numerator=moffset_divs.numerator,
                        denominator=moffset_divs.denominator,
                    ),
                    time_in_beats=time_beats,
                )
            )

        key_signatures = spart.iter_all(score.KeySignature, m.start, m.end)

        for ksig in key_signatures:
            time_divs = int(tsig.start.t)
            time_beats = float(beat_map(time_divs))
            dpq = int(spart.quarter_duration_map(time_divs))
            beat = int((time_beats - msb) // 1)

            ts_num, ts_den, _ = spart.time_signature_map(tsig.start.t)

            moffset_divs = Fraction(
                int(time_divs - msd - beat * dpq), (int(ts_den) * dpq)
            )

            scoreprop_lines["key_signatures"].append(
                make_scoreprop(
                    version=version,
                    attribute="keySignature",
                    value=MatchKeySignature(
                        fifths=int(ksig.fifths),
                        mode=ksig.mode,
                        is_list=False,
                        fmt="v1.0.0",
                    ),
                    measure=int(mnum),
                    beat=beat + 1,
                    offset=FractionalSymbolicDuration(
                        numerator=moffset_divs.numerator,
                        denominator=moffset_divs.denominator,
                    ),
                    time_in_beats=time_beats,
                )
            )

        # Get all notes in the measure
        snotes = spart.iter_all(score.Note, m.start, m.end, include_subclasses=True)
        # Beginning of each measure

        for snote in snotes:
            onset_divs, offset_divs = snote.start.t, snote.start.t + snote.duration_tied
            duration_divs = offset_divs - onset_divs

            onset_beats, offset_beats = beat_map([onset_divs, offset_divs])

            dpq = int(spart.quarter_duration_map(onset_divs))

            beat = int((onset_beats - msb) // 1)

            ts_num, ts_den, _ = spart.time_signature_map(snote.start.t)

            duration_symb = Fraction(duration_divs, dpq * 4)

            beat = int((onset_divs - msd) // dpq)

            moffset_divs = Fraction(int(onset_divs - msd - beat * dpq), (dpq * 4))

            if debug:
                duration_beats = offset_beats - onset_beats
                moffset_beat = (onset_beats - msb - beat) / ts_den
                assert np.isclose(float(duration_symb), duration_beats)
                assert np.isclose(moffset_beat, float(moffset_divs))

            score_attributes_list = []

            articulations = getattr(snote, "articulations", None)
            voice = getattr(snote, "voice", None)
            staff = getattr(snote, "staff", None)
            ornaments = getattr(snote, "ornaments", None)
            fermata = getattr(snote, "fermata", None)

            if voice is not None:
                score_attributes_list.append(f"v{voice}")

            if staff is not None:
                score_attributes_list.append(f"staff{staff}")

            if articulations is not None:
                score_attributes_list += list(articulations)

            if ornaments is not None:
                score_attributes_list += list(ornaments)

            if fermata is not None:
                score_attributes_list.append("fermata")

            score_info[snote.id] = MatchSnote(
                version=version,
                anchor=str(snote.id),
                note_name=str(snote.step).upper(),
                modifier=snote.alter if snote.alter is not None else 0,
                octave=int(snote.octave),
                measure=int(mnum),
                beat=beat + 1,
                offset=FractionalSymbolicDuration(
                    numerator=moffset_divs.numerator,
                    denominator=moffset_divs.denominator,
                ),
                duration=FractionalSymbolicDuration(
                    numerator=duration_symb.numerator,
                    denominator=duration_symb.denominator,
                ),
                onset_in_beats=onset_beats,
                offset_in_beats=offset_beats,
                score_attributes_list=score_attributes_list,
            )
            snote_sort_info[snote.id] = (onset_beats, snote.doc_order)

    perf_info = dict()
    pnote_sort_info = dict()
    for pnote in ppart.notes:
        onset = seconds_to_midi_ticks(pnote["note_on"], mpq=mpq, ppq=ppq)
        offset = seconds_to_midi_ticks(pnote["note_off"], mpq=mpq, ppq=ppq)
        perf_info[pnote["id"]] = MatchNote(
            version=version,
            id=(
                f"n{pnote['id']}"
                if not str(pnote["id"]).startswith("n")
                else str(pnote["id"])
            ),
            midi_pitch=int(pnote["midi_pitch"]),
            onset=onset,
            offset=offset,
            velocity=pnote["velocity"],
            channel=pnote.get("channel", 1),
            track=pnote.get("track", 0),
        )
        pnote_sort_info[pnote["id"]] = (
            float(ptime_to_stime_map(pnote["note_on"])),
            pnote["midi_pitch"],
        )

    sort_stime = []
    note_lines = []
    for al_note in alignment:
        label = al_note["label"]

        if label == "match":
            snote = score_info[al_note["score_id"]]
            pnote = perf_info[al_note["performance_id"]]
            snote_note_line = MatchSnoteNote(version=version, snote=snote, note=pnote)
            note_lines.append(snote_note_line)
            sort_stime.append(snote_sort_info[al_note["score_id"]])

        elif label == "deletion":
            snote = score_info[al_note["score_id"]]
            deletion_line = MatchSnoteDeletion(version=version, snote=snote)
            note_lines.append(deletion_line)
            sort_stime.append(snote_sort_info[al_note["score_id"]])

        elif label == "insertion":
            note = perf_info[al_note["performance_id"]]
            insertion_line = MatchInsertionNote(version=version, note=note)
            note_lines.append(insertion_line)
            sort_stime.append(pnote_sort_info[al_note["performance_id"]])

        elif label == "ornament":
            ornament_type = al_note["type"]
            snote = score_info[al_note["score_id"]]
            note = perf_info[al_note["performance_id"]]
            ornament_line = MatchOrnamentNote(
                version=version,
                anchor=snote.Anchor,
                note=note,
                ornament_type=[ornament_type],
            )

            note_lines.append(ornament_line)
            sort_stime.append(pnote_sort_info[al_note["performance_id"]])

    # sort notes by score onset (performed insertions are sorted
    # according to the interpolation map
    sort_stime = np.array(sort_stime)
    sort_stime_idx = np.lexsort((sort_stime[:, 1], sort_stime[:, 0]))
    note_lines = np.array(note_lines)[sort_stime_idx]

    # Create match lines for pedal information
    pedal_lines = []
    for c in ppart.controls:
        t = seconds_to_midi_ticks(c["time"], mpq=mpq, ppq=ppq)
        value = int(c["value"])
        if c["number"] == 64:  # c['type'] == 'sustain_pedal':
            sustain_pedal = MatchSustainPedal(version=version, time=t, value=value)
            pedal_lines.append(sustain_pedal)

        if c["number"] == 67:  # c['type'] == 'soft_pedal':
            soft_pedal = MatchSoftPedal(version=version, time=t, value=value)
            pedal_lines.append(soft_pedal)

    pedal_lines.sort(key=lambda x: x.Time)

    # Construct header of match file
    header_order = [
        "version",
        "piece",
        "score_filename",
        "performance_filename",
        "composer",
        "performer",
        "clock_units",
        "clock_rate",
        "key_signatures",
        "time_signatures",
    ]
    all_match_lines = []
    for h in header_order:
        if h in header_lines:
            all_match_lines.append(header_lines[h])

        if h in scoreprop_lines:
            all_match_lines += scoreprop_lines[h]

    # Concatenate all lines
    all_match_lines += list(note_lines) + pedal_lines

    matchfile = MatchFile(lines=all_match_lines)

    return matchfile


[docs]@deprecated_alias(spart="score_data", ppart="performance_data") def save_match( alignment: List[dict], performance_data: PerformanceLike, score_data: ScoreLike, out: PathLike = None, mpq: int = 500000, ppq: int = 480, performer: Optional[str] = None, composer: Optional[str] = None, piece: Optional[str] = None, score_filename: Optional[PathLike] = None, performance_filename: Optional[PathLike] = None, assume_unfolded: bool = False, ) -> Optional[MatchFile]: """ Save an Alignment of a PerformedPart to a Part in a match file. Parameters ---------- alignment : list A list of dictionaries containing alignment information. See `partitura.io.importmatch.alignment_from_matchfile`. performance_data : `PerformanceLike The performance information as a `Performance` score_data : `ScoreLike` The musical score. A :class:`partitura.score.Score` object, a :class:`partitura.score.Part`, a :class:`partitura.score.PartGroup` or a list of these. out : str Out to export the matchfile. mpq : int Milliseconds per quarter note. ppq: int Parts per quarter note. performer : str or None Name(s) of the performer(s) of the `PerformedPart`. composer : str or None Name(s) of the composer(s) of the piece represented by `Part`. piece : str or None: Name of the piece represented by `Part`. score_filename: PathLike Name of the file containing the score. performance_filename: PathLike Name of the (MIDI) file containing the performance. assume_part_unfolded: bool Whether to assume that the part has been unfolded according to the repetitions in the alignment. If False, the part will be automatically unfolded to have maximal coverage of the notes in the alignment. See `partitura.score.unfold_part_alignment`. Returns ------- matchfile: MatchFile If no output is specified using `out`, the function returns a `MatchFile` object. Otherwise, the function returns None. """ # For now, we assume that we align only one Part and a PerformedPart if isinstance(score_data, (Score, Iterable)): spart = score_data[0] elif isinstance(score_data, Part): spart = score_data elif isinstance(score_data, score.PartGroup): spart = score_data.children[0] else: raise ValueError( "`score_data` should be a `Score`, a `Part`, a `PartGroup` or a " f"list of `Part` objects, but is {type(score_data)}" ) if isinstance(performance_data, (Performance, Iterable)): ppart = performance_data[0] elif isinstance(performance_data, PerformedPart): ppart = performance_data else: raise ValueError( "`performance_data` should be a `Performance`, a `PerformedPart`, or a " f"list of `PerformedPart` objects, but is {type(score_data)}" ) # Get matchfile matchfile = matchfile_from_alignment( alignment=alignment, ppart=ppart, spart=spart, mpq=mpq, ppq=ppq, performer=performer, composer=composer, piece=piece, score_filename=score_filename, performance_filename=performance_filename, assume_part_unfolded=assume_unfolded, ) if out is not None: # write matchfile matchfile.write(out) else: return matchfile