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 ENGINE_VERSION = '4.7.4' 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: image.load() if image.format != 'PNG' or image.size != (PAGE_WIDTH, PAGE_HEIGHT): raise EngravingError(f'{path.name}: expected a {PAGE_WIDTH}x{PAGE_HEIGHT} PNG page') white = Image.new('RGBA', image.size, 'white') white.alpha_composite(image.convert('RGBA')) ink = ImageChops.difference(white.convert('RGB'), Image.new('RGB', image.size, 'white')).convert('L').point(lambda x: 255 if x > 32 else 0) bounds = ink.getbbox() if not bounds: raise EngravingError(f'{path.name}: engraver produced a blank page') if bounds[0] < 4 or bounds[1] < 4 or bounds[2] > PAGE_WIDTH - 4 or bounds[3] > PAGE_HEIGHT - 4: raise EngravingError(f'{path.name}: notation reaches the page edge; refusing potentially clipped output') 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