# -*- 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 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 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)