"""
Notation can be customized by adding so called :class:`Symbols <Symbol>` to
an object. Symbols are objects which can be attached to a note, chord, voice, etc.
to modify its representation as musical notation.
Most symbols do not have any other meaning than to indicate how a certain object
should be displayed as notation. In particular, they do not affect
playback in any way. For example a Notehead symbol can be attached to a note
to modify the notehead shape used, a Color can be attached to a voice to modify
the color of all its items, a Clef can be added to a note to force a clef change
at that particular moment, etc.
There are basically three kind of symbols: properties, note-attached symbols
and spanners.
Property
A **property** modifies an attribute of the object it is attached to.
For example, a SizeFactor property modifies the size of the object and, if the
object is a container (a voice, for example), then all elements within that
container are modified.
"""
# This module cannot import from maelzel.core
from __future__ import annotations
from abc import abstractmethod, ABC
import random
import copy
from functools import cache
from maelzel import _util
from maelzel import colortheory
from maelzel.common import F
from maelzel import scoring
import maelzel.scoring.spanner as _spanner
import maelzel.scoring.attachment as _attachment
from ._common import logger
import pitchtools as pt
import re
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing_extensions import Self
from typing import Sequence, Any
from maelzel.core import mobj
from maelzel.core import mevent
_uuid_alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'
def _makeuuid(size=8) -> str:
return ''.join(random.choices(_uuid_alphabet, k=size))
[docs]
class Symbol(ABC):
"""Base class for all symbols"""
exclusive = False
"""Only one of a given class"""
applyToTieStrategy = 'first'
"""One of 'first', or 'all'"""
def __init__(self):
self.properties: dict[str, Any] | None = None
def __repr__(self) -> str:
return _util.reprObj(self, hideFalsy=True)
[docs]
def getProperty(self, key: str, default=None) -> Any:
"""
Get a property value by key, or return the default value if not found.
"""
return default if not self.properties else self.properties.get(key, default)
[docs]
def setProperty(self, key: str, value) -> None:
"""
Set a property value by key.
"""
if self.properties is None:
self.properties = {}
self.properties[key] = value
@property
def name(self) -> str:
return self.__class__.__name__.lower()
[docs]
@abstractmethod
def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None:
"""Apply this symbol to the given notation, **inplace**"""
raise NotImplementedError
[docs]
def applyToTiedGroup(self, notations: Sequence[scoring.Notation], parent: mobj.MObj | None
) -> None:
"""
Apply this symbol to a group of tied notations, **inplace**.
"""
if self.applyToTieStrategy == 'all':
for n in notations:
self.applyToNotation(n, parent=parent)
elif self.applyToTieStrategy == 'first':
self.applyToNotation(notations[0], parent=parent)
[docs]
class Spanner(Symbol):
"""
A Spanner is a line/curve between two anchors (notes/chords/rests)
Spanners always come in pairs start/end.
Example
~~~~~~~
>>> from maelzel.core import *
>>> from maelzel.core import symbols
>>> chain = Chain(["C4:0.5", "D4:1", "E4:0.5"])
>>> slur = symbols.Slur()
>>> chain[0].addSymbol(slur)
>>> chain[2].addSymbol(slur.makeEndSpanner())
Or you can use the ``.bind`` method which is a shortcut:
>>> symbols.Slur().bind(chain[0], chain[2])
"""
exclusive = False
appliesToRests = True
def __init__(self,
kind='start',
uuid='',
linetype='solid',
placement='',
color='',
anchor: mevent.MEvent | None = None):
super().__init__()
assert not kind or kind == 'start' or kind == 'end', f"got kind={kind}"
assert linetype in {'', 'solid', 'dashed', 'dotted', 'wavy', 'trill', 'zigzag'}, f"got {linetype}"
if placement:
assert placement == 'above' or placement == 'below'
self.kind = kind
"""The kind of spanner. Either unset or 'start' or 'end'"""
self.uuid = uuid or _makeuuid(8)
"""An id identifying this spanner"""
self.linetype = linetype
"""Linetype, one of solid, dahsed, dotted, wavy, trill, zigzag"""
self.placement = placement
"""If given, one of above or below"""
self.color = color
"""Color, any hex or css color"""
self._anchor: mevent.MEvent | None = anchor
"""The event to which this spanner is anchored to, if known"""
self._partner: Spanner | None = None
"""The partner spanner"""
@property
def anchor(self) -> mevent.MEvent | None:
return self._anchor
@property
def partner(self) -> Spanner | None:
return self._partner
[docs]
def scoringSpanner(self) -> _spanner.Spanner:
raise NotImplementedError
[docs]
def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None:
spanner = self.scoringSpanner()
n.addSpanner(spanner)
[docs]
def applyToPair(self, start: scoring.Notation, end: scoring.Notation) -> None:
assert isinstance(start, scoring.Notation) and isinstance(end, scoring.Notation)
partner = self.makePartnerSpanner()
self.applyToNotation(start, parent=None)
partner.applyToNotation(end, parent=None)
[docs]
def applyToTiedGroup(self, notations: Sequence[scoring.Notation], parent: mobj.MObj | None
) -> None:
if self.kind == 'start':
self.applyToNotation(notations[0], parent=parent)
elif self.kind == 'end':
self.applyToNotation(notations[-1], parent=parent)
else:
logger.warning("Unknown kind '%s' for spanner %s", self.kind, str(self))
def __repr__(self) -> str:
return _util.reprObj(self,
hideFalsy=True,
properties=('anchor',),
filter={'linetype': lambda val: val!='solid'},
convert={'anchor': lambda ev: ev.name,
'partnerSpanner': lambda val: f"{type(val).__name__}"},)
def _attrs(self) -> dict:
keys = ('kind', 'uuid', 'linetype', 'placement', 'color')
return {k: v for k in keys
if (v := getattr(self, k))}
[docs]
def setAnchor(self, obj: mevent.MEvent) -> None:
"""
Set the anchor for this spanner.
This is called by :meth:``MusicObj.setSymbol`` or by :meth:``Spanner.bind`` to
set the anchor of this spanner. A User should not normally call this method
Args:
obj: the object this spanner is anchored to, either as start or end
"""
self._anchor = obj
[docs]
def bind(self, startobj: mevent.MEvent, endobj: mevent.MEvent) -> None:
"""
Bind a Spanner to two notes/chords
Args:
startobj: start anchor object
endobj: end anchor object
Example
~~~~~~~
>>> from maelzel.core import *
>>> chain = Chain(["4E:1", "4F:1", "4G:1"])
# slur the first two notes of the chain, customizing the line-type
>>> Slur(linetype='dashed').bind(chain[0], chain[1])
# The same can be achieved as:
>>> chain[0].addSpanner(Slur(linetype='dashed'), chain[1])
"""
if startobj is endobj:
raise ValueError("Cannot bind a spanner to the same object as start and end anchor")
startobj.addSymbol(self)
if self.anchor is None:
self.setAnchor(startobj)
self.makePartnerSpanner(anchor=endobj)
assert self.partner is not None
[docs]
def makePartnerSpanner(self, anchor: mevent.MEvent | None = None) -> Self:
"""
Creates the partner spanner for an already existing spanner
start and end spanner share the same uuid and have a ref to
each other, allowing each of the spanners to access their
twin. As each spanner also holds a ref to their anchor,
the anchored events can be made aware of each other.
Args:
anchor: the event to which the end spanner is anchored to.
Returns:
the created spanner. This is a copy of the start spanner in every
way with the only difference that ``kind='end'``
"""
if anchor and self.anchor is anchor:
raise ValueError("Start anchor and end anchor cannot be the same object")
if not self.kind:
raise ValueError("This spanner is not a start or end spanner")
endSpanner = copy.copy(self)
self.setPartner(endSpanner)
if anchor:
anchor.addSymbol(endSpanner)
return endSpanner
[docs]
def setPartner(self, partner: Spanner) -> None:
"""
Set the given spanner as the partner spanner of self (and self as partner of other)
Args:
partner: the partner spanner
"""
partner._partner = self
self._partner = partner
if self.kind == 'start':
partner.kind = 'end'
partner.uuid = self.uuid
else:
partner.kind = 'start'
self.uuid = partner.uuid
[docs]
class TrillLine(Spanner):
"""
Trill spanner between two notes.
Args:
kind: start | end
startmark: one of 'trill' or 'bisb' (bisbigliando)
trillpitch: if given, the pitch to trill with
alteration: if given, add an alteration to the trill. Not compatible with
trillpitch
placement: 'above' | 'below'
"""
def __init__(self,
kind='start',
startmark='trill',
trillpitch='',
alteration='',
placement='above',
uuid='',
**kws):
super().__init__(kind=kind, placement=placement, uuid=uuid, **kws)
self.startmark = startmark
self.trillpitch = trillpitch
self.alteration = alteration
[docs]
def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None:
spanner = _spanner.TrillLine(kind=self.kind, uuid=self.uuid,
startmark=self.startmark,
alteration=self.alteration,
trillpitch=self.trillpitch,
placement=self.placement)
n.addSpanner(spanner)
[docs]
class NoteheadLine(Spanner):
"""
A line conecting two noteheads
This results in a glissando only when rendered
as notation
Args:
kind: one of 'start', 'end'
uuid: used to match the start spanner
color: a css color
linetype: a valid linetype
text: a text attached to the line
"""
appliesToRests = False
def __init__(self, kind='start', uuid='', color='', linetype='solid', text=''):
super().__init__(kind=kind, uuid=uuid, color=color, linetype=linetype)
self.text = text
[docs]
def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None:
spanner = _spanner.Slide(kind=self.kind, uuid=self.uuid,
color=self.color, linetype=self.linetype,
text=self.text)
n.addSpanner(spanner)
[docs]
class OctaveShift(Spanner):
"""
An octave shift
Args:
kind: the kind of spanner, one of 'start', 'end'
octaves: number of octave to shift. Can be negative
uuid: used to match start and end spanner
"""
def __init__(self, kind='start', octaves=1, uuid=''):
assert octaves != 0 and abs(octaves) <= 3
super().__init__(kind=kind, placement='above' if octaves >= 0 else 'below', uuid=uuid)
self.octaves = octaves
[docs]
def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None:
spanner = _spanner.OctaveShift(kind=self.kind, octaves=self.octaves,
uuid=self.uuid)
n.addSpanner(spanner)
[docs]
class Slur(Spanner):
"""
A slur spanner between two notes
"""
[docs]
def scoringSpanner(self) -> _spanner.Spanner:
return _spanner.Slur(kind=self.kind, uuid=self.uuid, linetype=self.linetype,
placement=self.placement, color=self.color)
[docs]
class Beam(Spanner):
"""
Notes within a Beam spanner are beamed together
"""
appliesToRests = True
[docs]
def scoringSpanner(self) -> _spanner.Spanner:
return _spanner.Beam(kind=self.kind, uuid=self.uuid)
[docs]
class Hairpin(Spanner):
"""
A hairpin crescendo or decrescendo
Args:
direction: one of "<" or ">"
niente: if True, add a niente 'o' to the start or end of the hairpin
"""
def __init__(self, direction: str, niente=False, kind='start', uuid='', placement='', linetype=''):
super().__init__(kind=kind, uuid=uuid, placement=placement, linetype=linetype)
assert direction == "<" or direction == ">"
self.direction = direction
self.niente = niente
def _attrs(self):
attrs = {'direction': self.direction}
attrs.update(super()._attrs())
return attrs
[docs]
def scoringSpanner(self) -> _spanner.Spanner:
return _spanner.Hairpin(kind=self.kind, uuid=self.uuid,
direction=self.direction,
niente=self.niente,
placement=self.placement)
[docs]
class Bracket(Spanner):
def __init__(self, kind='start', uuid='', linetype='solid', placement='',
text=''):
super().__init__(kind=kind, uuid=uuid, linetype=linetype, placement=placement)
self.text = text
[docs]
def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None:
bracket = _spanner.Bracket(kind=self.kind, uuid=self.uuid,
text=self.text, placement=self.placement,
linetype=self.linetype)
n.addSpanner(bracket)
[docs]
class LineSpan(Spanner):
"""
A line spanner
Args:
kind: start or end
uuid: the uuid, will be autogenerated if not given.
linetype: one of solid, dashed, dotted, wavy, zigzag
placement: unset to use default, otherwise above or below
starttext: a text to add at the begining of the line
endtext: a text to add at the end. If an endtext is specified
an endhook is not allowed.
middletext: a text to add at the middle (not always supported)
verticalAlign: alignment of the text (one of up, down, center)
endhook: if true, draw a hook segment at the end of the line.
Direction of the hook is opposito to placement. An endhook
is mutually exclusive with an endtext
"""
def __init__(self, kind='start', uuid='', linetype='solid', placement='',
starttext='', endtext='', middletext='', verticalAlign='',
starthook=False, endhook=False):
super().__init__(kind=kind, uuid=uuid, linetype=linetype, placement=placement)
if verticalAlign:
assert verticalAlign in {'up', 'down', 'center'}
assert not (endtext and endhook), "An endtext and an endhook are mutually exclusive"
self.starttext = starttext
self.endtext = endtext
self.middletext = middletext
self.verticalAlign = verticalAlign
self.starthook = starthook
self.endhook = endhook
[docs]
def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None:
spanner = _spanner.LineSpan(kind=self.kind, uuid=self.uuid,
placement=self.placement, linetype=self.linetype,
starttext=self.starttext, endtext=self.endtext,
middletext=self.middletext,
verticalAlign=self.verticalAlign,
starthook=self.starthook,
endhook=self.endhook)
n.addSpanner(spanner)
_spannerNameToConstructor: dict[str, Any] = {
'slur': Slur,
'line': LineSpan,
'linespan': LineSpan,
'trill': TrillLine,
'tr': TrillLine,
'bracket': Bracket,
'<': lambda **kws: Hairpin(direction='<', **kws),
'cresc': lambda **kws: Hairpin(direction='<', **kws),
'>': lambda **kws: Hairpin(direction='>', **kws),
'decresc': lambda **kws: Hairpin(direction='>', **kws),
'dim': lambda **kws: Hairpin(direction=">", **kws),
'hairpin': Hairpin,
'beam': Beam
}
[docs]
def makeSpanner(descr: str, kind='start', linetype='', placement='', color=''
) -> Spanner:
"""
Create a spanner from a descriptor
This is mostly used within a note/chord defined as string. For example, within a
Voice/Chain, a note could be defined as "C4:1/2:slur"; this will create a C4 eighth
note which starts a slur. The slur will be extended until the end of the chain/voice or
until another note defines a '~slur' attribute, which ends the slur (a '~' sign
ends a previously open spanner of the same kind).
Possible descriptors:
* 'slur:dashed'
* 'bracket:text=foo'
* '<'
* '>'
* 'linespan:dotted:starttext=foo'
* 'trill'
* etc.
Args:
descr: a descriptor string
Returns:
the spanner
Example
-------
>>> from maelzel.core import *
>>> chain = Chain(...)
>>> spanner = makeSpanner("trill")
>>> spanner.bind(chain[0], chain[-1])
"""
name, *rest = descr.split(":")
name = name.lower()
if name.startswith("~"):
name = name[1:]
kind = 'end'
cls = _spannerNameToConstructor.get(name)
if cls is None:
raise ValueError(f"Spanner class {name} not understood. "
f"Possible spanners are {_spannerNameToConstructor.keys()}")
kws = {}
for part in rest:
if part in {'solid', 'dashed', 'dotted', 'zigzag', 'trill', 'wavy'}:
kws['linetype'] = part
elif part in {'above', 'below'}:
kws['placement'] = part
elif '=' in part:
k, v = part.split('=', maxsplit=2)
if v == 'True' or v == 'False':
v = bool(v)
kws[k] = v
else:
raise ValueError(f"Spanner descriptor not understood: {part} ({descr})")
if placement:
kws['placement'] = placement
if color:
kws['color'] = color
if linetype:
kws['linetype'] = linetype
spanner = cls(kind=kind, **kws)
return spanner
# --------------------------------
[docs]
class EventSymbol(Symbol):
"""
Base-class for all event-attached symbols
These are symbols attached to one event (note, chord, rest, clip, ...).
The color and placement attributes do not apply for all symbols of this
kind but we include it at this level to make the structure simpler
Args:
color (str): The color of the symbol.
placement (str): The placement of the symbol. One of 'above', 'below' or ''
to use the default placement
noMergeNext: if True, do not allow an event with this symbol to be merged
with another event
"""
appliesToRests = True
def __init__(self, color='', placement='', noMergeNext=False):
super().__init__()
assert not placement or placement in ('above', 'below')
self.placement = placement
self.noMergeNext = noMergeNext
self.color = color
[docs]
def checkAnchor(self, anchor: mevent.MEvent) -> str:
"""Returns an error message if the event cannot add this symbol"""
return ''
[docs]
def scoringAttachment(self) -> _attachment.Attachment:
raise NotImplementedError("Class should implement this method or "
"override applyToNotation")
[docs]
def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None:
if self.noMergeNext:
n.mergeableNext = False
attachment = self.scoringAttachment()
n.addAttachment(attachment)
[docs]
class Hidden(EventSymbol):
"""A hidden property can be attached to note to hide it"""
exclusive = True
applyToTieStrategy = 'all'
[docs]
def scoringAttachment(self) -> _attachment.Attachment:
return _attachment.Hidden()
[docs]
class SizeFactor(EventSymbol):
"""Sets the size of an object (as a factor of default size)"""
applyToTieStrategy = 'all'
exclusive = True
def __init__(self, size: float):
super().__init__()
self.size = size
def __repr__(self):
return f"SizeFactor({self.size})"
[docs]
def scoringAttachment(self) -> _attachment.Attachment:
return _attachment.SizeFactor(size=self.size)
[docs]
class Color(EventSymbol):
"""Customizes the color of an object"""
exclusive = True
applyToTieStrategy = 'all'
def __init__(self, color: str):
super().__init__()
self.color = color
def __repr__(self):
return f"Color({self.color})"
[docs]
def scoringAttachment(self) -> _attachment.Attachment:
return _attachment.Color(self.color)
[docs]
class NoteheadSymbol(Symbol):
"""Symbols attached to a notehead (a pitch)"""
appliesToRests = False
[docs]
@abstractmethod
def applyToPitch(self, n: scoring.Notation, idx: int | None, parent: mobj.MObj | None
) -> None:
raise NotImplementedError
[docs]
def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None:
for idx in range(len(n.pitches)):
self.applyToPitch(n, idx=idx, parent=parent)
[docs]
class PartMixin:
"""Symbols which can be attached to a voice"""
applyToAllParts = True
"""Within a multipart voice, apply this symbol to all parts"""
[docs]
@abstractmethod
def applyToPart(self, part: scoring.core.UnquantizedPart) -> None:
raise NotImplementedError
# ----------------------------------------------------------
[docs]
class BeamSubdivision(EventSymbol, PartMixin):
"""
Customize beam subdivision
The customization is applied to the beat starting at this event
The beams of consecutive 16th (or shorter) notes are, by default, not subdivided.
That is, the beams of more than two stems stretch unbroken over entire groups of notes.
This behavior can be modified to subdivide the beams into sub-groups. Beams will be
subdivided at intervals to match the metric value of the subdivision.
.. note:: At the moment this is only supported by the lilypond backend
Args:
minimum: minimum limit of beam subdivision. A fraction or simply the denominator.
1/8 indicates an eighth note, 16 indicates a 16th note
maximum: maximum limit of beam subdivision. Similar to minimum
"""
exclusive = True
appliesToRests = True
def __init__(self, minimum: int | F = 0, maximum: int | F = 0):
super().__init__()
self.minimum: F = minimum if isinstance(minimum, F) else F(1, minimum) if minimum > 0 else F(0)
self.maximum: F = maximum if isinstance(maximum, F) else F(1, maximum) if maximum > 0 else F(0)
[docs]
def scoringAttachment(self) -> _attachment.Attachment:
return _attachment.BeamSubdivisionHint(minimum=self.minimum, maximum=self.maximum)
[docs]
def applyToPart(self, part: scoring.core.UnquantizedPart) -> None:
att = _attachment.BeamSubdivisionHint(minimum=self.minimum,
maximum=self.maximum,
once=False)
part.notations[0].addAttachment(att)
[docs]
class Clef(EventSymbol):
"""
An explicit clef sign, applied prior to the event attached
Args:
kind: one of 'treble15', 'treble8', 'treble', 'bass', 'alto', 'bass8', 'bass15'
Some clefs have alternate names:
========= ==================
Clef Aliases
========= ==================
treble violin, g
bass f
alto viola
========= ==================
"""
exclusive = True
appliesToRests = True
def __init__(self, kind: str, color=''):
super().__init__(color=color)
clef = scoring.definitions.clefs.get(kind)
if clef is None:
raise ValueError(f"Clef {kind} unknown. Possible values: {scoring.definitions.clefs}")
self.kind = clef
[docs]
def scoringAttachment(self) -> _attachment.Attachment:
return _attachment.Clef(self.kind, color=self.color)
def __repr__(self):
return _util.reprObj(self, priorityargs=('kind',), hideFalsy=True)
[docs]
class Ornament(EventSymbol):
"""
Note-attached ornament (trill, mordent, prall, etc.)
Args:
kind: one of 'trill', 'mordent', 'prall', 'turn'
(see ``maelzel.scoring.definitions.avialableOrnaments``)
"""
exclusive = False
appliesToRests = False
def __init__(self, kind: str, color=''):
super().__init__(color=color)
if kind not in scoring.definitions.availableOrnaments:
raise ValueError(f"Ornament {kind} unknown. "
f"Possible values: {scoring.definitions.availableOrnaments}")
self.kind = kind
[docs]
def scoringAttachment(self) -> _attachment.Attachment:
return _attachment.Ornament(self.kind, color=self.color)
[docs]
class Tremolo(EventSymbol):
"""
A stem-attached tremolo sign
"""
exclusive = True
appliesToRests = False
def __init__(self, tremtype='single', nummarks: int = 2, relative=False, color=''):
"""
Args:
tremtype: the type of tremolo. 'single' indicates a repeated note/chord,
'start' indicates the first of two alternating notes/chords,
'end' indicates the second of two alternating notes/chords
nummarks: how many tremolo marks (2=16th tremolo, 3=32nd tremolo, ...)
relative: if True, the number of marks depends on the rhythmic figure
to which the tremolo is attached. For example, if relative is True,
a tremolo with nummarks 2 attached to an 8th note would result
in a single-beam tremolo. If relative is False, nummarks will
always determine the number of beams
"""
super().__init__(color=color)
assert tremtype in {'single', 'start', 'end'}, f'Unknown tremolo type: {tremtype}'
self.tremtype = tremtype
self.nummarks = nummarks
self.relative = relative
[docs]
def scoringAttachment(self) -> _attachment.Attachment:
return _attachment.Tremolo(tremtype=self.tremtype, nummarks=self.nummarks, relative=self.relative, color=self.color)
[docs]
class Fermata(EventSymbol):
"""A Fermata sign over a note"""
exclusive = True
appliesToRests = True
def __init__(self, kind='normal', mergenext=True, color=''):
"""
A fermata symbol over an event (note, rest, chord, ...)
Args:
kind: one of 'normal', 'square', 'angled', 'double-angled', 'double-square'
mergenext: whether an event with a fermata can be merged with
its right neighbour (if tied or two rests)
"""
super().__init__(color=color, noMergeNext=not mergenext)
self.kind = kind
[docs]
def scoringAttachment(self) -> _attachment.Attachment:
return _attachment.Fermata(kind=self.kind, color=self.color)
[docs]
class Breath(EventSymbol):
"""
A breathmark symbol, will also break the beam at the given instant
The breathmark is applied prior to the event
Args:
kind: one of 'comma', 'varcomma', 'upbow', 'outsidecomma', 'caesura', 'chant'
(see maelzel.scoring.definitions.breathMarks)
visible: if False, the mark will not be shown in notation but will still have
an effect on beaming
horizontalPlacement: one of 'pre', 'post'. Indicates whether the break
should be placed before or after the event
"""
exclusive = True
appliesToRests = True
def __init__(self, kind='', visible=True, horizontalPlacement='pre'):
super().__init__()
self.visible = visible
self.kind = kind
self.horizontalPlacement = horizontalPlacement
[docs]
def scoringAttachment(self) -> _attachment.Attachment:
return _attachment.Breath(kind=self.kind, visible=self.visible, horizontalPlacement=self.horizontalPlacement)
[docs]
class Text(EventSymbol):
"""
A note attached text expression
Args:
text: the text
placement: 'above', 'below' or None to leave it undetermined
fontsize: the size of the text. The actual resulting size will depend
on the backend used
weight: one of 'normal', 'bold'
italic: should this text be italic?
color: a valid css color
box: one of 'square', 'rectangle', 'circle' or '' to disable
force: force the text to be displayed even if the event is tied
"""
exclusive = False
appliesToRests = True
def __init__(self, text: str, placement='above', fontsize: float | None = None,
italic=False, weight='normal', box='',
color='', fontfamily='', force=False):
assert fontsize is None or isinstance(fontsize, (int, float)), \
f"Invalid fontsize: {fontsize}, type: {type(fontsize)}"
_util.checkChoice('box', box, ('', 'square', 'rectangle', 'circle'))
_util.checkChoice('weight', weight, ('normal', 'bold'))
super().__init__(color=color, placement=placement)
self.text = text
self.fontsize = fontsize
self.italic = italic
self.weight = weight
self.fontfamily = fontfamily
self.box = box
self.force = force
def __repr__(self):
return _util.reprObj(self, priorityargs=('text',), hideFalsy=True,
quoteStrings=True,
filter={'italic': lambda val: val,
'weight': lambda val: val != 'normal'})
[docs]
def scoringAttachment(self) -> _attachment.Attachment:
return _attachment.Text(text=self.text, placement=self.placement,
fontsize=self.fontsize, italic=self.italic,
weight=self.weight, box=self.box,
fontfamily=self.fontfamily)
def __hash__(self):
return hash((type(self).__name__, self.text, self.placement, self.fontsize, self.italic,
self.weight, self.fontfamily))
[docs]
class Transpose(EventSymbol, PartMixin):
"""
Apply a transposition for notation only
Notice that this will not have any effect other than modify the pitch
used for notation.
.. seealso:: :class:`NotatedPitch`
"""
exclusive = True
applyToTieStrategy = 'all'
appliesToRests = False
def __init__(self, interval: str | float):
super().__init__()
if isinstance(interval, str):
if interval[0] in '+-':
semitones = _intervalToSemitones.get(interval[1:])
if semitones is None:
raise ValueError(f"Invalid interval '{interval[1:]}', "
f"possible intervals: {_intervalToSemitones.keys()}")
if interval[0] == '-':
semitones = -semitones
interval = semitones
elif interval in _intervalToSemitones:
interval = _intervalToSemitones[interval]
else:
raise ValueError(f"Invalid interval '{interval}', Valid transpositions "
f"are {_intervalToSemitones.keys()}")
if interval == 0:
raise ValueError(f"Invalid transposition interval: {interval}")
self.interval: float = interval
def __repr__(self):
return _util.reprObj(self, priorityargs=('interval',))
[docs]
def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None:
pitches = [pitch + self.interval for pitch in n.pitches]
fixed = n.fixedNotenames.copy() if n.fixedNotenames else None
n.setPitches(pitches)
if fixed:
for i, pitch in fixed.items():
n.fixNotename(pt.transpose(pitch, self.interval), index=i)
[docs]
def applyToPart(self, part: scoring.core.UnquantizedPart) -> None:
for notation in part.notations:
if not notation.isRest:
self.applyToNotation(notation, parent=None)
[docs]
class NotatedPitch(NoteheadSymbol):
"""
Allows to customize the notated pitch of a note
This can be used both to fix the enharmonic variant or to display a different
pitch altogether.
Notice that this will not have any effect other than modify the pitch
used for notation
To apply a relative transposition, see :class:`Transpose`
.. seealso:: :class:`Transpose`
"""
exclusive = True
applyToTieStrategy = 'all'
appliesToRests = False
def __init__(self, pitch: str):
super().__init__()
self.pitch = pitch
def __repr__(self):
return _util.reprObj(self, priorityargs=('pitch',), hideEmptyStr=True)
[docs]
def applyToPitch(self, n: scoring.Notation, idx: int | None, parent: mobj.MObj | None
) -> None:
#if type(parent).__name__ != 'Note':
# raise TypeError(f"Expected a Note, got {parent}")
if idx is None:
from maelzel.core import event
assert isinstance(parent, event.Note)
pitch = parent.pitch
idx = min(range(len(n.pitches)), key=lambda idx: abs(pitch - n.pitches[idx]))
n.fixNotename(notename=self.pitch, index=idx)
_intervalToSemitones = {
'2nd': 2,
'2m': 1,
'2M': 2,
'3rd': 4,
'3m': 3,
'3M': 4,
'4': 5,
'4th': 5,
'5': 7,
'5th': 7,
'6m': 8,
'6M': 9,
'7m': 10,
'7M': 11,
'8': 12,
'8a': 12,
'8va': 12,
'15': 24,
'15a': 24,
'15va': 24,
'8b': -12,
'15b': -24,
}
[docs]
class Harmonic(EventSymbol):
"""
A natural/artificial harmonic, or a sound-pitch flageolet
In the case of a natural or artificial harmonic, the notated pitch
is the "action" pitch. In the case of a natural harmonic, for a string
instrument this means the pitch of the node to lightly touch.
Fon an attificial harmonic the note to which this symbol is attached
identifies the pressed pitch, the interval will determine the
node to touch (for a 4th harmonic the interval should be 5 since the
4th is 5 semitones above of the pressed pitch)
A flageolet is a harmonic where the written pitch indicates the
sounding pitch, and the means of execution is left for the player
to determine.
If the interval is given then an artificial harmonic is assumed.
Args:
kind: either 'natural', 'artificial' or an interval as string
('4th' is a perfect fourth, '3M' is a major third). In this last
case the kind is set to artificial and the interval is set
to this value
interval: the interval for artificial harmonics. If set, the
kind is set to 'artificial'
============= ========== =========
Interval semitones String
============= ========== =========
Perfect fifth 7 5th
Perfect fourth 5 4th
Major third 4 3M
Minor third 3 3m
Major second 2 2 or 2M
============= ========== =========
Example
~~~~~~~
# A string 4th (2 octave higher) harmonic
>>> symbols.Harmonic('4th')
# A flageolet, where the written note indicates the sounding pitch
>>> symbols.Harmonic('natural')
# A touch harmonic, where the written note indicates where to slightly
# touch the string. The interval is left as 0
>>> symbols.Harmonic('artificial')
"""
applyToRests = False
def __init__(self, kind='natural', interval: int | str = 0):
super().__init__()
if kind in _intervalToSemitones:
assert interval == 0
semitone = _intervalToSemitones[kind]
kind = 'artificial'
elif interval == 0:
semitone = 0
else:
kind = 'artificial'
if isinstance(interval, str):
semitone = _intervalToSemitones.get(kind)
if semitone is None:
raise ValueError(f"Invalid interval, expected one of {_intervalToSemitones.keys()}")
else:
semitone = interval
assert kind in ('natural', 'artificial')
self.kind: str = kind
self.interval: int = semitone
[docs]
def scoringAttachment(self) -> _attachment.Attachment:
if self.kind == 'natural':
return _attachment.Articulation('flageolet')
else:
return _attachment.Harmonic(self.interval)
[docs]
def applyToTiedGroup(self, notations: Sequence[scoring.Notation], parent: mobj.MObj | None
) -> None:
if self.kind == 'natural':
self.applyToNotation(notations[0], parent=parent)
else:
for n in notations:
n.addAttachment(_attachment.Harmonic(self.interval))
[docs]
class Notehead(NoteheadSymbol):
"""
Customizes the notehead shape, color, parenthesis and size
Args:
shape: one of 'cross', 'harmonic', 'triangleup', 'xcircle',
'triangle', 'rhombus', 'square', 'rectangle'. You can also add parenthesis
to the shape, as '(x)' or even '()' to indicate a normal, parenthesized
notehead
color: a css color (str)
parenthesis: if True, parenthesize the notehead
size: a size factor (1.0 means the size corresponding to the staff size, 2. indicates
a notehead twice as big)
"""
exclusive = False
applyToTieStrategy = 'all'
appliesToRests = False
def __init__(self, shape='', color='', parenthesis=False,
size: float | None = None, hidden=False):
super().__init__()
self.hidden = hidden
if shape and shape.endswith('?'):
parenthesis = True
shape = shape[:-1] if len(shape) > 1 else 'normal'
elif shape and shape[0] == '(' and shape[-1] == ')':
shape = shape[1:-1]
if not shape:
shape = 'normal'
parenthesis = True
elif shape == 'hidden':
shape = ''
self.hidden = True
if shape:
shape2 = scoring.definitions.normalizeNoteheadShape(shape)
assert shape2, (f"Notehead '{shape}' unknown. Possible noteheads: "
f"{scoring.definitions.noteheadShapes}")
shape = shape2
self.shape = shape
self.color = color
self.parenthesis = parenthesis
self.size = size
def __hash__(self):
return hash((type(self).__name__, self.shape, self.color, self.parenthesis, self.size))
def __repr__(self):
return _util.reprObj(self, priorityargs=('shape,'), hideFalsy=True)
[docs]
def asScoringNotehead(self) -> scoring.definitions.Notehead:
return scoring.definitions.Notehead(shape=self.shape, color=self.color, size=self.size,
parenthesis=self.parenthesis, hidden=self.hidden)
[docs]
def applyToPitch(self, n: scoring.Notation, idx: int | None, parent: mobj.MObj | None
) -> None:
# if idx is None, apply to all noteheads
scoringNotehead = self.asScoringNotehead()
n.setNotehead(scoringNotehead, index=idx, merge=True)
[docs]
def iscolor(s: str) -> bool:
return (re.match(r"^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$", s)) is not None or s in colortheory.cssColors()
[docs]
def makeKnownSymbol(name: str) -> Symbol | None:
"""
Create a symbol from a known name
Args:
name: the name of the symbol ('accent', 'fermata', 'mordent', etc.). Supported
are all articulations, ornaments, breath marks ('comma', 'caesura'),
colors, css colors, 'fermata'.
Returns:
the created Symbol, or None if the name cannot be interpreted
as a Symbol. No Exceptions are thrown
This is mostly used internally to add symbols to notes/chords which are defined
as strings. For example, a note defined as "C4:1:accent", will result in a
C4 note with a duration of 1 (quarternotes) and an Accent symbol.
"""
name = name.lower()
if name in scoring.definitions.allArticulations():
return Articulation(name)
if name in scoring.definitions.availableOrnaments:
return Ornament(name)
if name in ('comma', 'caesura'):
return Breath(name)
if name == 'break':
return BeamBreak()
if name == 'tremolo':
return Tremolo()
if iscolor(name):
return Color(name)
if name == 'fermata':
return Fermata()
if name == 'nomerge':
return NoMerge()
return None
[docs]
@cache
def knownSymbols() -> set[str]:
out = set()
out |= scoring.definitions.allArticulations()
out |= scoring.definitions.availableOrnaments
out |= {'comma', 'caesura', 'fermata', 'break'}
return out
[docs]
class Dynamic(EventSymbol):
"""
A notation only dynamic
This should only be used for the case where a note should
have a dynamic only for display
"""
exclusive = True
appliesToRests = False
def __init__(self, kind: str, force=False, placement=''):
if kind not in scoring.definitions.validDynamics():
raise ValueError(f"Invalid dynamic '{kind}', "
f"possible dynamics: {scoring.definitions.validDynamics()}")
super().__init__(placement=placement)
self.kind = kind
self.force = force
def __hash__(self):
return hash((type(self).__name__, self.kind, self.placement))
def __repr__(self):
return _util.reprObj(self, hideKeys=('kind',), hideFalsy=True)
[docs]
def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None:
n.dynamic = self.kind
if self.force:
n.dynamic += '!'
[docs]
class Articulation(EventSymbol):
"""
Represents a note attached articulation
"""
exclusive = False
appliesToRests = False
def __init__(self, kind: str, color='', placement=''):
super().__init__(color=color, placement=placement)
normalized = scoring.definitions.normalizeArticulation(kind)
assert normalized, f"Articulation {kind} unknown. Possible values: " \
f"{scoring.definitions.articulations}"
self.kind = normalized
def __hash__(self):
return hash((type(self).__name__, self.kind, self.color, self.placement))
def __repr__(self):
return _util.reprObj(self, hideKeys=('kind',), hideFalsy=True)
[docs]
def scoringAttachment(self) -> _attachment.Attachment:
return _attachment.Articulation(kind=self.kind, color=self.color, placement=self.placement)
[docs]
def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None:
if n.isRest:
logger.warning(f"Cannot apply {self} to a rest: {n}")
else:
if n.tiedPrev:
logger.debug(f"Applying articulation {self} to {n}, even if it is tied to prev")
super().applyToNotation(n=n, parent=parent)
[docs]
class Fingering(EventSymbol):
"""
A fingering attached to a note
"""
exclusive = True
appliesToRests = False
def __init__(self, finger: str):
super().__init__()
self.finger = finger
[docs]
def scoringAttachment(self) -> _attachment.Attachment:
return _attachment.Fingering(self.finger)
[docs]
class Bend(EventSymbol):
"""
A (post) bend with an alteration up or down
A bend is a modification of the pitch starting at the pitch
of the note and going in a curve up or down a number of semitones
Attributes:
alter: the alteration of the bend, in semitones (> 0 is an upward bend)
"""
exclusive = True
appliesToRests = False
def __init__(self, alter: float):
super().__init__()
self.alter = alter
[docs]
def scoringAttachment(self) -> _attachment.Attachment:
return _attachment.Bend(self.alter)
[docs]
class NoMerge(EventSymbol):
"""
A note marked with this symbol will not be merged to a previous note
This is true even if the note is tied to the previous and the
symbolic durations, after quantization, are compatible to be
merged into a longer duration
Args:
prev: if True, this cannot be merged to the previous note
next: if True, this cannot be merged to the next note
"""
appliesToRests = True
def __init__(self, prev=True, next=False):
super().__init__()
self.prev = prev
self.next = next
[docs]
def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None:
if self.prev:
n.mergeablePrev = False
if self.next:
n.mergeableNext = False
[docs]
class Stem(EventSymbol):
"""
Customize the stem of a note
Attributes:
hidden: if True, the stem will be hidden
"""
appliesToRests = False
def __init__(self, hidden: bool = False, color=''):
super().__init__(color=color)
self.hidden = hidden
[docs]
def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None:
if self.hidden or self.color:
n.addAttachment(_attachment.StemTraits(hidden=self.hidden, color=self.color))
[docs]
class Gracenote(EventSymbol):
"""
Customizes properties of a gracenote
"""
def __init__(self, slash=False, value=F(1, 2)):
super().__init__()
self.slash = slash
self.value = value
[docs]
def scoringAttachment(self) -> _attachment.Attachment:
return _attachment.GracenoteProperties(slash=self.slash, value=self.value)
[docs]
def checkAnchor(self, anchor: mevent.MEvent) -> str:
if anchor.dur != 0:
return f'A {type(self)} can only be added to a gracenote, got {anchor}'
return ''
[docs]
class GlissProperties(EventSymbol):
"""
Customizes properties of a glissando
It can only be attached to an event which starts a glissando
Args:
linetype: the linetype to use, one of 'solid', 'dashed', 'dotted', 'trill'
color: the color of the line
hidden: if True the glissando is not represented as notation. When
applied to a Notation, the .gliss attribute of the Notation is
set to false.
"""
exclusive = True
appliesToRests = False
def __init__(self, linetype='solid', color='', hidden=False):
super().__init__()
_util.checkChoice('linetype', linetype, _attachment.GlissProperties.linetypes)
self.linetype = linetype
self.color = color
self.hidden = hidden
[docs]
def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None:
if self.hidden:
n.gliss = False
return
elif not n.gliss and not (n.tiedPrev and n.tiedNext):
raise ValueError("Cannot apply GlissProperties to a Notation without glissando,"
f"(destination: {n})")
n.addAttachment(_attachment.GlissProperties(linetype=self.linetype, color=self.color))
[docs]
def checkAnchor(self, anchor: mevent.MEvent) -> str:
return f'This event ({self}) has no glissando' if not anchor.gliss else ''
[docs]
class Accidental(NoteheadSymbol):
"""
Customizes the accidental of a note
An accidental applies to a note/chord and is bound to the note/chord. Within
a chord any accidental attached to its individual notes is ...
TODO: clear what happends with symbols within a chord
Args:
hidden: is this accidental hidden?
parenthesis: put this accidental between parenthesis?
color: the color of this accidental
force: if True, force the accidental to be shown
size: a size factor
"""
exclusive = True
appliesToRests = False
def __init__(self,
hidden=False,
parenthesis=False,
color='',
force=False,
size: float | None = None):
super().__init__()
self.hidden: bool = hidden
self.parenthesis: bool = parenthesis
self.color: str = color
self.force: bool = force
self.size: float | None = size
def __repr__(self):
return _util.reprObj(self, hideFalse=True, hideEmptyStr=True)
def __hash__(self):
return hash((type(self).__name__, self.hidden, self.parenthesis, self.color, self.force))
[docs]
def applyToPitch(self, n: scoring.Notation, idx: int | None, parent: mobj.MObj | None
) -> None:
if n.isRest:
return
attachment = _attachment.AccidentalTraits(color=self.color,
hidden=self.hidden,
parenthesis=self.parenthesis,
force=self.force,
size=self.size)
n.addAttachment(attachment, pitchanchor=idx)
# -------------------------------------------------------------------
[docs]
class QuantHint(EventSymbol):
def __init__(self, division: tuple[int, ...], strength=100.):
super().__init__()
self.division = division
self.strength = strength
[docs]
def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None:
n.addAttachment(_attachment.QuantHint(division=self.division, strength=self.strength))
[docs]
class BeamBreak(EventSymbol):
"""
Symbolizes a beam break at the given location
A BeamBreak can be added both to a Note/Chord or to a Voice via ``.addSymbolAt``.
.. note::
When a BeamBreak is added to a Part this does not modify in any way the
contents of the Voice. The modification takes place at quantization.
The resulting Notation present at the given location is broken at that
point (adding a tie if needed)
"""
[docs]
def scoringAttachment(self) -> _attachment.Attachment:
return _attachment.Breath(visible=False)
[docs]
def parseAddSymbol(args: tuple, kws: dict) -> Symbol:
"""
Parses the input of addSymbol
.. seealso:: :meth:`maelzel.core.mobj.mobj.MObj.addsymbol`
Args:
args: args as passed to event.addSymbol
kws: keywords as passed to event.addSymbol
Returns:
a list of Symbols
"""
if len(args) == 2 and all(isinstance(arg, str) for arg in args) and not kws:
symbolclass, kind = args
symboldef = makeSymbol(symbolclass, kind)
elif len(args) == 1:
arg = args[0]
if isinstance(arg, str):
if not kws:
symboldef = makeKnownSymbol(arg)
else:
symboldef = makeSymbol(arg, **kws)
if not symboldef:
raise ValueError(f"{arg} is not a known symbol. Known symbols are: {knownSymbols()}")
elif not kws and isinstance(arg, Symbol):
symboldef = arg
elif isinstance(arg, type) and issubclass(arg, Symbol):
symboldef = arg()
else:
raise ValueError(f"Could not add a symbol with {args=}, {kws=}")
elif kws:
symboldef = next(makeSymbol(key, value) for key, value in kws.items())
else:
raise ValueError(f"Could not add a symbol with {args=}, {kws=}")
return symboldef
# -------------------------------
_symbolClasses = (
Notehead,
Articulation,
Text,
SizeFactor,
Color,
Accidental,
Ornament,
Fermata,
NotatedPitch,
Slur,
Hairpin,
LineSpan,
TrillLine,
NoteheadLine,
Bend,
Fingering,
Harmonic,
Breath,
BeamBreak,
Stem,
GlissProperties,
Gracenote,
Transpose
)
_voiceSymbols = (
Transpose,
)
_symbolNameToClass = {cls.__name__.lower(): cls for cls in _symbolClasses}
[docs]
def makeSymbol(clsname: str, *args, **kws) -> Symbol:
"""
Construct a Symbol from the symbol name and any values and/or keywords passed
Args:
clsname: the symbol name (case independent)
*args: any other arguments passed to the constructor
**kws: any keywords passed to the constructor
Returns:
the created Symbol
"""
cls = _symbolNameToClass.get(clsname.lower())
if cls is None:
raise ValueError(f"Class '{clsname}' unknown. "
f"Known symbol names: {list(_symbolNameToClass.keys())}")
return cls(*args, **kws)