Source code for clockblocks.moment

#  ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++  #
#  This file is part of SCAMP (Suite for Computer-Assisted Music in Python)                      #
#  Copyright © 2020 Marc Evanstein <marc@marcevanstein.com>.                                     #
#                                                                                                #
#  This program 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 program 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 <http://www.gnu.org/licenses/>.                                                   #
#  ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++  #

"""
Module containing :class:`Moment`, which names a point on a clock's timeline, and the
:class:`ResolvableMoment` protocol implemented by anything that can be pinned to such a point.
"""

from __future__ import annotations
from typing import Protocol, runtime_checkable, TYPE_CHECKING
from clockblocks.enums import DurationUnits
if TYPE_CHECKING:
    from clockblocks.clock import Clock


[docs] @runtime_checkable class ResolvableMoment(Protocol): """ Anything that can name a point on a clock's timeline. The scheduling layer (wait / wait_until / fork / schedule_action) accepts any ResolvableMoment and calls resolve(clock) to pin it to an absolute Moment on that clock. Moment and MetricPhaseTarget both implement this. """
[docs] def resolve(self, clock: Clock) -> Moment: """ Pin this to an absolute :class:`Moment` on the given clock. :param clock: the clock whose timeline the result is measured against :return: an absolute Moment on that clock """ ...
[docs] class Moment: """ Represents a point on a clock's timeline. Can be either in beats or in time (as defined by `units`), and can be either relative (measured from a clock's current beat/time) or absolute (measured from the clock's start). User code should generally construct via one of the classmethods: Moment.at_beat(8) # absolute: beat 8 of the clock Moment.at_time(8) # absolute: 8 seconds (in the clock's time) since it started Moment.after_beats(2) # relative: 2 beats from now Moment.after_time(0.5) # relative: 0.5 seconds from now resolve(clock) returns an *absolute* Moment on that clock (a no-op if already absolute). The final scheduling process works only with absolute Moments, which are either pinned to a specific beat or a specific time in the clock's timeline. Relative moments or MetricPhaseTargets are resolved at scheduling time into absolute moments. :param value: how far along the timeline this moment sits, in the given units :param units: whether `value` counts beats or time ("beats"/"time", or a :class:`~clockblocks.enums.DurationUnits`) :param relative: if True, `value` is an offset from the clock's current position rather than a point measured from the clock's start """ def __init__(self, value: float, units: str | DurationUnits = "beats", relative: bool = False): self.value = value self.units = DurationUnits(units) self.relative = relative
[docs] @classmethod def at_beat(cls, beat: float) -> Moment: """ An absolute moment at the given beat of a clock. :param beat: beat, counted from the clock's start """ return cls(beat, DurationUnits.BEATS, relative=False)
[docs] @classmethod def at_time(cls, time: float) -> Moment: """ An absolute moment at the given time on a clock. :param time: time in seconds, counted from the clock's start """ return cls(time, DurationUnits.TIME, relative=False)
[docs] @classmethod def after_beats(cls, beats: float) -> Moment: """ A relative moment, the given number of beats from a clock's current beat. :param beats: how many beats from now """ return cls(beats, DurationUnits.BEATS, relative=True)
[docs] @classmethod def after_time(cls, time: float) -> Moment: """ A relative moment, the given amount of time from a clock's current time. :param time: how many seconds from now """ return cls(time, DurationUnits.TIME, relative=True)
[docs] def resolve(self, clock: Clock) -> Moment: """ Pin this moment to an absolute one on the given clock. A no-op if it is already absolute; a relative moment is measured out from the clock's current beat or time. :param clock: the clock whose timeline the result is measured against :return: an absolute Moment on that clock """ if not self.relative: return self now = clock.beat if self.units == DurationUnits.BEATS else clock.time return Moment(now + self.value, self.units, relative=False)
[docs] def beat_on(self, clock: Clock) -> float: """This (absolute) moment expressed as a beat of `clock`.""" if self.relative: raise ValueError("beat_on() requires an absolute Moment; call resolve(clock) first.") return self.value if self.units == DurationUnits.BEATS else clock.tempo_history.beat_at_time(self.value)
[docs] def scheduler_time(self, clock: Clock) -> float: """The scheduler-time at which this (absolute) moment occurs on `clock`.""" if self.relative: raise ValueError("scheduler_time() requires an absolute Moment; call resolve(clock) first.") return clock.clock_to_scheduler_time(self.value, units=self.units)
def __repr__(self): if self.relative: name = "after_beats" if self.units == DurationUnits.BEATS else "after_time" else: name = "at_beat" if self.units == DurationUnits.BEATS else "at_time" return f"Moment.{name}({self.value})"
[docs] def to_absolute_moment(when: float | ResolvableMoment | None, clock: Clock, *, units_if_number: str | DurationUnits = DurationUnits.BEATS, relative_if_number: bool = True, allow_number: bool = True) -> Moment: """ Coerce a `when` argument into an absolute Moment on `clock`. None is treated as "now" (relative 0); anything that isn't None or a number is assumed to be a ResolvableMoment and resolved. A bare number is handled per `allow_number`: when True (wait()/wait_until(), whose names fix the meaning) it is wrapped using the caller's convention (`units_if_number` / `relative_if_number`); when False (fork()/schedule_action(), where "when" alone wouldn't say whether a number is relative or absolute) a number raises TypeError, steering the caller to an explicit Moment. """ if when is None: return Moment(0, DurationUnits.BEATS, relative=True).resolve(clock) if isinstance(when, (int, float)): if not allow_number: raise TypeError( "`when` must be a Moment or MetricPhaseTarget here, not a bare number — its meaning would " "be ambiguous. Use Moment.at_beat(n) / Moment.at_time(s) for an absolute point, or " "Moment.after_beats(n) / Moment.after_time(s) for an offset from now." ) return Moment(when, units_if_number, relative=relative_if_number).resolve(clock) return when.resolve(clock)