Source code for scamp.performance

"""
Module containing the :class:`PerformanceNote`, :class:`PerformancePart`, and :class:`Performance` classes, which
represent transcriptions of notes played by a group of :class:`~scamp.instruments.ScampInstrument` objects. These
classes contain continuous time, pitch, volume, and other parameter data, which can then be quantized and converted
into the notation-based classes in the score module.
"""

#  ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++  #
#  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 bisect
from functools import total_ordering
from numbers import Real
from expenvelope import Envelope
from .note_properties import NoteProperties
from .settings import engraving_settings
from .quantization import quantize_performance_part, QuantizationScheme
from .settings import quantization_settings
from clockblocks import Clock, TempoEnvelope, current_clock
from .instruments import Ensemble, ScampInstrument
from .score import Score, StaffGroup
from .utilities import SavesToJSON
import logging
from copy import deepcopy
import itertools
import textwrap
from typing import Sequence, Iterator, Callable
from midiutil import MIDIFile
from ._midi import MIDIChannelManager


[docs]@total_ordering class PerformanceNote(SavesToJSON): """ Represents a single note played by a :class:`~scamp.instruments.ScampInstrument`. :param start_beat: the start beat of the note :param length: the length of the note in beats (either a float or a tuple of floats representing tied segments) :param pitch: the pitch of the note (float or Envelope) :param volume: the volume of the note (float or Envelope) :param properties: dictionary of note properties, or string representing those properties :ivar start_beat: the start beat of the note :ivar length: the length of the note in beats (either a float or a tuple of floats representing tied segments) :ivar pitch: the pitch of the note (float or Envelope); note that this can also be a tuple of pitches representing a chord, but that this usually happens in the process of quantization when notes that can be merged into chords are merged. :ivar volume: the volume of the note (float or Envelope) :ivar properties: dictionary of note properties, or string representing those properties """ def __init__(self, start_beat: float, length: float | tuple[float, ...], pitch: float | Envelope | Sequence, volume: float | Envelope, properties: dict): self.start_beat = start_beat # if length is a tuple, this indicates that the note is to be split into tied segments self.length = length # if pitch is a tuple, this indicates a chord self.pitch = pitch self.volume = volume self.properties = properties if isinstance(properties, NoteProperties) \ else NoteProperties.interpret(properties)
[docs] def length_sum(self) -> float: """ Total length of this note, adding together any tied segments. (The attribute "length" can be a list of floats representing tied segments.) :return: length of note as a float """ return sum(self.length) if hasattr(self.length, "__len__") else self.length
@property def end_beat(self) -> float: """ End beat of this note """ return self.start_beat + self.length_sum() @end_beat.setter def end_beat(self, new_end_beat: float): new_length = new_end_beat - self.start_beat if hasattr(self.length, "__len__"): ratio = new_length / self.length_sum() self.length = tuple(segment_length * ratio for segment_length in self.length) else: self.length = new_length
[docs] def average_pitch(self) -> float: """ Averages the pitch of this note, accounting for if it's a glissando or a chord :return: the averaged pitch as a float """ if isinstance(self.pitch, tuple): # it's a chord, so take the average of its members return sum(x.average_level() if isinstance(x, Envelope) else x for x in self.pitch) / len(self.pitch) else: return self.pitch.average_level() if isinstance(self.pitch, Envelope) else self.pitch
[docs] def play(self, instrument: ScampInstrument, clock: Clock = None, blocking: bool = True) -> None: """ Play this note with the given instrument on the given clock :param instrument: instrument to play back with :param clock: the clock to play back on (if None, infers it from context) :param blocking: if True, don't return until the note is done playing; if False, return immediately """ if isinstance(self.pitch, tuple): instrument.play_chord(self.pitch, self.volume, self.length, self.properties, clock=clock, blocking=blocking) else: instrument.play_note(self.pitch, self.volume, self.length, self.properties, clock=clock, blocking=blocking)
_id_generator = itertools.count()
[docs] @staticmethod def next_id() -> int: """ Return a new unique ID number for this note, different from all PerformanceNotes created so far. :return: id number (int) """ return next(PerformanceNote._id_generator)
def _divide_length_at_gliss_control_points(self): if not isinstance(self.pitch, Envelope): return control_points = self.pitch.times[1:-1] if engraving_settings.glissandi.consider_non_extrema_control_points \ else self.pitch.local_extrema() for control_point in control_points: if control_point <= 0 or control_point >= self.length_sum(): continue first_part, second_part = PerformanceNote._split_length(self.length, control_point) self.length = (first_part if isinstance(first_part, tuple) else (first_part, )) + \ (second_part if isinstance(second_part, tuple) else (second_part, )) @staticmethod def _split_length(length, split_point): """ Utility method for splitting a note length into two pieces, including the case that the length is a tuple For instance, if length = (3, 2, 4) and the split_point = 4.5, this gives us the tuple (3, 1.5) and (0.5, 4) :param length: a note length, either a number or a tuple of numbers representing tied segments :param split_point: where to split the length :return: tuple of (first half length, second half length). Each of these lengths may themselves be a tuple, or may be a single number if they are not split. """ # raise an error if we try to split at a non-positive value or a value greater than the length if split_point <= 0 or split_point >= (sum(length) if hasattr(length, "__len__") else length): raise ValueError("Split point outside of length tuple.") if hasattr(length, "__len__"): # tuple length part_sum = 0 for i, segment_length in enumerate(length): if part_sum + segment_length < split_point: part_sum += segment_length elif part_sum + segment_length == split_point: first_part = length[:i + 1] second_part = length[i + 1:] return first_part if len(first_part) > 1 else first_part[0], \ second_part if len(second_part) > 1 else second_part[0] else: first_part = length[:i] + (split_point - part_sum,) second_part = (part_sum + segment_length - split_point,) + length[i + 1:] return first_part if len(first_part) > 1 else first_part[0], \ second_part if len(second_part) > 1 else second_part[0] else: # simple length, not a tuple return split_point, length - split_point
[docs] def split_at_beat(self, split_beat: float) -> Sequence[PerformanceNote]: """ Splits this note at the given beat, returning a tuple of the pieces created :param split_beat: where to split (relative to the performance start time, not the note start time) :return: tuple of (first half note, second half note) if split beat is within the note. Otherwise just return the unchanged note in a length-1 tuple. """ if not self.start_beat + 1e-10 < split_beat < self.end_beat - 1e-10: # if we're asked to split at a beat that is outside the note, it has no effect # since the expectation is a tuple as return value, return the note unaltered in a length-1 tuple return self, else: second_part = self.duplicate() second_part.start_beat = split_beat self.length, second_part.length = PerformanceNote._split_length(self.length, split_beat - self.start_beat) if self.pitch is not None: if isinstance(self.pitch, Envelope): # if the pitch is a envelope, then we split it appropriately pitch_curve_start, pitch_curve_end = self.pitch.split_at(self.length_sum()) self.pitch = pitch_curve_start second_part.pitch = pitch_curve_end elif isinstance(self.pitch, tuple) and isinstance(self.pitch[0], Envelope): # if the pitch is a tuple of envelopes (glissing chord) then same idea first_part_chord = [] second_part_chord = [] for pitch_curve in self.pitch: assert isinstance(pitch_curve, Envelope) pitch_curve_start, pitch_curve_end = pitch_curve.split_at(self.length_sum()) first_part_chord.append(pitch_curve_start) second_part_chord.append(pitch_curve_end) self.pitch = tuple(first_part_chord) second_part.pitch = tuple(second_part_chord) if isinstance(self.volume, Envelope): # if the volume is an envelope, then we split it appropriately volume_curve_start, volume_curve_end = self.volume.split_at(self.length_sum()) self.volume = volume_curve_start second_part.volume = volume_curve_end # also, if this isn't a rest, then we're going to need to keep track of ties that will be needed self.properties.starts_tie = True second_part.properties.ends_tie = True for articulation in reversed(self.properties.articulations): split_protocol = engraving_settings.articulation_split_protocols[articulation] \ if articulation in engraving_settings.articulation_split_protocols \ else engraving_settings.articulation_split_protocols["default"] if split_protocol == "first": # this articulation is about the attack, so should appear only in the first part # of a split note, so remove it from the second second_part.properties.articulations.remove(articulation) elif split_protocol == "last": # this articulation is about the release, so should appear only in the second part # of a split note, so remove it from the first self.properties.articulations.remove(articulation) elif split_protocol == "both": # this articulation is about the attack and release, but it doesn't really make # sense to play it on a note the middle of a tied group if self.properties.starts_tie and self.properties.ends_tie: self.properties.articulations.remove(articulation) if second_part.properties.starts_tie and second_part.properties.ends_tie: second_part.properties.articulations.remove(articulation) elif split_protocol == "all": # note, if the split protocol is "all", we simply keep the articulation on everything pass # same as above, but for notations for notation in reversed(self.properties.notations): split_protocol = engraving_settings.notation_split_protocols[notation] \ if notation in engraving_settings.notation_split_protocols \ else engraving_settings.notation_split_protocols["default"] if split_protocol == "first": second_part.properties.notations.remove(notation) elif split_protocol == "last": self.properties.notations.remove(notation) elif split_protocol == "both": if self.properties.starts_tie and self.properties.ends_tie: self.properties.notations.remove(notation) if second_part.properties.starts_tie and second_part.properties.ends_tie: second_part.properties.notations.remove(notation) elif split_protocol == "all": pass for spanner in reversed(self.properties.spanners): if spanner.START_MID_OR_STOP == "stop": self.properties.spanners.remove(spanner) else: second_part.properties.spanners.remove(spanner) second_part.properties.texts.clear() second_part.properties.dynamics.clear() # clear all of the text for the second part, since we only need it at the start of the note second_part.properties.texts.clear() # The second part is not explicitly tied to the first by the user (unless otherwise determined) second_part.properties.manual_split_point = False # we also want to keep track of which notes came from the same original note for doing ties and such if "_source_id" in self.properties.temp: second_part.properties.temp["_source_id"] = self.properties.temp["_source_id"] else: second_part.properties.temp["_source_id"] = \ self.properties.temp["_source_id"] = PerformanceNote.next_id() return self, second_part
[docs] def split_at_length_divisions(self) -> Sequence[PerformanceNote]: """ If the self.length is a tuple, indicating a set of tied constituents, splits this into separate PerformanceNotes :return: a list of pieces """ if not hasattr(self.length, "__len__") or len(self.length) == 1: return self, pieces = [self] for piece_length in self.length: last_piece = pieces.pop() # length divisions represent manually split segments that should not be recombined last_piece.properties.manual_split_point = True pieces.extend(last_piece.split_at_beat(last_piece.start_beat + piece_length)) return pieces
[docs] def attempt_chord_merger_with(self, other: PerformanceNote) -> bool: """ Try to merge this note with another note to form a chord. Returns whether it worked or not, and when it did, has the side effect of changing this note into a chord that incorporates the other note. :param other: another PerformanceNote :return: True if the merger works, False otherwise """ assert isinstance(other, PerformanceNote) # to merge, the start time, length, and volume must match and the properties need to be compatible if self.start_beat != other.start_beat or self.length != other.length \ or self.volume != other.volume or not self.properties.chord_mergeable_with(other.properties): return False # since one or both of these notes might already be chords (i.e. have a tuple for pitch), # let's make both pitches into tuples to simplify the logic self_pitches = (self.pitch,) if not isinstance(self.pitch, tuple) else self.pitch other_pitches = (other.pitch,) if not isinstance(other.pitch, tuple) else other.pitch all_pitches_together = self_pitches + other_pitches # check if any of the pitches involved are envelopes (glisses) rather than static pitches if any(isinstance(x, Envelope) for x in all_pitches_together): # if so, then they had all better be envelopes if not all(isinstance(x, Envelope) for x in all_pitches_together): return False # and moreover, they should all be a shifted version of the first pitch # otherwise, we keep them separate; a chord should gliss as a block if it glisses at all if not all(x.is_shifted_version_of(all_pitches_together[0]) for x in all_pitches_together[1:]): return False # if we've made it to here, then the notes are fit to be merged # the one wrinkle is that the notes may not be in pitch order. Let's put them in pitch order, # and sort the noteheads at the same time so that they match up all_start_pitches = [pitch.start_level() if isinstance(pitch, Envelope) else pitch for pitch in all_pitches_together] self.properties.noteheads = [ x for _, x in sorted(zip(all_start_pitches, self.properties.noteheads + other.properties.noteheads)) ] self.properties.spelling_policies = [ x for _, x in sorted(zip(all_start_pitches, self.properties.spelling_policies + other.properties.spelling_policies)) ] self.pitch = tuple(x for _, x in sorted(zip(all_start_pitches, all_pitches_together))) # and return true because we succeeded return True
def __lt__(self, other): # this allows it to be compared with numbers. I use that below to bisect a list of notes if isinstance(other, PerformanceNote): return self.start_beat < other.start_beat else: return self.start_beat < other def __eq__(self, other): if isinstance(other, PerformanceNote): return self.start_beat == other.start_beat else: return self.start_beat == other def _to_dict(self): return { "start_beat": self.start_beat, "length": self.length, "pitch": self.pitch, "volume": self.volume, "properties": self.properties } @classmethod def _from_dict(cls, json_dict): if hasattr(json_dict["pitch"], '__len__'): json_dict["pitch"] = tuple(json_dict["pitch"]) return PerformanceNote(**json_dict) def __repr__(self): return "PerformanceNote(start_beat={}, length={}, pitch={}, volume={}, properties={})".format( self.start_beat, self.length, self.pitch, self.volume, self.properties )
class _NoteFiltersMixin: def apply_note_filter(self, filter_function: Callable[[PerformanceNote], None], start_beat: float = 0, stop_beat: float = None, selected_voices: Sequence[str] = None): """ Applies a filter function to every note in this Performance. This can be used to apply a transformation to the entire Performance on a note-by-note basis. :param filter_function: function taking a PerformanceNote object and modifying it in place :param start_beat: beat to start on :param stop_beat: beat to stop on (None keeps going until the end of the part) :param selected_voices: which voices to take notes from (defaults to all if None) :return: self, for chaining purposes """ for note in self.get_note_iterator(start_beat, stop_beat, selected_voices): filter_function(note) return self def apply_pitch_filter(self, filter_function: Callable[[Envelope | float], Envelope | float], start_beat: float = 0, stop_beat: float = None, selected_voices: Sequence[str] = None): """ Applies a filter function to transform the pitch of every note in this Performance. :param filter_function: function taking a pitch (can be envelope, float, or even a chord tuple) and returning another pitch-like object. If the performance hasn't been quantized and you're not using any glissandi, though, you can assume the pitch is a float. :param start_beat: beat to start on :param stop_beat: beat to stop on (None keeps going until the end of the part) :param selected_voices: which voices to take notes from (defaults to all if None) :return: self, for chaining purposes """ def _note_filter(performance_note): performance_note.pitch = filter_function(performance_note.pitch) self.apply_note_filter(_note_filter, start_beat, stop_beat, selected_voices) return self def transpose(self, interval: float): """ Transposes all notes in this Performance up or down by the desired interval. For greater flexibility, use the :code:`apply_pitch_filter` and :code:`apply_note_filter` methods. :param interval: the interval by which to transpose this Performance :return: self, for chaining purposes """ return self.apply_pitch_filter(lambda p: p + interval) def apply_volume_filter(self, filter_function: Callable[[Envelope | float], Envelope | float], start_beat: float = 0, stop_beat: float = None, selected_voices: Sequence[str] = None): """ Applies a filter function to transform the volume of every note in this Performance. :param filter_function: function taking a volume (can be envelope or float) and returning another volume-like object. If you haven't used any envelopes, though, you can assume the pitch is a float. :param start_beat: beat to start on :param stop_beat: beat to stop on (None keeps going until the end of the part) :param selected_voices: which voices to take notes from (defaults to all if None) :return: self, for chaining purposes """ def _note_filter(performance_note): performance_note.volume = filter_function(performance_note.volume) self.apply_note_filter(_note_filter, start_beat, stop_beat, selected_voices) return self
[docs]class PerformancePart(SavesToJSON, _NoteFiltersMixin): """ Transcription of the notes played by a single :class:`~scamp.instruments.ScampInstrument`. Can be saved to and loaded from a json file and played back on a clock. :param instrument: the ScampInstrument associated with this part; used for playback :param name: The name of this part :param voices: either a list of PerformanceNotes (which is interpreted as one unnamed voice), a list of lists of PerformanceNotes (which is interpreted as several numbered voices), or a dictionary mapping voice names to lists of notes. :param instrument_id: a json serializable record of the instrument used :param voice_quantization_records: a record of how this part was quantized if it has been quantized :ivar instrument: the ScampInstrument associated with this part; used for playback :ivar name: The name of this part :ivar voices: dictionary mapping voice names to lists of notes. :ivar instrument_id: a json serializable record of the instrument used :ivar voice_quantization_records: dictionary mapping voice names to QuantizationRecords, if this is quantized """ def __init__(self, instrument: ScampInstrument = None, name: str = None, voices: dict | Sequence = None, instrument_id: tuple[str, int] = None, voice_quantization_records: dict = None, clef_preference: Sequence[str | tuple[str, Real]] = None): self.instrument = instrument # A ScampInstrument instance self.clef_preference = clef_preference if clef_preference is not None \ else instrument.resolve_clef_preference() if instrument is not None \ else engraving_settings.clefs_by_instrument["default"] # the name of the part can be specified directly, or if not derives from the instrument it's attached to # if the part is not attached to an instrument, it starts with a name of None self.name = name if name is not None else instrument.name if instrument is not None else None # this is used for serializing to and restoring from a json file. It should be enough to find # the instrument in the ensemble, so long as the ensemble is compatible. self._instrument_id = instrument_id if instrument_id is not None else \ ((instrument.name, instrument.name_count) if instrument is not None else None) if voices is None: # no voices specified; create a dictionary with the catch-all voice "_unspecified_" self.voices = {"_unspecified_": []} elif isinstance(voices, dict): self.voices = {} for key, value in voices.items(): # for each key, if it can be parsed as an integer, make sure it's doing so in only one way # this prevents confusion from two voices called "01" and "1" for instance try: self.voices[str(int(key))] = value except ValueError: self.voices[key] = value # make sure that the dict contains the catch-all voice "_unspecified_" if "_unspecified_" not in self.voices: self.voices["_unspecified_"] = [] else: # a single voice or list of voices is given assert hasattr(voices, "__len__") # check if we were given a list of voices if hasattr(voices[0], "__len__"): # if so, just assign them numbers 1, 2, 3, 4... self.voices = {str(i+1): voice for i, voice in enumerate(voices)} # and add the unspecified voice self.voices["_unspecified_"] = [] else: # otherwise, we should have just been given a single list of notes for an unspecified default voice assert all(isinstance(x, PerformanceNote) for x in voices) self.voices = {"_unspecified_": voices} # a record of the quantization that was applied to this part, if any self.voice_quantization_records = voice_quantization_records
[docs] def add_note(self, note: PerformanceNote, voice: str = None) -> PerformanceNote: """ Add a new Performance note to this PerformancePart. :param note: the note to add :param voice: name of the voice to which to add it (defaults to "_unspecified_") :return: the note you just added (for chaining purposes) """ # the voice kwarg here is only used when reconstructing this from a json serialization if voice is not None: # if the voice kwarg is given, use it - it should be a string assert isinstance(voice, str) voice_name = voice elif note.properties.voice is not None: # if the note specifies its voice, use that voice_name = str(note.properties.voice) else: # otherwise, use the catch-all voice "_unspecified_" voice_name = "_unspecified_" # make sure an integer voice is formatted in a unique way try: voice_name = str(int(voice_name)) except ValueError: pass # make sure we have an entry for the desired voice, or create one if not if voice_name not in self.voices: self.voices[voice_name] = [] voice = self.voices[voice_name] last_note_start_beat = voice[-1].start_beat if len(voice) > 0 else 0 voice.append(note) if note.start_beat < last_note_start_beat: # always keep self.notes sorted; if we're appending something that shouldn't be at the # very end, we'll need to sort the list after appending. This probably doesn't come up much. voice.sort() # they are defined to sort by start_beat return note
[docs] def new_note(self, start_beat: float, length, pitch, volume, properties: dict) -> PerformanceNote: """ Construct and add a new PerformanceNote to this Performance :param start_beat: the start beat of the note :param length: length of the note in beats (either a float or a list of floats representing tied segments) :param pitch: pitch of the note (float, Envelope, or list to interpret as an envelope) :param volume: volume of the note (float or Envelope, or list to interpret as an envelope) :param properties: dictionary of note properties, or string representing those properties :return: the note just added """ return self.add_note(PerformanceNote(start_beat, length, pitch, volume, properties))
[docs] def set_instrument(self, instrument: ScampInstrument) -> None: """ Set the instrument with which this PerformancePart will play back by default :param instrument: the instrument to use """ self.instrument = instrument self._instrument_id = instrument.name, instrument.name_count
@property def end_beat(self) -> float: """ End beat of the last note in this part. """ if len(self.voices) == 0: return 0 return max(max(n.start_beat + n.length_sum() for n in voice) if len(voice) > 0 else 0 for voice in self.voices.values())
[docs] def get_note_iterator(self, start_beat: float = 0, stop_beat: float = None, selected_voices: Sequence[str] = None) -> Iterator[PerformanceNote]: """ Returns an iterator returning all the notes from start_beat to stop_beat in the selected voices :param start_beat: beat to start on :param stop_beat: beat to stop on (None keeps going until the end of the part) :param selected_voices: which voices to take notes from (defaults to all if None) :return: an iterator """ # we can be given a list of voices to play, or if none is specified, we play all of them selected_voices = self.voices.keys() if selected_voices is None else selected_voices all_notes = list(itertools.chain(*[self.voices[x] for x in selected_voices])) all_notes.sort() def iterator(): note_index = bisect.bisect_left(all_notes, start_beat) while note_index < len(all_notes) and (stop_beat is None or all_notes[note_index].start_beat < stop_beat): yield all_notes[note_index] note_index += 1 return iterator()
[docs] def play(self, start_beat: float = 0, stop_beat: float = None, instrument: ScampInstrument = None, clock: Clock = None, blocking: bool = True, tempo_envelope: TempoEnvelope = None, selected_voices: Sequence[str] = None, note_filter: Callable[[PerformanceNote], PerformanceNote] = None) -> Clock: """ Play this PerformancePart (or a selection of it) :param start_beat: Place to start playing from :param stop_beat: Place to stop playing at :param instrument: instrument to play back with :param clock: clock to use for playback :param blocking: if True, don't return until the part is done playing; if False, return immediately :param tempo_envelope: (optional) a tempo envelope to use for playback :param selected_voices: which voices to play back (defaults to all if None) :param note_filter: a function that takes the PerformanceNote about to be played and returns a modified PerformanceNote to play. NB: this will modify the original note unless the input to the function is duplicated and left unaltered! :return: the Clock on which playback takes place """ instrument = self.instrument if instrument is None else instrument from scamp.instruments import ScampInstrument if not isinstance(instrument, ScampInstrument): raise ValueError("PerformancePart does not have a valid instrument and cannot play.") clock = Clock(instrument.name + " clock", pool_size=20) if clock is None else clock if not isinstance(clock, Clock): raise ValueError("PerformancePart was given an invalid clock.") stop_beat = self.end_beat if stop_beat is None else stop_beat if not stop_beat >= start_beat: raise ValueError("Stop beat must be after start beat.") def _play_thread(child_clock): note_iterator = self.get_note_iterator(start_beat, stop_beat, selected_voices) self.get_note_iterator(start_beat, stop_beat) try: current_note = next(note_iterator) except StopIteration: return child_clock.wait(current_note.start_beat - start_beat) while True: assert isinstance(current_note, PerformanceNote) if note_filter is not None: note_filter(current_note).play(instrument, clock=child_clock, blocking=False) else: current_note.play(instrument, clock=child_clock, blocking=False) try: next_note = next(note_iterator) child_clock.wait(next_note.start_beat - current_note.start_beat) current_note = next_note except StopIteration: # when done, wait for the children to finish child_clock.wait(current_note.length_sum()) return if blocking: # clock blocked ;-) if tempo_envelope is not None: clock.tempo_history.append_envelope(tempo_envelope) _play_thread(clock) return clock else: sub_clock = clock.fork(_play_thread) if tempo_envelope is not None: sub_clock.tempo_history.append_envelope(tempo_envelope) return sub_clock
[docs] def set_instrument_from_ensemble(self, ensemble: Ensemble) -> PerformancePart: """ Set the default instrument to play back with based on the best fit in the given ensemble :param ensemble: the ensemble to search in :return: self """ self.instrument = ensemble.get_instrument_by_name(*self._instrument_id) if self.instrument is None: logging.warning("No matching instrument could be found for part {}.".format(self.name)) return self
[docs] def write_to_midi_file_track(self, midi_file: MIDIFile, track_num: int, max_channels=16, ring_time=0.5, pitch_bend_range=2, envelope_precision=0.01) -> None: """ Writes the contents of this part to a track of a midiutil.MIDIFile. Used by :func:`Performance.export_to_midi_file`. :param midi_file: a midiutil.MIDIFile :param track_num: which track to write to :param max_channels: maximum number of channels to use for different notes. By default, notes with different pitch bends and cc messages will be placed on different channels to avoid interference. :param ring_time: When multiple channels are used for juggling different pitch bends and cc messages, channels are reused when the notes on them have finished. However, if they're reused right away, this can cause the note that just finished and is perhaps ringing/reverberating to get altered undesirably. ring_time is the amount of time that we wait before reassigning a channel. :param pitch_bend_range: By default +- 2 semitones. If a greater pitch bend is needed, this parameter will scale all pitch bend messages accordingly. It will also attempt to send RPN messages to let the synthesizer know, though in practice many softsynths ignore this and will need to have their pitch bend range set manually. :param envelope_precision: For glissandi, volume curves, and any other parameter that is being given an :class:`~expenvelope.envelope.Envelope`, this is the temporal precision of the corresponding midi events. """ t = 0 note_id_generator = itertools.count() mcm = MIDIChannelManager(max_channels, time_func=lambda: t, ring_time=ring_time) if pitch_bend_range != 2: for chan in range(16): midi_file.addControllerEvent(track_num, chan, 0, 101, 0) midi_file.addControllerEvent(track_num, chan, 0, 100, 0) midi_file.addControllerEvent(track_num, chan, 0, 6, pitch_bend_range) midi_file.addControllerEvent(track_num, chan, 0, 100, 127) event_cue = [] for note_or_chord in self.get_note_iterator(): while len(event_cue) > 0 and event_cue[0][0] <= note_or_chord.start_beat: event = event_cue.pop(0) t = event[0] event[1]() chord_members = [note_or_chord] if not hasattr(note_or_chord.pitch, '__len__') \ else [PerformanceNote(note_or_chord.start_beat, note_or_chord.length, note_or_chord.pitch[i], note_or_chord.volume, note_or_chord.properties) for i in range(len(note_or_chord.pitch))] for note in chord_members: note_id = next(note_id_generator) t = note.start_beat cc_start_values = note.properties.get_midi_cc_start_values() starting_pitch = note.pitch.start_level() if isinstance(note.pitch, Envelope) else note.pitch int_pitch = int(starting_pitch) pitch_bend = "variable" if isinstance(note.pitch, Envelope) else starting_pitch - int_pitch channel = mcm.assign_note_to_channel( note_id, int_pitch, pitch_bend, "variable" if any(isinstance(x, Envelope) for x in note.properties.get_midi_cc_params().values()) else cc_start_values ) # Go through all of cc_start_values and send the appropriate midi messages to get it started # If it's an envelope, then schedule all of the cc messages if isinstance(note.pitch, Envelope): for i in range(int(note.length_sum() / envelope_precision)): midi_file.addPitchWheelEvent( track_num, channel, t + i * envelope_precision, int(max(-8192, min(8191, (note.pitch.value_at( i * envelope_precision) - int_pitch) * 8192 / pitch_bend_range))) ) else: midi_file.addPitchWheelEvent( track_num, channel, t, int(max(-8192, min(8192, pitch_bend * 8192 / pitch_bend_range)))) if isinstance(note.volume, Envelope): start_volume = note.volume.max_level() for i in range(int(note.length_sum() / envelope_precision)): midi_file.addControllerEvent( track_num, channel, t + i * envelope_precision, 11, int(max(0, min(127, (note.volume.value_at(i * envelope_precision) / start_volume) * 127))) ) else: start_volume = note.volume for cc_num, cc_value in note.properties.get_midi_cc_params().items(): if isinstance(cc_value, Envelope): for i in range(int(note.length_sum() / envelope_precision)): midi_file.addControllerEvent( track_num, channel, t + i * envelope_precision, cc_num, int(max(0, min(127, (cc_value.value_at(i * envelope_precision) * 127)))) ) else: midi_file.addControllerEvent( track_num, channel, t, cc_num, int(max(0, min(127, cc_value * 127))) ) midi_file.addNote(track_num, channel, int_pitch, t, note.length_sum(), int(start_volume * 127)) def cutoff_note(which=note_id): mcm.end_note(which) event_cue.append((t + note.length_sum(), cutoff_note)) event_cue.sort(key=lambda cue_event: cue_event[0]) while len(event_cue) > 0: event = event_cue.pop(0) t = event[0] event[1]()
[docs] def quantize(self, quantization_scheme: QuantizationScheme = "default", onset_weighting: float = "default", termination_weighting: float = "default") -> PerformancePart: """ Quantizes this PerformancePart according to the quantization_scheme :param quantization_scheme: the QuantizationScheme to use. If "default", uses the default time signature defined in the quantization_settings. :param onset_weighting: how much to weight note onsets in the quantization. If "default", uses the default value defined in the quantization_settings. :param termination_weighting: how much to weight note terminations in the quantization. If "default", uses the default value defined in the quantization_settings. :return: this PerformancePart, having been quantized """ if quantization_scheme == "default": quantization_scheme = QuantizationScheme.from_time_signature(quantization_settings.default_time_signature) quantize_performance_part(self, quantization_scheme, onset_weighting=onset_weighting, termination_weighting=termination_weighting) return self
[docs] def quantized(self, quantization_scheme: QuantizationScheme = "default", onset_weighting: float = "default", termination_weighting: float = "default") -> PerformancePart: """ Same as quantize, except that it returns a new copy, rather than changing this PerformancePart in place. :param quantization_scheme: the QuantizationScheme to use. If "default", uses the default time signature defined in the quantization_settings. :param onset_weighting: how much to weight note onsets in the quantization. If "default", uses the default value defined in the quantization_settings. :param termination_weighting: how much to weight note terminations in the quantization. If "default", uses the default value defined in the quantization_settings. :return: a quantized copy of this PerformancePart """ if quantization_scheme == "default": quantization_scheme = QuantizationScheme.from_time_signature(quantization_settings.default_time_signature) copy = PerformancePart(instrument=self.instrument, name=self.name, voices=deepcopy(self.voices), instrument_id=self._instrument_id) quantize_performance_part(copy, quantization_scheme, onset_weighting=onset_weighting, termination_weighting=termination_weighting) return copy
[docs] def is_quantized(self) -> bool: """ Checks if this part has been quantized :return: True if quantized, False if not """ return self.voice_quantization_records is not None
def _get_longest_quantization_record(self): # useful if we want to get a sense of all the measures involved and their quantization, # since some voices may only last for a few measures and cut off early if len(self.voice_quantization_records) == 0: return None return max(self.voice_quantization_records.values(), key=lambda quantization_record: len(quantization_record.quantized_measures)) @property def measure_lengths(self) -> Sequence[float]: """ If this PerformancePart has been quantized, gets the lengths of all the measures :return: list of measure lengths """ assert self.is_quantized(), "Performance must be quantized to have measure lengths!" # base it on the longest quantization record return self._get_longest_quantization_record().measure_lengths
[docs] def num_measures(self) -> int: """ If this PerformancePart has been quantized, gets the number of measures :return: number of measures """ assert self.is_quantized(), "Performance must be quantized to have a number of measures" longest_quantization_record = self._get_longest_quantization_record() return 0 if longest_quantization_record is None else \ len(self._get_longest_quantization_record().quantized_measures)
[docs] def to_staff_group(self) -> StaffGroup: """ Converts this PerformancePart to a StaffGroup object. (Quantizes in a default way, if necessary, but it should be quantized already.) :return: a new StaffGroup made from this PerformancePart """ if not self.is_quantized(): logging.warning("PerformancePart was not quantized before calling to_staff_group(); " "quantizing according to default quantization time_signature") quantization_scheme = QuantizationScheme.from_time_signature(quantization_settings.default_time_signature) return self.quantized(quantization_scheme).to_staff_group() return StaffGroup.from_quantized_performance_part(self)
[docs] def name_count(self) -> int: """ When there are multiple instrument of the same name in an ensemble, keeps track of which one we mean :return: int representing which instrument we mean """ return self._instrument_id[1]
def _to_dict(self): return { "name": self.name, "instrument_id": self._instrument_id, "clef_preference": self.clef_preference, "voices": self.voices, "voice_quantization_records": self.voice_quantization_records } @classmethod def _from_dict(cls, json_dict): return cls(**json_dict) def __repr__(self): voice_strings = [ "{}: [\n{}\n]".format("'" + voice_name + "'" if isinstance(voice_name, str) else voice_name, textwrap.indent(",\n".join(str(x) for x in self.voices[voice_name]), " ")) for voice_name in self.voices ] return "PerformancePart(name=\'{}\', instrument_id={}, voices={}{}".format( self.name, self._instrument_id, "{{\n{}\n}}".format(textwrap.indent(",\n".join(voice_strings), " ")), ", quantization_record={})".format(self.voice_quantization_records) if self.voice_quantization_records is not None else ")" )
[docs]class Performance(SavesToJSON, _NoteFiltersMixin): """ Representation of note playback events, usually a transcription of the notes played by an :class:`~scamp.instruments.Ensemble`. Operates in continuous time, without regard to any particular way of notating it. (As opposed to a :class:`~scamp.score.Score`, which represents the notated music.) :param parts: list of parts (:class:`PerformancePart` objects) to start with (defaults to empty list) :param tempo_envelope: a tempo_envelope to associate with this performance :ivar parts: list of parts (:class:`PerformancePart` objects) in this Performance :ivar tempo_envelope: the tempo_envelope associated this performance and used for playback by default """ def __init__(self, parts: Sequence[PerformancePart] = None, tempo_envelope: TempoEnvelope = None): self.parts = [] if parts is None else parts self.tempo_envelope = TempoEnvelope() if tempo_envelope is None else tempo_envelope assert isinstance(self.parts, list) and all(isinstance(x, PerformancePart) for x in self.parts)
[docs] def new_part(self, instrument: ScampInstrument = None) -> PerformancePart: """ Construct and add a new PerformancePart to this Performance :param instrument: the instrument to use as a default for playing back the part :return: the newly constructed part """ new_part = PerformancePart(instrument) self.parts.append(new_part) return new_part
[docs] def add_part(self, part: PerformancePart) -> None: """ Add the given PerformancePart to this performance :param part: a PerformancePart to add """ self.parts.append(part)
[docs] def get_part_by_index(self, index: int) -> PerformancePart: """ Get the part with the given index (Parts are numbered starting with 0, in order that they are added/created.) :param index: the index of the part in question :return: the PerformancePart """ return self.parts[index]
[docs] def get_parts_by_name(self, name: str) -> Sequence[PerformancePart]: """ Get all parts with the given name :param name: the part name to search for :return: a list of parts with this name """ return [x for x in self.parts if x.name == name]
[docs] def get_parts_by_instrument(self, instrument: ScampInstrument) -> Sequence[PerformancePart]: """ Get all parts with the given instrument :param instrument: the instrument to search for :return: a list of parts with this instrument """ return [x for x in self.parts if x.instrument == instrument]
@property def end_beat(self) -> float: """ The end beat of this performance. (i.e. the beat corresponding to the end of the last note) :return: float representing the beat at which all notes are done playing """ return max(p.end_beat for p in self.parts)
[docs] def length(self) -> float: """ Total length of this performance. (Identical to Performance.end_beat) :return: float representing the total length of the Performance """ return self.end_beat
[docs] def get_note_iterator(self, start_beat: float = 0, stop_beat: float = None, selected_voices: Sequence[str] = None) -> Iterator[PerformanceNote]: """ Returns an iterator returning all the notes from start_beat to stop_beat in the selected voices, in all parts. In order of start time, jumping from part to part as needed. :param start_beat: beat to start on :param stop_beat: beat to stop on (None keeps going until the end of the part) :param selected_voices: which voices to take notes from (defaults to all if None) :return: an iterator """ part_note_iterators = [p.get_note_iterator(start_beat, stop_beat, selected_voices) for p in self.parts] next_notes = [] for part_note_iterator in part_note_iterators: try: next_notes.append(next(part_note_iterator)) except StopIteration: next_notes.append(None) while not all(x is None for x in next_notes): note_to_pop = min(range(len(next_notes)), key=lambda i: next_notes[i].start_beat if next_notes[i] is not None else float("inf")) yield next_notes[note_to_pop] try: next_notes[note_to_pop] = next(part_note_iterators[note_to_pop]) except StopIteration: next_notes[note_to_pop] = None
[docs] def remap_to_tempo(self, tempo: TempoEnvelope | float): """ Remaps this performance to use the given tempo or tempo envelope. All notes will happen at the same time, but on different beats. :param tempo: the new tempo envelope to use :return: self, for chaining purposes """ tempo_envelope = tempo if isinstance(tempo, TempoEnvelope) else TempoEnvelope(tempo) for part in self.parts: for voice in part.voices: for note in part.voices[voice]: original_start_beat = note.start_beat note.start_beat = tempo_envelope.beat_at_time(self.tempo_envelope.time_at_beat(note.start_beat)) if isinstance(note.length, tuple): division_points = [original_start_beat + x for x in itertools.accumulate(note.length)] note.length = tuple( tempo_envelope.beat_at_time(self.tempo_envelope.time_at_beat(x)) - note.start_beat for x in division_points ) else: note.length = tempo_envelope.beat_at_time(self.tempo_envelope.time_at_beat( original_start_beat + note.length)) - note.start_beat if isinstance(note.pitch, Envelope): note.pitch.normalize_to_duration(note.length_sum()) if isinstance(note.volume, Envelope): note.volume.normalize_to_duration(note.length_sum()) for param, value in note.properties.extra_playback_parameters.items(): if isinstance(param, Envelope): value.normalize_to_duration(note.length_sum()) self.tempo_envelope = tempo_envelope return self
[docs] def play(self, start_beat: float = 0, stop_beat: float = None, ensemble: Ensemble = "auto", clock: Clock = "auto", blocking: bool = True, tempo_envelope: TempoEnvelope = "auto", note_filter: Callable[[PerformanceNote], PerformanceNote] = None) -> Clock: """ Play back this Performance (or a selection of it) :param start_beat: Place to start playing from :param stop_beat: Place to stop playing at :param ensemble: The Ensemble whose instruments to use for playback. If "auto", checks to see if we are operating in a particular Session, and uses those instruments if so. :param clock: clock to use for playback :param blocking: if True, don't return until the part is done playing; if False, return immediately :param tempo_envelope: the TempoEnvelope with which to play back this performance. The default value of "auto" uses the tempo_envelope associated with the performance, and None uses a flat tempo of rate 60bpm :param note_filter: a function that takes the PerformanceNote about to be played and returns a modified PerformanceNote to play. NB: this will modify the original note unless the input to the function is duplicated and left unaltered! :return: the clock on which this performance is playing back """ if clock == "auto": clock = current_clock() if ensemble == "auto": # using the given clock (or the current clock on this thread as a fallback)... c = clock if isinstance(clock, Clock) else current_clock() # ... see if that clock is an Ensemble (and therefore probably a Session) if c is not None and isinstance(c.master, Ensemble): # and if so, use that as to set the instruments ensemble = c.master elif isinstance(ensemble, Sequence): ensemble = Ensemble(instruments=ensemble) if ensemble is not None: self.set_instruments_from_ensemble(ensemble, override=False) # if not given a valid clock, create one if not isinstance(clock, Clock): clock = Clock() if tempo_envelope == "auto": tempo_envelope = self.tempo_envelope if stop_beat is None: stop_beat = max(p.end_beat for p in self.parts) def _performance_playback(performance_playback_clock): for p in self.parts: p.play(start_beat, stop_beat, clock=performance_playback_clock, blocking=False, tempo_envelope=tempo_envelope, note_filter=note_filter) performance_playback_clock.wait_for_children_to_finish() if blocking: _performance_playback(clock) return clock else: return clock.fork(_performance_playback)
[docs] def set_instruments_from_ensemble(self, ensemble: Ensemble, override: bool = True) -> Performance: """ Set the playback instruments for each part in this Performance by their best match in the ensemble given. If override is False, only set the instrument for parts that don't already have one set. :param ensemble: the Ensemble in which to search for instruments :param override: Whether or not to override any instruments already assigned to parts :return: self """ for part in self.parts: if override or part.instrument is None: part.set_instrument_from_ensemble(ensemble) return self
[docs] def quantize(self, quantization_scheme: QuantizationScheme = "default", onset_weighting: float = "default", termination_weighting: float = "default") -> Performance: """ Quantizes all parts according to the quantization_scheme :param quantization_scheme: the QuantizationScheme to use. If "default", uses the default time signature defined in the quantization_settings. :param onset_weighting: how much to weight note onsets in the quantization. If "default", uses the default value defined in the quantization_settings. :param termination_weighting: how much to weight note terminations in the quantization. If "default", uses the default value defined in the quantization_settings. :return: this Performance, having been quantized """ if quantization_scheme == "default": logging.warning("No quantization scheme given; quantizing according to default time signature.") quantization_scheme = QuantizationScheme.from_time_signature(quantization_settings.default_time_signature) for part in self.parts: part.quantize(quantization_scheme, onset_weighting=onset_weighting, termination_weighting=termination_weighting) return self
[docs] def quantized(self, quantization_scheme: QuantizationScheme = "default", onset_weighting: float = "default", termination_weighting: float = "default") -> Performance: """ Same as quantize, except that it returns a new copy, rather than changing this Performance in place. :param quantization_scheme: the QuantizationScheme to use. If "default", uses the default time signature defined in the quantization_settings. :param onset_weighting: how much to weight note onsets in the quantization. If "default", uses the default value defined in the quantization_settings. :param termination_weighting: how much to weight note terminations in the quantization. If "default", uses the default value defined in the quantization_settings. :return: a quantized copy of this Performance """ if quantization_scheme == "default": quantization_scheme = QuantizationScheme.from_time_signature(quantization_settings.default_time_signature) return Performance([part.quantized(quantization_scheme, onset_weighting=onset_weighting, termination_weighting=termination_weighting) for part in self.parts], tempo_envelope=self.tempo_envelope)
[docs] def is_quantized(self) -> bool: """ Checks if this Performance has been quantized :return: True if all parts are quantized, False if not """ return all(part.is_quantized() for part in self.parts)
[docs] def num_measures(self) -> int: """ If this Performance has been quantized, gets the number of measures :return: number of measures """ return max(part.num_measures() for part in self.parts)
[docs] def export_to_midi_file(self, output_file, flatten_tempo_changes=False, max_channels: int = 16, ring_time: float = 0.5, pitch_bend_range: float = 2, envelope_precision: float = 0.01, tempo_precision: float = 0.1): """ Exports the Performance to a MIDI file. :param output_file: path of the MIDI file to create and write to. :param flatten_tempo_changes: If True, then everything is flattened to a tempo of 60bpm, and notes at a faster tempo are simply made shorter. If False, tempo changes are exported as part of the MIDI file. :param max_channels: maximum number of channels to use for different notes. By default, notes with different pitch bends and cc messages will be placed on different channels to avoid interference. :param ring_time: When multiple channels are used for juggling different pitch bends and cc messages, channels are reused when the notes on them have finished. However, if they're reused right away, this can cause the note that just finished and is perhaps ringing/reverberating to get altered undesirably. ring_time is the amount of time that we wait before reassigning a channel. :param pitch_bend_range: By default +- 2 semitones. If a greater pitch bend is needed, this parameter will scale all pitch bend messages accordingly. It will also attempt to send RPN messages to let the synthesizer know, though in practice many softsynths ignore this and will need to have their pitch bend range set manually. :param envelope_precision: For glissandi, volume curves, and any other parameter that is being given an :class:`~expenvelope.envelope.Envelope`, this is the temporal precision of the corresponding midi events. :param tempo_precision: if flatten_tempo_changes is False, then this determines the precision of tempo change MIDI events during gradual accelerandi/ritardandi. """ midi_file = MIDIFile(len(self.parts)) if flatten_tempo_changes: self.remap_to_tempo(60) midi_file.addTempo(0, 0, 60) else: for segment in self.tempo_envelope.segments: if segment.start_level == segment.end_level: midi_file.addTempo(0, segment.start_time, self.tempo_envelope.tempo_at(segment.start_time)) else: for i in range(int(segment.duration / tempo_precision)): t = segment.start_time + i * tempo_precision midi_file.addTempo(0, t, self.tempo_envelope.tempo_at(t)) midi_file.addTempo(0, self.tempo_envelope.end_time(), self.tempo_envelope.tempo_at(self.tempo_envelope.end_time())) for i, part in enumerate(self.parts): part.write_to_midi_file_track(midi_file, i, max_channels=max_channels, ring_time=ring_time, pitch_bend_range=pitch_bend_range, envelope_precision=envelope_precision) if hasattr(output_file, 'write'): midi_file.writeFile(output_file) else: with open(output_file, "wb") as output_file: midi_file.writeFile(output_file)
[docs] def to_score(self, quantization_scheme: QuantizationScheme = None, time_signature: str | Sequence = None, bar_line_locations: Sequence[float] = None, max_divisor: int = None, max_divisor_indigestibility: int = None, simplicity_preference: float = None, title: str = "default", composer: str = "default") -> Score: """ Convert this Performance (list of note events in continuous time and pitch) to a Score object, which represents the music in traditional western notation. In the process, the music must be quantized, for which two different options are available: one can either pass a QuantizationScheme to the first argument, which is very flexible but rather verbose to create, or one can specify arguments such as time signature and max divisor directly. :param quantization_scheme: The quantization scheme to be used when converting this performance into a score. If this is defined, none of the other quantization-related arguments should be defined. :param time_signature: the time signature to be used, represented as a string, e.g. "3/4", or a tuple, e.g. (3, 2). Alternatively, a list of time signatures can be given. If this list ends in "loop", then the pattern specified by the list will be looped. For example, ["4/4", "2/4", "3/4", "loop"] will cause the fourth measure to be in "4/4", the fifth in "2/4", etc. If the list does not end in "loop", all measures after the final time signature specified will continue to be in that time signature. :param bar_line_locations: As an alternative to defining the time signatures, a list of numbers representing the bar line locations can be given. For instance, [4.5, 6.5, 8, 11] would result in bars of time signatures 9/8, 2/4, 3/8, and 3/4 :param max_divisor: The largest divisor that will be allowed to divide the beat. :param max_divisor_indigestibility: Indigestibility, devised by composer Clarence Barlow, is a measure of the "primeness" of a beat divisor, and therefore of its complexity from the point of view of a performer. For instance, it is easier to divide a beat in 8 than in 7, even though 7 is a smaller number. See Clarence's paper here: https://mat.ucsb.edu/Publications/Quantification_of_Harmony_and_Metre.pdf. By setting a max indigestibility, we can allow larger divisions of the beat, but only so long as they are easy ones. For instance, a max_divisor of 16 and a max_divisor_indigestibility of 8 would allow the beat to be divided in 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, and 16. :param simplicity_preference: This defines the degree to which the quantizer will favor simple divisors. The higher the simplicity preference, the more precisely the notes have to fit for you to get a divisor like 7. Simplicity preference can range from 0 (in which case the divisor is chosen purely based on the lowest error) to infinity, with a typical value somewhere around 1. :param title: Title of the piece to be printed on the score. :param composer: Composer of the piece to be printed on the score. :return: the resulting Score object, which can then be rendered either as XML or LilyPond """ return Score.from_performance( self, quantization_scheme, time_signature=time_signature, bar_line_locations=bar_line_locations, max_divisor=max_divisor, max_divisor_indigestibility=max_divisor_indigestibility, simplicity_preference=simplicity_preference, title=title, composer=composer )
def _to_dict(self): return {"parts": self.parts, "tempo_envelope": self.tempo_envelope} @classmethod def _from_dict(cls, json_dict): return cls(**json_dict) def __repr__(self): return "Performance([\n{}\n])".format( textwrap.indent(",\n".join(str(x) for x in self.parts), " ") )