Source code for muspy.outputs.abc

"""ABC output interface."""
from abc import ABC, abstractmethod
from collections import OrderedDict
from copy import copy
from fractions import Fraction
from math import floor
from pathlib import Path
from typing import TYPE_CHECKING, List, Union

from music21.pitch import Pitch

from ..classes import Barline, Base

if TYPE_CHECKING:
    from ..classes import Chord, KeySignature, Note, Tempo, TimeSignature
    from ..music import Music


class _ABCTrackElement(ABC):
    """
    Base class for wrappers around MusPy classes.
    Handles converting elements of the music track to ABC notation and
    ordering them in it.
    """

    PRIORITY = 0
    # Whether element takes whole line in .abc file. Characteristic for
    # fields setting parameters of music such as "K:" or "M:"
    TAKES_LINE = False

    def __init__(self, represented: "Base") -> None:
        self.represented = represented

    def __eq__(self, other: "_ABCTrackElement"):
        """
        Check if `other` represents the same set of musical data, possibly
        occuring in a different moment of music track
        """
        try:
            temp_time = getattr(other.represented, "time")
            setattr(
                other.represented, "time", getattr(self.represented, "time")
            )
            result = self.represented == other.represented
            setattr(other.represented, "time", temp_time)
        except AttributeError:
            result = self.represented == other.represented
        return result

    def __lt__(self, other: "_ABCTrackElement"):
        """
        Check if element should be written in ABC notation before `other`.
        """
        if self.represented < other.represented:
            return True
        if (
            getattr(self.represented, "time")
            == getattr(other.represented, "time")
            and self.PRIORITY < other.PRIORITY
        ):
            return True
        return False

    def __gt__(self, other: "_ABCTrackElement"):
        """
        Check if element should be written in ABC notation after `other`.
        """
        if self.represented > other.represented:
            return True
        if (
            getattr(self.represented, "time")
            == getattr(other.represented, "time")
            and self.PRIORITY > other.PRIORITY
        ):
            return True
        return False

    def __str__(self):
        if self.TAKES_LINE:
            return f"\n{self._to_str()}\n"
        return self._to_str()

    @abstractmethod
    def _to_str(self) -> str:
        pass


class _ABCTimeSignature(_ABCTrackElement):
    PRIORITY = 1
    TAKES_LINE = True

    def __init__(self, represented: "TimeSignature"):
        self.represented: "TimeSignature"
        super().__init__(represented)

    def _to_str(self):
        numerator = self.represented.numerator
        denominator = self.represented.denominator
        if numerator == 4 and denominator == 4:
            meter = "M: C"  # common time
        elif numerator == 2 and denominator == 2:
            meter = "M: C|"  # cut time
        else:
            meter = f"M: {numerator}/{denominator}"
        length = f"L: 1/{denominator}"
        return meter + "\n" + length


class _ABCTempo(_ABCTrackElement):
    PRIORITY = 2
    TAKES_LINE = True

    def __init__(self, represented: "Tempo"):
        self.represented: "Tempo"
        super().__init__(represented)

    def _to_str(self):
        return f"Q: {int(self.represented.qpm)}"


class _ABCKeySignature(_ABCTrackElement):
    PRIORITY = 3
    TAKES_LINE = True

    def __init__(self, represented: "KeySignature"):
        self.represented: "KeySignature"
        super().__init__(represented)

    def _to_str(self):
        note = Pitch(self.represented.root)
        mode = self.represented.mode
        if mode is None:
            return f"K:{note.name}"
        return f"K:{note.name}{mode}"


