Source code for quickly.time

# -*- 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/>.


"""
Functionality to compute the time length of musical expressions.
"""


import collections
import itertools

from . import datatypes, duration
from .dom import lily


#: The result value (if not None) of the :meth:`~Time.position` and
#: :meth:`~Time.duration` methods.
Result = collections.namedtuple("Result", "node time")
Result.node.__doc__ = "The topmost Music expression."
Result.time.__doc__ = "The position or duration time value."


[docs]class Time: r"""Compute the length of musical expressions. A :class:`~.scope.Scope`, if given using the ``scope`` parameter, is used to resolve include files. If no scope is given, only searches the current DOM document for variable assignments. If a scope is given, include commands are followed and ``wait`` determines whether to wait for ongoing transformations of external DOM documents. If wait is False, and a transformation is not yet finished the include is not followed. An example:: >>> import parce, quickly.time >>> d = parce.Document(quickly.find('lilypond'), r''' ... music = { c4 d e f } ... ... { c2 \music g a b8 g f d } ... ''', transformer=True) >>> m = d.get_transform(True) >>> m.dump() <lily.Document (2 children)> ├╴<lily.Assignment music (3 children)> │ ├╴<lily.Identifier (1 child)> │ │ ╰╴<lily.Symbol 'music' [1:6]> │ ├╴<lily.EqualSign [7:8]> │ ╰╴<lily.MusicList (4 children) [9:21]> │ ├╴<lily.Note 'c' (1 child) [11:12]> │ │ ╰╴<lily.Duration Fraction(1, 4) [12:13]> │ ├╴<lily.Note 'd' [14:15]> │ ├╴<lily.Note 'e' [16:17]> │ ╰╴<lily.Note 'f' [18:19]> ╰╴<lily.MusicList (8 children) [23:49]> ├╴<lily.Note 'c' (1 child) [25:26]> │ ╰╴<lily.Duration Fraction(1, 2) [26:27]> ├╴<lily.IdentifierRef 'music' [28:34]> ├╴<lily.Note 'g' [35:36]> ├╴<lily.Note 'a' [37:38]> ├╴<lily.Note 'b' (1 child) [39:40]> │ ╰╴<lily.Duration Fraction(1, 8) [40:41]> ├╴<lily.Note 'g' [42:43]> ├╴<lily.Note 'f' [44:45]> ╰╴<lily.Note 'd' [46:47]> >>> t=quickly.time.Time() >>> t.position(m[1][0]) # first note in second expression Result(node=<lily.MusicList (8 children) [23:49]>, time=0) >>> t.position(m[1][1]) # \music identifier ref Result(node=<lily.MusicList (8 children) [23:49]>, time=Fraction(1, 2)) >>> t.position(m[1][2]) # the g after the \music ref Result(node=<lily.MusicList (8 children) [23:49]>, time=Fraction(3, 2)) >>> t.length(m[0][2]) # the \music expression Fraction(1, 1) >>> t.length(m[1]) # total length of second expression Fraction(3, 1) # referenced \music correctly counted in :) >>> t.duration(m[1][0], m[1][2])# length of "c2 \music g" part (g has duration 2) Result(node=<lily.MusicList (8 children) [23:49]>, time=Fraction(2, 1)) There are convenient methods to get the musical position of a parce :class:`~parce.Cursor`:: >>> c = parce.Cursor(d, 44, 47) >>> c.text() # two notes 'f d' >>> t.cursor_position(c) # length or music before the cursor Result(node=<lily.MusicList (8 children) [23:49]>, time=Fraction(11, 4)) >>> t.cursor_duration(c) # duration of the selected music Result(node=<lily.MusicList (8 children) [23:49]>, time=Fraction(1, 4)) LilyPond music functions that alter durations are recognized, and are abstracted in simple transformations that alter log, dotcount and/or scaling. An example:: >>> from quickly.dom import read >>> m = read.lily(r"\tuplet 3/2 { c8 d e }") >>> t.length(m) Fraction(1, 4) >>> m[1][1] # a single note in the tuplet <lily.Note 'd'> >>> t.length(m[1][1]) Fraction(1, 12) >>> m = read.lily(r"\shiftDurations #1 #1 { c4 d e f }") >>> t.length(m) # note value halved and dot added, so should be 3/4 Fraction(3, 4) >>> m[2][2] <lily.Note 'e'> >>> t.length(m[2][2]) # autodiscovers the current duration transform Fraction(3, 16) .. note:: As a :class:`Time` instance uses some caching for the duration of individual notes, don't rely on its computations while also modifying durations of music notes, rests etc. """ def __init__(self, scope=None, wait=False): self.scope = scope #: Our :class:`~.scope.Scope`. self.wait = wait #: If True, parce transformations are waited for. self.get_duration = lily.duration_getter() def __repr__(self): return "<{} scope={} wait={}>".format( type(self).__name__, self.scope, self.wait) @staticmethod def _music_child(node): """Return the topmost Music child or None.""" # avoid picking one note of a chord if isinstance(node, lily.Chord): return node for chord in node << lily.Chord: return chord while node: if isinstance(node, lily.Music) or isinstance(node.parent, lily.Music): return node node = node.parent @staticmethod def _preceding_music(node): """Return a two-tuple(music, trail). The ``node`` must be (a child of) a :class:`~.dom.lily.Music` instance. The returned music is the topmost Music ancestor and the trail lists the indices of the children upto the node. """ trail = [] for p, i in node.ancestors_with_index(): if not isinstance(p, lily.Music): break trail.append(i) node = p trail.reverse() return node, trail
[docs] def length(self, node): """Return the musical length of this node.""" context = TimeContext(self) if isinstance(node, (lily.Music, lily.Reference)): ancestors = list(itertools.takewhile(lily.is_music, node.ancestors())) for p in reversed(ancestors): context = context.enter(p) return context.length(node)
[docs] def position(self, node, include=False): """Return a :class:`Result` two-tuple(node, time). The ``node`` argument must be (a child of) a :class:`~.dom.lily.Music` instance. The returned ``node`` is the topmost Music element, and the ``time`` is the computed length of all preceding music nodes. If ``include`` is True, the node's length itself is also added. """ music, trail = self._preceding_music(node) context = TimeContext(self) context, node, length = context._follow_trail(music, trail) if include: length += context.length(node) return Result(music, length)
[docs] def duration(self, start_node, end_node): """Return a :class:`Result` two-tuple(node, time) or None. The returned ``node`` is the topmost Music element both nodes must be a descendant of. Both nodes must be (children of) :class:`~.dom.lily.Music` nodes. If they don't share the same ancestor, None is returned. The returned ``time`` value can be negative when the end node precedes the start node. """ music, start_trail = self._preceding_music(start_node) end_music, end_trail = self._preceding_music(end_node) if end_music is not music: return node = music context = TimeContext(self) # common part, just follow transform index = -1 for index, (pos, end) in enumerate(zip(start_trail, end_trail)): context = context.enter(node) if pos != end: break node = node[pos] else: index += 1 # compute time only for differing part of the trails start_time = context._follow_trail(node, start_trail[index:])[2] context, node, end_time = context._follow_trail(node, end_trail[index:]) end_time += context.length(node) return Result(music, end_time - start_time)
[docs] def cursor_position(self, cursor): """Return a :class:`Result` two-tuple(node, time) or None. The ``node`` is the music expression the cursor is in, and the ``time`` is the time offset from the start of that expression to the cursor's position. Returns None if the cursor is not in music. """ dom = cursor.document().get_transform(self.wait) if dom: node = dom.find_descendant_right(cursor.pos) c = self._music_child(node) if c: return self.position(c)
[docs] def cursor_duration(self, cursor): """Return a :class:`Result` two-tuple(node, time) or None. The ``node`` is the music expression the cursor is in, and the ``time`` is the length of the selected music fragment. Returns None if there's no selection or the selection's start and/or end are not in music, or in different music expressions. """ if cursor.has_selection(): pos, end = cursor.selection() dom = cursor.document().get_transform(self.wait) if dom: n = dom.find_descendant_right(pos) if n: start_node = self._music_child(n) if start_node: n = dom.find_descendant_left(end) if n: end_node = self._music_child(n) if end_node: result = self.duration(start_node, end_node) if result and result.time >= 0: return result
[docs]class TimeContext: """Encapsulates the transform and properties during time calculations. The transform (:class:`~.duration.Transform`) determines the actual length of Durable objects, and the properties (:class:`~.datatypes.Properties`) are forwarded to child contexts, where inside the :meth:`~.dom.lily.Music.time_length` of Music nodes values can be read and also modified. """ def __init__(self, time, transform=None, properties=None): self.time = time #: The :class:`Time` object we originate from. self.transform = transform or duration.Transform() #: The current Transform. self.properties = properties or datatypes.Properties() #: The current Properties. def __repr__(self): return "<{} time={} transform={} properties={}>".format( type(self).__name__, self.time, self.transform, self.properties) def _follow_trail(self, node, trail): """Compute length; return context, node and length at end of trail.""" context = self length = 0 for index in trail: context = context.enter(node) if index and node.is_sequential(): length += node.time_length(context, index) node = node[index] return context, node, length
[docs] def enter(self, node, time=None): """Return a new TimeContext. The returned TimeContext uses the new :class:`Time` (if given, otherwise the same as ours) and adds the :class:`~.duration.Transform` and the :class:`~.datatypes.Properties` of the specified ``node`` to the current ones. """ t = self.transform transform = node.transform() if transform: t += transform p = self.properties properties = node.properties() if properties: p += properties return type(self)(time or self.time, t, p)
[docs] def length(self, node, end=None): """Return the length of any node. Follows variable references using the scope (if given to the :class:`Time` instance) and calls :meth:`~.dom.lily.Music.time_length` for Music nodes. Returns 0 for any other, non-musical, node. """ if isinstance(node, lily.Reference): return self.remote_length(node) elif isinstance(node, lily.Music): context = self.enter(node) return node.time_length(context, end) return 0
[docs] def durable_length(self, node): """Return the length of a Durable node.""" return self.transform.length(*self.time.get_duration(node))
[docs] def remote_length(self, node): """Return the length of the value of an IdentifierRef node. Returns 0 if the node can't be found or is no music. """ node, scope = node.get_value_with_scope(self.time.scope, self.time.wait) while isinstance(node, lily.Reference): node, scope = node.get_value_with_scope(scope, self.time.wait) if isinstance(node, lily.Music): time = type(self.time)(scope, self.time.wait) return node.time_length(self.enter(node, time), None) return 0