261 lines
13 KiB
Python
261 lines
13 KiB
Python
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
|