class _ABCBarline(_ABCTrackElement):
    PRIORITY = 4

    def __init__(self, represented: "Barline"):
        self.represented: "Barline"
        super().__init__(represented)
        self.started_repeats = 0
        self.ended_repeats = 0
        self.ended_line = False
        self.breaks_line = False

    def _to_str(self):
        if self.started_repeats > 0 and self.ended_repeats > 0:
            return " {0}|{1}|{2} ".format(
                ":" * self.ended_repeats,
                "\n" if self.breaks_line else "",
                ":" * self.started_repeats,
            )
        else:
            return " {0}|{1}{2} {3}".format(
                ":" * self.ended_repeats,
                ":" * self.started_repeats,
                "]" * self.ended_line,
                "\n" if self.breaks_line else "",
            )

    @staticmethod
    def mark_repeats(
        start: "_ABCBarline", end: "_ABCBarline", repeat_count: int = 1
    ):
        start.started_repeats += repeat_count
        end.ended_repeats += repeat_count


class _ABCSymbol(_ABCTrackElement):
    PRIORITY = 5

    def __init__(
        self, represented: "Base", music: "Music", tie: "bool" = False
    ):
        self.represented: "Base"
        self.tie = tie
        super().__init__(represented)
        duration_in_quarters = self.represented.duration / music.resolution
        # denominator marks standard note length: 2-half note, 4-quarter,
        # 8-eighth, etc.
        time_signatures = [
            time_sig
            for time_sig in music.time_signatures
            if time_sig.time <= self.represented.time
        ]
        quarter_to_unit_length = time_signatures[-1].denominator / 4
        self._length = Fraction(
            duration_in_quarters * quarter_to_unit_length
        ).limit_denominator()

    def _length_suffix(self):
        note_lenght_str = ""
        if self._length.numerator != 1:
            note_lenght_str += str(self._length.numerator)
        if self._length.denominator != 1:
            note_lenght_str += "/" + str(self._length.denominator)
        return note_lenght_str


class _ABCNote(_ABCSymbol):
    def __init__(
        self, represented: "Note", music: "Music", tie: "bool" = False
    ):
        self.represented: "Note"
        super().__init__(represented, music, tie)

    def _to_str(self):
        return (
            self._octave_adjusted_note()
            + self._length_suffix()
            + "-" * self.tie
        )

    def _octave_adjusted_note(self):
        pitch = Pitch(midi=self.represented.pitch)
        note = pitch.name
        if len(note) > 1:
            if note[-1] == "#":  # sharp
                note = "^" + note[0]
            elif note[-1] == "-":  # flat
                note = "_" + note[0]
        if pitch.octave <= 4:
            note = note + "," * (4 - pitch.octave)
        else:
            note = note.lower() + "'" * (pitch.octave - 5)
        return note


class _ABCChord(_ABCSymbol):
    def __init__(
        self, represented: "Chord", music: "Music", tie: "bool" = False
    ):
        self.represented: "Chord"
        super().__init__(represented, music, tie)

    def _to_str(self):
        return "[" + self.print_notes() + "-" * self.tie + "]"

    def _octave_adjusted_note(self, pitch_numeric):
        pitch = Pitch(midi=pitch_numeric)
        note = pitch.name
        if len(note) > 1:
            if note[-1] == "#":  # sharp
                note = "^" + note[0]
            elif note[-1] == "-":  # flat
                note = "_" + note[0]
        if pitch.octave <= 4:
            note = note + "," * (4 - pitch.octave)
        else:
            note = note.lower() + "'" * (pitch.octave - 5)
        return note

    def print_notes(self):
        string = ""
        for pitch in self.represented.pitches:
            string += self._octave_adjusted_note(pitch)
            string += self._length_suffix()
        return string


class Rest(Base):
    """A container for rests.

    Attributes
    ----------
    time : int
        Time of the rest, in time steps.
    duration: int
        Duration of the rest, in time steps.
    """

    _attributes = OrderedDict([("time", int), ("duration", int)])

    def __init__(self, time: int, duration: int):
        self.time = time
        self.duration = duration


class _ABCRest(_ABCSymbol):
    def __init__(
        self, represented: "Rest", music: "Music", tie: "bool" = False
    ):
        self.represented: "Rest"
        super().__init__(represented, music, tie)

    def _to_str(self):
        return "z" + self._length_suffix()


