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 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)