Files
product-vamp/generator/repeats.py
T
khodowany 80873a0896
ci / checks (push) Has been cancelled
Tone Generation
2026-09-18 01:17:36 -05:00

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