diff --git a/mutwo/core/events/envelopes.py b/mutwo/core/events/envelopes.py index 802e6abc..21301de1 100644 --- a/mutwo/core/events/envelopes.py +++ b/mutwo/core/events/envelopes.py @@ -17,12 +17,12 @@ class Envelope(events.basic.SequentialEvent, typing.Generic[T]): """Model continuous changing values (e.g. glissandi, crescendo). - :param iterable: An iterable filled with events. Each event represents + :param event_iterable_or_point_sequence: An iterable filled with events. Each event represents a point in a two dimensional graph where the y-axis presents time and the x-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 iterable: typing.Iterable[T] + :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 @@ -307,21 +307,19 @@ def resolve( base_parameter: constants.ParameterType, resolve_envelope_class: type[Envelope] = Envelope, ) -> Envelope: - event_list = [] - copied_self = self.copy() - copied_self.duration = duration - for event in copied_self: + point_list = [] + duration_factor = duration / self.duration + 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 ) ) - self.apply_parameter_on_event(event, new_parameter) - event_list.append(event) - kwargs = { - name: getattr(self, name) - for name in resolve_envelope_class._class_specific_side_attribute_tuple - if hasattr(self, name) - } - return resolve_envelope_class(event_list, **kwargs) + point = ( + absolute_time * duration_factor, + new_parameter, + self.event_to_curve_shape(event), + ) + point_list.append(point) + return resolve_envelope_class(point_list) diff --git a/mutwo/core/parameters/abc.py b/mutwo/core/parameters/abc.py index af85b4a2..54a5ebd3 100644 --- a/mutwo/core/parameters/abc.py +++ b/mutwo/core/parameters/abc.py @@ -1,4 +1,12 @@ -"""Abstract base classes for different parameters.""" +"""Abstract base classes for different parameters. + +This module defines the public API of parameters. +Most other mutwo classes rely on this API. This means +when someone creates a new class inheriting from any of the +abstract parameter classes which are defined in this module, +she or he can make use of all other mutwo modules with this +newly created parameter class. +""" from __future__ import annotations @@ -13,10 +21,11 @@ except ImportError: import fractions # type: ignore -from mutwo.core.events import envelopes +from mutwo.core import events from mutwo.core.parameters import pitches_constants from mutwo.core.parameters import volumes_constants from mutwo.core.utilities import constants +from mutwo.core.utilities import decorators from mutwo.core.utilities import tools __all__ = ( @@ -34,28 +43,34 @@ class ParameterWithEnvelope(abc.ABC): """Abstract base class for all parameters with an envelope.""" - def __init__(self, envelope: envelopes.RelativeEnvelope): + def __init__(self, envelope: events.envelopes.RelativeEnvelope): self.envelope = envelope @property - def envelope(self) -> envelopes.RelativeEnvelope: + def envelope(self) -> events.envelopes.RelativeEnvelope: return self._envelope @envelope.setter def envelope(self, new_envelope: typing.Any): try: - assert isinstance(new_envelope, envelopes.RelativeEnvelope) + assert isinstance(new_envelope, events.envelopes.RelativeEnvelope) except AssertionError: raise TypeError( f"Found illegal object '{new_envelope}' of not " f"supported type '{type(new_envelope)}'. " - f"Only instances of '{envelopes.RelativeEnvelope}'" + f"Only instances of '{events.envelopes.RelativeEnvelope}'" " are allowed!" ) self._envelope = new_envelope - def resolve_envelope(self, duration: constants.DurationType) -> envelopes.Envelope: - return self.envelope.resolve(duration, self) + def resolve_envelope( + self, + duration: constants.DurationType, + resolve_envelope_class: type[ + events.envelopes.Envelope + ] = events.envelopes.Envelope, + ) -> events.envelopes.Envelope: + return self.envelope.resolve(duration, self, resolve_envelope_class) class PitchInterval(abc.ABC): @@ -73,6 +88,12 @@ class PitchInterval(abc.ABC): def cents(self) -> float: raise NotImplementedError + def __eq__(self, other: typing.Any) -> bool: + try: + return self.cents == other.cents + except AttributeError: + return False + @functools.total_ordering # type: ignore class Pitch(ParameterWithEnvelope): @@ -84,11 +105,242 @@ class Pitch(ParameterWithEnvelope): to define an :func:`add` and a :func:`subtract` method. """ - class PitchEnvelope(envelopes.Envelope): - pass + class PitchEnvelope(events.envelopes.Envelope): + """Default resolution envelope class for :class:`Pitch`""" + + def __init__( + self, + *args, + event_to_parameter: typing.Optional[ + typing.Callable[[events.abc.Event], constants.ParameterType] + ] = None, + value_to_parameter: typing.Optional[ + typing.Callable[ + [events.envelopes.Envelope.Value], constants.ParameterType + ] + ] = None, + parameter_to_value: typing.Optional[ + typing.Callable[ + [constants.ParameterType], events.envelopes.Envelope.Value + ] + ] = None, + apply_parameter_on_event: typing.Optional[ + typing.Callable[[events.abc.Event, constants.ParameterType], None] + ] = None, + **kwargs, + ): + if not event_to_parameter: + event_to_parameter = self._event_to_parameter + if not value_to_parameter: + value_to_parameter = self._value_to_parameter + if not apply_parameter_on_event: + apply_parameter_on_event = self._apply_parameter_on_event + if not parameter_to_value: + parameter_to_value = self._parameter_to_value + + super().__init__( + *args, + event_to_parameter=event_to_parameter, + value_to_parameter=value_to_parameter, + parameter_to_value=parameter_to_value, + apply_parameter_on_event=apply_parameter_on_event, + **kwargs, + ) - class PitchIntervalEnvelope(envelopes.RelativeEnvelope): - pass + @classmethod + def make_generic_pitch_class(cls, frequency: constants.Real) -> type[Pitch]: + @decorators.add_copy_option + def generic_add(self, pitch_interval: PitchInterval) -> Pitch: + self.frequency = ( + Pitch.cents_to_ratio(pitch_interval.cents) * self.frequency + ) + return self + + @decorators.add_copy_option + def generic_subtract(self, pitch_interval: PitchInterval) -> Pitch: + self.frequency = self.frequency / Pitch.cents_to_ratio( + pitch_interval.cents + ) + return self + + generic_pitch_class = type( + "GenericPitch", + (Pitch,), + { + "frequency": frequency, + "add": generic_add, + "subtract": generic_subtract, + "__repr__": lambda self: f"GenericPitchInterval(frequency = {self.frequency})", + }, + ) + + return generic_pitch_class + + @classmethod + def make_generic_pitch(cls, frequency: constants.Real) -> Pitch: + return cls.make_generic_pitch_class(frequency) + + @classmethod + def _value_to_parameter( + cls, + value: events.envelopes.Envelope.Value, # type: ignore + ) -> constants.ParameterType: + # For inner calculation (value) cents are used instead + # of frequencies. In this way we can ensure that the transitions + # are closer to the human logarithmic hearing. + # See als `_parameter_to_value`. + frequency = ( + Pitch.cents_to_ratio(value) + * pitches_constants.PITCH_ENVELOPE_REFERENCE_FREQUENCY + ) + return cls.make_generic_pitch(frequency) + + @classmethod + def _event_to_parameter( + cls, event: events.abc.Event + ) -> constants.ParameterType: + if hasattr( + event, + pitches_constants.DEFAULT_PITCH_ENVELOPE_PARAMETER_NAME, + ): + return getattr( + event, + pitches_constants.DEFAULT_PITCH_ENVELOPE_PARAMETER_NAME, + ) + else: + return cls.make_generic_pitch(pitches_constants.DEFAULT_CONCERT_PITCH) + + @classmethod + def _apply_parameter_on_event( + cls, event: events.abc.Event, parameter: constants.ParameterType + ): + setattr( + event, + pitches_constants.DEFAULT_PITCH_ENVELOPE_PARAMETER_NAME, + parameter, + ) + + @classmethod + def _parameter_to_value( + cls, parameter: constants.ParameterType + ) -> constants.Real: + # For inner calculation (value) cents are used instead + # of frequencies. In this way we can ensure that the transitions + # are closer to the human logarithmic hearing. + # See als `_value_to_parameter`. + return Pitch.hertz_to_cents( + pitches_constants.PITCH_ENVELOPE_REFERENCE_FREQUENCY, + parameter.frequency, + ) + + class PitchIntervalEnvelope(events.envelopes.RelativeEnvelope): + """Default envelope class for :class:`Pitch` + + Resolves into :class:`Pitch.PitchEnvelope`. + """ + + def __init__( + self, + *args, + event_to_parameter: typing.Optional[ + typing.Callable[[events.abc.Event], constants.ParameterType] + ] = None, + value_to_parameter: typing.Optional[ + typing.Callable[ + [events.envelopes.Envelope.Value], constants.ParameterType + ] + ] = None, + parameter_to_value: typing.Callable[ + [constants.ParameterType], events.envelopes.Envelope.Value + ] = lambda parameter: parameter.cents, + apply_parameter_on_event: typing.Optional[ + typing.Callable[[events.abc.Event, constants.ParameterType], None] + ] = None, + base_parameter_and_relative_parameter_to_absolute_parameter: typing.Optional[ + typing.Callable[ + [constants.ParameterType, constants.ParameterType], + constants.ParameterType, + ] + ] = None, + **kwargs, + ): + if not event_to_parameter: + event_to_parameter = self._event_to_parameter + if not value_to_parameter: + value_to_parameter = self._value_to_parameter + if not apply_parameter_on_event: + apply_parameter_on_event = self._apply_parameter_on_event + if not base_parameter_and_relative_parameter_to_absolute_parameter: + base_parameter_and_relative_parameter_to_absolute_parameter = ( + self._base_parameter_and_relative_parameter_to_absolute_parameter + ) + + super().__init__( + *args, + event_to_parameter=event_to_parameter, + value_to_parameter=value_to_parameter, + parameter_to_value=parameter_to_value, + apply_parameter_on_event=apply_parameter_on_event, + base_parameter_and_relative_parameter_to_absolute_parameter=base_parameter_and_relative_parameter_to_absolute_parameter, + **kwargs, + ) + + @staticmethod + def make_generic_pitch_interval(cents: constants.Real) -> PitchInterval: + return type( + "GenericPitchInterval", + (PitchInterval,), + { + "cents": cents, + "__repr__": lambda self: f"GenericPitchInterval(cents = {self.cents})", + }, + )() + + @classmethod + def _event_to_parameter( + cls, event: events.abc.Event + ) -> constants.ParameterType: + if hasattr( + event, pitches_constants.DEFAULT_PITCH_INTERVAL_ENVELOPE_PARAMETER_NAME + ): + return getattr( + event, + pitches_constants.DEFAULT_PITCH_INTERVAL_ENVELOPE_PARAMETER_NAME, + ) + else: + return cls.make_generic_pitch_interval(0) + + @classmethod + def _value_to_parameter( + cls, value: events.envelopes.Envelope.Value + ) -> constants.ParameterType: + return cls.make_generic_pitch_interval(value) + + @classmethod + def _apply_parameter_on_event( + cls, event: events.abc.Event, parameter: constants.ParameterType + ): + setattr( + event, + pitches_constants.DEFAULT_PITCH_INTERVAL_ENVELOPE_PARAMETER_NAME, + parameter, + ), + + @classmethod + def _base_parameter_and_relative_parameter_to_absolute_parameter( + cls, base_parameter: Pitch, relative_parameter: PitchInterval + ) -> Pitch: + return base_parameter + relative_parameter + + def __init__(self, envelope: typing.Optional[Pitch.PitchIntervalEnvelope] = None): + if not envelope: + generic_pitch_interval = ( + self.PitchIntervalEnvelope.make_generic_pitch_interval(0) + ) + envelope = self.PitchIntervalEnvelope( + [[0, generic_pitch_interval], [1, generic_pitch_interval]] + ) + super().__init__(envelope) # ###################################################################### # # conversion methods between different pitch describing units # @@ -214,6 +466,15 @@ def __add__(self, pitch_interval: PitchInterval) -> Pitch: def __sub__(self, pitch_interval: PitchInterval) -> Pitch: return self.subtract(pitch_interval, mutate=False) + def resolve_envelope( + self, + duration: constants.DurationType, + resolve_envelope_class: typing.Optional[type[events.envelopes.Envelope]] = None, + ) -> events.envelopes.Envelope: + if not resolve_envelope_class: + resolve_envelope_class = Pitch.PitchEnvelope + return super().resolve_envelope(duration, resolve_envelope_class) + @functools.total_ordering # type: ignore class Volume(abc.ABC): diff --git a/mutwo/core/parameters/pitches.py b/mutwo/core/parameters/pitches.py index f78d8f4f..42d727f8 100644 --- a/mutwo/core/parameters/pitches.py +++ b/mutwo/core/parameters/pitches.py @@ -91,7 +91,8 @@ class DirectPitch(parameters.abc.Pitch): >>> my_pitch = pitches.DirectPitch(440) """ - def __init__(self, frequency: constants.Real): + def __init__(self, frequency: constants.Real, *args, **kwargs): + super().__init__(*args, **kwargs) self._frequency = float(frequency) @property @@ -133,7 +134,8 @@ class MidiPitch(parameters.abc.Pitch): >>> middle_c_quarter_tone_high = pitches.MidiPitch(60.5) """ - def __init__(self, midi_pitch_number: float): + def __init__(self, midi_pitch_number: float, *args, **kwargs): + super().__init__(*args, **kwargs) self._midi_pitch_number = midi_pitch_number @property @@ -206,7 +208,11 @@ def __init__( str, fractions.Fraction, typing.Iterable[int] ] = "1/1", concert_pitch: ConcertPitch = None, + *args, + **kwargs, ): + super().__init__(*args, **kwargs) + if concert_pitch is None: concert_pitch = parameters.pitches_constants.DEFAULT_CONCERT_PITCH @@ -595,12 +601,6 @@ def __repr__(self) -> str: ratio += "/1" return f"JustIntonationPitch('{ratio}')" - def __add__(self, other: JustIntonationPitch) -> JustIntonationPitch: - return self._math(other, operator.add, mutate=False) # type: ignore - - def __sub__(self, other: JustIntonationPitch) -> JustIntonationPitch: - return self._math(other, operator.sub, mutate=False) # type: ignore - def __abs__(self): if self.numerator > self.denominator: return copy.deepcopy(self) @@ -1387,8 +1387,10 @@ def __init__( str, fractions.Fraction, typing.Iterable[int] ] = "1/1", concert_pitch: ConcertPitch = None, + *args, + **kwargs, ): - super().__init__(ratio_or_exponent_tuple, concert_pitch) + super().__init__(ratio_or_exponent_tuple, concert_pitch, *args, **kwargs) self.partial_tuple = partial_tuple def __repr__(self) -> str: @@ -1422,7 +1424,13 @@ def __init__( concert_pitch_pitch_class: constants.Real, concert_pitch_octave: int, concert_pitch: ConcertPitch = None, + *args, + **kwargs, ): + super().__init__( + *args, + **kwargs, + ) if concert_pitch is None: concert_pitch = parameters.pitches_constants.DEFAULT_CONCERT_PITCH @@ -1614,6 +1622,8 @@ def __init__( concert_pitch_pitch_class: constants.Real = None, concert_pitch_octave: int = None, concert_pitch: ConcertPitch = None, + *args, + **kwargs, ): if concert_pitch_pitch_class is None: concert_pitch_pitch_class = ( @@ -1638,6 +1648,8 @@ def __init__( concert_pitch_pitch_class, concert_pitch_octave, concert_pitch, + *args, + **kwargs, ) self._pitch_class_name = pitch_class_name diff --git a/mutwo/core/parameters/pitches_constants.py b/mutwo/core/parameters/pitches_constants.py index cdabf7e5..8df85c9e 100644 --- a/mutwo/core/parameters/pitches_constants.py +++ b/mutwo/core/parameters/pitches_constants.py @@ -291,3 +291,16 @@ 47: commas.Comma(fractions.Fraction(752, 729)), } """Standard commas defined by the `Helmholtz-Ellis JI Pitch Notation `_.""" + +PITCH_ENVELOPE_REFERENCE_FREQUENCY = 100 +"""Reference frequency for internal calculation in +:class:`mutwo.core.parameters.abc.Pitch.PitchEnvelope`. Exact +number doesn't really matter, it only has to keep consistent.""" + +DEFAULT_PITCH_ENVELOPE_PARAMETER_NAME = "pitch" +"""Default property parameter name for events in +:class:`mutwo.core.parameters.abc.Pitch.PitchEnvelope`.""" + +DEFAULT_PITCH_INTERVAL_ENVELOPE_PARAMETER_NAME = "pitch_interval" +"""Default property parameter name for events in +:class:`mutwo.core.parameters.abc.Pitch.PitchIntervalEnvelope`.""" diff --git a/tests/parameters/abc_tests.py b/tests/parameters/abc_tests.py index 5b5b90f0..7b5addaf 100644 --- a/tests/parameters/abc_tests.py +++ b/tests/parameters/abc_tests.py @@ -52,39 +52,140 @@ def test_hertz_to_midi_pitch_number(self): ) +class PitchIntervalEnvelopeTest(unittest.TestCase): + def setUp(cls): + pitch_interval0 = ( + parameters.abc.Pitch.PitchIntervalEnvelope.make_generic_pitch_interval(1200) + ) + pitch_interval1 = ( + parameters.abc.Pitch.PitchIntervalEnvelope.make_generic_pitch_interval(0) + ) + pitch_interval2 = ( + parameters.abc.Pitch.PitchIntervalEnvelope.make_generic_pitch_interval(-100) + ) + cls.pitch = parameters.abc.Pitch.PitchEnvelope.make_generic_pitch_class(440)( + envelope=parameters.abc.Pitch.PitchIntervalEnvelope( + [[0, pitch_interval0], [10, pitch_interval1], [20, pitch_interval2]] + ) + ) + cls.pitch_envelope = cls.pitch.resolve_envelope(1) + + def test_value_at(self): + self.assertEqual(self.pitch.envelope.value_at(0), 1200) + self.assertEqual(self.pitch.envelope.value_at(5), 600) + self.assertEqual(self.pitch.envelope.value_at(10), 0) + self.assertEqual(self.pitch.envelope.value_at(15), -50) + self.assertEqual(self.pitch.envelope.value_at(20), -100) + + def test_parameter_at(self): + for absolute_time, cents in ( + (0, 1200), + (5, 600), + (10, 0), + (15, -50), + (20, -100), + ): + self.assertEqual( + self.pitch.envelope.parameter_at(absolute_time), + parameters.abc.Pitch.PitchIntervalEnvelope.make_generic_pitch_interval( + cents + ), + ) + + def test_value_tuple(self): + self.assertEqual(self.pitch.envelope.value_tuple, (1200, 0, -100)) + + def test_resolve_envelope(self): + point_list = [] + for position, frequency in ( + (0, 880), + (0.5, 440), + (1, fractions.Fraction(116897880079141095, 281474976710656)), + ): + point_list.append( + ( + position, + parameters.abc.Pitch.PitchEnvelope.make_generic_pitch(frequency), + ) + ) + pitch_envelope = self.pitch.PitchEnvelope(point_list) + self.assertEqual(self.pitch_envelope, pitch_envelope) + + def test_value_at_resolved_envelope(self): + for position, frequency in ( + (0, 880), + (0.25, 622.2539674441618), + (0.5, 440), + (1, 415.3046975799451), + ): + self.assertAlmostEqual( + self.pitch_envelope.value_at(position), # type: ignore + parameters.abc.Pitch.hertz_to_cents( + parameters.pitches_constants.PITCH_ENVELOPE_REFERENCE_FREQUENCY, + frequency, + ), # type: ignore + ) + + def test_parameter_at_resolved_envelope(self): + for position, frequency in ( + (0, 880), + (0.25, 622.2539674441618), + (0.5, 440), + (1, 415.3046975799451), + ): + self.assertAlmostEqual( + self.pitch_envelope.parameter_at(position).frequency, frequency + ) + + class VolumeTest(unittest.TestCase): def test_decibel_to_amplitude_ratio(self): self.assertEqual( - parameters.abc.Volume.decibel_to_amplitude_ratio(0), 1, + parameters.abc.Volume.decibel_to_amplitude_ratio(0), + 1, ) self.assertEqual( - round(parameters.abc.Volume.decibel_to_amplitude_ratio(-6), 2,), 0.5, + round( + parameters.abc.Volume.decibel_to_amplitude_ratio(-6), + 2, + ), + 0.5, ) self.assertEqual( - round(parameters.abc.Volume.decibel_to_amplitude_ratio(-12), 2,), 0.25, + round( + parameters.abc.Volume.decibel_to_amplitude_ratio(-12), + 2, + ), + 0.25, ) # different reference amplitude self.assertEqual( - parameters.abc.Volume.decibel_to_amplitude_ratio(0, 0.5), 0.5, + parameters.abc.Volume.decibel_to_amplitude_ratio(0, 0.5), + 0.5, ) self.assertEqual( - parameters.abc.Volume.decibel_to_amplitude_ratio(0, 2), 2, + parameters.abc.Volume.decibel_to_amplitude_ratio(0, 2), + 2, ) def test_decibel_to_power_ratio(self): self.assertEqual( - parameters.abc.Volume.decibel_to_power_ratio(0), 1, + parameters.abc.Volume.decibel_to_power_ratio(0), + 1, ) self.assertEqual( - parameters.abc.Volume.decibel_to_power_ratio(-6), 0.251188643150958, + parameters.abc.Volume.decibel_to_power_ratio(-6), + 0.251188643150958, ) self.assertEqual( - parameters.abc.Volume.decibel_to_power_ratio(6), 3.9810717055349722, + parameters.abc.Volume.decibel_to_power_ratio(6), + 3.9810717055349722, ) def test_amplitude_ratio_to_decibel(self): self.assertEqual( - parameters.abc.Volume.amplitude_ratio_to_decibel(1), 0, + parameters.abc.Volume.amplitude_ratio_to_decibel(1), + 0, ) self.assertEqual( parameters.abc.Volume.amplitude_ratio_to_decibel( @@ -99,12 +200,14 @@ def test_amplitude_ratio_to_decibel(self): parameters.abc.Volume.amplitude_ratio_to_decibel(0.25), -12.041, places=3 ) self.assertEqual( - parameters.abc.Volume.amplitude_ratio_to_decibel(0), float("-inf"), + parameters.abc.Volume.amplitude_ratio_to_decibel(0), + float("-inf"), ) def test_power_ratio_to_decibel(self): self.assertEqual( - parameters.abc.Volume.power_ratio_to_decibel(1), 0, + parameters.abc.Volume.power_ratio_to_decibel(1), + 0, ) self.assertEqual( parameters.abc.Volume.power_ratio_to_decibel(0.5, reference_amplitude=0.5), @@ -117,7 +220,8 @@ def test_power_ratio_to_decibel(self): parameters.abc.Volume.power_ratio_to_decibel(0.06309), -12, places=3 ) self.assertEqual( - parameters.abc.Volume.power_ratio_to_decibel(0), float("-inf"), + parameters.abc.Volume.power_ratio_to_decibel(0), + float("-inf"), ) def test_amplitude_ratio_to_velocity(self):