From 2d5b50a8242c046afc5fd96fe1d397535352f842 Mon Sep 17 00:00:00 2001 From: gesellkammer Date: Thu, 29 Jun 2023 22:58:58 +0200 Subject: [PATCH] added more docs --- docs/renderer.rst | 3 + docs/renderoptions.rst | 3 + docs/scoringrender.rst | 7 + maelzel/core/musicxmlparser.py | 1022 ++++++++++++++++++++++++++++++++ 4 files changed, 1035 insertions(+) create mode 100644 docs/renderer.rst create mode 100644 docs/renderoptions.rst create mode 100644 docs/scoringrender.rst create mode 100644 maelzel/core/musicxmlparser.py diff --git a/docs/renderer.rst b/docs/renderer.rst new file mode 100644 index 0000000..2574427 --- /dev/null +++ b/docs/renderer.rst @@ -0,0 +1,3 @@ + +.. autoclass:: maelzel.scoring.renderer.Renderer + :members: \ No newline at end of file diff --git a/docs/renderoptions.rst b/docs/renderoptions.rst new file mode 100644 index 0000000..02ccb82 --- /dev/null +++ b/docs/renderoptions.rst @@ -0,0 +1,3 @@ + +.. autoclass:: maelzel.scoring.rendereroptions.RenderOptions + :members: \ No newline at end of file diff --git a/docs/scoringrender.rst b/docs/scoringrender.rst new file mode 100644 index 0000000..39a832f --- /dev/null +++ b/docs/scoringrender.rst @@ -0,0 +1,7 @@ +================================= +maelzel.scoring.render: Rendering +================================= + +.. automodapi:: maelzel.scoring.render + :no-inheritance-diagram: + :no-heading: diff --git a/maelzel/core/musicxmlparser.py b/maelzel/core/musicxmlparser.py new file mode 100644 index 0000000..0742c0b --- /dev/null +++ b/maelzel/core/musicxmlparser.py @@ -0,0 +1,1022 @@ +""" +Implements a musicxml parser +""" +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: + """ +