def generate_header(music: "Music") -> List[str]:
    """Generate ABC header from Music object.

    Parameters
    ----------
    music : :class:`muspy.Music`
        Music object to generate ABC header.
    """
    header_lines = ["X: 1"]

    # set filename as title if no other title
    title = music.metadata.title
    if title is None:
        title = Path(music.metadata.source_filename).stem
    header_lines.append(f"T: {title}")
    creators = music.metadata.creators
    if creators:
        header_lines.append(f"C: {','.join(creators)}")
    return header_lines


def remove_consecutive_repeats(list: list):
    """
    Creates a copy of `list` with consecutive repeats of values skipped.
    """
    cleared = list[:1]
    for element in list[1:]:
        if element == cleared[-1]:
            continue
        cleared.append(element)
    return cleared


class _TrackCompactor:
    """Compacts tracks by detecting repeats of bars"""

    def __init__(self, track: "list[_ABCBarline|_ABCNote]"):
        self._sections_borders: list[_ABCBarline] = []
        self._sections_occurences: list[int] = []
        self._sections: list[list[_ABCNote]] = []
        self.track = track

    @staticmethod
    def _enclose(track: "list[_ABCBarline|_ABCNote]"):
        """
        Ensure that `track` is enclosed by barlines in order to make it
        possible to mark repetitions involving either first or last bar of
        track.
        """
        if len(track) > 0:
            if type(track[0]) != _ABCBarline:
                barline = Barline(getattr(track[0].represented, "time"))
                track.insert(0, _ABCBarline(barline))
            if type(track[-1]) != _ABCBarline:
                barline = Barline(getattr(track[-1].represented, "time") + 1)
                track.append(_ABCBarline(barline))
        return track

    def _disassemble(self):
        """
        Break track into sequences of notes and separating them barlines.
        Prepare a counter for number of occurences for each sequence of notes.
        """
        last_barline_index = 0
        enclosed = self._enclose(self.track)
        self._sections_borders = [enclosed[0]]
        self._sections_occurences = []
        self._sections = []
        for i in range(1, len(enclosed)):
            element = enclosed[i]
            if type(element) == _ABCBarline:
                self._sections_borders.append(element)
                self._sections_occurences.append(1)
                self._sections.append(enclosed[last_barline_index + 1 : i])
                last_barline_index = i

    def _assemble(self):
        """
        Reassemble track from sections and separating them barlines.
        Mark repetitions of sections based on corresponding counters.
        """
        track = [self._sections_borders[0]]
        for occurences, section, closing_barline in zip(
            self._sections_occurences,
            self._sections,
            self._sections_borders[1:],
        ):
            opening_barline = track[-1]
            _ABCBarline.mark_repeats(
                opening_barline, closing_barline, occurences - 1
            )
            track += section + [closing_barline]
        return track

    def _get_section(self, index: int, n: int):
        """
        Get section of track starting with subsection at `index` and
        containing `n` next subsections. Barlines separating subsections are
        incorporated into retrieved section.
        """
        section = [*self._sections[index]]
        for i in range(1, n):
            section += [self._sections_borders[index + i]] + self._sections[
                index + i
            ]
        return section

    def _find_repeat(self, repeat_length: int):
        """
        Looks for the first repeat of given length in track

        Parameters
        ----------
        repeat_length : int
            number of sections to include in repeat

        Returns
        -------
        tuple[int, int]
            Index of section that starts repeating fragment and number of
            repetitions
        """
        sections_count = len(self._sections)
        repeat_count = 0
        for start in range(sections_count - 2 * repeat_length + 1):
            # ABC format has no syntax for nested repeats
            occurences = self._sections_occurences[
                start : start + repeat_length
            ]
            if any([n != 1 for n in occurences]):
                continue
            base = self._get_section(start, repeat_length)
            for repeat_start in range(
                start + repeat_length,
                sections_count - repeat_length + 1,
                repeat_length,
            ):
                repeat = self._get_section(repeat_start, repeat_length)
                if base == repeat:
                    repeat_count += 1
                    # ABC reader can't handle markings for more than 2 repeats
                    # (|: notes and bars :|)
                    break
                else:
                    break
            if repeat_count > 0:
                return start, repeat_count
        return 0, 0

    def _remove_repeat(
        self, index: int, repeat_length: int, repeat_count: int
    ):
        """
        Replace a block of `repeat_count` repeats each containing
        `repeat_length` sections starting at 'index' with concatenated
        sections from original and mark its occurence count.
        """
        concatenated = self._get_section(index, repeat_length)
        # include original
        repeated_sections_count = repeat_length * (repeat_count + 1)
        self._sections_borders = (
            self._sections_borders[: index + 1]
            + self._sections_borders[index + repeated_sections_count :]
        )
        self._sections_occurences = (
            self._sections_occurences[: index + 1]
            + self._sections_occurences[index + repeated_sections_count :]
        )
        self._sections = (
            self._sections[: index + 1]
            + self._sections[index + repeated_sections_count :]
        )
        self._sections_occurences[index] = repeat_count + 1
        self._sections[index] = concatenated

    def _compact(self):
        """
        Detect consecutive repetitions of track sections. Remove repeated
        sections and mark occurence counts of originals.
        """
        repeat_length = floor(len(self._sections) / 2)
        while repeat_length > 0:
            start, repeat_count = self._find_repeat(repeat_length)
            while repeat_count > 0:
                self._remove_repeat(start, repeat_length, repeat_count)
                repeat_length = floor(len(self._sections) / 2)
                if repeat_length < 1:
                    break
                start, repeat_count = self._find_repeat(repeat_length)
            repeat_length -= 1

    def compact(self):
        """
        Detect repetitions in track contained by object. Mark repeats of
        sections of track on barlines enclosing them and remove repeated
        elements.
        """
        if len(self.track) == 0:
            return self.track
        self._disassemble()
        self._compact()
        return self._assemble()


