@@ -0,0 +1,134 @@
|
||||
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
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from generator import harmony
|
||||
from generator.musicxml import load_musicxml
|
||||
|
||||
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')
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<museScore version="4.70">
|
||||
<Style>
|
||||
<pageWidth>10.1666666667</pageWidth>
|
||||
<pageHeight>5.0416666667</pageHeight>
|
||||
<pagePrintableWidth>9.5833333333</pagePrintableWidth>
|
||||
<pageEvenLeftMargin>0.2916666667</pageEvenLeftMargin>
|
||||
<pageOddLeftMargin>0.2916666667</pageOddLeftMargin>
|
||||
<pageEvenTopMargin>0.1875</pageEvenTopMargin>
|
||||
<pageOddTopMargin>0.1875</pageOddTopMargin>
|
||||
<pageEvenBottomMargin>0.1875</pageEvenBottomMargin>
|
||||
<pageOddBottomMargin>0.1875</pageOddBottomMargin>
|
||||
<pageTwosided>0</pageTwosided>
|
||||
<Spatium>3.175</Spatium>
|
||||
<musicalSymbolFont>Petaluma</musicalSymbolFont>
|
||||
<musicalTextFont>Petaluma Text</musicalTextFont>
|
||||
<defaultFontFace>Petaluma Script</defaultFontFace>
|
||||
<titleFontFace>Petaluma Script</titleFontFace>
|
||||
<titleFontSize>18</titleFontSize>
|
||||
<composerFontFace>Petaluma Script</composerFontFace>
|
||||
<composerFontSize>10</composerFontSize>
|
||||
<lyricsOddFontFace>Petaluma Script</lyricsOddFontFace>
|
||||
<lyricsEvenFontFace>Petaluma Script</lyricsEvenFontFace>
|
||||
<harmonyFontFace>Petaluma Script</harmonyFontFace>
|
||||
<harmonyFontSize>10</harmonyFontSize>
|
||||
<rehearsalMarkAlign>left,baseline</rehearsalMarkAlign>
|
||||
<rehearsalMarkFrameType>0</rehearsalMarkFrameType>
|
||||
<enableVerticalSpread>0</enableVerticalSpread>
|
||||
<minSystemDistance>6</minSystemDistance>
|
||||
<maxSystemDistance>6</maxSystemDistance>
|
||||
<showPageNumber>1</showPageNumber>
|
||||
<showPageNumberOne>1</showPageNumberOne>
|
||||
</Style>
|
||||
</museScore>
|
||||
Reference in New Issue
Block a user