+28
-103
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user