from __future__ import annotations import copy import fractions import pathlib import defusedxml.ElementTree import generator.harmony import generator.model import generator.validation class MusicXMLError(ValueError): pass # Ignoring a container never authorizes ignoring its musical descendants. LAYOUT = {'default-x', 'default-y', 'relative-x', 'relative-y', 'color', 'font-family', 'font-size', 'font-style', 'font-weight', 'halign', 'valign', 'justify', 'placement', 'id'} IGNORED_TOP = {'defaults', 'credit', 'work', 'movement-number', 'movement-title', 'identification', 'part-list'} class Importer: def __init__(self, path: pathlib.Path) -> None: self.path = path self.location = str(path) self.divisions = fractions.Fraction(1) self.key = generator.model.key_from_fifths(0) self.meter = {'groups': [{'beats': [4], 'unit': 4}], 'symbol': 'normal'} self.clef = {'sign': 'G', 'line': 2, 'octave_change': 0} self.transpose = {'diatonic': 0, 'chromatic': 0, 'octave_change': 0} self.sequence = 0 self.harmony_sequence = 0 self.tuplet_sequence = 0 self.active_tuplets: dict[str, dict] = {} self.tuplets: list[dict] = [] self.open_ties: dict[int, dict] = {} self.open_octaves: dict[str, dict] = {} self.octaves: list[dict] = [] self.voice_id = '1' self.staff_details = {'lines': 5, 'tunings': []} self.absolute = fractions.Fraction() self.tremolo_starts = {} def fail(self, text: str): raise MusicXMLError(f'{self.location}: {text}') def children(self, node, allowed: set[str]) -> None: for item in node: if item.tag not in allowed: self.fail(f'unsupported musical element <{item.tag}> in <{node.tag}>') def attrs(self, node, allowed=()) -> None: for name in node.attrib: if name not in LAYOUT and name not in allowed: self.fail(f'unsupported {node.tag} attribute {name!r}') def number(self, value: str | None, *, integer: bool = False, positive: bool = False) -> fractions.Fraction | int: try: result = fractions.Fraction(value or '') if integer and result.denominator != 1 or positive and result <= 0: raise ValueError() generator.model.rational(result) return int(result) if integer else result except (ValueError, ZeroDivisionError, OverflowError): self.fail(f'invalid {"integer" if integer else "number"}: {value!r}') def offset(self, node, cursor: fractions.Fraction) -> fractions.Fraction: return cursor + self.number(node.findtext('offset', '0')) / self.divisions def identity(self, node) -> None: voice = node.findtext('voice', '1' if node.tag in ('note', 'forward') else self.voice_id) staff = node.findtext('staff', '1') if staff != '1': self.fail('multiple staves are not supported') if voice is None: return if not voice.strip(): self.fail('empty voice identifier') self.voice_id = voice def attributes(self, node, onset: fractions.Fraction) -> dict: self.children(node, {'divisions', 'key', 'time', 'clef', 'staves', 'transpose', 'instruments', 'staff-details'}) result = {'onset': generator.model.rational(onset)} for item in node: if item.get('number', '1') != '1': self.fail('attributes for multiple staves are not supported') if item.tag == 'divisions': self.divisions = self.number(item.text, positive=True) elif item.tag in ('staves', 'instruments'): if item.text != '1': self.fail(f'multiple {item.tag} are not supported') elif item.tag == 'key': self.attrs(item, {'number', 'print-object'}) self.children(item, {'cancel', 'fifths', 'mode', 'key-step', 'key-alter', 'key-accidental', 'key-octave'}) if item.find('fifths') is not None: self.key = generator.model.key_from_fifths(self.number(item.findtext('fifths'), integer=True), item.findtext('mode', 'major')) else: self.key = {'fifths': 0, 'mode': 'none', 'tonic': None, 'alterations': [generator.model.rational(0) for _ in range(7)], 'custom': True, 'steps': []} pending = None for field in item: if field.tag == 'key-step': pending = (field.text or '').lower() if pending not in 'cdefgab' or len(pending) != 1: self.fail('invalid custom key step') elif field.tag == 'key-alter': if pending is None: self.fail('key alteration without a step') amount = generator.model.rational(self.number(field.text)) self.key['alterations']['cdefgab'.index(pending)] = amount self.key['steps'].append({'letter': pending, 'alter': amount}) pending = None elif field.tag == 'key-accidental': if not self.key['steps']: self.fail('key accidental without a preceding step') self.key['steps'][-1]['accidental'] = field.text elif field.tag == 'key-octave': number = self.number(field.get('number'), integer=True, positive=True) if number > len(self.key['steps']): self.fail('key octave refers to an absent step') self.key['steps'][number - 1]['octave'] = self.number(field.text, integer=True) if pending is not None: self.fail('custom key step without alteration') if item.find('cancel') is not None: self.key['cancel'] = self.number(item.findtext('cancel'), integer=True) result['key'] = copy.deepcopy(self.key) elif item.tag == 'time': self.attrs(item, {'number', 'symbol', 'separator', 'print-object'}) self.children(item, {'beats', 'beat-type', 'senza-misura'}) self.meter = {'groups': [], 'symbol': item.get('symbol', 'normal')} if self.meter['symbol'] not in ('normal', 'common', 'cut', 'single-number', 'note', 'dotted-note'): self.fail('unsupported meter symbol') if item.find('senza-misura') is not None: self.meter['unmetered'] = True else: pending = None for field in item: if field.tag == 'beats': if pending is not None: self.fail('beats without beat-type') pending = [self.number(v, integer=True, positive=True) for v in (field.text or '').split('+')] elif field.tag == 'beat-type': if not pending: self.fail('beat-type without beats') self.meter['groups'].append({'beats': pending, 'unit': self.number(field.text, integer=True, positive=True)}) pending = None if pending is not None or not self.meter['groups']: self.fail('incomplete meter') result['meter'] = copy.deepcopy(self.meter) elif item.tag == 'clef': self.attrs(item, {'number', 'additional', 'size', 'after-barline', 'print-object'}) self.children(item, {'sign', 'line', 'clef-octave-change'}) sign = item.findtext('sign') if sign == 'TAB': self.fail('authored TAB is unsupported; guitar tablature is generated from pitches') if sign not in ('G', 'F', 'C', 'percussion', 'TAB', 'none'): self.fail(f'unsupported clef {sign!r}') line = self.number(item.findtext('line', {'G': '2', 'F': '4'}.get(sign, '3')), integer=True) if not 1 <= line <= 6: self.fail('clef line must be between 1 and 6') self.clef = {'sign': sign, 'line': line, 'octave_change': self.number(item.findtext('clef-octave-change', '0'), integer=True)} result['clef'] = copy.deepcopy(self.clef) elif item.tag == 'staff-details': self.children(item, {'staff-lines', 'staff-tuning', 'capo'}) self.staff_details = {'lines': self.number(item.findtext('staff-lines', '5'), integer=True, positive=True), 'tunings': [], 'capo': self.number(item.findtext('capo', '0'), integer=True)} for tuning in item.findall('staff-tuning'): self.children(tuning, {'tuning-step', 'tuning-alter', 'tuning-octave'}) self.staff_details['tunings'].append({'line': self.number(tuning.get('line'), integer=True, positive=True), 'letter': tuning.findtext('tuning-step', '').lower(), 'alter': generator.model.rational(self.number(tuning.findtext('tuning-alter', '0'))), 'octave': self.number(tuning.findtext('tuning-octave'), integer=True)}) result['staff_details'] = copy.deepcopy(self.staff_details) elif item.tag == 'transpose': self.children(item, {'diatonic', 'chromatic', 'octave-change'}) self.transpose = {key: self.number(item.findtext(tag, '0'), integer=True) for key, tag in (('diatonic', 'diatonic'), ('chromatic', 'chromatic'), ('octave_change', 'octave-change'))} self.transpose['explicit'] = True if item.find('chromatic') is None: self.fail('instrument transposition requires chromatic semitones') if item.find('diatonic') is None: semitones = self.transpose['chromatic'] magnitude = abs(semitones) self.transpose['diatonic'] = (1 if semitones >= 0 else -1) * (7 * (magnitude // 12) + (0, 0, 1, 2, 2, 3, 3, 4, 5, 5, 6, 6)[magnitude % 12]) result['transpose'] = copy.deepcopy(self.transpose) return result def read_harmony(self, node, cursor: fractions.Fraction) -> dict: self.attrs(node, {'print-object', 'print-frame', 'type'}) self.children(node, {'root', 'kind', 'degree', 'bass', 'inversion', 'offset', 'staff'}) if len(node.findall('kind')) != 1 or len(node.findall('root')) > 1: self.fail('stacked harmonies require separate harmony events') self.identity(node) kind = node.find('kind') self.harmony_sequence += 1 result = {'id': f'harmony_{self.harmony_sequence}', 'onset': generator.model.rational(self.offset(node, cursor)), 'kind': kind.text, 'degrees': []} self.attrs(kind, {'text', 'use-symbols', 'stack-degrees', 'parentheses-degrees', 'bracket-degrees'}) for tag in ('root', 'bass'): item = node.find(tag) if item is None: if tag == 'root' and kind.text != 'none': self.fail('pitched harmony requires a root') continue self.children(item, {tag + '-step', tag + '-alter'}) step = item.findtext(tag + '-step', '').lower() if len(step) != 1 or step not in 'cdefgab': self.fail('invalid harmony root/bass') result[tag] = {'letter': step, 'alter': generator.model.rational(self.number(item.findtext(tag + '-alter', '0')))} for degree in node.findall('degree'): self.children(degree, {'degree-value', 'degree-alter', 'degree-type'}) value = self.number(degree.findtext('degree-value'), integer=True, positive=True) degree_alter = self.number(degree.findtext('degree-alter', '0')) operation = degree.findtext('degree-type') # MusicXML alterations are relative to the kind; additions use dominant intervals. if operation == 'alter': degree_alter += generator.harmony.KINDS.get(result['kind'], {}).get(value, 0) elif operation == 'add' and (value - 1) % 7 == 6: degree_alter -= 1 elif operation == 'subtract' and degree_alter: self.fail('cannot subtract a degree different from the one in the chord kind') result['degrees'].append({'value': value, 'alter': generator.model.rational(degree_alter), 'type': operation}) if kind.get('text') and result['kind'] != 'none': generator.harmony.normalize_text(result, kind.get('text')) chord_degrees = generator.harmony.degrees(result) if node.find('inversion') is not None: inversion = self.number(node.findtext('inversion'), integer=True) result['inversion'] = inversion if not 0 <= inversion < len(chord_degrees): self.fail('invalid chord inversion') if inversion and not result.get('bass'): degree = sorted(chord_degrees)[inversion] root = result['root'] letter_index = 'cdefgab'.index(root['letter']) + degree - 1 bass_letter = 'cdefgab'[letter_index % 7] target = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter']) + generator.harmony.degree_semitones(degree, chord_degrees[degree]) result['bass'] = {'letter': bass_letter, 'alter': generator.model.rational(target - (12 * (letter_index // 7) + generator.model.NATURAL[bass_letter]))} result['symbol'] = generator.harmony.symbol(result) return result def direction(self, node, cursor: fractions.Fraction, measure: dict, optional: list[fractions.Fraction]) -> None: self.attrs(node, {'print-object', 'directive', 'system'}) self.children(node, {'direction-type', 'offset', 'staff', 'voice', 'sound'}) onset = self.offset(node, cursor) for group in node.findall('direction-type'): if group.find('segno') is not None or group.find('coda') is not None: self.fail('navigation jumps are unsupported; write the music out in reading order') self.children(group, {'words', 'octave-shift', 'rehearsal', 'metronome'}) for item in group: if item.tag == 'words': value = ''.join(item.itertext()).strip() if value == 'vamp:optional-chord': optional.append(onset) elif value: self.fail(f'unsupported staff text {value!r}; only Vamp directives are accepted') elif item.tag == 'rehearsal': if onset != 0: self.fail('section labels must be at measure starts') measure['section_label'] = ''.join(item.itertext()) elif item.tag == 'octave-shift': number, action = item.get('number', '1'), item.get('type') size = self.number(item.get('size', '8'), integer=True) if size not in (8, 15, 22): self.fail('octave shifts support 8, 15, or 22') position = generator.model.rational(self.absolute + onset) if action in ('up', 'down'): if number in self.open_octaves: self.fail('duplicate octave-shift start') span = {'id': f'octave_{len(self.octaves) + 1}', 'start': position, 'octaves': (size - 1) // 7 * (1 if action == 'up' else -1)} self.open_octaves[number] = span self.octaves.append(span) elif action == 'stop': if number not in self.open_octaves: self.fail('octave-shift stop without start') self.open_octaves.pop(number)['end'] = position elif action != 'continue' or number not in self.open_octaves: self.fail('invalid octave-shift continuation') elif item.tag == 'metronome': self.children(item, {'beat-unit', 'beat-unit-dot', 'per-minute'}) written = {'type': item.findtext('beat-unit'), 'dots': len(item.findall('beat-unit-dot'))} generator.model.written_length(written) bpm = self.number(item.findtext('per-minute'), positive=True) measure.setdefault('tempos', []).append({'onset': generator.model.rational(onset), 'written': written, 'bpm': generator.model.rational(bpm)}) sound = node.find('sound') if sound is not None: self.sound(sound, onset, measure) def sound(self, node, onset: fractions.Fraction, measure: dict) -> None: if {'dacapo', 'dalsegno', 'segno', 'coda', 'tocoda', 'fine', 'forward-repeat', 'time-only'} & node.attrib.keys(): self.fail('navigation jumps are unsupported; write the music out in reading order') self.attrs(node, {'tempo'}) self.children(node, set()) if node.get('tempo') and not any(generator.model.fraction(t['onset']) == onset for t in measure.get('tempos', [])): measure.setdefault('tempos', []).append({'onset': generator.model.rational(onset), 'written': {'type': 'quarter', 'dots': 0}, 'bpm': generator.model.rational(self.number(node.get('tempo'), positive=True))}) def read_note(self, node, cursor: fractions.Fraction, previous: dict | None) -> tuple[dict, fractions.Fraction, bool]: self.attrs(node, {'print-object', 'print-dot', 'print-spacing', 'default-x', 'dynamics', 'end-dynamics'}) if node.get('dynamics') or node.get('end-dynamics'): self.fail('note dynamics are not supported') self.children(node, {'pitch', 'unpitched', 'instrument', 'rest', 'chord', 'duration', 'voice', 'type', 'dot', 'accidental', 'time-modification', 'stem', 'staff', 'beam', 'notations', 'lyric', 'tie', 'grace', 'cue', 'notehead'}) for tag in ('pitch', 'rest', 'chord', 'duration', 'voice', 'type', 'accidental', 'time-modification', 'stem', 'staff', 'grace', 'cue', 'notehead'): if len(node.findall(tag)) > 1: self.fail(f'duplicate <{tag}> in ') if node.find('rest') is not None and node.find('pitch') is not None: self.fail('note cannot contain both pitch and rest') self.identity(node) voice = self.voice_id is_chord = node.find('chord') is not None grace = node.find('grace') duration = fractions.Fraction() if grace is not None else self.number(node.findtext('duration'), positive=True) / self.divisions onset = generator.model.fraction(previous['onset']) if is_chord and previous else cursor if is_chord and (previous is None or previous['voice'] != voice or previous['kind'] != 'note' or duration > generator.model.fraction(previous['duration'])): self.fail('invalid chord tone or chord tone longer than its anchor') if is_chord and (grace is not None) != ('grace' in previous): self.fail('chord tones must share grace/regular timing') written = {'type': node.findtext('type'), 'dots': len(node.findall('dot'))} if written['type'] is None: written = {'type': 'whole', 'dots': 0} if node.find('rest') is not None and node.find('rest').get('measure') == 'yes' else generator.model.written_for(duration) generator.model.written_length(written) modulation = node.find('time-modification') if modulation is not None: self.children(modulation, {'actual-notes', 'normal-notes', 'normal-type', 'normal-dot'}) actual = self.number(modulation.findtext('actual-notes'), integer=True, positive=True) normal = self.number(modulation.findtext('normal-notes'), integer=True, positive=True) written['ratio'] = generator.model.rational(fractions.Fraction(normal, actual)) if modulation.find('normal-type') is not None: written['normal'] = {'type': modulation.findtext('normal-type'), 'dots': len(modulation.findall('normal-dot'))} generator.model.written_length(written['normal']) elif modulation.findall('normal-dot'): self.fail(' requires ') measure_rest = node.find('rest') is not None and node.find('rest').get('measure') == 'yes' if grace is None and not measure_rest: expected = generator.model.written_length(written) * generator.model.fraction(written.get('ratio', generator.model.rational(1))) if duration != expected: self.fail(f' duration {duration} disagrees with written duration/time-modification {expected}') self.sequence += 1 note = {'id': f'note_{self.sequence}', 'voice': voice, 'kind': 'rest' if node.find('rest') is not None else 'note', 'onset': generator.model.rational(onset), 'duration': generator.model.rational(duration), 'written': written, 'hidden': node.get('print-object') == 'no', 'pitches': []} if is_chord: note = previous if grace is not None and not is_chord: self.attrs(grace, {'slash', 'steal-time-previous', 'steal-time-following', 'make-time'}) note['grace'] = {'slash': grace.get('slash') == 'yes'} for name in ('steal-time-previous', 'steal-time-following', 'make-time'): if name in grace.attrib: value = self.number(grace.get(name), positive=True) note['grace'][name] = generator.model.rational(value / self.divisions if name == 'make-time' else value) if node.find('cue') is not None and not is_chord: note['cue'] = True if node.find('rest') is not None: self.children(node.find('rest'), {'display-step', 'display-octave'}) if node.find('rest').get('measure') == 'yes': note['measure_rest'] = True if node.find('rest/display-step') is not None: note['rest_position'] = {'letter': node.findtext('rest/display-step').lower(), 'octave': self.number(node.findtext('rest/display-octave'), integer=True)} else: pitch_node = node.find('pitch') p = {'id': f'{note["id"]}_p{len(note["pitches"]) + 1}', 'duration': generator.model.rational(duration), 'written': written} if pitch_node is not None: self.children(pitch_node, {'step', 'alter', 'octave'}) p.update({'letter': pitch_node.findtext('step', '').lower(), 'alter': generator.model.rational(self.number(pitch_node.findtext('alter', '0'))), 'octave': self.number(pitch_node.findtext('octave'), integer=True)}) generator.model.pitch_number(p) value = (voice, p['letter'], generator.model.alteration(p['alter']), p['octave']) elif node.find('unpitched') is not None: self.children(node.find('unpitched'), {'display-step', 'display-octave'}) p['unpitched'] = {'letter': node.findtext('unpitched/display-step', 'B').lower(), 'octave': self.number(node.findtext('unpitched/display-octave', '4'), integer=True)} p['instrument'] = node.find('instrument').get('id') if node.find('instrument') is not None else None value = (voice, p['instrument'], p['unpitched']['letter'], p['unpitched']['octave']) else: self.fail('note has neither pitch, unpitched position, nor rest') if node.find('instrument') is not None: self.attrs(node.find('instrument'), {'id'}) p['instrument'] = node.find('instrument').get('id') for name in ('print-object', 'print-dot', 'print-spacing'): if name in node.attrib: p.setdefault('print', {})[name] = node.get(name) if node.find('cue') is not None: p['cue'] = True if node.find('stem') is not None: p['stem'] = node.findtext('stem') p['_absolute'] = self.absolute + onset accidental = node.find('accidental') if accidental is not None: self.attrs(accidental, {'cautionary', 'editorial', 'parentheses', 'bracket', 'size', 'smufl'}) if 'unpitched' in p or accidental.text in generator.model.ACCIDENTALS and generator.model.ACCIDENTALS[accidental.text] != generator.model.alteration(p['alter']): self.fail('contradictory accidental') p['accidental_display'] = {'name': accidental.text, **{k: v for k, v in accidental.attrib.items() if k not in LAYOUT}} if node.find('notehead') is not None: p['notehead'] = {'name': node.findtext('notehead'), **dict(node.find('notehead').attrib)} ties = {t.get('type') for t in node.findall('tie')} for tie in node.findall('tie'): self.attrs(tie, {'type', 'time-only'}) if 'time-only' in tie.attrib: self.fail('pass-specific ties are unsupported; write the music out in reading order') notations = node.find('notations') visual_ties = set() if notations is not None: visual_ties = {t.get('type') for t in notations.findall('tied')} if 'continue' in visual_ties: visual_ties.remove('continue') visual_ties.update({'start', 'stop'}) ties |= visual_ties if ties - {'start', 'stop'}: self.fail('unsupported tie notation') if 'stop' in ties: source = self.open_ties.pop(value, None) if source is None or source['_absolute'] + generator.model.fraction(source['duration']) != p['_absolute']: self.fail('tie stop has no contiguous matching start') source['tie_to'], p['tie_from'] = p['id'], source['id'] if 'start' in ties: if value in self.open_ties: self.fail('ambiguous duplicate tie at the same pitch') self.open_ties[value] = p note['pitches'].append(p) if not is_chord: if node.find('stem') is not None: stem = node.findtext('stem') if stem not in ('up', 'down', 'none', 'double'): self.fail('unsupported stem') note['stem'] = stem head = node.findtext('notehead') if head is not None: note['notehead'] = head beams = [] for beam in node.findall('beam'): self.attrs(beam, {'number', 'repeater'}) if beam.get('repeater') == 'yes': self.fail('repeater beams are not supported') level = self.number(beam.get('number', '1'), integer=True) if not 1 <= level <= 8 or beam.text not in ('begin', 'continue', 'end', 'forward hook', 'backward hook'): self.fail('invalid beam') beams.append({'level': level, 'type': beam.text}) if beams: note['beams'] = beams for lyric in node.findall('lyric'): self.children(lyric, {'syllabic', 'text', 'elision', 'extend', 'end-line', 'end-paragraph'}) number = lyric.get('number', '1') if any(v['number'] == number for v in note.get('lyrics', [])): self.fail('duplicate lyric verse on one event') pieces = [] for item in lyric: if item.tag in ('text', 'elision'): pieces.append(item.text or (' ' if item.tag == 'elision' else '')) syllabic = lyric.findtext('syllabic', 'single') if syllabic not in ('single', 'begin', 'middle', 'end'): self.fail('invalid lyric syllabification') verse = {'number': number, 'syllables': [{'kind': child.tag, 'text': child.text or '', 'attributes': dict(child.attrib)} for child in lyric]} note.setdefault('lyrics', []).append(verse) if len(note['lyrics']) == 1: note['lyric'] = ''.join(pieces) + ('-' if syllabic in ('begin', 'middle') else '') note['lyric_notation'] = {'text': ''.join(pieces), 'syllabic': syllabic} if lyric.find('extend') is not None: note['lyric_notation']['extend'] = lyric.find('extend').get('type', 'start') stops = [] for notations in node.findall('notations'): self.children(notations, {'tied', 'tuplet', 'ornaments', 'technical'}) for item in notations: if item.tag == 'technical': self.fail('authored TAB and finger instructions are unsupported; guitar positions are generated') if item.tag == 'tuplet' and not is_chord: number, action = (voice, item.get('number', '1')), item.get('type') self.attrs(item, {'number', 'type', 'bracket', 'show-number', 'show-type', 'line-shape'}) self.children(item, {'tuplet-actual', 'tuplet-normal'}) if action == 'start': if number in self.active_tuplets: self.fail('duplicate tuplet start') self.tuplet_sequence += 1 actual = item.findtext('tuplet-actual/tuplet-number', modulation.findtext('actual-notes') if modulation is not None else '') normal = item.findtext('tuplet-normal/tuplet-number', modulation.findtext('normal-notes') if modulation is not None else '') active = [t for (v, _), t in self.active_tuplets.items() if v == voice] if active and (item.find('tuplet-actual') is None or item.find('tuplet-normal') is None): self.fail('nested tuplets require explicit tuplet-actual and tuplet-normal numbers') span = {'id': f'tuplet_{self.tuplet_sequence}', 'voice': voice, 'start': note['id'], 'note_count': self.number(actual, integer=True, positive=True), 'space_count': self.number(normal, integer=True, positive=True), 'depth': len(active), 'bracket': item.get('bracket', 'auto'), 'show_number': item.get('show-number', 'actual'), 'show_type': item.get('show-type', 'none'), 'line_shape': item.get('line-shape', 'straight')} for tag in ('tuplet-actual', 'tuplet-normal'): detail = item.find(tag) if detail is not None: self.attrs(detail) self.children(detail, {'tuplet-number', 'tuplet-type', 'tuplet-dot'}) span[tag.replace('-', '_')] = {'number': self.number(detail.findtext('tuplet-number'), integer=True, positive=True), 'type': detail.findtext('tuplet-type'), 'dots': len(detail.findall('tuplet-dot'))} self.active_tuplets[number] = span self.tuplets.append(span) elif action == 'stop': stops.append(number) else: self.fail('invalid tuplet type') elif item.tag == 'ornaments': self.children(item, {'tremolo'}) for tremolo in item: marks = self.number(tremolo.text, integer=True) if not 0 <= marks <= 8 or tremolo.get('type', 'single') == 'unmeasured': self.fail('only measured tremolos are supported') action = tremolo.get('type', 'single') note['tremolo'] = {'marks': marks, 'type': action} if action == 'start': if voice in self.tremolo_starts: self.fail('nested tremolo') self.tremolo_starts[voice] = note elif action == 'stop': if voice not in self.tremolo_starts: self.fail('tremolo stop without start') partner = self.tremolo_starts.pop(voice) note['tremolo']['partner'] = partner['id'] partner['tremolo']['partner'] = note['id'] elif action != 'single': self.fail('unsupported tremolo type') if not is_chord: note['tuplet_ids'] = [t['id'] for (v, _), t in self.active_tuplets.items() if v == voice] for number in stops: if number not in self.active_tuplets: self.fail('tuplet stop without start') self.active_tuplets.pop(number)['end'] = note['id'] return note, cursor if is_chord else cursor + duration, is_chord def read(self) -> dict: root = defusedxml.ElementTree.parse(self.path, forbid_entities=True, forbid_external=True).getroot() if root.tag != 'score-partwise' or root.get('version') not in ('3.1', '4.0'): self.fail('expected uncompressed score-partwise MusicXML 3.1 or 4.0') self.children(root, IGNORED_TOP | {'part'}) parts = root.findall('part') definitions = root.findall('part-list/score-part') if len(parts) != 1 or len(definitions) != 1 or parts[0].get('id') != definitions[0].get('id'): self.fail('exactly one matching score part is required') instruments = [] for item in definitions[0].findall('score-instrument'): self.attrs(item, {'id'}) self.children(item, {'instrument-name', 'instrument-abbreviation', 'instrument-sound', 'solo', 'ensemble', 'virtual-instrument'}) instrument = {'id': item.get('id'), 'name': item.findtext('instrument-name', ''), 'abbreviation': item.findtext('instrument-abbreviation', ''), 'sound': item.findtext('instrument-sound', '')} if item.find('solo') is not None: instrument['solo'] = True if item.find('ensemble') is not None: instrument['ensemble'] = item.findtext('ensemble') if item.find('virtual-instrument') is not None: virtual = item.find('virtual-instrument') self.children(virtual, {'virtual-library', 'virtual-name'}) instrument['virtual'] = {child.tag: child.text or '' for child in virtual} midi = next((m for m in definitions[0].findall('midi-instrument') if m.get('id') == instrument['id']), None) if midi is not None: self.children(midi, {'midi-channel', 'midi-name', 'midi-bank', 'midi-program', 'midi-unpitched', 'volume', 'pan', 'elevation'}) instrument['midi'] = {child.tag: child.text for child in midi} instruments.append(instrument) if any(item.get('id') not in {i['id'] for i in instruments} for item in definitions[0].findall('midi-instrument')): self.fail('MIDI definition refers to an unknown score instrument') for item in root.findall('identification/miscellaneous/miscellaneous-field'): name = item.get('name', '') if name.startswith('vamp:'): self.fail(f'unsupported Vamp property {name!r}') metadata = {'title': root.findtext('work/work-title') or root.findtext('movement-title') or self.path.stem.replace('_', ' ').replace('-', ' ').title(), 'composer': ' / '.join(c.text for c in root.findall('identification/creator') if c.get('type') == 'composer' and c.text)} measures = [] self.children(parts[0], {'measure'}) for index, bar in enumerate(parts[0]): self.location = f'{self.path}: part {parts[0].get("id")}, measure {bar.get("number", str(index + 1))}' self.children(bar, {'attributes', 'note', 'direction', 'sound', 'harmony', 'forward', 'backup', 'barline', 'print'}) self.attrs(bar, {'number', 'implicit', 'non-controlling', 'width', 'text'}) cursor, extent = fractions.Fraction(), fractions.Fraction() voices = {} previous = None optional = [] measure = {'id': f'measure_{index + 1}', 'number': bar.get('number', str(index + 1)), 'implicit': bar.get('implicit') == 'yes', 'harmonies': [], 'attributes': [], 'key': copy.deepcopy(self.key), 'meter': copy.deepcopy(self.meter), 'clef': copy.deepcopy(self.clef), 'transpose': copy.deepcopy(self.transpose), 'staff_details': copy.deepcopy(self.staff_details)} for item in bar: if item.tag == 'attributes': state = self.attributes(item, cursor) if cursor == 0: for k in ('key', 'meter', 'clef', 'transpose', 'staff_details'): measure[k] = copy.deepcopy(getattr(self, k)) elif len(state) > 1: measure['attributes'].append(state) elif item.tag == 'note': previous, cursor, is_chord = self.read_note(item, cursor, previous) if not is_chord: voices.setdefault(self.voice_id, []).append(previous) extent = max(extent, cursor) elif item.tag == 'harmony': measure['harmonies'].append(self.read_harmony(item, cursor)) elif item.tag == 'direction': self.direction(item, cursor, measure, optional) elif item.tag == 'sound': self.sound(item, cursor, measure) elif item.tag == 'backup': self.children(item, {'duration'}) cursor -= self.number(item.findtext('duration'), positive=True) / self.divisions if cursor < 0: self.fail('backup moves before the start of the measure') previous = None elif item.tag == 'forward': self.children(item, {'duration', 'voice', 'staff'}) self.identity(item) duration = self.number(item.findtext('duration'), positive=True) / self.divisions if item.find('voice') is not None: self.sequence += 1 voices.setdefault(self.voice_id, []).append(generator.model.make_event(f'spacer_{self.sequence}', cursor, duration, 'spacer') | {'voice': self.voice_id}) cursor += duration extent = max(extent, cursor) previous = None elif item.tag == 'barline': if item.find('repeat') is not None or item.find('ending') is not None: self.fail('repeats and numbered endings are unsupported; write the music out in reading order') self.attrs(item, {'location'}) self.children(item, {'bar-style'}) side = item.get('location', 'right') if side not in ('left', 'right'): self.fail('barlines must be at measure boundaries') if side in measure.get('barlines', {}): self.fail(f'duplicate {side} barline') if side == 'left' and cursor != 0 or side == 'right' and cursor != extent: self.fail('barline location disagrees with its musical position') style = item.findtext('bar-style', 'regular') if style not in ('regular', 'light-light', 'light-heavy', 'heavy-light', 'heavy-heavy', 'heavy', 'dashed', 'dotted', 'none', 'tick', 'short'): self.fail('unsupported barline') measure.setdefault('barlines', {})[side] = style if side == 'right': measure['_right_barline_onset'] = cursor if not voices or extent <= 0: self.fail('empty or zero-duration measure') cursor = extent if measure.pop('_right_barline_onset', cursor) != cursor: self.fail('right barline must occur at the end of its measure') expected = generator.model.meter_length(measure['meter']) meter_changed = any('meter' in a for a in measure['attributes']) if expected and cursor != expected and not measure['implicit'] and not meter_changed: self.fail(f'measure has {cursor} quarter notes, expected {expected}; mark a partial measure implicit') measure['duration'] = generator.model.rational(cursor) measure['key_signature'], measure['time_signature'] = generator.model.key_label(measure['key']), generator.model.meter_label(measure['meter']) measure['voices'] = [{'id': voice, 'notes': sorted(events, key=lambda n: generator.model.fraction(n['onset']))} for voice, events in voices.items()] measure['harmonies'].sort(key=lambda h: generator.model.fraction(h['onset'])) used = set() for onset in optional: matches = [h for h in measure['harmonies'] if generator.model.fraction(h['onset']) == onset] if len(matches) != 1 or onset in used: self.fail('optional-chord marker must identify exactly one harmony and occur only once') matches[0]['chord_optional'] = True matches[0]['parenthesized'] = True matches[0]['symbol'] = generator.harmony.symbol(matches[0]) used.add(onset) measures.append(measure) self.absolute += cursor if self.open_ties or self.active_tuplets or self.open_octaves or self.tremolo_starts: self.fail('unterminated tie, tuplet, octave shift, or tremolo') if not measures: self.fail('score has no measures') for measure in measures: for n in (n for v in measure['voices'] for n in v['notes']): for p in n['pitches']: p.pop('_absolute', None) metadata.update({'key_signature': measures[0]['key_signature'], 'time_signature': measures[0]['time_signature'], 'key': copy.deepcopy(measures[0]['key'])}) result = {'metadata': metadata, 'staves': [{'id': 'staff_source', 'name': definitions[0].findtext('part-name', 'Source'), 'role': 'vocal', 'clef': copy.deepcopy(measures[0]['clef']), 'tuplets': self.tuplets, 'octave_shifts': self.octaves, 'measures': measures}]} result['staves'][0]['instruments'] = instruments generator.validation.validate_document(result) return result def load_musicxml(path: pathlib.Path) -> dict: importer = Importer(pathlib.Path(path)) try: return importer.read() except MusicXMLError: raise except Exception as error: raise MusicXMLError(f'{importer.location}: {error}') from error