From 520c5f9686cc0cd60e9daba581b3ac7ebb2693f4 Mon Sep 17 00:00:00 2001 From: gesellkammer Date: Thu, 29 Jun 2023 22:58:32 +0200 Subject: [PATCH] * removed music21 as a dependency. core ---- * clip: redesigned the duration to incorporate looping. Added the possibility to give a Clip an explicit duration. This is needed, when looping, to set the duration of the clip independent of the duration of the source/selection. * fixed chain.flat, scoring ------- * renamed Part to UnquantizedPart to make obvious what it is * renamed Arrangement (a list of Parts) to UnquantizedScore * renamed stackNotations... to resolveOffsets since, because now all events have a duration, stacking all that it does is fill in offsets * renamed cloneAsPart to cloneAsTie to make more clear the purpose * fixed some edge cases where the node merging algorithm was confused * renderoptions now validates most options at creation time * removed config from snd.plotting. The user should pass explicit options * documentation ------------- * include documentation on scoring.render * removed references regarding MEvents (notes, chord) having the possibility to let the duration undetermined. This was the previous design which was changed to enforce a duration on every object. But this was not always mentioned in the docs --- docs/Reference.rst | 1 + docs/chain.rst | 2 +- docs/clip.rst | 2 +- docs/core.rst | 16 +- docs/requirements.txt | 1 - docs/scoringquant.rst | 12 +- maelzel/__init__.py | 2 +- maelzel/core/chain.py | 14 +- maelzel/core/clip.py | 82 ++- maelzel/core/configdata.py | 2 +- maelzel/core/mobj.py | 30 +- maelzel/core/musicxml.py | 1019 --------------------------- maelzel/core/notation.py | 4 +- maelzel/core/score.py | 7 +- maelzel/core/symbols.py | 4 +- maelzel/partialtracking/spectrum.py | 4 - maelzel/scoring/__init__.py | 15 +- maelzel/scoring/common.py | 20 +- maelzel/scoring/core.py | 134 ++-- maelzel/scoring/node.py | 26 +- maelzel/scoring/notation.py | 105 +-- maelzel/scoring/quant.py | 129 ++-- maelzel/scoring/render.py | 29 +- maelzel/scoring/renderer.py | 36 +- maelzel/scoring/renderlily.py | 4 +- maelzel/scoring/renderoptions.py | 55 +- maelzel/snd/plotting.py | 230 +++--- notebooks/test/test-parent.ipynb | 1008 ++++++++++++-------------- notebooks/test/test-quant.ipynb | 365 +++++----- notebooks/test/test-slurs.ipynb | 125 ++-- 30 files changed, 1228 insertions(+), 2255 deletions(-) delete mode 100644 maelzel/core/musicxml.py diff --git a/docs/Reference.rst b/docs/Reference.rst index d6e7817..bb5339e 100644 --- a/docs/Reference.rst +++ b/docs/Reference.rst @@ -31,6 +31,7 @@ multiple backends (*lilypond*, *musescore*) scoringcore scoringquant + scoringrender -------------------- diff --git a/docs/chain.rst b/docs/chain.rst index 0d03719..2e4c8ed 100644 --- a/docs/chain.rst +++ b/docs/chain.rst @@ -23,7 +23,7 @@ the following differences: * A :class:`Voice` can contain a Chain, but **a Voice cannot contain another Voice** * A :class:`Voice` does not have a time offset, **its offset is always 0** -* A :class:`Voice` is used to represent a *Part* or *Instrument* within a score. +* A :class:`Voice` is used to represent a *UnquantizedPart* or *Instrument* within a score. It includes multiple attributes and methods to customize its representation and playback. .. automodapi:: maelzel.core.chain diff --git a/docs/clip.rst b/docs/clip.rst index 7b96a11..58840f9 100644 --- a/docs/clip.rst +++ b/docs/clip.rst @@ -8,4 +8,4 @@ Clip .. automodapi:: maelzel.core.clip :no-inherited-members: - :no-heading: + diff --git a/docs/core.rst b/docs/core.rst index 442b230..fce56a4 100644 --- a/docs/core.rst +++ b/docs/core.rst @@ -29,18 +29,16 @@ Key Concepts MObj All classes defined in **maelzel.core** inherit from :class:`~maelzel.core.mobj.MObj` (*Maelzel Object*, or - *Music Object*). A :class:`~maelzel.core.mobj.MObj` **exists in time** (it has a time offset and - duration attributes), it **can be displayed as notation** (:meth:`~maelzel.core.mobj.MObj.show`) + *Music Object*). A :class:`~maelzel.core.mobj.MObj` **exists in time** (it has a duration and a + time offset), it **can be displayed as notation** (:meth:`~maelzel.core.mobj.MObj.show`) and **played as audio** (:meth:`~maelzel.core.mobj.MObj.play`) Explicit / Implicit Time - A :class:`~maelzel.core.mobj.MObj` has always an *offset* and *dur* attributes. - These can be unset (``None``), meaning that they are **not explicitely determined** and depend - on the context. For example, a :class:`~maelzel.core.event.Note` might have no offset or - duration set. When adding such a note to a sequence of notes (a :class:`~maelzel.core.chain.Chain`) - its start time will be set to the end of the previous note/chord in the chain, or 0 if this is - the first note. Its duration will be determined to last until the next event in the sequence. + A :class:`~maelzel.core.mobj.MObj` always has an explicit duration (the *dur* attribute). The + *offset* can be undetermined (``None``), meaning that it is **not explicitely set** and depends + on the context. A :class:`~maelzel.core.event.Note` without an explicit offset + will be stacked left to the previous event. Absolute Time / Relative Time The time attributes (*offset*, *dur*, *end*) of a :class:`~maelzel.core.mobj.MObj` refer to a @@ -134,7 +132,7 @@ Table of Contents Containers: Chain, Voice Score Structure: interfacing symbolic and real time Score - Clip + Clip coreplayintro config workspace diff --git a/docs/requirements.txt b/docs/requirements.txt index a8d1e61..ebceaa6 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,7 +2,6 @@ emlib>=1.7.0 numpy scipy matplotlib -music21 bpf4 configdict>=2.5 appdirs diff --git a/docs/scoringquant.rst b/docs/scoringquant.rst index d3f5395..c332bb5 100644 --- a/docs/scoringquant.rst +++ b/docs/scoringquant.rst @@ -1,6 +1,8 @@ -=================== -Quantization Engine -=================== + + +=================================== +maelzel.scoring.quant: Quantization +=================================== .. automodapi:: maelzel.scoring.quant :no-inheritance-diagram: @@ -13,9 +15,7 @@ Other Classes :class:`~maelzel.scoring.node.Node` ------------------------------------ - -A Node is a container, grouping Notation and other Nodes under one time modifier + A Node is a container, grouping Notation and other Nodes under one time modifier .. toctree:: diff --git a/maelzel/__init__.py b/maelzel/__init__.py index c6f26eb..c481467 100644 --- a/maelzel/__init__.py +++ b/maelzel/__init__.py @@ -3,7 +3,7 @@ # Only check dependencies on first run if _state.isFirstSession() and not "sphinx" in sys.modules: - + import logging logging.basicConfig(level=logging.WARNING, format='[%(name)s:%(filename)s:%(lineno)s - %(funcName)s] %(message)s') diff --git a/maelzel/core/chain.py b/maelzel/core/chain.py index 81352dd..b382f7c 100644 --- a/maelzel/core/chain.py +++ b/maelzel/core/chain.py @@ -317,7 +317,7 @@ def flat(self, forcecopy=False) -> Chain: return self flatevents = self.eventsWithOffset() - events = [ev if not forcecopy or ev.offset == offset else ev.clone(offset=offset) + events = [ev.clone(offset=offset) if forcecopy or ev.offset != offset else ev for ev, offset in flatevents] return self.clone(items=events) @@ -848,7 +848,7 @@ def scoringEvents(self, config = Workspace.active.config if parentOffset is None: - parentOffset = self.parent.absoluteOffset() if self.parent else F(0) + parentOffset = self.parent.absoluteOffset() if self.parent else F0 offset = parentOffset + self.resolveOffset() chain = self.flat() @@ -857,7 +857,7 @@ def scoringEvents(self, notations.append(scoring.makeRest(duration=chain[0].resolveOffset())) for item in chain.items: notations.extend(item.scoringEvents(groupid=groupid, config=config, parentOffset=offset)) - scoring.stackNotationsInPlace(notations) + scoring.resolveOffsetsInPlace(notations) for n0, n1 in iterlib.pairwise(notations): if n0.tiedNext and not n1.isRest: @@ -888,16 +888,16 @@ def _solveOrfanHairpins(self, currentDynamic='mf'): def scoringParts(self, config: CoreConfig = None - ) -> list[scoring.Part]: + ) -> list[scoring.UnquantizedPart]: if config is None: config = Workspace.active.config self._update() notations = self.scoringEvents(config=config) if not notations: return [] - scoring.stackNotationsInPlace(notations) + scoring.resolveOffsetsInPlace(notations) if config['show.voiceMaxStaves'] == 1: - parts = [scoring.Part(notations, name=self.label)] + parts = [scoring.UnquantizedPart(notations, name=self.label)] else: groupid = scoring.makeGroupId() parts = scoring.distributeNotationsByClef(notations, groupid=groupid) @@ -1458,7 +1458,7 @@ def clone(self, **kws) -> Voice: return out def scoringParts(self, config: CoreConfig = None - ) -> list[scoring.Part]: + ) -> list[scoring.UnquantizedPart]: if not self.name and self.label: logger.debug("This Voice has a set label ({self.label}) but no name. If you " "need to set the staff name/shortname, set those attributes " diff --git a/maelzel/core/clip.py b/maelzel/core/clip.py index 97bf0d3..0366ac8 100644 --- a/maelzel/core/clip.py +++ b/maelzel/core/clip.py @@ -38,17 +38,28 @@ class Clip(event.MEvent): Args: source: the source of the clip (a filename, audiosample, samples as numpy array) + dur: the duration of the clip, in quarternotes. If not given, the duration will + be the duration of the source. If loop==True, then the duration **needs** to + be given pitch: the pitch representation of this clip. It has no influence in the playback itself, it is only for notation purposes + amp: the playback gain offset: the time offset of this clip. Like in a Note, if not given, the start time depends on the context (previous events) where this clip is evaluated - label: a label str to identify this clip - dynamic: allows to attach a dynamic expression to this Clip. This is - only for notation purposes, it does not modify playback startsecs: selection start time (in seconds) endsecs: selection end time (in seconds). If 0., play until the end of the source + loop: if True, playback of this Clip should be looped + label: a label str to identify this clip speed: playback speed of the clip + dynamic: allows to attach a dynamic expression to this Clip. This is + only for notation purposes, it does not modify playback + tied: this clip should be tied to the next one. This is only valid if the clips + share the same source (same soundfile or samples) and allows to automate + parameters such as playback speed or amplitude. + noteheadShape: the notehead shape to use for notation, one of 'normal', 'cross', + 'harmonic', 'triangle', 'xcircle', 'rhombus', 'square', 'rectangle', 'slash', 'cluster'. + (see :ref:`config['show.clipNoteheadShape'] `) """ _excludedPlayKeys: tuple[str] = ('instr', 'args') @@ -71,22 +82,23 @@ class Clip(event.MEvent): '_engine', '_csoundTable', '_sample', - '_durContext' + '_durContext', + '_explicitDur' ) def __init__(self, source: str | audiosample.Sample | tuple[np.ndarray, int], + dur: time_t = None, pitch: pitch_t = None, amp: float = 1., offset: time_t = None, - label: str = '', - dynamic: str = '', startsecs: float | F = 0., endsecs: float | F = 0., channel: int = None, - speed: F | float = F1, - parent: MContainer | None = None, loop=False, + speed: F | float = F1, + label: str = '', + dynamic: str = '', tied=False, noteheadShape: str = None ): @@ -95,6 +107,9 @@ def __init__(self, if not source: raise ValueError("No source selected") + if loop and dur is None: + raise ValueError(f"The duration of a looping Clip needs to be given (source: {source})") + self.soundfile = '' """The soundfile holding the audio data (if any)""" @@ -128,6 +143,8 @@ def __init__(self, self.noteheadShape = noteheadShape """The shape to use as notehead""" + self._explicitDur: F | None = dur + if isinstance(source, tuple) and len(source) == 2 and isinstance(source[0], np.ndarray): data, sr = source assert isinstance(data, np.ndarray) @@ -191,7 +208,8 @@ def __init__(self, if offset is not None: offset = asF(offset) - super().__init__(offset=offset, dur=None, label=label, parent=parent) + super().__init__(offset=offset, dur=dur, label=label) + @property def sr(self) -> float: @@ -249,7 +267,7 @@ def asSample(self) -> audiosample.Sample: Return a :class:`maelzel.snd.audiosample.Sample` with the audio data of this Clip Returns: - a Sample with the audio data of this Clip. The returned Sample is read-only + a Sample with the audio data of this Clip. The returned Sample is read-only. Example ~~~~~~~ @@ -258,10 +276,16 @@ def asSample(self) -> audiosample.Sample: """ if self._sample is not None: return self._sample + if isinstance(self.source, audiosample.Sample): return self.source else: - sample = audiosample.Sample(self.source, readonly=True, engine=self._engine) + assert isinstance(self.source, str) + sample = audiosample.Sample(self.source, + readonly=True, + engine=self._engine, + start=float(self.selectionStartSecs), + end=float(self.selectionEndSecs)) self._sample = sample return sample @@ -269,27 +293,44 @@ def isRest(self) -> bool: return False def durSecs(self) -> F: - assert isinstance(self.selectionEndSecs, F) - assert isinstance(self.selectionStartSecs, F) - assert isinstance(self.speed, F) return (self.selectionEndSecs - self.selectionStartSecs) / self.speed def pitchRange(self) -> tuple[float, float]: return (self.pitch, self.pitch) + def _durationInBeats(self, + absoffset: F | None = None, + scorestruct: ScoreStruct = None) -> F: + """ + Calculate the duration in beats without considering looping or explicit duration + + Args: + scorestruct: the score structure + + Returns: + the duration in quarternotes + """ + absoffset = absoffset if absoffset is not None else self.absoluteOffset() + struct = scorestruct or self.scorestruct() or Workspace.active.scorestruct + starttime = struct.beatToTime(absoffset) + endbeat = struct.timeToBeat(starttime + self.durSecs()) + return endbeat - absoffset + @property def dur(self) -> F: "The duration of this Clip, in quarter notes" + if self._explicitDur: + return self._explicitDur + absoffset = self.absoluteOffset() struct = self.scorestruct() or Workspace.active.scorestruct + if self._dur is not None and self._durContext is not None: cachedstruct, cachedbeat = self._durContext if struct is cachedstruct and cachedbeat == absoffset: return self._dur - starttime = struct.beatToTime(absoffset) - dursecs = self.durSecs() - endbeat = struct.timeToBeat(starttime + dursecs) - dur = endbeat - absoffset + + dur = self._durationInBeats(absoffset=absoffset, scorestruct=struct) self._dur = dur self._durContext = (struct, absoffset) return dur @@ -308,7 +349,7 @@ def _synthEvents(self, reloffset = self.resolveOffset() offset = reloffset + parentOffset starttime = float(scorestruct.beatToTime(offset)) - endtime = float(starttime + self.durSecs()) + endtime = float(scorestruct.beatToTime(offset + self.dur)) amp = firstval(self.amp, 1.0) bps = [[starttime, self.pitch, amp], [endtime, self.pitch, amp]] @@ -320,7 +361,8 @@ def _synthEvents(self, args = {'ipath': self.soundfile, 'isndfilechan': -1 if self.channel is None else self.channel, 'kspeed': self.speed, - 'iskip': skip} + 'iskip': skip, + 'iwrap': 1 if self.loop else 0} playargs = playargs.clone(instr='_clip_diskin', args=args) elif self._playbackMethod == 'table': diff --git a/maelzel/core/configdata.py b/maelzel/core/configdata.py index 982dedf..00b5eae 100644 --- a/maelzel/core/configdata.py +++ b/maelzel/core/configdata.py @@ -90,7 +90,7 @@ 'quant.minBeatFractionAcrossBeats': 0.5, 'quant.nestedTuplets': None, 'quant.nestedTupletsInMusicxml': False, - 'quant.breakSyncopationsLevel': 'weak', + 'quant.breakSyncopationsLevel': 'none', 'quant.complexity': 'high', 'quant.divisionErrorWeight': None, 'quant.gridErrorWeight': None, diff --git a/maelzel/core/mobj.py b/maelzel/core/mobj.py index a97eae5..fff0642 100644 --- a/maelzel/core/mobj.py +++ b/maelzel/core/mobj.py @@ -864,35 +864,35 @@ def scoringEvents(self, def scoringParts(self, config: CoreConfig = None - ) -> list[scoring.Part]: + ) -> list[scoring.UnquantizedPart]: """ - Returns this object as a list of scoring Parts. + Returns this object as a list of scoring UnquantizedParts. Args: config: if given, this config instead of the active config will be used Returns: - a list of scoring.Part + a list of unquantized parts This method is used internally to generate the parts which constitute a given MObj prior to rendering, but might be of use itself so it is exposed here. - A :class:`maelzel.scoring.Part` is an intermediate format used by the scoring - package to represent notated events. A :class:`maelzel.scoring.Part` - is unquantized and independent of any score structure + An :class:`maelzel.scoring.UnquantizedPart` is an intermediate format used by the scoring + package to represent notated events. It represents a list of non-simultaneous Notations, + unquantized and independent of any score structure """ notations = self.scoringEvents(config=config or Workspace.active.config) if not notations: return [] - scoring.stackNotationsInPlace(notations) + scoring.resolveOffsetsInPlace(notations) parts = scoring.distributeNotationsByClef(notations) return parts - def scoringArrangement(self, title: str = None) -> scoring.Arrangement: + def unquantizedScore(self, title: str = None) -> scoring.UnquantizedScore: """ - Create a maelzel.scoring.Arrangement from this object + Create a maelzel.scoring.UnquantizedScore from this object Args: title: the title of the resulting score (if given) @@ -900,8 +900,8 @@ def scoringArrangement(self, title: str = None) -> scoring.Arrangement: Returns: the Arrangement representation of this object - An :class:`~maelzel.scoring.Arrangement` is a list of - :class:`~maelzel.scoring.Part`, which is itself a list of + An :class:`~maelzel.scoring.UnquantizedScore` is a list of + :class:`~maelzel.scoring.UnquantizedPart`, which is itself a list of :class:`~maelzel.scoring.Notation`. An :class:`Arrangement` represents an **unquantized** score, meaning that the Notations within each part are not split into measures, nor organized in beats. To generate a quantized score @@ -915,7 +915,7 @@ def scoringArrangement(self, title: str = None) -> scoring.Arrangement: """ parts = self.scoringParts() - return scoring.Arrangement(parts, title=title) + return scoring.UnquantizedScore(parts, title=title) def _scoringAnnotation(self, text: str = None, config: CoreConfig = None ) -> scoring.attachment.Text: @@ -932,12 +932,6 @@ def _scoringAnnotation(self, text: str = None, config: CoreConfig = None weight='bold' if labelstyle.bold else '', color=labelstyle.color) - def musicxml(self) -> str: - """ - Return the music representation of this object as musicxml. - """ - raise NotImplementedError - def scorestruct(self) -> ScoreStruct | None: """ Returns the ScoreStruct active for this obj or its parent (recursively) diff --git a/maelzel/core/musicxml.py b/maelzel/core/musicxml.py deleted file mode 100644 index b2c889b..0000000 --- a/maelzel/core/musicxml.py +++ /dev/null @@ -1,1019 +0,0 @@ -from __future__ import annotations -import copy -from maelzel.scorestruct import ScoreStruct, TimeSignature -from .event import Note, Chord, Rest -from .chain import Voice -from .score import Score -from . import symbols -from maelzel.common import F -from ._common import logger -import pitchtools as pt -from emlib.iterlib import pairwise -import emlib.mathlib -from dataclasses import dataclass -from maelzel import scoring -from typing import Callable - -import xml.etree.ElementTree as ET - - -__all__ = ( - 'parseMusicxml', - 'parseMusicxmlFile', - 'MusicxmlParseError', -) - - -class MusicxmlParseError(Exception): - pass - - -_unitToFactor = { - 'quarter': 1., - 'eighth': 0.5, - '16th': 0.25, - 'half': 2, - 'whole': 4, -} - - -class ParseContext: - """ - A musicxml parsing context - - Args: - divisions: the musicxml divisions per quarternote - enforceParsedSpelling: if True, use the original enharmonic spelling as read from musicxml - """ - def __init__(self, divisions: int, enforceParsedSpelling=True): - - self.divisions = divisions - "The number of divisions per quarter note" - - self.enforceParsedSpelling = enforceParsedSpelling - "If True, use the parsed enharmonic spelling of the musicxml" - - self.spanners: dict[str, symbols.Spanner] = {} - "A registry of open spanners" - - self.octaveshift: int = 0 - "Octave shift context" - - self.transposition: int = 0 - "Transposition context" - - def copy(self) -> ParseContext: - out = copy.copy(self) - assert out.divisions > 0 - return out - - -def _parseMetronome(metronome: ET.Element) -> float: - """ - Parse tag, return the quarter-note tempo - - Args: - metronome: the tag element - - Returns: - the tempo of a quarter note - """ - unit = _.text if (_ := metronome.find('beat-unit')) is not None else 'quarter' - dotted = bool(metronome.find('beat-unit-dot') is not None) - bpm = metronome.find('per-minute').text - factor = _unitToFactor[unit] - if dotted: - factor *= 1.5 - quarterTempo = float(bpm) * factor - return quarterTempo - - -def _parseRest(root: ET.Element, context: ParseContext) -> Note: - measureRest = root.find('rest').attrib.get('measure', False) == 'yes' - xmldur = int(root.find('totalDuration').text) - divisions = context.divisions - rest = Rest(dur=F(xmldur, divisions)) - if measureRest: - rest.properties['measureRest'] = True - return rest - - -_alterToAccidental = { - 0: '', - 1: '#', - 0.5: '+', - -1: 'b', - -0.5: '-' -} - - -def _notename(step: str, octave: int, alter: float, accidental: str = '') -> str: - - cents = round(alter*100) - if cents >= 0: - midi = pt.n2m(f"{octave}{step}+{cents}") - else: - midi = pt.n2m(f"{octave}{step}-{abs(cents)}") - notename = pt.m2n(midi) - pitchclass = notename[1] - if pitchclass.upper() != step.upper(): - notename = pt.enharmonic(notename) - return notename - - -def _parsePitch(node, prefix='') -> tuple[int, int, float]: - step = node.find(f'{prefix}step').text - oct = int(node.find(f'{prefix}octave').text) - alter = float(_.text) if (_ := node.find('alter')) is not None else 0 - return step, oct, alter - - -def _makeSpannerId(prefix: str, d: dict, key='number'): - spannerid = d.get(key) - return prefix if not spannerid else f"{prefix}-{spannerid}" - - -@dataclass -class Notation: - kind: str - value: str = '' - properties: dict | None = None - skip: bool = False - - -def _makeSpannerNotation(kind: str, class_: type, node: ET.Element): - properties = {k: v for k, v in node.attrib.items()} - properties['class'] = class_ - properties['mxml/tag'] = f'notation/{node.tag}' - return Notation('spanner', kind, properties=properties) - - -def _parseOrnament(node: ET.Element) -> Notation: - tag = node.tag - if tag == 'tremolo': - # 2 - # 3 - tremtype = node.attrib.get('type') - if tremtype == 'stop': - tremtype = 'end' - return Notation('ornament', 'tremolo', - properties={'tremtype': tremtype, - 'nummarks': int(node.text)}) - else: - return Notation('ornament', tag, properties=dict(node.attrib)) - - -def _parseNotations(root: ET.Element) -> list[Notation]: - notations = [] - node: ET.Element - for node in root: - tag = node.tag - assert isinstance(node.attrib, dict) - if tag == 'articulations': - notations.extend([Notation('articulation', subnode.tag, properties=dict(subnode.attrib)) for subnode in node]) - elif tag == 'ornaments': - notations.extend([_parseOrnament(subnode) for subnode in node]) - elif tag == 'glissando': - notation = _makeSpannerNotation('glissando', symbols.NoteheadLine, node) - notations.append(notation) - elif tag == 'slide': - notation = _makeSpannerNotation('glissando', symbols.NoteheadLine, node) - notations.append(notation) - elif tag == 'fermata': - notations.append(Notation('fermata', node.text, properties=dict(node.attrib))) - elif tag == 'dynamics': - dyn = node[0].tag - if dyn == 'other-dynamics': - dyn = node[0].text - notations.append(Notation('dynamic', dyn)) - elif tag == 'arpeggiate': - notations.append(Notation('articulation', 'arpeggio')) - elif tag == 'technical': - notation = _parseTechnicalNotation(node) - if notation is not None: - notations.append(notation) - elif tag == 'slur': - notation = _makeSpannerNotation('slur', symbols.Slur, node) - notations.append(notation) - out = [] - - # Handle some special cases - if notations: - for n0, n1 in pairwise(notations): - if (n0.kind == 'ornament' and - n1.kind == 'ornament' and - n0.value == 'trill-mark' and - n1.value == 'wavy-line'): - n1.properties['startmark'] = 'trill' - elif (n0.kind == 'ornament' and n1.kind == 'ornament' and - n0.value == 'wavy-line' and n1.value == 'wavy-line' and - n0.properties['type'] == 'start' and n1.properties['type'] == 'stop'): - n1.value = 'inverted-mordent' - else: - out.append(n0) - out.append(notations[-1]) - return out - - -_technicalNotationToArticulation = { - 'up-bow': 'upbow', - 'down-bow': 'downbow', - 'open-string': 'openstring' -} - - -def _parseTechnicalNotation(root: ET.Element) -> Notation | None: - inner = root[0] - tag = inner.tag - if tag == 'stopped': - return Notation('articulation', 'closed') - elif tag == 'snap-pizzicato': - return Notation('articulation', 'snappizz') - elif tag == 'string': - whichstr = inner.text - if whichstr: - romannum = emlib.mathlib.roman(int(whichstr)) - return Notation('text', romannum, properties={'placement': 'above'}) - elif tag == 'harmonic': - # possibilities: - # harmonic - # harmonic/natural - # harmonic/artificial - if len(inner) == 0: - return Notation('articulation', 'flageolet') - elif inner[0].tag == 'artificial': - return Notation('notehead', 'harmonic') - elif inner[0].tag == 'natural': - if inner.find('touching-pitch') is not None: - # This should be solved via a notehead change so leave this out - return Notation('notehead', 'harmonic') - elif inner.find('base-pitch') is not None: - # Do nothing here - pass - else: - return Notation('articulation', 'flageolet') - elif (articulation := _technicalNotationToArticulation.get(tag)) is not None: - return Notation('articulation', value=articulation) - elif tag == 'fingering': - return Notation('fingering', value=inner.text) - elif tag == 'bend': - bendalter = float(inner.find('bend-alter').text) - return Notation('bend', properties={'alter': bendalter}) - - -def _parseNote(root: ET.Element, context: ParseContext) -> Note: - notesymbols = [] - pstep = '' - dur = 0 - noteType = '' - tied = False - properties = {} - notations = [] - poct = 4 - palter = 0 - for node in root: - if node.tag == 'rest': - noteType = 'rest' - elif node.tag == 'chord': - noteType = 'chord' - elif node.tag == 'unpitched': - noteType = 'unpitched' - pstep, poct, palter = _parsePitch(node, prefix='display-') - elif node.tag == 'notehead': - shape = scoring.definitions.normalizeNoteheadShape(node.text) - parens = node.attrib.get('parentheses') == 'yes' - if not shape: - logger.warning(f'Notehead shape not supported: "{node.text}"') - else: - notesymbols.append(symbols.Notehead(shape=shape, parenthesis=parens)) - properties['mxml/notehead'] = node.text - elif node.tag == 'grace': - noteType = 'grace' - elif node.tag == 'pitch': - pstep, poct, palter = _parsePitch(node) - elif node.tag == 'totalDuration': - dur = F(int(node.text), context.divisions) - elif node.tag == 'accidental': - accidental = node.text - properties['mxml/accidental'] = accidental - if node.attrib.get('editorial') == 'yes': - notesymbols.append(symbols.Accidental(parenthesis=True)) - elif node.tag == 'type': - properties['mxml/durationtype'] = node.text - elif node.tag == 'voice': - properties['voice'] = int(node.text) - elif node.tag == 'tie' and node.attrib.get('type', 'start') == 'start': - tied = True - elif node.tag == 'notations': - notations.extend(_parseNotations(node)) - elif node.tag == 'lyric': - if (textnode := node.find('text')) is not None: - text = textnode.text - if text: - notesymbols.append(symbols.Text(text, placement='below')) - else: - ET.dump(node) - logger.error("Could not find lyrincs text") - - if noteType == 'rest': - rest = Rest(dur) - if properties: - if rest.properties is None: - rest.properties = properties - else: - rest.properties.update(properties) - return rest - - if not pstep: - ET.dump(root) - raise MusicxmlParseError("Did not find pitch-step for note") - - notename = _notename(step=pstep, octave=poct, alter=palter) - if context.transposition != 0: - notename = pt.transpose(notename, context.transposition) - - if noteType == 'chord': - dur = 0 - properties['_chordCont'] = True - elif noteType == 'grace': - dur = 0 - - note = Note(pitch=notename, dur=dur, tied=tied, properties=properties) - - if noteType == 'unpitched': - note.addSymbol(symbols.Notehead('x')) - - if context.enforceParsedSpelling: - note.pitchSpelling = notename - - if notations: - for notation in notations: - if notation.skip: - continue - if notation.kind == 'articulation': - articulation = scoring.definitions.normalizeArticulation(notation.value) - if articulation: - note.addSymbol('articulation', articulation) - else: - # If this is an unsupported articulation, at least save it as a property - logger.warning(f"Articulation not supported: {notation.value}") - note.properties['mxml/articulation'] = notation.value - elif notation.kind == 'ornament': - if notation.value == 'wavy-line': - kind = notation.properties['type'] - key = _makeSpannerId('trilline', notation.properties, 'number') - if kind == 'start': - spanner = symbols.TrillLine(kind='start', - placement=notation.properties.get('placement', ''), - startmark=notation.properties.get('startmark', '')) - note.addSymbol(spanner) - context.spanners[key] = spanner - else: - assert kind == 'stop' - startspanner = context.spanners.pop(key) - startspanner.makeEndSpanner(note) - elif notation.value == 'tremolo': - tremtype = notation.properties.get('tremtype', 'single') - nummarks = notation.properties.get('nummarks', 2) - note.addSymbol(symbols.Tremolo(tremtype=tremtype, nummarks=nummarks)) - else: - if ornament := scoring.definitions.normalizeOrnament(notation.value): - note.addSymbol('ornament', ornament) - else: - note.properties['mxml/ornament'] = notation.value - elif notation.kind == 'fermata': - note.addSymbol('fermata', scoring.definitions.normalizeFermata(notation.value)) - elif notation.kind == 'dynamic': - dynamic = notation.value - if dynamic2 := scoring.definitions.normalizeDynamic(dynamic): - note.dynamic = dynamic2 - else: - note.addText(dynamic, placement='below', fontstyle='italic,bold') - elif notation.kind == 'fingering': - note.addSymbol(symbols.Fingering(notation.value)) - elif notation.kind == 'notehead': - note.addSymbol(symbols.Notehead(shape=notation.value)) - elif notation.kind == 'text': - note.addSymbol(symbols.Text(notation.value, - placement=notation.properties.get('placement', 'above'), - fontstyle=notation.properties.get('fontstyle'))) - elif notation.kind == 'spanner': - spannertype = notation.properties['type'] - key = _makeSpannerId(notation.value, notation.properties) - if spannertype == 'start': - cls = notation.properties.pop('class') - spanner = cls(kind='start', - linetype=notation.properties.get('line-type', 'solid'), - color=notation.properties.get('color', '')) - if notation.properties: - for k, v in notation.properties.items(): - spanner.setProperty(f'mxml/{k}', v) - context.spanners[key] = spanner - note.addSymbol(spanner) - else: - startspanner = context.spanners.pop(key, None) - if not startspanner: - logger.error(f"No start spanner found for key {key}") - else: - startspanner.makeEndSpanner(note) - - elif notation.kind == 'bend': - note.addSymbol(symbols.Bend(notation.properties['alter'])) - for symbol in notesymbols: - note.addSymbol(symbol) - - return note - - -def _joinChords(notes: list[Note]) -> list[Note | Chord]: - """ - Join notes belonging to a chord - - Musicxml encodes chords as individual notes, where - the first note of a chord is just a regular note - followed by other notes which contain the - tag. Those notes should be merged to the previous - note into a chord. The totalDuration is given by the - first note and no subsequent note can be longer - (but they might be shorted). - - Since at the time in `maelzel.core` all notes within - a chord share the same totalDuration we discard - all durations but the first one. - - Args: - notes: a list of Notes, as parsed from musicxml. - - Returns: - a list of notes/chords - """ - # mark chord starts - if len(notes) == 1: - return notes - - groups = [] - for note in notes: - if note.properties.get('_chordCont'): - groups[-1].append(note) - else: - groups.append([note]) - - for i, group in enumerate(groups): - if len(group) == 1: - groups[i] = group[0] - else: - first = group[0] - assert isinstance(first, Note) - chord = first.asChord() - for note in group[1:]: - chord.append(note) - chord.sort() - chord.properties['voice'] = first.properties.get('voice', 1) - groups[i] = chord - return groups - - -def _measureDuration(beats: int, beattype: int) -> F: - return F(beats*4, beattype) - - -@dataclass -class Direction: - kind: str - value: str = '' - placement: str = '' - properties: dict | None = None - - def getProperty(self, key, default=None): - if not self.properties: - return default - return self.properties.get(key, default) - - -def _attr(attrib: dict, key: str, default, convert=None): - value = attrib.get(key) - if value is not None: - return value if not convert else convert(value) - return default - - -def _parsePosition(x: ET.Element) -> str: - attrib = x.attrib - defaulty = _attr(attrib, 'default-y', 0., float) - relativey = _attr(attrib, 'relative-y', 0., float) - pos = defaulty + relativey - return '' if pos == 0 else 'above' if pos > 0 else 'below' - - -def _parseAttribs(attrib: dict, convertfuncs: dict[str, Callable] = None) -> dict: - out = {} - for k, v in attrib.items(): - convertfunc = None if not convertfuncs else convertfuncs.get(k) - if v is not None: - if convertfunc: - v2 = convertfunc(v) - elif v == 'yes': - v2 = True - elif v == 'no': - v2 = False - elif v.isnumeric(): - v2 = int(v) - else: - v2 = v - out[k] = v2 - return out - - -def _applyDynamic(event: Note | Chord, dynamic: str) -> None: - if dynamic2 := scoring.definitions.normalizeDynamic(dynamic): - event.dynamic = dynamic2 - else: - event.addText(dynamic, placement='below', fontstyle='italic') - - -def _parseTimesig(root: ET.Element) -> TimeSignature: - """ -