# 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/>. #
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ #
import re
from numbers import Real
from .utilities import SavesToJSON, NoteProperty
from ._engraving_translations import articulation_to_xml_element_name, notehead_name_to_xml_type, \
notations_to_xml_notations_element
from expenvelope import Envelope
from typing import Union, Sequence
from collections import UserDict
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: Union[Real, Envelope, Sequence] = 1, add: Union[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:
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 not isinstance(other, ParamPlaybackAdjustment):
return False
return self.multiply == other.multiply and self.add_amount == other.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
"""
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 not isinstance(other, NotePlaybackAdjustment):
return False
return self._to_dict() == other._to_dict()
def __repr__(self):
return "NotePlaybackAdjustment({}, {}, {})".format(
self.pitch_adjustment, self.volume_adjustment, self.length_adjustment
)
[docs]class PlaybackAdjustmentsDictionary(UserDict, SavesToJSON):
"""
Dictionary containing playback adjustments for different articulations, noteheads, and other notations.
The instance of this at playback_settings.adjustments is consulted during playback. Essentially, this is
just a dictionary with some validation and a couple of convenience methods to set and get adjustments for
different properties.
:param articulations: dictionary mapping articulation names to playback adjustments. For example, to have
staccato notes be played at half length: `{"staccato": NotePlaybackAdjustment.scale_params(length=0.5)}`
:param noteheads: dictionary mapping notehead names to playback adjustments. For example, to have harmonic
noteheads be played up an octave: `{"harmonic": NotePlaybackAdjustment.add_to_params(pitch=12)}`
:param notations: dictionary mapping notation names to playback adjustments.
"""
#: list of all recognized articulations
all_articulations = list(articulation_to_xml_element_name.keys())
#: list of all recognized noteheads
all_noteheads = list(notehead_name_to_xml_type.keys())
all_noteheads.extend(["filled " + notehead_name for notehead_name in all_noteheads])
all_noteheads.extend(["open " + notehead_name for notehead_name in all_noteheads
if not notehead_name.startswith("filled")])
#: list of all recognized notations
all_notations = list(notations_to_xml_notations_element.keys())
def __init__(self, articulations: dict = None, noteheads: dict = None, notations: dict = None):
# make sure there is an entry for every notehead, articulation, and notation
if articulations is None:
articulations = {x: None for x in PlaybackAdjustmentsDictionary.all_articulations}
else:
articulations = {x: articulations[x] if x in articulations else None
for x in PlaybackAdjustmentsDictionary.all_articulations}
if noteheads is None:
noteheads = {x: None for x in PlaybackAdjustmentsDictionary.all_noteheads}
else:
noteheads = {x: noteheads[x] if x in noteheads else None
for x in PlaybackAdjustmentsDictionary.all_noteheads}
if notations is None:
notations = {x: None for x in PlaybackAdjustmentsDictionary.all_notations}
else:
notations = {x: notations[x] if x in notations else None
for x in PlaybackAdjustmentsDictionary.all_notations}
super().__init__(articulations=articulations, noteheads=noteheads, notations=notations)
@property
def articulations(self) -> dict:
"""
Dictionary mapping articulation names to corresponding playback adjustments
"""
return self["articulations"]
@property
def noteheads(self) -> dict:
"""
Dictionary mapping notehead names to corresponding playback adjustments
"""
return self["noteheads"]
@property
def notations(self) -> dict:
"""
Dictionary mapping notation names to corresponding playback adjustments
"""
return self["notations"]
[docs] def set(self, notation_detail: str, adjustment: Union[str, NotePlaybackAdjustment]) -> None:
"""
Set the given notation detail to have the given :class:`NotePlaybackAdjustment`.
Based on the name of the notation detail, it is automatically determined whether or not we are talking about
an articulation, a notehead, or another kind of notation.
:param notation_detail: name of the notation detail, e.g. "staccato" or "harmonic"
:param adjustment: the adjustment to make for that notation. Either a :class:`NotePlaybackAdjustment` or a
string to be parsed to a :class:`NotePlaybackAdjustment` using :code:`NotePlaybackAdjustment.from_string`
"""
if isinstance(adjustment, str):
adjustment = NotePlaybackAdjustment.from_string(adjustment)
if "notehead" in notation_detail:
notation_detail = notation_detail.replace("notehead", "").replace(" ", "").lower()
if notation_detail in PlaybackAdjustmentsDictionary.all_noteheads:
self["noteheads"][notation_detail] = adjustment
elif notation_detail in PlaybackAdjustmentsDictionary.all_articulations:
self["articulations"][notation_detail] = adjustment
elif notation_detail in PlaybackAdjustmentsDictionary.all_notations:
self["notations"][notation_detail] = adjustment
else:
raise ValueError("Playback property not understood.")
[docs] def get(self, notation_detail: str) -> NotePlaybackAdjustment:
"""
Get the :class:`NotePlaybackAdjustment` for the given notation detail.
Based on the name of the notation detail, it is automatically determined whether or not we are talking about
an articulation, a notehead, or another kind of notation.
:param notation_detail: name of the notation detail, e.g. "staccato" or "harmonic"
:return: the :class:`NotePlaybackAdjustment` for that detail
"""
if "notehead" in notation_detail:
notation_detail = notation_detail.replace("notehead", "").replace(" ", "").lower()
if notation_detail in PlaybackAdjustmentsDictionary.all_noteheads:
return self["noteheads"][notation_detail]
elif notation_detail in PlaybackAdjustmentsDictionary.all_articulations:
return self["articulations"][notation_detail]
elif notation_detail in PlaybackAdjustmentsDictionary.all_notations:
return self["notations"][notation_detail]
else:
raise ValueError("Playback property not found.")
def _to_dict(self):
return {
key: value._to_dict() if hasattr(value, "_to_dict")
else PlaybackAdjustmentsDictionary._to_dict(value) if isinstance(value, dict)
else value for key, value in self.items() if value is not None
}
@classmethod
def _from_dict(cls, json_dict):
# convert all adjustments from dictionaries to NotePlaybackAdjustments
for notation_category in json_dict:
for notation_name in json_dict[notation_category]:
if json_dict[notation_category][notation_name] is not None:
json_dict[notation_category][notation_name] = \
NotePlaybackAdjustment._from_dict(json_dict[notation_category][notation_name])
return cls(**json_dict)
def __repr__(self):
return "PlaybackAdjustmentsDictionary(articulations={}, noteheads={}, notations={})".format(
self["articulations"], self["noteheads"], self["notations"]
)
```