Skip to content

Commit

Permalink
- fix: allow forced dynamics (dynamics with an added suffix ! will al…
Browse files Browse the repository at this point in the history
…ways

  be shown even if they are the same as the current dynamic
- new: chain.eventsWithOffset now can select events between a given
  time range
- fix: eventsBetween and itemsBetween are now consistent, the return
  a list of events/items
- fix: chords with fixed spelling split between two staves would have
  wrongly assigned spelling
- new: Dynamic symbol, allows to add a notation-only dynamic
- fix: scorestruct was not setting the parent of measure defs created during
  text parsing
  • Loading branch information
gesellkammer committed Mar 18, 2024
1 parent be35576 commit 50ec4f3
Show file tree
Hide file tree
Showing 33 changed files with 2,772 additions and 3,644 deletions.
2 changes: 2 additions & 0 deletions maelzel/core/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,8 @@ def parseNote(s: str) -> NoteProperties:
properties[key] = value
elif part in _knownDynamics:
properties['dynamic'] = part
elif part[-1] == '!' and part[:-1] in _knownDynamics:
properties['dynamic'] = part
elif part in _knownArticulations:
# properties['articulation'] = part
symbols.append(_symbols.Articulation(part))
Expand Down
143 changes: 120 additions & 23 deletions maelzel/core/chain.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import re
import sys

from maelzel.scorestruct import ScoreStruct
from maelzel.common import F, asF, F0
Expand Down Expand Up @@ -1072,10 +1073,23 @@ def recurse(self, reverse=False) -> Iterator[MEvent]:
else:
yield from item.recurse(reverse=True)

def eventsWithOffset(self) -> list[tuple[MEvent, F]]:
def eventsWithOffset(self,
start: beat_t = None,
end: beat_t = None,
partial=True) -> list[tuple[MEvent, F]]:
"""
Recurse the events in self and resolves each event's offset
Args:
start: absolute start beat/location. Filters the returned
event pairs to events within this time range
end: absolute end beat/location. Filters the returned event
pairs to events within the given range
partial: only used if either start or end are given, this controls
how events are matched. If True, events only need to be
defined within the time range. Otherwise, events need
to be fully included within the time range
Returns:
a list of pairs, where each pair has the form (event, offset), the offset being
the **absolute** offset of the event. Event themselves are not modified
Expand All @@ -1093,10 +1107,19 @@ def eventsWithOffset(self) -> list[tuple[MEvent, F]]:
"""
self._update()
if self._cachedEventsWithOffset:
return self._cachedEventsWithOffset
eventpairs, totaldur = self._eventsWithOffset(frame=self.absOffset())
self._dur = totaldur
self._cachedEventsWithOffset = eventpairs
eventpairs = self._cachedEventsWithOffset
else:
eventpairs, totaldur = self._eventsWithOffset(frame=self.absOffset())
self._dur = totaldur
self._cachedEventsWithOffset = eventpairs
if start is not None or end is not None:
struct = self.scorestruct(resolve=True)
start = struct.asBeat(start) if start else F0
end = struct.asBeat(end) if end else F(sys.maxsize)
eventpairs = _eventPairsBetween(eventpairs,
start=start,
end=end,
partial=partial)
return eventpairs

def itemsWithOffset(self) -> Iterator[tuple[MEvent|Chain], F]:
Expand Down Expand Up @@ -1333,37 +1356,71 @@ def eventAt(self, location: time_t | tuple[int, time_t], margin=F(1, 8), split=F
event = self.splitAt(start, beambreak=False, nomerge=False)
return event

def eventsBetween(self,
start: beat_t,
end: beat_t,
partial=True
) -> list[MEvent]:
"""
Events between the given time range
If ``partial`` is false, only events which lie completey within
the given range are included.
Args:
start: absolute start location (a beat or a score location)
end: absolute end location (a beat or score location)
partial: include also events wich are partially included within
the given time range
Returns:
a list of the events within the given time range
.. seealso:: :meth:`Chain.eventsWithOffset`, :meth:`Chain.itemsBetween`
"""
eventpairs = self.eventsWithOffset(start=start, end=end, partial=partial)
return [event for event, offset in eventpairs]

def itemsBetween(self,
start: time_t | tuple[int, time_t],
end: time_t | tuple[int, time_t],
) -> list[MEvent]:
start: beat_t,
end: beat_t,
partial=True
) -> list[MEvent | Chain]:
"""
Returns the items which are **included** by the given times in quarternotes
Items between the given time range
Items which start before *startbeat* or end after *endbeat* are not
included, even if parts of them might lie between the given time interval.
Chains are treated as one item. To access sub-chains, first flatten self.
If ``partial`` is false, only items which lie completey within
the given range are included.
Args:
start: absolute start location (a beat or a score location)
end: absolute end location (a beat or score location)
partial: include also events wich are partially included within
the given time range
Returns:
a list of events located between the given time-interval
a list of the items within the given time range
.. seealso:: :meth:`Chain.itemsWithOffset`, :meth:`Chain.eventsBetween`
"""
sco = self.scorestruct(resolve=True)
startbeat = sco.asBeat(start)
endbeat = sco.asBeat(end)
items = []
for item, itemoffset in self.eventsWithOffset():
itemend = itemoffset + item.dur
if itemoffset > endbeat:
break
if itemend > startbeat and ((item.dur > 0 and itemoffset < endbeat) or
(item.dur == 0 and itemoffset <= endbeat)):
items.append(item)
return items
out = []
if partial:
for item, offset in self.itemsWithOffset():
if offset > endbeat:
break
if offset < endbeat and offset + item.dur > startbeat:
out.append(item)
else:
for item, offset in self.eventsWithOffset():
if offset > endbeat:
break
if startbeat <= offset and offset + item.dur <= endbeat:
out.append(item)
return out

def splitEventsAtMeasures(self, scorestruct: ScoreStruct = None, startindex=0, stopindex=0
) -> None:
Expand Down Expand Up @@ -1399,6 +1456,8 @@ def splitAt(self, location: F | tuple[int, F], beambreak=False, nomerge=True
"""
Split any event present at the given absolute offset (in place)
The parts resulting from the split operation will be part of this chain/voice.
If you need to split at a relative offset, just substract the absolute
offset of this Chain from the given offset
Expand Down Expand Up @@ -1919,12 +1978,13 @@ def makeLine(nodeindex: int, groupindex: int, availableNodesPerGroup: list[set[i
return out


def _resolveGlissandi(flatevents: Sequence[MEvent], force=False) -> None:
def _resolveGlissandi(flatevents: Iterator[MEvent], force=False) -> None:
"""
Set the _glissTarget attribute with the pitch of the gliss target
if a note or chord has an unset gliss target (in place)
Args:
flatevents: subsequent events
force: if True, calculate/update all glissando targets
"""
Expand Down Expand Up @@ -1958,3 +2018,40 @@ def _resolveGlissandi(flatevents: Sequence[MEvent], force=False) -> None:
ev2._glissTarget = ev2.pitches
elif isinstance(ev2, Note):
ev2._glissTarget = ev2.pitch


def _eventPairsBetween(eventpairs: list[tuple[MEvent, F]],
start: F,
end: F,
partial=True,
) -> list[tuple[MEvent, F]]:
"""
Events between the given time range
If ``partial`` is false, only events which lie completey within
the given range are included.
Args:
eventpairs: list of pairs (event, absoluteOffset)
start: absolute start location in beats
end: absolute end location in beats
partial: include also events wich are partially included within
the given time range
Returns:
a list pairs (event: MEvent, absoluteoffset: F)
"""
out = []
if partial:
for event, offset in eventpairs:
if offset > end:
break
if offset < end and offset + event.dur > start:
out.append((event, offset))
else:
for event, offset in eventpairs:
if offset > end:
break
if start <= offset and offset + event.dur <= end:
out.append((event, offset))
return out
2 changes: 1 addition & 1 deletion maelzel/core/configdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
'show.clipNoteheadShape': 'square',
'show.referenceStaffsize': 12.0,
'show.musicxmlFontScaling': 1.0,
'show.flagStyle': 'normal',
'show.flagStyle': 'straight',
'show.autoClefChanges': True,
'show.proportionalSpacing': False,
'show.proportionalSpacingKind': 'strict',
Expand Down
3 changes: 0 additions & 3 deletions maelzel/core/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,6 @@ def __init__(self,
if amp and amp > 0:
assert midinote > 0

if dynamic:
assert dynamic in scoring.definitions.dynamicLevels

assert properties is None or isinstance(properties, dict)

super().__init__(dur=dur, offset=offset, label=label, properties=properties,
Expand Down
7 changes: 7 additions & 0 deletions maelzel/core/eventbase.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from maelzel.core.mobj import MObj, MContainer
import maelzel.core.symbols as _symbols
from maelzel.core.synthevent import PlayArgs
from maelzel.scoring import definitions

from typing import TYPE_CHECKING
if TYPE_CHECKING:
Expand Down Expand Up @@ -37,6 +38,12 @@ def __init__(self,
self.amp: float | None = amp
"The playback amplitude 0-1 of this note"

if dynamic:
if dynamic.endswith('!'):
dynamic = dynamic[:-1]
self.addSymbol(_symbols.Dynamic(dynamic, force=True))
assert dynamic in definitions.dynamicLevels

self.dynamic: str = dynamic

self._glissTarget: float = 0.
Expand Down
40 changes: 35 additions & 5 deletions maelzel/core/symbols.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,10 @@ def __init__(self,
startmark='trill',
trillpitch='',
alteration='',
placement='above', uuid=''):
super().__init__(kind=kind, placement=placement, uuid=uuid)
placement='above',
uuid='',
**kws):
super().__init__(kind=kind, placement=placement, uuid=uuid, **kws)
self.startmark = startmark
self.trillpitch = trillpitch
self.alteration = alteration
Expand Down Expand Up @@ -426,7 +428,7 @@ def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None
}


def makeSpanner(descr: str, kind='start', linetype='solid', placement='', color=''
def makeSpanner(descr: str, kind='start', linetype='', placement='', color=''
) -> Spanner:
"""
Create a spanner from a descriptor
Expand Down Expand Up @@ -565,7 +567,8 @@ def applyToPitch(self, n: scoring.Notation, idx: int | None, parent: mobj.MObj |
raise NotImplementedError

def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None:
self.applyToPitch(n, idx=0, parent=parent)
for idx in range(len(n.pitches)):
self.applyToPitch(n, idx=idx, parent=parent)


class VoiceSymbol(Symbol):
Expand Down Expand Up @@ -1063,6 +1066,33 @@ def knownSymbols() -> set[str]:
return out


class Dynamic(EventSymbol):
"""
A notation only dynamic
This should only be used for the seldom 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.dynamicLevels:
raise ValueError(f"Invalid dynamic '{kind}', "
f"possible dynamics: {scoring.definitions.dynamicLevels}")
super().__init__(placement=placement)
self.kind = kind
self.force = force

def __hash__(self):
return hash((type(self).__name__, self.kind, self.placement))

def applyToNotation(self, n: scoring.Notation, parent: mobj.MObj | None) -> None:
n.dynamic = self.kind
if self.force:
n.dynamic += '!'


class Articulation(EventSymbol):
"""
Represents a note attached articulation
Expand All @@ -1079,7 +1109,7 @@ def __init__(self, kind: str, color='', placement=''):
self.kind = normalized

def __hash__(self):
return hash((type(self).__name__, self.kind))
return hash((type(self).__name__, self.kind, self.color, self.placement))

def __repr__(self):
return _util.reprObj(self, priorityargs=('kind',), hideFalsy=True)
Expand Down
2 changes: 2 additions & 0 deletions maelzel/core/synthevent.py
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,8 @@ def bprepr3(bp):
bps = "; ".join([bprepr3(bp) for bp in self.bps])
return f"SynthEvent({info}, bps=‹{bps}›)"
else:
lines = [f"SynthEvent({info})"]

def bpline(bp):
rest = " ".join(("%.6g" % b).ljust(8) if isinstance(b, float) else str(b) for b in bp[1:])
return f"{float(bp[0]):7.6g}s: {rest}"
Expand Down
7 changes: 4 additions & 3 deletions maelzel/core/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,9 +495,10 @@ def setScoreStruct(score: str | ScoreStruct | None = None,
"""
Sets the current score structure
If given a ScoreStruct, this is simply a shortcut to ``getWorkspace().scorestruct = s``
If given a score as string or simply a time signature and/or tempo, it creates
a ScoreStruct and sets it as active
If given a ScoreStruct, it sets it as the active score structure.
As an alternative a score structure as string can be given, or simply
a time signature and/or tempo, in which case it will create the ScoreStruct
and set it as active
Args:
score: the scorestruct as a ScoreStruct or a string score (see ScoreStruct for more
Expand Down
3 changes: 3 additions & 0 deletions maelzel/partialtracking/pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ def __repr__(self):
return f"SplitResult(tracks: {len(self.tracks)}, noisetracks: {len(self.noisetracks)}, " \
f"residual: {len(self.residual)} partials)"

def __iter__(self):
return iter((self.tracks, self.noisetracks, self.residual))


def optimizeSplit(partials: list[Partial],
maxtracks: int,
Expand Down
4 changes: 4 additions & 0 deletions maelzel/partialtracking/partial.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ def meanfreq(self, weighted=True) -> float:
freqs = self.freqs
return numpyx.weightedavg(freqs, self.times, np.ones_like(freqs))

@cache
def maxfreq(self) -> float:
return float(self.freqs.max())

@cache
def meanpitch(self) -> float:
freq = self.meanfreq()
Expand Down
Loading

0 comments on commit 50ec4f3

Please sign in to comment.