"""
Musical Objects
---------------
Time
~~~~
A MObj has always an offset and dur attribute. The offset can be unset (None).
They refer to an abstract time. When visualizing a MObj as musical notation
these times are interpreted/converted to beats and score locations based on
a score structure.
"""
#
# Internal notes
#
# offset
#
# Each object has an offset. This offset can be None if not explicitly set
# by the object itself. A cached offset, ._resolvedOffset, can be set by
# either the object itself or by the parent
#
# dur
#
# Each object has a duration (.dur). The duration is always explicit. It is implemented
# as a property since it might be calculated.
from __future__ import annotations
import functools
from abc import ABC, abstractmethod
import os
import math
import re
import html as _html
from dataclasses import dataclass
from maelzel.common import asmidi, F, asF, F0
from maelzel.core._common import logger
from .config import CoreConfig
from .workspace import Workspace
from . import environment
from . import realtimerenderer
from . import notation
from . import _tools
from maelzel.textstyle import TextStyle
from . import symbols as _symbols
from .synthevent import PlayArgs, SynthEvent
from maelzel import _util
from maelzel import scoring
import typing as _t
if _t.TYPE_CHECKING:
from typing_extensions import Self
from matplotlib.axes import Axes
# from . import symbols as _symbols
from maelzel.common import location_t, beat_t, time_t, num_t
from maelzel.core import chain
from maelzel.scoring.renderoptions import RenderOptions
from maelzel.scoring.render import Renderer
from maelzel.scoring import quant
from maelzel.scoring import enharmonics
import csoundengine
import csoundengine.synth
from . import offline
from maelzel.scorestruct import ScoreStruct
__all__ = (
'MObj',
'MContainer',
'clearImageCache',
)
@dataclass
class _TimeScale:
factor: F
offset: F
def __call__(self, t: num_t):
r = asF(t)
return r*self.factor + self.offset
@dataclass
class _PostSymbol:
symbol: _symbols.Symbol
"""A symbol or spanner"""
offset: F
"""location to apply the symbol"""
end: F | None = None
"""Only needed for spanners, end location"""
[docs]
class MObj(ABC):
"""
This is the base class for all core objects.
This is an abstract class. **It should not be instantiated by itself**.
A :class:`MObj` can display itself via :meth:`show` and play itself via :meth:`play`.
Any MObj has a duration (:attr:`dur`) and a time offset (:attr:`offset`). The offset
can be left as ``None``, indicating that it is not explicitely set, in which case it
will be calculated from the context. In the case of events or chains, which can be
contained within other objects, the offset depends on the previous objects. The
resolved (implicit) offset can be queried via :meth:`MObj.relOffset`. This offset
is relative to the parent, or an absolute offset if the object has no parent. The absolute
offset can be queried via :meth:`MObj.absOffset`.
A :class:`MObj` can customize its playback via :meth:`setPlay`. The playback attributes can
be accessed through the `playargs` attribute
Elements purely related to notation (text annotations, articulations, etc)
are added to a :class:`MObj` through the :meth:`addSymbol` and can be accessed
through the :attr:`MObj.symbols` attribute. A symbol is an attribute or notation
element (like color, size or an attached text expression) which has meaning only
in the realm of graphical representation.
Args:
dur: the duration of this object, in quarternotes
offset: the (optional) time offset of this object, in quarternotes
label: a string label to identify this object, if necessary
"""
_acceptsNoteAttachedSymbols = True
_isDurationRelative = True
__slots__ = ('parent', '_dur', 'offset', 'label', 'playargs', 'symbols',
'properties', '_resolvedOffset',
'__weakref__')
def __init__(self,
dur: F,
offset: F | None = None,
label='',
parent: MContainer | None = None,
properties: dict[str, _t.Any] | None = None,
symbols: list[_symbols.Symbol] | None = None):
if offset is not None and offset < F0:
raise ValueError(f"Invalid offset: {offset}")
if dur is None or dur < F0:
raise ValueError(f"Invalid duration: {dur}")
self.parent: MContainer | None = parent
"The parent of this object (or None if it has no parent)"
self.label: str = label
"a label can be used to identify an object within a group of objects"
self._dur: F = dur
"the duration of this object in quarternotes. It cannot be None"
self.offset: F | None = offset
"""Optional offset, in quarternotes. Specifies the start time relative to its parent
It can be None, which indicates that within a container this object would
start after the previous object. For an object without a parent, the offset
is an absolute offset. """
self.symbols: list[_symbols.Symbol] | None = symbols
"A list of all symbols added via :meth:`addSymbol` (None by default)"
# playargs are set via .setPlay and serve the purpose of
# attaching playing parameters (like pan position, instrument)
# to an object
self.playargs: PlayArgs | None = None
"playargs are set via :meth:`.setPlay` and are used to customize playback (instr, gain, …). None by default"
self.properties: dict[str, _t.Any] | None = properties
"""
User-defined properties as a dict (None by default). Set them via :meth:`~maelzel.core.mobj.MObj.setProperty`
"""
self._resolvedOffset: F | None = None
@abstractmethod
def __hash__(self) -> int: ...
@property
def dur(self) -> F:
"""The duration of this object, in quarternotes"""
return self._dur
@dur.setter
def dur(self, dur: time_t):
self._dur = asF(dur)
self._changed()
def _copyAttributesTo(self, other: Self) -> None:
"""
Copy symbols, playargs and properties to other
Args:
other: destination object
"""
if type(other) is not type(self):
logger.warning(f"Copying attributes to an object of different class, "
f"{self=}, {type(self)=}, {other=}, {type(other)=}")
if self.symbols is not None:
other.symbols = self.symbols.copy()
if self.playargs is not None:
other.playargs = self.playargs.copy()
if self.properties is not None:
other.properties = self.properties.copy()
[docs]
def setProperty(self, key: str, value) -> Self:
"""
Set a property, returns self
An MObj can have user-defined properties. These properties are optional:
before any property is created the :attr:`.properties <properties>` attribute
is ``None``. This method creates the dict if it is still None and
sets the property.
Args:
key: the key to set
value: the value of the property
Returns:
self (similar to setPlay or setSymbol, to allow for chaining calls)
Example
~~~~~~~
>>> from maelzel.core import *
>>> n = Note("4C", 1)
>>> n.setProperty('foo', 'bar')
4C:1♩
>>> # To query a property do:
>>> n.getProperty('foo')
bar
>>> # Second Method: query the properties attribute directly.
>>> # WARNING: if no properties were set, this attribute would be None
>>> if n.properties:
... foo = n.properties.get('foo')
... print(foo)
bar
.. seealso:: :meth:`~MObj.getProperty`, :attr:`~MObj.properties`
"""
if self.properties is None:
self.properties = {key: value}
else:
self.properties[key] = value
return self
[docs]
def getPlay(self, key: str, default=None, recursive=True):
"""
Get a playback attribute previously set via :meth:`MObj.setPlay`
All locally set playback attributes are accessible via the
:attr:`MEvent.playargs` attribute. This method checks
not only the locally set attributes, but any attribute set
by the parent
Args:
key: the key (see setPlay for possible keys)
default: the value to return if the given key has not been set
recursive: if True, search the given attribute up the parent chain
Returns:
either the value previously set, or default otherwise.
"""
if self.playargs and (value := self.playargs.db.get(key)) is not None:
return value
if not recursive or not self.parent:
return default
return self.parent.getPlay(key, default=default, recursive=True)
[docs]
def getProperty(self, key: str, default=None):
"""
Get a property of this objects
An MObj can have multiple properties. A property is a key:value pair,
where the key is a string and the value can be anything. Properties can
be used to attach information to an object, to tag it in any way needed.
Properties are set via :meth:`MObj.setProperty`. The :attr:`MObj.properties`
attribute can be queries directly, but bear in mind that **if no properties have
been set, this attribute is ``None`` by default**.
Args:
key: the property to query
default: returned value if the property has not been set
Returns:
the value of the property, or the default value
.. seealso:: :meth:`setProperty() <maelzel.core.mobj.MObj.setProperty>`, :attr:`properties <maelzel.core.mobj.MObj.properties>`
"""
if not self.properties:
return default
return self.properties.get(key, default)
[docs]
def meanPitch(self) -> float | None:
"""
The mean pitch of this object
Returns:
The mean pitch of this object
"""
pitchrange = self.pitchRange()
if pitchrange is None:
return None
minpitch, maxpitch = pitchrange
return (maxpitch + minpitch) / 2.
[docs]
def pitchRange(self) -> tuple[float, float] | None:
"""
The pitch range of this object, if applicable
This is useful in order to assign this object to a proper Voice
when distributing objects among voices
Returns:
either None or a tuple (lowest pitch, highest pitch)
"""
raise NotImplementedError
def _detachedOffset(self, default: F | None = None) -> F | None:
"""
The explicit or implicit offset (if it has been resolved), or default otherwise
This method does not call the parent
Args:
default: value returned if this object has no explicit or implicit default
Returns:
the explicit or implicit offset, or *default* otherwise
"""
return _ if (_:=self.offset) is not None else _ if (_:=self._resolvedOffset) is not None else default
[docs]
def relEnd(self) -> F:
"""
Resolved end of this object, relative to its parent
An object's offset can be explicit (set in the ``.offset`` attributes)
or implicit, as calculated from the context of the parent. For example,
inside a Chain, the offset of an event depends on the offsets and
durations of the objects preceding it.
.. note::
To calculate the absolute end of an object, use
``obj.absOffset() + obj.dur``
Returns:
the resolved end of this object, relative to its parent. If this
object has no parent, the relative end and the absolute end are
the same
.. seealso:: :meth:`MObj.relOffset`, :meth:`MObj.absOffset`
"""
return self.relOffset() + self.dur
[docs]
def relOffset(self) -> F:
"""
Resolve the offset of this object, relative to its parent
If this object has no parent the offset is an absolute offset.
The ``.offset`` attribute holds the explicit offset. If this attribute
is unset (``None``) this object might ask its parent to determine the
offset based on the durations of any previous objects
Returns:
the offset, in quarter notes. If no explicit or implicit
offset and the object has no parent it returns 0.
.. seealso:: :meth:`absOffset() <maelzel.core.mobj.MObj.absOffset>`
"""
if (offset := self.offset) is not None:
return offset
elif self._resolvedOffset is not None:
return self._resolvedOffset
elif self.parent:
self._resolvedOffset = offset = self.parent._childOffset(self)
return offset
else:
return F0
[docs]
def absOffset(self) -> F:
"""
Returns the absolute offset of this object in quarternotes
If this object is embedded (has a parent) in a container,
its absolute offset depends on the offset of its parent,
recursively. If the object has no parent then the absolute offset
is just the resolved offset
Returns:
the absolute start position of this object
"""
offset = self.relOffset()
return offset if not self.parent else offset + self.parent.absOffset()
[docs]
def absEnd(self) -> F:
"""
Returns the absolute end of this offset, as quarternotes
If this object is embedded (has a parent) in a container,
its absolute end depends on the offset of its parent,
recursively. If the object has no parent then the absolute offset
is just the resolved offset
Returns:
the absolute end position of this object
"""
return self.absOffset() + self.dur
[docs]
def parentAbsOffset(self) -> F:
"""
The absolute offset of the parent
Returns:
the absolute offset of the parent if this object has a parent, else 0
"""
return self.parent.absOffset() if self.parent else F0
[docs]
def withExplicitOffset(self, forcecopy=False) -> Self:
"""
Copy of self with explicit times
If self already has explicit offset and duration, self itself
is returned. If you relie on the fact that this method returns
a copy, use ``forcecopy=True``
Args:
forcecopy: if forcecopy, a copy of self will be returned even
if self already has explicit times
Returns:
a clone of self with explicit times
Example
~~~~~~~
>>> n = None("4C", dur=0.5)
>>> n
4C:0.5♩
>>> n.offset is None
True
# An unset offset resolves to 0
>>> n.withExplicitTimes()
4C:0.5♩:offset=0
# An unset duration resolves to 1 quarternote beat
>>> Note("4C", offset=2.5).withExplicitTimes()
4C:1♩:offset=2.5
"""
if self.offset is not None and not forcecopy:
return self
return self.clone(offset=self.relOffset())
def _asVoices(self) -> list[chain.Voice]:
raise NotImplementedError
[docs]
def plot(self,
axes: Axes | None = None,
figsize: tuple[int, int] = (15, 5),
timeSignatures=True,
grid=True,
**kws) -> Axes:
"""
Plot this object
To see all supported options, see :func:`maelzel.core.plotting.plotVoices`
Args:
axes: use this Axes, if given
figsize: figure size of the plot, if not axes is given (otherwise
uses the figure corresponding to the given axes)
timeSignatures: draw time signatures
grid: draw a grid
kws: passed to maelzel.core.plotting.plotVoices
Returns:
the axes used
"""
voices = self._asVoices()
from maelzel.core import plotting
return plotting.plotVoices(voices, axes=axes, figsize=figsize, grid=grid,
timeSignatures=timeSignatures,
**kws)
[docs]
def setPlay(self, /, **kws) -> Self:
"""
Set any playback attributes, returns self
Args:
**kws: any argument passed to :meth:`~MObj.play` (delay, dur, chan,
gain, fade, instr, pitchinterpol, fadeshape, params,
priority, position).
Returns:
self. This allows to chain this to any constructor (see example)
============== ====== =====================================================
Attribute Type Descr
============== ====== =====================================================
instr str The instrument preset to use
delay float Delay in seconds, added to the start of the object
chan int The channel to output to, **channels start at 1**
fade float The fade time; can also be a tuple (fadein, fadeout)
fadeshape str One of 'linear', 'cos', 'scurve'
pitchinterpol str One of 'linear', 'cos', 'freqlinear', 'freqcos'
gain float A gain factor applied to the amplitud of this object.
**Dynamic argument** (*kgain*)
position float Dynamic argument. Panning position (0=left, 1=right).
**Dynamic argument** (*kpos*)
skip float Skip time of playback; allows to play a fragment of the object.
Time in beats relative to the start of the object
**NB**: set the delay to the -skip to start playback at the
original time but from the timepoint specified by the skip param
end float End time of playback, in beats, relative to the start of the
object; counterpart of `skip`, allow to trim playback of the object
sustain float An extra sustain time, in seconds. This is useful for sample
based instruments
transpose float Transpose the pitch of this object **only for playback**
glisstime float The duration (in beats) of the glissando for events with
glissando. A short glisstime can be used for legato playback
in non-percusive instruments. Implies gliss. to the next event.
priority int The order of evaluation. Events scheduled with a higher
priority are evaluated later in the chain
args dict Named arguments passed to the playback instrument
gliss float/ An object can be set to have a playback only gliss. It
bool is equivalent to having a gliss., but the gliss is not
displayed as notation.
============== ====== =====================================================
**Example**::
# a piano note
>>> from maelzel.core import *
# Create a note with predetermined instr and panning position
>>> note = Note("C4+25", dur=0.5).setPlay(instr="piano", position=1)
# When .play is called, the note will play with the preset instr and position
>>> note.play()
.. seealso:: :meth:`MObj.addSymbol <maelzel.core.mobj.MObj.addSymbol>`, :attr:`MObj.playargs <maelzel.core.mobj.MObj.playargs>`
"""
playargs = self.playargs
if playargs is None:
self.playargs = playargs = PlayArgs()
extrakeys = kws.keys() - PlayArgs.playkeys
# set extrakeys as args, without checking the instrument
if extrakeys:
# raise ValueError(f"Unknown keys: {extrakeys}, {self=}")
args = kws.get('args')
if args:
for k in extrakeys:
args[k] = kws[k]
else:
kws['args'] = {k: kws[k] for k in extrakeys}
for k in extrakeys:
kws.pop(k)
playargs.update(kws)
return self
[docs]
def clone(self,
**kws) -> Self:
"""
Clone this object, changing parameters if needed
Args:
**kws: any keywords passed to the constructor
Returns:
a clone of this object, with the given arguments changed
Example::
>>> from maelzel.core import *
>>> a = Note("C4+", dur=1)
>>> b = a.clone(dur=0.5)
"""
out = self.copy()
for k, v in kws.items():
if k == 'offset':
offset = asF(v)
assert offset >= F0, f"Invalid offset for {self}: {offset}"
out.offset = asF(v)
else:
setattr(out, k, v)
self._copyAttributesTo(out)
return out
[docs]
def remap(self, deststruct: ScoreStruct, sourcestruct: ScoreStruct | None = None
) -> Self:
"""
Creates a clone, remapping times from source scorestruct to destination scorestruct
The absolute time remains the same
Args:
deststruct: the destination scorestruct
sourcestruct: the source scorestructure, or None to use the resolved scoresturct
Returns:
a clone of self remapped to the destination scorestruct
"""
if sourcestruct is None:
sourcestruct = self.activeScorestruct()
offset, dur = deststruct.remapSpan(sourcestruct, self.absOffset(), self.dur)
return self.clone(offset=offset, dur=dur)
[docs]
def copy(self) -> Self:
"""Returns a copy of this object"""
raise NotImplementedError
[docs]
def timeShift(self, offset: time_t) -> Self:
"""
Return a copy of this object with an added offset
Args:
offset: a delta time added
Returns:
a copy of this object shifted in time by the given amount
"""
offset = asF(offset)
return self.timeTransform(lambda t: t + offset, inplace=False)
def __eq__(self, other) -> bool:
if isinstance(other, type(self)):
return hash(self) == hash(other)
else:
return False
def __ne__(self, other) -> bool:
return not (self == other)
def __rshift__(self, timeoffset: time_t):
return self.timeShift(timeoffset)
def __lshift__(self, timeoffset: time_t):
return self.timeShift(-timeoffset)
@property
def end(self) -> F | None:
"""
The end time of this object.
Will be None if this object has no explicit offset. Use :meth:`
"""
return None if self.offset is None else self.offset + self.dur
[docs]
def quantizePitch(self, step=0.) -> Self:
""" Returns a new object, with pitch rounded to step
Args:
step: quantization step, in semitones.
Returns:
a copy of self with the pitch quantized
"""
raise NotImplementedError()
[docs]
def transposeByRatio(self, ratio: float) -> Self:
"""
Transpose this by a given frequency ratio, if applicable
A ratio of 2 equals to transposing an octave higher.
Args:
ratio: the ratio to transpose by
Returns:
a copy of this object, transposed by the given ratio
Example
-------
>>> from maelzel.core import *
>>> n = Note("4C")
# A transposition by a ratio of 2 results in a pitch an octave higher
>>> n.transposeByRatio(2)
5C
"""
return self.transpose(12 * math.log(ratio, 2))
[docs]
def getConfig(self, prototype: CoreConfig | None = None) -> CoreConfig | None:
"""
Returns a CoreConfig overloaded with options set for this object
Args:
prototype: the config to use as prototype, falls back to the active config
Returns:
A clone of the active config with any customizations made via :meth:`Voice.setConfig` or
:meth:`Voice.configQuantization`
If no customizations have been made, None is returned
.. seealso::
* :meth:`setConfig() <maelzel.core.chain.Voice.configQuantization>`
* :meth:`configQuantization() <maelzel.core.chain.Voice.configQuantization>`
"""
return self.parent.getConfig(prototype) if self.parent else None
[docs]
def show(self,
fmt='',
external: bool | None = None,
backend='',
resolution: int = 0,
pageSize='',
staffSize: float | None = None,
cents: bool | None = None,
autoClefChanges: bool | None = None,
** kws
) -> None:
"""
Show this as notation.
Args:
external: True to force opening the image in an external image viewer,
even when inside a jupyter notebook. If False, show will
display the image inline if inside a notebook environment.
To change the default, modify :ref:`config['openImagesInExternalApp'] <config_openImagesInExternalApp>`
backend: backend used when rendering to png/pdf.
One of 'lilypond', 'musicxml'. If not given, use default
(see :ref:`config['show.backend'] <config_show_backend>`)
fmt: one of 'png', 'pdf', 'ly'. None to use default.
resolution: dpi resolution when rendering to an image, overrides the
:ref:`config key 'show.pngResolution' <config_show_pngresolution>`
pageSize: if given, overrides config 'show.pageSize'. One of 'a3', 'a4', ...
staffSize: if given, overrides config 'show.staffSize'. A value in points
(default = 10.)
cents: overrides config 'show.cents'. False to hide cents deviations
as text annotation
kws: any keyword is used to override the config. All options starting with
the 'show.' prefix can be used directly (see below)
Useful keywords
~~~~~~~~~~~~~~~
================ ===================== ===============================
kws Config Option Description
================ ===================== ===============================
staffSize show.staffSize Size of a staff, in points
spacing show.spacing One of normal (traditional spacing),
strict (proportional), uniform (proportional)
voiceMaxStaves show.voiceMaxStaves Expands any voice to at most
this number of staves
autoClefChanges show.autoClefChanges Adds automatic clef changes when rendering
clefSimplify show.clefSimplify Simplifies automatic clef changes
cents show.cents set to False to avoid showing cents
deviations as text annotation
glissStemless show.glissStemless remove stems from the end note of a gliss
horizontalSpace show.horizontalSpace configure proportional spacing (one of
"default", "small", "medium", "large")
pageOrientation show.pageOrientation one of "landscape", "portrait"
pageSize show.pageSize one of "a4", "a3", ...
================ ===================== ===============================
"""
cfg = self.getConfig() or Workspace.active.config
cfg = cfg.copy()
if resolution or kws:
if resolution:
cfg['show.pngResolution'] = resolution
for kw, value in kws.items():
if kw in cfg:
cfg[kw] = value
elif (showkw := f"show.{kw}") in cfg:
cfg[showkw] = value
else:
matches = cfg._bestMatches(kw, limit=8)
logger.error(f'Invalid config keyword {kw} or {showkw} (possible matches: {matches})')
if staffSize is not None:
cfg['show.staffSize'] = staffSize
if cents is not None:
cfg['show.cents'] = cents
if pageSize:
cfg['show.pageSize'] = pageSize
if autoClefChanges is not None:
cfg['show.autoClefChanges'] = autoClefChanges
if external is None:
external = cfg['openImagesInExternalApp']
assert isinstance(external, bool)
if not backend:
backend = cfg['show.backend']
elif backend != cfg['show.backend']:
cfg['show.backend'] = backend
if not fmt:
fmt = 'png' if not external and environment.insideJupyter else cfg['show.format']
if fmt == 'ly':
renderer = self.render(backend='lilypond', config=cfg)
if external:
lyfile = _util.mktemp(suffix='.ly')
renderer.write(lyfile)
import emlib.misc
emlib.misc.open_with_app(lyfile)
else:
_tools.showLilypondScore(renderer.render())
else:
img = self._renderImage(fmt=fmt, config=cfg)
if fmt == 'png':
scalefactor = cfg['show.scaleFactor']
if backend == 'musicxml':
scalefactor *= cfg['show.scaleFactorMusicxml']
_tools.pngShow(img, forceExternal=external, scalefactor=scalefactor)
else:
import emlib.misc
emlib.misc.open_with_app(img)
def _changed(self) -> None:
"""
This method is called whenever the object changes its representation
This happens when a note changes its pitch inplace, the duration is modified, etc.
"""
if self.parent:
self.parent._childChanged(self)
[docs]
def quantizedScore(self,
scorestruct: ScoreStruct | None = None,
config: CoreConfig | None = None,
quantizationProfile: str | quant.QuantizationProfile | None = None,
enharmonicOptions: enharmonics.EnharmonicOptions | None = None,
nestedTuplets: bool | None = None
) -> quant.QuantizedScore:
"""
Returns a QuantizedScore representing this object
Args:
scorestruct: if given it will override the scorestructure active for this object
config: if given will override the active config
quantizationProfile: if given it is used to customize the quantization process.
Otherwise, a profile is constructed based on the config. It is also possible
to pass the name of a quantization preset (possible values: 'lowest', 'low',
'medium', 'high', 'highest', see :meth:`maelzel.scoring.quant.QuantizationProfile.fromPreset`)
enharmonicOptions: if given it is used to customize enharmonic respelling.
Otherwise, the enharmonic options used for respelling are constructed based
on the config
nestedTuplets: if given, overloads the config value 'quant.nestedTuplets'. This is used
to disallow nested tuplets, mainly for rendering musicxml since there are some music
editors (MuseScore, for example) which fail to import nested tuplets. This can be set
at the config level as ``getWorkspace().config['quant.nestedTuplets'] = False``
Returns:
a quantized score. To render such a quantized score as notation call
its :meth:`~maelzel.scoring.quant.QuantizedScore.render` method
A QuantizedScore contains a list of QuantizedParts, which each consists of
list of QuantizedMeasures. To access the recursive notation structure of each measure
call its :meth:`~maelzel.scoring.QuantizedMeasure.asTree` method
"""
w = Workspace.active
if config is None:
config = w.config
from maelzel.scoring import quant
if not scorestruct:
scorestruct = self.scorestruct() or w.scorestruct
if quantizationProfile is None:
quantizationProfile = config.makeQuantizationProfile()
elif isinstance(quantizationProfile, str):
quantizationProfile = quant.QuantizationProfile.fromPreset(quantizationProfile)
else:
assert isinstance(quantizationProfile, quant.QuantizationProfile)
if nestedTuplets is not None:
quantizationProfile.nestedTuplets = nestedTuplets
parts = self.scoringParts()
if config['show.respellPitches'] and enharmonicOptions is None:
enharmonicOptions = config.makeEnharmonicOptions()
qscore = quant.quantizeParts(parts,
quantizationProfile=quantizationProfile,
struct=scorestruct,
enharmonicOptions=enharmonicOptions)
return qscore
[docs]
def render(self,
backend='',
renderoptions: RenderOptions | None = None,
config: CoreConfig | None = None,
quantizationProfile: str | quant.QuantizationProfile = ''
) -> Renderer:
"""
Renders this object as notation
First the object is quantized to abstract notation, then it is passed to the backend
to render it for the specific format (lilypond, musicxml, ...), which is then used
to generate a document (pdf, png, ...)
Args:
backend: the backend to use, one of 'lilypond', 'musicxml'. If not given,
defaults to the :ref:`config key 'show.backend' <config_show_backend>`
renderoptions: the render options to use. If not given, these are generated from
the active config
config: if given, overrides the active config
quantizationProfile: if given, it is used to customize the quantization process
and will override any config option related to quantization.
A QuantizationProfile can be created from a config via
:meth:`maelzel.core.config.CoreConfig.makeQuantizationProfileFromPreset`.
Returns:
a scoring.Renderer. This can be used to write the rendered structure
to an image (png, pdf) or as a musicxml or lilypond file.
Example
~~~~~~~
>>> from maelzel.core import *
>>> voice = Voice(...)
# Render with the settings defined in the config
>>> voice.render()
# Customize the rendering process
>>> from maelzel.scoring.renderer import RenderOptions
>>> from maelzel.scoring.quant import QuantizationProfile
>>> quantprofile = QuantizationProfile.simple(
... possibleDivisionsByTempo={80: []
... })
"""
w = Workspace.active
if not config:
config = w.config
if backend and backend != config['show.backend']:
config = config.clone({'show.backend': backend})
if not renderoptions:
renderoptions = config.makeRenderOptions()
scorestruct = self.scorestruct() or w.scorestruct
from maelzel.scoring import quant
if not quantizationProfile:
quantizationProfile = config.makeQuantizationProfile()
elif isinstance(quantizationProfile, str):
quantizationProfile = quant.QuantizationProfile.fromPreset(quantizationProfile)
else:
assert isinstance(quantizationProfile, quant.QuantizationProfile)
return _renderObject(self, renderoptions=renderoptions,
scorestruct=scorestruct, config=config,
quantizationProfile=quantizationProfile)
def _renderImage(self,
outfile='',
fmt="png",
config: CoreConfig | None = None
) -> str:
"""
Creates an image representation, returns the path to the image
Args:
outfile: the path of the generated file. Use None to generate
a temporary file.
fmt: if outfile is None, fmt will determine the format of the
generated file. Possible values: 'png', 'pdf'.
Returns:
the path of the generated file. If outfile was given, the returned
path will be the same as the outfile.
.. seealso:: :meth:`~maelzel.core.mobj.MObj.render`
"""
w = Workspace.active
if not config:
config = w.config
if fmt == 'ly' and config['show.backend'] != 'lilypond':
config = config.clone(updates={'show.backend': 'lilypond'})
if not outfile:
assert fmt in ('png', 'pdf')
outfile = _util.mktemp(suffix='.' + fmt)
_renderImage(obj=self, outfile=outfile, config=config)
if not os.path.exists(outfile):
# cached image does not exist?
logger.debug("Error rendering %s, the rendering process did not generate "
"the expected output file '%s'. This might be a cached "
"path and the cache might be invalid. Resetting the cache and "
"trying again...", self, outfile)
clearImageCache()
# Try again, uncached
_renderImage(obj=self, outfile=outfile, config=config)
if not os.path.exists(outfile):
raise FileNotFoundError(f"Could not generate image, returned image file '{outfile}' "
f"does not exist")
else:
logger.debug("... resetting the cache worked, an image file '%s' "
"was generated", outfile)
return outfile
[docs]
def scoringEvents(self,
groupid='',
config: CoreConfig | None = None,
parentOffset: F | None = None
) -> list[scoring.Notation]:
"""
Returns its notated form as scoring.Notations
These can then be converted into notation via some of the available
backends: musicxml or lilypond
Args:
groupid: passed by an object higher in the hierarchy to
mark this objects as belonging to a group
config: a configuration to customize rendering
parentOffset: if given this should be the absolute offset of this object's parent
Returns:
A list of scoring.Notation which best represent this
object as notation
"""
raise NotImplementedError("Subclass should implement this")
[docs]
def scoringParts(self,
config: CoreConfig | None = None
) -> list[scoring.core.UnquantizedPart]:
"""
Returns this object as a list of scoring UnquantizedParts.
Args:
config: if given, this config instead of the active config will
be used
Returns:
a list of unquantized parts, sorted from high (on the score) to low.
This method is used internally to generate the parts which
constitute a given MObj prior to rendering,
but might be of use itself so it is exposed here.
An :class:`maelzel.scoring.UnquantizedPart` is an intermediate format used by the scoring
package to represent notated events. It represents a list of non-simultaneous Notations,
unquantized and independent of any score structure
"""
cfg = config or Workspace.active.config
notations = self.scoringEvents(config=cfg)
return self._scoringPartsFromNotations(notations, config=cfg)
def _scoringPartsFromNotations(self,
notations: list[scoring.Notation],
config: CoreConfig
) -> list[scoring.core.UnquantizedPart]:
if not notations:
return []
scoring.core.resolveOffsets(notations)
parts = scoring.core.distributeNotationsByClef(notations, maxStaves=config['show.voiceMaxStaves'])
parts.reverse()
return parts
[docs]
def unquantizedScore(self, title='') -> scoring.core.UnquantizedScore:
"""
Create a maelzel.scoring.UnquantizedScore from this object
Args:
title: the title of the resulting score (if given)
Returns:
the Arrangement representation of this object
An :class:`~maelzel.scoring.UnquantizedScore` is a list of
:class:`~maelzel.scoring.UnquantizedPart`, which is itself a list of
:class:`~maelzel.scoring.Notation`. It represents a score in which
the Notations within each part are not split into measures, nor organized
in beats. To generate a quantized score see
:meth:`quantizedScore() <maelzel.core.mobj.MObj.quantizedScore>`
This method is mostly used internally when an object is asked to be represented
as a score. In this case, an UnquantizedScore is created first, which is then quantized,
generating a :class:`~maelzel.scoring.quant.QuantizedScore`
.. seealso:: :meth:`quantizedScore() <maelzel.core.mobj.MObj.quantizedScore>`, :class:`~maelzel.scoring.quant.QuantizedScore`
"""
parts = self.scoringParts()
return scoring.core.UnquantizedScore(parts, title=title)
@classmethod
def _labelSymbol(cls, label: str, config: CoreConfig | None = None) -> _symbols.Text:
if config is None:
config = Workspace.active.config
labelstyle = TextStyle.parse(config['show.labelStyle'])
return _symbols.Text(text=label,
fontsize=labelstyle.fontsize,
italic=labelstyle.italic,
weight="bold" if labelstyle.bold else '',
color=labelstyle.color,
box=labelstyle.box,
placement=labelstyle.placement,
fontfamily=labelstyle.family)
[docs]
def activeScorestruct(self) -> ScoreStruct:
"""
Returns the ScoreStruct active for this obj or its parent.
Otherwise returns the scorestruct for the active workspace
Returns:
the active scorestruct for this object
.. seealso:: :meth:`MObj.scorestruct`
"""
return self.scorestruct() or Workspace.active.scorestruct
def _asBeat(self, time: beat_t) -> F:
if isinstance(time, F):
return time
elif isinstance(time, tuple):
measidx, beat = time
return self.activeScorestruct().locationToBeat(measidx, beat)
else:
return F(time)
[docs]
def scorestruct(self) -> ScoreStruct | None:
"""
Returns the ScoreStruct active for this obj or its parent (recursively)
If this object has no parent ``None`` is returned. Use
:meth:`activeScorestruct() <maelzel.core.mobj.MObj.activeScorestruct>`
to always resolve the active struct for this object
Returns:
the associated scorestruct, if set (either directly or through its parent)
Example
~~~~~~~
.. code-block:: python
>>> from maelzel.core import *
>>> n = Note("4C", 1)
>>> voice = Voice([n])
>>> score = Score([voice])
>>> score.setScoreStruct(ScoreStruct(timesig=(3, 4), tempo=72))
>>> n.scorestruct()
ScoreStruct(timesig=(3, 4), tempo=72)
.. seealso:: :meth:`activeScorestruct() <maelzel.core.mobj.MObj.activeScorestruct>`
"""
return None if not self.parent else self.parent.scorestruct()
[docs]
def write(self,
outfile: str,
backend='',
resolution: int = 0,
format='',
) -> None:
"""
Export to multiple formats
Formats supported: pdf, png, musicxml (extension: .xml or .musicxml),
lilypond (.ly), midi (.mid or .midi) and pickle
To configure any options either modify the active config or use
:meth:`.setConfig` for self. You can also use a config
as context manager to temporary change the active config
Args:
outfile: the path of the output file. The extension determines
the format. Formats available are pdf, png, lilypond, musicxml,
midi, csd and pickle.
backend: the backend used when writing as pdf or png. If not given,
the default defined in the active config is used
(:ref:`key: 'show.backend' <config_show_backend>`).
Possible backends: ``lilypond``; ``musicxml`` (uses MuseScore to render musicxml as
image so MuseScore needs to be installed)
resolution: image DPI (only valid if rendering to an image) - overrides
the :ref:`config key 'show.pngResolution' <config_show_pngresolution>`
format: the format to write to. If not given, the format is inferred from the
extension of the output file. If the extension is not recognized, an error is raised.
One of 'pdf', 'png', 'lilypond', 'musicxml', 'midi', 'csd', 'pickle'.
Formats
-------
* pdf, png: will render the object as notation and save that to the given format
* lilypond: `.ly` extension. Will render the object as notation and save it as lilypond text
* midi: `.mid` or `.midi` extension. At the moment this is done via lilypond, so the midi
produced follows the quantization process used for rendering to notation. Notice that
midi cannot reproduce many features of a maelzel object, like microtones, and many
complex rhythms will not be translated correctly
* pickle: the object is serialized using the pickle module. This allows to load it
via ``pickle.load``: ``myobj = pickle.load(open('myobj.pickle'))``
Example
~~~~~~~
.. code-block:: python
chain = Chain(...)
with CoreConfig({'show.voiceMaxStaves': 2, 'show.staffSize': 12}):
chain.write('chain.pdf')
"""
if outfile == '?':
from . import _dialogs
selected = _dialogs.selectFileForSave(key="writeLastDir",
filter="All formats (*.pdf, *.png, "
"*.ly, *.xml, *.mid)")
if not selected:
logger.info("File selection cancelled")
return
outfile = selected
ext = os.path.splitext(outfile)[1]
cfg = Workspace.active.config
if not format:
format = {
'.ly': 'lilypond',
'.mid': 'midi',
'.midi': 'midi',
'.xml': 'musicxml',
'.musicxml': 'musicxml',
'.csd': 'csd',
'.pickle': 'pickle'
}.get(ext)
if format == 'lilypond' or format == 'midi':
backend = 'lilypond'
elif format == 'musicxml':
backend = 'musicxml'
elif format == 'csd':
renderer = self._makeOfflineRenderer()
renderer.writeCsd(outfile)
return
elif format == 'pickle':
import pickle
with open(outfile, 'wb') as f:
pickle.dump(self, f)
return
elif not backend:
backend = cfg['show.backend']
elif format not in ('pdf', 'png'):
raise ValueError(f"Unsupported format: {format}")
if resolution or backend != cfg['show.backend']:
updates = {'show.backend': backend}
if resolution:
updates['show.pngResolution'] = resolution
cfg = cfg.clone(updates=updates)
r = notation.renderWithActiveWorkspace(parts=self.scoringParts(config=cfg),
scorestruct=self.scorestruct(),
config=cfg)
r.write(outfile)
def _htmlImage(self, scaleFactor: float = 0.) -> tuple[bytes, str]:
"""
Returns a tuple of the image as a base64 string and the width and height of the image.
Args:
scaleFactor: The scale factor to apply to the image.
Returns:
A tuple `(base64 string of the image, html img tag)` representing the base64 string
of the image and the html img tag.
"""
imgpath = self._renderImage()
from maelzel import _imgtools
img64, width, height = _imgtools.readImageAsBase64(imgpath)
if scaleFactor == 0.:
scaleFactor = Workspace.active.config.get('show.scaleFactor', 1.0)
return img64, _util.htmlImage64(img64=img64, imwidth=width, width=f'{int(width * scaleFactor)}px')
def _repr_html_header(self):
return _html.escape(repr(self))
def _repr_html_(self) -> str:
cfg = Workspace.active.config
txt = self._repr_html_header()
html = rf'<code style="white-space: pre-line; font-size:0.9em;">{txt}</code><br>'
if cfg['jupyterReprShow']:
img64, img = self._htmlImage()
html += '<br>' + img
return html
def _makeOfflineRenderer(self,
sr=0,
numchannels=2,
eventoptions={}
) -> offline.OfflineRenderer:
from maelzel.core import offline
r = offline.OfflineRenderer(sr=sr, numchannels=numchannels)
events = self.synthEvents(**eventoptions)
r.schedEvents(coreevents=events)
return r
[docs]
def dump(self, indents=0, forcetext=False):
"""
Prints all relevant information about this object
Args:
indents: number of indents
forcetext: if True, force text output via print instead of html
even when running inside jupyter
"""
print(f'{" "*indents}{repr(self)}')
if self.playargs:
print(f'{" "*(indents+1)}{self.playargs}')
def _synthEvents(self,
playargs: PlayArgs,
parentOffset: F,
workspace: Workspace,
) -> list[SynthEvent]:
"""
Must be overriden by each class to generate SynthEvents
Args:
playargs: a :class:`PlayArgs`, structure, filled with given values,
own .playargs values and config defaults (in that order)
parentOffset: the absolute offset of the parent of this object.
If the object has no parent, this will be F(0)
workspace: a Workspace. This is used to determine the scorestruct, the
configuration and a mapping between dynamics and amplitudes
Returns:
a list of :class:`SynthEvent`s
"""
raise NotImplementedError("Subclass should implement this")
[docs]
def synthEvents(self,
instr='',
delay: float | None = None,
args: dict[str, float] | None = None,
gain: float | None = None,
chan: int | None = None,
pitchinterpol='',
fade: float | tuple[float, float] | None = None,
fadeshape='',
position: float | None = None,
skip: float | None = None,
end: float | None = None,
sustain: float | None = None,
workspace: Workspace | None = None,
transpose: float = 0.,
glisstime: float = 0.,
**kwargs
) -> list[SynthEvent]:
"""
Returns the SynthEvents needed to play this object
All these attributes here can be set previously via `playargs` (or
using :meth:`~maelzel.core.mobj.MObj.setPlay`)
Args:
gain: modifies the own amplitude for playback/recording (0-1)
delay: delay in seconds, added to the start of the object
As opposed to the .offset attribute of each object, which is defined
in quarternotes, the delay is always in seconds. It can be negative, in
which case synth events start the given amount of seconds earlier.
instr: which instrument to use (see defPreset, definedPresets). Use "?" to
select from a list of defined presets.
chan: the channel to output to. **Channels start at 1**
pitchinterpol: 'linear', 'cos', 'freqlinear', 'freqcos'
fade: fade duration in seconds, can be a tuple (fadein, fadeout)
fadeshape: 'linear' | 'cos'
args: named arguments passed to the note. A dict ``{paramName: value}``
position: the panning position (0=left, 1=right)
skip: start playback at the given beat (in quarternotes), relative
to the start of the object. Allows to play a fragment of the object
(NB: this trims the playback of the object. Use `delay` to offset
the playback in time while keeping the playback time unmodified)
end: end time of playback, in quarternotes. Allows to play a fragment of the object by trimming the end of the playback
sustain: a time added to the playback events to facilitate overlapping/legato between
notes, or to allow one-shot samples to play completely without being cropped.
workspace: a Workspace. If given, overrides the current workspace. It's scorestruct
is used to determine the mapping between beat-time and real-time.
transpose: an interval to transpose any pitch
Returns:
A list of SynthEvents (see :class:`SynthEvent`)
Example
~~~~~~~
>>> from maelzel.core import *
>>> n = Note(60, dur=1).setPlay(instr='piano')
>>> n.synthEvnets(gain=0.5)
[SynthEvent(delay=0.000, gain=0.5, chan=1, fade=(0.02, 0.02), instr=piano)
bps 0.000s: 60, 1.000000
1.000s: 60, 1.000000]
>>> play(n.synthEvents(chan=2))
"""
if instr == "?":
from .presetmanager import presetManager
instr = presetManager.selectPreset()
if not instr:
raise ValueError("No preset selected")
if kwargs:
if args:
args.update(kwargs)
else:
args = kwargs
if workspace is None:
workspace = Workspace.active
if (struct := self.scorestruct()) is not None:
workspace = workspace.clone(scorestruct=struct, config=workspace.config)
playargs = PlayArgs.makeDefault(workspace.config)
db = playargs.db
if instr:
db['instr'] = instr
if delay is not None:
db['delay'] = delay
if args is not None:
db['args'] = args
if gain is not None:
db['gain'] = gain
if chan is not None:
db['chan'] = chan
if pitchinterpol:
db['pitchinterpol'] = pitchinterpol
if fade is not None:
db['fade'] = fade
if fadeshape:
db['fadeshape'] = fadeshape
if position is not None:
db['position'] = position
if sustain is not None:
db['sustain'] = sustain
if transpose:
db['transpose'] = transpose
if glisstime:
db['glisstime'] = glisstime
parentOffset = self.parent.absOffset() if self.parent else F(0)
events = self._synthEvents(playargs=playargs,
parentOffset=parentOffset,
workspace=workspace)
struct = workspace.scorestruct
playdelay: float = playargs['delay']
if skip is not None or end is not None or playdelay < 0.:
skiptime = 0. if skip is None else float(struct.beatToTime(skip))
endtime = math.inf if end is None else float(struct.beatToTime(end))
events = SynthEvent.cropEvents(events, start=max(0., skiptime+playdelay), end=endtime + playdelay)
if any(ev.delay < 0 for ev in events):
raise ValueError(f"Events cannot have negative delay, events={events}")
return events
[docs]
def play(self, /,
instr='',
delay: float | None = None,
args: dict[str, float] | None = None,
gain: float | None = None,
chan: int | None = None,
pitchinterpol='',
fade: float | tuple[float, float] | None = None,
fadeshape='',
position: float | None = None,
skip: float | None = None,
end: float | None = None,
whenfinished: _t.Callable | None = None,
sustain: float | None = None,
workspace: Workspace | None = None,
transpose: float = 0.,
config: CoreConfig | None = None,
display=False,
glisstime: float = 0.,
**kwargs
) -> csoundengine.synth.SynthGroup:
"""
Plays this object.
Play is always asynchronous (to block, use some sleep funcion).
By default, :meth:`play` schedules this event to be renderer in realtime.
.. note::
To record events offline, see the example below
Args:
gain: modifies the own amplitude for playback/recording (0-1)
delay: delay in seconds, added to the start of the object
As opposed to the .offset attribute of each object, which is defined
in symbolic (beat) time, the delay is always in real (seconds) time.
Delay can be negative, in which case synth events start the given amount
of seconds earlier.
instr: which instrument to use (see defPreset, definedPresets). Use "?" to
select from a list of defined presets.
chan: the channel to output to. **Channels start at 1**
pitchinterpol: 'linear', 'cos', 'freqlinear', 'freqcos'
fade: fade duration in seconds, can be a tuple (fadein, fadeout)
fadeshape: 'linear' | 'cos'
args: arguments passed to the note. A dict ``{paramName: value}``
position: the panning position (0=left, 1=right)
skip: amount of time (in quarternotes) to skip. Allows to play a fragment of
the object (NB: this trims the playback of the object. Use `delay` to
offset the playback in time while keeping the playback time unmodified)
end: end time of playback. Allows to play a fragment of the object by trimming the end of the playback
sustain: a time added to the playback events to facilitate overlapping/legato between
notes, or to allow one-shot samples to play completely without being cropped.
workspace: a Workspace. If given, overrides the current workspace. It's scorestruct
is used to to determine the mapping between beat-time and real-time.
transpose: add a transposition interval to the pitch of this object
glisstime: slide time to next event (in seconds). This allows to add glissando lines for events
even if their gliss attr is not set, or to generate legato lines
config: if given, overrides the current config
whenfinished: function to be called when the playback is finished. Only applies to
realtime rendering
display: if True and running inside Jupyter, display the resulting synth's html
Returns:
A :class:`~csoundengine.synth.SynthGroup`
.. seealso::
* :meth:`synthEvents() <maelzel.core.mobj.MObj.synthEvents>`
* :meth:`MObj.rec() <maelzel.core.mobj.MObj.rec>`
* :func:`~maelzel.core.offline.render`,
* :func:`~maelzel.core.playback.play`
Example
~~~~~~~
Play a note
>>> from maelzel.core import *
>>> note = Note(60).play(gain=0.1, chan=2)
Play multiple objects synchronised
>>> play(
... Note(60, 1.5).synthEvents(gain=0.1, position=0.5)
... Chord("4E 4G", 2, start=1.2).synthEvents(instr='piano')
... )
Or using play as a context managger:
>>> with play():
... Note(60, 1.5).play(gain=0.1, position=0.5)
... Chord("4E 4G", 2, start=1.2).play(instr='piano')
... ...
Render offline
>>> with render("out.wav", sr=44100) as r:
... Note(60, 5).play(gain=0.1, chan=2)
... Chord("4E 4G", 3).play(instr='piano')
"""
if config is not None:
workspace = Workspace.active.clone(config=config)
elif workspace is None:
workspace = Workspace.active
events = self.synthEvents(delay=delay,
chan=chan,
fade=fade,
gain=gain,
instr=instr,
pitchinterpol=pitchinterpol,
fadeshape=fadeshape,
args=args,
position=position,
sustain=sustain,
workspace=workspace,
skip=skip,
end=end,
transpose=transpose,
glisstime=glisstime,
**kwargs)
if not events:
import csoundengine.synth
group = csoundengine.synth.SynthGroup([])
else:
renderer = workspace.renderer or realtimerenderer.RealtimeRenderer()
group = renderer.schedEvents(coreevents=events, whenfinished=whenfinished)
if display and environment.insideJupyter:
from IPython.display import display
display(group)
return group
[docs]
def rec(self, /,
outfile='',
delay: float | None = None,
sr: int = 0,
verbose: bool | None = None,
wait: bool | None = None,
nchnls: int | None = None,
instr='',
args: dict[str, float] | None = None,
gain: float | None = None,
position: float | None = None,
extratime: float | None = None,
workspace: Workspace | None = None,
skip: float | None = None,
end: float | None = None,
**kws
) -> offline.OfflineRenderer:
"""
Record the output of .play as a soundfile
Args:
outfile: the outfile where sound will be recorded. Can be
None, in which case a filename will be generated. Use '?'
to open a save dialog
sr: the sampling rate (:ref:`config key: 'rec.sr' <config_rec_sr>`)
wait: if True, the operation blocks until recording is finishes
(:ref:`config 'rec.block' <config_rec_blocking>`)
nchnls: if given, use this as the number of channels to record.
gain: modifies the own amplitude for playback/recording (0-1)
delay: delay in seconds, added to the start of the object
As opposed to the .start attribute of each object, which is defined
in symbolic (beat) time, the delay is always in real (seconds) time
instr: which instrument to use (see defPreset, definedPresets). Use "?" to
select from a list of defined presets.
args: named arguments passed to the note. A dict ``{paramName: value}``
position: the panning position (0=left, 1=right)
workspace: if given it overrides the active workspace
extratime: extratime added to the recording (:ref:`config key: 'rec.extratime' <config_rec_extratime>`)
skip: start beat of recording. Use ``scorestruct.time(beat)`` to indicate a skip time in seconds
end: end beat of recording. Use ``scorestruct.time(beat)`` to indicate an end time in seconds
verbose: if True, display synthesis output
**kws: any keyword passed to .play
Returns:
the offline renderer used. If no outfile was given it is possible to
access the renderer soundfile via
:meth:`OfflineRenderer.lastOutfile() <maelzel.core.offline.OfflineRenderer.lastOutfile>`
Example
~~~~~~~
>>> from maelzel.core import *
>>> # a simple note
>>> chord = Chord("4C 4E 4G", dur=8).setPlay(gain=0.1, instr='piano')
>>> renderer = chord.rec(wait=True)
>>> renderer.lastOutfile()
'/home/testuser/.local/share/maelzel/recordings/tmpashdas.wav'
.. seealso:: :class:`~maelzel.core.offline.OfflineRenderer`
"""
events = self.synthEvents(instr=instr, position=position,
delay=delay, args=args, gain=gain,
workspace=workspace,
skip=skip,
end=end,
**kws)
from maelzel.core import offline
endtime = max(ev.end for ev in events if ev.dur > 0)
return offline.render(outfile=outfile, events=events, sr=sr, wait=wait,
verbose=verbose, nchnls=nchnls, tail=extratime,
endtime=endtime)
[docs]
def isRest(self) -> bool:
"""
Is this object a Rest?
Rests are used as separators between objects inside an Chain or a Track
"""
return False
[docs]
def addSymbol(self, *args, **kws) -> Self:
"""
Add a notation symbol to this object
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
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)
"""
raise NotImplementedError
def _addSymbol(self, symbol: _symbols.Symbol) -> None:
if self.symbols is None:
self.symbols = []
if self.symbols and symbol.exclusive:
cls = type(symbol)
if any(isinstance(s, cls) for s in self.symbols):
self.symbols = [s for s in self.symbols if not isinstance(s, cls)]
self.symbols.append(symbol)
def _removeSymbolsOfClass(self, cls: str | type):
if self.symbols is None:
return
if isinstance(cls, str):
cls = cls.lower()
symbols = [s for s in self.symbols if s.name == cls]
else:
symbols = [s for s in self.symbols if isinstance(s, cls)]
for s in symbols:
self.symbols.remove(s)
[docs]
def getSymbol(self, classname: str) -> _symbols.Symbol | None:
"""
Get a symbol of a given class, if present
This is only supported for symbol classes which are exclusive
(notehead, color, ornament, etc.). For symbols like 'articulation',
which can be present multiple times, query the symbols attribute
directly (**NB**: symbols might be ``None`` if no symbols have been set):
.. code::
if note.symbols:
articulations = [s for s in note.symbols
if s.name == 'articulation']
Args:
classname: the class of the symbol. Possible values are
'articulation', 'text', 'notehead', 'color', 'ornament',
'fermata', 'notatedpitch'. See XXX (TODO) for a complete list
Returns:
a symbol of the given class, or None
"""
if not self.symbols:
return None
classname = classname.lower()
return next((s for s in self.symbols if s.name==classname), None)
[docs]
def addText(self,
text: str,
placement='above',
italic=False,
weight='normal',
fontsize: int | None = None,
fontfamily='',
box=''
) -> Self:
"""
Add a text annotation to this object
This is a shortcut to ``self.addSymbol(symbols.Text(...))``. Use
that for in-depth customization.
Args:
text: the text annotation
placement: where to place the annotation ('above', 'below')
italic: if True, use italic as font style
weight: 'normal' or 'bold'
fontsize: the size of the annotation
fontfamily: the font family to use. It is probably best to leave this unset
box: the enclosure shape, or '' for no box around the text. Possible shapes
are 'square', 'circle', 'rounded'
Returns:
self. This can be used to create an object and add text in one call
Example
~~~~~~~
>>> chain = Chain([
... Note("4C", 1).addText('do'),
... Note("4D", 1).addText('re')
... ])
>>> chain
.. image:: assets/event-addText.png
"""
from . import symbols as _symbols
self.addSymbol(_symbols.Text(text, placement=placement, fontsize=fontsize,
italic=italic, weight=weight, fontfamily=fontfamily,
box=box))
return self
[docs]
def timeShiftInPlace(self, offset: time_t) -> None:
"""
Shift the time of this by the given offset (inplace)
Args:
offset: the time delta (in quarterNotes)
"""
newoffset = self.relOffset() + asF(offset)
if newoffset < 0:
raise ValueError(f"This operation would result in a negative offset. "
f"Own offset: {self.relOffset()}, resulting offset: {newoffset}, "
f"given time shift: {offset}, self: {self}, absolute offset: {self.absOffset()}")
self.offset = newoffset
self._changed()
[docs]
def timeRange(self) -> tuple[F, F]:
"""
Returns a tuple (starttime, endtime), in seconds
Returns:
a tuple ``(starttime: F, endtime: F)``, where starttime
and endtime are both absolute times in seconds
"""
struct = self.activeScorestruct()
start = self.absOffset()
return struct.beatToTime(start), struct.beatToTime(start+self.dur)
[docs]
def durSecs(self) -> F:
"""
Returns the duration in seconds according to the active score
Returns:
the duration of self in seconds
"""
startsecs, endsecs = self.timeRange()
return endsecs - startsecs
[docs]
def location(self) -> tuple[location_t, location_t]:
"""
Returns the location of this object within the active score struct
Returns:
a tuple ``(startlocation, endlocation)`` where both ``startlocation``
are tuples ``(measureindex, beatoffset)`` representing the position
of this object within the score
Example
-------
>>> setScoreStruct(timesig='3/4')
>>> note = Note("4C", 1, offset=5)
>>> note.location()
((1, Fraction(2, 1)), (2, Fraction(0, 1)))
The note starts at measure 1, beat 2 and ends at
measure 2, beat 0 (both measures and beats start at 0)
"""
struct = self.activeScorestruct()
startbeat = self.absOffset()
return struct.beatToLocation(startbeat), struct.beatToLocation(startbeat + self.dur)
[docs]
def timeScale(self, factor: num_t, offset: num_t = F0) -> Self:
"""
Create a copy with modified timing by applying a linear transformation
Args:
factor: a factor which multiplies all durations and start times
offset: an offset added to all start times
Returns:
the modified object
"""
transform = _TimeScale(asF(factor), offset=asF(offset))
return self.timeTransform(transform)
[docs]
def invertPitch(self, pivot: str | float | int) -> Self:
"""
Invert the pitch of this object
Args:
pivot: the point around which to invert pitches
Returns:
the inverted object
.. code::
>>> from maelzel.core import *
>>> series = Chain("4Bb 4E 4F# 4Eb 4F 4A 5D 5C# 4G 4G# 4B 5C".split())
>>> inverted = series.invertPitch("4F#")
>>> print(" ".join(_.name.ljust(4) for _ in series))
... print(" ".join(_.name.ljust(4) for _ in inverted))
4A# 4E 4F# 4D# 4F 4A 5D 5C# 4G 4G# 4B 5C
4D 4G# 4F# 4A 4G 4D# 3A# 3B 4F 4E 4C# 4C
>>> Score([series, inverted])
.. image:: ../assets/dodecaphonic-series-1-inverted.png
"""
pivotm = asmidi(pivot)
def transform(pitch, pivot=pivotm):
return pivot * 2 - pitch
return self.pitchTransform(transform)
[docs]
def transpose(self, interval: int | float) -> Self:
"""
Transpose this object by the given interval
Args:
interval: the interval in semitones
Returns:
the transposed object
"""
return self.pitchTransform(lambda pitch: pitch+interval)
# --------------------------------------------------------------------
class MNode:
pass
class MContainer(MObj):
"""
An interface for any class which can be a parent
Implemented downstream by classes like Chain or Score.
"""
_configKeysRegistry = {}
"A cache for all class config keys"
__slots__ = ('_config',)
def __init__(self,
offset: F | None = None,
label='',
parent: MContainer | None = None,
properties: dict[str, _t.Any] | None = None):
super().__init__(offset=offset, dur=F0, label=label,
properties=properties, parent=parent)
self._config: dict[str, _t.Any] = {}
"Collects customizations to the config specific to this container"
@abstractmethod
def __iter__(self) -> _t.Iterator[MObj | MContainer]:
raise NotImplementedError
@property
def dur(self) -> F:
"""The duration of this object"""
return self._dur
def _copyAttributesTo(self, other: Self) -> None:
super()._copyAttributesTo(other)
@classmethod
def _classConfigKeys(cls) -> set[str]:
# This method can be overloaded to return keys specific to a subclass
pattern = r'\.?(quant|show)\.\w[a-zA-Z0-9_]*'
return set(k for k in CoreConfig.root().keys() if re.match(pattern, k))
@classmethod
def _configKeys(cls) -> set[str]:
# This method should probably not be overloaded. It is a workaround
# to the fact that we want to cache config keys without any subclass
# needing to worry about what kind of caching we are using
clsname = cls.__qualname__
if (keys := cls._configKeysRegistry.get(clsname)) is not None:
return keys
configkeys = cls._classConfigKeys()
cls._configKeysRegistry[clsname] = configkeys
return configkeys
def setConfig(self, *args) -> None:
"""
Configure this object
Possible keys are any CoreConfig keys with the prefixes 'quant.' and 'show.'
and also secondary keys starting with '.quant' and '.show'
Internal note: any subclass can set the keys accepted by its instances by
overloading :meth:`MContainer._configKeys`
Args:
args: an even number of args of the form key1, value1, key2, value2, ...
Example
~~~~~~~
Configure the voice to break syncopations at every beat when
rendered or quantized as a QuantizedScore
>>> voice = Voice(...)
>>> voice.setConfig('quant.brakeSyncopationsLevel', 'all')
Now, whenever the voice is shown all syncopations across beat boundaries
will be split into tied notes.
This is the same as:
>>> voice = Voice(...)
>>> score = Score([voice])
>>> quantizedscore = score.quantizedScore()
>>> quantizedscore.parts[0].brakeSyncopations(level='all')
>>> quantizedscore.render()
"""
keys = self._classConfigKeys()
root = CoreConfig.root()
assert len(args) % 2 == 0
kws = args[::2]
values = args[1::2]
for key, value in zip(kws, values):
if key not in keys:
raise KeyError(f"Invalid key '{key}' for a {self.__class__}. "
f"Valid keys are {keys}")
if errmsg := root.checkValue(key, value):
raise ValueError(f"Invalid value {value} for key '{key}': {errmsg}")
self._config[key] = value
def getConfig(self, prototype: CoreConfig | None = None) -> CoreConfig | None:
# most common first
if prototype is None:
prototype = Workspace.active.config
if not self.parent:
return None if not self._config else prototype.clone(self._config)
if (parentconfig := self.parent.getConfig(prototype)) is None:
# parent made no changes
return None if not self._config else prototype.clone(self._config)
else:
return parentconfig if not self._config else parentconfig.clone(self._config)
def _resolveConfig(self, config: CoreConfig | dict | None = None
) -> tuple[CoreConfig, bool]:
"""
Returns a tuple (resolvedConfig, iscustomized)
where resolvedConfig is the config for this object, given any customizations,
and iscustomized is True if self has own customizations
Args:
config: a config to use as the active config. Any customizations made
will have priority over this
Returns:
a tuple (resolvedConfig: CoreConfig, iscustomized: bool)
"""
if config is None:
activeconfig = Workspace.active.config
elif not isinstance(config, CoreConfig):
assert isinstance(config, dict)
activeconfig = CoreConfig(updates=config)
else:
activeconfig = config
ownconfig = self.getConfig(prototype=activeconfig)
config = ownconfig or activeconfig
return config, ownconfig is not None
@abstractmethod
def _childOffset(self, child: MObj) -> F:
"""The offset of child relative to this parent"""
raise NotImplementedError
def _childChanged(self, child: MObj) -> None:
"""
This should be called by a child when changed
Not all changes are relevant to a parent. In particular only
changes regarding offset or duration should be signaled
Args:
child: the modified child
"""
pass
@abstractmethod
def _update(self) -> None:
raise NotImplementedError
# @abstractmethod
# def _resolveGlissandi(self, force=False) -> None:
# raise NotImplementedError
# def nextItem(self, item: MObj) -> MObj | None:
# """Returns the item after *item*, if any (None otherwise)"""
# return None
#
# def previousItem(self, item: MObj) -> MObj | None:
# return None
#
# def previousEvent(self, event: _event.MEvent) -> _event.MEvent | None:
# return None
def __contains__(self, item: MObj) -> bool:
raise NotImplementedError
def root(self) -> MContainer:
"""
The root of this object
Objects are organized in a tree structure. For example,
a note can be embedded in a Chain, which is part
of a Voice, which is part of a Score. In this case, the
root of all this objects is the score. A container
without no parent is its own root.
Returns:
the root of this object
Example
~~~~~~~
>>> voice = Voice([
... "4C:1",
... Chain("4D 4E 4F")
... ])
>>> score = Score([voice])
>>> voice[0].root() is score
True
>>> score.root() is score
True
>>> Note(60).root() is None
True
>>> voice2 = voice.copy()
>>> voice2.parent is None
True
>>> voice2.root() is voice2
True
"""
return self if self.parent is None else self.parent.root()
# --------------------------------------------------------------------
def _renderImage(obj: MObj,
outfile: str,
config: CoreConfig,
) -> Renderer:
ext = os.path.splitext(outfile)[1].lower()
if ext not in ('.png', '.pdf'):
raise ValueError(f"Unknown format '{ext}', possible formats are pdf and png")
fmt = ext[1:]
renderoptions = config.makeRenderOptions()
tmpfile, renderer = _renderImageCached(obj=obj,
fmt=fmt,
config=config,
renderoptions=renderoptions)
if not os.path.exists(tmpfile):
logger.debug("Cached file '%s' not found, resetting cache, trying again", tmpfile)
clearImageCache()
tmpfile, renderer = _renderImageCached(obj=obj,
fmt=fmt,
config=config,
renderoptions=renderoptions)
if not os.path.exists(tmpfile):
raise RuntimeError(f"Could not render {obj} to file '{tmpfile}'")
import shutil
shutil.copy(tmpfile, outfile)
assert os.path.exists(outfile), f"Could not copy {tmpfile} to {outfile}"
return renderer
@functools.cache
def _renderImageCached(obj: MObj,
fmt: str,
config: CoreConfig,
renderoptions: RenderOptions
) -> tuple[str, Renderer]:
assert fmt in ('pdf', 'png')
renderer = obj.render(renderoptions=renderoptions, config=config)
outfile = _util.mktemp(suffix="." + fmt)
renderer.write(outfile)
if not os.path.exists(outfile):
raise RuntimeError(f"Error rendering to file '{outfile}', file does not exist")
return (outfile, renderer)
@functools.cache
def _renderObject(obj: MObj,
scorestruct: ScoreStruct,
config: CoreConfig,
renderoptions: RenderOptions,
quantizationProfile: quant.QuantizationProfile,
check=True
) -> Renderer:
"""
Render an object
NB: we put it here in order to make it easier to cache rendering, if needed
All args must be given (not None) so that caching is meaningful.
Args:
obj: the object to make the image from (a Note, Chord, etc.)
scorestruct: if given, this ScoreStruct will be used for rendering. Otherwise
the scorestruct within the active Workspace is used
config: if given, this config is used for rendering. Otherwise the config
within the active Workspace is used
check: if True, check that the generated scoring parts are valid
Returns:
a scoring.Renderer. The returned object can be used to render (via the
:meth:`~maelzel.scoring.Renderer.write` method) or to have access to the
generated score (see :meth:`~maelzel.scoring.Renderer.nativeScore`)
.. note::
To render with a temporary Wrokspace (i.e., without modifying the active Workspace),
use::
.. code-block:: python
with Workspace(scorestruct=..., config=..., ...) as w:
renderObject(myobj, "outfile.pdf")
"""
assert scorestruct and config
parts = obj.scoringParts(config=config)
if not parts:
if config['show.warnIfEmpty']:
logger.warning("The object %s did not produce any scoring parts", obj)
measure0 = scorestruct.measuredefs[0]
part = scoring.core.UnquantizedPart(notations=[scoring.Notation.makeRest(measure0.beatStructure()[0].duration)])
parts = [part]
elif check:
for part in parts:
part.check()
renderer = notation.renderWithActiveWorkspace(parts,
renderOptions=renderoptions,
scorestruct=scorestruct,
config=config,
quantizationProfile=quantizationProfile)
return renderer
[docs]
def clearImageCache() -> None:
"""
Clear the image cache. Useful when changing display format
"""
logger.info("Resetting image cache")
_renderImageCached.cache_clear()
_renderObject.cache_clear()