@@ -90,8 +90,7 @@ 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')
|
||||
from generator.repeats import expand_document
|
||||
document = concert_document(expand_document(document))
|
||||
document = concert_document(document)
|
||||
staff = source_staff(document)
|
||||
source = staff['measures']
|
||||
sections = full_arrangement_sections(source, part_type)
|
||||
|
||||
@@ -58,22 +58,6 @@ 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]:
|
||||
@@ -101,7 +85,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') or source.get('printed_ties')) and any(f.visible for f in pieces):
|
||||
if identifier in index.tones and source.get('tie_to') 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'):
|
||||
@@ -113,8 +97,6 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
|
||||
for _, _, measure in index.measures:
|
||||
for value in measure['harmonies']:
|
||||
label = harmony.symbol(value)
|
||||
if value.get('chord_optional'):
|
||||
label = '(' + label + ')'
|
||||
if not any(p.role == 'harmony' and p.value == label for p in primitives.get(value['id'], [])):
|
||||
raise EngravingError(f'{value["id"]}: harmony was omitted')
|
||||
for i, _ in enumerate(measure.get('tempos', [])):
|
||||
|
||||
@@ -10,7 +10,6 @@ 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
|
||||
|
||||
@@ -52,8 +51,6 @@ def column(index: ScoreIndex, time: Fraction, start: Fraction, end: Fraction, fo
|
||||
for item in measure['harmonies']:
|
||||
if measure_start + fraction(item['onset']) == time:
|
||||
label = harmony.symbol(item)
|
||||
if item.get('chord_optional'):
|
||||
label = '(' + label + ')'
|
||||
items.append(fonts.text(label, 0, 0, 21, 'harmony', (item['id'],)))
|
||||
for i, value in enumerate(measure.get('tempos', [])):
|
||||
if measure_start + fraction(value['onset']) == time:
|
||||
@@ -71,12 +68,6 @@ 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 = []
|
||||
@@ -88,18 +79,15 @@ 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(draw_boundary(previous, m, 0, -style.space * (index.state(time)['staff_details']['lines'] - 1), fonts, style))
|
||||
leading.extend(barline(previous.get('barlines', {}).get('right', 'regular'), 0, -style.space * (index.state(time)['staff_details']['lines'] - 1), previous['id']))
|
||||
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])
|
||||
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))
|
||||
leading.extend(p.moved(12, 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
|
||||
@@ -121,18 +109,13 @@ 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:
|
||||
@@ -151,11 +134,10 @@ 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
|
||||
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 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 start in index.boundaries:
|
||||
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']))
|
||||
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))
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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,20 +101,6 @@ 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:
|
||||
|
||||
+90
-28
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from generator.model import NATURAL, alteration, parse_pitch_name, pitch_name
|
||||
import re
|
||||
|
||||
from generator.model import NATURAL, alteration, parse_pitch_name, pitch_name, rational
|
||||
|
||||
# Semantic degrees are independent of guitar voicing omissions.
|
||||
KINDS = {
|
||||
@@ -20,11 +22,27 @@ for family, base in (('dominant', 'dominant'), ('major', 'major-seventh'), ('min
|
||||
for suffix, highest in (('ninth', 9), ('11th', 11), ('13th', 13)):
|
||||
KINDS[f'{family}-{suffix}'] = {**KINDS[base], **{i: 0 for i in range(9, highest + 1, 2)}}
|
||||
|
||||
SUFFIXES = {'major': '', 'minor': '-', 'augmented': '+', 'diminished': 'dim', 'dominant': '7', 'major-seventh': 'maj7', 'minor-seventh': '-7', 'diminished-seventh': 'dim7', 'augmented-seventh': '+7', 'augmented-major-seventh': '+maj7', 'half-diminished': '-7b5', 'major-minor': '-maj7', 'major-sixth': '6', 'minor-sixth': '-6', 'suspended-second': 'sus2', 'suspended-fourth': 'sus4', 'power': '5', 'pedal': '', 'other': ''}
|
||||
for family, prefix in (('dominant', ''), ('major', 'maj'), ('minor', '-')):
|
||||
SUFFIXES = {'major': 'M', 'minor': 'm', 'augmented': 'aug', 'diminished': 'dim', 'dominant': '7', 'major-seventh': 'M7', 'minor-seventh': 'm7', 'diminished-seventh': 'dim7', 'augmented-seventh': 'aug7', 'augmented-major-seventh': 'augM7', 'half-diminished': 'm7b5', 'major-minor': 'mM7', 'major-sixth': 'M6', 'minor-sixth': 'm6', 'suspended-second': 'sus2', 'suspended-fourth': 'sus', 'power': '5', 'pedal': 'pedal', 'other': ''}
|
||||
for family, prefix in (('dominant', ''), ('major', 'M'), ('minor', 'm')):
|
||||
for suffix, number in (('ninth', '9'), ('11th', '11'), ('13th', '13')):
|
||||
SUFFIXES[f'{family}-{suffix}'] = prefix + number
|
||||
|
||||
SPELLINGS = {suffix: kind for kind, suffix in SUFFIXES.items() if kind != 'other'}
|
||||
SPELLINGS.update({'': 'major', 'maj': 'major', 'min': 'minor', '-': 'minor', '+': 'augmented', 'o': 'diminished', 'o7': 'diminished-seventh', '6': 'major-sixth', 'sus4': 'suspended-fourth', 'mM': 'major-minor', 'mMaj7': 'major-minor', 'mmaj7': 'major-minor', '-maj7': 'major-minor', '-7b5': 'half-diminished', '+7': 'augmented-seventh', '+maj7': 'augmented-major-seventh'})
|
||||
for kind, suffix in SUFFIXES.items():
|
||||
if suffix.startswith('M'):
|
||||
SPELLINGS['maj' + suffix[1:]] = kind
|
||||
if suffix.startswith('m') and not suffix.startswith('mM'):
|
||||
SPELLINGS['-' + suffix[1:]] = kind
|
||||
SPELLINGS['min' + suffix[1:]] = kind
|
||||
|
||||
EXTENSIONS = {f'mM{number}': ('major-minor', number) for number in (9, 11, 13)}
|
||||
EXTENSIONS.update({f'{number}sus{suffix}': (kind, number) for number in (7, 9, 11, 13) for suffix, kind in (('', 'suspended-fourth'), ('2', 'suspended-second'), ('4', 'suspended-fourth'))})
|
||||
|
||||
def extended_chord(kind: str, highest: int) -> dict:
|
||||
additions = [{'value': degree, 'alter': -1 if degree == 7 else 0, 'type': 'add'} for degree in range(7, highest + 1, 2) if degree not in KINDS[kind]]
|
||||
return {'kind': kind, 'degrees': additions}
|
||||
|
||||
def degrees(harmony: dict) -> dict[int, int]:
|
||||
kind = harmony['kind']
|
||||
if kind not in KINDS:
|
||||
@@ -50,7 +68,7 @@ def degrees(harmony: dict) -> dict[int, int]:
|
||||
raise ValueError(f'unsupported degree operation: {operation}')
|
||||
if kind != 'none' and not result:
|
||||
if kind == 'other':
|
||||
raise ValueError("harmony kind 'other' requires explicit added degrees; use a standard <kind> or add <degree> elements; display text alone does not define pitches")
|
||||
raise ValueError("harmony kind 'other' requires explicit degrees or a recognized chord symbol")
|
||||
raise ValueError('harmony contains no pitch information')
|
||||
return result
|
||||
|
||||
@@ -67,43 +85,87 @@ def pitch_classes(harmony: dict) -> tuple[int, ...]:
|
||||
values.append((NATURAL[bass['letter']] + alteration(bass['alter'])) % 12)
|
||||
return tuple(dict.fromkeys(values))
|
||||
|
||||
def chord_suffix(harmony: dict) -> str:
|
||||
values = degrees(harmony)
|
||||
kind = harmony['kind']
|
||||
if values == KINDS[kind]:
|
||||
return SUFFIXES.get(kind, kind)
|
||||
for name, suffix in SUFFIXES.items():
|
||||
if name != 'other' and values == KINDS[name]:
|
||||
return suffix
|
||||
for suffix, (name, highest) in EXTENSIONS.items():
|
||||
if values == degrees(extended_chord(name, highest)):
|
||||
return suffix
|
||||
suffix = SUFFIXES.get(kind, kind)
|
||||
for change in sorted(harmony.get('degrees', []), key=lambda change: change['value']):
|
||||
amount = alteration(change['alter'])
|
||||
sign = '#' * max(int(amount), 0) + 'b' * max(int(-amount), 0) if amount.denominator == 1 else f'({amount})'
|
||||
suffix += ('no' if change['type'] == 'subtract' else ('add' if change['type'] == 'add' and not sign else '') + sign) + str(change['value'])
|
||||
return suffix
|
||||
|
||||
def symbol(harmony: dict) -> str:
|
||||
if harmony['kind'] == 'none':
|
||||
return 'N.C.'
|
||||
suffix = harmony.get('kind_text', SUFFIXES.get(harmony['kind'], harmony['kind']))
|
||||
if 'kind_text' not in harmony:
|
||||
for d in harmony.get('degrees', []):
|
||||
amount = alteration(d['alter'])
|
||||
sign = '#' * max(int(amount), 0) + 'b' * max(int(-amount), 0) if amount.denominator == 1 else f'({amount})'
|
||||
suffix += ('no' if d['type'] == 'subtract' else 'add' if d['type'] == 'add' and not sign else '') + sign + str(d['value'])
|
||||
suffix = chord_suffix(harmony)
|
||||
result = pitch_name(harmony['root']) + suffix
|
||||
if bass := harmony.get('bass'):
|
||||
result += '/' + pitch_name(bass)
|
||||
return result
|
||||
return '(' + result + ')' if harmony.get('parenthesized') else result
|
||||
|
||||
def parse_symbol(text: str) -> dict:
|
||||
import re
|
||||
match = re.fullmatch(r'([A-Ga-g][#b]*)([^/]*)(?:/([A-Ga-g][#b]*))?', text.strip())
|
||||
text = text.strip()
|
||||
parenthesized = text.startswith('(') and text.endswith(')')
|
||||
content = text[1:-1] if parenthesized else text
|
||||
match = re.fullmatch(r'([A-Ga-g][#b]*)([^/()]*)(?:/([A-Ga-g][#b]*))?', content)
|
||||
if not match:
|
||||
raise ValueError(f'invalid chord symbol: {text!r}')
|
||||
root, suffix, bass = match.groups()
|
||||
aliases = {'m': 'minor', 'min': 'minor', 'maj': 'major', 'M': 'major', 'm7': 'minor-seventh', 'm6': 'minor-sixth', 'm7b5': 'half-diminished', 'o': 'diminished', 'o7': 'diminished-seventh', 'aug': 'augmented', 'sus': 'suspended-fourth'}
|
||||
aliases.update({v: k for k, v in SUFFIXES.items() if k not in ('other', 'pedal')})
|
||||
core = suffix
|
||||
changes = []
|
||||
while core not in aliases:
|
||||
m = re.search(r'(add|no)?([b#]?)(\d+)$', core)
|
||||
if not m:
|
||||
core = next(core for core in sorted(SPELLINGS.keys() | EXTENSIONS.keys(), key=lambda value: (-len(value), value)) if suffix.startswith(core))
|
||||
result = extended_chord(*EXTENSIONS[core]) if core in EXTENSIONS else {'kind': SPELLINGS[core], 'degrees': []}
|
||||
remainder = suffix[len(core):]
|
||||
changed = set()
|
||||
while remainder:
|
||||
modifier = re.match(r'(add|no)?(b+|#+)?([1-9][0-9]*)', remainder)
|
||||
if not modifier or not (modifier[1] or modifier[2]):
|
||||
raise ValueError(f'unsupported chord spelling: {text!r}')
|
||||
op, accidental, value = m.groups()
|
||||
changes.insert(0, {'value': int(value), 'alter': 1 if accidental == '#' else -1 if accidental == 'b' else 0, 'type': 'subtract' if op == 'no' else 'add'})
|
||||
core = core[:m.start()]
|
||||
kind = aliases[core]
|
||||
for d in changes:
|
||||
if d['type'] == 'add' and d['value'] in KINDS[kind]:
|
||||
d['type'] = 'alter'
|
||||
result = {'root': parse_pitch_name(root), 'kind': kind, 'degrees': changes, 'kind_text': suffix}
|
||||
operation, accidental, value = modifier.groups()
|
||||
accidental = accidental or ''
|
||||
degree = int(value)
|
||||
if degree in changed or operation == 'no' and accidental:
|
||||
raise ValueError(f'contradictory chord degrees: {text!r}')
|
||||
changed.add(degree)
|
||||
operation = 'subtract' if operation == 'no' else 'alter' if degree in degrees(result) and operation != 'add' else 'add'
|
||||
result['degrees'].append({'value': degree, 'alter': accidental.count('#') - accidental.count('b'), 'type': operation})
|
||||
remainder = remainder[modifier.end():]
|
||||
result['root'] = parse_pitch_name(root)
|
||||
for change in result['degrees']:
|
||||
change['alter'] = rational(change['alter'])
|
||||
if bass:
|
||||
result['bass'] = parse_pitch_name(bass)
|
||||
if parenthesized:
|
||||
result['parenthesized'] = True
|
||||
degrees(result)
|
||||
return result
|
||||
|
||||
def normalize_text(harmony: dict, text: str) -> None:
|
||||
text = text.strip()
|
||||
parenthesized = text.startswith('(') and text.endswith(')')
|
||||
content = text[1:-1] if parenthesized else text
|
||||
try:
|
||||
displayed = parse_symbol(pitch_name(harmony['root']) + content)
|
||||
except ValueError:
|
||||
displayed = parse_symbol(content)
|
||||
text_only = harmony['kind'] == 'other' and not harmony['degrees']
|
||||
if displayed['root'] != harmony['root']:
|
||||
if not text_only or harmony['root'] != parse_pitch_name('C'):
|
||||
raise ValueError(f'contradictory harmony root in {text!r}')
|
||||
if 'bass' in displayed and 'bass' in harmony and displayed['bass'] != harmony['bass']:
|
||||
raise ValueError(f'contradictory harmony bass in {text!r}')
|
||||
if text_only:
|
||||
harmony.update(displayed)
|
||||
elif degrees(displayed) not in (degrees(harmony), KINDS[harmony['kind']]):
|
||||
raise ValueError(f'contradictory harmony text {text!r} and structured chord degrees')
|
||||
if 'bass' in displayed:
|
||||
harmony['bass'] = displayed['bass']
|
||||
if parenthesized:
|
||||
harmony.update(parenthesized=True, chord_optional=True)
|
||||
|
||||
+4
-3
@@ -146,9 +146,6 @@ 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):
|
||||
@@ -159,6 +156,8 @@ def validate_document(document: dict) -> None:
|
||||
ownership: dict[str, str] = {}
|
||||
absolute = Fraction()
|
||||
for measure in staves[0]['measures']:
|
||||
if {'repeats', 'endings', 'initial_attributes'} & measure.keys():
|
||||
raise ValueError('repeat notation is unsupported; regenerate scores from music written out in reading order')
|
||||
length = fraction(measure['duration'])
|
||||
if length <= 0:
|
||||
raise ValueError('measure duration must be positive')
|
||||
@@ -167,6 +166,8 @@ def validate_document(document: dict) -> None:
|
||||
raise ValueError('measure requires uniquely identified voice lanes')
|
||||
previous_ends = {v['id']: Fraction() for v in voices}
|
||||
for voice, note in ((v, n) for v in voices for n in v['notes']):
|
||||
if any({'sound_ties', 'printed_ties'} & pitch.keys() for pitch in note['pitches']):
|
||||
raise ValueError('pass-specific tie metadata is unsupported; regenerate scores')
|
||||
if not note['id'] or note['id'] in ids:
|
||||
raise ValueError(f'duplicate event ID: {note["id"]}')
|
||||
ids.add(note['id'])
|
||||
|
||||
+16
-55
@@ -38,7 +38,6 @@ 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:
|
||||
@@ -178,8 +177,6 @@ class Importer:
|
||||
self.harmony_sequence += 1
|
||||
result = {'id': f'harmony-{self.harmony_sequence}', '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'):
|
||||
item = node.find(tag)
|
||||
if item is None:
|
||||
@@ -204,17 +201,9 @@ class Importer:
|
||||
elif operation == 'subtract' and degree_alter:
|
||||
self.fail('cannot subtract a degree different from the one in the chord kind')
|
||||
result['degrees'].append({'value': value, 'alter': rational(degree_alter), 'type': operation})
|
||||
if kind.get('text') and result['kind'] != 'none':
|
||||
harmony.normalize_text(result, kind.get('text'))
|
||||
chord_degrees = harmony.degrees(result)
|
||||
if 'kind_text' in result and result['kind'] != 'none':
|
||||
label = 'C' + result['kind_text']
|
||||
try:
|
||||
displayed = harmony.degrees(harmony.parse_symbol(label))
|
||||
except ValueError:
|
||||
self.fail(f'unmapped harmony display text {label!r}; use a standard chord kind/degree spelling')
|
||||
if displayed != chord_degrees:
|
||||
if displayed != harmony.KINDS[result['kind']]:
|
||||
self.fail(f'contradictory harmony text {label!r} and structured chord degrees')
|
||||
result.pop('kind_text')
|
||||
if node.find('inversion') is not None:
|
||||
inversion = self.number(node.findtext('inversion'), integer=True)
|
||||
result['inversion'] = inversion
|
||||
@@ -235,6 +224,8 @@ class Importer:
|
||||
self.children(node, {'direction-type', 'offset', 'staff', 'voice', 'sound'})
|
||||
onset = self.offset(node, cursor)
|
||||
for group in node.findall('direction-type'):
|
||||
if group.find('segno') is not None or group.find('coda') is not None:
|
||||
self.fail('navigation jumps are unsupported; write the music out in reading order')
|
||||
self.children(group, {'words', 'octave-shift', 'rehearsal', 'metronome'})
|
||||
for item in group:
|
||||
if item.tag == 'words':
|
||||
@@ -275,6 +266,8 @@ class Importer:
|
||||
if sound is not None:
|
||||
self.sound(sound, onset, measure)
|
||||
def sound(self, node, onset: Fraction, measure: dict) -> None:
|
||||
if {'dacapo', 'dalsegno', 'segno', 'coda', 'tocoda', 'fine', 'forward-repeat', 'time-only'} & node.attrib.keys():
|
||||
self.fail('navigation jumps are unsupported; write the music out in reading order')
|
||||
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', [])):
|
||||
@@ -375,8 +368,8 @@ class Importer:
|
||||
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')
|
||||
if 'time-only' in tie.attrib:
|
||||
self.fail('pass-specific ties are unsupported; write the music out in reading order')
|
||||
notations = node.find('notations')
|
||||
visual_ties = set()
|
||||
if notations is not None:
|
||||
@@ -387,22 +380,12 @@ class Importer:
|
||||
ties |= visual_ties
|
||||
if ties - {'start', 'stop'}:
|
||||
self.fail('unsupported tie notation')
|
||||
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:
|
||||
if '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 not self.has_repeats and 'start' in ties:
|
||||
if 'start' in ties:
|
||||
if value in self.open_ties:
|
||||
self.fail('ambiguous duplicate tie at the same pitch')
|
||||
self.open_ties[value] = p
|
||||
@@ -512,7 +495,6 @@ 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')
|
||||
@@ -564,8 +546,6 @@ 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:
|
||||
@@ -599,11 +579,13 @@ class Importer:
|
||||
extent = max(extent, cursor)
|
||||
previous = None
|
||||
elif item.tag == 'barline':
|
||||
if item.find('repeat') is not None or item.find('ending') is not None:
|
||||
self.fail('repeats and numbered endings are unsupported; write the music out in reading order')
|
||||
self.attrs(item, {'location'})
|
||||
self.children(item, {'bar-style', 'repeat', 'ending'})
|
||||
self.children(item, {'bar-style'})
|
||||
side = item.get('location', 'right')
|
||||
if side not in ('left', 'right'):
|
||||
self.fail('barlines and repeats must be at measure boundaries')
|
||||
self.fail('barlines 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:
|
||||
@@ -614,26 +596,6 @@ class Importer:
|
||||
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
|
||||
@@ -653,6 +615,8 @@ class Importer:
|
||||
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
|
||||
matches[0]['parenthesized'] = True
|
||||
matches[0]['symbol'] = harmony.symbol(matches[0])
|
||||
used.add(onset)
|
||||
measures.append(measure)
|
||||
self.absolute += cursor
|
||||
@@ -667,9 +631,6 @@ 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
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ def write_harmony(parent, value: dict, divisions: int) -> None:
|
||||
item = element(node, 'root')
|
||||
element(item, 'root-step', root['letter'].upper())
|
||||
element(item, 'root-alter', decimal(root['alter']))
|
||||
element(node, 'kind', value['kind'], text=value.get('kind_text'))
|
||||
element(node, 'kind', value['kind'], text=harmony.symbol(value) if value.get('parenthesized') else None)
|
||||
if 'inversion' in value:
|
||||
element(node, 'inversion', value['inversion'])
|
||||
if 'bass' in value:
|
||||
@@ -138,8 +138,6 @@ 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)
|
||||
@@ -172,8 +170,6 @@ 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)
|
||||
@@ -207,14 +203,10 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di
|
||||
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')):
|
||||
if location not in measure.get('barlines', {}):
|
||||
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)
|
||||
|
||||
@@ -10,7 +10,6 @@ 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'
|
||||
@@ -34,7 +33,6 @@ def sounding_pitch(pitch: dict, state: dict, guitar: bool) -> Fraction:
|
||||
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']:
|
||||
|
||||
@@ -22,6 +22,8 @@ def practice_document(document: dict) -> dict:
|
||||
section_label=str(measure.get("section_label", "")),
|
||||
)
|
||||
output_measure['voices'] = voices
|
||||
for chord in output_measure['harmonies']:
|
||||
chord.pop('chord_optional', None)
|
||||
measures.append(output_measure)
|
||||
tuplets = reidentify(measures, staff.get('tuplets', []))
|
||||
result = make_arranged_document(
|
||||
|
||||
@@ -86,8 +86,6 @@ 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}
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
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
|
||||
@@ -90,9 +90,6 @@ 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