@@ -11,6 +11,7 @@ from generator.score import count_json_files, validate_generated_tree
|
||||
from generator.summary import validate_expected_performance_summary, validate_practice_variants
|
||||
from generator.musicxml import load_musicxml
|
||||
from generator.engraving import engrave_tree
|
||||
from generator.playback import compile_tree
|
||||
|
||||
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
|
||||
|
||||
@@ -47,6 +48,7 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
if args.check:
|
||||
validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
|
||||
validate_practice_variants(staged / 'practice')
|
||||
compile_tree(staged)
|
||||
engrave_tree(staged, static_assets_dir / 'fonts' / 'petaluma')
|
||||
validate_generated_tree(staged)
|
||||
backup = Path(temporary) / 'previous'
|
||||
|
||||
@@ -90,7 +90,8 @@ def arrange_document(document: dict, part_type: str) -> dict:
|
||||
specialized_notes = any('unpitched' in pitch or 'tablature' in pitch for note in measure_notes(measure) for pitch in note['pitches'])
|
||||
if specialized_staff or specialized_notes:
|
||||
raise ValueError(f'{measure["id"]}: automatic performance arranging requires a pitched non-TAB lead; percussion/TAB conversion is not qualified')
|
||||
document = concert_document(document)
|
||||
from generator.repeats import expand_document
|
||||
document = concert_document(expand_document(document))
|
||||
staff = source_staff(document)
|
||||
source = staff['measures']
|
||||
sections = full_arrangement_sections(source, part_type)
|
||||
|
||||
@@ -52,8 +52,10 @@ def engrave_tree(root: Path, fonts: Path) -> None:
|
||||
rasterize_page(page, family).save(target, format='PNG')
|
||||
check_page(target)
|
||||
entries.append({'path': destination.name + '/' + name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
|
||||
music = {k: v for k, v in document.items() if k != 'schema_version'}
|
||||
music = {k: v for k, v in document.items() if k not in ('schema_version', 'playback')}
|
||||
bundle = {'schema_version': 3, 'music': music, 'presentation': {'engine': ENGINE_NAME, 'engine_version': ENGINE_VERSION, 'style_sha256': layout.fingerprint, 'width': PAGE_WIDTH, 'height': PAGE_HEIGHT, 'pages': entries}}
|
||||
if 'playback' in document:
|
||||
bundle['playback'] = document['playback']
|
||||
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
except ValueError as error:
|
||||
raise EngravingError(f'{path.relative_to(root)}: {error}') from error
|
||||
|
||||
@@ -58,6 +58,22 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
|
||||
raise EngravingError(f'{item.sources} and {other.sources}: colliding {item.role}/{other.role}')
|
||||
if previous_end != index.end:
|
||||
raise EngravingError('layout omits the end of the score')
|
||||
from generator.repeats import analyze
|
||||
_, endings = analyze(index.staff['measures'])
|
||||
for ending in endings:
|
||||
if not ending.visible:
|
||||
continue
|
||||
identifier = index.measures[ending.start][2]['id'] + ':ending'
|
||||
if not any(p.role == 'ending-label' and p.value == ending.text for p in primitives.get(identifier, [])):
|
||||
raise EngravingError(f'{identifier}: numbered ending was omitted')
|
||||
low, high = index.measures[ending.start][0], index.measures[ending.end - 1][1]
|
||||
for page in layout.pages:
|
||||
for system in page.systems:
|
||||
if system.start < high and system.end > low and not any(p.role == 'ending' and identifier in p.sources for p in system.primitives):
|
||||
raise EngravingError(f'{identifier}: ending continuation was omitted')
|
||||
for _, _, measure in index.measures:
|
||||
if measure.get('repeats') and not any(p.role == 'repeat-dot' for p in primitives.get(measure['id'], [])):
|
||||
raise EngravingError(f'{measure["id"]}: repeat dots were omitted')
|
||||
for event in index.events:
|
||||
note = event.value
|
||||
for source in note['pitches'] or [note]:
|
||||
@@ -85,7 +101,7 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
|
||||
if any(f.visible for f in pieces) and identifier in index.tones and index.accidentals[identifier]:
|
||||
if not any(p.role == 'accidental' for p in primitives.get(identifier, [])):
|
||||
raise EngravingError(f'{identifier}: required accidental was omitted')
|
||||
if identifier in index.tones and source.get('tie_to') and any(f.visible for f in pieces):
|
||||
if identifier in index.tones and (source.get('tie_to') or source.get('printed_ties')) and any(f.visible for f in pieces):
|
||||
if not any(p.role == 'tie' for p in primitives.get(identifier, [])):
|
||||
raise EngravingError(f'{identifier}: tie was omitted')
|
||||
if not note.get('hidden'):
|
||||
|
||||
@@ -10,6 +10,7 @@ from generator.engraving.attributes import barline, staff_attributes, tempo
|
||||
from generator.engraving.fonts import Fonts
|
||||
from generator.engraving.notation import ScoreIndex
|
||||
from generator.engraving.notes import draw_event
|
||||
from generator.engraving.repeats import draw_boundary, draw_endings
|
||||
from generator.engraving.spans import draw_beams, draw_lyric_spans, draw_spans, place_text_lanes
|
||||
from generator.engraving.types import Anchor, EngravingError, Fragment, LayoutDocument, Page, Primitive, Style, System, bounds, line
|
||||
|
||||
@@ -70,6 +71,12 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
|
||||
header = staff_attributes(index, state, start, fonts, style.space, measure['id'] + ':initial', ('clef', 'key', 'meter', 'staff_details'))
|
||||
items.extend(header)
|
||||
cursor = max((p.box.right for p in header), default=0) + 22
|
||||
boundary_positions = {start: cursor - 9}
|
||||
if start in index.boundaries and measure.get('repeats', {}).get('left'):
|
||||
initial = draw_boundary(None, measure, cursor, -style.space * (state['staff_details']['lines'] - 1), fonts, style)
|
||||
boundary_positions[start] = cursor
|
||||
items.extend(initial)
|
||||
cursor = max(p.box.right for p in initial) + 18
|
||||
content_left = cursor - 9
|
||||
times = sorted({start} | {t for t in index.columns if start < t < end})
|
||||
placements = []
|
||||
@@ -81,15 +88,18 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
|
||||
for measure_start, measure_end, m in index.measures:
|
||||
if time == measure_start and time > start:
|
||||
previous = next(previous for a, b, previous in index.measures if b == time)
|
||||
leading.extend(barline(previous.get('barlines', {}).get('right', 'regular'), 0, -style.space * (index.state(time)['staff_details']['lines'] - 1), previous['id']))
|
||||
leading.extend(draw_boundary(previous, m, 0, -style.space * (index.state(time)['staff_details']['lines'] - 1), fonts, style))
|
||||
old_state = index.state(time, before=True)
|
||||
fields = tuple(k for k in ('clef', 'key', 'meter', 'staff_details') if old_state[k] != index.state(time)[k])
|
||||
leading.extend(p.moved(12, 0) for p in staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + ':initial', fields))
|
||||
offset = max((p.box.right for p in leading), default=0) + 12 if previous.get('repeats') or m.get('repeats') else 12
|
||||
leading.extend(p.moved(offset, 0) for p in staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + ':initial', fields))
|
||||
for ci, change in enumerate(m.get('attributes', [])):
|
||||
if measure_start + fraction(change['onset']) == time and time > start:
|
||||
extra = staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + f':attributes:{ci}', tuple(change))
|
||||
leading.extend(p.moved(max((p.box.right for p in leading), default=0) + 10, 0) for p in extra)
|
||||
if leading:
|
||||
if time in index.boundaries:
|
||||
boundary_positions[time] = cursor - bounds(leading).left
|
||||
width = bounds(leading).right - bounds(leading).left + 14
|
||||
items.extend(p.moved(cursor - bounds(leading).left, 0) for p in leading)
|
||||
cursor += width
|
||||
@@ -111,12 +121,18 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
|
||||
items[first_item:last_item] = [p.moved(i * stretch, 0) for p in items[first_item:last_item]]
|
||||
anchors[first_anchor:last_anchor] = [a.moved(i * stretch, 0) for a in anchors[first_anchor:last_anchor]]
|
||||
placements = [(time, x + i * stretch) for i, (time, x) in enumerate(placements)]
|
||||
boundary_positions = {time: x + times.index(time) * stretch for time, x in boundary_positions.items()}
|
||||
width = available
|
||||
right = width - 5
|
||||
boundary_positions[end] = width - 3
|
||||
last_measure = next(m for a, b, m in index.measures if a < end <= b)
|
||||
if end in index.boundaries and last_measure.get('repeats', {}).get('right'):
|
||||
items.extend(draw_boundary(last_measure, None, width - 3, -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), fonts, style))
|
||||
items = draw_beams(index, anchors, items, start, end, content_left, right, style)
|
||||
items = draw_spans(index, anchors, items, start, end, content_left, right, fonts, style)
|
||||
items = place_text_lanes(items, fonts, style)
|
||||
items = draw_lyric_spans(index, items, anchors, start, end, content_left, right, fonts)
|
||||
items = draw_endings(index, items, start, end, boundary_positions, fonts, style)
|
||||
# Staff lines are interrupted behind TAB numerals, not painted through them.
|
||||
changes = [(0, state['staff_details']['lines'])]
|
||||
for time, x in placements:
|
||||
@@ -135,11 +151,11 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
|
||||
staff_lines.append(line('staff', (), ((cursor, y), (a, y)), style.space * 0.13))
|
||||
cursor = max(cursor, b)
|
||||
items = staff_lines + items
|
||||
last_measure = next(m for a, b, m in index.measures if a < end <= b)
|
||||
if end in index.boundaries:
|
||||
items.extend(barline(last_measure.get('barlines', {}).get('right', 'regular'), width - 1, -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), last_measure['id']))
|
||||
if end in index.boundaries and not last_measure.get('repeats', {}).get('right'):
|
||||
items.extend(draw_boundary(last_measure, None, width - (3 if last_measure.get('repeats') else 1), -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), fonts, style))
|
||||
if start in index.boundaries:
|
||||
items.extend(barline(measure.get('barlines', {}).get('left', 'none'), 0, -style.space * (state['staff_details']['lines'] - 1), measure['id']))
|
||||
if not measure.get('repeats', {}).get('left'):
|
||||
items.extend(barline(measure.get('barlines', {}).get('left', 'none'), 0, -style.space * (state['staff_details']['lines'] - 1), measure['id']))
|
||||
else:
|
||||
items.append(fonts.text(str(measure['number']) + ' (cont.)', 0, min(p.box.top for p in items) - 6, 12, 'continuation', (measure['id'],)))
|
||||
return System(start, end, tuple(items), tuple(anchors), tuple(fragments), bounds(items))
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from generator.engraving.attributes import barline
|
||||
from generator.engraving.types import line
|
||||
from generator.repeats import analyze
|
||||
|
||||
def draw_boundary(previous, following, x, top, fonts, style):
|
||||
backward = previous.get('repeats', {}).get('right') if previous else None
|
||||
forward = following.get('repeats', {}).get('left') if following else None
|
||||
source = previous or following
|
||||
if not backward and not forward:
|
||||
value = previous.get('barlines', {}).get('right', 'regular') if previous else following.get('barlines', {}).get('left', 'none')
|
||||
return barline(value, x, top, source['id'])
|
||||
refs = tuple(m['id'] for m in (previous, following) if m)
|
||||
result = [line('repeat-bar', refs, ((x, top), (x, 0)), 4)]
|
||||
for marker, direction in ((backward, -1), (forward, 1)):
|
||||
if not marker:
|
||||
continue
|
||||
result.append(line('repeat-bar', refs, ((x + direction * 6, top), (x + direction * 6, 0)), 1.4))
|
||||
for offset in (-0.5, 0.5):
|
||||
result.append(fonts.glyph('augmentationDot', x + direction * 13 - 2, top / 2 + style.space * offset, style.space, 'repeat-dot', refs))
|
||||
if backward and backward.get('times', 2) != 2:
|
||||
result.append(fonts.text(f'{backward["times"]} times', x - 10, top - 12, 14, 'repeat-count', refs, True))
|
||||
return result
|
||||
|
||||
def draw_endings(index, items, start, end, positions, fonts, style):
|
||||
_, endings = analyze(index.staff['measures'])
|
||||
result = list(items)
|
||||
boundaries = [a for a, _, _ in index.measures] + [index.end]
|
||||
top = min((p.box.top for p in items), default=0) - 25
|
||||
for ending in endings:
|
||||
low, high = boundaries[ending.start], boundaries[ending.end]
|
||||
if not ending.visible or high <= start or low >= end:
|
||||
continue
|
||||
x1 = positions[max(low, start)]
|
||||
x2 = positions[min(high, end)]
|
||||
refs = (index.measures[ending.start][2]['id'] + ':ending',)
|
||||
result.append(line('ending', refs, ((x1, top), (x2, top))))
|
||||
if low >= start:
|
||||
result.append(line('ending', refs, ((x1, top), (x1, top + 10))))
|
||||
result.append(fonts.text(ending.text, x1 + 5, top - 4, 16, 'ending-label', refs))
|
||||
if high <= end and ending.closed:
|
||||
result.append(line('ending', refs, ((x2, top), (x2, top + 10))))
|
||||
return result
|
||||
@@ -101,6 +101,20 @@ def draw_spans(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive],
|
||||
target = tone.get('tie_to')
|
||||
tone_end = event.start + fraction(tone['duration'])
|
||||
anchor = by_tone.get(identifier)
|
||||
if tone.get('printed_ties'):
|
||||
for action in tone['printed_ties']:
|
||||
candidates = [(other_id, other) for other_id, (other_event, other) in index.tones.items() if other_event.voice == event.voice and other_event.start == tone_end and 'stop' in other.get('printed_ties', []) and all(other.get(k) == tone.get(k) for k in ('letter', 'alter', 'octave', 'unpitched'))]
|
||||
if action == 'start' and candidates:
|
||||
target = candidates[0][0]
|
||||
continue
|
||||
if action == 'stop' and any(other_event.voice == event.voice and other_event.start + fraction(other['duration']) == event.start and 'start' in other.get('printed_ties', []) and all(other.get(k) == tone.get(k) for k in ('letter', 'alter', 'octave', 'unpitched')) for other_event, other in index.tones.values()):
|
||||
continue
|
||||
if anchor is None:
|
||||
continue
|
||||
x1, x2 = (anchor.x + style.space * 1.4, min(right, anchor.x + 36)) if action == 'start' else (max(left, anchor.x - 24), anchor.x - 2)
|
||||
y = anchor.y - anchor.direction * 8
|
||||
if x2 > x1:
|
||||
result.append(curve('tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y - anchor.direction * 9), (x2 - (x2 - x1) / 3, y - anchor.direction * 9), (x2, y)), 1.6))
|
||||
if anchor and anchor.fragment.end < tone_end:
|
||||
target = identifier
|
||||
if target:
|
||||
|
||||
@@ -146,6 +146,9 @@ def validate_document(document: dict) -> None:
|
||||
staves = document.get('staves', [])
|
||||
if len(staves) != 1:
|
||||
raise ValueError('exactly one staff is currently supported')
|
||||
if any(m.get('repeats') or m.get('endings') for m in staves[0]['measures']):
|
||||
from generator.repeats import performed_order
|
||||
performed_order(staves[0]['measures'])
|
||||
instruments = staves[0].get('instruments', [])
|
||||
instrument_ids = {i['id'] for i in instruments}
|
||||
if len(instruments) != len(instrument_ids) or any(not isinstance(i, str) or not i for i in instrument_ids):
|
||||
|
||||
+58
-4
@@ -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
|
||||
|
||||
|
||||
@@ -138,6 +138,8 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di
|
||||
element(item, 'octave', pitch['octave'] if pitch else 4)
|
||||
if not event.get('grace'):
|
||||
element(node, 'duration', int(fraction(pitch['duration'] if pitch else event['duration']) * divisions))
|
||||
for marker in (pitch or {}).get('sound_ties', []):
|
||||
element(node, 'tie', type=marker['type'], time_only=','.join(map(str, marker['passes'])) if marker.get('passes') else None)
|
||||
for field, action in (('tie_from', 'stop'), ('tie_to', 'start')):
|
||||
if pitch and pitch.get(field):
|
||||
element(node, 'tie', type=action)
|
||||
@@ -170,6 +172,8 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di
|
||||
for beam in event.get('beams', []):
|
||||
element(node, 'beam', beam['type'], number=beam['level'])
|
||||
notations = element(node, 'notations')
|
||||
for action in (pitch or {}).get('printed_ties', []):
|
||||
element(notations, 'tied', type=action)
|
||||
for field, action in (('tie_from', 'stop'), ('tie_to', 'start')):
|
||||
if pitch and pitch.get(field):
|
||||
element(notations, 'tied', type=action)
|
||||
@@ -202,6 +206,16 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di
|
||||
for syllable in verse['syllables']:
|
||||
element(lyric, syllable['kind'], syllable['text'], **syllable['attributes'])
|
||||
|
||||
def write_barline(bar, measure, location):
|
||||
if not any(location in measure.get(field, {}) for field in ('barlines', 'repeats', 'endings')):
|
||||
return
|
||||
node = element(bar, 'barline', location=location)
|
||||
element(node, 'bar-style', measure.get('barlines', {}).get(location, 'regular'))
|
||||
if ending := measure.get('endings', {}).get(location):
|
||||
element(node, 'ending', ending.get('text', ''), number=','.join(map(str, ending['numbers'])), type=ending['type'], print_object='yes' if ending.get('visible', True) else 'no')
|
||||
if repeat := measure.get('repeats', {}).get(location):
|
||||
element(node, 'repeat', direction=repeat['direction'], times=repeat.get('times'))
|
||||
|
||||
def musicxml_tree(document: dict) -> ElementTree.ElementTree:
|
||||
validate_document(document)
|
||||
root = ElementTree.Element('score-partwise', {'version': '4.0'})
|
||||
@@ -245,6 +259,7 @@ def musicxml_tree(document: dict) -> ElementTree.ElementTree:
|
||||
previous_state = {}
|
||||
for index, measure in enumerate(staff['measures']):
|
||||
bar = element(part, 'measure', number=index + 1, implicit='yes' if measure['implicit'] else 'no')
|
||||
write_barline(bar, measure, 'left')
|
||||
times = [fraction(measure['duration'])]
|
||||
times.extend(fraction(n[field]) for v in measure['voices'] for n in v['notes'] for field in ('onset', 'duration'))
|
||||
times.extend(fraction(p['duration']) for v in measure['voices'] for n in v['notes'] for p in n['pitches'])
|
||||
@@ -303,8 +318,7 @@ def musicxml_tree(document: dict) -> ElementTree.ElementTree:
|
||||
if cursor < fraction(measure['duration']):
|
||||
element(element(bar, 'forward'), 'duration', int((fraction(measure['duration']) - cursor) * divisions))
|
||||
cursor = fraction(measure['duration'])
|
||||
for location, style in measure.get('barlines', {}).items():
|
||||
element(element(bar, 'barline', location=location), 'bar-style', style)
|
||||
write_barline(bar, measure, 'right')
|
||||
absolute += fraction(measure['duration'])
|
||||
return ElementTree.ElementTree(root)
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from array import array
|
||||
from fractions import Fraction
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import wave
|
||||
|
||||
from generator.model import TYPE_LENGTHS, fraction, pitch_number, rational
|
||||
from generator.repeats import expand_document
|
||||
|
||||
SAMPLE_RATE = 48000
|
||||
PROFILE = 'soft-struck-1'
|
||||
MAX_EVENTS = 1000000
|
||||
|
||||
class PlaybackUnavailable(ValueError):
|
||||
pass
|
||||
|
||||
def sounding_pitch(pitch: dict, state: dict, guitar: bool) -> Fraction:
|
||||
if 'unpitched' in pitch:
|
||||
raise PlaybackUnavailable('unpitched notation has no generated-tone interpretation')
|
||||
if guitar:
|
||||
import generator.practice.guitar
|
||||
value = generator.practice.guitar.sounding_pitch(pitch, state)
|
||||
else:
|
||||
transpose = state['transpose']
|
||||
value = pitch_number(pitch) + transpose['chromatic'] + 12 * transpose['octave_change']
|
||||
frequency = 440 * 2 ** (float(value - 69) / 12)
|
||||
if not 20 <= frequency <= 18000:
|
||||
raise PlaybackUnavailable(f'sounding pitch {value} is outside the generated-tone range (20-18000 Hz)')
|
||||
return value
|
||||
|
||||
def compile_timeline(document: dict, *, guitar: bool = False) -> dict:
|
||||
document = expand_document(document)
|
||||
events, notes, linked = [], {}, {}
|
||||
absolute = Fraction()
|
||||
for measure in document['staves'][0]['measures']:
|
||||
for voice in measure['voices']:
|
||||
state = {key: measure[key] for key in ('transpose', 'clef', 'staff_details')}
|
||||
changes = iter(sorted(measure.get('attributes', []), key=lambda a: fraction(a['onset'])))
|
||||
change = next(changes, None)
|
||||
for note in voice['notes']:
|
||||
onset = fraction(note['onset'])
|
||||
while change is not None and fraction(change['onset']) <= onset:
|
||||
state.update({key: change[key] for key in state if key in change})
|
||||
change = next(changes, None)
|
||||
notes[note['id']] = (note, absolute + onset, dict(state), voice['id'])
|
||||
absolute += fraction(measure['duration'])
|
||||
adjustments = {}
|
||||
insertions = {}
|
||||
lanes = {}
|
||||
for item in notes.values():
|
||||
lanes.setdefault(item[3], []).append(item)
|
||||
for lane in lanes.values():
|
||||
pending = []
|
||||
previous = None
|
||||
for note, onset, state, voice in lane:
|
||||
if note.get('grace'):
|
||||
pending.append((note, onset, state, voice))
|
||||
continue
|
||||
if pending:
|
||||
cursor = onset
|
||||
specified = [n['grace'] for n, _, _, _ in pending]
|
||||
modes = {key for g in specified for key in ('make-time', 'steal-time-previous', 'steal-time-following') if key in g}
|
||||
if len(modes) > 1:
|
||||
raise PlaybackUnavailable('grace group mixes conflicting timing modes')
|
||||
mode = next(iter(modes), 'steal-time-following')
|
||||
if mode == 'make-time':
|
||||
lengths = [fraction(g.get(mode, rational(0))) for g in specified]
|
||||
total = sum(lengths, Fraction())
|
||||
insertions[onset] = max(insertions.get(onset, Fraction()), total)
|
||||
else:
|
||||
donor = previous[0] if mode == 'steal-time-previous' and previous else note if mode == 'steal-time-following' else None
|
||||
if donor is None:
|
||||
raise PlaybackUnavailable('grace notes have no preceding duration to steal')
|
||||
percentages = [fraction(g[mode]) if mode in g else Fraction(25, len(pending)) for g in specified]
|
||||
if not 0 < sum(percentages) < 100:
|
||||
raise PlaybackUnavailable('grace group must steal between zero and 100 percent')
|
||||
lengths = [fraction(donor['duration']) * amount / 100 for amount in percentages]
|
||||
total = sum(lengths, Fraction())
|
||||
if mode == 'steal-time-previous':
|
||||
cursor -= total
|
||||
adjustments[donor['id']] = (Fraction(), -total)
|
||||
else:
|
||||
adjustments[donor['id']] = (total, -total)
|
||||
for item, length in zip(pending, lengths):
|
||||
value, _, pitch_state, lane_id = item
|
||||
adjustments[value['id']] = (cursor - item[1], length)
|
||||
cursor += length
|
||||
pending = []
|
||||
previous = (note, onset)
|
||||
if pending:
|
||||
raise PlaybackUnavailable('trailing grace group has no following anchor')
|
||||
for identifier, (note, onset, state, voice) in notes.items():
|
||||
if note.get('cue'):
|
||||
continue
|
||||
delay, extension = adjustments.get(identifier, (Fraction(), Fraction()))
|
||||
performed = onset + delay + sum((length for time, length in insertions.items() if time < onset or time == onset and not note.get('grace')), Fraction())
|
||||
tremolo = note.get('tremolo')
|
||||
if tremolo and tremolo['type'] == 'stop':
|
||||
continue
|
||||
for pitch in note['pitches']:
|
||||
if pitch.get('cue'):
|
||||
continue
|
||||
length = fraction(pitch['duration']) + extension
|
||||
if not note.get('grace'):
|
||||
length += sum((amount for time, amount in insertions.items() if onset < time < onset + fraction(pitch['duration'])), Fraction())
|
||||
if length <= 0:
|
||||
raise PlaybackUnavailable(f'{identifier}: grace timing consumes a chord tone')
|
||||
value = sounding_pitch(pitch, state, guitar)
|
||||
if pitch.get('tie_from'):
|
||||
previous = linked.get(pitch['tie_from'])
|
||||
if previous is None or fraction(previous['onset']) + fraction(previous['duration']) != performed or fraction(previous['pitch']) != value:
|
||||
raise PlaybackUnavailable(f'{identifier}: tie has inconsistent performed timing or pitch')
|
||||
previous['duration'] = rational(fraction(previous['duration']) + length)
|
||||
linked[pitch['id']] = previous
|
||||
continue
|
||||
if tremolo and tremolo['marks']:
|
||||
if pitch.get('tie_to'):
|
||||
raise PlaybackUnavailable('tied measured tremolo is not yet qualified for playback')
|
||||
written = pitch['written']
|
||||
step = min(Fraction(1), TYPE_LENGTHS[written['type']]) * fraction(written.get('ratio', rational(1))) / 2 ** tremolo['marks']
|
||||
if tremolo['type'] == 'start':
|
||||
partner = notes[tremolo['partner']]
|
||||
if partner[1] != onset + fraction(note['duration']) or len(partner[0]['pitches']) != len(note['pitches']):
|
||||
raise PlaybackUnavailable('paired tremolo requires contiguous equal-size chords')
|
||||
step *= 2
|
||||
length += fraction(partner[0]['pitches'][note['pitches'].index(pitch)]['duration'])
|
||||
cursor, attack = Fraction(), 0
|
||||
while cursor < length:
|
||||
amount = value
|
||||
if tremolo['type'] == 'start' and attack % 2:
|
||||
other = partner[0]['pitches'][note['pitches'].index(pitch)]
|
||||
amount = sounding_pitch(other, partner[2], guitar)
|
||||
events.append({'id': f'{pitch["id"]}-{attack}', 'pitch': rational(amount), 'onset': rational(performed + cursor), 'duration': rational(min(step, length - cursor))})
|
||||
cursor += step
|
||||
attack += 1
|
||||
if len(events) > MAX_EVENTS:
|
||||
raise PlaybackUnavailable('playback exceeds one million tone events')
|
||||
else:
|
||||
event = {'id': pitch['id'], 'pitch': rational(value), 'onset': rational(performed), 'duration': rational(length)}
|
||||
events.append(event)
|
||||
linked[pitch['id']] = event
|
||||
if len(events) > MAX_EVENTS:
|
||||
raise PlaybackUnavailable('playback exceeds one million tone events')
|
||||
events.sort(key=lambda e: (fraction(e['onset']), e['id']))
|
||||
return {'version': 1, 'available': True, 'duration': rational(absolute + sum(insertions.values(), Fraction())), 'events': events}
|
||||
|
||||
def write_pcm(path: Path, values) -> str:
|
||||
samples = array('h', (round(max(-1, min(1, sample)) * 32767) for sample in values))
|
||||
if sys.byteorder != 'little':
|
||||
samples.byteswap()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with wave.open(str(path), 'wb') as output:
|
||||
output.setparams((1, 2, SAMPLE_RATE, 0, 'NONE', 'not compressed'))
|
||||
output.writeframes(samples.tobytes())
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
def generate_tone(root: Path, pitch: Fraction) -> dict:
|
||||
frequency = 440 * 2 ** (float(pitch - 69) / 12)
|
||||
period = Fraction(frequency / SAMPLE_RATE).limit_denominator(SAMPLE_RATE)
|
||||
loop_length, cycles = period.denominator, period.numerator
|
||||
attack = 12000
|
||||
name = hashlib.sha256(f'{PROFILE}:{pitch}'.encode('ascii')).hexdigest()[:24]
|
||||
relative = f'audio/{name}.wav'
|
||||
harmonics = [h for h in range(1, 7) if frequency * h < SAMPLE_RATE * 0.45]
|
||||
|
||||
def sample(frame):
|
||||
phase = 2 * math.pi * cycles * frame / loop_length
|
||||
envelope = min(1, frame / 240) * (0.18 + 0.82 * (1 - min(1, frame / attack)) ** 3)
|
||||
return 0.3 * envelope * sum(math.sin(phase * h) / (h * h) for h in harmonics)
|
||||
|
||||
checksum = write_pcm(root / relative, (sample(i) for i in range(attack + loop_length)))
|
||||
return {'id': name, 'path': relative, 'sha256': checksum, 'sample_rate': SAMPLE_RATE, 'frames': attack + loop_length, 'loop_start': attack, 'loop_end': attack + loop_length}
|
||||
|
||||
def compile_tree(root: Path) -> None:
|
||||
tones = {}
|
||||
release = {'path': 'audio/release.wav', 'sample_rate': SAMPLE_RATE, 'frames': 2400}
|
||||
release['sha256'] = write_pcm(root / release['path'], ((1 - i / 2399) ** 2 for i in range(2400)))
|
||||
for path in sorted(root.rglob('*.json')):
|
||||
document = json.loads(path.read_text(encoding='utf-8'))
|
||||
try:
|
||||
guitar = path.relative_to(root).parts[:2] == ('practice', 'chord') or document['staves'][0].get('arrangement_part_type') in ('acoustic', 'electric')
|
||||
playback = compile_timeline(document, guitar=guitar)
|
||||
used = {}
|
||||
for event in playback['events']:
|
||||
event['end'] = rational(fraction(event['onset']) + fraction(event.pop('duration')))
|
||||
pitch = fraction(event.pop('pitch'))
|
||||
if pitch not in tones:
|
||||
tones[pitch] = generate_tone(root, pitch)
|
||||
tone = tones[pitch]
|
||||
event['sample'] = tone['id']
|
||||
used[tone['id']] = tone
|
||||
playback.update({'samples': list(used.values()), 'release': release, 'profile': PROFILE})
|
||||
except PlaybackUnavailable as error:
|
||||
playback = {'version': 1, 'available': False, 'reason': str(error)}
|
||||
document['playback'] = playback
|
||||
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
|
||||
def validate_assets(playback: dict, root: Path, cache: dict | None = None) -> None:
|
||||
if playback.get('version') != 1 or type(playback.get('available')) is not bool:
|
||||
raise ValueError('invalid playback manifest version/availability')
|
||||
if not playback['available']:
|
||||
if not isinstance(playback.get('reason'), str) or not playback['reason']:
|
||||
raise ValueError('unavailable playback must explain why')
|
||||
return
|
||||
cache = {} if cache is None else cache
|
||||
|
||||
def check_sample(value):
|
||||
name = value['path']
|
||||
relative = Path(name)
|
||||
if not name.startswith('audio/') or relative.is_absolute() or relative.suffix != '.wav' or any(p in ('', '.', '..') for p in name.split('/')) or '\\' in name or '\x00' in name:
|
||||
raise ValueError('invalid generated audio path')
|
||||
path = (root / relative).resolve()
|
||||
if not path.is_relative_to(root.resolve()):
|
||||
raise ValueError('generated audio escapes asset root')
|
||||
key = (str(path), value['sha256'], value['frames'], value['sample_rate'])
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
if value['sample_rate'] != SAMPLE_RATE or not 0 < value['frames'] <= 96000:
|
||||
raise ValueError('invalid generated audio rate/length')
|
||||
if path.stat().st_size != 44 + value['frames'] * 2 or hashlib.sha256(path.read_bytes()).hexdigest() != value['sha256']:
|
||||
raise ValueError('generated audio checksum mismatch')
|
||||
with wave.open(str(path)) as audio:
|
||||
pcm = audio.readframes(value['frames'])
|
||||
if (audio.getnchannels(), audio.getsampwidth(), audio.getframerate(), audio.getnframes()) != (1, 2, SAMPLE_RATE, value['frames']) or len(pcm) != value['frames'] * 2:
|
||||
raise ValueError('invalid or truncated generated PCM WAV')
|
||||
samples = array('h')
|
||||
samples.frombytes(pcm)
|
||||
if sys.byteorder != 'little':
|
||||
samples.byteswap()
|
||||
if min(samples) < -32767:
|
||||
raise ValueError('generated PCM exceeds unit range')
|
||||
cache[key] = samples
|
||||
return samples
|
||||
|
||||
duration = fraction(playback['duration'])
|
||||
if duration <= 0 or len(playback['events']) > MAX_EVENTS:
|
||||
raise ValueError('invalid playback timeline length')
|
||||
release = check_sample(playback['release'])
|
||||
if not 1 < playback['release']['frames'] <= SAMPLE_RATE // 2:
|
||||
raise ValueError('invalid release envelope length')
|
||||
if release[0] != 32767 or release[-1] != 0 or any(a < b for a, b in zip(release, release[1:])):
|
||||
raise ValueError('release envelope must decrease from one to zero')
|
||||
if len(playback['samples']) > 512 or sum(s['frames'] for s in playback['samples']) > 16 * 1024 * 1024:
|
||||
raise ValueError('generated playback exceeds sample memory budget')
|
||||
samples = {}
|
||||
for sample in playback['samples']:
|
||||
if not sample['id'] or sample['id'] in samples or not 0 <= sample['loop_start'] < sample['loop_end'] == sample['frames']:
|
||||
raise ValueError('invalid sample identity or loop')
|
||||
check_sample(sample)
|
||||
samples[sample['id']] = sample
|
||||
previous = Fraction()
|
||||
identities = set()
|
||||
for event in playback['events']:
|
||||
onset, end = fraction(event['onset']), fraction(event['end'])
|
||||
if event['id'] in identities or event['sample'] not in samples or not previous <= onset < end <= duration:
|
||||
raise ValueError('invalid playback event identity, reference or timing')
|
||||
identities.add(event['id'])
|
||||
previous = onset
|
||||
@@ -86,6 +86,8 @@ def extend_fingerings(held: dict[str, TabPosition], pending: list[tuple[str, tup
|
||||
yield from extend_fingerings({**held, identifier: position}, pending[1:])
|
||||
|
||||
def validate_guitar_practice(document: dict) -> None:
|
||||
from generator.repeats import expand_document
|
||||
document = expand_document(document)
|
||||
tones = guitar_tones(document)
|
||||
by_id = {tone.pitch['id']: tone for tone in tones}
|
||||
options = {tone.pitch['id']: tone_positions(tone) for tone in tones}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from fractions import Fraction
|
||||
import re
|
||||
|
||||
from generator.model import alteration, fraction, key_label, meter_label, rational
|
||||
|
||||
MAX_DEPTH = 16
|
||||
MAX_MEASURES = 10000
|
||||
|
||||
def pass_numbers(value: str) -> list[int]:
|
||||
if not re.fullmatch(r'[1-9][0-9]*(?:,\s*[1-9][0-9]*)*', value.strip()):
|
||||
raise ValueError('ending/time-only numbers must be comma-separated positive integers')
|
||||
result = [int(item) for item in value.split(',')]
|
||||
if len(set(result)) != len(result) or max(result) > MAX_MEASURES:
|
||||
raise ValueError('duplicate or excessive ending/time-only number')
|
||||
return sorted(result)
|
||||
|
||||
@dataclass
|
||||
class Ending:
|
||||
start: int
|
||||
end: int
|
||||
numbers: list[int]
|
||||
text: str
|
||||
closed: bool
|
||||
visible: bool
|
||||
|
||||
@dataclass
|
||||
class Region:
|
||||
start: int
|
||||
end: int
|
||||
extent: int
|
||||
count: int
|
||||
endings: list[Ending]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Occurrence:
|
||||
measure: int
|
||||
passes: tuple[int, ...]
|
||||
|
||||
def analyze(measures: list[dict]) -> tuple[list[Region], list[Ending]]:
|
||||
regions, endings, starts = [], [], []
|
||||
active = None
|
||||
for index, measure in enumerate(measures):
|
||||
for side, boundary in (('left', index), ('right', index + 1)):
|
||||
if marker := measure.get('endings', {}).get(side):
|
||||
numbers = marker['numbers']
|
||||
if numbers != pass_numbers(','.join(map(str, numbers))):
|
||||
raise ValueError(f'measure {index + 1}: invalid ending passes')
|
||||
if marker['type'] == 'start':
|
||||
if active is not None:
|
||||
raise ValueError(f'measure {index + 1}: overlapping numbered endings')
|
||||
active = (boundary, marker)
|
||||
elif marker['type'] in ('stop', 'discontinue'):
|
||||
if active is None or active[1]['numbers'] != numbers or active[0] >= boundary:
|
||||
raise ValueError(f'measure {index + 1}: ending stop has no matching nonempty start')
|
||||
first, value = active
|
||||
endings.append(Ending(first, boundary, numbers, value.get('text') or ', '.join(map(str, numbers)) + '.', marker['type'] == 'stop', value.get('visible', True)))
|
||||
active = None
|
||||
else:
|
||||
raise ValueError(f'measure {index + 1}: invalid ending type')
|
||||
if marker := measure.get('repeats', {}).get(side):
|
||||
direction = marker['direction']
|
||||
if direction == 'forward' and side == 'left':
|
||||
starts.append(boundary)
|
||||
if len(starts) > MAX_DEPTH:
|
||||
raise ValueError('repeat nesting exceeds 16 levels')
|
||||
elif direction == 'backward' and side == 'right':
|
||||
first = starts.pop() if starts else 0
|
||||
count = marker.get('times', 2)
|
||||
if type(count) is not int or not 1 <= count <= MAX_MEASURES or first >= boundary:
|
||||
raise ValueError(f'measure {index + 1}: invalid repeat count or empty repeat')
|
||||
regions.append(Region(first, boundary, boundary, count, []))
|
||||
else:
|
||||
raise ValueError(f'measure {index + 1}: repeat direction disagrees with its boundary')
|
||||
if starts or active is not None:
|
||||
raise ValueError('unterminated repeat or numbered ending')
|
||||
assigned = set()
|
||||
for region in regions:
|
||||
first = next((ending for ending in endings if region.start <= ending.start < region.end and ending.end == region.end), None)
|
||||
if first is None:
|
||||
continue
|
||||
group = [first]
|
||||
while following := next((ending for ending in endings if ending.start == group[-1].end), None):
|
||||
group.append(following)
|
||||
numbers = [number for ending in group for number in ending.numbers]
|
||||
count = max(numbers)
|
||||
if sorted(numbers) != list(range(1, count + 1)) or count not in group[-1].numbers or len(group) < 2:
|
||||
raise ValueError(f'measure {region.start + 1}: endings must cover each pass exactly once and finish with the last pass')
|
||||
marker = measures[region.end - 1]['repeats']['right']
|
||||
if 'times' in marker and marker['times'] != count:
|
||||
raise ValueError(f'measure {region.end}: repeat count contradicts ending passes')
|
||||
region.count, region.extent, region.endings = count, group[-1].end, group
|
||||
assigned.update(id(ending) for ending in group)
|
||||
if any(id(ending) not in assigned for ending in endings):
|
||||
raise ValueError('numbered ending is not attached to a repeat group')
|
||||
for a, region in enumerate(regions):
|
||||
for other in regions[a + 1:]:
|
||||
if region.start == other.start and region.extent == other.extent:
|
||||
raise ValueError('duplicate repeat region')
|
||||
left, right = sorted((region, other), key=lambda item: (item.start, -item.extent))
|
||||
if left.start < right.start < left.extent < right.extent:
|
||||
raise ValueError('crossing repeat regions are ambiguous')
|
||||
for ending in left.endings:
|
||||
if right.start < ending.start < right.extent or right.start < ending.end < right.extent:
|
||||
raise ValueError('nested repeat crosses an ending boundary')
|
||||
return regions, endings
|
||||
|
||||
def performed_order(measures: list[dict]) -> list[Occurrence]:
|
||||
regions, _ = analyze(measures)
|
||||
result = []
|
||||
|
||||
def walk(start, end, parents, passes, excluded=()):
|
||||
if len(parents) > MAX_DEPTH:
|
||||
raise ValueError('repeat nesting exceeds 16 levels')
|
||||
index = start
|
||||
while index < end:
|
||||
skipped = next((ending for ending in excluded if ending.start == index), None)
|
||||
if skipped:
|
||||
index = skipped.end
|
||||
continue
|
||||
candidates = [r for r in regions if r.start == index and r.extent <= end and id(r) not in parents]
|
||||
if candidates:
|
||||
region = max(candidates, key=lambda r: r.extent)
|
||||
for number in range(1, region.count + 1):
|
||||
walk(index, region.extent, (*parents, id(region)), (*passes, number), [e for e in region.endings if number not in e.numbers])
|
||||
index = region.extent
|
||||
else:
|
||||
result.append(Occurrence(index, passes))
|
||||
if len(result) > MAX_MEASURES:
|
||||
raise ValueError('expanded repeat traversal exceeds 10000 measures')
|
||||
index += 1
|
||||
|
||||
walk(0, len(measures), (), ())
|
||||
return result
|
||||
|
||||
def expand_document(document: dict) -> dict:
|
||||
staff = document['staves'][0]
|
||||
source = staff['measures']
|
||||
if not any(m.get('repeats') or m.get('endings') for m in source):
|
||||
return copy.deepcopy(document)
|
||||
order = performed_order(source)
|
||||
output = copy.deepcopy(document)
|
||||
target = output['staves'][0]
|
||||
target['measures'], target['tuplets'], target['octave_shifts'] = [], [], []
|
||||
starts = [Fraction()]
|
||||
harmonies_before = []
|
||||
active_harmony = None
|
||||
event_measures = {n['id']: i for i, m in enumerate(source) for v in m['voices'] for n in v['notes']}
|
||||
event_ids = {(oi, n['id']): f'occurrence-{oi + 1}-n{ni + 1}' for oi, o in enumerate(order) for ni, n in enumerate(n for v in source[o.measure]['voices'] for n in v['notes'])}
|
||||
for measure in source:
|
||||
starts.append(starts[-1] + fraction(measure['duration']))
|
||||
harmonies_before.append(active_harmony)
|
||||
for harmony in measure['harmonies']:
|
||||
active_harmony = harmony
|
||||
absolute = Fraction()
|
||||
open_ties = {}
|
||||
expected_ties, used_ties = set(), set()
|
||||
previous_state = {}
|
||||
previous_index = -1
|
||||
active_tuplets = {}
|
||||
for oi, occurrence in enumerate(order):
|
||||
si = occurrence.measure
|
||||
measure = copy.deepcopy(source[si])
|
||||
measure['id'] = f'occurrence-{oi + 1}'
|
||||
measure.pop('repeats', None)
|
||||
measure.pop('endings', None)
|
||||
if active_harmony and oi and si <= previous_index and not any(fraction(h['onset']) == 0 for h in measure['harmonies']):
|
||||
restored = copy.deepcopy(harmonies_before[si]) if harmonies_before[si] else {'kind': 'none', 'symbol': 'N.C.', 'degrees': []}
|
||||
restored['onset'] = rational(0)
|
||||
measure['harmonies'].insert(0, restored)
|
||||
for side, marker in source[si].get('repeats', {}).items():
|
||||
measure.get('barlines', {}).pop(side, None)
|
||||
if oi and si > previous_index:
|
||||
for field, value in previous_state.items():
|
||||
if field not in measure.get('initial_attributes', {}):
|
||||
measure[field] = copy.deepcopy(value)
|
||||
measure.pop('initial_attributes', None)
|
||||
if oi and measure['key'].get('cancel') is not None:
|
||||
measure['key']['cancel'] = previous_state['key'].get('fifths', 0)
|
||||
measure['key_signature'] = key_label(measure['key'])
|
||||
measure['time_signature'] = meter_label(measure['meter'])
|
||||
previous_state = {field: copy.deepcopy(measure[field]) for field in ('key', 'meter', 'clef', 'transpose', 'staff_details')}
|
||||
for change in measure.get('attributes', []):
|
||||
previous_state.update({field: copy.deepcopy(value) for field, value in change.items() if field != 'onset'})
|
||||
event_map, tone_ids = {}, {}
|
||||
for hi, harmony in enumerate(measure['harmonies']):
|
||||
harmony['id'] = f'{measure["id"]}-h{hi + 1}'
|
||||
for ni, note in enumerate(n for v in measure['voices'] for n in v['notes']):
|
||||
old = note['id']
|
||||
note['id'] = f'{measure["id"]}-n{ni + 1}'
|
||||
event_map[old] = note['id']
|
||||
for pi, pitch in enumerate(note['pitches']):
|
||||
original = pitch['id']
|
||||
pitch['id'] = f'{note["id"]}-p{pi + 1}'
|
||||
tone_ids[pitch['id']] = original
|
||||
if 'sound_ties' not in pitch:
|
||||
pitch['sound_ties'] = [{'type': action} for field, action in (('tie_from', 'stop'), ('tie_to', 'start')) if pitch.get(field)]
|
||||
expected_ties.update((original, marker['type']) for marker in pitch['sound_ties'])
|
||||
pitch.pop('tie_from', None)
|
||||
pitch.pop('tie_to', None)
|
||||
for voice in measure['voices']:
|
||||
for note in voice['notes']:
|
||||
onset = absolute + fraction(note['onset'])
|
||||
for pitch in note['pitches']:
|
||||
markers = pitch.pop('sound_ties', [])
|
||||
pitch.pop('printed_ties', None)
|
||||
unpitched = pitch.get('unpitched', {})
|
||||
identity = (voice['id'], pitch.get('letter'), alteration(pitch.get('alter', 0)), pitch.get('octave'), unpitched.get('letter'), unpitched.get('octave'), pitch.get('instrument'))
|
||||
for marker in sorted(markers, key=lambda t: t['type'] == 'start'):
|
||||
if marker.get('passes') and (not occurrence.passes or occurrence.passes[-1] not in marker['passes']):
|
||||
continue
|
||||
used_ties.add((tone_ids[pitch['id']], marker['type']))
|
||||
if marker['type'] == 'stop':
|
||||
pending = open_ties.pop(identity, None)
|
||||
if not pending or pending[1] != onset:
|
||||
raise ValueError(f'measure {source[si]["number"]}, passes {occurrence.passes}: tie stop has no adjacent start on the performed path; use time-only for pass-specific ties')
|
||||
old, _ = pending
|
||||
old['tie_to'], pitch['tie_from'] = pitch['id'], old['id']
|
||||
else:
|
||||
if identity in open_ties:
|
||||
raise ValueError(f'measure {source[si]["number"]}: overlapping tie starts')
|
||||
open_ties[identity] = (pitch, onset + fraction(pitch['duration']))
|
||||
if partner := note.get('tremolo', {}).get('partner'):
|
||||
pi = oi + event_measures[partner] - si
|
||||
low, high = sorted((oi, pi))
|
||||
if pi < 0 or pi >= len(order) or any(order[j].measure != si + j - oi for j in range(low, high + 1)):
|
||||
raise ValueError(f'measure {source[si]["number"]}: repeat jump splits a paired tremolo')
|
||||
note['tremolo']['partner'] = event_ids[pi, partner]
|
||||
continued = {}
|
||||
for span in staff.get('tuplets', []):
|
||||
members = [n for v in measure['voices'] for n in v['notes'] if span['id'] in n.get('tuplet_ids', [])]
|
||||
if members:
|
||||
previous = active_tuplets.get(span['id']) if si == previous_index + 1 else None
|
||||
if previous and previous['continued_end'] and span['start'] not in event_map:
|
||||
rendered = previous
|
||||
rendered['end'] = members[-1]['id']
|
||||
rendered['continued_end'] = span['end'] not in event_map
|
||||
else:
|
||||
rendered = {**copy.deepcopy(span), 'id': f'{measure["id"]}-{span["id"]}', 'start': members[0]['id'], 'end': members[-1]['id'], 'continued_start': span['start'] not in event_map, 'continued_end': span['end'] not in event_map}
|
||||
target['tuplets'].append(rendered)
|
||||
identifier = rendered['id']
|
||||
continued[span['id']] = rendered
|
||||
for note in members:
|
||||
note['tuplet_ids'] = [identifier if t == span['id'] else t for t in note['tuplet_ids']]
|
||||
active_tuplets = continued
|
||||
for shift in staff.get('octave_shifts', []):
|
||||
low, high = max(starts[si], fraction(shift['start'])), min(starts[si + 1], fraction(shift['end']))
|
||||
if low < high:
|
||||
target['octave_shifts'].append({**shift, 'id': f'{measure["id"]}-{shift["id"]}', 'start': rational(absolute + low - starts[si]), 'end': rational(absolute + high - starts[si])})
|
||||
target['measures'].append(measure)
|
||||
absolute += fraction(measure['duration'])
|
||||
previous_index = si
|
||||
if open_ties:
|
||||
raise ValueError('tie start has no matching endpoint on the performed path; use time-only for pass-specific ties')
|
||||
if expected_ties != used_ties:
|
||||
raise ValueError('time-only tie refers to a pass that is never performed')
|
||||
return output
|
||||
@@ -123,5 +123,10 @@ def count_json_files(root: Path) -> int:
|
||||
return sum(1 for p in root.rglob('*.json') if p.is_file())
|
||||
|
||||
def validate_generated_tree(root: Path) -> None:
|
||||
audio_cache = {}
|
||||
for path in root.rglob('*.json'):
|
||||
load_json(path)
|
||||
document = json.loads(path.read_text(encoding='utf-8'))
|
||||
if 'playback' in document:
|
||||
from generator.playback import validate_assets
|
||||
validate_assets(document['playback'], root, audio_cache)
|
||||
|
||||
@@ -110,6 +110,9 @@ def transpose_document(document: dict, target_key_name: str, *, octave_shift: in
|
||||
for change in measure.get('attributes', []):
|
||||
if 'key' in change:
|
||||
change['key'] = shift_key(change['key'], steps, semitones)
|
||||
initial = measure.get('initial_attributes', {})
|
||||
if 'key' in initial:
|
||||
initial['key'] = shift_key(initial['key'], steps, semitones)
|
||||
for h in measure.get('harmonies', []):
|
||||
for field in ('root', 'bass'):
|
||||
if field in h:
|
||||
|
||||
Reference in New Issue
Block a user