"""
Module containing user-facing playback classes: :class:`Ensemble`, :class:`ScampInstrument`, and :class:`NoteHandle`/
:class:`ChordHandle`
The underlying implementation of playback is done by :class:`~scamp.playback_implementations.PlaybackImplementation` and
all of its subclasses, which are found in playback_implementations.py.
"""
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ #
# 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 itertools
from ._soundfont_host import get_best_preset_match_for_name, print_soundfont_presets
from ._midi import get_available_midi_output_devices, print_available_midi_output_devices
from .utilities import SavesToJSON, NoteProperty
from .spelling import SpellingPolicy
from .note_properties import NoteProperties
from .playback_implementations import PlaybackImplementation, SoundfontPlaybackImplementation, \
MIDIStreamPlaybackImplementation, OSCPlaybackImplementation
from .settings import engraving_settings, playback_settings
from clockblocks.utilities import wait
from clockblocks.clock import current_clock, Clock, ClockKilledError, DeadClockError, TimeStamp
from expenvelope import EnvelopeSegment
import logging
import time
from threading import Lock
from typing import Sequence, TypeAlias
from numbers import Real
from expenvelope import Envelope
from copy import deepcopy
import atexit
[docs]class Ensemble(SavesToJSON):
"""
Host for multiple :class:`ScampInstrument` objects, keeping shared resources, and shared default settings.
A :class:`~scamp.session.Session` is, among other things, an Ensemble.
:param default_audio_driver: value to initialize default_audio_driver instance variable to
:param default_soundfont: value to initialize default_soundfont instance variable to
:param default_spelling_policy: a :class:`~scamp.spelling.SpellingPolicy` (or a string or tuple interpretable as
such) to use for all instruments in this ensemble, overriding scamp defaults.
:param instruments: list of instruments to populate this ensemble with. NOTE: generally it is not a good idea
to initialize an Ensemble with this argument, but better to use the new_part methods after the fact. This is
because instrument playback implementations look to share ensemble resources when they are created, and this
is not possible if they are not already part of an ensemble.
:ivar default_audio_driver: the audio driver instruments in this ensemble will default to. If "default", then
this defers to the scamp global playback_settings default.
:ivar default_soundfont: the soundfont that instruments in this ensemble will default to. If "default", then
this defers to the scamp global playback_settings default.
:ivar instruments: List of all of the ScampInstruments within the Ensemble.
"""
def __init__(self, default_soundfont: str = "default", default_audio_driver: str = "default",
default_spelling_policy: SpellingPolicy | str | tuple = None,
instruments: Sequence[ScampInstrument] = None):
self.default_soundfont = default_soundfont
self.default_audio_driver = default_audio_driver
self._default_spelling_policy = SpellingPolicy.interpret(default_spelling_policy) \
if default_spelling_policy is not None else None
self._instruments = list(instruments) if instruments is not None else []
for instrument in self._instruments:
instrument.set_ensemble(self)
@property
def instruments(self):
"""
Returns a tuple of the instruments currently in this Ensemble.
"""
return tuple(self._instruments)
[docs] def add_instrument(self, instrument: ScampInstrument) -> ScampInstrument:
"""
Adds an instance of :class:`ScampInstrument` to this Ensemble. Generally, creating of and instrument and adding
it to an ensemble are done simultaneously via one of the "new_instrument" methods.
:param instrument: instrument to add to this ensemble
:return: self
"""
if not hasattr(instrument, "name") or instrument.name is None:
instrument.name = "Track " + str(len(self._instruments) + 1)
self._instruments.append(instrument)
instrument.set_ensemble(self)
return instrument
[docs] def pop_instrument(self, index):
"""
Pops the instrument at the given index, severing its ties to the ensemble.
:param index: which instrument
"""
inst = self._instruments.pop(index)
inst.ensemble = None
inst.name_count = 0
return inst
[docs] def new_silent_part(self, name: str = None, default_spelling_policy: SpellingPolicy = None,
clef_preference="from_name") -> ScampInstrument:
"""
Creates and returns a new ScampInstrument for this Ensemble with no PlaybackImplementations.
:param name: name of the new part
:param default_spelling_policy: the :attr:`~ScampInstrument.default_spelling_policy` for the new part
:param clef_preference: the :attr:`~ScampInstrument.clef_preference` for the new part
:return: the newly created ScampInstrument
"""
return self.add_instrument(ScampInstrument(name, self, default_spelling_policy=default_spelling_policy,
clef_preference=clef_preference))
@staticmethod
def _resolve_preset_from_name(name, soundfont):
# if preset is auto, try to find a match in the soundfont
if name is None:
preset = (0, 0)
else:
preset_match, match_score = get_best_preset_match_for_name(name, which_soundfont=soundfont)
if match_score > 1.0:
preset = preset_match.bank, preset_match.preset
print("Using preset {} for {}".format(preset_match.name, name))
else:
logging.warning("Could not find preset matching {}. "
"Falling back to preset 0 (probably piano).".format(name))
preset = (0, 0)
return preset
[docs] def new_part(self, name: str = None, preset="auto", soundfont: str = "default", num_channels: int = 8,
audio_driver: str = "default", max_pitch_bend: int = "default",
note_on_and_off_only: bool = False, default_spelling_policy: SpellingPolicy = None,
clef_preference="from_name") -> ScampInstrument:
"""
Creates and returns a new ScampInstrument for this Ensemble that uses a SoundfontPlaybackImplementation. Unless
otherwise specified, the default soundfont for this Ensemble/Session will be used, and we will search for the
preset that best matches the name given.
:param name: name used for this instrument in score, etc.
:param preset: if an int, assumes bank #0; can also be a tuple of form (bank, preset). If "auto", searches
for a preset of the appropriate name.
:param soundfont: the name of the soundfont to use for fluidsynth playback
:param num_channels: maximum of midi channels available to this midi part. It's wise to use more when doing
microtonal playback, since pitch bends are applied per channel.
:param audio_driver: which audio driver to use for this instrument (defaults to ensemble default)
:param max_pitch_bend: max pitch bend to use for this instrument
:param note_on_and_off_only: This enforces a rule of no dynamic pitch bends, expression (volume) changes, or
other cc messages. Valuable when using :code:`start_note` instead of :code:`play_note` in music that
doesn't do any dynamic pitch/volume/parameter changes. Without this flag, notes will all be placed on
separate MIDI channels, since they could potentially change pitch or volume; with this flags, we know they
won't, so they can share the same MIDI channels, only using an extra one due to microtonality.
:param default_spelling_policy: the :attr:`~ScampInstrument.default_spelling_policy` for the new part
:param clef_preference: the :attr:`~ScampInstrument.clef_preference` for the new part
:return: the newly created ScampInstrument
"""
# Resolve soundfont and audio driver to ensemble defaults if necessary (these may well be the string
# "default", in which case it gets resolved to the playback_settings default)
soundfont = self.default_soundfont if soundfont == "default" else soundfont
audio_driver = self.default_audio_driver if audio_driver == "default" else audio_driver
# if preset is auto, try to find a match in the soundfont
if preset == "auto":
preset = Ensemble._resolve_preset_from_name(name, soundfont)
elif isinstance(preset, int):
preset = (0, preset)
name = "Track " + str(len(self._instruments) + 1) if name is None else name
instrument = self.new_silent_part(name, default_spelling_policy=default_spelling_policy,
clef_preference=clef_preference)
instrument.add_soundfont_playback(preset=preset, soundfont=soundfont, num_channels=num_channels,
audio_driver=audio_driver, max_pitch_bend=max_pitch_bend,
note_on_and_off_only=note_on_and_off_only)
return instrument
[docs] def new_midi_part(self, name: str = None, midi_output_device: int | str = None,
num_channels: int = 8, midi_output_name: str = None, max_pitch_bend: int = "default",
note_on_and_off_only: bool = False, default_spelling_policy: SpellingPolicy = None,
clef_preference="from_name", start_channel: int = 0) -> ScampInstrument:
"""
Creates and returns a new ScampInstrument for this Ensemble that uses a MIDIStreamPlaybackImplementation.
This means that when notes are played by this instrument, midi messages are sent out to the given device.
:param name: name used for this instrument in score, etc. for a preset of the appropriate name.
:param midi_output_device: name or number of the device used to output midi. Call
get_available_midi_output_devices to check what's available.
:param num_channels: maximum of midi channels available to this midi part. It's wise to use more when doing
microtonal playback, since pitch bends are applied per channel.
:param midi_output_name: name of this part
:param max_pitch_bend: max pitch bend to use for this instrument
:param note_on_and_off_only: This enforces a rule of no dynamic pitch bends, expression (volume) changes, or
other cc messages. Valuable when using :code:`start_note` instead of :code:`play_note` in music that
doesn't do any dynamic pitch/volume/parameter changes. Without this flag, notes will all be placed on
separate MIDI channels, since they could potentially change pitch or volume; with this flags, we know they
won't, so they can share the same MIDI channels, only using an extra one due to microtonality.
:param default_spelling_policy: the :attr:`~ScampInstrument.default_spelling_policy` for the new part
:param clef_preference: the :attr:`~ScampInstrument.clef_preference` for the new part
:param start_channel: the first channel to use. For instance, if start_channel is 4, and num_channels is 5,
we will use channels (4, 5, 6, 7, 8). NOTE: channel counting in SCAMP starts from 0, so this may show
up as channels 5-9 in your MIDI software.
:return: the newly created ScampInstrument
"""
name = "Track " + str(len(self._instruments) + 1) if name is None else name
midi_output_device = name if midi_output_device is None else midi_output_device
instrument = self.new_silent_part(name, default_spelling_policy=default_spelling_policy,
clef_preference=clef_preference)
instrument.add_streaming_midi_playback(midi_output_device=midi_output_device, num_channels=num_channels,
midi_output_name=midi_output_name, max_pitch_bend=max_pitch_bend,
note_on_and_off_only=note_on_and_off_only, start_channel=start_channel)
return instrument
[docs] def new_osc_part(self, name: str = None, port: int = None, ip_address: str = "127.0.0.1",
message_prefix: str = None, osc_message_addresses: dict = "default",
default_spelling_policy: SpellingPolicy = None, clef_preference="from_name") -> ScampInstrument:
"""
Creates and returns a new ScampInstrument for this Ensemble that uses a OSCPlaybackImplementation. This means
that when notes are played by this instrument, osc messages are sent out to the specified address
:param name: name used for this instrument in score, etc. for a preset of the appropriate name.
:param port: port osc messages are sent to
:param ip_address: ip_address osc messages are sent to
:param message_prefix: prefix used for this instrument in osc messages
:param osc_message_addresses: dictionary defining the address used for each type of playback message. defaults
to using "start_note", "end_note", "change_pitch", "change_volume", "change_parameter". The default can
be changed in playback settings.
:param default_spelling_policy: the :attr:`~ScampInstrument.default_spelling_policy` for the new part
:param clef_preference: the :attr:`~ScampInstrument.clef_preference` for the new part
:return: the newly created ScampInstrument
"""
name = "Track " + str(len(self._instruments) + 1) if name is None else name
instrument = self.new_silent_part(name, default_spelling_policy=default_spelling_policy,
clef_preference=clef_preference)
instrument.add_osc_playback(port=port, ip_address=ip_address, message_prefix=message_prefix,
osc_message_addresses=osc_message_addresses)
return instrument
def _get_part_name_count(self, name):
return sum(i.name == name for i in self._instruments)
[docs] def get_instrument_by_name(self, name: str, which: int = 0):
"""
Returns the instrument of the given name.
:param name: name of the instrument to return
:param which: If there are multiple with the same name, this parameter specifies the one returned. (If none
match the number given by which, the first name match is returned)
"""
# if there are multiple instruments of the same name, which determines which one is chosen
imperfect_match = None
for instrument in self._instruments:
if name == instrument.name:
if which == instrument.name_count:
return instrument
else:
imperfect_match = instrument if imperfect_match is None else imperfect_match
return imperfect_match
[docs] def print_default_soundfont_presets(self) -> None:
"""
Prints a list of presets available with the default soundfont.
"""
print_soundfont_presets(self.default_soundfont)
[docs] @staticmethod
def get_available_midi_output_devices() -> enumerate:
"""
Returns an enumeration of available ports and devices for midi output.
"""
return get_available_midi_output_devices()
[docs] @staticmethod
def print_available_midi_output_devices() -> None:
"""
Prints a list of available ports and devices for midi output.
"""
print_available_midi_output_devices()
@property
def default_spelling_policy(self) -> SpellingPolicy:
"""
Default spelling policy used for transcriptions made with this Ensemble.
"""
return self._default_spelling_policy
@default_spelling_policy.setter
def default_spelling_policy(self, value: SpellingPolicy | str | tuple):
self._default_spelling_policy = SpellingPolicy.interpret(value) if value is not None else None
def _to_dict(self):
return {
"default_soundfont": self.default_soundfont,
"default_audio_driver": self.default_audio_driver,
"default_spelling_policy": self.default_spelling_policy,
"instruments": self._instruments
}
@classmethod
def _from_dict(cls, json_dict):
return cls(**json_dict)
def __str__(self):
return "Ensemble(instruments=[{}])".format(", ".join(str(i) for i in self._instruments))
def __repr__(self):
return "Ensemble({})".format(", ".join("{}={}".format(k, repr(v)) for k, v in self._to_dict().items()))
NotePropertiesCompatible: TypeAlias = str | dict | NoteProperty | NoteProperties | Sequence['NotePropertiesCompatible']
PitchCompatible: TypeAlias = float | Envelope | Sequence[float] | Sequence[Sequence[float]]
VolumeCompatible: TypeAlias = float | Envelope | Sequence[float] | Sequence[Sequence[float]]
DurationCompatible: TypeAlias = float | tuple[float, ...]
[docs]class ScampInstrument(SavesToJSON):
"""
Instrument class that does the playing of the notes. Generally this will be created through one of the
"new_part" methods of the Session or Ensemble class.
:param name: name of this instrument (e.g. when printed in a score)
:param ensemble: Ensemble to which this instrument will belong.
:param default_spelling_policy: sets :attr:`ScampInstrument.default_spelling_policy`
:param clef_preference: sets :attr:`ScampInstrument.clef_preference`
:param playback_implementations: PlaybackImplementation(s) used to actually playback notes
:ivar name: name of this instrument (e.g. when printed in a score)
:ivar name_count: when there are multiple instruments of the same name within an Ensemble, this variable assigns
each a unique index (starting with 0), to distinguish them
:ivar ensemble: Ensemble to which this instrument will belong.
:ivar playback_implementations: list of PlaybackImplementation(s) used to actually playback notes
"""
_note_id_generator = itertools.count()
_change_param_call_counter = itertools.count()
def __init__(self, name: str = None, ensemble: Ensemble = None, default_spelling_policy: SpellingPolicy = None,
clef_preference="from_name", playback_implementations: Sequence[PlaybackImplementation] = None):
super().__init__()
self.name = "" if name is None else name
self._clef_preference = None
self.clef_preference = clef_preference
self._transcribers_to_notify = []
self._note_info_by_id = {}
self.playback_implementations = [] if playback_implementations is None else playback_implementations
# A policy for spelling notes used as the default for this instrument. Overrides any broader defaults.
# (Has a getter and setter method allowing constructor strings to be passed.)
self._default_spelling_policy = default_spelling_policy
# this lock stops multiple threads from simultaneously accessing the self._note_info_by_id
self._note_info_lock = Lock()
#: used when exporting to json to see if this is the top level object being exported, or part of an ensemble
self._export_as_stand_alone = False
self.ensemble = None
self.name_count = 0
if ensemble is not None:
self.set_ensemble(ensemble)
atexit.register(self.end_all_notes)
[docs] def set_ensemble(self, ensemble: Ensemble) -> None:
"""
Sets the ensemble that this instrument belongs to. Generally this happens automatically.
:param ensemble: the :class:`Ensemble` that this instrument should belong to.
"""
if self.ensemble == ensemble:
# already set to this ensemble
return
self.ensemble = ensemble
# used to help distinguish between identically named instruments in the same ensemble
self.name_count = ensemble._get_part_name_count(self.name)
[docs] def play_note(self, pitch: PitchCompatible, volume: VolumeCompatible, length: DurationCompatible,
properties: NotePropertiesCompatible = None, blocking: bool = True, clock: Clock = None,
silent: bool = False, transcribe: bool = True) -> None:
"""
Play a note on this instrument, with the given pitch, volume and length.
:param pitch: either a number, an Envelope, or a list used to create an Envelope. MIDI pitch values are used,
with 60 representing middle C. However, microtones are allowed; for instance, a pitch of 64.7 produces an
F4 30 cents flat. A pitch of `None` simply translates to a rest.
:param volume: either a number, an Envelope, or a list used to create an Envelope. Volume is scaled from 0 to 1,
with 0 representing silence and 1 representing max volume.
:param length: either a number (of beats), or a tuple representing a set of tied segments
:param properties: Catch-all for a wide range of other playback and notation details that we may want to convey
about a note. See :ref:`The Note Properties Argument`
:param blocking: if True, don't return until the note is done playing; if False, return immediately
:param clock: which clock to use. If None, capture the clock from context.
:param silent: if True, note is not played back, but is still transcribed when a
:class:`~scamp.transcriber.Transcriber` is active. (Generally ignored by end user.)
:param transcribe: if False, note is not transcribed even when a :class:`~scamp.transcriber.Transcriber` is
active. (Generally ignored by end user.)
"""
clock, blocking = self._resolve_clock(clock, blocking)
# A convenience: passing "None" to the pitch just causes a wait call
if pitch is None:
if blocking:
clock.wait(sum(length) if hasattr(length, '__len__') else length)
return
if not (hasattr(length, "__len__") and all(x > 0 for x in length) or length > 0):
raise ValueError("Note length must be positive.")
properties = NoteProperties.interpret(properties)
self._resolve_spelling_policies(properties)
if hasattr(pitch, "__len__"):
pitch = Envelope.from_list(pitch)
pitch.parsed_from_list = True
if hasattr(volume, "__len__"):
volume = Envelope.from_list(volume)
volume.parsed_from_list = True
ScampInstrument._normalize_envelopes(pitch, volume, length, properties)
adjusted_pitch, adjusted_volume, adjusted_length, did_an_adjustment = \
properties.apply_playback_adjustments(pitch, volume, length)
if did_an_adjustment:
# play, but don't transcribe the modified version (though only if the clock is not fast-forwarding)
if not clock.is_fast_forwarding():
clock.fork(self._do_play_note, name="DO_PLAY_NOTE",
args=(adjusted_pitch, adjusted_volume, adjusted_length, properties),
kwargs={"transcribe": False, "silent": silent})
# transcribe, but don't play the unmodified version
if blocking:
self._do_play_note(clock, pitch, volume, length, properties, silent=True, transcribe=transcribe)
else:
clock.fork(self._do_play_note, name="DO_PLAY_NOTE",
args=(pitch, volume, length, properties), kwargs={"silent": True, "transcribe": transcribe})
else:
# No adjustments, so no need to separate transcription from playback
# (However, if the clock is fast-forwarding, make it silent)
if blocking:
self._do_play_note(clock, pitch, volume, length, properties,
silent=clock.is_fast_forwarding() or silent, transcribe=transcribe)
else:
clock.fork(self._do_play_note, name="DO_PLAY_NOTE",
args=(pitch, volume, length, properties),
kwargs={"silent": clock.is_fast_forwarding() or silent, "transcribe": transcribe})
def _resolve_spelling_policies(self, properties: NoteProperties):
"""
Resolves the spelling policies for the NoteProperties, based on instrument or ensemble defaults, if applicable
"""
# resolve the spelling policy based on defaults (local first, then more global)
if len(properties.spelling_policies) == 0:
# if the note doesn't say how to be spelled, check the instrument
if self.default_spelling_policy is not None:
properties.spelling_policies = [self.default_spelling_policy]
# if the instrument doesn't have a default spelling policy check the host (probably a Session)
elif self.ensemble is not None and self.ensemble.default_spelling_policy is not None:
properties.spelling_policies = [self.ensemble.default_spelling_policy]
# if the host doesn't have a default, then fall back to engraving_settings
else:
properties.spelling_policies = [engraving_settings.default_spelling_policy]
def _resolve_clock(self, clock, blocking):
"""
Resolves the clock argument, as well as the blocking state, given to several functions.
"""
if clock is not None:
# if the clock is given explicitly, go with that
return clock, blocking
elif current_clock() is not None:
# otherwise, try to get the clock active on the current thread
return current_clock(), blocking
elif isinstance(self.ensemble, Clock):
# If there is none, but the ensemble is a clock (meaning it's a Session probably), use that.
# Note that, in this case, we shouldn't block, because it causes issues with multiple threads
# calling wait on the same clock at the same time. This would happen when the Session is run as a server.
return self.ensemble, False
else:
return Clock(), blocking
@staticmethod
def _normalize_envelopes(pitch, volume, length, properties):
# length can either be a single number of beats or a list/tuple or segments to be split
# sum_length will represent the total number of beats in either case
sum_length = sum(length) if hasattr(length, "__len__") else length
# normalize envelopes to the duration of the note if the setting say to do so
if isinstance(pitch, Envelope) and (playback_settings.resize_parameter_envelopes == "always" or
playback_settings.resize_parameter_envelopes == "lists" and
hasattr(pitch, "parsed_from_list")):
pitch.normalize_to_duration(sum_length)
if isinstance(volume, Envelope) and (playback_settings.resize_parameter_envelopes == "always" or
playback_settings.resize_parameter_envelopes == "lists" and
hasattr(volume, "parsed_from_list")):
volume.normalize_to_duration(sum_length)
for param, value in properties.extra_playback_parameters.items():
if isinstance(value, Envelope) and (playback_settings.resize_parameter_envelopes == "always" or
playback_settings.resize_parameter_envelopes == "lists" and
hasattr(value, "parsed_from_list")):
value.normalize_to_duration(sum_length)
def _do_play_note(self, clock, pitch, volume, length, properties, silent=False, transcribe=True):
"""
This runs the actual thread that plays the note, and is scheduled when play_note is called.
If playback adjustments were made, then we schedule the altered version of _do_play_note to play back, but with
"transcribe" set to false, and we schedule an unaltered version of _do_play_note to run silently, but with
"transcribe" set to true. This way the transcription is not affected by performance adjustments.
:param clock: which clock this plays back on
:param pitch: either a number, an Envelope
:param volume: either a number, an Envelope
:param length: either a number (of beats), or a tuple representing a set of tied segments
:param properties: a NoteProperties dictionary
:param silent: if True, don't actually do any of the playback; just go through the motions for transcribing it
:param transcribe: if False, don't notify Transcribers at the end of the note
"""
# if we know ahead of time that neither pitch nor volume changes, we can pass
fixed = not isinstance(pitch, Envelope) and not isinstance(volume, Envelope) and \
not any(isinstance(param_val, Envelope) for param_val in properties.extra_playback_parameters.values())
# start the note. (Note that this will also start the animation of pitch, volume,
# and any other parameters if they are envelopes.)
note_flags = []
if fixed:
note_flags.append("fixed")
if silent:
note_flags.append("silent")
if not transcribe:
note_flags.append("no_transcribe")
note_handle = self.start_note(
pitch, volume, properties, clock=clock, flags=note_flags,
max_volume=volume.max_level() if isinstance(volume, Envelope) else volume
)
try:
if hasattr(length, "__len__"):
for length_segment in length:
clock.wait(length_segment)
note_handle.split()
else:
clock.wait(length)
note_handle.end()
except (ClockKilledError, DeadClockError) as e:
note_handle.end()
raise e
[docs] def play_chord(self, pitches: Sequence[PitchCompatible], volume: VolumeCompatible, length: DurationCompatible,
properties: NotePropertiesCompatible = None, blocking: bool = True, clock: Clock = None,
silent: bool = False, transcribe: bool = True) -> None:
"""
Play a chord with the given pitches, volume, and length. Essentially, this is a convenience method that
bundles together several calls to "play_note" and takes a list of pitches rather than a single pitch
:param pitches: a list of pitches for the notes of this chord
:param volume: see :func:`play_note`
:param length: see :func:`play_note`
:param properties: see :ref:`The Note Properties Argument`
:param blocking: see description for "play_note"
:param clock: see description for "play_note"
:param silent: see description for "play_note"
:param transcribe: see description for "play_note"
"""
# A convenience: passing "None" to the pitch just causes a wait call
if pitches is None:
if blocking:
clock.wait(sum(length) if hasattr(length, '__len__') else length)
return
if not hasattr(pitches, "__len__"):
raise ValueError("'pitches' must be a list of pitches.")
properties = NoteProperties.interpret(properties)
self._resolve_spelling_policies(properties)
for i, pitch in enumerate(pitches):
# play the pitches individually, duplicating the properties dictionary for each.
# (note that individual noteheads and spellings need to be separated out)
properties_copy = properties.duplicate()
if len(properties.noteheads) > 1:
# if we've been given multiple noteheads, assign them note by note
# (if given too few, just repeat the last notehead)
properties_copy.noteheads = [properties_copy.noteheads[i]
if i < len(properties_copy.noteheads)
else properties_copy.noteheads[-1]]
if len(properties.spelling_policies) > 1:
# same with spelling policies
properties_copy.spelling_policies = [properties_copy.spelling_policies[i]
if i < len(properties_copy.spelling_policies)
else properties_copy.spelling_policies[-1]]
self.play_note(pitch, volume, length, properties=properties_copy,
blocking=(i == len(pitches) - 1) if blocking else False,
clock=clock, silent=silent, transcribe=transcribe)
[docs] def start_note(self, pitch: PitchCompatible, volume: VolumeCompatible, properties: NotePropertiesCompatible = None,
clock: Clock = None, max_volume: float = 1, flags: Sequence[str] = None) -> NoteHandle:
"""
Start a note with the given pitch, volume, and properties
:param pitch: the pitch / starting pitch of the note
:param volume: the volume / starting volume of the note
:param properties: see :ref:`The Note Properties Argument`
:param clock: the clock on which to run any animation of pitch, volume, etc. If None, captures the clock from
context.
:param max_volume: This is a bit of a pain, but since midi playback requires us to set the velocity at the
beginning of the note, and thereafter vary volume using expression, and since expression can only make the
note quieter, we need to start the note with velocity equal to the max desired volume (using expression to
adjust it down to the actual start volume). The default will be 1, meaning as loud as possible, since unless
we know in advance what the note is going to do, we need to be prepared to go up to full volume. Using
play_note, we do actually know in advance how loud the note is going to get, so we can set max volume to the
peak of the Envelope. Honestly, I wish I could separate this implementation detail from the ScampInstrument
class, but I don't see how this would be possible.
:param flags: list of strings that act as flags for how the note should be processed. Should probably be
ignored by a normal user.
:return: a NoteHandle with which to later manipulate the note
"""
clock, _ = self._resolve_clock(clock, None)
# standardize properties if necessary, turn pitch and volume into lists if necessary
properties = NoteProperties.interpret(properties)
self._resolve_spelling_policies(properties)
pitch = Envelope.from_list(pitch) if hasattr(pitch, "__len__") else pitch
volume = Envelope.from_list(volume) if hasattr(volume, "__len__") else volume
# get the starting values for all the parameters to pass to the playback implementations
start_pitch = pitch.start_level() if isinstance(pitch, Envelope) else pitch
start_volume = volume.start_level() if isinstance(volume, Envelope) else volume
other_param_start_values = properties.get_extra_parameter_start_values()
with self._note_info_lock:
# generate a new id for this note, and set up all of its info
note_id = next(ScampInstrument._note_id_generator)
self._note_info_by_id[note_id] = {
"clock": clock,
"start_time_stamp": TimeStamp(clock),
"end_time_stamp": None,
"split_points": [],
"parameter_start_values": dict(other_param_start_values, pitch=start_pitch, volume=start_volume),
"parameter_values": dict(other_param_start_values, pitch=start_pitch, volume=start_volume),
"parameter_change_segments": {},
"segments_list_lock": Lock(),
"note_info_lock": self._note_info_lock,
"properties": properties,
"max_volume": max_volume,
"flags": [] if flags is None else flags
}
if clock.is_fast_forwarding() and "silent" not in self._note_info_by_id[note_id]["flags"]:
self._note_info_by_id[note_id]["flags"].append("silent")
if "silent" not in self._note_info_by_id[note_id]["flags"]:
# otherwise, call all the playback implementation!
for playback_implementation in self.playback_implementations:
playback_implementation.start_note(
note_id, start_pitch, start_volume,
properties, self._note_info_by_id[note_id]
)
# we now exit the lock, since otherwise the following calls will not be able to happen
# create a handle for this note
handle = NoteHandle(note_id, self)
# start all the note animation for pitch, volume, and any extra parameters
# note that, if the note is silent, then start_note has added the silent flag to the note_info dict
# this will cause unsynchronized animation threads not to fire
if isinstance(pitch, Envelope):
handle.change_pitch(pitch.levels[1:], pitch.durations, pitch.curve_shapes, clock)
if isinstance(volume, Envelope):
handle.change_volume(volume.levels[1:], volume.durations, volume.curve_shapes, clock)
for param, value in properties.extra_playback_parameters.items():
if isinstance(value, Envelope):
handle.change_parameter(param, value.levels[1:], value.durations, value.curve_shapes, clock)
return handle
[docs] def start_chord(self, pitches: Sequence[PitchCompatible], volume: VolumeCompatible,
properties: NotePropertiesCompatible = None, clock: Clock = None, max_volume: float = 1,
flags: Sequence[str] = None) -> ChordHandle:
"""
Simple utility for starting chords without starting each note individually.
:param pitches: a list of pitches
:param volume: see :func:`start_note`
:param properties: see :ref:`The Note Properties Argument`
:param clock: see start_note
:param max_volume: see start_note
:param flags: see start_note
:return: a ChordHandle, which is used to manipulate the chord thereafter. Pitch change calls on the ChordHandle
are based on the first note of the chord; all other notes are shifted in parallel
"""
assert hasattr(pitches, "__len__")
properties = NoteProperties.interpret(properties)
self._resolve_spelling_policies(properties)
# we should either be given a number of noteheads equal to the number of pitches or just one notehead for all
assert len(properties.noteheads) == len(pitches) or len(properties.noteheads) == 1, \
"Wrong number of noteheads for chord."
note_handles = []
pitches = [Envelope.from_list(pitch) if hasattr(pitch, "__len__") else pitch for pitch in pitches]
first_pitch_start_level = pitches[0].start_level() if isinstance(pitches[0], Envelope) else pitches[0]
intervals = [(pitch.start_level() if isinstance(pitch, Envelope) else pitch) - first_pitch_start_level
for pitch in pitches]
for i, pitch in enumerate(pitches):
# for all but the last pitch, play it without blocking, so we can start all the others
# also copy the properties dictionary, and pick out the correct notehead if we've been given several
properties_copy = deepcopy(properties)
if len(properties.noteheads) > 1:
properties_copy.noteheads = [properties_copy.noteheads[i]]
note_handles.append(self.start_note(pitch, volume, properties=properties_copy, clock=clock,
max_volume=max_volume, flags=flags))
return ChordHandle(note_handles, intervals)
[docs] def change_note_parameter(self, note_id: int | NoteHandle, param_name: str,
target_value_or_values: float | Sequence[float],
transition_length_or_lengths: float | Sequence[float] = 0,
transition_curve_shape_or_shapes: float | Sequence[float] = 0,
clock: Clock = None) -> None:
"""
Changes the value of parameter of note playback over a given time; can also take a sequence of targets and times
:param note_id: which note to affect (an id or a NoteHandle)
:param param_name: name of the parameter to affect. "pitch" and "volume" are special cases
:param target_value_or_values: target value (or list of values) for the parameter
:param transition_length_or_lengths: transition time(s) in beats to the target value(s)
:param transition_curve_shape_or_shapes: curve shape(s) for the transition(s)
:param clock: which clock all of this happens on; by default, reuses the clock that the note started on.
"""
with self._note_info_lock:
note_id = note_id.note_id if isinstance(note_id, NoteHandle) else note_id
note_info = self._note_info_by_id[note_id]
if clock is None:
clock = note_info["clock"]
assert isinstance(clock, Clock), "Invalid clock argument."
if "fixed" in note_info["flags"] and param_name in ("pitch", "volume"):
raise Exception("Cannot change pitch or volume of a note with 'fixed' set to True.")
# which function do we use to actually carry out the change of parameter? Pitch and volume are special.
if "silent" in note_info["flags"]:
# if it's silent, then we don't actually call any of the implementation, so pass a dummy function
def parameter_change_function(value): note_info["parameter_values"][param_name] = value
temporal_resolution = None
elif param_name == "pitch":
def parameter_change_function(value):
for playback_implementation in self.playback_implementations:
playback_implementation.change_note_pitch(note_id, value)
note_info["parameter_values"][param_name] = value
temporal_resolution = "pitch-based"
elif param_name == "volume":
def parameter_change_function(value):
for playback_implementation in self.playback_implementations:
playback_implementation.change_note_volume(note_id, value)
note_info["parameter_values"][param_name] = value
temporal_resolution = "volume-based"
else:
def parameter_change_function(value):
for playback_implementation in self.playback_implementations:
playback_implementation.change_note_parameter(note_id, param_name, value)
note_info["parameter_values"][param_name] = value
temporal_resolution = 0.01
assert param_name in note_info["parameter_values"], \
"Cannot change parameter {}, as it was undefined at note start.".format(param_name)
if param_name in note_info["parameter_change_segments"]:
segments_list = note_info["parameter_change_segments"][param_name]
else:
segments_list = note_info["parameter_change_segments"][param_name] = []
# if there was a previous segment changing this same parameter, and it's not done yet, we should abort it
if len(segments_list) > 0:
segments_list[-1].abort_if_running()
# this helps to keep track of which call to change_note_parameter happened first, since when
# do_animation_sequence gets forked, order can become indeterminate (see comment there)
call_priority = next(ScampInstrument._change_param_call_counter)
if hasattr(target_value_or_values, "__len__"):
# assume linear segments unless otherwise specified
transition_curve_shape_or_shapes = [0] * len(target_value_or_values) if \
transition_curve_shape_or_shapes == 0 else transition_curve_shape_or_shapes
assert hasattr(transition_length_or_lengths, "__len__") and \
hasattr(transition_curve_shape_or_shapes, "__len__")
assert len(target_value_or_values) == len(transition_length_or_lengths) == \
len(transition_curve_shape_or_shapes), \
"List of target values must be accompanied by a equal length list of transition lengths and shapes."
def do_animation_sequence():
for target, length, shape in zip(target_value_or_values, transition_length_or_lengths,
transition_curve_shape_or_shapes):
with note_info["segments_list_lock"]:
if len(segments_list) > 0 and segments_list[-1].running:
# if two segments are started at the exact same (clock) time, then we want to abort the
# one that was called first. Often that will happen in the call to segments_list[-1].
# abort_if_running() above. However, it may be that they both make it through that check
# before either is added to the segments list. This checks in on that case, and aborts
# whichever segment came from the earlier call to change_note_parameter
if call_priority > segments_list[-1].call_priority:
# this call to change_note_parameter happened after, abort the other one
segments_list[-1].abort_if_running()
else:
# this call to change_note_parameter happened before, abort
return
this_segment = _ParameterChangeSegment(
parameter_change_function, note_info["parameter_values"][param_name], target,
length, shape, clock, call_priority, temporal_resolution=temporal_resolution)
segments_list.append(this_segment)
# note that these segments are not forked individually: they are chained together and called
# directly on a function (do_animation_sequence) that is forked. This means that when we abort
# one of them, we kill the clock that do_animation_sequence is running on, thereby aborting all
# remaining segments as well. This is exactly what we want: if we call change_note_parameter
# while a previous change_note_parameter is running, we want to abort all segments of the
# one that's running
try:
this_segment.run(silent="silent" in note_info["flags"])
except Exception as e:
raise e
clock.fork(do_animation_sequence, name="PARAM_ANIMATION_SEQUENCE({})".format(param_name))
else:
parameter_change_segment = _ParameterChangeSegment(
parameter_change_function, note_info["parameter_values"][param_name], target_value_or_values,
transition_length_or_lengths, transition_curve_shape_or_shapes, clock, call_priority,
temporal_resolution=temporal_resolution)
with note_info["segments_list_lock"]:
segments_list.append(parameter_change_segment)
clock.fork(parameter_change_segment.run, name="PARAM_ANIMATION({})".format(param_name),
kwargs={"silent": "silent" in note_info["flags"]})
[docs] def change_note_pitch(self, note_id: int | NoteHandle, target_value_or_values: float | Sequence[float],
transition_length_or_lengths: float | Sequence[float] = 0,
transition_curve_shape_or_shapes: float | Sequence[float] = 0,
clock: Clock = None) -> None:
"""
Change the pitch of an already started note; can also take a sequence of targets and times.
:param note_id: which note to affect (an id or a NoteHandle)
:param target_value_or_values: target value (or list of values) for the parameter
:param transition_length_or_lengths: transition time(s) in beats to the target value(s)
:param transition_curve_shape_or_shapes: curve shape(s) for the transition(s)
:param clock: which clock all of this happens on; by default, reuses the clock that the note started on.
"""
self.change_note_parameter(note_id, "pitch", target_value_or_values, transition_length_or_lengths,
transition_curve_shape_or_shapes, clock)
[docs] def change_note_volume(self, note_id: int | NoteHandle, target_value_or_values: float | Sequence[float],
transition_length_or_lengths: float | Sequence[float] = 0,
transition_curve_shape_or_shapes: float | Sequence[float] = 0,
clock: Clock = None) -> None:
"""
Change the volume of an already started note; can also take a sequence of targets and times
:param note_id: which note to affect (an id or a NoteHandle)
:param target_value_or_values: target value (or list thereof) for the parameter
:param transition_length_or_lengths: transition time(s) in beats to the target value(s)
:param transition_curve_shape_or_shapes: curve shape(s) for the transition(s)
:param clock: which clock all of this happens on; "from_note" simply reuses the clock that the note started on.
"""
self.change_note_parameter(note_id, "volume", target_value_or_values, transition_length_or_lengths,
transition_curve_shape_or_shapes, clock)
[docs] def split_note(self, note_id: int | NoteHandle) -> None:
"""
Adds a split point in a note, causing it later to be rendered as tied pieces.
:param note_id: Which note or NoteHandle to split
"""
with self._note_info_lock:
note_id = note_id.note_id if isinstance(note_id, NoteHandle) else note_id
note_info = self._note_info_by_id[note_id]
note_info["split_points"].append(TimeStamp(note_info["clock"]))
[docs] def end_note(self, note_id: int | NoteHandle = None) -> None:
"""
Ends the note with the given note id. If none is specified, ends oldest note started.
Note that this only applies to notes started in an open-ended way with :func:`start_note`, notes created using
:func:`play_note` have their lifecycle controlled automatically.
:param note_id: either the id itself or a NoteHandle with that id. Default of None ends the oldest note
"""
with self._note_info_lock:
# in case we're passed a NoteHandle instead of an actual id number, get the number from the handle
note_id = note_id.note_id if isinstance(note_id, NoteHandle) else note_id
if note_id is not None:
# as specific note_id has been given, so it had better belong to a currently playing note!
if note_id not in self._note_info_by_id:
logging.warning("Tried to end a note that was never started (or already ended)!")
return
elif len(self._note_info_by_id) > 0:
# no specific id was given, so end the oldest note
# (note that ids just count up, so the lowest active id is the oldest)
note_id = min(self._note_info_by_id.keys())
else:
logging.warning("Tried to end a note that was never started (or already ended)!")
return
note_info = self._note_info_by_id[note_id]
# resolve the clock to use
clock = note_info["clock"]
# end any segments that are still changing
for param_name in note_info["parameter_change_segments"]:
if len(note_info["parameter_change_segments"][param_name]) > 0:
note_info["parameter_change_segments"][param_name][-1].abort_if_running()
# transcribe the note, if applicable
note_info["end_time_stamp"] = TimeStamp(clock)
if "no_transcribe" not in note_info["flags"]:
for transcriber in self._transcribers_to_notify:
transcriber.register_note(self, note_info)
# do the sonic implementation of ending the note, as long as it's not silent
if "silent" not in note_info["flags"]:
for playback_implementation in self. playback_implementations:
playback_implementation.end_note(note_id)
# remove from active notes and delete the note info
del self._note_info_by_id[note_id]
[docs] def end_all_notes(self) -> None:
"""
Ends all notes currently playing
"""
while len(self._note_info_by_id) > 0:
self.end_note()
[docs] def num_notes_playing(self) -> int:
"""
Returns the number of notes currently playing.
"""
return len(self._note_info_by_id)
"""
---------------------------------------- Adding and removing playback ----------------------------------------
"""
[docs] def add_soundfont_playback(self, preset: str | int | tuple[int, int] = "auto", soundfont: str = "default",
num_channels: int = 8, audio_driver: str = "default", max_pitch_bend: int = "default",
note_on_and_off_only: bool = False) -> ScampInstrument:
"""
Add a soundfont playback implementation for this instrument.
:param preset: either a preset number, a tuple of (bank, preset), a string giving a name to search for in the
soundfont, or the string "auto", in which case the name of this instrument is used to search for a preset.
:param soundfont: which soundfont to use. This can be either a path to a soundfont or the name of one of the
soundfonts specified in playback_settings.named_soundfonts. If this instrument belongs to an Ensemble,
"default" means use the Ensemble default; if not, we will fall back to the default provided in
playback_settings.
:param num_channels: how many channels to allocate for managing pitch bends, etc.
:param audio_driver: which driver to use
:param max_pitch_bend: max pitch bend to allow
:param note_on_and_off_only: This enforces a rule of no dynamic pitch bends, expression (volume) changes, or
other cc messages. Valuable when using :func:`start_note` instead of :func:`play_note` in music that
doesn't do any dynamic pitch/volume/parameter changes. Without this flag, notes will all be placed on
separate MIDI channels, since they could potentially change pitch or volume; with this flags, we know they
won't, so they can share the same MIDI channels, only using an extra one due to microtonality.
:return: self, for chaining purposes
"""
soundfont = self.ensemble.default_soundfont \
if self.ensemble is not None and soundfont == "default" else soundfont
if isinstance(preset, str):
preset = Ensemble._resolve_preset_from_name(self.name if preset == "auto" else preset, soundfont)
elif isinstance(preset, int):
preset = (0, preset)
self.playback_implementations.append(
SoundfontPlaybackImplementation(bank_and_preset=preset, soundfont=soundfont, num_channels=num_channels,
audio_driver=audio_driver, max_pitch_bend=max_pitch_bend,
note_on_and_off_only=note_on_and_off_only)
)
return self
[docs] def remove_soundfont_playback(self) -> ScampInstrument:
"""
Remove the most recent SoundfontPlaybackImplementation from this instrument.
:return: self, for chaining purposes
"""
for index in reversed(range(len(self.playback_implementations))):
if isinstance(self.playback_implementations[index], SoundfontPlaybackImplementation):
self.playback_implementations.pop(index)
break
return self
[docs] def add_streaming_midi_playback(self, midi_output_device: int | str = "default", num_channels: int = 8,
midi_output_name: str = None, max_pitch_bend: int = "default",
note_on_and_off_only: bool = False, start_channel: int = 0) -> ScampInstrument:
"""
Add a streaming MIDI playback implementation for this instrument.
:param midi_output_device: name or number of the device to use
:param num_channels: how many channels to allocate for managing pitch bends, etc.
:param midi_output_name: name given to the output stream
:param max_pitch_bend: max pitch bend to allow
:param note_on_and_off_only: This enforces a rule of no dynamic pitch bends, expression (volume) changes, or
other cc messages. Valuable when using :func:`start_note` instead of :func:`play_note` in music that
doesn't do any dynamic pitch/volume/parameter changes. Without this flag, notes will all be placed on
separate MIDI channels, since they could potentially change pitch or volume; with this flags, we know they
won't, so they can share the same MIDI channels, only using an extra one due to microtonality.
:param start_channel: the first channel to use. For instance, if start_channel is 4, and num_channels is 5,
we will use channels (4, 5, 6, 7, 8). NOTE: channel counting in SCAMP starts from 0, so this may show
up as channels 5-9 in your MIDI software.
:return: self, for chaining purposes
"""
self.playback_implementations.append(
MIDIStreamPlaybackImplementation(midi_output_device=midi_output_device, num_channels=num_channels,
midi_output_name=midi_output_name, max_pitch_bend=max_pitch_bend,
note_on_and_off_only=note_on_and_off_only, start_channel=start_channel)
)
return self
[docs] def remove_streaming_midi_playback(self) -> ScampInstrument:
"""
Remove the most recent MIDIStreamPlaybackImplementation from this instrument.
:return: self, for chaining purposes
"""
for index in reversed(range(len(self.playback_implementations))):
if isinstance(self.playback_implementations[index], MIDIStreamPlaybackImplementation):
self.playback_implementations.pop(index)
break
return self
[docs] def add_osc_playback(self, port: int, ip_address: str = "127.0.0.1", message_prefix: str = None,
osc_message_addresses: dict = "default"):
"""
Add an OSCPlaybackImplementation for this instrument.
:param port: port to use
:param ip_address: ip address to use
:param message_prefix: the prefix to give to all outgoing osc messages; defaults to the instrument name
with all spaces removed.
:param osc_message_addresses: the specifix message addresses to be used for each type of message. Defaults are
defined in playback_settings
:return: self, for chaining purposes
"""
message_prefix = self.name.replace(" ", "") if message_prefix is None else message_prefix
self.playback_implementations.append(
OSCPlaybackImplementation(port=port, ip_address=ip_address, message_prefix=message_prefix,
osc_message_addresses=osc_message_addresses)
)
return self
[docs] def remove_osc_playback(self) -> ScampInstrument:
"""
Remove the most recent OSCPlaybackImplementation from this instrument.
:return: self, for chaining purposes
"""
for index in reversed(range(len(self.playback_implementations))):
if isinstance(self.playback_implementations[index], OSCPlaybackImplementation):
self.playback_implementations.pop(index)
break
return self
"""
------------------------------------------------- Other -----------------------------------------------------
"""
[docs] def set_max_pitch_bend(self, semitones: int) -> None:
"""
Set the max pitch bend for all midi playback implementations on this instrument
"""
for playback_implementation in self.playback_implementations:
playback_implementation.set_max_pitch_bend(semitones)
[docs] def send_midi_cc(self, cc_number: int, value_from_0_to_1: float) -> None:
"""
Sends a midi cc message to all midi-based playback implementations, affecting all channels this instrument uses.
This is useful for stuff like pedal messages, that we don't really want to bundle with note playback, and that
we want to apply to all channels.
:param cc_number: the cc number from 0 to 127
:param value_from_0_to_1: the value to send, normalized from 0 to 1
"""
for playback_implementation in self.playback_implementations:
if hasattr(playback_implementation, "cc"):
for chan in range(playback_implementation.num_channels):
playback_implementation.cc(chan, cc_number, value_from_0_to_1)
@property
def clef_preference(self):
"""
The clef preference for this instrument. Can be any of:
- "from_name", which picks clef based on the instrument name
- "default", which uses the default clef preferences for an unknown instrument
- the name of a clef
- the name of an instrument whose clef defaults to use
- a list of possible clefs. Each of these choices should be either a valid clef name string or a tuple of (valid clef name string, center pitch).
"""
return self._clef_preference
@clef_preference.setter
def clef_preference(self, value):
old_value = self._clef_preference
self._clef_preference = value
try:
self.resolve_clef_preference()
except RuntimeError as e:
self._clef_preference = old_value
raise e
[docs] def resolve_clef_preference(self) -> Sequence[str | tuple[str, Real]]:
"""
Resolves the clef preference to a sequence of possible clef choices.
"""
if isinstance(self.clef_preference, str):
if self.clef_preference == "from_name":
# base clef preference on instrument name
if self.name.lower().strip() in engraving_settings.clefs_by_instrument:
return engraving_settings.clefs_by_instrument[self.name.lower().strip()]
else:
# instrument name is not found, so revert to default clef preferences
return engraving_settings.clefs_by_instrument["default"]
elif self.clef_preference == "default":
# just use the default clef preferences
return engraving_settings.clefs_by_instrument["default"]
elif self.clef_preference in engraving_settings.clef_pitch_centers:
# clef preference is the name of a clef
return [self.clef_preference]
elif self.clef_preference.lower().strip() in engraving_settings.clefs_by_instrument:
# clef preference is an instrument name
return engraving_settings.clefs_by_instrument[self.clef_preference.lower().strip()]
raise RuntimeError("Clef preference could not be resolved.")
elif isinstance(self.clef_preference, Sequence):
# if not a string, clef preference should be a sequence of possible clef choices
# each of these choices should be either a valid clef name string or a tuple of
# (valid clef name string, center pitch)
if all(
x in engraving_settings.clef_pitch_centers or hasattr(x, "__len__") and
x[0] in engraving_settings.clef_pitch_centers and isinstance(x[1], Real)
for x in self.clef_preference
):
return self.clef_preference
else:
raise RuntimeError("Clef preference not understood.")
else:
raise RuntimeError("Clef preference not understood.")
@property
def default_spelling_policy(self):
"""
The default spelling policy for notes played back by this instrument. (Can be set with either a
:class:`~scamp.spelling.SpellingPolicy` or a string, which is passed to
:func:`~scamp.spelling.SpellingPolicy.from_string`)
"""
return self._default_spelling_policy
@default_spelling_policy.setter
def default_spelling_policy(self, value: SpellingPolicy | str):
if value is None or isinstance(value, SpellingPolicy):
self._default_spelling_policy = value
elif isinstance(value, str):
self._default_spelling_policy = SpellingPolicy.from_string(value)
else:
raise ValueError("Spelling policy not understood.")
"""
--------------------------------------------- To / from JSON -------------------------------------------------
"""
def _to_dict(self):
return {
"name": self.name,
"playback_implementations": self.playback_implementations,
"default_spelling_policy": self.default_spelling_policy,
"clef_preference": self.clef_preference
}
@classmethod
def _from_dict(cls, json_dict):
return cls(**json_dict)
def __str__(self):
return "ScampInstrument('{}')".format(self.name)
def __repr__(self):
return "ScampInstrument({})".format(", ".join("{}={}".format(k, repr(v)) for k, v in self._to_dict().items()))
@property
def note_info_by_id(self):
return self._note_info_by_id
[docs]class NoteHandle:
"""
This handle, which is returned by instrument.start_note, allows us to manipulate the note that we have started,
(i.e. by changing pitch, volume, or another other parameter, or by ending the note). You would never create
one of these directly.
:param note_id: the reference id of the note
:param instrument: the instrument playing the note
:ivar note_id: the reference id of the note
:ivar instrument: the instrument playing the note
"""
def __init__(self, note_id: int, instrument: ScampInstrument):
self.note_id: int = note_id
self.instrument: ScampInstrument = instrument
[docs] def change_parameter(self, param_name: str, target_value_or_values: float | Sequence[float],
transition_length_or_lengths: float | Sequence[float] = 0,
transition_curve_shape_or_shapes: float | Sequence[float] = 0, clock: Clock = None) -> None:
"""
Change a custom playback parameter for this note to a given target value or values, over a given duration and
with a given curve shape.
:param param_name: name of the parameter to change
:param target_value_or_values: either a single value or a list of values to which we want to change the
parameter of interest.
:param transition_length_or_lengths: the duration (in beats) that we want it to take to reach the target value.
The default value of 0 represents an instantaneous change. If multiple target values were given, a list of
durations should be given for each segment.
:param transition_curve_shape_or_shapes: the curve shape used in transitioning to the new target value.
The default value of 0 represents an linear change, a value greater than zero represents late change,
and a value less than 0 represents early change. If multiple target values were given, a list of curve
shapes should be given (unless it is left as the default 0, in which case all segments are linear).
:param clock: The clock with which to interpret the transition timings. The default value of "from_note", which
you likely don't want to change, carries out the timings on the clock on which the note was started.
"""
self.instrument.change_note_parameter(self.note_id, param_name, target_value_or_values,
transition_length_or_lengths, transition_curve_shape_or_shapes, clock)
[docs] def change_pitch(self, target_value_or_values: float | Sequence[float],
transition_length_or_lengths: float | Sequence[float] = 0,
transition_curve_shape_or_shapes: float | Sequence[float] = 0, clock: Clock = None) -> None:
"""
Change the pitch of this note to a given target value or values, over a given duration and with a given
curve shape.
:param target_value_or_values: either a single target pitch or a list of target pitches.
:param transition_length_or_lengths: the duration (in beats) that we want it to take to reach the target pitch.
The default value of 0 represents an instantaneous change. If multiple target values were given, a list of
durations should be given for each segment.
:param transition_curve_shape_or_shapes: the curve shape used in transitioning to the new target pitch.
The default value of 0 represents an linear change, a value greater than zero represents late change,
and a value less than 0 represents early change. If multiple target values were given, a list of curve
shapes should be given (unless it is left as the default 0, in which case all segments are linear).
:param clock: The clock with which to interpret the transition timings. The default value of "from_note", which
you likely don't want to change, carries out the timings on the clock on which the note was started.
"""
self.instrument.change_note_pitch(self.note_id, target_value_or_values, transition_length_or_lengths,
transition_curve_shape_or_shapes, clock)
[docs] def change_volume(self, target_value_or_values: float | Sequence[float],
transition_length_or_lengths: float | Sequence[float] = 0,
transition_curve_shape_or_shapes: float | Sequence[float] = 0, clock: Clock = None) -> None:
"""
Change the volume of this note to a given target value or values, over a given duration and with a given
curve shape.
:param target_value_or_values: either a single target volume or a list of target volumes.
:param transition_length_or_lengths: the duration (in beats) that we want it to take to reach the target volume.
The default value of 0 represents an instantaneous change. If multiple target values were given, a list of
durations should be given for each segment.
:param transition_curve_shape_or_shapes: the curve shape used in transitioning to the new target volume.
The default value of 0 represents an linear change, a value greater than zero represents late change,
and a value less than 0 represents early change. If multiple target values were given, a list of curve
shapes should be given (unless it is left as the default 0, in which case all segments are linear).
:param clock: The clock with which to interpret the transition timings. The default value of "from_note", which
you likely don't want to change, carries out the timings on the clock on which the note was started.
"""
self.instrument.change_note_volume(self.note_id, target_value_or_values, transition_length_or_lengths,
transition_curve_shape_or_shapes, clock)
[docs] def split(self) -> None:
"""
Adds a split point to this note, causing it later to be rendered as tied pieces.
"""
self.instrument.split_note(self.note_id)
[docs] def end(self) -> None:
"""
Ends this note.
"""
self.instrument.end_note(self.note_id)
def __repr__(self):
return "NoteHandle({}, {})".format(self.note_id, self.instrument)
[docs]class ChordHandle:
"""
This handle, returned by instrument.start_chord, allows us to manipulate a chord that we have started,
(i.e. by changing pitch, volume, or another other parameter, or by ending the note). You would never create
one of these directly.
:param note_handles: the handles of the notes that make up this chord
:param intervals: the original pitch intervals between the chord tones
:ivar note_handles: the handles of the notes that make up this chord
"""
def __init__(self, note_handles: Sequence[NoteHandle], intervals: Sequence[float]):
self.note_handles = tuple(note_handles) if not isinstance(note_handles, tuple) else note_handles
self._intervals = tuple(intervals) if not isinstance(intervals, tuple) else intervals
[docs] def change_parameter(self, param_name: str, target_value_or_values: float | Sequence[float],
transition_length_or_lengths: float | Sequence[float] = 0,
transition_curve_shape_or_shapes: float | Sequence[float] = 0, clock: Clock = None) -> None:
"""
Change a custom playback parameter for all notes in this chord to a given target value or values, over a given
duration and with a given curve shape.
:param param_name: name of the parameter to change
:param target_value_or_values: either a single value or a list of values to which we want to change the
parameter of interest.
:param transition_length_or_lengths: the duration (in beats) that we want it to take to reach the target value.
The default value of 0 represents an instantaneous change. If multiple target values were given, a list of
durations should be given for each segment.
:param transition_curve_shape_or_shapes: the curve shape used in transitioning to the new target value.
The default value of 0 represents an linear change, a value greater than zero represents late change,
and a value less than 0 represents early change. If multiple target values were given, a list of curve
shapes should be given (unless it is left as the default 0, in which case all segments are linear).
:param clock: The clock with which to interpret the transition timings. The default value of "from_note", which
you likely don't want to change, carries out the timings on the clock on which the note was started.
"""
for note_handle in self.note_handles:
note_handle.change_parameter(param_name, target_value_or_values, transition_length_or_lengths,
transition_curve_shape_or_shapes, clock)
[docs] def change_pitch(self, target_value_or_values: float | Sequence[float],
transition_length_or_lengths: float | Sequence[float] = 0,
transition_curve_shape_or_shapes: float | Sequence[float] = 0, clock: Clock = None) -> None:
"""
Change the pitches of this chord such that the first note of the chord goes to the given target value or values,
over a given duration and with a given curve shape.
:param target_value_or_values: either a single target pitch or a list of target pitches. Note that this is the
pitch that the first note of the chord gets changed to; all of the other notes in the chord follow suit,
maintaining the same interval as before with the first note of the chord.
:param transition_length_or_lengths: the duration (in beats) that we want it to take to reach the target pitch.
The default value of 0 represents an instantaneous change. If multiple target values were given, a list of
durations should be given for each segment.
:param transition_curve_shape_or_shapes: the curve shape used in transitioning to the new target pitch.
The default value of 0 represents an linear change, a value greater than zero represents late change,
and a value less than 0 represents early change. If multiple target values were given, a list of curve
shapes should be given (unless it is left as the default 0, in which case all segments are linear).
:param clock: The clock with which to interpret the transition timings. The default value of "from_note", which
you likely don't want to change, carries out the timings on the clock on which the note was started.
"""
for note_handle, interval in zip(self.note_handles, self._intervals):
this_note_pitch_targets = [target_value + interval for target_value in target_value_or_values] \
if hasattr(target_value_or_values, "__len__") else target_value_or_values + interval
note_handle.change_pitch(this_note_pitch_targets, transition_length_or_lengths,
transition_curve_shape_or_shapes, clock)
[docs] def change_volume(self, target_value_or_values: float | Sequence[float],
transition_length_or_lengths: float | Sequence[float] = 0,
transition_curve_shape_or_shapes: float | Sequence[float] = 0, clock: Clock = None) -> None:
"""
Change the volume for all notes in this chord to a given target value or values, over a given duration and with
a given curve shape.
:param target_value_or_values: either a single target volume or a list of target volumes.
:param transition_length_or_lengths: the duration (in beats) that we want it to take to reach the target volume.
The default value of 0 represents an instantaneous change. If multiple target values were given, a list of
durations should be given for each segment.
:param transition_curve_shape_or_shapes: the curve shape used in transitioning to the new target volume.
The default value of 0 represents an linear change, a value greater than zero represents late change,
and a value less than 0 represents early change. If multiple target values were given, a list of curve
shapes should be given (unless it is left as the default 0, in which case all segments are linear).
:param clock: The clock with which to interpret the transition timings. The default value of "from_note", which
you likely don't want to change, carries out the timings on the clock on which the note was started.
"""
for note_handle in self.note_handles:
note_handle.change_volume(target_value_or_values, transition_length_or_lengths,
transition_curve_shape_or_shapes, clock)
[docs] def split(self) -> None:
"""
Adds a split point to this chord, causing it later to be rendered as tied pieces.
"""
for note_handle in self.note_handles:
note_handle.split()
[docs] def end(self) -> None:
"""
Ends all notes in this chord.
"""
for note_handle in self.note_handles:
note_handle.end()
def __repr__(self):
return "ChordHandle({}, {})".format(self.note_handles, self._intervals)
class _ParameterChangeSegment(EnvelopeSegment):
"""
Convenience class for handling interruptable transitions of parameter values and storing info on them
(This is an implementation detail.)
:param parameter_change_function: since this is for general parameters, we pass the function to be called
to set the parameter. Generally will call _do_change_note_parameter/pitch/volume for a given note_id
:param start_value: start value of the parameter in the transition
:param target_value: target value of the parameter in the transition
:param transition_length: length of the transition in beats on the clock given
:param transition_curve_shape: curve shape of the transition
:param clock: the clock that all of this happens in reference to
:param call_priority: this is used to determine which call to change_parameter happened first, since once these
things get spawned in threads, the order gets indeterminate.
:param temporal_resolution: time resolution of the unsynchronized process. One of: just a number (in seconds); the
string "pitch-based", in which case we derive it based on trying to get a smooth pitch change; the string
"volume-based", in which case we derive it based on trying to get a smooth volume change.
"""
def __init__(self, parameter_change_function, start_value, target_value, transition_length, transition_curve_shape,
clock, call_priority, temporal_resolution=0.01):
# set this up as an envelope
super().__init__(0, transition_length, start_value, target_value, transition_curve_shape)
# "do_change_parameter" feels more like an action name
self.do_change_parameter = parameter_change_function
self.clock = clock # the parent clock that this process runs on
self._run_clock = None # the sub-clock created by forking this process
self.running = False # flag used for aborting the unsynchronized process
# some of the key data that this envelope holds onto are the time stamps at which it starts and finishes
# this can be used to construct the appropriate envelope segment on whichever clock we're recording on
self.start_time_stamp = None
self.end_time_stamp = None
self.call_priority = call_priority
self.temporal_resolution = temporal_resolution
def run(self, silent=False):
"""
Runs the segment from start to finish, gradually changing the parameter.
This function runs as a synchronized clock process (it should be forked), and it starts a parallel,
unsynchronized process ("_animation_function") to do the actual calls to change parameter
:param silent: this flag causes none of the animation to actually happen. This is used when we're trying to
notate a note but not play it back, as in the case of a note that has been adjusted (where we playback -- but
don't notate -- the adjusted version, while we run -- but don't play back -- the unadjusted version.)
"""
self.start_time_stamp = TimeStamp(self.clock)
# if this segment has no duration, no need to do any animation
# just set it to the final value and return
if self.duration == 0:
self.end_time_stamp = TimeStamp(self.clock)
self.do_change_parameter(self.end_level)
return
self.start_time_stamp = TimeStamp(self.clock)
self.running = True # used to kill the unsynchronized process when we abort or this synchronized one ends
# we note down the clock we're running this on. If abort is called, this clock gets killed
self._run_clock = current_clock()
# if there's no change, or if we're skipping animation, just wait and finish
if self.end_level == self.start_level or silent:
wait(self.duration)
self.end_time_stamp = TimeStamp(self.clock)
self.do_change_parameter(self.end_level)
self.running = False
return
# determine the time increment, perhaps by calculating a good one for the given parameter
if self.temporal_resolution == "pitch-based":
time_increment = self._get_good_pitch_bend_temporal_resolution()
elif self.temporal_resolution == "volume-based":
time_increment = self._get_good_volume_temporal_resolution()
else:
time_increment = self.temporal_resolution
# don't animate faster than 4ms though, and don't go slower than half the duration
time_increment = min(self.duration / 2, max(0.004, time_increment))
def _animation_function():
# does the intermediate changing of values; since it's sleeping in small time increments, we fork it
# as unsynchronized parallel process so that it doesn't gum up the clocks with the overhead of
# waking and sleeping rapidly
beats_passed = 0
time_estimate = self.clock.master.time()
self.clock.master.unsynced_time = time_estimate
while beats_passed < self.duration and self.running:
start = time.time()
if beats_passed > 0: # no need to change the parameter the first time, before we had a chance to wait
self.do_change_parameter(self.value_at(beats_passed))
time.sleep(time_increment)
time_estimate += time_increment
self.clock.master.unsynced_time = max(time_estimate, self.clock.master.unsynced_time)
# TODO: Absolute_rate would be great, except that it doesn't update between synchronized clock events
# Is there a way of improving this??
beats_passed += (time.time() - start) * self.clock.absolute_rate()
# start the unsynchronized animation function
self.clock.fork_unsynchronized(_animation_function)
# waits in a synchronized fashion so that it can save an accurate time stamp at the end
wait(self.duration)
# we only get here if it wasn't aborted while running, since that will call kill on the child clock
self.running = False
self.end_time_stamp = TimeStamp(self.clock)
self.do_change_parameter(self.end_level)
def abort_if_running(self):
if self.running:
# if we were running, we save the time stamp at which we aborted as the end time stamp
self.end_time_stamp = TimeStamp(self.clock)
self._run_clock.kill() # kill the clock doing the "run" function
# since the units of this envelope are beats in self.clock, see how far we got in the envelope by
# subtracting converting the start and end time stamps to those beats and subtracting
how_far_we_got = self.end_time_stamp.beat_in_clock(self.clock) - \
self.start_time_stamp.beat_in_clock(self.clock)
# now split there, discarding the rest of the envelope. This makes self.end_level the value we ended up at.
if self.start_time < how_far_we_got < self.end_time:
self.split_at(how_far_we_got)
elif self.start_time == how_far_we_got:
# this was aborted before it even got going. Later, the transcriber will ignore this nothing segment
self.end_time = self.start_time
self.end_level = self.start_level
self.running = False
return
self.do_change_parameter(self.end_level) # set it to where we should be at this point
self.running = False # this will make sure to abort the animation function
def completed(self):
# it's not running, but because it finished, not because it never started
return not self.running and self.end_time_stamp is not None
def _get_good_pitch_bend_temporal_resolution(self):
"""
Returns a reasonable temporal resolution, based on this clock's envelope and rate, assuming it's a pitch curve
"""
max_cents_per_second = self.max_absolute_slope() * 100 * self.clock.absolute_rate()
# cents / update * updates / sec = cents / sec => updates_freq = cents_per_second / cents_per_update
# we'll aim for 4 cents per update, since some say the JND is 5-6 cents
update_freq = max_cents_per_second / 4.0
return 1 / update_freq
def _get_good_volume_temporal_resolution(self):
"""
Returns a reasonable temporal resolution, based on this clock's envelope and rate, assuming it's a volume curve
"""
max_volume_per_second = self.max_absolute_slope() * self.clock.absolute_rate()
# based on the idea that for midi volumes, it's quantized from 0 to 127, so there's not much point in updating
# in between those quantization levels. It's a decent enough rule even if not using midi output.
update_freq = max_volume_per_second * 127
return 1 / update_freq
def __repr__(self):
return "_ParameterChangeSegment[{}, {}, {}, {}, {}]".format(
self.start_time_stamp, self.end_time_stamp, self.start_level, self.end_level, self.curve_shape
)