"""Envelope events"""
from __future__ import annotations
import bisect
import typing
from scipy import integrate
from mutwo import core_constants
from mutwo import core_events
from mutwo import core_parameters
from mutwo import core_utilities
__all__ = ("Envelope", "RelativeEnvelope", "TempoEnvelope")
T = typing.TypeVar("T", bound=core_events.abc.Event)
[docs]class Envelope(
core_events.Consecution,
typing.Generic[T],
class_specific_side_attribute_tuple=(
"event_to_parameter",
"event_to_curve_shape",
"value_to_parameter",
"parameter_to_value",
"apply_parameter_on_event",
"apply_curve_shape_on_event",
"default_event_class",
"initialise_default_event_class",
),
):
"""Model continuous changing values (e.g. glissandi, crescendo).
:param event_iterable_or_point_sequence: An iterable filled with events
or with points. If the sequence is filled with points, the points
will be converted to events. Each event represents a point in a
two dimensional graph where the x-axis presents time and the y-axis
a changing value. Any event class can be used. It is
more important that the used event classes fit with the functions
passed in the following parameters.
:type event_iterable_or_point_sequence: typing.Iterable[T]
:param event_to_parameter: A function which receives an event and has to
return a parameter object (any object). By default the function will
ask the event for its `value` property. If the property can't be found
it will return 0.
:type event_to_parameter: typing.Callable[[core_events.abc.Event], core_constants.ParameterType]
:param event_to_curve_shape: A function which receives an event and has
to return a curve_shape. A curve_shape is either a float, an integer
or a fraction. For a curve_shape = 0 a linear transition between two
points is created. For a curve_shape > 0 the envelope changes slower
at the beginning and faster at the end, for a curve_shape < 0 it is
the inverse behaviour. The default function will ask the event for its
`curve_shape` property. If the property can't be found
it will return 0.
:type event_to_curve_shape: typing.Callable[[core_events.abc.Event], CurveShape]
:param parameter_to_value: Convert a parameter to a value. A value is any
object which supports mathematical operations.
:type parameter_to_value: typing.Callable[[Value], core_constants.ParameterType]
:param value_to_parameter: A callable object which converts a value to a parameter.
:type value_to_parameter: typing.Callable[[Value], core_constants.ParameterType]
:param apply_parameter_on_event: A callable object which applies a parameter on an event.
:type apply_parameter_on_event: typing.Callable[[core_events.abc.Event, core_constants.ParameterType], None]
:param apply_curve_shape_on_event: A callable object which applies a curve shape on an event.
:type apply_curve_shape_on_event: typing.Callable[[core_events.abc.Event, CurveShape], None]
:param default_event_class: The default event class which describes a point.
:type default_event_class: type[core_events.abc.Event]
:param initialise_default_event_class:
:type initialise_default_event_class: typing.Callable[[type[core_events.abc.Event], core_constants.DurationType], core_events.abc.Event]
This class is inspired by Marc Evansteins `Envelope` class in his
`expenvelope <https://git.sr.ht/~marcevanstein/expenvelope>`_
python package and is made to fit better into the `mutwo` ecosystem.
**Hint:**
When comparing two envelopes (e.g. `env0 == env1`) `mutwo` will only return
`True` in case all control points (= chronons inside the envelope) are
equal between both envelopes. So `mutwo` won't make the much more
complicated test to check if two envelopes have the same shape (= the same
value at each `env0.value_at(x) == env1.value_at(x)` for each possible
`x`). Such a test is not implemented yet.
**Example:**
>>> from mutwo import core_events
>>> core_events.Envelope([[0, 0, 1], [0.5, 1]])
Envelope([SimpleEvent(curve_shape = 1, duration = DirectDuration(duration = 1/2), value = 0), SimpleEvent(curve_shape = 0, duration = DirectDuration(duration = 0), value = 1)])
"""
# Type definitions
Value = core_constants.Real
CurveShape = core_constants.Real
IncompletePoint = tuple[core_constants.DurationType, core_constants.ParameterType]
CompletePoint = tuple[
core_constants.DurationType, core_constants.ParameterType, CurveShape # type: ignore
]
Point = CompletePoint | IncompletePoint
def __init__(
self,
event_iterable_or_point_sequence: typing.Iterable[T] | typing.Sequence[Point],
tempo_envelope: typing.Optional[core_events.TempoEnvelope] = None,
event_to_parameter: typing.Callable[
[core_events.abc.Event], core_constants.ParameterType
] = lambda event: getattr(
event, core_events.configurations.DEFAULT_PARAMETER_ATTRIBUTE_NAME
)
if hasattr(event, core_events.configurations.DEFAULT_PARAMETER_ATTRIBUTE_NAME)
else 0,
event_to_curve_shape: typing.Callable[
[core_events.abc.Event], CurveShape
] = lambda event: getattr(
event, core_events.configurations.DEFAULT_CURVE_SHAPE_ATTRIBUTE_NAME
)
if hasattr(event, core_events.configurations.DEFAULT_CURVE_SHAPE_ATTRIBUTE_NAME)
else 0,
parameter_to_value: typing.Callable[
[Value], core_constants.ParameterType
] = lambda parameter: parameter,
value_to_parameter: typing.Callable[
[Value], core_constants.ParameterType
] = lambda value: value,
apply_parameter_on_event: typing.Callable[
[core_events.abc.Event, core_constants.ParameterType], None
] = lambda event, parameter: setattr(
event,
core_events.configurations.DEFAULT_PARAMETER_ATTRIBUTE_NAME,
parameter,
),
apply_curve_shape_on_event: typing.Callable[
[core_events.abc.Event, CurveShape], None
] = lambda event, curve_shape: setattr(
event,
core_events.configurations.DEFAULT_CURVE_SHAPE_ATTRIBUTE_NAME,
curve_shape,
),
default_event_class: type[core_events.abc.Event] = core_events.SimpleEvent,
initialise_default_event_class: typing.Callable[
[type[core_events.abc.Event], core_constants.DurationType],
core_events.abc.Event,
] = lambda chronon_class, duration: chronon_class(
duration
), # type: ignore
):
self.event_to_parameter = event_to_parameter
self.event_to_curve_shape = event_to_curve_shape
self.value_to_parameter = value_to_parameter
self.parameter_to_value = parameter_to_value
self.apply_parameter_on_event = apply_parameter_on_event
self.apply_curve_shape_on_event = apply_curve_shape_on_event
self.default_event_class = default_event_class
self.initialise_default_event_class = initialise_default_event_class
event_iterable = self._event_iterable_or_point_sequence_to_event_iterable(
event_iterable_or_point_sequence
)
super().__init__(event_iterable, tempo_envelope)
# ###################################################################### #
# public class methods #
# ###################################################################### #
[docs] @classmethod
def from_points(
cls,
*point: Point,
**kwargs,
) -> Envelope:
"""Create new :class:`Envelope` from points.
This is merely a convenience wrapper to write
>>> Envelope.from_points([0, 1], [1, 100])
Envelope([SimpleEvent(curve_shape = 0, duration = DirectDuration(duration = 1), value = 1), SimpleEvent(curve_shape = 0, duration = DirectDuration(duration = 0), value = 100)])
instead of
>>> Envelope([[0, 1], [1, 100]])
Envelope([SimpleEvent(curve_shape = 0, duration = DirectDuration(duration = 1), value = 1), SimpleEvent(curve_shape = 0, duration = DirectDuration(duration = 0), value = 100)])
to mimic the default initialization behaviour of
`expenvelope.Envelope`. It's recommended to initialise
an Envelope without this method. This method will be
removed sooner or later.
"""
return cls(point, **kwargs)
# ###################################################################### #
# magic methods #
# ###################################################################### #
@typing.overload # type: ignore
def __setitem__(self, index_or_slice: int, event_or_sequence: T):
...
@typing.overload
def __setitem__(
self,
index_or_slice: slice,
event_or_sequence: typing.Iterable[T] | typing.Iterable[Envelope.Point],
):
...
def __setitem__(
self,
index_or_slice: int | slice,
event_or_sequence: T | typing.Iterable[T] | typing.Iterable[Envelope.Point],
):
if isinstance(index_or_slice, slice) and isinstance(
event_or_sequence, typing.Iterable
):
event_or_sequence = self._event_iterable_or_point_sequence_to_event_iterable( # type: ignore
event_or_sequence # type: ignore
)
super().__setitem__(index_or_slice, event_or_sequence) # type: ignore
# ###################################################################### #
# private static methods #
# ###################################################################### #
@staticmethod
def _point_sequence_to_corrected_point_list(
point_or_invalid_type_sequence: typing.Sequence[Point | typing.Any],
) -> list[Envelope.CompletePoint | None]:
corrected_point_list: list[Envelope.CompletePoint | None] = []
for point in point_or_invalid_type_sequence:
point_count = len(point)
if point_count == 2:
point += (0,) # type: ignore
elif point_count != 3:
raise core_utilities.InvalidPointError(point, point_count)
corrected_point_list.append(point) # type: ignore
return corrected_point_list
# ###################################################################### #
# private methods #
# ###################################################################### #
def _make_event(self, duration, parameter, curve_shape):
event = self.initialise_default_event_class(self.default_event_class, duration)
self.apply_parameter_on_event(event, parameter)
self.apply_curve_shape_on_event(event, curve_shape)
return event
def _point_sequence_to_event_list(
self,
point_or_invalid_type_sequence: typing.Sequence[Point | typing.Any],
) -> list[core_events.abc.Event]:
corrected_point_list = Envelope._point_sequence_to_corrected_point_list(
point_or_invalid_type_sequence
)
corrected_point_list.append(None)
event_list = []
for point0, point1 in zip(corrected_point_list, corrected_point_list[1:]):
if point0 is not None:
absolute_time0, value_or_parameter, curve_shape = point0
else:
raise TypeError("Found unexpected position of None in provided points.")
if point1:
absolute_time1 = point1[0]
assert absolute_time1 >= absolute_time0
else:
absolute_time1 = absolute_time0
duration = absolute_time1 - absolute_time0
event = self._make_event(duration, value_or_parameter, curve_shape)
event_list.append(event)
return event_list
def _event_iterable_or_point_sequence_to_event_iterable(
self,
event_iterable_or_point_sequence: typing.Iterable[T] | typing.Sequence[Point],
) -> typing.Iterable[core_events.abc.Event]:
item_type_list = [
isinstance(event_or_point, core_events.abc.Event)
for event_or_point in event_iterable_or_point_sequence
]
if all(item_type_list):
event_iterable = event_iterable_or_point_sequence
elif any(item_type_list):
raise TypeError(
"Found inconsistent iterable with mixed types. "
"Please only use events or only use points for "
"'event_iterable_or_point_sequence'. First 200 "
"characters of the problematic iterable: \n"
f"{str(event_iterable_or_point_sequence)[:200]}"
)
else:
event_iterable = self._point_sequence_to_event_list(
event_iterable_or_point_sequence # type: ignore
)
return event_iterable # type: ignore
def _event_to_value(self, event: core_events.abc.Event) -> Value:
return self.parameter_to_value(self.event_to_parameter(event))
# ###################################################################### #
# public properties #
# ###################################################################### #
@property
def parameter_tuple(self) -> tuple[core_constants.ParameterType, ...]:
"""Get `parameter` for each event inside :class:`Envelope`."""
return tuple(map(self.event_to_parameter, self))
@property
def value_tuple(self) -> tuple[Value, ...]:
"""Get `value` for each event inside :class:`Envelope`."""
return tuple(map(self.parameter_to_value, self.parameter_tuple))
@property
def curve_shape_tuple(self) -> tuple[CurveShape, ...]:
"""Get `curve_shape` for each event inside :class:`Envelope`."""
return tuple(map(self.event_to_curve_shape, self))
@property
def is_static(self) -> bool:
"""Return `True` if :class:`Envelope` only has one static value."""
return len(set(self.value_tuple)) <= 1
# ###################################################################### #
# public methods #
# ###################################################################### #
[docs] def value_at(
self, absolute_time: core_parameters.abc.Duration | typing.Any
) -> Value:
"""Get `value` at `absolute_time`.
:param absolute_time: Absolute position in time at which value shall be found.
This is 'x' in the function notation 'f(x)'.
:type absolute_time: core_parameters.abc.Duration | typing.Any
This function interpolates between the control points according to
their `curve_shape` property.
**Example:**
>>> from mutwo import core_events
>>> e = core_events.Envelope([[0, 0], [1, 2]])
>>> e.value_at(0)
0
>>> e.value_at(1)
2
>>> e.value_at(0.5)
1.0
"""
absolute_time = core_events.configurations.UNKNOWN_OBJECT_TO_DURATION(
absolute_time
)
absolute_time_in_floats = absolute_time.duration_in_floats
(
absolute_time_in_floats_tuple,
duration_in_floats,
) = self._absolute_time_in_floats_tuple_and_duration
try:
use_only_first_event = (
absolute_time_in_floats <= absolute_time_in_floats_tuple[0]
)
except IndexError:
raise core_utilities.EmptyEnvelopeError(self, "value_at")
use_only_last_event = absolute_time_in_floats >= (
# If the duration of the last event == 0 there is the danger
# of floating point errors (the value in absolute_time_tuple could
# be slightly higher than the duration of the Envelope. If this
# happens the function will raise an AssertionError, because
# "_get_index_at_from_absolute_time_tuple" will return
# "None"). With explicitly testing if the last duration
# equals 0 we can avoid this danger.
absolute_time_in_floats_tuple[-1]
if self[-1].duration > 0
else duration_in_floats
)
if use_only_first_event or use_only_last_event:
index = 0 if use_only_first_event else -1
return self._event_to_value(self[index])
event_0_index = self._get_index_at_from_absolute_time_tuple(
absolute_time, absolute_time_in_floats_tuple, duration_in_floats
)
assert event_0_index is not None
value0, value1 = (
self._event_to_value(self[event_0_index + n]) for n in range(2)
)
curve_shape = self.event_to_curve_shape(self[event_0_index])
return core_utilities.scale(
absolute_time_in_floats,
absolute_time_in_floats_tuple[event_0_index],
absolute_time_in_floats_tuple[event_0_index + 1],
value0,
value1,
curve_shape,
)
[docs] def parameter_at(
self, absolute_time: core_parameters.abc.Duration | typing.Any
) -> core_constants.ParameterType:
"""Get `parameter` at `absolute_time`.
:param absolute_time: Absolute position in time at which parameter shall
be found. This is 'x' in the function notation 'f(x)'.
:type absolute_time: core_parameters.abc.Duration | typing.Any
"""
return self.value_to_parameter(self.value_at(absolute_time))
[docs] @core_utilities.add_copy_option
def sample_at(
self,
absolute_time: core_parameters.abc.Duration | typing.Any,
append_duration: core_parameters.abc.Duration | typing.Any = 0,
) -> Envelope:
"""Discretize envelope at given time
:param absolute_time: Position in time where the envelope should
define a new event.
:type absolute_time: core_parameters.abc.Duration | typing.Any
:param append_duration: In case we add a new control point after any
already defined point, the duration of this control point will be
equal to "append_duration". Default to core_parameters.DirectDuration(0)
"""
def find_duration(
absolute_time: core_parameters.abc.Duration,
absolute_time_tuple: tuple[core_parameters.abc.Duration, ...],
):
"""Find duration of new control point"""
next_event_start_index = bisect.bisect_right(
absolute_time_tuple, absolute_time
)
try:
next_event_start = absolute_time_tuple[next_event_start_index]
# In case we call "sample_at" at a position after any already
# specified point.
except IndexError:
duration_new_event = append_duration
else:
duration_new_event = next_event_start - absolute_time
return duration_new_event
def find_curve_shape(
absolute_time: core_parameters.abc.Duration,
absolute_time_tuple: tuple[core_parameters.abc.Duration, ...],
envelope_duration: core_parameters.abc.Duration,
):
"""Find curve shape of new control point"""
old_event_index = (
core_events.Consecution._get_index_at_from_absolute_time_tuple(
absolute_time, absolute_time_tuple, envelope_duration
)
)
if old_event_index is not None:
old_event = self[old_event_index]
curve_shape = self.event_to_curve_shape(old_event)
curve_shape_old_event = (
(absolute_time - absolute_time_tuple[old_event_index])
/ old_event.duration
).duration_in_floats * curve_shape
curve_shape_new_event = curve_shape - curve_shape_old_event
self.apply_curve_shape_on_event(old_event, curve_shape_old_event)
else:
curve_shape_new_event = 0
return curve_shape_new_event
if not self:
raise core_utilities.EmptyEnvelopeError(self, "sample_at")
absolute_time, append_duration = (
core_events.configurations.UNKNOWN_OBJECT_TO_DURATION(unknown_object)
for unknown_object in (absolute_time, append_duration)
)
self._assert_valid_absolute_time(absolute_time)
# We only add a new event in case there isn't any event yet at
# given point in time.
if absolute_time not in (absolute_time_tuple := self.absolute_time_tuple):
envelope_duration = absolute_time_tuple[-1] + self[-1].duration
event = self._make_event(
find_duration(absolute_time, absolute_time_tuple),
self.parameter_at(absolute_time),
find_curve_shape(absolute_time, absolute_time_tuple, envelope_duration),
)
try:
self.squash_in(absolute_time, event)
# This means we want to squash in at a position much
# later than any already defined event.
except core_utilities.InvalidStartValueError:
difference = absolute_time - envelope_duration
self[-1].duration += difference
self.append(event)
return self
[docs] def integrate_interval(
self, start: core_constants.DurationType, end: core_constants.DurationType
) -> float:
"""Integrate envelope above given interval.
:param start: Beginning of integration interval.
:type start: core_parameters.abc.Duration
:param end: End of integration interval.
:type end: core_parameters.abc.Duration
"""
return integrate.quad(lambda x: self.value_at(x), start, end)[0]
[docs] def get_average_value(
self,
start: typing.Optional[core_parameters.abc.Duration | typing.Any] = None,
end: typing.Optional[core_parameters.abc.Duration | typing.Any] = None,
) -> Value:
"""Find average `value` in given interval.
:param start: The beginning of the interval. If set to `None` this
will be 0. Default to `None`.
:type start: typing.Optional[core_parameters.abc.Duration | typing.Any]
:param end: The end of the interval. If set to `None` this
will be the duration of the :class:`Envelope`.. Default to `None`.
:type end: typing.Optional[core_parameters.abc.Duration | typing.Any]
**Example:**
>>> from mutwo import core_events
>>> e = core_events.Envelope([[0, 1], [2, 0]])
>>> e.get_average_value()
0.5
>>> e.get_average_value(0.5)
0.375
>>> e.get_average_value(0.5, 1)
0.625
"""
if start is None:
start = core_parameters.DirectDuration(0)
if end is None:
end = self.duration
start, end = (
core_events.configurations.UNKNOWN_OBJECT_TO_DURATION(unknown_object)
for unknown_object in (start, end)
)
duration = end - start
if duration == 0:
self._logger.warning(core_utilities.InvalidAverageValueStartAndEndWarning())
return self.value_at(start)
return self.integrate_interval(start, end) / duration.duration
[docs] def get_average_parameter(
self,
start: typing.Optional[core_constants.DurationType] = None,
end: typing.Optional[core_constants.DurationType] = None,
) -> core_constants.ParameterType:
"""Find average `parameter` in given interval.
:param start: The beginning of the interval. If set to `None` this
will be 0. Default to `None`.
:type start: typing.Optional[core_parameters.abc.Duration | typing.Any]
:param end: The end of the interval. If set to `None` this
will be the duration of the :class:`Envelope`.. Default to `None`.
:type end: typing.Optional[core_parameters.abc.Duration | typing.Any]
**Example:**
>>> from mutwo import core_events
>>> e = core_events.Envelope([[0, 1], [2, 0]])
>>> e.get_average_parameter()
0.5
>>> e.get_average_parameter(0.5)
0.375
>>> e.get_average_parameter(0.5, 1)
0.625
"""
return self.value_to_parameter(self.get_average_value(start, end))
[docs] @core_utilities.add_copy_option
def cut_out(
self,
start: core_parameters.abc.Duration | typing.Any,
end: core_parameters.abc.Duration | typing.Any,
) -> Envelope[T]:
start, end = (
core_events.configurations.UNKNOWN_OBJECT_TO_DURATION(unknown_object)
for unknown_object in (start, end)
)
# _assert_correct_start_and_end_values and _assert_valid_absolute_time
# is called when super().cut_out is called later.
self.sample_at(start, append_duration=end - start)
self.sample_at(end)
last_point = self.get_event_at(end)
# In case last_point.duration == 0 "get_event_at" won't return
# any object. This only happens in case
#
# end > self.duration
#
# So the new point will be appended.
if last_point is None:
last_point = self[-1]
assert last_point
cut_out_envelope = super().cut_out(start, end)
cut_out_envelope.append(last_point.set("duration", 0))
return cut_out_envelope
[docs] @core_utilities.add_copy_option
def cut_off(
self,
start: core_parameters.abc.Duration | typing.Any,
end: core_parameters.abc.Duration | typing.Any,
) -> Envelope[T]:
start, end = (
core_events.configurations.UNKNOWN_OBJECT_TO_DURATION(unknown_object)
for unknown_object in (start, end)
)
self._assert_valid_absolute_time(start)
self._assert_correct_start_and_end_values(
start, end, condition=lambda start, end: start < end
)
if (cut_off_duration := end - start) > 0:
# It is sufficient to find the first control point
# by simply using "parameter_at" instead of "sample_at":
# We don't need an accurate curve_shape or duration,
# because this point only exists in an infinitely short
# moment in time anyway (or better: its main function is
# to ensure that interpolation from the previous point
# to this point works as expected).
parameter_0 = self.parameter_at(start)
event_0 = self._make_event(0, parameter_0, 0)
self.sample_at(end)
self._cut_off(start, end, cut_off_duration)
self.squash_in(start, event_0)
return self
[docs] @core_utilities.add_copy_option
def extend_until(
self,
duration: core_parameters.abc.Duration,
duration_to_white_space: typing.Optional[
typing.Callable[[core_parameters.abc.Duration], core_events.abc.Event]
] = None,
prolong_chronon: bool = True,
) -> Envelope[T]:
if not self:
raise core_utilities.EmptyEnvelopeError(self, "extend_until")
self.sample_at(duration)
[docs] def split_at(
self,
*absolute_time: core_parameters.abc.Duration,
ignore_invalid_split_point: bool = False,
) -> tuple[Envelope, ...]:
if not absolute_time:
raise core_utilities.NoSplitTimeError()
absolute_time = sorted(absolute_time)
if absolute_time[-1] > self.duration and not ignore_invalid_split_point:
raise core_utilities.SplitError(absolute_time[-1])
# We copy, because the 'sample_at' calls would change our envelope.
self = self.copy()
for t in absolute_time:
self.sample_at(t)
def add(s, value):
s.append(s._make_event(0, s.value_to_parameter(value), 0))
segment_tuple = super().split_at(
*absolute_time, ignore_invalid_split_point=ignore_invalid_split_point
)
# We already added the interpolation points with 'self.sample_at(*t)',
# but they are always only available in the segments after the split
# point (because for each segment the start is included, but the end
# duration isn't included anymore). So we need to add them to the
# segments before the split points, otherwise 'value_at' returns wrong
# values.
for segment0, segment1 in zip(segment_tuple, segment_tuple[1:]):
add(segment0, segment1.value_at(0))
if segment_tuple:
s = segment_tuple[-1]
v = self.value_at(self.duration)
# Only add control point, if it isn't present
# yet anyway (minimal changes).
# This condition is 'true' if we only split at
# start time ('env.split_at(0)').
if s.value_at(s.duration) != v:
add(s, v)
return segment_tuple
[docs]class RelativeEnvelope(Envelope, typing.Generic[T]):
__parent_doc_string = Envelope.__doc__.split("\n")[2:] # type: ignore
__after_parameter_text_index = __parent_doc_string.index("")
__doc__ = "\n".join(
["Envelope with relative durations and values / parameters.\n"]
+ __parent_doc_string[:__after_parameter_text_index]
+ [
" :param base_parameter_and_relative_parameter_to_absolute_parameter: A function",
" which runs when the :func:`resolve` is called. It expects the base parameter",
" and the relative parameter (which is extracted from the envelope events)",
" and should return an absolute parameter.",
]
+ __parent_doc_string[__after_parameter_text_index:]
+ [
" The :class:`RelativeEnvelope` adds the :func:`resolve` method",
" to the base class :class:`Envelope`.",
]
)
def __init__(
self,
*args,
base_parameter_and_relative_parameter_to_absolute_parameter: typing.Callable[
[core_constants.ParameterType, core_constants.ParameterType],
core_constants.ParameterType,
],
**kwargs,
):
self.base_parameter_and_relative_parameter_to_absolute_parameter = (
base_parameter_and_relative_parameter_to_absolute_parameter
)
super().__init__(*args, **kwargs)
[docs] def resolve(
self,
duration: core_parameters.abc.Duration | typing.Any,
base_parameter: core_constants.ParameterType,
resolve_envelope_class: type[Envelope] = Envelope,
) -> Envelope:
duration = core_events.configurations.UNKNOWN_OBJECT_TO_DURATION(duration)
point_list = []
try:
duration_factor = duration / self.duration
except ZeroDivisionError:
duration_factor = core_parameters.DirectDuration(0)
for absolute_time, event in zip(self.absolute_time_tuple, self):
relative_parameter = self.event_to_parameter(event)
new_parameter = (
self.base_parameter_and_relative_parameter_to_absolute_parameter(
base_parameter, relative_parameter
)
)
point = (
absolute_time * duration_factor,
new_parameter,
self.event_to_curve_shape(event),
)
point_list.append(point)
return resolve_envelope_class(point_list)
TempoPoint: typing.TypeAlias = "core_parameters.abc.TempoPoint | core_constants.Real"
[docs]class TempoEnvelope(Envelope):
"""Define dynamic or static tempo trajectories.
You can either define a new `TempoEnvelope` with instances
of classes which inherit from :class:`mutwo.core_parameters.abc.TempoPoint`
(for instance :class:`mutwo.core_parameters.DirectTempoPoint`) or with
`float` or `int` objects which represent beats per minute.
Please see the :class:`mutwo.core_events.Envelope` for full documentation
for initialization attributes.
The default parameters of the `TempoEnvelope` class expects
:class:`mutwo.core_events.SimpleEvent` to which a tempo point
was assigned by the name "tempo_point". This is specified in the global
`mutwo.core_events.configurations.DEFAULT_TEMPO_ENVELOPE_PARAMETER_NAME`
and can be adjusted.
**Example:**
>>> from mutwo import core_events
>>> from mutwo import core_parameters
>>> # (1) define with floats
>>> # So we have an envelope which moves from tempo 60 to 30
>>> # and back to 60.
>>> tempo_envelope_with_float = core_events.TempoEnvelope(
... [[0, 60], [1, 30], [2, 60]]
... )
>>> # (2) define with tempo points
>>> tempo_envelope_with_tempo_points = core_events.TempoEnvelope(
... [
... [0, core_parameters.DirectTempoPoint(60)],
... [1, core_parameters.DirectTempoPoint(30)],
... [2, core_parameters.DirectTempoPoint(30, reference=2)],
... ]
... )
"""
def __init__(
self,
*args,
event_to_parameter: typing.Optional[
typing.Callable[[core_events.abc.Event], core_constants.ParameterType]
] = None,
value_to_parameter: typing.Optional[
typing.Callable[[core_events.Envelope.Value], core_constants.ParameterType]
] = None,
parameter_to_value: typing.Optional[
typing.Callable[[core_constants.ParameterType], core_events.Envelope.Value]
] = None,
apply_parameter_on_event: typing.Optional[
typing.Callable[[core_events.abc.Event, core_constants.ParameterType], None]
] = None,
default_event_class: type[core_events.abc.Event] = core_events.TempoChronon,
initialise_default_event_class: typing.Callable[
[type[core_events.abc.Event], core_constants.DurationType],
core_events.abc.Event,
] = lambda chronon_class, duration: chronon_class(
tempo_point=1, duration=duration
),
**kwargs,
):
def default_event_to_parameter(event: core_events.abc.Event) -> TempoPoint:
return getattr(
event,
core_events.configurations.DEFAULT_TEMPO_ENVELOPE_PARAMETER_NAME,
)
def default_value_to_parameter(value: float) -> TempoPoint:
return core_parameters.DirectTempoPoint(value)
def default_parameter_to_value(parameter: TempoPoint) -> float:
# Here we specify, that we allow either core_parameters.abc.TempoPoint
# or float/number objects.
# So in case we have a core_parameters.abc.TempoPoint 'getattr' is
# successful, if not it will return 'parameter', because it
# will assume that we have a number based tempo point.
return float(
getattr(parameter, "absolute_tempo_in_beats_per_minute", parameter)
)
def default_apply_parameter_on_event(
event: core_events.abc.Event, parameter: TempoPoint
):
setattr(
event,
core_events.configurations.DEFAULT_TEMPO_ENVELOPE_PARAMETER_NAME,
parameter,
)
super().__init__(
*args,
event_to_parameter=event_to_parameter or default_event_to_parameter,
value_to_parameter=value_to_parameter or default_value_to_parameter,
parameter_to_value=parameter_to_value or default_parameter_to_value,
apply_parameter_on_event=apply_parameter_on_event
or default_apply_parameter_on_event,
default_event_class=default_event_class,
initialise_default_event_class=initialise_default_event_class,
**kwargs,
)
def __eq__(self, other: typing.Any):
# TempoEnvelope can't use the default '__eq__' method inherited
# from list, because this would create endless recursion
# (because every event has a TempoEnvelope, so Python would forever
# compare the TempoEnvelopes of TempoEnvelopes).
try:
return (
# Prefer lazy evaluation for better performance
# (use 'and' instead of 'all').
self.absolute_time_tuple == other.absolute_time_tuple
and self.curve_shape_tuple == other.curve_shape_tuple
and self.value_tuple == other.value_tuple
)
except AttributeError:
return False