Engraver
ci / checks (push) Successful in 45m44s

This commit is contained in:
khodowany
2026-09-16 18:17:42 -05:00
parent 1d1bd3e871
commit 7a83b5435f
29 changed files with 13678 additions and 322 deletions
+28 -103
View File
@@ -2,77 +2,20 @@ from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
from xml.etree import ElementTree
from PIL import Image, ImageChops
from generator.model import ACCIDENTALS, alteration, fraction, validate_document
from generator.musicxml_writer import write_musicxml
from generator.engraving.audit import audit_roundtrip
from generator.engraving.fonts import Fonts
from generator.engraving.layout import layout_score
from generator.engraving.raster import rasterize_page
from generator.engraving.types import EngravingError, Style
ENGINE_VERSION = '4.7.4'
ENGINE_NAME = 'Vamp Python'
ENGINE_VERSION = '1.0.0'
PAGE_WIDTH = 976
PAGE_HEIGHT = 484
STYLE = Path(__file__).with_name('petaluma.mss')
class EngravingError(ValueError):
pass
def qualify(document: dict) -> None:
"""Keep model support separate from the pinned engine's qualified surface."""
validate_document(document)
staff = document['staves'][0]
if len({v['id'] for m in staff['measures'] for v in m['voices']} - {'voice-arranged'}) > 4:
raise EngravingError('MuseScore 4.7.4 supports at most four voices on one staff')
event_measures = {n['id']: m['id'] for m in staff['measures'] for v in m['voices'] for n in v['notes']}
for span in staff.get('tuplets', []):
if event_measures[span['start']] != event_measures[span['end']]:
raise EngravingError(f'{span["id"]}: cross-bar tuplet engraving is not qualified')
if span.get('show_type', 'none') != 'none' or span.get('line_shape', 'straight') != 'straight':
raise EngravingError(f'{span["id"]}: this tuplet appearance is not qualified')
for measure in staff['measures']:
for event in (n for v in measure['voices'] for n in v['notes']):
location = f'{measure["id"]}, voice {event.get("voice")}, {event["id"]}'
if event['written']['type'] == 'maxima' or event['written'].get('dots', 0) > 4:
raise EngravingError(f'{location}: written duration exceeds the qualified engraver range')
if event.get('stem') == 'double':
raise EngravingError(f'{location}: double-stem engraving is not qualified')
for pitch in event['pitches']:
if pitch['written'] != event['written'] or pitch['duration'] != event['duration']:
raise EngravingError(f'{location}: independently sustained chord tones need separate voices for MuseScore engraving')
if 'alter' in pitch and alteration(pitch['alter']) not in ACCIDENTALS.values():
raise EngravingError(f'{location}: this microtonal alteration is not qualified for engraving')
if pitch.get('accidental_display', {}).get('name') == 'other':
raise EngravingError(f'{location}: custom accidental glyph is not qualified')
def isolated_environment(root: Path, fonts: Path) -> dict[str, str]:
env = dict(os.environ)
for name, path in (('HOME', root / 'home'), ('XDG_CONFIG_HOME', root / 'config'), ('XDG_DATA_HOME', root / 'data'), ('XDG_CACHE_HOME', root / 'cache'), ('XDG_RUNTIME_DIR', root / 'runtime')):
path.mkdir(mode=0o700, parents=True, exist_ok=True)
env[name] = str(path)
config = ElementTree.Element('fontconfig')
ElementTree.SubElement(config, 'include', {'ignore_missing': 'yes'}).text = '/etc/fonts/fonts.conf'
ElementTree.SubElement(config, 'dir').text = str(fonts.resolve())
ElementTree.SubElement(config, 'cachedir').text = str(root / 'font-cache')
fontconfig = root / 'fonts.conf'
ElementTree.ElementTree(config).write(fontconfig, encoding='utf-8', xml_declaration=True)
env.update({'QT_QPA_PLATFORM': 'offscreen', 'QT_QPA_PLATFORMTHEME': '', 'FONTCONFIG_FILE': str(fontconfig), 'QT_STYLE_OVERRIDE': 'Fusion'})
return env
def run_engine(executable: str, arguments: list[str], env: dict) -> str:
try:
result = subprocess.run([executable, '-platform', 'offscreen', *arguments], env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=180, check=False)
except (OSError, subprocess.TimeoutExpired) as error:
raise EngravingError(f'MuseScore export failed: {error}') from error
if result.returncode:
raise EngravingError(f'MuseScore export exited {result.returncode}: {result.stdout[-4000:]}')
return result.stdout
def check_page(path: Path) -> None:
with Image.open(path) as image:
@@ -92,43 +35,25 @@ def engrave_tree(root: Path, fonts: Path) -> None:
paths = sorted(root.rglob('*.json'))
if not paths:
return
executable = os.environ.get('VAMP_MUSESCORE') or shutil.which('mscore') or shutil.which('musescore')
if not executable:
raise EngravingError(f'MuseScore Studio {ENGINE_VERSION} is required at build time (set VAMP_MUSESCORE)')
with tempfile.TemporaryDirectory(prefix='vamp-engraving-') as temporary:
work = Path(temporary)
env = isolated_environment(work, fonts)
version = run_engine(executable, ['--version'], env)
if not re.search(r'\b4\.7\.4\b', version):
raise EngravingError(f'expected MuseScore Studio {ENGINE_VERSION}, received {version.strip()!r}')
for index, path in enumerate(paths):
document = json.loads(path.read_text(encoding='utf-8'))
try:
qualify(document)
source = work / f'score-{index}.musicxml'
write_musicxml(source, document, engine_voices=True)
output = work / f'score-{index}.png'
audit = work / f'audit-{index}.musicxml'
job = work / 'job.json'
job.write_text(json.dumps([{'in': str(source), 'out': [str(output), str(audit)]}]), encoding='utf-8')
run_engine(executable, ['-S', str(STYLE.resolve()), '-r', '96', '-j', str(job)], env)
audit_roundtrip(source, audit)
pages = sorted(work.glob(f'score-{index}-*.png'), key=lambda p: int(p.stem.rsplit('-', 1)[1]))
if not pages:
raise EngravingError('engraver produced no pages')
destination = path.with_suffix('.pages')
if destination.exists():
shutil.rmtree(destination)
destination.mkdir()
entries = []
for number, page in enumerate(pages, 1):
check_page(page)
name = f'{number:04}.png'
target = destination / name
shutil.copyfile(page, target)
entries.append({'path': destination.name + '/' + name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
music = {k: v for k, v in document.items() if k != 'schema_version'}
bundle = {'schema_version': 3, 'music': music, 'presentation': {'engine': 'MuseScore Studio', 'engine_version': ENGINE_VERSION, 'style_sha256': hashlib.sha256(STYLE.read_bytes()).hexdigest(), 'width': PAGE_WIDTH, 'height': PAGE_HEIGHT, 'pages': entries}}
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
except ValueError as error:
raise EngravingError(f'{path.relative_to(root)}: {error}') from error
family = Fonts(fonts)
style = Style()
for path in paths:
document = json.loads(path.read_text(encoding='utf-8'))
try:
layout = layout_score(document, style, family)
destination = path.with_suffix('.pages')
if destination.exists():
shutil.rmtree(destination)
destination.mkdir()
entries = []
for page in layout.pages:
name = f'{page.number:04}.png'
target = destination / name
rasterize_page(page, family).save(target, format='PNG')
check_page(target)
entries.append({'path': destination.name + '/' + name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
music = {k: v for k, v in document.items() if k != 'schema_version'}
bundle = {'schema_version': 3, 'music': music, 'presentation': {'engine': ENGINE_NAME, 'engine_version': ENGINE_VERSION, 'style_sha256': layout.fingerprint, 'width': PAGE_WIDTH, 'height': PAGE_HEIGHT, 'pages': entries}}
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
except ValueError as error:
raise EngravingError(f'{path.relative_to(root)}: {error}') from error