"""
All individual events inherit from :class:`MEvent`
Events can be chained together (with ties or glissandi )
within a :class:`~maelzel.core.chain.Chain` or a :class:`~maelzel.core.chain.Chain`
to produce more complex horizontal structures. When an event is added
to a chain this chain becomes the event's *parent*.
Absolute / Relative Offset
--------------------------
The :attr:`~maelzel.core.event.MEvent.offset` attribute of any event determines the
start of the event **relative** to the parent container (if the event has not parent
the relative and the absolute offset are the same). The resolved offset can be queried
via :meth:`~maelzel.core.mobj.MObj.relOffset`, the absolute offset via
:meth:`~maelzel.core.mobj.MObj.absOffset`
"""
from __future__ import annotations
import math
import functools
import pitchtools as pt
from maelzel import scoring
from maelzel.core import mobj
from maelzel.common import F, asF, F1, asmidi
from maelzel.common import UNSET
from maelzel.core.workspace import Workspace
from maelzel.core.mevent import MEvent
from maelzel.core._common import MAXDUR, logger
from maelzel.core import synthevent
from maelzel.core import _tools
from . import symbols as _symbols
from maelzel import _util
from typing import TYPE_CHECKING, overload as _overload, cast as _cast
if TYPE_CHECKING:
from .config import CoreConfig
from typing import Callable, Any, Sequence, Iterator
from typing_extensions import Self
from maelzel.common import time_t, pitch_t, num_t, UnsetType
from maelzel.dynamiccurve import DynamicCurve
__all__ = (
'MEvent',
'Note',
'Chord',
'Rest',
'asEvent',
'Grace'
)
[docs]
@functools.total_ordering
class Note(MEvent):
"""
A Note represents a one-pitch event
A Note makes a clear division between the value itself and the
representation as notation or as sound. Playback specific options
(instrument, pan position, etc) can be set via the
:meth:`~Note.setPlay` method.
Any aspects regarding notation (articulation, enharmonic variant, etc)
can be set via :meth:`~Note.addSymbol`
The *pitch* parameter can be used to set the pitch and other attributes in
multiple ways.
* To set the pitch from a frequency, use `pitchtools.f2m` or use a string as '400hz'.
* The spelling of the notename can be fixed by suffixing the notename with a '!' sign.
For example, ``Note('4Db!')`` will fix the spelling of this note to Db
instead of C#. This is the same as ``Note('4Db', fixed=True)``. It is also possible
to bind all pitches given as notename, see :ref:`fixStringNotenames <config_fixstringnotenames>`
* The duration can be set as ``Note('4C#:0.5')`` to set a duration of 0.5, or
also Note('4C#/8') to set a duration of an 8th note
* Dynamics can also be set: ``Note('4C#:mf')``. Multiple settings can be combined:
``Note('4C#:0.5:mf')``.
Args:
pitch: a midinote or a note as a string. A pitch can be a midinote (a float) or a notename (a string).
dur: the duration of this note (optional)
amp: amplitude 0-1 (optional)
offset: offset time fot the note, in quarternotes (optional). If None, the offset time
will depend on the context (previous notes) where this Note is evaluated.
gliss: if given, defines a glissando. It can be either the endpitch of the glissando, or
True, in which case the endpitch remains undefined
label: a label str to identify this note
dynamic: allows to attach a dynamic expression to this Note. This dynamic
is only for notation purposes, it does not modify playback
tied: is this Note tied to the next?
fixed: if True, fix the spelling of the note to the notename given (this is
only taken into account if the pitch was given as a notename). See also
the configuration key `fixStringNotenames <config_fixstringnotenames>`.
**NB**: as a shortcut, it is possible to suffix the notename with a !
mark in order to lock its spelling, independently of the configuration
settings
_init: if True, fast initialization is performed, skipping any checks. This is
used internally for fast copying/cloning of objects.
"""
__slots__ = ('pitch',
'pitchSpelling',
'_gliss'
)
def __init__(self,
pitch: pitch_t,
dur: time_t | None = None,
*,
amp: float | None = None,
offset: time_t | None = None,
gliss: pitch_t | bool = False,
label='',
dynamic='',
tied=False,
properties: dict[str, Any] | None = None,
symbols: list[_symbols.Symbol] | None = None,
fixed=False,
_init=True
):
pitchSpelling = ''
spanners: list[_symbols.Spanner] | None = None
if not _init:
midinote = _cast(float, pitch)
assert offset is None or isinstance(offset, F)
else:
if not isinstance(pitch, str):
assert 0 <= pitch <= 200, f"Expected a midinote (0-127) but got {pitch}"
midinote = pitch
else:
if ":" in pitch:
props = _tools.parseNote(pitch)
dur = dur if dur is not None else props.dur
if isinstance(props.notename, list):
raise ValueError(f"Can only accept a single pitch, got {props.notename}")
pitch = props.notename
if p := props.keywords:
offset = offset or p.pop('offset', None)
dynamic = dynamic or p.pop('dynamic', '')
tied = tied or p.pop('tied', False)
gliss = gliss or p.pop('gliss', False)
fixed = p.pop('fixPitch', False) or fixed
label = label or p.pop('label', '')
properties = p if not properties else p | properties
if props.symbols:
if symbols is None:
symbols = props.symbols
else:
symbols.extend(props.symbols)
if props.spanners:
spanners = props.spanners
elif "/" in pitch:
parsednote = _tools.parseNote(pitch)
if not isinstance(parsednote.notename, str):
raise ValueError(f"A Note can only have one pitch, got {parsednote.notename}")
pitch = parsednote.notename
dur = parsednote.dur
assert isinstance(pitch, str)
pitch = pitch.lower()
if pitch == 'rest' or pitch == 'r':
midinote, amp = 0, 0
else:
pitch, _tied, _fixed = _tools.parsePitch(pitch)
if _tied:
tied = _tied
if _fixed:
fixed = _fixed
if pitch.endswith('hz'):
pitchSpelling = ''
else:
pitchSpelling = pt.notename_upper(pitch)
midinote = pt.str2midi(pitch)
if not fixed and Workspace.active.config['fixStringNotenames']:
fixed = True
if offset is not None:
offset = asF(offset)
if not isinstance(gliss, bool):
gliss = asmidi(gliss)
if amp and amp > 0:
assert midinote > 0
assert properties is None or isinstance(properties, dict)
dur = asF(dur) if dur is not None else F1
super().__init__(dur=dur, offset=offset, label=label, properties=properties,
symbols=symbols, tied=tied, amp=amp, dynamic=dynamic)
if spanners is not None:
for sp in spanners:
self.addSpanner(sp)
self.pitch: float = midinote
"The pitch of this note"
self.pitchSpelling = '' if not fixed else pitchSpelling
"The pitch representation of this Note. Can be different from the sounding pitch"
self._gliss: float | bool = gliss # type: ignore
[docs]
@staticmethod
def makeRest(dur: time_t | str, offset: time_t = None, label='', dynamic='') -> Note:
"""
Static method to create a Rest
A Rest is actually a regular Note with pitch == 0.
Args:
dur: duration of the rest
offset: explicit offset of the rest
label: a label to attach to the rest
dynamic: a dynamic for this rest (yes, rests can have dynanic...)
Returns:
the Note object representing the rest
"""
return Rest(dur=dur, offset=offset, label=label, dynamic=dynamic)
[docs]
def setPlay(self, /, **kws) -> Note:
if glide := kws.pop('gliss', None) is not None:
glisstime = float(glide) if glide is not True else min(0.1, self.dur * F(3, 4))
kws['glisstime'] = glisstime
if not self._gliss:
self._gliss = True
self.addSymbol(_symbols.GlissProperties(hidden=True))
super().setPlay(**kws)
return self
[docs]
def transpose(self, interval: float, inplace=False) -> Note:
"""
Transpose this note by the given interval
If inplace is True the operation is done inplace and the
returned note can be ignored.
.. note::
If this Note has a set spelling, its spelling will also be transposed
To remove a fixed spelling do ``note.pitchSpelling = ''``
Args:
interval: the transposition interval (1=one semitone)
inplace: if True, the note itself is modified
Returns:
The transposed note
"""
out = self if inplace else self.copy()
out._setpitch(self.pitch+interval)
return out
[docs]
@classmethod
def grace(cls,
pitch: pitch_t,
stemless=False,
slash=False,
value: F | str | float | None = None,
parenthesis=False,
hidden=False,
**kws) -> Self:
"""
Class method to create a grace note
Args:
pitch: the pitch of the grace chord
stemless: if is stemless?
slash: slashed stem
value: the rhythic value of the grace note ("1/2" or F(1, 2)=8th note,
"1/8"=32nd note, etc.
parenthesis: is the notehead parenthesized?
hidden: should the whole note be hidden?
**kws: keyword args passed to the Note constructor
Returns:
the grace note
"""
note = cls(pitch=pitch, dur=0, **kws)
_customizeGracenote(note, stemless=stemless, slash=slash,
value=value, hidden=hidden, parenthesis=parenthesis)
return note
[docs]
def mergeWith(self, other: Note) -> Note | None:
"""
Merge this Note with another Note, if possible
Args:
other: the other note to merge this with
Returns:
the merged note or None if merging is not possible
"""
if self.isRest() and other.isRest():
assert self.dur is not None and other.dur is not None
out = self.clone(dur=self.dur + other.dur)
return out
if (not self.tied or
self.gliss or
other.isRest() or
self.isRest() or
self.pitch != other.pitch or
self.amp != other.amp or
self.dynamic != other.dynamic):
return None
return self.clone(dur=self.dur + other.dur, tied=other.tied)
def _setNotatedPitch(self, notename: str) -> None:
"""
Set the pitch representation when this object is rendered as notation
This will not change the actual pitch or its playback. The pitch may differ
from the actual pitch of the object.
Args:
notename: the pitch to use to represent this object. Can be set
to an empty string to remove any pitch previously set
"""
if not notename:
self._removeSymbolsOfClass(_symbols.NotatedPitch)
self.pitchSpelling = ''
else:
self.addSymbol(_symbols.NotatedPitch(notename))
self.pitchSpelling = notename
def _canBeLinkedTo(self, other: MEvent) -> bool:
if self.isRest() or not isinstance(other, (Note, Chord)) or other.isRest():
return False
if self._gliss is True or (self.playargs and self.playargs.get('glisstime', 0.) > 0.):
return True
if isinstance(other, Note):
if self.tied and self.pitch == other.pitch:
return True
elif isinstance(other, Chord):
if self.tied and self.pitch in other.pitches:
return True
return False
[docs]
def isGrace(self) -> bool:
return not self.isRest() and self.dur == 0
@property
def gliss(self) -> float | bool:
"""the end pitch (as midinote), True if the gliss extends to the next note, or False"""
return self._gliss
@gliss.setter
def gliss(self, gliss: pitch_t | bool):
"""
Set the gliss attribute of this Note, inplace
"""
self._gliss = gliss if isinstance(gliss, bool) else asmidi(gliss)
def __eq__(self, other: Note | str | float) -> bool:
if isinstance(other, (int, float)):
return self.pitch == other
elif isinstance(other, str):
try:
pitch = pt.str2midi(other)
return self.pitch == pitch
except ValueError as e:
raise ValueError(f"Cannot interpret '{other}' as a note: {e}")
elif isinstance(other, Note):
return hash(self) == hash(other)
else:
return False
[docs]
def copy(self) -> Self:
out = self.__class__(self.pitch, dur=self.dur, amp=self.amp, gliss=self._gliss, tied=self.tied,
dynamic=self.dynamic, offset=self.offset, label=self.label,
_init=False)
self._copyAttributesTo(out)
out.pitchSpelling = self.pitchSpelling
return out
[docs]
def clone(self,
pitch: pitch_t | None = None,
dur: time_t | None = None,
amp: float | None = None,
offset: time_t | None | UnsetType = UNSET,
gliss: pitch_t | bool | None = None,
label='',
tied: bool | None = None,
dynamic='') -> Self:
"""
Clone this note with overridden attributes
Returns a new note
"""
offset = self.offset if offset is UNSET else None if offset is None else asF(offset) # type: ignore
out = self.__class__(pitch=pitch if pitch is not None else self.pitch,
dur=asF(dur) if dur is not None else self.dur,
amp=amp if amp is not None else self.amp,
offset=offset,
gliss=gliss if gliss is not None else self.gliss,
label=label or self.label,
tied=tied if tied is not None else self.tied,
dynamic=dynamic or self.dynamic,
_init=False)
if pitch is not None:
if self.pitchSpelling:
out.pitchSpelling = pt.transpose(self.pitchSpelling, out.pitch - self.pitch)
else:
out.pitchSpelling = self.pitchSpelling
self._copyAttributesTo(out)
return out
def __hash__(self) -> int:
hashsymbols = hash(tuple(self.symbols)) if self.symbols else 0
return hash((self.pitch, self._dur, self.offset, self._gliss, self.label,
self.dynamic, self.tied, self.pitchSpelling, hashsymbols))
[docs]
def asChord(self, pitches: list[pitch_t] | None = None) -> Chord:
""" Convert this Note to a Chord of one note """
gliss: bool | list[float] = self.gliss if isinstance(self.gliss, bool) else [self.gliss]
properties = self.properties.copy() if self.properties else None
if pitches:
notes = pitches
else:
notes = [self]
chord = Chord(notes=notes,
dur=self.dur,
amp=self.amp,
offset=self.offset,
gliss=gliss,
label=self.label,
tied=self.tied,
dynamic=self.dynamic,
properties=properties,
_init=False)
if self.symbols:
for s in self.symbols:
chord.addSymbol(s)
if self.playargs:
chord.playargs = self.playargs.copy()
return chord
[docs]
def isRest(self) -> bool:
""" Is this a Rest? """
return self.pitch == 0
[docs]
def pitchRange(self) -> tuple[float, float] | None:
return self.pitch, self.pitch
[docs]
def meanPitch(self) -> float | None:
return self.pitch
[docs]
def timeShift(self, offset: time_t) -> Self:
reloffset = self.relOffset()
reloffset += offset
if reloffset < 0:
raise ValueError(f"Cannot shift to a negative offset, {offset=}, "
f"relative offset prior to shift: {self.relOffset()}, "
f"resulting offset: {reloffset}. ({self=})")
return self.clone(offset=reloffset)
[docs]
def freqShift(self, freq: float) -> Self:
"""
Return a copy of self, shifted in freq.
Example::
# Shifting a note by its own freq. sounds one octave higher
>>> n = Note("C3")
>>> n.freqShift(n.freq)
C4
"""
out = self.copy()
out._setpitch(pt.f2m(self.freq + freq))
return out
def __lt__(self, other: pitch_t | Note) -> bool:
if isinstance(other, Note):
return self.pitch < other.pitch
elif isinstance(other, (float, int, F)):
return self.pitch < other
elif isinstance(other, str):
return self.pitch < pt.str2midi(other)
else:
raise NotImplementedError()
def __gt__(self, other: pitch_t | Note) -> bool:
if isinstance(other, Note):
return self.pitch > other.pitch
elif isinstance(other, (float, int, F)):
return self.pitch > other
elif isinstance(other, str):
return self.pitch > pt.str2midi(other)
else:
raise NotImplementedError()
def __abs__(self) -> Self:
if self.pitch >= 0:
return self
return self.clone(pitch=-self.pitch)
def _setpitch(self, pitch: float) -> None:
interval = pitch - self.pitch
self.pitch = pitch
if self.pitchSpelling:
self.pitchSpelling = pt.transpose(self.pitchSpelling, interval)
@property
def freq(self) -> float:
"""The frequency of this Note (according to the current A4 value)"""
return pt.m2f(self.pitch)
@freq.setter
def freq(self, value: float) -> None:
self.pitch = pt.f2m(value)
@property
def name(self) -> str:
"""The notename of this Note"""
if self.isRest():
return 'Rest'
return self.pitchSpelling or pt.m2n(self.pitch)
@name.setter
def name(self, notename: str):
self.pitchSpelling = notename
self.pitch = pt.n2m(notename)
@property
def pitchclass(self) -> int:
"""The pitch-class of this Note (an int between 0-11)"""
return round(self.pitch) % 12
@property
def cents(self) -> int:
"""The fractional part of this pitch, rounded to the cent"""
return _tools.midicents(self.pitch)
[docs]
def scoringParts(self, config: CoreConfig | None = None) -> list[scoring.core.UnquantizedPart]:
if self.isRest():
notations = self.scoringEvents(config=config)
assert len(notations) == 1
n = notations[0]
n.mergeableNext = False
n.mergeablePrev = False
from maelzel.scoring import attachment
n.addAttachment(attachment.Breath(visible=False, horizontalPlacement='post'))
return [scoring.core.UnquantizedPart(notations)]
else:
return super().scoringParts(config)
[docs]
def scoringEvents(self,
groupid='',
config: CoreConfig | None = None,
parentOffset: F | None = None
) -> list[scoring.Notation]:
if not config:
config = Workspace.active.config
offset = self.absOffset() if parentOffset is None else self.relOffset() + parentOffset
dur = self.dur
def _mergeOptLists(a: list | None, b: list | None) -> list | None:
return a+b if (a and b) else a or b
if self.isRest():
rest = scoring.Notation.makeRest(dur, offset=offset, dynamic=self.dynamic)
if self.label:
rest.addText(self.label, role='label')
tempsymbols = self.properties.pop('.tempsymbols', None) if self.properties else None
if (symbols := _mergeOptLists(tempsymbols, self.symbols)) is not None:
for symbol in symbols:
if symbol.appliesToRests:
symbol.applyToNotation(rest, parent=self)
return [rest]
notation = scoring.Notation.makeNote(pitch=self.pitch,
duration=asF(dur),
offset=offset,
gliss=bool(self.gliss),
dynamic=self.dynamic,
group=groupid)
if self.pitchSpelling:
notation.fixNotename(self.pitchSpelling, index=0)
if self.tied:
notation.tiedNext = True
notes = [notation]
if self.gliss and not isinstance(self.gliss, bool):
offset = self.end if self.end is not None else None
groupid = groupid or str(hash(self))
notes[0].groupid = groupid
assert self.gliss >= 12, f"self.gliss = {self.gliss}"
notes.append(scoring.Notation.makeNote(pitch=self.gliss,
duration=0,
offset=offset,
group=groupid))
if config['show.glissStemless']:
from maelzel.scoring import attachment
notes[-1].addAttachment(attachment.StemTraits(hidden=True))
if self.label:
labelsymbol = self._labelSymbol(self.label, config=config)
labelsymbol.applyToNotation(notes[0])
tempsymbols = self.properties.pop('.tempsymbols', None) if self.properties else None
if (symbols := _mergeOptLists(tempsymbols, self.symbols)) is not None:
for symbol in symbols:
symbol.applyToTiedGroup(notes, parent=self)
return notes
def _asTableRow(self, config: CoreConfig | None = None) -> list[str]:
config = config or Workspace.active.config
if self.isRest():
elements = ["REST"]
else:
notename = self.name
if (unicodeaccidentals := config['reprUnicodeAccidentals']):
full = unicodeaccidentals == 'full'
notename = _util.unicodeNotename(notename, full=full)
if self.tied:
notename += "~"
if self.symbols:
notatedPitch = next((s for s in self.symbols
if isinstance(s, _symbols.NotatedPitch)), None)
if notatedPitch and notatedPitch.pitch != notename:
notename = f'{notename} ({notatedPitch.pitch})'
elements = [notename]
if config['reprShowFreq']:
elements.append("%dHz" % int(self.freq))
if self.amp is not None and self.amp < 1:
elements.append("%ddB" % round(pt.amp2db(self.amp)))
if self.dur:
if self.dur >= MAXDUR:
elements.append("dur=inf")
elif config['reprDurationAsFraction']:
elements.append(f"{_util.showF(self.dur, maxdenom=32, approxAsFloat=True, unicode=config['reprUnicodeFractions'])}♩")
else:
elements.append(f"{_util.showT(self.dur)}")
if self.offset is not None:
elements.append(f"offset={_util.showT(self.offset)}")
if self.gliss:
if isinstance(self.gliss, bool):
elements.append(f"gliss={self.gliss}")
else:
elements.append(f"gliss={pt.m2n(self.gliss)}")
if self.symbols:
elements.append(f"symbols={self.symbols}")
return elements
def __repr__(self) -> str:
if self.isRest():
cfg = Workspace.active.config
if cfg['reprDurationAsFraction']:
parts = [f"{_util.showF(self.dur, maxdenom=32, approxAsFloat=True)}♩"]
else:
parts = [f"{_util.showT(self.dur)}"]
if self.offset is not None:
parts.append(f"offset={_util.showT(self.offset)}")
if self.symbols:
parts.append(f"symbols={(self.symbols)}")
return "R:" + ":".join(parts)
elements = self._asTableRow()
if len(elements) == 1:
return elements[0]
else:
s = ":".join(elements)
return s
def __float__(self) -> float: return float(self.pitch)
def __int__(self) -> int: return int(self.pitch)
def __add__(self, other: num_t) -> Self:
if isinstance(other, (int, float)):
out = self.copy()
out._setpitch(self.pitch + other)
out._gliss = self.gliss if isinstance(self.gliss, bool) else self.gliss + other
return out
raise TypeError(f"can't add {other} ({other.__class__}) to a Note")
def __mul__(self, other: num_t) -> Self:
# modify the duration
if isinstance(other, (int, float, F)):
return self.clone(dur=self.dur * other)
raise TypeError(f"Can't multiply {other} ({other.__class__}) to a {type(self)}")
def __xor__(self, freq) -> Self: return self.freqShift(freq)
def __sub__(self, other: num_t) -> Self:
return self + (-other)
[docs]
def quantizePitch(self, step=1.) -> Self:
"""
Returns a new Note, rounded to step.
.. note::
- If this note has an explicit gliss, the target pitch is also quantized
- Any set pitch spelling is deleted
"""
if self.isRest():
# When an event is already part of a container (chain/voice), we
# need to return a copy since otherwise it cannot be made part of
# a different container. For example, if .quantizedPitch is
# called on a chain, the returned events will be made part of
# a newly constructed chain
return self.copy() if self.parent else self
out = self.clone(pitch=round(self.pitch / step) * step)
if isinstance(self._gliss, (int, float)) and self._gliss != 0.:
gliss = round(out._gliss / step) * step
out._gliss = gliss if gliss != self.pitch else None
out.pitchSpelling = ''
return out
def _resolveAmp(self,
config: CoreConfig,
dyncurve: DynamicCurve
) -> float:
"""
Resolves the amplitude of this event.
This is mostly used internally to determine the amplitude corresponding
to a given event.
Args:
config: the active config
dyncurve: the active dynamic curve
Returns:
the playback amplitude as a float between 0 and 1
"""
if self.amp is not None:
return self.amp
else:
if config['play.useDynamics']:
dyn = self.dynamic or (self.properties and self.properties.get('.tempdynamic')) or config['play.defaultDynamic']
return dyncurve.dyn2amp(dyn)
return config['play.defaultAmplitude']
[docs]
def resolveAmp(self) -> float:
"""
Resolves the amplitude of this event.
The amplitude can be set explicitely (the ``.amp`` attribute) or can
be set via the dynamic if ``config['play.useDynamics']`` is True.
If no amplitude is set, a default amplitude via ``config['play.defaultAmplitude']``
is used as fallback
Returns:
the playback amplitude as a float between 0 and 1
"""
w = Workspace.active
return self._resolveAmp(config=w.config, dyncurve=w.dynamicCurve)
def _synthEvents(self,
playargs: synthevent.PlayArgs,
parentOffset: F,
workspace: Workspace,
) -> list[synthevent.SynthEvent]:
if self.isRest():
return []
conf = workspace.config
struct = workspace.scorestruct
if self.playargs is not None:
playargs = playargs.updated(self.playargs)
amp = self._resolveAmp(config=conf, dyncurve=workspace.dynamicCurve)
glissdur = playargs.get('glisstime', 0.)
linkednext = glissdur or self.gliss
endpitch = self.resolveGliss() if linkednext else self.pitch
absoffset = self.relOffset() + parentOffset
startbeat = absoffset + playargs.get('skip', 0)
endbeat = absoffset + playargs.get('end', self.dur)
startsecs = float(struct.beatToTime(startbeat))
endsecs = float(struct.beatToTime(endbeat))
if startsecs >= endsecs:
raise ValueError(f"Trying to play an event with 0 or negative duration: {endsecs-startsecs}. "
f"Object: {self}, {startbeat=}, {endbeat=}, {playargs=}")
transp = playargs.get('transpose', 0.)
startpitch = self.pitch + transp
if glissdur > 0.:
glissstart = float(struct.beatToTime(max(startbeat, endbeat - glissdur)))
bps = [(startsecs, startpitch, amp),
(glissstart, startpitch, amp),
(endsecs, endpitch + transp, amp)]
else:
bps = [(startsecs, self.pitch + transp, amp),
(endsecs, endpitch + transp, amp)]
event = synthevent.SynthEvent.fromPlayArgs(bps=bps, playargs=playargs)
if playargs.automations:
event.addAutomationsFromPlayArgs(playargs, scorestruct=struct)
if self.tied or linkednext:
event.linkednext = True
return [event]
[docs]
def resolveGliss(self) -> float:
"""
Resolve the target pitch for this note's glissando
Returns:
the target pitch or this note's own pitch if its target
pitch cannot be determined
"""
if not isinstance(self._gliss, bool):
# .gliss is already a pitch, return that
return self._gliss
elif self._glissTarget:
return self._glissTarget
elif not self.parent:
# .gliss is a bool, so we need to know the next event, but we are parentless
return self.pitch
self.parent._resolveGlissandi()
return self._glissTarget or self.pitch
[docs]
def glissTarget(self) -> str:
"""
The gliss target as notename
Raises ValueError if this Note does not have a gliss
Returns:
The gliss target as notename
"""
if not self.gliss:
raise ValueError("This note does not have a glissando")
if self._glissTarget:
return pt.m2n(self._glissTarget)
elif not isinstance(self._gliss, bool):
return pt.m2n(self._gliss)
elif self.parent:
self.parent._resolveGlissandi()
target = self._glissTarget
return target if isinstance(target, str) else pt.m2n(target)
else:
return self.name
[docs]
def Rest(dur: time_t | str, offset: time_t | None = None, label='', dynamic='') -> Note:
"""
Create a Rest
Args:
dur: duration in beats
offset:
label:
dynamic:
Returns:
"""
fdur = _tools.parseDuration(dur) if isinstance(dur, str) else asF(dur)
if fdur <= 0:
raise ValueError(f"A rest must have a possitive duration, got {dur}")
return Note(pitch=0, dur=fdur,
offset=None if offset is None else asF(offset),
amp=0, label=label, dynamic=dynamic,
_init=False)
[docs]
class Chord(MEvent):
"""
A Chord is a stack of Notes
a Chord can be instantiated as::
Chord(note1, note2, ...)
Chord([note1, note2, ...])
Chord("C4,E4,G4", ...)
Chord("C4 E4 G4", ...)
Where each note is either a Note, a notename ("C4", "E4+", etc) or a midinote
Args:
notes: the notes of this chord. Can be a list of pitches, where each
pitch is either a fractional midinote or a notename (str); notes
can be already created ``Note`` instances; or a string with multiple
notes separated by a space
amp: the amplitude of this chord. To specify a different amplitude for each
pitch within the chord, first create a Note for each pitch with its
corresponding amplitude and use that list as the *notes* argument
dur: the duration of this chord (in quarternotes)
offset: the offset time (in quarternotes)
gliss: either a list of end pitches (with the same size as the chord), or
True to leave the end pitches unspecified (a gliss to the next chord)
label: if given, it will be used for printing purposes
tied: if True, this chord should be tied to a following chord, if possible
dynamic: a dynamic for this chord ('pp', 'mf', 'ff', etc). Dynamics range from
'pppp' to 'ffff'
fixed: if True, fix the spelling of the note to the notename given (this is
only taken into account if the pitch was given as a notename). See also
the configuration key `fixStringNotenames <config_fixstringnotenames>`.
**NB**: it is possible to suffix the notename with a '!'
mark in order to lock its spelling, independently of the configuration
settings
properties: properties are a space given to the user to attach any information to
this object
Attributes:
amp: the amplitude of the chord itself (each note can have an individual amp)
notes: the notes which build this chord
tied: is this Chord tied to another Chord?
"""
__slots__ = (
'notes',
'dynamic',
'_gliss',
'_glissTarget',
'_notatedPitches',
)
def __init__(self,
notes: str | Sequence[Note | int | float | str],
dur: time_t | None = None,
*,
amp: float | None = None,
offset: time_t | None = None,
gliss: str | bool | Sequence[pitch_t] = False,
label='',
tied=False,
dynamic='',
properties: dict[str, Any] | None = None,
fixed=False,
_init=True
) -> None:
"""
a Chord can be instantiated as:
Chord(note1, note2, ...) or
Chord([note1, note2, ...])
Chord("C4 E4 G4")
where each note is either a Note, a notename ("C4", "E4+", etc), a midinote
or a tuple (midinote, amp)
Args:
amp: the amplitude (volume) of this chord. This applies to all the notes in the chord,
but each note can have its own amplitude, which is then multiplied by this factor
dur: the duration of this chord (in quarternotes)
offset: the offset time (in quarternotes)
gliss: either a list of end pitches (with the same size as the chord), or
True to leave the end pitches unspecified (a gliss to the next chord)
label: if given, it will be used for printing purposes
fixed: if True, any note in this chord which was given as a notename (str)
will be fixed in the given spelling (**NB**: the spelling can also
be fixed by suffixing any notename with a '!' sign)
"""
self.amp: float | None = amp
if dur is not None:
dur = asF(dur)
if not notes:
super().__init__(dur=dur if dur is not None else F1, offset=offset, label=label)
return
symbols = None
spanners = None
# notes might be: Chord([n1, n2, ...]) or Chord("4c 4e 4g", ...)
if isinstance(notes, str):
if ':' in notes:
props = _tools.parseNote(notes)
dur = dur if dur is not None else props.dur
pitches = props.notename if isinstance(props.notename, list) else [props.notename]
if p := props.keywords:
offset = offset or p.pop('offset', None)
dynamic = dynamic or p.pop('dynamic', '')
tied = tied or p.pop('tied', False)
gliss = gliss or p.pop('gliss', False)
fixed = p.pop('fixPitch', False) or fixed
label = label or p.pop('label', '')
properties = p if not properties else p | properties
if props.symbols:
symbols = props.symbols
if props.spanners:
spanners = props.spanners
notes = [Note(pitch, amp=amp, fixed=fixed) for pitch in pitches]
elif ',' in notes:
notes = [Note(n.strip(), amp=amp, fixed=fixed) for n in notes.split(',')]
else:
notes = [Note(n.strip(), amp=amp, fixed=fixed) for n in notes.split()]
assert offset is None or isinstance(offset, F)
super().__init__(dur=dur if dur is not None else F1,
offset=offset, label=label, properties=properties,
tied=tied, amp=amp, dynamic=dynamic)
if not _init:
notes2 = _cast(list[Note], notes)
else:
notes2: list[Note] = []
for n in notes:
if isinstance(n, Note):
if n.offset is None:
notes2.append(n)
else:
notes2.append(n.clone(offset=None))
elif isinstance(n, (int, float, str)):
notes2.append(Note(n, amp=amp))
else:
raise TypeError(f"Expected a Note or a pitch, got {n}")
notes2.sort(key=lambda n: n.pitch)
if any(n.tied for n in notes2):
self.tied = True
if not isinstance(gliss, bool):
gliss = pt.as_midinotes(gliss)
if not len(gliss) == len(notes):
raise ValueError(f"The destination chord of the gliss should have "
f"the same length as the chord itself, {notes=}, {gliss=}")
self.notes: list[Note] = notes2
"""The notes in this chord, each an instance of Note"""
self._gliss: bool | list[float] = gliss # type: ignore
self._glissTarget: list[float] | None = None
if symbols:
for symbol in symbols:
self.addSymbol(symbol)
if spanners:
for sp in spanners:
self.addSpanner(sp)
@property
def gliss(self) -> list[float] | bool:
return self._gliss
@gliss.setter
def gliss(self, gliss: bool | list[pitch_t]):
"""
Set the gliss attribute of this Note, inplace
"""
if isinstance(gliss, bool):
self._gliss = gliss
self._glissTarget = None
else:
if not isinstance(gliss, (list, tuple)):
raise TypeError(f"Expected a list/tuple of pitches, got {gliss}")
if len(gliss) != len(self.notes):
raise ValueError(f"The number of pitches for the target of the glissando "
f"should match the number of pitches in this chord, "
f"{gliss=}, {self=}")
self._gliss = [asmidi(pitch) for pitch in gliss]
[docs]
def copy(self):
notes = [n.copy() for n in self.notes]
out = self.__class__(notes=notes, dur=self.dur, amp=self.amp, offset=self.offset,
gliss=self.gliss, label=self.label, tied=self.tied,
dynamic=self.dynamic,
_init=False)
self._copyAttributesTo(out)
return out
def __len__(self) -> int:
return len(self.notes)
@_overload
def __getitem__(self, idx: int) -> Note: ...
@_overload
def __getitem__(self, idx: slice) -> Chord: ...
def __getitem__(self, idx):
out = self.notes.__getitem__(idx)
if isinstance(out, list):
out = self.__class__(out)
return out
def __iter__(self) -> Iterator[Note]:
return iter(self.notes)
@property
def name(self) -> str:
return ",".join(self._bestSpelling())
[docs]
def setNotatedPitch(self, notenames: str | list[str]) -> None:
if isinstance(notenames, str):
return self.setNotatedPitch(notenames.split())
if not len(notenames) == len(self.notes):
raise ValueError(f"The number of given fixed spellings ({notenames}) does not correspond"
f"to the number of notes in this chord ({self._bestSpelling()})")
for notename, n in zip(notenames, self.notes):
if notename:
n.pitchSpelling = notename
def _canBeLinkedTo(self, other: mobj.MObj) -> bool:
if other.isRest():
return False
if self._gliss is True:
return True
if isinstance(other, Note):
if self.tied and any(p == other.pitch for p in self.pitches):
return True
else:
logger.debug("Chord %s is tied, but %s has no pitches in common with it", self, other)
elif isinstance(other, Chord):
if self.tied and any(p in other.pitches for p in self.pitches):
return True
else:
logger.debug("Chord %s is tied, but %s has no pitches in common", self, other)
return False
[docs]
@classmethod
def grace(cls,
notes: str | Sequence[int | float | str],
stemless=False,
slash=False,
value: F | str | float | None = None,
parenthesis=False,
hidden=False,
**kws) -> Self:
"""
Class method to create a grace chord
Args:
notes: the pitches of the grace chord
stemless: if is stemless?
slash: slashed stem
value: the rhythic value of the grace chord ("1/2" or F(1, 2)=8th note,
"1/8"=32nd note, etc.
parenthesis: are noteheads parenthesized?
hidden: should the whole note be hidden?
**kws: keyword args passed to the Chord constructor
Returns:
the grace chord
"""
chord = cls(notes=notes, dur=0, **kws)
_customizeGracenote(chord,
stemless=stemless,
slash=slash,
value=value,
parenthesis=parenthesis, hidden=hidden)
return chord
[docs]
def mergeWith(self, other: Chord) -> Self | None:
if not isinstance(other, Chord):
return None
if not self.tied or other.gliss or self.pitches != other.pitches:
return None
if any(n1 != n2 for n1, n2 in zip(self.notes, other.notes)):
return None
return self.clone(dur=self.dur + other.dur)
[docs]
def pitchRange(self) -> tuple[float, float] | None:
return min(n.pitch for n in self.notes), max(n.pitch for n in self.notes)
[docs]
def meanPitch(self) -> float | None:
return sum(n.pitch for n in self.notes) / len(self.notes)
[docs]
def glissTarget(self) -> list[str]:
"""
The gliss targets as a list of notenames
Raised ValueError if this chord does not have a gliss
Returns:
The gliss target as notename
"""
if not self.gliss:
raise ValueError("This Chord does not have a glissando")
elif self._glissTarget:
return [pt.m2n(pitch) for pitch in self._glissTarget]
elif not isinstance(self._gliss, bool):
return [pt.m2n(pitch) for pitch in self._gliss]
elif self.parent:
self.parent._resolveGlissandi()
assert self._glissTarget is not None
return [pt.m2n(pitch) for pitch in self._glissTarget]
else:
return [note.name for note in self.notes]
[docs]
def resolveGliss(self) -> list[float]:
"""
Resolve the target pitch for this chord's glissando
Returns:
the target pitch or this note's own pitch if its target
pitch cannot be determined
"""
if not self.gliss:
raise ValueError("This Chord does not have a glissando")
if not isinstance(self.gliss, bool):
assert all(isinstance(_, (int, float)) for _ in self.gliss)
return self.gliss
if self._glissTarget:
return self._glissTarget
if not self.parent:
# .gliss is a bool, so we need to know the next event, but we are parentless
return self.pitches
self.parent._update()
if self._glissTarget is None:
self._glissTarget = self.pitches
return self._glissTarget
[docs]
def scoringEvents(self,
groupid='',
config: CoreConfig | None = None,
parentOffset: F | None = None
) -> list[scoring.Notation]:
if not config:
config = Workspace.active.config
notenames = [note.name for note in self.notes]
dur = self.dur
offset = self.absOffset() if parentOffset is None else self.relOffset() + parentOffset
notation = scoring.Notation.makeChord(pitches=notenames,
duration=dur,
offset=offset,
group=groupid,
dynamic=self.dynamic,
tiedNext=self.tied)
# Transfer any pitch spelling
for i, note in enumerate(self.notes):
if note.pitchSpelling:
notation.fixNotename(note.pitchSpelling, i)
if self.label:
labelsymbol = self._labelSymbol(self.label, config=config)
labelsymbol.applyToNotation(notation)
# Add gliss.
notations = [notation]
if self.gliss:
notation.gliss = True
if not isinstance(self.gliss, bool):
groupid = scoring.core.makeGroupId(groupid)
notation.groupid = groupid
endEvent = scoring.Notation.makeChord(pitches=self.gliss, duration=0,
offset=self.end, group=groupid)
if config['show.glissStemless']:
from maelzel.scoring import attachment
endEvent.addAttachment(attachment.StemTraits(hidden=True))
notations.append(endEvent)
if self.symbols:
for s in self.symbols:
s.applyToNotation(notation, parent=self)
# Transfer note symbols
for i, n in enumerate(self.notes):
if n.symbols:
for symbol in n.symbols:
if isinstance(symbol, _symbols.NoteheadSymbol):
symbol.applyToPitch(notation, idx=i, parent=n)
else:
logger.debug("Cannot apply symbol %s to a pitch inside "
"chord %s", symbol, self)
return notations
[docs]
def scoringParts(self,
config: CoreConfig | None = None
) -> list[scoring.core.UnquantizedPart]:
# The only reason we need to overload this is that when showing a chord
# which is split across multiple parts we want to lock the spelling
from maelzel.scoring import enharmonics
config = config or Workspace.active.config
notations = self.scoringEvents(config=config)
n0 = notations[0]
notenames = n0.resolveNotenames(keepFixedAnnotation=True)
spelling0 = enharmonics.bestChordSpelling(notenames, options=config.makeEnharmonicOptions())
for name in spelling0:
n0.fixNotename(name)
return self._scoringPartsFromNotations(notations, config=config)
def __hash__(self):
if isinstance(self.gliss, bool):
glisshash = int(self.gliss)
elif isinstance(self.gliss, list):
glisshash = hash(tuple(self.gliss))
else:
glisshash = hash(self.gliss)
symbolshash = hash(tuple(self.symbols)) if self.symbols else 0
data = (self.dur, self.offset, self.label, glisshash, self.dynamic,
self.tied, symbolshash,
*(hash(n) for n in self.notes))
return hash(data)
[docs]
def append(self, note: float | str | Note) -> None:
""" append a note to this Chord """
note = note if isinstance(note, Note) else Note(note)
if note.freq < 17:
logger.debug("appending a note with very low freq: %s", note.freq)
self.notes.append(note)
self._changed()
[docs]
def extend(self, notes) -> None:
""" extend this Chord with the given notes """
for note in notes:
self.notes.append(note if isinstance(note, Note) else Note(note))
self._changed()
[docs]
def insert(self, index: int, note: pitch_t) -> None:
"""
Insert a note in this chord, in place
Args:
index: where to insert it
note: a Note or a pitch
"""
self.notes.insert(index, note if isinstance(note, Note) else Note(note))
self._changed()
[docs]
def transposeTo(self, fundamental: pitch_t) -> Self:
"""
Return a copy of self, transposed to the new fundamental
.. note::
the fundamental is the lowest note in the chord
Args:
fundamental: the new lowest note in the chord
Returns:
A Chord transposed to the new fundamental
"""
base = min(n.pitch for n in self.notes)
step = asmidi(fundamental) - base
return self.transpose(step)
[docs]
def freqShift(self, freq: float) -> Self:
"""
Return a copy of this chord shifted in frequency
"""
return self.clone(notes=[note.freqShift(freq) for note in self])
[docs]
def quantizePitch(self, step=1.) -> Self:
"""
Returns a copy of this chord, with the pitches quantized.
Two notes with the same pitch are considered equal if they quantize to the same
pitch, independently of their amplitude. Amplitudes of equal notes are accumulated
"""
assert step > 0
notes = {}
for note in self:
note2 = note.quantizePitch(step)
if accumnote := notes.get(note2.pitch):
accumnote.amp += note2.amp
else:
notes[note2.pitch] = note2
return self.clone(notes=notes.values())
def __setitem__(self, i: int, note: pitch_t) -> None:
self.notes.__setitem__(i, note if isinstance(note, Note) else Note(note))
self._changed()
def __add__(self, other: pitch_t) -> Self:
if isinstance(other, Note):
# append the note
notes = self.notes.copy()
notes.append(other)
return self.clone(notes=notes)
elif isinstance(other, (int, float)):
# transpose
notes = [n + other for n in self]
return self.clone(notes=notes)
elif isinstance(other, (Chord, str)):
# join chords together
return self.clone(notes = self.notes+_asChord(other).notes)
raise TypeError(f"Can't add a Chord to a {other.__class__.__name__}")
[docs]
def loudest(self, n: int) -> Self:
"""
Return a new Chord with the loudest `n` notes from this chord
"""
out = self.copy()
out.sort(key='amp', reverse=True)
return out[:n]
[docs]
def sort(self, key='pitch', reverse=False) -> None:
"""
Sort **INPLACE**.
If inplace sorting is undesired, use ``x = chord.copy(); x.sort()``
By default, chords are sorted by pitch, from low to high
Args:
key: either 'pitch' or 'amp'
reverse: similar as sort
"""
if key == 'pitch':
self.notes.sort(key=lambda n: n.pitch, reverse=reverse)
elif key == 'amp':
self.notes.sort(key=lambda n: n.amp if n.amp is not None else 1.0, reverse=reverse)
else:
raise KeyError(f"Unknown sort key {key}. Options: 'pitch', 'amp'")
@property
def pitches(self) -> list[float]:
"""
The pitches of this chord (a list of midinotes)
"""
return [n.pitch for n in self.notes]
[docs]
def resolveAmps(self,
config: CoreConfig,
dyncurve: DynamicCurve,
) -> list[float]:
useDynamics = config['play.useDynamics']
if self.amp is not None:
chordamp = self.amp
else:
if not useDynamics:
chordamp = config['play.defaultAmplitude']
else:
dyn = self.dynamic or config['play.defaultDynamic']
chordamp = dyncurve.dyn2amp(dyn)
amps = []
for n in self.notes:
if n.amp:
amps.append(n.amp)
elif n.dynamic and useDynamics:
amps.append(dyncurve.dyn2amp(n.dynamic))
else:
amps.append(chordamp)
return amps
def _synthEvents(self,
playargs: synthevent.PlayArgs,
parentOffset: F,
workspace: Workspace
) -> list[synthevent.SynthEvent]:
if not self.notes:
return []
conf = workspace.config
struct = workspace.scorestruct
if self.playargs:
playargs = playargs.updated(self.playargs)
if conf['chordAdjustGain'] and all(n.amp is None for n in self.notes):
globalgain = 1/math.sqrt(len(self.notes))
else:
globalgain = 1.
absoffset = self.relOffset() + parentOffset
startbeat = absoffset + playargs.get('skip', 0)
endbeat = absoffset + playargs.get('end', self.dur)
startsecs = float(struct.beatToTime(startbeat))
endsecs = float(struct.beatToTime(endbeat))
endpitches = self.pitches if not self.gliss else self.resolveGliss()
amps = self.resolveAmps(config=conf, dyncurve=workspace.dynamicCurve)
transpose = playargs.get('transpose', 0.)
glissdur = playargs.get('glisstime', 0)
linkednext = self.gliss or glissdur > 0
if glissdur > endbeat - startbeat:
glissdur = endbeat - startbeat
synthevents = []
for note, endpitch, amp in zip(self.notes, endpitches, amps):
startpitch = note.pitch + transpose
amp *= globalgain
bps = [(float(startsecs), startpitch, amp)]
if glissdur:
glissabstime = float(struct.beatToTime(endbeat - glissdur))
bps.append((glissabstime, startpitch, amp))
bps.append((float(endsecs), endpitch+transpose, amp))
event = synthevent.SynthEvent.fromPlayArgs(bps=bps, playargs=playargs)
if playargs.automations:
event.addAutomationsFromPlayArgs(playargs, scorestruct=struct)
if linkednext or self._isNoteTied(note):
event.linkednext = True
synthevents.append(event)
return synthevents
[docs]
def tieNotes(self, notes: Sequence[Note | pitch_t]) -> None:
"""
Marks the given notes as tied, in place
Raises ValueError if any note/pitch is not present in the given chord a
"""
changed = False
for n in notes:
pitch = n.pitch if isinstance(n, Note) else asmidi(n)
if note := self.findNote(pitch):
note.tied = True
changed = True
else:
raise ValueError(f"Pitch {n} not present in this chord: {self}")
if changed:
self.tied = True
self._changed()
[docs]
def tieCommonNotes(self, other: Chord, tolerance=0.) -> list[Note]:
notes = self.commonNotes(other, tolerance=tolerance)
if not notes:
return []
for note in notes:
note.tied = True
self.tied = True
self._changed()
return notes
[docs]
def commonNotes(self, other: Chord, tolerance=0.) -> list[Note]:
"""
"""
otherpitches = other.pitches
common = [n for n in self.notes
if any(abs(n.pitch - p) <= tolerance for p in otherpitches)]
return common
def _isNoteTied(self, note: Note) -> bool:
"""
Query if the given note within this chord is tied to the following note/chord
For a note within a chord to be tied the chord needs to be tied and the
next event needs to have a pitch equal to the pitch of that note
Args:
note: a note within this chord
Returns:
True if this note is tied to the next chord/note
"""
if not self.tied:
return False
if not self.parent:
return True
nextitem = self.parent.nextItem(self)
if not nextitem:
return False
elif isinstance(nextitem, Chord):
istied = next((candidate.pitch == note.pitch for candidate in nextitem), None) is not None
elif isinstance(nextitem, Note):
istied = nextitem.pitch == note.pitch
else:
return False
if istied and not self.tied:
logger.warning(f"A note ({note}) in this chord is tied, but the chord itself is "
f"not tied (chord={self})")
return istied
def _bestSpelling(self) -> tuple[str, ...]:
notenames = [n.pitchSpelling + '!' if n.pitchSpelling else n.name
for n in self.notes]
from maelzel.scoring import enharmonics
return enharmonics.bestChordSpelling(notenames)
def __repr__(self):
# «4C+14,4A 0.1q -50dB»
elements = [" ".join(self._bestSpelling())]
if self.dur:
if self.dur >= MAXDUR:
elements.append("dur=inf")
else:
elements.append(f"{float(self.dur):.3g}♩")
if self.offset is not None:
elements.append(f'offset={float(self.offset):.3g}')
if self.gliss:
if isinstance(self.gliss, bool):
elements.append("gliss")
else:
endpitches = ','.join([pt.m2n(_) for _ in self.gliss])
elements.append(f"gliss={endpitches}")
if self.dynamic:
elements.append(self.dynamic)
if len(elements) == 1:
return f'‹{elements[0]}›'
else:
return f'‹{elements[0].ljust(3)} {" ".join(elements[1:])}›'
[docs]
def dump(self, indents=0, forcetext=False):
elements = f'offset={self.offset}, dur={self.dur}, gliss={self.gliss}'
print(f"{' '*indents}Chord({elements})")
if self.playargs:
print(" "*(indents+1), self.playargs)
if self.symbols:
print(" "*(indents+1), "symbols:", self.symbols)
for n in reversed(self.notes):
print(" "*(indents+2), repr(n))
[docs]
def mappedAmplitudes(self, curve, db=False) -> Self:
"""
Return new Chord with the amps of the notes modified according to curve
Example::
# compress all amplitudes to 30 dB
>>> import bpf4 as bpf
>>> chord = Chord([Note(60, amp=0.5), Note(65, amp=0.1)])
>>> curve = bpf.linear(-90, -30, -30, -12, 0, 0)
>>> chord2 = chord.mappedAmplitudes(curve, db=True)
Args:
curve: a func mapping ``amp -> amp``
db: if True, the value returned by func is interpreted as dB
if False, it is interpreted as amplitude (0-1)
Returns:
the resulting chord
"""
notes = []
if db:
for note in self:
db = curve(pt.amp2db(note.amp if note.amp is not None else 1.0))
notes.append(note.clone(amp=pt.db2amp(db)))
else:
for note in self:
amp2 = curve(note.amp if note.amp is not None else 1.0)
notes.append(note.clone(amp=amp2))
return self.clone(notes=notes)
[docs]
def setAmplitudes(self, amp: float) -> None:
"""
Set the amplitudes of the notes in this chord to `amp` (inplace)
This modifies the Note objects within this chord, without modifying
the amplitude of the chord itself
.. note::
Each note (instance of Note) within a Chord can have its own ampltidue.
The Chord itself can also have an amplitude, which is used as fallback
for any note with unset amplitude. These two amplitudes do not multiply
Args:
amp: the new amplitude for the notes of this chord
Example
~~~~~~~
>>> chord = Chord("3f 3b 4d# 4g#", dur=4)
>>> chord.amp = 0.5 # Fallback amplitude
>>> chord[0:-1].setAmplitude(0.01) # Sets the amp of all notes but the last
>>> chord.synthEvents()
[SynthEvent(delay=0, dur=4, gain=0.5, chan=1, fade=(0.02, 0.02), instr=_piano)
bps 0.000s: 53 0.01
4.000s: 53 0.01 ,
SynthEvent(delay=0, dur=4, gain=0.5, chan=1, fade=(0.02, 0.02), instr=_piano)
bps 0.000s: 59 0.01
4.000s: 59 0.01 ,
SynthEvent(delay=0, dur=4, gain=0.5, chan=1, fade=(0.02, 0.02), instr=_piano)
bps 0.000s: 63 0.01
4.000s: 63 0.01 ,
SynthEvent(delay=0, dur=4, gain=0.5, chan=1, fade=(0.02, 0.02), instr=_piano)
bps 0.000s: 68 0.5
4.000s: 68 0.5 ]
Notice that the last event, corresponding to the highest note, has taken the ampltidue
of the chord (0.5) as default, since it was unset. The other notes have an amplitude
of 0.01, as set via :meth:`~Chord.setAmplitudes`
"""
return self.scaleAmplitudes(factor=0., offset=amp)
[docs]
def scaleAmplitudes(self, factor: float, offset=0.0) -> None:
"""
Scale the amplitudes of the notes within this chord **inplace**
.. note::
Each note (instance of Note) within a Chord can have its own ampltidue.
The Chord itself can also have an amplitude, which is used as fallback
for any note with unset amplitude. These two amplitudes do not multiply
Args:
factor: a factor to multiply the amp of each note
offset: a value to add to the amp of each note
"""
for n in self.notes:
amp = n.amp if n.amp is not None else self.amp if self.amp is not None else 1.0
n.amp = amp * factor + offset
[docs]
def equalize(self, curve: Callable[[float], float]) -> None:
"""
Scale the amplitude of the notes according to their frequency, **inplace**
Args:
curve: a func mapping freq to gain
"""
for note in self:
gain = curve(note.freq)
if note.amp is None:
note.amp = gain
else:
note.amp *= gain
def _isTooCrowded(self) -> bool:
"""
Is this chord two dense that it needs to be arpeggiated when shown?
"""
if len(self.notes) < 3:
return False
n0, n1 = self.notes[0], self.notes[1]
for n2 in self.notes[2:]:
if abs(n0.pitch - n1.pitch) <= 1 and abs(n1.pitch - n2.pitch) <= 1:
return True
n0, n1 = n1, n2
return False
[docs]
def findNote(self, pitch: pitch_t) -> Note | None:
"""
Find a note within this Chord
Args:
pitch: the pitch to match (a midinote or a notename)
Returns:
the matched note or None. If multiple notes within this chord share
the same pitch only one is returned
"""
midi = pt.str2midi(pitch) if isinstance(pitch, str) else pitch
return next((n for n in self.notes if n.pitch == midi), None)
def _asChord(obj, amp: float | None = None, dur: float | None = None) -> Chord:
"""
Create a Chord from `obj`
Args:
obj: a string with spaces in it, a list of notes, a single Note, a Chord
amp: the amp of the chord
dur: the duration of the chord
Returns:
a Chord
"""
if isinstance(obj, Chord):
out = obj
elif isinstance(obj, (list, tuple, str)):
out = Chord(obj)
elif hasattr(obj, "asChord"):
out = obj.asChord()
elif isinstance(obj, (int, float)):
out = Chord([Note(obj)])
else:
raise ValueError(f"cannot express this as a Chord: {obj}")
return out if amp is None and dur is None else out.clone(amp=amp, dur=dur)
@_overload
def Grace(pitch: pitch_t,
slash=False,
stemless=False,
offset: time_t | None = None,
value: F | None = None,
hidden=False,
parenthesis=False,
**kws
) -> Note:
...
@_overload
def Grace(pitch: Sequence[pitch_t],
slash=False,
stemless=False,
offset: time_t | None = None,
value: F | None = None,
hidden=False,
parenthesis=False,
**kws
) -> Note | Chord:
...
[docs]
def Grace(pitch: pitch_t | Sequence[pitch_t],
slash=False,
stemless=False,
offset: time_t | None = None,
value: F | None = None,
hidden=False,
parenthesis=False,
**kws
) -> Note | Chord:
"""
Create a gracenote (a note or chord)
The resulting gracenote will be a Note or a Chord, depending on pitch.
.. note::
A gracenote is just a regular note or chord with a duration of 0.
This function is here for visibility and to allow to customize
attributes specific to a gracenote
Args:
pitch: a single pitch (as midinote, notename, etc), a list of pitches or string
representing one or more pitches
offset: the offset of this gracenote. Normally a gracenote should not have an explicit offset
slash: if True, the gracenote will be marked as slashed
stemless: if True, hide the stem of this gracenote
value: the rhythmic value to use (1/2=eighth note, 1/4=sixteenth note, etc.)
Returns:
a Note if one pitch is given, a Chord if a list of pitches are passed instead.
A gracenote is basically a note/chord with 0 duration
Example
~~~~~~~
>>> grace = Grace('4F')
>>> grace2 = Note('4F', dur=0)
>>> grace == grace2
True
>>> gracechord = Grace('4F 4A')
>>> gracechord2 = Chord('4F 4A', dur=0)
>>> gracechord == gracechord2
True
"""
out = asEvent(pitch, dur=0, offset=offset, **kws)
assert isinstance(out, (Note, Chord))
_customizeGracenote(out, slash=slash, stemless=stemless, value=value, hidden=hidden, parenthesis=parenthesis)
return out
def _customizeGracenote(grace: Note | Chord,
slash=False,
stemless=False,
value: F | str | int | float | None = None,
hidden=False,
parenthesis=False,
) -> None:
if hidden:
grace.addSymbol(_symbols.Hidden())
else:
if stemless and (slash or value):
logger.debug("A gracenote cannot be stemless and have a slashed stem or "
"a custom value...")
slash = False
value = None
if stemless:
grace.addSymbol(_symbols.Stem(hidden=True))
elif slash or value:
grace.addSymbol(_symbols.Gracenote(slash=True,
value=F(1, 2) if not value else asF(value)))
if parenthesis:
grace.addSymbol(_symbols.Notehead(parenthesis=True))
[docs]
def asEvent(obj, **kws) -> MEvent:
"""
Convert obj to a Note or Chord, depending on the input itself
============================= ==========
Input Output
============================= ==========
int, float (midinote) Note
list (of notes) Chord
notename as string ("C4") Note
str with notenames ("C4 E4") Chord
============================= ==========
Args:
obj: the object to convert
kws: any keyword passed to the constructor (Note, Chord)
Returns:
the resulting object, either a Note or a Chord
Example
~~~~~~~
>>> from maelzel.core import *
>>> asEvent("4C") # a Note
4C
>>> asEvent("4C E4") # a Chord
‹4C 4E›
>>> asEvent("4C:1/3:accent")
4C:0.333♩:symbols=[Articulation(kind=accent)]
# Internally this note has a duration of 1/3
>>> asEvent("4C,4E:0.5:mf")
‹4C 4E 0.5♩ mf›
"""
if isinstance(obj, MEvent):
return obj
elif isinstance(obj, str):
if " " in obj:
return Chord(obj.split(), **kws)
elif ":" or "," in obj:
notedef = _tools.parseNote(obj)
dur = kws.pop('dur', None) or notedef.dur
if notedef.keywords:
kws = notedef.keywords | kws
if isinstance(notedef.notename, list):
if len(notedef.notename) > 1:
out = Chord(notedef.notename, dur=dur, **kws)
else:
out = Note(notedef.notename[0], dur=dur, **kws)
elif notedef.notename == 'rest':
if dur is None:
raise ValueError(f"A rest needs a duration, got {obj}")
out = Rest(dur=dur, **kws)
else:
out = Note(notedef.notename, dur=dur, **kws)
if notedef.symbols:
for symbol in notedef.symbols:
out.addSymbol(symbol)
if notedef.spanners:
for spanner in notedef.spanners:
out.addSpanner(spanner)
elif " " in obj:
out = Chord(obj.split(), **kws)
else:
out = Note(obj, **kws)
elif isinstance(obj, (list, tuple)):
out = Chord(obj, **kws)
elif isinstance(obj, (int, float)):
out = Note(obj, **kws)
else:
raise TypeError(f"Cannot convert {obj} to a Note or Chord")
return out