Source code for scamp.playback_adjustments

"""
Module containing classes for defining adjustments to the playback of note parameters, as well as the
:class:`PlaybackAdjustmentsDictionary`, which defines how particular notations should be played back.
"""

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

from __future__ import annotations
import re
from numbers import Real
from .utilities import SavesToJSON, NoteProperty
from expenvelope import Envelope
from typing import Sequence


def _split_string_at_outer_spaces(s):
    """
    Splits a string only at those commas that are not inside some sort of parentheses
    e.g. "hello, [2, 5], {2: 7}" => ["hello", "[2, 5]", "{2: 7}"]
    """
    out = []
    paren_count = square_count = curly_count = 0
    last_split = i = 0
    for character in s:
        i += 1
        if character == "(":
            paren_count += 1
        elif character == "[":
            square_count += 1
        elif character == "{":
            curly_count += 1
        elif character == ")":
            paren_count -= 1
        elif character == "]":
            square_count -= 1
        elif character == "}":
            curly_count -= 1
        elif character == " " and paren_count == square_count == curly_count == 0:
            out.append(s[last_split:i-1].strip())
            last_split = i
    out.append(s[last_split:].strip())
    return out


[docs]class ParamPlaybackAdjustment(SavesToJSON): """ Represents a multiply/add playback adjustment to a single parameter. (The multiply happens first, then the add.) :param multiply: how much to multiply by :param add: how much to add :ivar multiply: how much to multiply by :ivar add: how much to add """ def __init__(self, multiply: Real | Envelope | Sequence = 1, add: Real | Envelope | Sequence = 0): self.multiply = Envelope.from_list(multiply) if isinstance(multiply, Sequence) else multiply self.add_amount = Envelope.from_list(add) if isinstance(add, Sequence) else add
[docs] @classmethod def from_string(cls, string: str) -> ParamPlaybackAdjustment: """ Construct a ParamPlaybackAdjustment from an appropriately formatted string. :param string: written using either "*", "+"/"-", both "*" and "+"/"-" or equals followed by numbers. For example, "* 0.5" multiplies by 0.5, "= 7" sets equal to 87, "* 1.1 - 3" multiplies by 1.1 and then subtracts 3. Note that "/" for division is not understood (instead multiply by the inverse), and that where a multiplication and an addition/subtraction are used together, the multiplication must come first. :return: a ParamPlaybackAdjustment """ if "envelope" in string.lower(): raise ValueError("Cannot directly use envelope object in string representation of parameter adjustment; " "use list shorthand instead, or create a ParamPlaybackAdjustment or NotePlaybackAdjustment" "directly.") times_index = string.index("*") if "*" in string else None plus_index = string.index("+") if "+" in string else None minus_index = string.index("-") if "-" in string else None equals_index = string.index("=") if "=" in string else None try: if equals_index is not None: assert times_index is None and plus_index is None and minus_index is None return cls.set_to(eval(string[equals_index + 1:])) else: assert not (plus_index is not None and minus_index is not None) plus_minus_index = plus_index if plus_index is not None else minus_index multiply_string = add_string = None if times_index is not None: if plus_minus_index is not None: assert plus_minus_index > times_index multiply_string = string[times_index + 1:plus_minus_index] add_string = string[plus_minus_index:] if minus_index is not None \ else string[plus_minus_index + 1:] else: multiply_string = string[times_index + 1:] else: add_string = string[plus_minus_index:] if minus_index is not None \ else string[plus_minus_index + 1:] add = eval(re.sub(r'\[.*\]', lambda match: "Envelope.from_list(" + match.group(0) + ")", add_string)) if add_string is not None else 0 multiply = eval(re.sub(r'\[.*\]', lambda match: "Envelope.from_list(" + match.group(0) + ")", multiply_string)) if multiply_string is not None else 1 return cls(add=add, multiply=multiply) except (AssertionError, SyntaxError): raise ValueError("Bad parameter adjustment expression.")
[docs] @classmethod def set_to(cls, value) -> ParamPlaybackAdjustment: """ Class method for an adjustment that resets the value of the parameter, ignoring its original value :param value: the value to set the parameter to """ return cls(0, value)
[docs] @classmethod def scale(cls, value) -> ParamPlaybackAdjustment: """ Class method for a simple scaling adjustment. :param value: the factor to scale by """ return cls(value)
[docs] @classmethod def add(cls, value) -> ParamPlaybackAdjustment: """ Class method for a simple additive adjustment. :param value: how much to add """ return cls(1, value)
[docs] def adjust_value(self, param_value, normalize_envelope_to_length=None): """ Apply this adjustment to a given parameter value :param param_value: the parameter value to adjust :param normalize_envelope_to_length: if given, and the add or multiply is an Envelope object, normalize the length of that envelope object to this. :return: the adjusted value of the parameter """ # in case the param value is a Envelope, it's best to leave out the multiply if it's zero # for instance, if param_value is a Envelope, multiply is zero and add is a Envelope, # you would end up trying to add two Envelopes, which we don't allow add_amount = self.add_amount.normalize_to_duration(normalize_envelope_to_length, in_place=False) \ if isinstance(self.add_amount, Envelope) and normalize_envelope_to_length is not None else self.add_amount mul_amount = self.multiply.normalize_to_duration(normalize_envelope_to_length, in_place=False) \ if isinstance(self.multiply, Envelope) and normalize_envelope_to_length is not None else self.multiply return add_amount if mul_amount == 0 else param_value + add_amount if mul_amount == 1 \ else param_value * mul_amount + add_amount
[docs] def uses_envelope(self): return isinstance(self.add_amount, Envelope) or isinstance(self.multiply, Envelope)
def _to_dict(self): return {"multiply": self.multiply, "add": self.add_amount} @classmethod def _from_dict(cls, json_dict): return cls(**json_dict) def __eq__(self, other): if isinstance(other, ParamPlaybackAdjustment): return hash(self) == hash(other) return False def __hash__(self): return hash((self.multiply, self.add_amount)) def __repr__(self): return "ParamPlaybackAdjustment({}, {})".format(self.multiply, self.add_amount)
[docs]class NotePlaybackAdjustment(SavesToJSON, NoteProperty): """ Represents an adjustment to the pitch, volume and/or length of the playback of a single note :param pitch_adjustment: The desired adjustment for the note's pitch. (None indicates no adjustment) :param volume_adjustment: The desired adjustment for the note's volume. (None indicates no adjustment) :param length_adjustment: The desired adjustment for the note's length. (None indicates no adjustment) :ivar pitch_adjustment: The desired adjustment for the note's pitch. (None indicates no adjustment) :ivar volume_adjustment: The desired adjustment for the note's volume. (None indicates no adjustment) :ivar length_adjustment: The desired adjustment for the note's length. (None indicates no adjustment) """ def __init__(self, pitch_adjustment: ParamPlaybackAdjustment = None, volume_adjustment: ParamPlaybackAdjustment = None, length_adjustment: ParamPlaybackAdjustment = None, scale_envelopes_to_length=False): self.pitch_adjustment: ParamPlaybackAdjustment = pitch_adjustment self.volume_adjustment: ParamPlaybackAdjustment = volume_adjustment self.length_adjustment: ParamPlaybackAdjustment = length_adjustment if self.length_adjustment is not None and self.length_adjustment.uses_envelope(): raise ValueError("Length adjustment cannot use an Envelope; what would that even mean?") self.scale_envelopes_to_length = scale_envelopes_to_length
[docs] @classmethod def from_string(cls, string: str) -> NotePlaybackAdjustment: """ Construct a NotePlaybackAdjustment from a string using a particular grammar. :param string: should take the form of, e.g. "volume * 0.5 pitch = 69 length * 2 - 1". This would cause the volume to be halved, the pitch to be set to 69, and the length to be doubled plus 1. Note that "/" for division is not understood and that where a multiplication and an addition/subtraction are used together, the multiplication must come first. The parameters can be separated by commas or semicolons optionally, for visual clarity. :return: a shiny new NotePlaybackAdjustment """ if "pitch" not in string and "volume" not in string and "length" not in string: raise ValueError("String not parsable as NotePlaybackAdjustment") string = string.replace(" ", "").replace(";", "") pitch_adjustment = volume_adjustment = length_adjustment = None for adjustment_expression in NotePlaybackAdjustment._split_at_param_names(string): if adjustment_expression.startswith("pitch"): if pitch_adjustment is not None: raise ValueError("Multiple conflicting pitch adjustments given.") pitch_adjustment = ParamPlaybackAdjustment.from_string(adjustment_expression) elif adjustment_expression.startswith("volume"): if volume_adjustment is not None: raise ValueError("Multiple conflicting volume adjustments given.") volume_adjustment = ParamPlaybackAdjustment.from_string(adjustment_expression) elif adjustment_expression.startswith("length"): if length_adjustment is not None: raise ValueError("Multiple conflicting length adjustments given.") length_adjustment = ParamPlaybackAdjustment.from_string(adjustment_expression) return cls(pitch_adjustment=pitch_adjustment, volume_adjustment=volume_adjustment, length_adjustment=length_adjustment, scale_envelopes_to_length=True)
@staticmethod def _split_at_param_names(string: str): if "pitch" in string and not string.startswith("pitch"): i = string.index("pitch") return NotePlaybackAdjustment._split_at_param_names(string[:i]) + \ NotePlaybackAdjustment._split_at_param_names(string[i:]) elif "volume" in string and not string.startswith("volume"): i = string.index("volume") return NotePlaybackAdjustment._split_at_param_names(string[:i]) + \ NotePlaybackAdjustment._split_at_param_names(string[i:]) elif "length" in string and not string.startswith("length"): i = string.index("length") return NotePlaybackAdjustment._split_at_param_names(string[:i]) + \ NotePlaybackAdjustment._split_at_param_names(string[i:]) return string,
[docs] @classmethod def scale_params(cls, pitch=1, volume=1, length=1) -> NotePlaybackAdjustment: """ Constructs a NotePlaybackAdjustment that scales the parameters :param pitch: pitch scale factor :param volume: volume scale factor :param length: length scale factor :return: a shiny new NotePlaybackAdjustment """ return cls(ParamPlaybackAdjustment.scale(pitch) if pitch != 1 else None, ParamPlaybackAdjustment.scale(volume) if volume != 1 else None, ParamPlaybackAdjustment.scale(length) if length != 1 else None)
[docs] @classmethod def add_to_params(cls, pitch=None, volume=None, length=None) -> NotePlaybackAdjustment: """ Constructs a NotePlaybackAdjustment that adds to the parameters :param pitch: pitch addition :param volume: volume addition :param length: length addition :return: a shiny new NotePlaybackAdjustment """ return cls(ParamPlaybackAdjustment.add(pitch) if pitch is not None else None, ParamPlaybackAdjustment.add(volume) if volume is not None else None, ParamPlaybackAdjustment.add(length) if length is not None else None)
[docs] @classmethod def set_params(cls, pitch=None, volume=None, length=None) -> NotePlaybackAdjustment: """ Constructs a NotePlaybackAdjustment that directly resets the parameters :param pitch: new pitch setting :param volume: new volume setting :param length: new length setting :return: a shiny new NotePlaybackAdjustment """ return cls(ParamPlaybackAdjustment.set_to(pitch) if pitch is not None else None, ParamPlaybackAdjustment.set_to(volume) if volume is not None else None, ParamPlaybackAdjustment.set_to(length) if length is not None else None)
[docs] def adjust_parameters(self, pitch, volume, length): """ Carry out the adjustments represented by this object on the pitch, volume and length given. :param pitch: pitch to adjust :param volume: volume to adjust :param length: length to adjust :return: tuple of (adjusted_pitch, adjusted_volume, adjusted_length) """ adjusted_length = self.length_adjustment.adjust_value(length) if self.length_adjustment is not None else length return self.pitch_adjustment.adjust_value(pitch, adjusted_length if self.scale_envelopes_to_length else None) \ if self.pitch_adjustment is not None else pitch, \ self.volume_adjustment.adjust_value(volume, adjusted_length if self.scale_envelopes_to_length else None) \ if self.volume_adjustment is not None else volume, \ adjusted_length
def _to_dict(self): json_dict = {} if self.pitch_adjustment is not None: json_dict["pitch_adjustment"] = self.pitch_adjustment if self.volume_adjustment is not None: json_dict["volume_adjustment"] = self.volume_adjustment if self.length_adjustment is not None: json_dict["length_adjustment"] = self.length_adjustment if self.scale_envelopes_to_length: json_dict["scale_envelopes_to_length"] = self.scale_envelopes_to_length return json_dict @classmethod def _from_dict(cls, json_dict): return cls(**json_dict) def __eq__(self, other): if isinstance(other, NotePlaybackAdjustment): return hash(self) == hash(other) return False def __hash__(self): return hash((self.pitch_adjustment, self.volume_adjustment, self.length_adjustment, self.scale_envelopes_to_length)) def __repr__(self): return "NotePlaybackAdjustment({}, {}, {})".format( self.pitch_adjustment, self.volume_adjustment, self.length_adjustment )