Source code for quickly.rhythm

# -*- coding: utf-8 -*-
#
# This file is part of `quickly`, a library for LilyPond and the `.ly` format
#
# Copyright © 2021-2021 by Wilbert Berendsen <info@wilbertberendsen.nl>
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.


"""
Classes and convenience functions to manipulate the rhythm of music.

For all convenience functions: the ``music`` argument may be a
:class:`parce.Document`, a parce :class:`~parce.document.Cursor` (optionally
only selecting a range of the document to edit), a node :class:`~.node.Range`
or any :class:`~.dom.element.Element` node (DOM tree).

"""


import itertools


from .dom import edit, lily, util
from .duration import duration


[docs]class EditRhythm(edit.Edit): """Base class for rhythm editing operations."""
[docs] @staticmethod def durables(r): """Yield all Durable instances in range.""" for n in r.nodes(): if isinstance(n, lily.Durable): # skip \skip with music instead of duration (which is legal # as of LilyPond 2.23.6) if isinstance(n, lily.Skip) and not any(n / lily.Duration): continue yield n
[docs] @staticmethod def may_remove(node): """Return True if the duration of this node may be removed. A duration may not be removed if ``node.duration_required`` is True, or when the node's right sibling has only the duration visible, such as is the case with an unpitched note or an empty lyric item. In that case, the current duration may not be removed, because the next duration would then be understood as the duration of the current node when rewriting the music text. """ if node.duration_required: return False elif any(node / lily.Articulations): return True n = node.right_sibling() if n: if isinstance(n, lily.Unpitched): return False elif isinstance(n, lily.LyricItem) and n.duration_required: return False return True
[docs] def edit_range(self, r): """Perform our operations on all Durables in the range.""" prev = None for n in self.durables(r): prev = self.process(n, prev)
[docs] def process(self, node, prev): """Implement to perform an operation on the ``node``. The ``prev`` parameter is the value the previous call to this method returned, it is None on the first call. """ raise NotImplementedError
[docs]class Remove(EditRhythm): """Remove duration from Durable nodes, if allowed."""
[docs] def process(self, node, prev): """Remove duration from ``node``; ``prev`` is unused.""" if self.may_remove(node): del node.duration
[docs]class RemoveScaling(EditRhythm): """Remove scaling from Durable nodes."""
[docs] def process(self, node, prev): """Remove scaling from ``node``; ``prev`` is unused.""" del node.scaling
[docs]class RemoveFractionScaling(EditRhythm): """Remove scaling if it contains a fraction."""
[docs] def process(self, node, prev): """Remove scaling if it contains a fraction from ``node``; ``prev`` is unused.""" s = node.scaling if s is not None and int(s) != s: del node.scaling
[docs]class RhythmExplicit(EditRhythm): """Add the current duration to all nodes that don't have one."""
[docs] def process(self, node, prev): """Add duration to ``node`` if absent; ``prev`` is the previous Duration node.""" if node.duration is None: if prev is None: prev = lily.Duration.from_duration(*lily.previous_duration(node)) node.add(prev.copy()) elif node.duration_sets_previous: prev = next(node / lily.Duration) return prev
[docs]class RhythmImplicit(EditRhythm): """Remove reoccurring durations."""
[docs] def process(self, node, prev): """Remove duration from ``node`` if same as (duration, scaling) tuple in ``prev``.""" dur = node.duration_scaling if dur: if dur == prev and self.may_remove(node): del node.duration elif node.duration_sets_previous: prev = dur return prev
[docs]class RhythmImplicitPerLine(EditRhythm): """Remove reoccurring durations within the same line, but always add a duration to the first Durable on a line. This only works when editing from a parce Document, otherwise we don't know the newlines in the original text. If there is no parce Document, the behaviour is the same as :class:`RhythmImplicit`. """
[docs] def process(self, node, prev): """Remove duration from ``node`` if duration and text block are the same as [duration, scaling, block] list in ``prev``. """ dur = node.duration_scaling block = self.find_block(node) if dur: if [dur, block] == prev and self.may_remove(node): del node.duration elif node.duration_sets_previous: prev = [dur, block] elif block and prev and prev[1] != block: node.duration_scaling = prev[0] prev[1] = block return prev
[docs]class RhythmTransform(EditRhythm): r"""Transform durations using a :class:`~.duration.Transform`. This can be used for all types of shift and scale operations. For example, to add a dot to all durations:: >>> from quickly.rhythm import RhythmTransform >>> from quickly.duration import Transform >>> from quickly.dom import read >>> music = read.lily_document(r"{ c4 d8 e16 f g2 }") >>> t = Transform(dotcount=1) # add a dot >>> RhythmTransform(t).edit(music) >>> music.write() '{ c4. d8. e16. f g2. }' Remove a dot:: >>> t = Transform(dotcount=-1) >>> RhythmTransform(t).edit(music) >>> music.write() '{ c4 d8 e16 f g2 }' Double durations:: >>> t = Transform(log=-1) >>> RhythmTransform(t).edit(music) >>> music.write() '{ c2 d4 e8 f g1 }' Add a scaling factor:: >>> t = Transform(scale=1/3) >>> RhythmTransform(t).edit(music) >>> music.write() '{ c2*1/3 d4*1/3 e8*1/3 f g1*1/3 }' Or modify all in one go:: >>> t = Transform(1, 1, 3) >>> RhythmTransform(t).edit(music) >>> music.write() '{ c4. d8. e16. f g2. }' This function also modifies durations in ``\tempo``, ``\tuplet``, ``\after`` and ``\partial`` commands. """ def __init__(self, transform): self._transform = transform.transform # store the transform method
[docs] def edit_range(self, r): """Transform all durations.""" for n in r.nodes(): if isinstance(n, lily.HandleDuration): dur = n.duration_scaling if dur: dur = self._transform(*dur) if isinstance(n, lily.Tempo): n.duration = dur[0] # \tempo does not support scaling else: n.duration_scaling = dur
[docs]class CopyRhythm(EditRhythm): """Extract durations from a range in the form of (duration, scaling) tuples. The durations are returned by :meth:`edit_range` and thus also all other edit methods. Durables without duration yield a None if ``explicit`` is False, otherwise the previous duration is repeated. Example:: >>> from quickly.dom import read >>> music = read.lily_document(r"{ c4 d8 e16 f g2 }") >>> from quickly.rhythm import CopyRhythm >>> durations = CopyRhythm().edit(music) >>> durations [(Fraction(1, 4), 1), (Fraction(1, 8), 1), (Fraction(1, 16), 1), None, (Fraction(1, 2), 1.0)] >>> CopyRhythm(True).edit(music) [(Fraction(1, 4), 1), (Fraction(1, 8), 1), (Fraction(1, 16), 1), (Fraction(1, 16), 1), (Fraction(1, 2), 1)] """ readonly = True def __init__(self, explicit=False): self.explicit = explicit #: If True, yield every reoccurring duration explicit instead of None
[docs] def edit_range(self, r): """Return the list of extracted durations.""" if self.explicit: def durations(): prev = None for n in self.durables(r): dur = n.duration_scaling if dur: prev = dur elif not prev: prev = lily.previous_duration(n) yield prev return list(durations()) return [n.duration_scaling for n in self.durables(r)]
[docs]class PasteRhythm(EditRhythm): """Paste durations such as returned by :class:`CopyRhythm` into music. The durations are an iterable of either the two-tuple (duration, scaling) or None. If ``cycle`` is True, the pasted durations are endlessly repeated in the selected range. Example:: >>> from fractions import Fraction >>> durations = [(Fraction(1, 4), 1), (Fraction(3, 16), 0.5), None] >>> from quickly.dom import read >>> music = read.lily_document(r"{ c4 d8 e16 f g2 }") >>> from quickly.rhythm import PasteRhythm >>> PasteRhythm(durations).edit(music) >>> music.write() '{ c4 d8.*1/2 e f4 g8.*1/2 }' """ def __init__(self, durations, cycle=True): self._durations = durations self.cycle = cycle
[docs] def edit_range(self, r): """Paste the durations.""" durs = (itertools.cycle if self.cycle else iter)(self._durations) prev = None for node, duration in zip(self.durables(r), durs): if duration: node.duration_scaling = duration prev = duration elif self.may_remove(node): del node.duration elif prev: node.duration_scaling = prev
[docs]def remove(music): r"""Remove all durations from music. Does not remove the duration from ``\skip`` and Unpitched notes, and also not from durables that immediately precede Unpitched notes (or empty lyric items), because the Unpitched's duration would then be mistakenly held for the duration of the preceding note. """ return Remove().edit(music)
[docs]def remove_scaling(music): """Remove all scalings from the durations in music.""" return RemoveScaling().edit(music)
[docs]def remove_fraction_scaling(music): """Remove all scalings that contain a fraction (like ``1/3``) from the durations in music.""" return RemoveFractionScaling().edit(music)
[docs]def explicit(music): """Add the current duration to all notes, chords, rests etc in the music.""" return RhythmExplicit().edit(music)
[docs]def implicit(music, per_line=False): """Remove all reoccuring durations from the music. If ``per_line`` is True, the first duration in a text line is not removed, but rather added if absent. (This only works when editing a parce document or cursor, otherwise we can't know the newlines in the original text.) An example:: >>> import parce >>> import quickly.rhythm >>> d=parce.Document(quickly.find('lilypond'), r'''music = { ... c4 d8 e8 f8 g8 a4 ... g f e4 d ... c d4 e2 ... } ... ''', transformer=True) >>> quickly.rhythm.implicit(d, True) >>> print(d.text()) music = { c4 d8 e f g a4 g4 f e d c4 d e2 } """ cls = RhythmImplicitPerLine if per_line else RhythmImplicit return cls().edit(music)
[docs]def transform(music, log=0, dotcount=0, scale=1): r"""Transform durations in music by modifying log, dot count and/or scaling. Increasing the log by 1 halves the durations, decreasing the log doubles them. (See also the :mod:`.duration` module.) An example, where the duration is halved and one dot is added:: >>> from quickly.dom import read >>> from quickly import rhythm >>> m = read.lily_document("{ c4 d8 e16 f g2 }") >>> rhythm.transform(m, 1, 1) >>> m.write() '{ c8. d16. e32. f g4. }' This function also modifies durations in ``\tempo``, ``\tuplet``, ``\after`` and ``\partial`` commands. """ from .duration import Transform return RhythmTransform(Transform(log, dotcount, scale)).edit(music)
[docs]def copy(music, explicit=False): """Extract durations from music. Every duration is a two-tuple of integers or fractions (duration, scaling), or, if ``explicit`` is False, None for Durables without duration. If ``explicit`` is True, the previous duration is repeated for Durables without duration. """ return CopyRhythm(explicit).edit(music)
[docs]def paste(music, durations, cycle=True): """Replace durations in the music with the specified durations. Every duration is a two-tuple of integers or fractions (duration, scaling), or None for Durables without duration. If ``cycle`` is True, the pasted durations are endlessly repeated in the selected range. An example:: >>> from quickly.dom import read >>> from quickly.rhythm import copy, paste >>> durs = copy(read.lily_document("{ 8. 16 8 }")) >>> durs [(Fraction(3, 16), 1.0), (Fraction(1, 16), 1.0), (Fraction(1, 8), 1.0)] >>> music = read.lily_document("{ g a g c d c a b a f g f }") >>> paste(music, durs) >>> music.write() '{ g8. a16 g8 c8. d16 c8 a8. b16 a8 f8. g16 f8 }' """ return PasteRhythm(durations, cycle).edit(music)