+108
-87
@@ -1,92 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from fractions import Fraction
|
||||
|
||||
from generator.model import TYPE_LENGTHS, fraction
|
||||
from generator import harmony
|
||||
from generator.musicxml import load_musicxml
|
||||
from generator.engraving.fonts import Fonts
|
||||
from generator.engraving.notation import ScoreIndex, head_glyph
|
||||
from generator.engraving.types import EngravingError, LayoutDocument
|
||||
|
||||
def same(left, right, location: str) -> None:
|
||||
if left != right:
|
||||
raise ValueError(f'{location}: engraver changed musical content ({left!r} -> {right!r})')
|
||||
|
||||
def event_positions(staff: dict) -> dict:
|
||||
return {event['id']: (mi, vi, ni) for mi, measure in enumerate(staff['measures']) for vi, voice in enumerate(measure['voices']) for ni, event in enumerate(voice['notes'])}
|
||||
|
||||
def tuplet_members(staff: dict, identifier: str) -> list:
|
||||
positions = event_positions(staff)
|
||||
return [positions[event['id']] for measure in staff['measures'] for voice in measure['voices'] for event in voice['notes'] if identifier in event.get('tuplet_ids', [])]
|
||||
|
||||
def audit_roundtrip(source: Path, output: Path) -> None:
|
||||
before = load_musicxml(source)['staves'][0]
|
||||
after = load_musicxml(output)['staves'][0]
|
||||
before_positions, after_positions = event_positions(before), event_positions(after)
|
||||
before_instruments = {i['id']: i for i in before.get('instruments', [])}
|
||||
after_instruments = {i['id']: i for i in after.get('instruments', [])}
|
||||
same(len(before['measures']), len(after['measures']), 'measure count')
|
||||
for original, rendered in zip(before['measures'], after['measures']):
|
||||
location = original['id']
|
||||
for field in ('duration', 'key', 'meter', 'clef', 'transpose', 'attributes'):
|
||||
same(original[field], rendered[field], f'{location} {field}')
|
||||
same({'capo': 0, **original['staff_details']}, {'capo': 0, **rendered['staff_details']}, f'{location} staff details')
|
||||
for side, style in original.get('barlines', {}).items():
|
||||
same(style, rendered.get('barlines', {}).get(side, 'regular'), f'{location} {side} barline')
|
||||
same(original.get('tempos', []), rendered.get('tempos', []), f'{location} tempos')
|
||||
same(original.get('section_label', ''), rendered.get('section_label', ''), f'{location} section label')
|
||||
same(len(original['harmonies']), len(rendered['harmonies']), f'{location} harmony count')
|
||||
for a, b in zip(original['harmonies'], rendered['harmonies']):
|
||||
for field in ('root', 'bass', 'onset'):
|
||||
same(a.get(field), b.get(field), f'{location} harmony {field}')
|
||||
same(harmony.degrees(a), harmony.degrees(b), f'{location} harmony degrees')
|
||||
same(len(original['voices']), len(rendered['voices']), f'{location} voice count')
|
||||
for first, second in zip(original['voices'], rendered['voices']):
|
||||
same(len(first['notes']), len(second['notes']), f'{location} voice {first["id"]} event count')
|
||||
for a, b in zip(first['notes'], second['notes']):
|
||||
event_location = f'{location}, voice {first["id"]}, {a["id"]}'
|
||||
for field in ('kind', 'onset', 'duration', 'written', 'hidden'):
|
||||
same(a[field], b[field], f'{event_location} {field}')
|
||||
for field in ('grace', 'cue', 'measure_rest', 'rest_position', 'lyrics'):
|
||||
same(a.get(field), b.get(field), f'{event_location} {field}')
|
||||
for field in ('stem', 'beams', 'notehead'):
|
||||
if field in a:
|
||||
same(a[field], b.get(field), f'{event_location} explicit {field}')
|
||||
same(len(a['pitches']), len(b['pitches']), f'{event_location} chord tone count')
|
||||
for p, q in zip(a['pitches'], b['pitches']):
|
||||
if p.get('instrument') and 'unpitched' in p:
|
||||
first_instrument = before_instruments[p['instrument']]
|
||||
second_instrument = after_instruments.get(q.get('instrument'), {})
|
||||
same(first_instrument.get('midi', {}).get('midi-unpitched'), second_instrument.get('midi', {}).get('midi-unpitched'), f'{event_location} percussion instrument')
|
||||
for field in ('letter', 'alter', 'octave', 'duration', 'written', 'unpitched', 'tablature', 'notehead', 'cue'):
|
||||
same(p.get(field), q.get(field), f'{event_location} pitch {field}')
|
||||
for field in ('stem', 'print'):
|
||||
if field in p:
|
||||
same(p[field], q.get(field), f'{event_location} pitch {field}')
|
||||
for field in ('tie_from', 'tie_to'):
|
||||
same(bool(p.get(field)), bool(q.get(field)), f'{event_location} {field}')
|
||||
if display := p.get('accidental_display'):
|
||||
other = q.get('accidental_display', {})
|
||||
same(display.get('name'), other.get('name'), f'{event_location} accidental')
|
||||
for field in ('parentheses', 'bracket'):
|
||||
same(display.get(field, 'no'), other.get(field, 'no'), f'{event_location} accidental {field}')
|
||||
for field in ('cautionary', 'editorial'):
|
||||
if field in display:
|
||||
same(display[field], other.get(field, 'no'), f'{event_location} accidental {field}')
|
||||
if tremolo := a.get('tremolo'):
|
||||
other = b.get('tremolo', {})
|
||||
for field in ('type', 'marks'):
|
||||
same(tremolo[field], other.get(field), f'{event_location} tremolo {field}')
|
||||
if 'partner' in tremolo:
|
||||
same(before_positions.get(tremolo['partner']), after_positions.get(other.get('partner')), f'{event_location} tremolo partner')
|
||||
same(len(before.get('tuplets', [])), len(after.get('tuplets', [])), 'tuplet count')
|
||||
for a, b in zip(before.get('tuplets', []), after.get('tuplets', [])):
|
||||
for field in ('note_count', 'space_count', 'depth', 'show_number', 'show_type'):
|
||||
same(a.get(field), b.get(field), f'{a["id"]} {field}')
|
||||
if a['bracket'] != 'auto':
|
||||
same(a['bracket'], b['bracket'], f'{a["id"]} bracket')
|
||||
same(tuplet_members(before, a['id']), tuplet_members(after, b['id']), f'{a["id"]} members')
|
||||
for endpoint in ('start', 'end'):
|
||||
same(before_positions[a[endpoint]], after_positions[b[endpoint]], f'{a["id"]} {endpoint}')
|
||||
for field in ('tuplet_actual', 'tuplet_normal'):
|
||||
if a.get(field, {}).get('type'):
|
||||
same(a[field], b.get(field), f'{a["id"]} {field}')
|
||||
same([{k: v for k, v in s.items() if k != 'id'} for s in before.get('octave_shifts', [])],
|
||||
[{k: v for k, v in s.items() if k != 'id'} for s in after.get('octave_shifts', [])], 'octave spans')
|
||||
def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
|
||||
index = ScoreIndex(document)
|
||||
fragments, primitives = {}, {}
|
||||
previous_end = Fraction()
|
||||
for page in layout.pages:
|
||||
for item in page.primitives:
|
||||
if not item.box.finite():
|
||||
raise EngravingError(f'{item.sources}: non-finite or inverted primitive geometry')
|
||||
if item.box.left < 4 or item.box.top < 4 or item.box.right > page.width - 4 or item.box.bottom > page.height - 4:
|
||||
raise EngravingError(f'{item.sources}, {item.role}: primitive outside page bounds')
|
||||
if item.kind == 'glyph':
|
||||
actual = fonts.glyph_box(item.value, item.size).moved(*item.points[0])
|
||||
if any(abs(a - b) > 0.001 for a, b in zip((actual.left, actual.top, actual.right, actual.bottom), (item.box.left, item.box.top, item.box.right, item.box.bottom))):
|
||||
raise EngravingError(f'{item.sources}: glyph bounds disagree with its actual outline')
|
||||
for source in item.sources:
|
||||
primitives.setdefault(source, []).append(item)
|
||||
for system in page.systems:
|
||||
if system.start != previous_end or system.end <= system.start:
|
||||
raise EngravingError('layout systems do not partition the score timeline')
|
||||
previous_end = system.end
|
||||
baseline = max(p.points[0][1] for p in system.primitives if p.role == 'staff')
|
||||
for anchor in system.anchors:
|
||||
if anchor.fragment.kind == 'tone':
|
||||
expected = index.pitch_position(index.tones[anchor.source][1], anchor.time)
|
||||
if abs(anchor.y - (baseline - expected * layout.style.space / 2)) > 0.01:
|
||||
raise EngravingError(f'{anchor.source}: displayed staff position changed pitch')
|
||||
role = 'tab' if anchor.fragment.kind == 'tone' and index.state(anchor.time)['clef']['sign'] == 'TAB' else 'head' if anchor.fragment.kind == 'tone' else anchor.fragment.kind
|
||||
candidates = [p for p in system.primitives if p.role == role and anchor.source in p.sources and abs(p.points[0][0] - anchor.x) < 0.01]
|
||||
if not candidates:
|
||||
raise EngravingError(f'{anchor.source}: visible anchor has no corresponding drawing primitive')
|
||||
if role == 'head' and candidates[0].kind == 'glyph':
|
||||
event, tone = index.tones[anchor.source]
|
||||
expected_glyph = head_glyph({'type': anchor.fragment.written_type}, tone.get('notehead', event.value.get('notehead')))
|
||||
if candidates[0].value != expected_glyph:
|
||||
raise EngravingError(f'{anchor.source}: notehead changed written duration or shape')
|
||||
for fragment in system.fragments:
|
||||
fragments.setdefault(fragment.source, []).append(fragment)
|
||||
# Staff, ledger, stem, beam and tie contacts are intentional. Distinct
|
||||
# noteheads, accidentals and text in the same lane must remain separate.
|
||||
checked = [p for p in system.primitives if p.role in ('head', 'tab', 'accidental', 'harmony', 'lyric')]
|
||||
for i, item in enumerate(checked):
|
||||
for other in checked[i + 1:]:
|
||||
if set(item.sources) & set(other.sources):
|
||||
continue
|
||||
if item.role != other.role and {item.role, other.role} - {'head', 'tab', 'accidental'}:
|
||||
continue
|
||||
if item.box.overlaps(other.box, 0.1):
|
||||
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')
|
||||
for event in index.events:
|
||||
note = event.value
|
||||
for source in note['pitches'] or [note]:
|
||||
identifier = source['id']
|
||||
pieces = sorted(fragments.get(identifier, []), key=lambda f: f.start)
|
||||
if not pieces:
|
||||
raise EngravingError(f'{identifier}: missing notation fragments')
|
||||
cursor = event.start
|
||||
for fragment in pieces:
|
||||
expected_visible = not note.get('hidden') and note['kind'] != 'spacer' and source.get('print', {}).get('print-object') != 'no'
|
||||
if isinstance(source.get('notehead'), dict) and source['notehead'].get('name') == 'none':
|
||||
expected_visible = False
|
||||
if fragment.visible != expected_visible:
|
||||
raise EngravingError(f'{identifier}: notation visibility changed')
|
||||
if fragment.start != cursor:
|
||||
raise EngravingError(f'{identifier}: fragments overlap or omit metric time')
|
||||
cursor = fragment.end
|
||||
expected = TYPE_LENGTHS[fragment.written_type] * (2 - Fraction(1, 2 ** fragment.dots)) * fragment.ratio
|
||||
if not note.get('grace') and not note.get('measure_rest') and fragment.length() != expected:
|
||||
raise EngravingError(f'{identifier}: written fragment duration is inexact')
|
||||
if fragment.visible and not any(p.role in ('head', 'tab', 'rest', 'slash') for p in primitives.get(identifier, [])):
|
||||
raise EngravingError(f'{identifier}: visible notation has no drawing primitive')
|
||||
if cursor != event.start + fraction(source['duration']):
|
||||
raise EngravingError(f'{identifier}: rendered duration changed')
|
||||
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 not any(p.role == 'tie' for p in primitives.get(identifier, [])):
|
||||
raise EngravingError(f'{identifier}: tie was omitted')
|
||||
if not note.get('hidden'):
|
||||
for verse in note.get('lyrics', []):
|
||||
text = ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision'))
|
||||
identifier = note['id'] + ':lyric:' + verse['number']
|
||||
if text and not any(p.role == 'lyric' and p.value == text for p in primitives.get(identifier, [])):
|
||||
raise EngravingError(f'{identifier}: lyric text was omitted or changed')
|
||||
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', [])):
|
||||
if not any(p.role == 'tempo' for p in primitives.get(measure['id'] + f':tempo:{i}', [])):
|
||||
raise EngravingError(f'{measure["id"]}: tempo was omitted')
|
||||
for span in index.staff.get('tuplets', []):
|
||||
if span.get('show_number', 'actual') != 'none' or span.get('bracket', 'auto') != 'no':
|
||||
if not any(p.role.startswith('tuplet-') for p in primitives.get(span['id'], [])):
|
||||
raise EngravingError(f'{span["id"]}: tuplet was omitted')
|
||||
for span in index.staff.get('octave_shifts', []):
|
||||
if not any(p.role == 'octave-shift' for p in primitives.get(span['id'], [])):
|
||||
raise EngravingError(f'{span["id"]}: octave shift was omitted')
|
||||
|
||||
Reference in New Issue
Block a user