def break_lines(track: "list[_ABCTrackElement]", bars_per_line: int = 4):
    i = 0
    bar_counter = 0
    while i < len(track):
        element = track[i]
        if type(element) == _ABCBarline:
            bar_counter = (bar_counter + 1) % bars_per_line
            if bar_counter == 0:
                element.breaks_line = True
        elif element.TAKES_LINE:
            bar_counter = 0
            i += 1  # Skip opening barline of the first bar
        i += 1


def mark_repetitions(track: "list[_ABCTrackElement]"):
    same_key_fragments: list[list[_ABCTrackElement]] = []
    changes = []
    last_change_index = -1
    for i in range(len(track)):
        if track[i].TAKES_LINE:
            same_key_fragments.append(track[last_change_index + 1 : i])
            changes.append(track[i])
            last_change_index = i
    same_key_fragments.append(track[last_change_index + 1 : len(track)])

    for i in range(len(same_key_fragments)):
        same_key_fragments[i] = _TrackCompactor(
            same_key_fragments[i]
        ).compact()

    new_track = same_key_fragments[0]
    for i in range(len(changes)):
        new_track.append(changes[i])
        new_track += same_key_fragments[i + 1]
    return new_track


def find_rests(notes: "_ABCSymbol", music: "Music"):
    rests = []
    prev_note = notes[0]
    for note in notes[1:]:
        gap_duration = note.represented.time - (
            prev_note.represented.time + prev_note.represented.duration
        )
        if gap_duration > 0:
            rests.append(
                _ABCRest(
                    Rest(
                        prev_note.represented.time
                        + prev_note.represented.duration,
                        gap_duration,
                    ),
                    music,
                )
            )
        prev_note = note
    return rests


