Music Notation
ci / checks (push) Successful in 43m19s

This commit is contained in:
khodowany
2026-09-15 01:30:42 -05:00
parent fe42d811fa
commit 1d1bd3e871
41 changed files with 1546 additions and 5236 deletions
+156 -75
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from defusedxml import ElementTree
from generator import harmony
from generator.model import (SCHEMA_VERSION, TYPE_LENGTHS, fraction, key_from_fifths, key_label, meter_label, meter_length, pitch_number, rational, validate_document, written_for, written_length, )
from generator.model import (ACCIDENTALS, SCHEMA_VERSION, TYPE_LENGTHS, alteration, fraction, key_from_fifths, key_label, meter_label, meter_length, pitch_number, rational, validate_document, written_for, written_length, )
from generator.styles import canonical_style
class MusicXMLError(ValueError):
@@ -27,16 +27,17 @@ class Importer:
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 = None
self.lyric_number = None
self.voice_id = '1'
self.staff_details = {'lines': 5, 'tunings': []}
self.absolute = Fraction()
self.tremolo_start = None
self.tremolo_starts = {}
def fail(self, text: str):
raise MusicXMLError(f'{self.location}: {text}')
def children(self, node, allowed: set[str]) -> None:
@@ -65,12 +66,11 @@ class Importer:
self.fail('multiple staves are not supported')
if voice is None:
return
if self.voice_id is None:
self.voice_id = voice
if voice != self.voice_id:
self.fail('multiple rhythmic voices are not supported')
if not voice.strip():
self.fail('empty voice identifier')
self.voice_id = voice
def attributes(self, node, onset: Fraction) -> dict:
self.children(node, {'divisions', 'key', 'time', 'clef', 'staves', 'transpose', 'instruments'})
self.children(node, {'divisions', 'key', 'time', 'clef', 'staves', 'transpose', 'instruments', 'staff-details'})
result = {'onset': rational(onset)}
for item in node:
if item.get('number', '1') != '1':
@@ -86,7 +86,7 @@ class Importer:
if item.find('fifths') is not None:
self.key = key_from_fifths(self.number(item.findtext('fifths'), integer=True), item.findtext('mode', 'major'))
else:
self.key = {'fifths': 0, 'mode': 'none', 'tonic': None, 'alterations': [0] * 7, 'custom': True}
self.key = {'fifths': 0, 'mode': 'none', 'tonic': None, 'alterations': [rational(0) for _ in range(7)], 'custom': True, 'steps': []}
pending = None
for field in item:
if field.tag == 'key-step':
@@ -96,10 +96,19 @@ class Importer:
elif field.tag == 'key-alter':
if pending is None:
self.fail('key alteration without a step')
self.key['alterations']['cdefgab'.index(pending)] = self.number(field.text, integer=True)
amount = 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 in ('key-octave', 'key-accidental'):
self.fail(f'<{field.tag}> requires unsupported custom key placement')
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:
@@ -132,13 +141,20 @@ class Importer:
self.attrs(item, {'number', 'additional', 'size', 'after-barline', 'print-object'})
self.children(item, {'sign', 'line', 'clef-octave-change'})
sign = item.findtext('sign')
if sign not in ('G', 'F', 'C'):
self.fail(f'unsupported clef {sign!r}; only pitched G/F/C staves are supported')
line = self.number(item.findtext('line', {'G': '2', 'F': '4', 'C': '3'}[sign]), integer=True)
if not 1 <= line <= 5:
self.fail('clef line must be between 1 and 5')
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': 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'))}
@@ -157,7 +173,8 @@ class Importer:
self.fail('stacked harmonies require separate harmony events')
self.identity(node)
kind = node.find('kind')
result = {'id': f'harmony-{self.sequence}-{cursor}', 'onset': rational(self.offset(node, cursor)), 'kind': kind.text, 'degrees': []}
self.harmony_sequence += 1
result = {'id': f'harmony-{self.harmony_sequence}', 'onset': rational(self.offset(node, cursor)), 'kind': kind.text, 'degrees': []}
self.attrs(kind, {'text', 'use-symbols', 'stack-degrees', 'parentheses-degrees', 'bracket-degrees'})
if 'text' in kind.attrib:
result['kind_text'] = kind.get('text')
@@ -171,24 +188,23 @@ class Importer:
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': self.number(item.findtext(tag + '-alter', '0'), integer=True)}
result[tag] = {'letter': step, 'alter': 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)
alteration = self.number(degree.findtext('degree-alter', '0'), integer=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':
alteration += harmony.KINDS.get(result['kind'], {}).get(value, 0)
degree_alter += harmony.KINDS.get(result['kind'], {}).get(value, 0)
elif operation == 'add' and (value - 1) % 7 == 6:
alteration -= 1
elif operation == 'subtract' and alteration:
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': alteration, 'type': operation})
result['degrees'].append({'value': value, 'alter': rational(degree_alter), 'type': operation})
chord_degrees = harmony.degrees(result)
if 'kind_text' in result and result['kind'] != 'none':
from generator.model import pitch_name
label = pitch_name(result['root']) + result['kind_text']
label = 'C' + result['kind_text']
try:
displayed = harmony.degrees(harmony.parse_symbol(label))
except ValueError:
@@ -208,12 +224,12 @@ class Importer:
letter_index = 'cdefgab'.index(root['letter']) + degree - 1
bass_letter = 'cdefgab'[letter_index % 7]
from generator.model import NATURAL
target = NATURAL[root['letter']] + root['alter'] + harmony.degree_semitones(degree, chord_degrees[degree])
result['bass'] = {'letter': bass_letter, 'alter': target - (12 * (letter_index // 7) + NATURAL[bass_letter])}
target = NATURAL[root['letter']] + alteration(root['alter']) + harmony.degree_semitones(degree, chord_degrees[degree])
result['bass'] = {'letter': bass_letter, 'alter': rational(target - (12 * (letter_index // 7) + NATURAL[bass_letter]))}
result['symbol'] = harmony.symbol(result)
return result
def direction(self, node, cursor: Fraction, measure: dict, optional: list[Fraction]) -> None:
self.attrs(node, {'print-object', 'directive'})
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'):
@@ -265,18 +281,19 @@ class Importer:
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', 'rest', 'chord', 'duration', 'voice', 'type', 'dot', 'accidental', 'time-modification', 'stem', 'staff', 'beam', 'notations', 'lyric', 'tie', 'grace', 'cue', 'notehead'})
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 <note>')
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 = Fraction() if grace is not None else self.number(node.findtext('duration'), positive=True) / self.divisions
onset = fraction(previous['onset']) if is_chord and previous else cursor
if is_chord and (previous is None or previous['kind'] != 'note' or duration > fraction(previous['duration'])):
if is_chord and (previous is None or previous['voice'] != voice or previous['kind'] != 'note' or duration > 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')
@@ -301,16 +318,17 @@ class Importer:
if duration != expected:
self.fail(f'<note> duration {duration} disagrees with written duration/time-modification {expected}')
self.sequence += 1
note = {'id': f'note-{self.sequence}', 'kind': 'rest' if node.find('rest') is not None else 'note', 'onset': rational(onset), 'duration': rational(duration), 'written': written, 'hidden': node.get('print-object') == 'no', 'pitches': []}
note = {'id': f'note-{self.sequence}', 'voice': voice, 'kind': 'rest' if node.find('rest') is not None else 'note', 'onset': rational(onset), 'duration': rational(duration), 'written': written, 'hidden': node.get('print-object') == 'no', 'pitches': []}
if is_chord:
note = previous
if grace is not None:
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:
note['grace'][name] = rational(self.number(grace.get(name), positive=True))
if node.find('cue') is not None:
value = self.number(grace.get(name), positive=True)
note['grace'][name] = 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'})
@@ -320,19 +338,38 @@ class Importer:
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')
if pitch_node is None:
self.fail('note has neither pitch nor rest')
self.children(pitch_node, {'step', 'alter', 'octave'})
p = {'id': f'{note["id"]}-p{len(note["pitches"]) + 1}', 'letter': pitch_node.findtext('step', '').lower(), 'alter': self.number(pitch_node.findtext('alter', '0'), integer=True), 'octave': self.number(pitch_node.findtext('octave'), integer=True), 'duration': rational(duration), 'written': written}
value = pitch_number(p)
p = {'id': f'{note["id"]}-p{len(note["pitches"]) + 1}', 'duration': 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': rational(self.number(pitch_node.findtext('alter', '0'))), 'octave': self.number(pitch_node.findtext('octave'), integer=True)})
pitch_number(p)
value = (voice, p['letter'], 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'})
allowed = {'natural': 0, 'sharp': 1, 'flat': -1, 'double-sharp': 2, 'sharp-sharp': 2, 'flat-flat': -2, 'triple-sharp': 3, 'triple-flat': -3, 'natural-sharp': 1, 'natural-flat': -1}
if accidental.text not in allowed or allowed[accidental.text] != p['alter']:
self.fail('unsupported or contradictory accidental')
p['accidental_display'] = {'name': accidental.text, 'parentheses': accidental.get('parentheses') == 'yes' or accidental.get('cautionary') == 'yes', 'bracket': accidental.get('bracket') == 'yes'}
if 'unpitched' in p or accidental.text in ACCIDENTALS and ACCIDENTALS[accidental.text] != 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')}
notations = node.find('notations')
if notations is not None:
@@ -361,11 +398,12 @@ class Importer:
note['stem'] = stem
head = node.findtext('notehead')
if head is not None:
if head not in ('normal', 'slash'):
self.fail(f'unsupported notehead: {head}')
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')
@@ -375,10 +413,8 @@ class Importer:
for lyric in node.findall('lyric'):
self.children(lyric, {'syllabic', 'text', 'elision', 'extend', 'end-line', 'end-paragraph'})
number = lyric.get('number', '1')
if self.lyric_number is None:
self.lyric_number = number
if self.lyric_number != number or 'lyric' in note:
self.fail('only one lyric verse is supported')
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'):
@@ -386,16 +422,25 @@ class Importer:
syllabic = lyric.findtext('syllabic', 'single')
if syllabic not in ('single', 'begin', 'middle', 'end'):
self.fail('invalid lyric syllabification')
note['lyric'] = ''.join(pieces) + ('-' if syllabic in ('begin', 'middle') else '')
note['lyric_notation'] = {'text': ''.join(pieces), 'syllabic': syllabic}
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'})
self.children(notations, {'tied', 'tuplet', 'ornaments', 'technical'})
for item in notations:
if item.tag == 'technical':
self.children(item, {'string', 'fret'})
if note['kind'] != 'note' or item.find('string') is None or item.find('fret') is None:
self.fail('tablature requires a string and fret on a note')
note['pitches'][-1]['tablature'] = {'string': self.number(item.findtext('string'), integer=True, positive=True), 'fret': self.number(item.findtext('fret'), integer=True)}
if item.tag == 'tuplet' and not is_chord:
number, action = item.get('number', '1'), item.get('type')
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:
@@ -403,9 +448,16 @@ class Importer:
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 '')
if self.active_tuplets and (item.find('tuplet-actual') is None or item.find('tuplet-normal') is None):
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}', 'start': note['id'], 'note_count': self.number(actual, integer=True, positive=True), 'space_count': self.number(normal, integer=True, positive=True), 'depth': len(self.active_tuplets), 'bracket': item.get('bracket', 'auto'), 'show_number': item.get('show-number', 'actual')}
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':
@@ -421,19 +473,19 @@ class Importer:
action = tremolo.get('type', 'single')
note['tremolo'] = {'marks': marks, 'type': action}
if action == 'start':
if self.tremolo_start is not None:
if voice in self.tremolo_starts:
self.fail('nested tremolo')
self.tremolo_start = note
self.tremolo_starts[voice] = note
elif action == 'stop':
if self.tremolo_start is None:
if voice not in self.tremolo_starts:
self.fail('tremolo stop without start')
note['tremolo']['partner'] = self.tremolo_start['id']
self.tremolo_start['tremolo']['partner'] = note['id']
self.tremolo_start = None
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 t in self.active_tuplets.values()]
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')
@@ -448,6 +500,26 @@ class Importer:
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')
fields = {}
for item in root.findall('identification/miscellaneous/miscellaneous-field'):
name = item.get('name', '')
@@ -465,23 +537,24 @@ class Importer:
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 = Fraction()
events = []
cursor, extent = Fraction(), 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)}
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'):
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:
events.append(previous)
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':
@@ -489,15 +562,21 @@ class Importer:
elif item.tag == 'sound':
self.sound(item, cursor, measure)
elif item.tag == 'backup':
self.fail('backward timeline movement requires unsupported independent voices')
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
self.sequence += 1
from generator.model import make_event
events.append(make_event(f'spacer-{self.sequence}', cursor, duration, 'spacer'))
if item.find('voice') is not None:
voices.setdefault(self.voice_id, []).append(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':
self.children(item, {'bar-style'})
@@ -505,15 +584,16 @@ class Importer:
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', {})[item.get('location', 'right')] = style
if not events or cursor <= 0:
if not voices or extent <= 0:
self.fail('empty or zero-duration measure')
cursor = extent
expected = 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'] = rational(cursor)
measure['key_signature'], measure['time_signature'] = key_label(measure['key']), meter_label(measure['meter'])
measure['voices'] = [{'id': 'voice-source', 'notes': events}]
measure['voices'] = [{'id': voice, 'notes': sorted(events, key=lambda n: fraction(n['onset']))} for voice, events in voices.items()]
measure['harmonies'].sort(key=lambda h: fraction(h['onset']))
used = set()
for onset in optional:
@@ -524,16 +604,17 @@ class Importer:
used.add(onset)
measures.append(measure)
self.absolute += cursor
if self.open_ties or self.active_tuplets or self.open_octaves or self.tremolo_start:
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 measure['voices'][0]['notes']:
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 = {'schema_version': SCHEMA_VERSION, '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
validate_document(result)
return result