Tone Generation
ci / checks (push) Has been cancelled

This commit is contained in:
khodowany
2026-09-18 01:17:36 -05:00
parent 64eac2f11e
commit 80873a0896
39 changed files with 1624 additions and 52 deletions
+58 -4
View File
@@ -38,6 +38,7 @@ class Importer:
self.staff_details = {'lines': 5, 'tunings': []}
self.absolute = Fraction()
self.tremolo_starts = {}
self.has_repeats = False
def fail(self, text: str):
raise MusicXMLError(f'{self.location}: {text}')
def children(self, node, allowed: set[str]) -> None:
@@ -372,7 +373,12 @@ class Importer:
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 tie.get('time-only') and not self.has_repeats:
self.fail('time-only tie requires a repeated section')
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:
@@ -381,12 +387,22 @@ class Importer:
ties |= visual_ties
if ties - {'start', 'stop'}:
self.fail('unsupported tie notation')
if 'stop' in ties:
if self.has_repeats and ties:
from generator.repeats import pass_numbers
p['sound_ties'] = []
p['printed_ties'] = sorted(visual_ties)
for tie in node.findall('tie'):
self.attrs(tie, {'type', 'time-only'})
marker = {'type': tie.get('type')}
if tie.get('time-only'):
marker['passes'] = pass_numbers(tie.get('time-only'))
p['sound_ties'].append(marker)
if not self.has_repeats and 'stop' in ties:
source = self.open_ties.pop(value, None)
if source is None or source['_absolute'] + 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 not self.has_repeats and 'start' in ties:
if value in self.open_ties:
self.fail('ambiguous duplicate tie at the same pitch')
self.open_ties[value] = p
@@ -496,6 +512,7 @@ class Importer:
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.has_repeats = bool(root.findall('.//barline/repeat') or root.findall('.//barline/ending'))
self.children(root, IGNORED_TOP | {'part'})
parts = root.findall('part')
definitions = root.findall('part-list/score-part')
@@ -547,6 +564,8 @@ class Importer:
if item.tag == 'attributes':
state = self.attributes(item, cursor)
if cursor == 0:
if self.has_repeats:
measure.setdefault('initial_attributes', {}).update({k: v for k, v in state.items() if k != 'onset'})
for k in ('key', 'meter', 'clef', 'transpose', 'staff_details'):
measure[k] = copy.deepcopy(getattr(self, k))
elif len(state) > 1:
@@ -580,14 +599,46 @@ class Importer:
extent = max(extent, cursor)
previous = None
elif item.tag == 'barline':
self.children(item, {'bar-style'})
self.attrs(item, {'location'})
self.children(item, {'bar-style', 'repeat', 'ending'})
side = item.get('location', 'right')
if side not in ('left', 'right'):
self.fail('barlines and repeats 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', {})[item.get('location', 'right')] = style
measure.setdefault('barlines', {})[side] = style
if side == 'right':
measure['_right_barline_onset'] = cursor
for repeat in item.findall('repeat'):
self.attrs(repeat, {'direction', 'times', 'winged'})
self.children(repeat, set())
if side in measure.get('repeats', {}):
self.fail('duplicate repeat marker')
marker = {'direction': repeat.get('direction')}
if repeat.get('times'):
if marker['direction'] != 'backward':
self.fail('repeat times requires a backward repeat')
marker['times'] = self.number(repeat.get('times'), integer=True, positive=True)
if repeat.get('winged', 'none') != 'none':
self.fail('winged repeat engraving is not supported')
measure.setdefault('repeats', {})[side] = marker
for ending in item.findall('ending'):
from generator.repeats import pass_numbers
self.attrs(ending, {'number', 'type', 'print-object', 'end-length', 'text-x', 'text-y'})
self.children(ending, set())
if side in measure.get('endings', {}):
self.fail('duplicate ending marker')
measure.setdefault('endings', {})[side] = {'type': ending.get('type'), 'numbers': pass_numbers(ending.get('number', '')), 'text': ending.text or '', 'visible': ending.get('print-object', 'yes') != 'no'}
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 = 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:
@@ -616,6 +667,9 @@ class Importer:
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
if self.has_repeats:
from generator.repeats import expand_document
validate_document(expand_document(result))
validate_document(result)
return result