+106
-121
@@ -4,35 +4,28 @@ import copy
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
|
||||
from defusedxml import ElementTree as XML
|
||||
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 (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.styles import canonical_style
|
||||
|
||||
|
||||
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: Path) -> None:
|
||||
self.path = path
|
||||
self.location = str(path)
|
||||
self.divisions = Fraction(1)
|
||||
self.key = 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.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.tuplet_sequence = 0
|
||||
self.active_tuplets: dict[str, dict] = {}
|
||||
@@ -44,20 +37,16 @@ class Importer:
|
||||
self.lyric_number = None
|
||||
self.absolute = Fraction()
|
||||
self.tremolo_start = None
|
||||
|
||||
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) -> Fraction | int:
|
||||
try:
|
||||
result = Fraction(value or '')
|
||||
@@ -67,12 +56,10 @@ class Importer:
|
||||
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: Fraction) -> 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)
|
||||
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')
|
||||
@@ -82,10 +69,9 @@ class Importer:
|
||||
self.voice_id = voice
|
||||
if voice != self.voice_id:
|
||||
self.fail('multiple rhythmic voices are not supported')
|
||||
|
||||
def attributes(self, node, onset: Fraction) -> dict:
|
||||
self.children(node, {'divisions', 'key', 'time', 'clef', 'staves', 'transpose', 'instruments'})
|
||||
result = {'onset':rational(onset)}
|
||||
result = {'onset': rational(onset)}
|
||||
for item in node:
|
||||
if item.get('number', '1') != '1':
|
||||
self.fail('attributes for multiple staves are not supported')
|
||||
@@ -100,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': [0] * 7, 'custom': True}
|
||||
pending = None
|
||||
for field in item:
|
||||
if field.tag == 'key-step':
|
||||
@@ -122,7 +108,7 @@ class Importer:
|
||||
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')}
|
||||
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:
|
||||
@@ -137,7 +123,7 @@ class Importer:
|
||||
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)})
|
||||
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')
|
||||
@@ -146,25 +132,24 @@ 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'):
|
||||
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)
|
||||
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')
|
||||
self.clef = {'sign':sign, 'line':line, 'octave_change':self.number(item.findtext('clef-octave-change','0'), integer=True)}
|
||||
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 == '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 = {key: self.number(item.findtext(tag, '0'), integer=True) for key, tag in (('diatonic', 'diatonic'), ('chromatic', 'chromatic'), ('octave_change', 'octave-change'))}
|
||||
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])
|
||||
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: Fraction) -> dict:
|
||||
self.attrs(node, {'print-object', 'print-frame', 'type'})
|
||||
self.children(node, {'root', 'kind', 'degree', 'bass', 'inversion', 'offset', 'staff'})
|
||||
@@ -172,34 +157,34 @@ 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':[]}
|
||||
result = {'id': f'harmony-{self.sequence}-{cursor}', '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')
|
||||
for tag in ('root','bass'):
|
||||
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.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':self.number(item.findtext(tag+'-alter','0'), integer=True)}
|
||||
result[tag] = {'letter': step, 'alter': self.number(item.findtext(tag + '-alter', '0'), integer=True)}
|
||||
for degree in node.findall('degree'):
|
||||
self.children(degree, {'degree-value','degree-alter','degree-type'})
|
||||
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)
|
||||
alteration = self.number(degree.findtext('degree-alter', '0'), integer=True)
|
||||
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)
|
||||
elif operation == 'add' and (value-1)%7 == 6:
|
||||
elif operation == 'add' and (value - 1) % 7 == 6:
|
||||
alteration -= 1
|
||||
elif operation == 'subtract' and alteration:
|
||||
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': alteration, 'type': operation})
|
||||
chord_degrees = harmony.degrees(result)
|
||||
if 'kind_text' in result and result['kind'] != 'none':
|
||||
from generator.model import pitch_name
|
||||
@@ -224,16 +209,15 @@ class Importer:
|
||||
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])}
|
||||
result['bass'] = {'letter': bass_letter, 'alter': 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.children(node, {'direction-type','offset','staff','voice','sound'})
|
||||
self.children(node, {'direction-type', 'offset', 'staff', 'voice', 'sound'})
|
||||
onset = self.offset(node, cursor)
|
||||
for group in node.findall('direction-type'):
|
||||
self.children(group, {'words','octave-shift','rehearsal','metronome'})
|
||||
self.children(group, {'words', 'octave-shift', 'rehearsal', 'metronome'})
|
||||
for item in group:
|
||||
if item.tag == 'words':
|
||||
value = ''.join(item.itertext()).strip()
|
||||
@@ -246,15 +230,15 @@ class Importer:
|
||||
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):
|
||||
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 = rational(self.absolute + onset)
|
||||
if action in ('up','down'):
|
||||
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)}
|
||||
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':
|
||||
@@ -264,24 +248,25 @@ class Importer:
|
||||
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'))}
|
||||
self.children(item, {'beat-unit', 'beat-unit-dot', 'per-minute'})
|
||||
written = {'type': item.findtext('beat-unit'), 'dots': len(item.findall('beat-unit-dot'))}
|
||||
written_length(written)
|
||||
bpm = self.number(item.findtext('per-minute'), positive=True)
|
||||
measure.setdefault('tempos', []).append({'onset':rational(onset), 'written':written, 'bpm':rational(bpm)})
|
||||
measure.setdefault('tempos', []).append({'onset': rational(onset), 'written': written, 'bpm': rational(bpm)})
|
||||
sound = node.find('sound')
|
||||
if sound is not None:
|
||||
self.attrs(sound, {'tempo'})
|
||||
self.children(sound, set())
|
||||
if sound.get('tempo') and not any(fraction(t['onset'])==onset for t in measure.get('tempos', [])):
|
||||
measure.setdefault('tempos', []).append({'onset':rational(onset), 'written':{'type':'quarter','dots':0}, 'bpm':rational(self.number(sound.get('tempo'), positive=True))})
|
||||
|
||||
self.sound(sound, onset, measure)
|
||||
def sound(self, node, onset: Fraction, measure: dict) -> None:
|
||||
self.attrs(node, {'tempo'})
|
||||
self.children(node, set())
|
||||
if node.get('tempo') and not any(fraction(t['onset']) == onset for t in measure.get('tempos', [])):
|
||||
measure.setdefault('tempos', []).append({'onset': rational(onset), 'written': {'type': 'quarter', 'dots': 0}, 'bpm': rational(self.number(node.get('tempo'), positive=True))})
|
||||
def read_note(self, node, cursor: Fraction, previous: dict | None) -> tuple[dict, 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','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'):
|
||||
self.children(node, {'pitch', '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:
|
||||
@@ -291,22 +276,22 @@ class Importer:
|
||||
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['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')
|
||||
written = {'type':node.findtext('type'), 'dots':len(node.findall('dot'))}
|
||||
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 written_for(duration)
|
||||
written = {'type': 'whole', 'dots': 0} if node.find('rest') is not None and node.find('rest').get('measure') == 'yes' else written_for(duration)
|
||||
written_length(written)
|
||||
modulation = node.find('time-modification')
|
||||
if modulation is not None:
|
||||
self.children(modulation, {'actual-notes','normal-notes','normal-type','normal-dot'})
|
||||
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'] = rational(Fraction(normal, actual))
|
||||
if modulation.find('normal-type') is not None:
|
||||
written['normal'] = {'type':modulation.findtext('normal-type'), 'dots':len(modulation.findall('normal-dot'))}
|
||||
written['normal'] = {'type': modulation.findtext('normal-type'), 'dots': len(modulation.findall('normal-dot'))}
|
||||
written_length(written['normal'])
|
||||
elif modulation.findall('normal-dot'):
|
||||
self.fail('<normal-dot> requires <normal-type>')
|
||||
@@ -316,47 +301,47 @@ 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}', '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:
|
||||
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'):
|
||||
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:
|
||||
note['cue'] = True
|
||||
if node.find('rest') is not None:
|
||||
self.children(node.find('rest'), {'display-step','display-octave'})
|
||||
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)}
|
||||
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}
|
||||
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['_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}
|
||||
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'}
|
||||
p['accidental_display'] = {'name': accidental.text, 'parentheses': accidental.get('parentheses') == 'yes' or accidental.get('cautionary') == 'yes', 'bracket': accidental.get('bracket') == 'yes'}
|
||||
ties = {t.get('type') for t in node.findall('tie')}
|
||||
notations = node.find('notations')
|
||||
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'})
|
||||
visual_ties.update({'start', 'stop'})
|
||||
ties |= visual_ties
|
||||
if ties - {'start','stop'}:
|
||||
if ties - {'start', 'stop'}:
|
||||
self.fail('unsupported tie notation')
|
||||
if 'stop' in ties:
|
||||
source = self.open_ties.pop(value, None)
|
||||
@@ -371,24 +356,24 @@ class Importer:
|
||||
if not is_chord:
|
||||
if node.find('stem') is not None:
|
||||
stem = node.findtext('stem')
|
||||
if stem not in ('up','down','none','double'):
|
||||
if stem not in ('up', 'down', 'none', 'double'):
|
||||
self.fail('unsupported stem')
|
||||
note['stem'] = stem
|
||||
head = node.findtext('notehead')
|
||||
if head is not None:
|
||||
if head not in ('normal','slash'):
|
||||
if head not in ('normal', 'slash'):
|
||||
self.fail(f'unsupported notehead: {head}')
|
||||
note['notehead'] = head
|
||||
beams = []
|
||||
for beam in node.findall('beam'):
|
||||
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'):
|
||||
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})
|
||||
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'})
|
||||
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
|
||||
@@ -396,22 +381,22 @@ class Importer:
|
||||
self.fail('only one lyric verse is supported')
|
||||
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'):
|
||||
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')
|
||||
note['lyric'] = ''.join(pieces) + ('-' if syllabic in ('begin','middle') else '')
|
||||
note['lyric_notation'] = {'text':''.join(pieces), 'syllabic':syllabic}
|
||||
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')
|
||||
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'})
|
||||
for item in notations:
|
||||
if item.tag == 'tuplet' and not is_chord:
|
||||
number, action = item.get('number','1'), item.get('type')
|
||||
self.children(item, {'tuplet-actual','tuplet-normal'})
|
||||
number, action = item.get('number', '1'), item.get('type')
|
||||
self.children(item, {'tuplet-actual', 'tuplet-normal'})
|
||||
if action == 'start':
|
||||
if number in self.active_tuplets:
|
||||
self.fail('duplicate tuplet start')
|
||||
@@ -420,7 +405,7 @@ class Importer:
|
||||
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):
|
||||
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}', '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')}
|
||||
self.active_tuplets[number] = span
|
||||
self.tuplets.append(span)
|
||||
elif action == 'stop':
|
||||
@@ -431,10 +416,10 @@ class Importer:
|
||||
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':
|
||||
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}
|
||||
action = tremolo.get('type', 'single')
|
||||
note['tremolo'] = {'marks': marks, 'type': action}
|
||||
if action == 'start':
|
||||
if self.tremolo_start is not None:
|
||||
self.fail('nested tremolo')
|
||||
@@ -454,10 +439,9 @@ class Importer:
|
||||
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 = XML.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'):
|
||||
root = 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')
|
||||
@@ -466,32 +450,32 @@ class Importer:
|
||||
self.fail('exactly one matching score part is required')
|
||||
fields = {}
|
||||
for item in root.findall('identification/miscellaneous/miscellaneous-field'):
|
||||
name = item.get('name','')
|
||||
name = item.get('name', '')
|
||||
if name.startswith('vamp:'):
|
||||
if name not in ('vamp:style','vamp:tonic') or name in fields:
|
||||
if name not in ('vamp:style', 'vamp:tonic') or name in fields:
|
||||
self.fail(f'unknown or duplicate Vamp property {name!r}')
|
||||
fields[name] = item.text or ''
|
||||
metadata = {'title':root.findtext('work/work-title') or root.findtext('movement-title') or self.path.stem, 'composer':' / '.join(c.text for c in root.findall('identification/creator') if c.get('type')=='composer' and c.text), 'style':canonical_style(fields.get('vamp:style'))}
|
||||
metadata = {'title': root.findtext('work/work-title') or root.findtext('movement-title') or self.path.stem, 'composer': ' / '.join(c.text for c in root.findall('identification/creator') if c.get('type') == 'composer' and c.text), 'style': canonical_style(fields.get('vamp:style'))}
|
||||
if 'vamp:tonic' in fields:
|
||||
from generator.model import parse_pitch_name
|
||||
metadata['transpose_origin'] = parse_pitch_name(fields['vamp:tonic'])
|
||||
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','harmony','forward','backup','barline','print'})
|
||||
self.attrs(bar, {'number','implicit','non-controlling','width','text'})
|
||||
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 = []
|
||||
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)}
|
||||
for item in bar:
|
||||
if item.tag == 'attributes':
|
||||
state = self.attributes(item, cursor)
|
||||
if cursor == 0:
|
||||
for k in ('key','meter','clef','transpose'):
|
||||
measure[k] = copy.deepcopy(getattr(self,k))
|
||||
for k in ('key', 'meter', 'clef', 'transpose'):
|
||||
measure[k] = copy.deepcopy(getattr(self, k))
|
||||
elif len(state) > 1:
|
||||
measure['attributes'].append(state)
|
||||
elif item.tag == 'note':
|
||||
@@ -502,12 +486,14 @@ class Importer:
|
||||
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.fail('backward timeline movement requires unsupported independent voices')
|
||||
elif item.tag == 'forward':
|
||||
self.children(item, {'duration','voice','staff'})
|
||||
self.children(item, {'duration', 'voice', 'staff'})
|
||||
self.identity(item)
|
||||
duration = self.number(item.findtext('duration'), positive=True)/self.divisions
|
||||
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'))
|
||||
@@ -515,10 +501,10 @@ class Importer:
|
||||
previous = None
|
||||
elif item.tag == 'barline':
|
||||
self.children(item, {'bar-style'})
|
||||
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'):
|
||||
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', {})[item.get('location','right')] = style
|
||||
measure.setdefault('barlines', {})[item.get('location', 'right')] = style
|
||||
if not events or cursor <= 0:
|
||||
self.fail('empty or zero-duration measure')
|
||||
expected = meter_length(measure['meter'])
|
||||
@@ -527,12 +513,12 @@ class Importer:
|
||||
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['harmonies'].sort(key=lambda h:fraction(h['onset']))
|
||||
measure['voices'] = [{'id': 'voice-source', 'notes': events}]
|
||||
measure['harmonies'].sort(key=lambda h: fraction(h['onset']))
|
||||
used = set()
|
||||
for onset in optional:
|
||||
matches = [h for h in measure['harmonies'] if fraction(h['onset'])==onset]
|
||||
if len(matches)!=1 or onset in used:
|
||||
matches = [h for h in measure['harmonies'] if 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
|
||||
used.add(onset)
|
||||
@@ -546,12 +532,11 @@ class Importer:
|
||||
for n in measure['voices'][0]['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}]}
|
||||
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}]}
|
||||
validate_document(result)
|
||||
return result
|
||||
|
||||
|
||||
def load_musicxml(path: Path) -> dict:
|
||||
importer = Importer(Path(path))
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user