Source code for maelzel.core.eventbase

from __future__ import annotations

from emlib import mathlib
from maelzel.common import F
from maelzel.core.mobj import MObj, MContainer
import maelzel.core.symbols as _symbols
from maelzel.core.synthevent import PlayArgs
from maelzel.scoring import definitions
from maelzel import _util
from maelzel.scorestruct import ScoreStruct
from maelzel.core import workspace

from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from typing_extensions import Self
    from typing import Any, Callable
    from ._typedefs import time_t, location_t, num_t


[docs] class MEvent(MObj): """ A discrete event in time (a Note, Chord, etc) """ __slots__ = ('tied', 'amp', 'dynamic', '_glissTarget') def __init__(self, dur: F, offset: F = None, amp: float | None = None, parent: MContainer = None, properties: dict[str, Any] = None, symbols: list[_symbols.Symbol] = None, label='', dynamic='', tied=False): if not isinstance(dur, F): raise ValueError(f"Invalid (None) duration for {self}") super().__init__(dur=dur, offset=offset, label=label, parent=parent, properties=properties, symbols=symbols) self.tied: bool = tied """Is this event tied?""" self.amp: float | None = amp "The playback amplitude 0-1 of this note" if dynamic: if dynamic.endswith('!'): dynamic = dynamic[:-1] self.addSymbol(_symbols.Dynamic(dynamic, force=True)) assert dynamic in definitions.dynamicLevels self.dynamic: str = dynamic self._glissTarget: float = 0.
[docs] def linkedNext(self) -> bool: """ Is this event linked to the next? An event is linked to a next event if it is tied or has glissando set to True where this applies. This is not the case if the event has a gliss value set to other than True """ return self.tied or (self.gliss is True)
[docs] def linkedPrev(self) -> bool: """ Is this event linked to the previous? """ if not self.parent: return False prev = self.parent.previousEvent(self) return prev.linkedNext() if prev else False
@property def gliss(self): """The end target of this event, if any""" return False
[docs] def isRest(self) -> bool: """Is this a rest?""" return False
[docs] def isGracenote(self) -> bool: """ Is this a grace note? A grace note has a pitch but no duration Returns: True if this can be considered a grace note """ return not self.isRest() and self.dur == 0
def _asVoices(self) -> list[chain.Voice]: from maelzel.core.chain import Voice return [Voice([self])]
[docs] def addSymbol(self, *args, **kws) -> Self: """ Add a notation symbol to this object Notation symbols are attributes attached to **one event** and are intended for **notation only**. Such symbols include articulations, ornaments, fermatas but also properties, like color, size, etc. Also customizations like notehead shape, bend signs, all are considered symbols. Notation symbols spanning across multiple events (like slurs, crescendo hairpins, lines, etc.) are considered *spanners* and are added via :meth:`~MObj.addSpanner` Some symbols are exclusive, meaning that adding a symbol of this kind will replace a previously set symbol. Exclusive symbols include any properties (color, size, etc) and other customizations like notehead shape .. note:: Dynamics are not treated as symbols since they can also be used for playback Example ------- >>> from maelzel.core import * >>> n = Note(60) >>> n.addSymbol(symbols.Articulation('accent')) # The same can be achieved via keyword arguments: >>> n.addSymbol(articulation='accent') # Multiple symbols can be added at once: >>> n = Note(60).addSymbol(text='dolce', articulation='tenuto') >>> n2 = Note("4G").addSymbol(symbols.Articulation('accent'), symbols.Ornament('mordent')) # Known symbols - most common symbols don't actually need keyword arguments: >>> n = Note("4Db").addSymbol('accent').addSymbol('fermata') # Some symbols can take customizations: >>> n3 = Note("4C+:1/3").addSymbol(symbols.Harmonic(interval='4th')) Returns: self (similar to setPlay, allows to chain calls) ============ ========================================================== Symbol Possible Values ============ ========================================================== text any text notehead cross, harmonic, triangleup, xcircle, triangle, rhombus, square, rectangle articulation accent, staccato, tenuto, marcato, staccatissimo, etc. size A relative size (0=default, 1, 2, …=bigger, -1, -2, … = smaller) color a css color ============ ========================================================== """ symbol = _symbols.parseAddSymbol(args, kws) self._addSymbol(symbol) if isinstance(symbol, _symbols.Spanner): symbol.setAnchor(self) elif isinstance(symbol, _symbols.EventSymbol): if errormsg := symbol.checkAnchor(self): raise ValueError(f"Cannot add this symbol to {self}: {errormsg}") return self
def _canBeLinkedTo(self, other: MEvent) -> bool: """ Can self be linked to *other* within a playback line, assuming other follows self? A line is a sequence of events (notes, chords) where one is linked to the next by either being tied, a gliss leading to the next pitch, or a portamento (an implicit glissano) This method should not take offset time into account: it should simply return if self can be linked to other assuming that other follows self """ raise NotImplementedError
[docs] def mergeWith(self, other: MEvent) -> Self | None: """ Merge this with other, return None if not possible Args: other: the event to concatenato to this. Only events of the same type can be merged (a Note with a Note, a Chord with a Chord) Returns: the merged event, or None """ raise NotImplemented
@property def name(self) -> str: """A string representing this event""" raise NotImplementedError('Subclass should implement this')
[docs] def cropped(self, start: beat_t, end: beat_t ) -> Self: """ A copy of Self, cropped to the given time range The returned event will have an explicit offset set to its absolute offset. It is parentless Args: start: the start location to crop this event end: the end location to crop this event Returns: the cropped event """ scorestruct = self.scorestruct(resolve=True) startbeat = start if isinstance(start, F) else scorestruct.asBeat(start) endbeat = end if isinstance(end, F) else scorestruct.asBeat(end) absoffset = self.absOffset() if intersect := _util.intersectF(startbeat, endbeat, absoffset, absoffset + self.dur): intersect0, intersect1 = intersect return self.clone(offset=intersect0, dur=intersect1 - intersect0) else: raise ValueError(f"No intersection between {self} and the given time range " f"({start=}, {end=}")
[docs] def splitAt(self, offset: beat_t, tie=True, nomerge=False ) -> tuple[Self, ...]: """ Split this event at the given absolute offset Args: offset: the absolute offset at which to split this event. Can be a beat or a location tie: tie the parts nomerge: if True, adds a break symbol to the events resulted in the split operation to prevent them from being merged when converted to notation Returns: a tuple with the parts. If the offset lies perfectly at the start or end of this event, only one part will be returned. If the offset does not intersect the event, ValueError is raised Example ------- >>> n = Note(60, 4) >>> n.splitAt(2) TODO """ parts = self.splitAtOffsets([offset], tie=tie, nomerge=nomerge) if not parts: raise ValueError(f"Offset {offset} does not intersect {self}") return tuple(parts)
[docs] def splitAtOffsets(self, offsets: list[time_t], tie=True, nomerge=False ) -> list[Self]: """ Split this event at the given offsets Args: offsets: absolute offsets. To use score locations, convert those to absolute offsets via :meth:`scorestruct.locationToBeat <maelzel.scorestruct.ScoreStruct.locationToBeat>` tie: if True, tie the parts nomerge: if True, adds a break symbol to the events resulted in the split operation to prevent them from being merged when converted to notation Returns: the parts. The total duration of the parts should sum up to the duration of self """ if not offsets: raise ValueError("No offsets given") offset = self.absOffset() dur = self.dur if offset >= offsets[-1] or offset + dur <= offsets[0]: return [self] intervals = _util.splitInterval(offset, offset + dur, offsets) events = [self.clone(dur=end-start, offset=None) for start, end in intervals] events[0].offset = self.offset if tie and len(events) > 1: for event in events[:-1]: event.tied = True if nomerge: for event in events[1:]: event.addSymbol(_symbols.NoMerge()) return events
[docs] def addSpanner(self, spanner: str | _symbols.Spanner, endobj: MEvent = None ) -> Self: """ Adds a spanner symbol to this object A spanner is a slur, line or any other symbol attached to two or more objects. A spanner always has a start and an end. Args: spanner: a Spanner object or a spanner description (one of 'slur', '<', '>', 'trill', 'bracket', etc. - see :func:`maelzel.core.symbols.makeSpanner` When passing a string description, prepend it with '~' to create an end spanner endobj: the object where this spanner ends, if known Returns: self (allows to chain calls) Example ~~~~~~~ >>> from maelzel.core import * >>> a = Note("4C") >>> b = Note("4E") >>> c = Note("4G") >>> a.addSpanner('slur', c) >>> chain = Chain([a, b, c]) .. seealso:: :meth:`Spanner.bind() <maelzel.core.symbols.Spanner.bind>` In some cases the end target can be inferred: >>> chain = Chain([ ... Note("4C", 1, dynamic='p').addSpanner("<"), ... Note("4D", 0.5), ... Note("4E", dynamic='f') # This ends the hairpin spanner ... ]) Or it can be set later >>> chain = Chain([ ... Note("4C", 1).addSpanner("slur"), ... Note("4D", 0.5), ... Note("4E").addSpanner("~slur") # This ends the last slur spanner ... ]) """ if isinstance(spanner, str): if spanner.startswith('~'): spanner = spanner[1:].lower() kind = 'end' else: kind = 'start' spanner = _symbols.makeSpanner(spanner.lower(), kind=kind) assert isinstance(spanner, _symbols.Spanner) if endobj is not None: assert spanner.kind == 'start' spanner.bind(self, endobj) else: self.addSymbol(spanner) spanner.setAnchor(self) return self
[docs] def timeTransform(self, timemap: Callable[[F], F], inplace=False ) -> Self: """ Apply a transformation to the time axes of this Args: timemap: a callable mapping time (in quarterbeats) to time (in quarterbeats) inplace: Returns: """ offset = self.relOffset() dur = self.dur offset2 = timemap(offset) dur2 = timemap(offset + dur) - offset2 if inplace: self.offset = offset2 self.dur = dur2 self._changed() return self else: return self.clone(offset=offset2, dur=dur2)
[docs] def automate(self, param: str, breakpoints: list[tuple[time_t | location_t, float]] | list[tuple[time_t|location_t, float, str]] | list[num_t], interpolation='linear', relative=True, ) -> None: """ Add an automation action to this event Args: param: the playback parameter to modify, either a builtin parameter like position, or an instrument defined parameter (for example, an instrument based on substractive synthesis could define a 'filterattack' parameter, or a vocal synthesis instrument could define a 'vibratoamount' parameter) breakpoints: the data, a list of pairs in the form (time, value), or (time, value, interpolation). time is given in quarternotes or as a location (measure, beatoffset); value is any valid valud for the given parameter; interpolation is one of 'linear', 'cos'. As a shortcut it is possible to also pass a flat list of the form [time0, value0, time1, value1, ...]. A single point is also possible. This sets the value for the given param at the specified time interpolation: default interpolation used for breakpoints without interpolation relative: if True, the time positions are relative to the absolute offset of this event. If False, these times are absolute times Example ~~~~~~~ .. code:: note = Note("4c", 10) # Automate position starting at beat 5 after this event has started note.automate('position', [(5, 0.), (6, 1.)], relative=True) # The same data can be given as a flat list note.automate('position', [5, 0., 6, 1.]) # Time position can be also given as a tuple (measure num, beat offset), # and the time mode can be set to absolute # In this case, this automation indicates a modification of the # pan position, from 0 to 1 starting at the 4th measure (index 3) and # ending at the 5th measure (index 4) note.automate('position', [(3, 0), 0., (4, 0), 1.], relative=False) Any dynamic parameter can be automated: .. code:: # Define a preset with some dynamic parameter defPreset('detuned', ''' |kdetune=2| aout1 = oscili:a(kamp, kfreq) + oscili:a(kamp, kfreq+kdetune) ''') # Automate the kdetune param. When automating a Note/Chord, the times # are given in quarterbeats, which means that the real time in seconds # depend on the tempo structure. In this case the kdetune param will # be shifted from 0 to 20 starting at the moment the note is played # and ending 4 **quarterbeats** after. note.automate('kdetune', (0, 0, 4, 20)) # When the note is actually played the automation takes effect synth = note.play(instr='detuned') # The synth itself can be automated. In this case, we are already # in the real-time realm and any times are given in seconds. If # needed the active scorestruct can be used to convert between # quarterbeats and seconds synth.automate('position', (2, 0.5, 4, 1)) """ if self.playargs is None: self.playargs = PlayArgs() self.playargs.addAutomation(param=param, breakpoints=breakpoints, interpolation=interpolation, relative=relative)