def split_symbol(
    bar_time: int, el_prev: "_ABCSymbol", end: int, music: "Music"
):
    new_el1 = copy(el_prev.represented)
    new_el1.duration = bar_time - el_prev.represented.time
    new_el1 = type(el_prev)(new_el1, music, True)

    new_el2 = copy(el_prev.represented)
    new_el2.duration = end - bar_time
    new_el2.time = bar_time
    new_el2 = type(el_prev)(new_el2, music, False)
    return new_el1, new_el2


def adjust_symbol_duration_over_bars(
    track: "list[_ABCTrackElement]", music: "Music"
):
    track_new = []
    el_prev = track[0]
    if isinstance(el_prev, _ABCTimeSignature):
        bar_lenght = (
            4
            / el_prev.represented.denominator
            * el_prev.represented.numerator
            * music.resolution
        )

    for el in track[1:]:
        if isinstance(el, _ABCTimeSignature):
            bar_lenght = (
                4
                / el.represented.denominator
                * el.represented.numerator
                * music.resolution
            )
        if isinstance(el, _ABCBarline) and isinstance(el_prev, _ABCSymbol):
            end = el_prev.represented.time + el_prev.represented.duration
            if end > el.represented.time:
                # symbol lasts several bars
                new_el1, new_el2 = split_symbol(
                    el.represented.time, el_prev, end, music
                )
                track_new.append(new_el1)
                # symbol lasts more than 2 bars
                while new_el2.represented.duration > bar_lenght:
                    end = (
                        new_el2.represented.time + new_el2.represented.duration
                    )
                    new_el3, new_el2 = split_symbol(
                        el.represented.time + bar_lenght, new_el2, end, music
                    )
                    track_new.append(new_el3)
                track_new.append(new_el2)
            else:
                track_new.append(el_prev)
        else:
            track_new.append(el_prev)
        el_prev = el

    track_new.append(track[-1])

    return track_new


def generate_note_body(
    music: "Music", compact_repeats: bool = True, **kwargs
) -> "list[str]":
    """Generate ABC note body from Music object.

    Parameters
    ----------
    music : :class:`muspy.Music`
        Music object to generate ABC note body.
    """
    time_sigs = [
        _ABCTimeSignature(time_sig) for time_sig in music.time_signatures
    ]
    time_sigs = remove_consecutive_repeats(time_sigs)

    tempos = [_ABCTempo(tempo) for tempo in music.tempos]
    tempos = remove_consecutive_repeats(tempos)

    keys = [_ABCKeySignature(key) for key in music.key_signatures]
    keys = remove_consecutive_repeats(keys)

    barlines = [_ABCBarline(barline) for barline in music.barlines]
    notes_chords = []
    rests = []
    if music.tracks:
        notes = [_ABCNote(note, music) for note in music.tracks[0].notes]
        chords = [_ABCChord(chord, music) for chord in music.tracks[0].chords]

        notes_chords = notes + chords
        notes_chords.sort()
        rests = find_rests(notes_chords, music)

    track = time_sigs + tempos + keys + barlines + notes_chords + rests
    track.sort()

    track = adjust_symbol_duration_over_bars(track, music)
    track.sort()

    if compact_repeats:
        track = mark_repetitions(track)

    break_lines(track, **kwargs)

    ended_barline = _ABCBarline(Barline(track[-1].represented.time + 1))
    ended_barline.ended_line = True
    track += [ended_barline]

    track_str = "".join(str(element) for element in track)
    lines = track_str.split("\n")
    lines = list(filter(None, lines))
    for i in range(len(lines)):
        lines[i] = lines[i].lstrip().rstrip()
    return lines


[docs]def write_abc(path: Union[str, Path], music: "Music", **kwargs): """Write a Music object to an ABC file. Parameters ---------- path : str or Path Path to write the ABC file. music : :class:`muspy.Music` Music object to write. """ file_lines = generate_header(music) file_lines += generate_note_body(music, **kwargs) with open(path, "w", encoding="utf-8") as file: for line in file_lines: file.write(f"{line}\n")