Files
product-vamp/generator/engraving/audit.py
T
2026-09-26 10:12:51 -05:00

129 lines
9.0 KiB
Python

from __future__ import annotations
import fractions
import generator.engraving.fonts
import generator.engraving.notation
import generator.engraving.types
import generator.harmony
import generator.model
def audit_primitives(primitives, width, height, fonts):
for item in primitives:
if not item.box.finite():
raise generator.engraving.types.EngravingError(f'{item.sources}: non-finite or inverted primitive geometry')
if item.box.left < 4 or item.box.top < 4 or item.box.right > width - 4 or item.box.bottom > height - 4:
raise generator.engraving.types.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 generator.engraving.types.EngravingError(f'{item.sources}: glyph bounds disagree with its actual outline')
def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocument, fonts: generator.engraving.fonts.Fonts) -> None:
index = generator.engraving.notation.ScoreIndex(document)
fragments, primitives = {}, {}
previous_end = fractions.Fraction()
for page in layout.pages:
if len(page.systems) != 2 and (page is not layout.pages[-1] or len(page.systems) != 1):
raise generator.engraving.types.EngravingError('pages must contain two music systems, except an odd final system')
if len(page.systems) == 2 and page.systems[0].box.bottom + layout.style.system_gap > page.systems[1].box.top + 0.01:
raise generator.engraving.types.EngravingError('music systems overlap their page rows')
audit_primitives(page.primitives, page.width, page.height, fonts)
for item in page.primitives:
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 generator.engraving.types.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 * system.scale / 2)) > 0.01:
raise generator.engraving.types.EngravingError(f'{anchor.source}: displayed staff position changed pitch')
role = '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 generator.engraving.types.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 = generator.engraving.notation.head_glyph({'type': anchor.fragment.written_type}, tone.get('notehead', event.value.get('notehead')))
if candidates[0].value != expected_glyph:
raise generator.engraving.types.EngravingError(f'{anchor.source}: notehead changed written duration or shape')
if index.staff.get('tablature') and anchor.fragment.kind == 'tone':
tone = index.tones[anchor.source][1]
tab = tone['tablature']
tab_baseline = max(p.points[0][1] for p in system.primitives if p.role == 'tab-staff')
expected_y = tab_baseline + (4 - (6 - tab['string']) * layout.style.tab_space) * system.scale
expected_x = min(a.x for a in system.anchors if a.event == anchor.event and a.time == anchor.time)
tabs = [p for p in system.primitives if p.role == 'tab' and anchor.source in p.sources]
if len(tabs) != 1 or tabs[0].value != str(tab['fret']) or abs(tabs[0].points[0][1] - expected_y) > 0.01 or abs(tabs[0].points[0][0] - expected_x) > 0.01:
raise generator.engraving.types.EngravingError(f'{anchor.source}: tablature changed position or rhythmic alignment')
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) and (item.role != 'tab' or other.role != 'tab'):
continue
if item.role != other.role and {item.role, other.role} - {'head', 'tab', 'accidental'}:
continue
if item.box.overlaps(other.box, 0.1 * system.scale):
raise generator.engraving.types.EngravingError(f'{item.sources} and {other.sources}: colliding {item.role}/{other.role}')
if previous_end != index.end:
raise generator.engraving.types.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 generator.engraving.types.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 generator.engraving.types.EngravingError(f'{identifier}: notation visibility changed')
if fragment.start != cursor:
raise generator.engraving.types.EngravingError(f'{identifier}: fragments overlap or omit metric time')
cursor = fragment.end
expected = generator.model.TYPE_LENGTHS[fragment.written_type] * (2 - fractions.Fraction(1, 2 ** fragment.dots)) * fragment.ratio
if not note.get('grace') and not note.get('measure_rest') and fragment.length() != expected:
raise generator.engraving.types.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 generator.engraving.types.EngravingError(f'{identifier}: visible notation has no drawing primitive')
if cursor != event.start + generator.model.fraction(source['duration']):
raise generator.engraving.types.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 generator.engraving.types.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 generator.engraving.types.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 generator.engraving.types.EngravingError(f'{identifier}: lyric text was omitted or changed')
for _, _, measure in index.measures:
for value in measure['harmonies']:
label = generator.harmony.symbol(value)
if not any(p.role == 'harmony' and p.value == label for p in primitives.get(value['id'], [])):
raise generator.engraving.types.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 generator.engraving.types.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 generator.engraving.types.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 generator.engraving.types.EngravingError(f'{span["id"]}: octave shift was omitted')