Rhythm Selection & Cleanup
ci / checks (push) Successful in 1h16m10s

This commit is contained in:
khodowany
2026-09-26 02:50:22 -05:00
parent 4d6348d354
commit 9fd5abd075
91 changed files with 4692 additions and 2484 deletions
+1 -108
View File
@@ -1,108 +1 @@
from __future__ import annotations
from concurrent.futures import ProcessPoolExecutor
import hashlib
import json
import multiprocessing
from pathlib import Path
import shutil
from PIL import Image, ImageChops
from generator.engraving.fonts import Fonts
from generator.engraving.cache import renderer_fingerprint, renderer_namespace, restore_pages, score_fingerprint, store_pages
from generator.engraving.layout import layout_score
from generator.engraving.raster import rasterize_page
from generator.engraving.types import EngravingError, Style
from generator.model import validate_document
ENGINE_NAME = 'Vamp Python'
PAGE_WIDTH = 976
PAGE_HEIGHT = 484
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_scores(paths: list[Path], root: Path, fonts: Path, cache_dir: Path | None, progress: bool, worker: int, renderer: str) -> tuple[int, int, int, int]:
family = Fonts(fonts)
style = Style()
fingerprint = family.fingerprint(style)
reused, missing, rejected = 0, 0, 0
for index, path in enumerate(paths, 1):
document = json.loads(path.read_text(encoding='utf-8'))
try:
validate_document(document)
destination = path.with_suffix('.pages')
if destination.exists():
shutil.rmtree(destination)
entry = cache_dir / score_fingerprint(document, renderer) if cache_dir else None
present = entry is not None and (entry.exists() or entry.is_symlink())
pages = restore_pages(entry, destination) if entry else None
if pages is None:
if entry:
rejected += int(present)
missing += int(not present)
if destination.exists():
shutil.rmtree(destination)
destination.mkdir()
layout = layout_score(document, style, family)
pages = []
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)
pages.append({'path': name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
if entry:
store_pages(entry, destination, pages)
else:
reused += 1
entries = [{**page, 'path': destination.name + '/' + page['path']} for page in pages]
music = {k: v for k, v in document.items() if k != 'playback'}
bundle = {'music': music, 'presentation': {'engine': ENGINE_NAME, 'style_sha256': fingerprint, 'width': PAGE_WIDTH, 'height': PAGE_HEIGHT, 'pages': entries}}
if 'playback' in document:
bundle['playback'] = document['playback']
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
if progress and (index % 50 == 0 or index == len(paths)):
print(f'Engraving worker {worker}: {index}/{len(paths)} scores, {reused} reused, {missing} missing, {rejected} rejected', flush=True)
except ValueError as error:
raise EngravingError(f'{path.relative_to(root)}: {error}') from error
return len(paths), reused, missing, rejected
def engrave_tree(root: Path, fonts: Path, *, cache_dir: Path | None = None, jobs: int = 1, progress: bool = False) -> None:
if type(jobs) is not int or not 1 <= jobs <= 16:
raise ValueError('engraving jobs must be between 1 and 16')
if cache_dir and (cache_dir.resolve().is_relative_to(root.resolve()) or root.resolve().is_relative_to(cache_dir.resolve())):
raise ValueError('engraving cache and generated assets must not overlap')
paths = sorted(root.rglob('*.json'))
if not paths:
return
renderer = renderer_fingerprint(Fonts(fonts), Style()) if cache_dir else ''
if cache_dir:
cache_dir = renderer_namespace(cache_dir.resolve(), renderer)
if progress:
state = 'warm namespace' if cache_dir.is_dir() else 'cold namespace'
print(f'Engraving cache enabled: {cache_dir} ({state}; renderer {renderer})', flush=True)
elif progress:
print('Engraving cache disabled', flush=True)
jobs = min(jobs, len(paths))
if jobs == 1:
results = [engrave_scores(paths, root, fonts, cache_dir, progress, 1, renderer)]
else:
with ProcessPoolExecutor(max_workers=jobs, mp_context=multiprocessing.get_context('spawn')) as pool:
futures = [pool.submit(engrave_scores, paths[index::jobs], root, fonts, cache_dir, progress, index + 1, renderer) for index in range(jobs)]
results = [future.result() for future in futures]
if progress:
total, reused, missing, rejected = (sum(row[index] for row in results) for index in range(4))
print(f'Engraving complete: {total} scores, {reused} reused, {missing} missing, {rejected} rejected', flush=True)
"""Score engraving implementation; import the owning modules explicitly."""
+25 -26
View File
@@ -1,13 +1,13 @@
from __future__ import annotations
from fractions import Fraction
import fractions
from generator.model import LETTERS, TYPE_LENGTHS, alteration, fraction, key_from_fifths
from generator.engraving.fonts import Fonts
from generator.engraving.notation import ScoreIndex, accidental
from generator.engraving.types import Primitive, bounds, line
import generator.engraving.fonts
import generator.engraving.notation
import generator.engraving.types
import generator.model
def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Fonts, space: float, source: str, fields: tuple[str, ...]) -> list[Primitive]:
def staff_attributes(index: generator.engraving.notation.ScoreIndex, state: dict, time: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, space: float, source: str, fields: tuple[str, ...]) -> list[generator.engraving.types.Primitive]:
result = []
x = 0
references = (source,)
@@ -22,18 +22,18 @@ def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Font
if octave := clef.get('octave_change', 0):
label = str(abs(octave) * 7 + 1)
result.append(fonts.text(label, (glyph.box.left + glyph.box.right) / 2, glyph.box.top - 2 if octave > 0 else glyph.box.bottom + 14, 14, 'clef-octave', references, True))
x = bounds(result).right + space
x = generator.engraving.types.bounds(result).right + space
if 'key' in fields and clef['sign'] != 'percussion':
key = state['key']
steps = key.get('steps')
if steps is None:
order = 'fcgdaeb' if key.get('fifths', 0) >= 0 else 'beadgcf'
steps = [{'letter': c, 'alter': key['alterations'][LETTERS.index(c)]} for c in order if alteration(key['alterations'][LETTERS.index(c)])]
steps = [{'letter': c, 'alter': key['alterations'][generator.model.LETTERS.index(c)]} for c in order if generator.model.alteration(key['alterations'][generator.model.LETTERS.index(c)])]
cancellation = key.get('cancel', 0)
if cancellation:
order = 'fcgdaeb' if cancellation > 0 else 'beadgcf'
old = key_from_fifths(cancellation)['alterations']
steps = [{'letter': c, 'alter': 0} for c in order if alteration(old[LETTERS.index(c)]) and alteration(old[LETTERS.index(c)]) != alteration(key['alterations'][LETTERS.index(c)])] + steps
old = generator.model.key_from_fifths(cancellation)['alterations']
steps = [{'letter': c, 'alter': 0} for c in order if generator.model.alteration(old[generator.model.LETTERS.index(c)]) and generator.model.alteration(old[generator.model.LETTERS.index(c)]) != generator.model.alteration(key['alterations'][generator.model.LETTERS.index(c)])] + steps
for step in steps:
pitch = {'letter': step['letter'], 'octave': step.get('octave', 5), 'id': source}
position = index.pitch_position(pitch, time)
@@ -42,9 +42,9 @@ def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Font
position -= 7
while position < 2:
position += 7
items = accidental(fonts, alteration(step['alter']), {'name': step.get('accidental', '')}, space, references)
items = generator.engraving.notation.accidental(fonts, generator.model.alteration(step['alter']), {'name': step.get('accidental', '')}, space, references)
result.extend(p.moved(x, -position * space / 2) for p in items)
x += bounds(items).right - bounds(items).left + 4
x += generator.engraving.types.bounds(items).right - generator.engraving.types.bounds(items).left + 4
if steps:
x += space / 2
if 'meter' in fields:
@@ -67,8 +67,8 @@ def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Font
items = []
cursor = 0
if y == -space and meter.get('symbol') in ('note', 'dotted-note'):
unit = Fraction(4, group['unit'])
kind = next((name for name, length in TYPE_LENGTHS.items() if length == unit), None)
unit = fractions.Fraction(4, group['unit'])
kind = next((name for name, length in generator.model.TYPE_LENGTHS.items() if length == unit), None)
glyph_name = {'whole': 'noteWhole', 'half': 'noteHalfUp', 'quarter': 'noteQuarterUp', 'eighth': 'note8thUp'}.get(kind, 'note' + str(kind) + 'Up')
if kind and glyph_name in fonts.names:
glyph = fonts.glyph(glyph_name, 0, y + space, space * 0.55, 'meter', references)
@@ -82,36 +82,35 @@ def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Font
items.append(glyph)
cursor = glyph.box.right + 1
groups.append(items)
width = max(bounds(items).right for items in groups)
width = max(generator.engraving.types.bounds(items).right for items in groups)
for items in groups:
result.extend(p.moved(x + (width - bounds(items).right) / 2, 0) for p in items)
result.extend(p.moved(x + (width - generator.engraving.types.bounds(items).right) / 2, 0) for p in items)
x += width + 6
if 'staff_details' in fields and state['staff_details'].get('capo'):
result.append(fonts.text(f'Capo {state["staff_details"]["capo"]}', x, -space * (lines + 1), 16, 'staff-details', references))
return result
def barline(style: str, x: float, top: float, source: str) -> list[Primitive]:
def barline(style: str, x: float, top: float, source: str) -> list[generator.engraving.types.Primitive]:
if style == 'none':
return []
references = (source,)
if style in ('dashed', 'dotted'):
dash, gap = (5, 4) if style == 'dashed' else (1, 4)
return [line('barline', references, ((x, y), (x, min(y + dash, 0)))) for y in range(round(top), 0, dash + gap)]
return [generator.engraving.types.line('barline', references, ((x, y), (x, min(y + dash, 0)))) for y in range(round(top), 0, dash + gap)]
if style in ('tick', 'short'):
return [line('barline', references, ((x, top - 4 if style == 'tick' else top * 0.75), (x, top + 5 if style == 'tick' else top * 0.25)))]
return [generator.engraving.types.line('barline', references, ((x, top - 4 if style == 'tick' else top * 0.75), (x, top + 5 if style == 'tick' else top * 0.25)))]
weights = {'regular': [1.4], 'heavy': [4], 'light-light': [1.4, 1.4], 'light-heavy': [1.4, 4], 'heavy-light': [4, 1.4], 'heavy-heavy': [4, 4]}[style]
return [line('barline', references, ((x - (len(weights) - 1 - i) * 5, top), (x - (len(weights) - 1 - i) * 5, 0)), width) for i, width in enumerate(weights)]
return [generator.engraving.types.line('barline', references, ((x - (len(weights) - 1 - i) * 5, top), (x - (len(weights) - 1 - i) * 5, 0)), width) for i, width in enumerate(weights)]
def tempo(value: dict, fonts: Fonts, x: float, y: float, space: float, source: str) -> list[Primitive]:
from generator.engraving.notation import flags, head_glyph
def tempo(value: dict, fonts: generator.engraving.fonts.Fonts, x: float, y: float, space: float, source: str) -> list[generator.engraving.types.Primitive]:
written = value['written']
references = (source,)
glyph = fonts.glyph(head_glyph(written, None), x, y, space * 0.7, 'tempo', references)
glyph = fonts.glyph(generator.engraving.notation.head_glyph(written, None), x, y, space * 0.7, 'tempo', references)
items = [glyph]
stem_x = glyph.box.right - 0.5
if written['type'] not in ('whole', 'breve', 'long', 'maxima'):
items.append(line('tempo', references, ((stem_x, y), (stem_x, y - 23)), 1))
if flags(written):
items.append(generator.engraving.types.line('tempo', references, ((stem_x, y), (stem_x, y - 23)), 1))
if generator.engraving.notation.flags(written):
name = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + 'Up'
items.append(fonts.glyph(name, stem_x, y - 23, space * 0.7, 'tempo', references))
cursor = max(p.box.right for p in items) + 3
@@ -119,5 +118,5 @@ def tempo(value: dict, fonts: Fonts, x: float, y: float, space: float, source: s
dot = fonts.glyph('augmentationDot', cursor, y - 3, space * 0.7, 'tempo', references)
items.append(dot)
cursor = dot.box.right + 3
items.append(fonts.text(' = ' + str(fraction(value['bpm'])), cursor, y + 3, 16, 'tempo', references))
items.append(fonts.text(' = ' + str(generator.model.fraction(value['bpm'])), cursor, y + 3, 16, 'tempo', references))
return items
+45 -41
View File
@@ -1,48 +1,52 @@
from __future__ import annotations
from fractions import Fraction
import fractions
from generator.model import TYPE_LENGTHS, fraction
from generator import harmony
from generator.engraving.fonts import Fonts
from generator.engraving.notation import ScoreIndex, head_glyph
from generator.engraving.types import EngravingError, LayoutDocument
import generator.engraving.fonts
import generator.engraving.notation
import generator.engraving.types
import generator.harmony
import generator.model
def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
index = ScoreIndex(document)
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 = Fraction()
previous_end = fractions.Fraction()
for page in layout.pages:
audit_primitives(page.primitives, page.width, page.height, fonts)
for item in page.primitives:
if not item.box.finite():
raise EngravingError(f'{item.sources}: non-finite or inverted primitive geometry')
if item.box.left < 4 or item.box.top < 4 or item.box.right > page.width - 4 or item.box.bottom > page.height - 4:
raise 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 EngravingError(f'{item.sources}: glyph bounds disagree with its actual outline')
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 EngravingError('layout systems do not partition the score timeline')
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 / 2)) > 0.01:
raise EngravingError(f'{anchor.source}: displayed staff position changed pitch')
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 EngravingError(f'{anchor.source}: visible anchor has no corresponding drawing primitive')
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 = head_glyph({'type': anchor.fragment.written_type}, tone.get('notehead', event.value.get('notehead')))
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 EngravingError(f'{anchor.source}: notehead changed written duration or shape')
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']
@@ -51,7 +55,7 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
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 EngravingError(f'{anchor.source}: tablature changed position or rhythmic alignment')
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
@@ -64,57 +68,57 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
if item.role != other.role and {item.role, other.role} - {'head', 'tab', 'accidental'}:
continue
if item.box.overlaps(other.box, 0.1):
raise EngravingError(f'{item.sources} and {other.sources}: colliding {item.role}/{other.role}')
raise generator.engraving.types.EngravingError(f'{item.sources} and {other.sources}: colliding {item.role}/{other.role}')
if previous_end != index.end:
raise EngravingError('layout omits the end of the score')
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 EngravingError(f'{identifier}: missing notation fragments')
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 EngravingError(f'{identifier}: notation visibility changed')
raise generator.engraving.types.EngravingError(f'{identifier}: notation visibility changed')
if fragment.start != cursor:
raise EngravingError(f'{identifier}: fragments overlap or omit metric time')
raise generator.engraving.types.EngravingError(f'{identifier}: fragments overlap or omit metric time')
cursor = fragment.end
expected = TYPE_LENGTHS[fragment.written_type] * (2 - Fraction(1, 2 ** fragment.dots)) * fragment.ratio
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 EngravingError(f'{identifier}: written fragment duration is inexact')
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 EngravingError(f'{identifier}: visible notation has no drawing primitive')
if cursor != event.start + fraction(source['duration']):
raise EngravingError(f'{identifier}: rendered duration changed')
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 EngravingError(f'{identifier}: required accidental was omitted')
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 EngravingError(f'{identifier}: tie was omitted')
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 EngravingError(f'{identifier}: lyric text was omitted or changed')
raise generator.engraving.types.EngravingError(f'{identifier}: lyric text was omitted or changed')
for _, _, measure in index.measures:
for value in measure['harmonies']:
label = harmony.symbol(value)
label = generator.harmony.symbol(value)
if not any(p.role == 'harmony' and p.value == label for p in primitives.get(value['id'], [])):
raise EngravingError(f'{value["id"]}: harmony was omitted')
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 EngravingError(f'{measure["id"]}: tempo was omitted')
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 EngravingError(f'{span["id"]}: tuplet was omitted')
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 EngravingError(f'{span["id"]}: octave shift was omitted')
raise generator.engraving.types.EngravingError(f'{span["id"]}: octave shift was omitted')
+79 -16
View File
@@ -1,19 +1,26 @@
from __future__ import annotations
import dataclasses
import hashlib
import importlib.metadata
import io
import json
from pathlib import Path
import shutil
import pathlib
import platform
import re
import shutil
import tempfile
from PIL import features
import PIL.Image
import PIL.ImageChops
import PIL.features
import generator.engraving.validation
def renderer_identity(fonts, style) -> dict:
root = Path(__file__).resolve().parents[1]
root = pathlib.Path(__file__).resolve().parents[1]
versions = {name: importlib.metadata.version(name) for name in ('Pillow', 'fonttools', 'aggdraw')}
versions.update({name: features.version(name) for name in features.get_supported()})
versions.update({name: PIL.features.version(name) for name in PIL.features.get_supported()})
sources = [*root.joinpath('engraving').glob('*.py'), root / 'model.py', root / 'harmony.py', root / 'requirements.txt']
return {'fonts_style': fonts.fingerprint(style), 'python': {'implementation': platform.python_implementation(), 'version': platform.python_version()}, 'dependencies': versions,
'sources': {path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(sources)}}
@@ -21,7 +28,7 @@ def renderer_identity(fonts, style) -> dict:
def renderer_fingerprint(fonts, style) -> str:
return hashlib.sha256(json.dumps(renderer_identity(fonts, style), sort_keys=True).encode()).hexdigest()
def renderer_namespace(cache: Path, renderer: str) -> Path:
def renderer_namespace(cache: pathlib.Path, renderer: str) -> pathlib.Path:
namespace = cache / renderer
if namespace.parent.is_symlink() or namespace.is_symlink():
raise ValueError('engraving cache namespaces must not be symlinks')
@@ -31,10 +38,19 @@ def score_fingerprint(document: dict, renderer: str) -> str:
music = {key: value for key, value in document.items() if key != 'playback'}
return hashlib.sha256(json.dumps([renderer, music], sort_keys=True, ensure_ascii=False).encode()).hexdigest()
def restore_pages(entry: Path, destination: Path) -> list[dict] | None:
from generator.engraving import check_page
def notation_fingerprint(document: dict, renderer: str) -> str:
return hashlib.sha256(json.dumps([renderer, document['staves']], sort_keys=True, ensure_ascii=False).encode()).hexdigest()
def band_fingerprint(notation: str, page, band) -> str:
context = [notation, page.width, page.height, page.supersampling, dataclasses.asdict(band)]
return hashlib.sha256(json.dumps(context, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
def has_symlink(path: pathlib.Path) -> bool:
return any(parent.is_symlink() for parent in (path, *path.parents))
def restore_pages(entry: pathlib.Path, destination: pathlib.Path, validated_pages: set[str] | None = None) -> list[dict] | None:
try:
if entry.is_symlink():
if has_symlink(entry) or has_symlink(destination):
return None
manifest = entry / 'pages.json'
if manifest.is_symlink():
@@ -44,27 +60,29 @@ def restore_pages(entry: Path, destination: Path) -> list[dict] | None:
return None
destination.mkdir(parents=True, exist_ok=True)
for index, page in enumerate(pages, 1):
if not isinstance(page, dict) or set(page) != {'path', 'sha256'} or page['path'] != f'{index:04}.png':
if not isinstance(page, dict) or set(page) != {'path', 'sha256'} or page['path'] != f'{index:04}.png' or not isinstance(page['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', page['sha256']):
return None
path = entry / page['path']
if path.is_symlink():
if path.is_symlink() or not path.is_file():
return None
target = destination / page['path']
shutil.copyfile(path, target)
if hashlib.sha256(target.read_bytes()).hexdigest() != page['sha256']:
if target.is_symlink():
return None
check_page(target)
shutil.copyfile(path, target)
generator.engraving.validation.check_page(target, validated_pages, expected_sha256=page['sha256'])
return pages
except (OSError, ValueError, TypeError):
return None
def store_pages(entry: Path, destination: Path, pages: list[dict]) -> None:
def store_pages(entry: pathlib.Path, destination: pathlib.Path, pages: list[dict]) -> None:
if has_symlink(entry.parent):
raise ValueError('engraving cache directories must not be symlinks')
entry.parent.mkdir(parents=True, exist_ok=True)
if entry.is_symlink() or entry.is_file():
entry.unlink(missing_ok=True)
entry.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.engraving-', dir=entry.parent) as temporary:
staged = Path(temporary)
staged = pathlib.Path(temporary)
for page in pages:
shutil.copyfile(destination / page['path'], staged / page['path'])
(staged / 'pages.json').write_text(json.dumps(pages), encoding='utf-8')
@@ -74,3 +92,48 @@ def store_pages(entry: Path, destination: Path, pages: list[dict]) -> None:
if target.is_dir() and not target.is_symlink():
shutil.rmtree(target, ignore_errors=True)
(staged / name).replace(target)
def restore_band(entry: pathlib.Path, key: str, width: int, height: int) -> PIL.Image.Image | None:
try:
manifest, asset = entry / 'band.json', entry / 'band.png'
if has_symlink(entry) or manifest.is_symlink() or asset.is_symlink():
return None
metadata = json.loads(manifest.read_text(encoding='utf-8'))
if not isinstance(metadata, dict) or set(metadata) != {'context', 'width', 'height', 'sha256'}:
return None
if metadata['context'] != key or type(metadata['width']) is not int or type(metadata['height']) is not int or (metadata['width'], metadata['height']) != (width, height):
return None
digest = metadata['sha256']
if not isinstance(digest, str) or not re.fullmatch('[0-9a-f]{64}', digest):
return None
data = asset.read_bytes()
if hashlib.sha256(data).hexdigest() != digest:
return None
with PIL.Image.open(io.BytesIO(data)) as image:
if image.format != 'PNG' or image.size != (width, height) or image.mode != 'RGB':
return None
image.load()
ink = PIL.ImageChops.difference(image, PIL.Image.new('RGB', image.size, 'white')).getbbox()
if not ink or ink[0] < 3 or ink[1] < 3 or ink[2] > width - 3 or ink[3] > height - 3:
return None
return image.copy()
except (OSError, ValueError, TypeError):
return None
def store_band(entry: pathlib.Path, key: str, image: PIL.Image.Image) -> None:
if has_symlink(entry.parent):
raise ValueError('engraving cache directories must not be symlinks')
entry.parent.mkdir(parents=True, exist_ok=True)
if entry.is_symlink() or entry.is_file():
entry.unlink()
entry.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.engraving-', dir=entry.parent) as temporary:
staged = pathlib.Path(temporary)
image.save(staged / 'band.png', format='PNG')
metadata = {'context': key, 'width': image.width, 'height': image.height, 'sha256': hashlib.sha256((staged / 'band.png').read_bytes()).hexdigest()}
(staged / 'band.json').write_text(json.dumps(metadata), encoding='utf-8')
for name in ('band.png', 'band.json'):
target = entry / name
if target.is_dir() and not target.is_symlink():
shutil.rmtree(target)
(staged / name).replace(target)
+26 -26
View File
@@ -1,27 +1,27 @@
from __future__ import annotations
from dataclasses import asdict
import dataclasses
import hashlib
import json
from pathlib import Path
import pathlib
from fontTools.pens.boundsPen import BoundsPen
from fontTools.pens.recordingPen import DecomposingRecordingPen
from fontTools.ttLib import TTFont
from PIL import ImageFont
import PIL.ImageFont
import fontTools.pens.boundsPen
import fontTools.pens.recordingPen
import fontTools.ttLib
from generator.engraving.types import Box, EngravingError, Primitive, Style
import generator.engraving.types
class Fonts:
def __init__(self, root: Path):
def __init__(self, root: pathlib.Path):
self.root = root
try:
self.music = TTFont(root / 'Petaluma.otf')
self.script = TTFont(root / 'PetalumaScript.otf')
self.music = fontTools.ttLib.TTFont(root / 'Petaluma.otf')
self.script = fontTools.ttLib.TTFont(root / 'PetalumaScript.otf')
self.metadata = json.loads((root / 'petaluma_metadata.json').read_text())
self.names = json.loads((root / 'smufl_glyphnames.json').read_text())
except (OSError, ValueError) as error:
raise EngravingError(f'cannot load bundled engraving fonts: {error}') from error
raise generator.engraving.types.EngravingError(f'cannot load bundled engraving fonts: {error}') from error
self.names.update(self.metadata.get('optionalGlyphs', {}))
self.cmap = self.music.getBestCmap()
self.text_cmap = self.script.getBestCmap()
@@ -30,37 +30,37 @@ class Fonts:
self.metrics = {}
self.outlines = {}
self.text_fonts = {}
def fingerprint(self, style: Style) -> str:
digest = hashlib.sha256(json.dumps(asdict(style), sort_keys=True).encode())
def fingerprint(self, style: generator.engraving.types.Style) -> str:
digest = hashlib.sha256(json.dumps(dataclasses.asdict(style), sort_keys=True).encode())
for name in ('Petaluma.otf', 'PetalumaScript.otf', 'petaluma_metadata.json', 'smufl_glyphnames.json'):
digest.update(name.encode())
digest.update((self.root / name).read_bytes())
return digest.hexdigest()
def glyph_name(self, name: str) -> str:
if name not in self.names:
raise EngravingError(f'unknown SMuFL glyph {name!r}')
raise generator.engraving.types.EngravingError(f'unknown SMuFL glyph {name!r}')
code = int(self.names[name]['codepoint'].removeprefix('U+'), 16)
if code not in self.cmap:
raise EngravingError(f'Petaluma is missing requested SMuFL glyph {name!r}')
raise generator.engraving.types.EngravingError(f'Petaluma is missing requested SMuFL glyph {name!r}')
return self.cmap[code]
def glyph_box(self, name: str, size: float) -> Box:
def glyph_box(self, name: str, size: float) -> generator.engraving.types.Box:
if name not in self.metrics:
pen = BoundsPen(self.glyphs)
pen = fontTools.pens.boundsPen.BoundsPen(self.glyphs)
self.glyphs[self.glyph_name(name)].draw(pen)
if pen.bounds is None:
raise EngravingError(f'empty SMuFL glyph {name!r}')
raise generator.engraving.types.EngravingError(f'empty SMuFL glyph {name!r}')
self.metrics[name] = pen.bounds
left, bottom, right, top = self.metrics[name]
scale = size / self.units
return Box(left * scale, -top * scale, right * scale, -bottom * scale)
return generator.engraving.types.Box(left * scale, -top * scale, right * scale, -bottom * scale)
def outline(self, name: str):
if name not in self.outlines:
pen = DecomposingRecordingPen(self.glyphs)
pen = fontTools.pens.recordingPen.DecomposingRecordingPen(self.glyphs)
self.glyphs[self.glyph_name(name)].draw(pen)
self.outlines[name] = pen
return self.outlines[name]
def glyph(self, name: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...]) -> Primitive:
return Primitive('glyph', role, sources, self.glyph_box(name, size).moved(x, y), ((x, y),), name, size)
def glyph(self, name: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...]) -> generator.engraving.types.Primitive:
return generator.engraving.types.Primitive('glyph', role, sources, self.glyph_box(name, size).moved(x, y), ((x, y),), name, size)
def anchor(self, name: str, anchor: str, size: float) -> tuple[float, float]:
value = self.metadata.get('glyphsWithAnchors', {}).get(name, {}).get(anchor)
if value is None:
@@ -70,14 +70,14 @@ class Fonts:
def text_font(self, size: float):
pixels = max(1, round(size))
if pixels not in self.text_fonts:
self.text_fonts[pixels] = ImageFont.truetype(str(self.root / 'PetalumaScript.otf'), pixels)
self.text_fonts[pixels] = PIL.ImageFont.truetype(str(self.root / 'PetalumaScript.otf'), pixels)
return self.text_fonts[pixels]
def text(self, value: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...], center: bool = False) -> Primitive:
def text(self, value: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...], center: bool = False) -> generator.engraving.types.Primitive:
missing = [c for c in value if ord(c) not in self.text_cmap and not c.isspace()]
if missing:
raise EngravingError(f'Petaluma Script is missing text glyph {missing[0]!r}')
raise generator.engraving.types.EngravingError(f'Petaluma Script is missing text glyph {missing[0]!r}')
font = self.text_font(size * 4)
left, top, right, bottom = font.getbbox(value, anchor='ls')
offset = (left + right) / 8 if center else 0
x -= offset
return Primitive('text', role, sources, Box(x + left / 4, y + top / 4, x + right / 4, y + bottom / 4), ((x, y),), value, size)
return generator.engraving.types.Primitive('text', role, sources, generator.engraving.types.Box(x + left / 4, y + top / 4, x + right / 4, y + bottom / 4), ((x, y),), value, size)
+81 -61
View File
@@ -1,33 +1,35 @@
from __future__ import annotations
from dataclasses import replace
from bisect import bisect_left, bisect_right
from fractions import Fraction
import bisect
import collections
import dataclasses
import fractions
import math
from generator import harmony
from generator.engraving import tablature
from generator.model import validate_document
from generator.engraving.attributes import barline, staff_attributes, tempo
from generator.engraving.fonts import Fonts
from generator.engraving.notation import ScoreIndex
from generator.engraving.notes import draw_event
from generator.engraving.spans import draw_beams, draw_lyric_spans, draw_spans, place_text_lanes
from generator.engraving.types import Anchor, EngravingError, Fragment, LayoutDocument, Page, Primitive, Style, System, bounds, line
import generator.engraving.attributes
import generator.engraving.audit
import generator.engraving.fonts
import generator.engraving.notation
import generator.engraving.notes
import generator.engraving.spans
import generator.engraving.tablature
import generator.engraving.types
import generator.harmony
import generator.model
def lyric_text(verse: dict) -> str:
return ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision'))
def column(index: ScoreIndex, time: Fraction, start: Fraction, end: Fraction, fonts: Fonts, style: Style) -> tuple[list[Primitive], list[Anchor], list[Fragment]]:
def column(index: generator.engraving.notation.ScoreIndex, time: fractions.Fraction, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[list[generator.engraving.types.Primitive], list[generator.engraving.types.Anchor], list[generator.engraving.types.Fragment]]:
items, anchors, fragments = [], [], []
events = index.events_at(time, include_sustained=time == start)
grace_width = 0
occupied = []
for event in events:
try:
drawn, placed, pieces = draw_event(index, event, start, end, fonts, style)
drawn, placed, pieces = generator.engraving.notes.draw_event(index, event, start, end, fonts, style)
except ValueError as error:
raise EngravingError(f'measure {event.measure["number"]}, voice {event.voice}, {event.value["id"]}: {error}') from error
raise generator.engraving.types.EngravingError(f'measure {event.measure["number"]}, voice {event.voice}, {event.value["id"]}: {error}') from error
x = grace_width
collidable = [p for p in drawn if p.role in ('head', 'tab', 'rest', 'slash', 'accidental', 'dot')]
while any(p.box.moved(x, 0).overlaps(q.box, 2) for p in collidable for q in occupied):
@@ -38,7 +40,7 @@ def column(index: ScoreIndex, time: Fraction, start: Fraction, end: Fraction, fo
grace_width = max((p.box.right for p in drawn), default=grace_width) + 9
occupied.extend(p for p in drawn if p.role in ('head', 'tab', 'rest', 'slash', 'accidental', 'dot'))
items.extend(drawn)
items.extend(tablature.column(index, event, placed, fonts, style))
items.extend(generator.engraving.tablature.column(index, event, placed, fonts, style))
anchors.extend(placed)
fragments.extend(pieces)
if event.start == time and not event.value.get('hidden'):
@@ -51,47 +53,61 @@ def column(index: ScoreIndex, time: Fraction, start: Fraction, end: Fraction, fo
measure_start, _, measure = index.measure_at(time)
for owner, _, item in index.harmonies_by_onset.get(time, []):
if owner is measure:
items.append(fonts.text(harmony.symbol(item), 0, 0, 21, 'harmony', (item['id'],)))
items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 21, 'harmony', (item['id'],)))
for owner, i, value in index.tempos_by_onset.get(time, []):
if owner is measure:
items.extend(tempo(value, fonts, 0, 0, style.space, measure['id'] + f':tempo:{i}'))
items.extend(generator.engraving.attributes.tempo(value, fonts, 0, 0, style.space, measure['id'] + f':tempo:{i}'))
if time == measure_start:
items.append(fonts.text(str(measure['number']), 0, -style.space * (index.state(time)['staff_details']['lines'] + 0.5), 12, 'measure-number', (measure['id'],)))
if label := measure.get('section_label'):
items.append(fonts.text(label, 0, 0, 19, 'section', (measure['id'] + ':section',)))
return items, anchors, fragments
def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts, style: Style, justify: bool = False) -> System:
def column_metrics(index, time, start, end, fonts, style, columns):
events = index.events_at(time, include_sustained=time == start)
finish = min(end, max((event.end for event in events), default=time))
key = (time, start, finish)
if columns is not None and key in columns:
columns.move_to_end(key)
return columns[key]
drawn, placed, pieces = column(index, time, start, end, fonts, style)
result = drawn, placed, pieces, generator.engraving.types.bounds(drawn)
if columns is not None:
columns[key] = result
if len(columns) > 256:
columns.popitem(last=False)
return result
def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, justify: bool = False, *, columns=None) -> generator.engraving.types.System:
items, anchors, fragments = [], [], []
state = index.state(start)
measure = index.measure_at(start)[2]
header = staff_attributes(index, state, start, fonts, style.space, measure['id'] + ':initial', ('clef', 'key', 'meter', 'staff_details'))
header = generator.engraving.attributes.staff_attributes(index, state, start, fonts, style.space, measure['id'] + ':initial', ('clef', 'key', 'meter', 'staff_details'))
items.extend(header)
cursor = max((p.box.right for p in header), default=0) + 22
content_left = cursor - 9
times = [start] + index.column_times[bisect_right(index.column_times, start):bisect_left(index.column_times, end)]
times = [start] + index.column_times[bisect.bisect_right(index.column_times, start):bisect.bisect_left(index.column_times, end)]
placements = []
groups = []
for ti, time in enumerate(times):
item_start, anchor_start = len(items), len(anchors)
drawn, placed, pieces = column(index, time, start, end, fonts, style)
drawn, placed, pieces, box = column_metrics(index, time, start, end, fonts, style, columns)
leading = []
measure_start, _, m = index.measure_at(time)
if time == measure_start and time > start:
previous = index.measure_at(time, before=True)[2]
leading.extend(barline(previous.get('barlines', {}).get('right', 'regular'), 0, -style.space * (index.state(time)['staff_details']['lines'] - 1), previous['id']))
leading.extend(generator.engraving.attributes.barline(previous.get('barlines', {}).get('right', 'regular'), 0, -style.space * (index.state(time)['staff_details']['lines'] - 1), previous['id']))
old_state = index.state(time, before=True)
fields = tuple(k for k in ('clef', 'key', 'meter', 'staff_details') if old_state[k] != index.state(time)[k])
leading.extend(p.moved(12, 0) for p in staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + ':initial', fields))
leading.extend(p.moved(12, 0) for p in generator.engraving.attributes.staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + ':initial', fields))
if time > start:
for owner, ci, change in index.attributes_by_onset.get(time, []):
extra = staff_attributes(index, index.state(time), time, fonts, style.space, owner['id'] + f':attributes:{ci}', tuple(change))
extra = generator.engraving.attributes.staff_attributes(index, index.state(time), time, fonts, style.space, owner['id'] + f':attributes:{ci}', tuple(change))
leading.extend(p.moved(max((p.box.right for p in leading), default=0) + 10, 0) for p in extra)
if leading:
width = bounds(leading).right - bounds(leading).left + 14
items.extend(p.moved(cursor - bounds(leading).left, 0) for p in leading)
width = generator.engraving.types.bounds(leading).right - generator.engraving.types.bounds(leading).left + 14
items.extend(p.moved(cursor - generator.engraving.types.bounds(leading).left, 0) for p in leading)
cursor += width
box = bounds(drawn)
x = cursor - min(0, box.left)
placements.append((time, x))
items.extend(p.moved(x, 0) for p in drawn)
@@ -114,10 +130,10 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
last_measure = index.measure_at(end, before=True)[2]
tab_items = [p for p in items if p.role.startswith('tab')]
items = [p for p in items if not p.role.startswith('tab')]
items = draw_beams(index, anchors, items, start, end, content_left, right, style)
items = draw_spans(index, anchors, items, start, end, content_left, right, fonts, style)
items = place_text_lanes(items, fonts, style)
items = draw_lyric_spans(index, items, anchors, start, end, content_left, right, fonts)
items = generator.engraving.spans.draw_beams(index, anchors, items, start, end, content_left, right, style)
items = generator.engraving.spans.draw_spans(index, anchors, items, start, end, content_left, right, fonts, style)
items = generator.engraving.spans.place_text_lanes(items, fonts, style)
items = generator.engraving.spans.draw_lyric_spans(index, items, anchors, start, end, content_left, right, fonts)
changes = [(0, state['staff_details']['lines'])]
for time, x in placements:
lines = index.state(time)['staff_details']['lines']
@@ -128,18 +144,18 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
x2 = changes[i + 1][0] if i + 1 < len(changes) else width - 1
for number in range(lines):
y = -number * style.space
staff_lines.append(line('staff', (), ((x1, y), (x2, y)), style.space * 0.13))
staff_lines.append(generator.engraving.types.line('staff', (), ((x1, y), (x2, y)), style.space * 0.13))
items = staff_lines + items
if end in index.boundaries:
items.extend(barline(last_measure.get('barlines', {}).get('right', 'regular'), width - 1, -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), last_measure['id']))
items.extend(generator.engraving.attributes.barline(last_measure.get('barlines', {}).get('right', 'regular'), width - 1, -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), last_measure['id']))
if start in index.boundaries:
items.extend(barline(measure.get('barlines', {}).get('left', 'none'), 0, -style.space * (state['staff_details']['lines'] - 1), measure['id']))
items.extend(generator.engraving.attributes.barline(measure.get('barlines', {}).get('left', 'none'), 0, -style.space * (state['staff_details']['lines'] - 1), measure['id']))
else:
items.append(fonts.text(str(measure['number']) + ' (cont.)', 0, min(p.box.top for p in items) - 6, 12, 'continuation', (measure['id'],)))
items = tablature.staff(index, items + tab_items, start, end, content_left, width - 1, fonts, style)
return System(start, end, tuple(items), tuple(anchors), tuple(fragments), bounds(items))
items = generator.engraving.tablature.staff(index, items + tab_items, start, end, content_left, width - 1, fonts, style)
return generator.engraving.types.System(start, end, tuple(items), tuple(anchors), tuple(fragments), generator.engraving.types.bounds(items))
def paginate(systems: list[System], document: dict, fonts: Fonts, style: Style) -> tuple[Page, ...]:
def paginate(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[generator.engraving.types.Page, ...]:
pages = []
placed = []
header = []
@@ -154,10 +170,10 @@ def paginate(systems: list[System], document: dict, fonts: Fonts, style: Style)
size -= 1
item = fonts.text(title, style.margin_x, style.margin_y + size, size, 'title', ())
if item.box.right > style.width - style.margin_x:
raise EngravingError('score title cannot fit the page without clipping')
raise generator.engraving.types.EngravingError('score title cannot fit the page without clipping')
result = [item]
if number == 1:
subtitle = ' - '.join(str(value) for value in (document['metadata'].get('composer'), document['staves'][0].get('name'), document['metadata'].get('style')) if value)
subtitle = ' - '.join(str(value) for value in (document['metadata'].get('composer'), document['staves'][0].get('name')) if value)
if subtitle:
result.append(fonts.text(subtitle, style.margin_x, item.box.bottom + 22, 16, 'subtitle', ()))
return result
@@ -166,61 +182,65 @@ def paginate(systems: list[System], document: dict, fonts: Fonts, style: Style)
height = system.box.bottom - system.box.top
if first:
header = page_header(len(pages) + 1)
cursor = bounds(header).bottom + 24
cursor = generator.engraving.types.bounds(header).bottom + 24
first = False
if cursor + height > style.height - style.margin_y - 18:
if not placed:
raise EngravingError(f'at musical offset {system.start}: simultaneous notation needs {height:.1f}px vertically; fixed-size page cannot fit it')
raise generator.engraving.types.EngravingError(f'at musical offset {system.start}: simultaneous notation needs {height:.1f}px vertically; fixed-size page cannot fit it')
number = len(pages) + 1
footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True)
pages.append(Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
placed = []
header = page_header(len(pages) + 1)
cursor = bounds(header).bottom + 24
cursor = generator.engraving.types.bounds(header).bottom + 24
if cursor + height > style.height - style.margin_y - 18:
raise EngravingError(f'at musical offset {system.start}: simultaneous notation is too tall for a page')
raise generator.engraving.types.EngravingError(f'at musical offset {system.start}: simultaneous notation is too tall for a page')
dx, dy = style.margin_x + 1, cursor - system.box.top
moved = replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy))
moved = dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy))
placed.append(moved)
cursor += height + style.system_gap
if placed:
number = len(pages) + 1
footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True)
pages.append(Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
return tuple(pages)
def layout_score(document: dict, style: Style, fonts: Fonts) -> LayoutDocument:
validate_document(document)
index = ScoreIndex(document)
def layout_systems(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> tuple[generator.engraving.types.System, ...]:
generator.model.validate_document(document)
index = generator.engraving.notation.ScoreIndex(document)
if not index.measures:
raise EngravingError('score has no measures')
raise generator.engraving.types.EngravingError('score has no measures')
systems = []
start = Fraction()
start = fractions.Fraction()
available = style.width - style.margin_x * 2
while start < index.end:
candidates = index.boundary_times[bisect_right(index.boundary_times, start):]
columns = collections.OrderedDict()
candidates = index.boundary_times[bisect.bisect_right(index.boundary_times, start):]
selected = None
# Reserve the same title/credit and footer bands when considering a split,
# so a wide pitch range can break vertically as well as horizontally.
height_limit = style.height - style.margin_y * 2 - 110
for end in candidates:
trial = make_system(index, start, end, fonts, style)
trial = make_system(index, start, end, fonts, style, columns=columns)
if trial.box.right - trial.box.left > available or trial.box.bottom - trial.box.top > height_limit:
break
selected = trial
if selected is None:
measure_end = candidates[0]
for end in index.column_times[bisect_right(index.column_times, start):bisect_left(index.column_times, measure_end)]:
trial = make_system(index, start, end, fonts, style)
for end in index.column_times[bisect.bisect_right(index.column_times, start):bisect.bisect_left(index.column_times, measure_end)]:
trial = make_system(index, start, end, fonts, style, columns=columns)
if trial.box.right - trial.box.left <= available and trial.box.bottom - trial.box.top <= height_limit:
selected = trial
else:
break
if selected is None:
raise EngravingError(f'at musical offset {start}: irreducible simultaneous content exceeds the page width or height')
systems.append(make_system(index, start, selected.end, fonts, style, justify=selected.end < index.end))
raise generator.engraving.types.EngravingError(f'at musical offset {start}: irreducible simultaneous content exceeds the page width or height')
systems.append(make_system(index, start, selected.end, fonts, style, justify=True, columns=columns) if selected.end < index.end else selected)
start = selected.end
layout = LayoutDocument(paginate(systems, document, fonts, style), style, fonts.fingerprint(style))
from generator.engraving.audit import audit_layout
audit_layout(document, layout, fonts)
return tuple(systems)
def layout_score(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> generator.engraving.types.LayoutDocument:
systems = layout_systems(document, style, fonts)
layout = generator.engraving.types.LayoutDocument(paginate(systems, document, fonts, style), style, fonts.fingerprint(style))
generator.engraving.audit.audit_layout(document, layout, fonts)
return layout
+36 -36
View File
@@ -1,24 +1,24 @@
from __future__ import annotations
from dataclasses import dataclass
from bisect import bisect_left, bisect_right
from fractions import Fraction
import bisect
import dataclasses
import fractions
from generator.model import LETTERS, NOTE_TYPES, alteration, fraction, written_for
from generator.engraving.fonts import Fonts
from generator.engraving.types import EngravingError, Primitive, bounds
import generator.engraving.fonts
import generator.engraving.types
import generator.model
ACCIDENTAL_GLYPHS = {Fraction(0): 'accidentalNatural', Fraction(1): 'accidentalSharp', Fraction(-1): 'accidentalFlat', Fraction(2): 'accidentalDoubleSharp', Fraction(-2): 'accidentalDoubleFlat', Fraction(3): 'accidentalTripleSharp', Fraction(-3): 'accidentalTripleFlat', Fraction(1, 2): 'accidentalQuarterToneSharpStein', Fraction(-1, 2): 'accidentalQuarterToneFlatStein', Fraction(3, 2): 'accidentalThreeQuarterTonesSharpStein', Fraction(-3, 2): 'accidentalThreeQuarterTonesFlatZimmermann'}
ACCIDENTAL_GLYPHS = {fractions.Fraction(0): 'accidentalNatural', fractions.Fraction(1): 'accidentalSharp', fractions.Fraction(-1): 'accidentalFlat', fractions.Fraction(2): 'accidentalDoubleSharp', fractions.Fraction(-2): 'accidentalDoubleFlat', fractions.Fraction(3): 'accidentalTripleSharp', fractions.Fraction(-3): 'accidentalTripleFlat', fractions.Fraction(1, 2): 'accidentalQuarterToneSharpStein', fractions.Fraction(-1, 2): 'accidentalQuarterToneFlatStein', fractions.Fraction(3, 2): 'accidentalThreeQuarterTonesSharpStein', fractions.Fraction(-3, 2): 'accidentalThreeQuarterTonesFlatZimmermann'}
HEADS = {'x': 'X', 'diamond': 'Diamond', 'triangle': 'TriangleUp', 'inverted triangle': 'TriangleDown', 'circle-x': 'CircleX', 'cross': 'Plus', 'slash': 'Slash', 'normal': '', 'do': 'TriangleUp', 're': 'Moon', 'mi': 'Diamond', 'fa': 'TriangleRight', 'fa up': 'TriangleLeft', 'so': 'Round', 'la': 'Square', 'ti': 'TriangleRoundDown', 'rectangle': 'Square', 'square': 'Square', 'arrow down': 'LargeArrowDown', 'arrow up': 'LargeArrowUp'}
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class Event:
measure: dict
voice: str
lane: int
lanes: int
start: Fraction
end: Fraction
start: fractions.Fraction
end: fractions.Fraction
value: dict
class ScoreIndex:
@@ -32,20 +32,20 @@ class ScoreIndex:
self.tempos_by_onset = {}
self.by_id = {}
self.tones = {}
self.boundaries = {Fraction()}
self.columns = {Fraction()}
self.boundaries = {fractions.Fraction()}
self.columns = {fractions.Fraction()}
self._states = {}
self._beam_groups = None
time = Fraction()
time = fractions.Fraction()
for measure in self.staff['measures']:
end = time + fraction(measure['duration'])
end = time + generator.model.fraction(measure['duration'])
self.measures.append((time, end, measure))
self.boundaries.add(end)
self.columns.update((time, end))
for lane, voice in enumerate(measure['voices']):
for note in voice['notes']:
onset = time + fraction(note['onset'])
event = Event(measure, voice['id'], lane, len(measure['voices']), onset, onset + fraction(note['duration']), note)
onset = time + generator.model.fraction(note['onset'])
event = Event(measure, voice['id'], lane, len(measure['voices']), onset, onset + generator.model.fraction(note['duration']), note)
self.events.append(event)
self.by_id[note['id']] = event
self.columns.add(onset)
@@ -54,7 +54,7 @@ class ScoreIndex:
for field in ('attributes', 'harmonies', 'tempos'):
bucket = {'attributes': self.attributes_by_onset, 'harmonies': self.harmonies_by_onset, 'tempos': self.tempos_by_onset}[field]
for index, item in enumerate(measure.get(field, [])):
onset = time + fraction(item['onset'])
onset = time + generator.model.fraction(item['onset'])
self.columns.add(onset)
bucket.setdefault(onset, []).append((measure, index, item))
time = end
@@ -72,15 +72,15 @@ class ScoreIndex:
self.boundary_times = sorted(self.boundaries)
self.lyric_lanes = list(dict.fromkeys((e.voice, verse['number']) for e in self.events for verse in e.value.get('lyrics', [])))
self.accidentals = self.accidental_state()
def measure_at(self, time: Fraction, before: bool = False) -> tuple:
position = bisect_left(self.measure_starts, time) if before else bisect_right(self.measure_starts, time)
def measure_at(self, time: fractions.Fraction, before: bool = False) -> tuple:
position = bisect.bisect_left(self.measure_starts, time) if before else bisect.bisect_right(self.measure_starts, time)
return self.measures[max(0, position - 1)]
def events_at(self, time: Fraction, include_sustained: bool = False) -> list[Event]:
def events_at(self, time: fractions.Fraction, include_sustained: bool = False) -> list[Event]:
attacks = self.events_by_onset.get(time, [])
if not include_sustained:
return attacks
sustained = []
position = bisect_left(self.event_starts, time) - 1
position = bisect.bisect_left(self.event_starts, time) - 1
# Prefix maxima let system continuations stop searching once every earlier note has ended.
while position >= 0 and self.event_max_ends[position] > time:
event = self.events[position]
@@ -88,18 +88,18 @@ class ScoreIndex:
sustained.append(event)
position -= 1
return list(reversed(sustained)) + attacks
def state(self, time: Fraction, before: bool = False) -> dict:
def state(self, time: fractions.Fraction, before: bool = False) -> dict:
if (time, before) in self._states:
return self._states[time, before]
start, _, measure = self.measure_at(time, before)
state = {key: measure[key] for key in ('key', 'meter', 'clef', 'transpose', 'staff_details')}
for change in measure.get('attributes', []):
onset = start + fraction(change['onset'])
onset = start + generator.model.fraction(change['onset'])
if onset < time or (onset == time and not before):
state.update({key: value for key, value in change.items() if key != 'onset'})
self._states[time, before] = state
return state
def pitch_position(self, tone: dict, time: Fraction) -> float:
def pitch_position(self, tone: dict, time: fractions.Fraction) -> float:
state = self.state(time)
clef = state['clef']
pitch = tone.get('unpitched', tone)
@@ -107,14 +107,14 @@ class ScoreIndex:
reference = {'G': 4 * 7 + 4, 'F': 3 * 7 + 3, 'C': 4 * 7}.get(sign, 4 * 7 + 4)
clef_line = clef.get('line', 2) if sign in ('G', 'F', 'C') else 2
octave = 0 if 'unpitched' in tone else clef.get('octave_change', 0)
shift = sum(s['octaves'] for s in self.staff.get('octave_shifts', []) if fraction(s['start']) <= time < fraction(s['end'])) if 'unpitched' not in tone else 0
return pitch['octave'] * 7 + LETTERS.index(pitch['letter']) + 7 * (shift - octave) - reference + 2 * (clef_line - 1)
shift = sum(s['octaves'] for s in self.staff.get('octave_shifts', []) if generator.model.fraction(s['start']) <= time < generator.model.fraction(s['end'])) if 'unpitched' not in tone else 0
return pitch['octave'] * 7 + generator.model.LETTERS.index(pitch['letter']) + 7 * (shift - octave) - reference + 2 * (clef_line - 1)
def accidental_state(self) -> dict[str, bool]:
result = {}
for start, end, measure in self.measures:
memory = {}
key = None
onsets = self.onsets[bisect_left(self.onsets, start):bisect_left(self.onsets, end)]
onsets = self.onsets[bisect.bisect_left(self.onsets, start):bisect.bisect_left(self.onsets, end)]
for onset in onsets:
state = self.state(onset)
if key != state['key']:
@@ -130,11 +130,11 @@ class ScoreIndex:
result[tone['id']] = False
continue
identity = (tone['letter'], tone['octave'])
value = alteration(tone['alter'])
signature = Fraction() if key.get('custom') else alteration(key['alterations'][LETTERS.index(tone['letter'])])
value = generator.model.alteration(tone['alter'])
signature = fractions.Fraction() if key.get('custom') else generator.model.alteration(key['alterations'][generator.model.LETTERS.index(tone['letter'])])
for step in key.get('steps', []):
if step['letter'] == tone['letter'] and step.get('octave', tone['octave']) == tone['octave']:
signature = alteration(step['alter'])
signature = generator.model.alteration(step['alter'])
result[tone['id']] = bool(tone.get('accidental_display')) or (not tone.get('tie_from') and memory.get(identity, signature) != value)
simultaneous.setdefault(identity, set()).add(value)
for identity, values in simultaneous.items():
@@ -146,11 +146,11 @@ class ScoreIndex:
result[tone['id']] = True
return result
def fragment_written(written: dict, original_start: Fraction, original_end: Fraction, start: Fraction, end: Fraction) -> dict:
return written if (original_start, original_end) == (start, end) else written_for(end - start)
def fragment_written(written: dict, original_start: fractions.Fraction, original_end: fractions.Fraction, start: fractions.Fraction, end: fractions.Fraction) -> dict:
return written if (original_start, original_end) == (start, end) else generator.model.written_for(end - start)
def flags(written: dict) -> int:
return max(0, NOTE_TYPES.index(written['type']) - NOTE_TYPES.index('quarter'))
return max(0, generator.model.NOTE_TYPES.index(written['type']) - generator.model.NOTE_TYPES.index('quarter'))
def head_glyph(written: dict, head: dict | str | None) -> str:
head = {'name': head} if isinstance(head, str) else head or {'name': 'normal'}
@@ -170,13 +170,13 @@ def head_glyph(written: dict, head: dict | str | None) -> str:
if name in ('circled', 'circle dot'):
return 'noteheadCircled' + suffix
if name not in HEADS:
raise EngravingError(f'no SMuFL mapping for notehead {name!r}; specify its smufl glyph name')
raise generator.engraving.types.EngravingError(f'no SMuFL mapping for notehead {name!r}; specify its smufl glyph name')
shape = HEADS[name]
if shape in ('Moon', 'Round', 'Square', 'TriangleRight', 'TriangleLeft', 'TriangleRoundDown'):
suffix = 'Black' if suffix == 'Black' else 'White'
return 'notehead' + shape + suffix
def accidental(fonts: Fonts, value: Fraction, display: dict, size: float, sources: tuple[str, ...]) -> list[Primitive]:
def accidental(fonts: generator.engraving.fonts.Fonts, value: fractions.Fraction, display: dict, size: float, sources: tuple[str, ...]) -> list[generator.engraving.types.Primitive]:
glyph = display.get('smufl') or ACCIDENTAL_GLYPHS.get(value)
special = {'natural-sharp': ('accidentalNatural', 'accidentalSharp'), 'natural-flat': ('accidentalNatural', 'accidentalFlat')}
if display.get('name') in special and not display.get('smufl'):
@@ -192,7 +192,7 @@ def accidental(fonts: Fonts, value: Fraction, display: dict, size: float, source
sign = '+' if value > 0 else ''
items = [fonts.text(f'({sign}{value} st)', 0, 0, size * 1.15, 'accidental', sources)]
if display.get('parentheses') == 'yes' or display.get('bracket') == 'yes':
box = bounds(items)
box = generator.engraving.types.bounds(items)
left, right = ('[', ']') if display.get('bracket') == 'yes' else ('(', ')')
items.extend((fonts.text(left, box.left - 7, size / 2, size * 1.8, 'accidental', sources), fonts.text(right, box.right + 2, size / 2, size * 1.8, 'accidental', sources)))
return items
+35 -35
View File
@@ -1,13 +1,14 @@
from __future__ import annotations
from fractions import Fraction
import dataclasses
import fractions
from generator.model import alteration, fraction
from generator.engraving.fonts import Fonts
from generator.engraving.notation import Event, ScoreIndex, accidental, flags, fragment_written, head_glyph
from generator.engraving.types import Anchor, Fragment, Primitive, Style, bounds, line
import generator.engraving.fonts
import generator.engraving.notation
import generator.engraving.types
import generator.model
def dots(fonts: Fonts, count: int, x: float, y: float, size: float, sources: tuple[str, ...]) -> list[Primitive]:
def dots(fonts: generator.engraving.fonts.Fonts, count: int, x: float, y: float, size: float, sources: tuple[str, ...]) -> list[generator.engraving.types.Primitive]:
result = []
for i in range(count):
result.append(fonts.glyph('augmentationDot', x + i * size * 0.6, y, size, 'dot', sources))
@@ -16,13 +17,13 @@ def dots(fonts: Fonts, count: int, x: float, y: float, size: float, sources: tup
def rest_glyph(kind: str) -> str:
return {'maxima': 'restMaxima', 'long': 'restLonga', 'breve': 'restDoubleWhole', 'whole': 'restWhole', 'half': 'restHalf', 'quarter': 'restQuarter', 'eighth': 'rest8th'}.get(kind, 'rest' + kind)
def time_modification(written: dict, items: list[Primitive], fonts: Fonts, sources: tuple[str, ...]) -> None:
ratio = fraction(written.get('ratio', {'numerator': 1, 'denominator': 1}))
def time_modification(written: dict, items: list[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, sources: tuple[str, ...]) -> None:
ratio = generator.model.fraction(written.get('ratio', {'numerator': 1, 'denominator': 1}))
if ratio != 1 and items:
box = bounds(items)
box = generator.engraving.types.bounds(items)
items.append(fonts.text(f'{ratio.denominator}:{ratio.numerator}', (box.left + box.right) / 2, box.top - 6, 13, 'time-modification', sources, True))
def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction, fonts: Fonts, style: Style) -> tuple[list[Primitive], list[Anchor], list[Fragment]]:
def draw_event(index: generator.engraving.notation.ScoreIndex, event: generator.engraving.notation.Event, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[list[generator.engraving.types.Primitive], list[generator.engraving.types.Anchor], list[generator.engraving.types.Fragment]]:
note = event.value
onset = max(start, event.start)
finish = min(end, event.end)
@@ -33,17 +34,17 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction,
state = index.state(onset)
lines = state['staff_details']['lines']
if not note['pitches']:
written = fragment_written(note['written'], event.start, event.end, onset, finish)
written = generator.engraving.notation.fragment_written(note['written'], event.start, event.end, onset, finish)
y = -style.space * (lines - 1) / 2
if position := note.get('rest_position'):
y = -index.pitch_position(position, onset) * style.space / 2
elif event.lanes > 1:
y += (-1 if event.lane % 2 == 0 else 1) * (event.lane // 2 + 1) * style.space * 3
kind = 'slash' if note.get('notehead') == 'slash' else 'rest'
fragment = Fragment(note['id'], note['id'], onset, finish, written['type'], written.get('dots', 0), fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), visible, kind, None)
fragment = generator.engraving.types.Fragment(note['id'], note['id'], onset, finish, written['type'], written.get('dots', 0), generator.model.fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), visible, kind, None)
fragments.append(fragment)
if visible:
glyph = head_glyph(written, 'slash') if kind == 'slash' else rest_glyph('whole' if note.get('measure_rest') and onset == event.start and finish == event.end else written['type'])
glyph = generator.engraving.notation.head_glyph(written, 'slash') if kind == 'slash' else rest_glyph('whole' if note.get('measure_rest') and onset == event.start and finish == event.end else written['type'])
if written['type'] == 'whole' and not note.get('rest_position') and event.lanes == 1:
y -= style.space
items.append(fonts.glyph(glyph, 0, y, size, kind, references))
@@ -51,20 +52,20 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction,
if kind == 'slash' and written['type'] not in ('whole', 'breve', 'long', 'maxima'):
stem_x = items[0].box.right
stem_y = y - 3 * size
items.append(line('stem', references, ((stem_x, y), (stem_x, stem_y))))
if flags(written):
items.append(generator.engraving.types.line('stem', references, ((stem_x, y), (stem_x, stem_y))))
if generator.engraving.notation.flags(written):
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + 'Up'
items.append(fonts.glyph(flag, stem_x, stem_y, size, 'flag', references))
anchors.append(Anchor(note['id'], note['id'], event.voice, onset, finish, 0, y, 0, y - size * 3, -1, 0, fragment))
anchors.append(generator.engraving.types.Anchor(note['id'], note['id'], event.voice, onset, finish, 0, y, 0, y - size * 3, -1, 0, fragment))
if not note.get('tuplet_ids') or written != note['written']:
time_modification(written, items, fonts, references)
return items, anchors, fragments
groups = {}
for tone in note['pitches']:
tone_end = event.start + fraction(tone['duration'])
tone_end = event.start + generator.model.fraction(tone['duration'])
if tone_end <= start and not note.get('grace'):
continue
written = fragment_written(tone['written'], event.start, tone_end, onset, min(end, tone_end))
written = generator.engraving.notation.fragment_written(tone['written'], event.start, tone_end, onset, min(end, tone_end))
stem = tone.get('stem', note.get('stem', 'auto'))
identity = (written['type'], written.get('dots', 0), str(written.get('ratio')), stem)
groups.setdefault(identity, []).append((tone, written, tone_end))
@@ -82,7 +83,7 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction,
for position, tone, written, tone_end in positioned:
sources = (note['id'], tone['id'])
tone_size = size * (0.7 if tone.get('cue') and not note.get('cue') else 1)
glyph = head_glyph(written, tone.get('notehead', note.get('notehead')))
glyph = generator.engraving.notation.head_glyph(written, tone.get('notehead', note.get('notehead')))
displayed = visible and tone.get('print', {}).get('print-object') != 'no' and bool(glyph)
y = -position * style.space / 2
if previous is not None and abs(position - previous) < 2:
@@ -91,13 +92,13 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction,
displaced = False
x = group_x + (tone_size * 1.2 * -direction if displaced else 0)
previous = position
fragment = Fragment(tone['id'], note['id'], onset, min(end, tone_end), written['type'], written.get('dots', 0), fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), displayed, 'tone', position)
fragment = generator.engraving.types.Fragment(tone['id'], note['id'], onset, min(end, tone_end), written['type'], written.get('dots', 0), generator.model.fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), displayed, 'tone', position)
fragments.append(fragment)
if not displayed:
continue
if written['type'] in ('long', 'maxima') and tone.get('notehead', {}).get('name', 'normal') == 'normal':
width = tone_size * (2.8 if written['type'] == 'maxima' else 1.4)
head = line('head', sources, ((x, y - tone_size / 2), (x + width, y - tone_size / 2), (x + width, y + tone_size / 2), (x, y + tone_size / 2), (x, y - tone_size / 2)), 2)
head = generator.engraving.types.line('head', sources, ((x, y - tone_size / 2), (x + width, y - tone_size / 2), (x + width, y + tone_size / 2), (x, y + tone_size / 2), (x, y - tone_size / 2)), 2)
else:
head = fonts.glyph(glyph, x, y, tone_size, 'head', sources)
group_items.append(head)
@@ -105,15 +106,15 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction,
group_items.extend((fonts.text('(', head.box.left - 6, y + 5, 18, 'head-parentheses', sources), fonts.text(')', head.box.right + 2, y + 5, 18, 'head-parentheses', sources)))
ledger = -2
while position <= ledger:
group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
group_items.append(generator.engraving.types.line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
ledger -= 2
ledger = lines * 2
while position >= ledger:
group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
group_items.append(generator.engraving.types.line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
ledger += 2
if 'alter' in tone and index.accidentals[tone['id']] and onset == event.start:
accidental_items = accidental(fonts, alteration(tone['alter']), tone.get('accidental_display', {}), tone_size, sources)
box = bounds(accidental_items)
accidental_items = generator.engraving.notation.accidental(fonts, generator.model.alteration(tone['alter']), tone.get('accidental_display', {}), tone_size, sources)
box = generator.engraving.types.bounds(accidental_items)
dx = min(group_x, head.box.left) - 5 - box.right
candidate = box.moved(dx, y)
while any(candidate.overlaps(other, 2) for other in accidental_boxes):
@@ -128,32 +129,31 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction,
stem_x = x + fonts.anchor(glyph, 'stemUpSE' if direction == -1 else 'stemDownNW', tone_size)[0]
else:
stem_x = head.box.right - 0.6 if direction == -1 else head.box.left + 0.6
stem_y = y + direction * max(3.5 * size, (flags(written) + 1) * size * 0.75)
group_anchors.append(Anchor(tone['id'], note['id'], event.voice, onset, min(end, tone_end), x, y, stem_x, stem_y, direction, flags(written), fragment))
stem_y = y + direction * max(3.5 * size, (generator.engraving.notation.flags(written) + 1) * size * 0.75)
group_anchors.append(generator.engraving.types.Anchor(tone['id'], note['id'], event.voice, onset, min(end, tone_end), x, y, stem_x, stem_y, direction, generator.engraving.notation.flags(written), fragment))
if group_anchors:
stem_y = min(a.stem_y for a in group_anchors) if direction == -1 else max(a.stem_y for a in group_anchors)
anchor = min(group_anchors, key=lambda a: a.y) if direction == 1 else max(group_anchors, key=lambda a: a.y)
written = members[0][1]
if stem != 'none' and written['type'] not in ('whole', 'breve'):
group_items.append(line('stem', references, ((anchor.stem_x, anchor.y), (anchor.stem_x, stem_y)), size * 0.12))
group_items.append(generator.engraving.types.line('stem', references, ((anchor.stem_x, anchor.y), (anchor.stem_x, stem_y)), size * 0.12))
if stem == 'double':
other_x = anchor.x if direction == -1 else anchor.x + size * 1.3
other_y = anchor.y - direction * size * 3.5
group_items.append(line('stem', references, ((other_x, anchor.y), (other_x, other_y)), size * 0.12))
if flags(written):
group_items.append(generator.engraving.types.line('stem', references, ((other_x, anchor.y), (other_x, other_y)), size * 0.12))
if generator.engraving.notation.flags(written):
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + ('Down' if direction == -1 else 'Up')
group_items.append(fonts.glyph(flag, other_x, other_y, size, 'flag', references))
if flags(written):
if generator.engraving.notation.flags(written):
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + ('Up' if direction == -1 else 'Down')
group_items.append(fonts.glyph(flag, anchor.stem_x, stem_y, size, 'flag', references))
if note.get('grace', {}).get('slash'):
group_items.append(line('grace-slash', references, ((anchor.stem_x - 5, stem_y + 14), (anchor.stem_x + 9, stem_y + 4)), 1.5))
group_items.append(generator.engraving.types.line('grace-slash', references, ((anchor.stem_x - 5, stem_y + 14), (anchor.stem_x + 9, stem_y + 4)), 1.5))
if note.get('tremolo', {}).get('type') == 'single':
for mark in range(note['tremolo']['marks']):
ty = stem_y - direction * (10 + mark * 5)
group_items.append(line('tremolo', references, ((anchor.stem_x - 6, ty + 2), (anchor.stem_x + 6, ty - 2)), 3))
from dataclasses import replace
anchors.extend(replace(a, stem_x=anchor.stem_x, stem_y=stem_y) for a in group_anchors)
group_items.append(generator.engraving.types.line('tremolo', references, ((anchor.stem_x - 6, ty + 2), (anchor.stem_x + 6, ty - 2)), 3))
anchors.extend(dataclasses.replace(a, stem_x=anchor.stem_x, stem_y=stem_y) for a in group_anchors)
if not note.get('tuplet_ids') or members[0][1] != members[0][0]['written']:
time_modification(members[0][1], group_items, fonts, references)
items.extend(group_items)
+261
View File
@@ -0,0 +1,261 @@
from __future__ import annotations
import collections
import concurrent.futures
import dataclasses
import hashlib
import json
import multiprocessing
import pathlib
import shutil
import tempfile
import time
import PIL.Image
import generator.engraving.audit
import generator.engraving.cache
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.raster
import generator.engraving.types
import generator.engraving.validation
import generator.model
@dataclasses.dataclass
class GroupResult:
scores: list = dataclasses.field(default_factory=list)
fullscore: int = 0
bodyhits: int = 0
bodies: int = 0
renderedpages: int = 0
rejected: int = 0
timings: dict = dataclasses.field(default_factory=lambda: dict.fromkeys(('layout', 'pagination', 'raster', 'cache'), 0.0))
class BodyBands:
def __init__(self, root, persistent, result, limit=16 * 1024 * 1024):
self.root, self.persistent, self.result, self.limit = root, persistent, result, limit
self.images = collections.OrderedDict()
self.size = 0
self.known = {}
self.root.mkdir(parents=True, exist_ok=True)
def get(self, notation, page, band, fonts):
key = generator.engraving.cache.band_fingerprint(notation, page, band)
if key in self.images:
self.images.move_to_end(key)
self.result.bodyhits += 1
return self.images[key]
started = time.monotonic()
entry = self.root / (key + '.rgb')
image = None
if key in self.known:
if entry.is_symlink() or entry.stat().st_size != page.width * (band.bottom - band.top) * 3:
raise generator.engraving.types.EngravingError('temporary notation band was corrupted')
data = entry.read_bytes()
if hashlib.sha256(data).hexdigest() != self.known[key]:
raise generator.engraving.types.EngravingError('temporary notation band was corrupted')
image = PIL.Image.frombytes('RGB', (page.width, band.bottom - band.top), data)
elif self.persistent:
cached = self.persistent / 'notation' / key
image = generator.engraving.cache.restore_band(cached, key, page.width, band.bottom - band.top)
self.result.rejected += int(image is None and (cached.exists() or cached.is_symlink()))
self.result.timings['cache'] += time.monotonic() - started
if image is None:
started = time.monotonic()
image = generator.engraving.raster.rasterize_band(page, band.primitives, band.top, band.bottom, fonts)
self.result.timings['raster'] += time.monotonic() - started
self.result.bodies += 1
if self.persistent:
started = time.monotonic()
generator.engraving.cache.store_band(self.persistent / 'notation' / key, key, image)
self.result.timings['cache'] += time.monotonic() - started
else:
self.result.bodyhits += 1
if key not in self.known:
started = time.monotonic()
# Spill finalized pixels, so eviction never repeats rasterization or PNG decoding.
data = image.tobytes()
entry.write_bytes(data)
self.result.timings['cache'] += time.monotonic() - started
self.known[key] = hashlib.sha256(data).hexdigest()
size = image.width * image.height * 3
while self.images and self.size + size > self.limit:
_, old = self.images.popitem(last=False)
self.size -= old.width * old.height * 3
old.close()
self.images[key] = image
self.size += size
return image
def close(self):
for image in self.images.values():
image.close()
self.images.clear()
self.size = 0
def clear_pages(destination):
if destination.is_symlink() or destination.is_file():
destination.unlink()
elif destination.exists():
shutil.rmtree(destination)
def write_bundle(path, document, pages, fingerprint):
entries = [{**page, 'path': path.with_suffix('.pages').name + '/' + page['path']} for page in pages]
music = {key: value for key, value in document.items() if key != 'playback'}
bundle = {'music': music, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'style_sha256': fingerprint,
'width': generator.engraving.validation.PAGE_WIDTH, 'height': generator.engraving.validation.PAGE_HEIGHT, 'pages': entries}}
if 'playback' in document:
bundle['playback'] = document['playback']
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, progress=False):
family = generator.engraving.fonts.Fonts(font_path)
style = generator.engraving.types.Style()
systems = None
scores = {}
audited = set()
result = GroupResult()
with tempfile.TemporaryDirectory(prefix='vamp-notation-') as temporary:
bodies = BodyBands(pathlib.Path(temporary), cache_dir, result)
try:
for path, score_key in paths:
try:
document = json.loads(path.read_text(encoding='utf-8'))
destination = path.with_suffix('.pages')
clear_pages(destination)
destination.mkdir()
if score_key in scores:
source, pages = scores[score_key]
for page in pages:
shutil.copyfile(source / page['path'], destination / page['path'])
result.fullscore += 1
else:
if systems is None:
started = time.monotonic()
systems = generator.engraving.layout.layout_systems(document, style, family)
result.timings['layout'] += time.monotonic() - started
started = time.monotonic()
pages_layout = generator.engraving.layout.paginate(systems, document, family, style)
placement = tuple((page.width, page.height, tuple((system.start, system.end, system.box) for system in page.systems)) for page in pages_layout)
if placement not in audited:
layout = generator.engraving.types.LayoutDocument(pages_layout, style, fingerprint)
generator.engraving.audit.audit_layout(document, layout, family)
audited.add(placement)
else:
for page in pages_layout:
decoration = tuple(p for p in page.primitives if p.role in ('title', 'subtitle', 'page-number'))
generator.engraving.audit.audit_primitives(decoration, page.width, page.height, family)
result.timings['pagination'] += time.monotonic() - started
pages = []
for page in pages_layout:
bands = generator.engraving.raster.page_bands(page)
if bands is None:
started = time.monotonic()
image = generator.engraving.raster.rasterize_page(page, family)
else:
body = bodies.get(notation, page, bands[1], family)
started = time.monotonic()
image = generator.engraving.raster.compose_page(page, family, bands, body)
target = destination / f'{page.number:04}.png'
with image:
image.save(target, format='PNG')
result.timings['raster'] += time.monotonic() - started
result.renderedpages += 1
pages.append({'path': target.name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
scores[score_key] = (destination, pages)
write_bundle(path, document, pages, fingerprint)
result.scores.append((path, score_key, pages))
if progress and len(result.scores) % 50 == 0:
print(f'Engraving body {notation[:12]}: {len(result.scores)}/{len(paths)} scores, fullscore {result.fullscore}, bodyhits {result.bodyhits}, bodies {result.bodies}, renderedpages {result.renderedpages}', flush=True)
except ValueError as error:
raise generator.engraving.types.EngravingError(f'{path.relative_to(root)}: {error}') from error
finally:
bodies.close()
return result
def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.Path | None = None, jobs: int = 1, progress: bool = False) -> set[str]:
if type(jobs) is not int or not 1 <= jobs <= 16:
raise ValueError('engraving jobs must be between 1 and 16')
if cache_dir and (cache_dir.resolve().is_relative_to(root.resolve()) or root.resolve().is_relative_to(cache_dir.resolve())):
raise ValueError('engraving cache and generated assets must not overlap')
started = time.monotonic()
paths = sorted(root.rglob('*.json'))
validated_pages = set()
if not paths:
return validated_pages
family, style = generator.engraving.fonts.Fonts(fonts), generator.engraving.types.Style()
fingerprint = family.fingerprint(style)
renderer = generator.engraving.cache.renderer_fingerprint(family, style)
if cache_dir:
cache_dir = generator.engraving.cache.renderer_namespace(cache_dir, renderer)
if progress:
state = 'warm namespace' if cache_dir.is_dir() else 'cold namespace'
print(f'Engraving cache enabled: {cache_dir} ({state}; renderer {renderer})', flush=True)
elif progress:
print('Engraving cache disabled', flush=True)
totals = GroupResult()
missing, rejected, completed = 0, 0, 0
groups = {}
for path in paths:
try:
document = json.loads(path.read_text(encoding='utf-8'))
generator.model.validate_document(document)
score_key = generator.engraving.cache.score_fingerprint(document, renderer)
destination = path.with_suffix('.pages')
clear_pages(destination)
entry = cache_dir / 'fullscore' / score_key if cache_dir else None
present = entry is not None and (entry.exists() or entry.is_symlink())
pages = generator.engraving.cache.restore_pages(entry, destination, validated_pages) if entry else None
if pages is None:
rejected += int(present)
missing += int(entry is not None and not present)
notation = generator.engraving.cache.notation_fingerprint(document, renderer)
groups.setdefault(notation, []).append((path, score_key))
else:
write_bundle(path, document, pages, fingerprint)
totals.fullscore += 1
completed += 1
except ValueError as error:
raise generator.engraving.types.EngravingError(f'{path.relative_to(root)}: {error}') from error
prepare_time = time.monotonic() - started
if progress:
print(f'Engraving grouped: {len(paths)} scores, {len(groups)} notation bodies, {totals.fullscore} fullscore hits', flush=True)
validation_time = 0.0
def finish(result):
nonlocal completed, validation_time
started = time.monotonic()
for path, score_key, pages in result.scores:
destination = path.with_suffix('.pages')
for page in pages:
generator.engraving.validation.check_page(destination / page['path'], validated_pages, expected_sha256=page['sha256'])
validation_time += time.monotonic() - started
if cache_dir:
started = time.monotonic()
stored = set()
for path, score_key, pages in result.scores:
if score_key not in stored:
generator.engraving.cache.store_pages(cache_dir / 'fullscore' / score_key, path.with_suffix('.pages'), pages)
stored.add(score_key)
result.timings['cache'] += time.monotonic() - started
for field in ('fullscore', 'bodyhits', 'bodies', 'renderedpages', 'rejected'):
setattr(totals, field, getattr(totals, field) + getattr(result, field))
for phase, elapsed in result.timings.items():
totals.timings[phase] += elapsed
completed += len(result.scores)
if progress:
print(f'Engraving: {completed}/{len(paths)} scores, fullscore {totals.fullscore}, bodyhits {totals.bodyhits}, bodies {totals.bodies}, renderedpages {totals.renderedpages}', flush=True)
jobs = min(jobs, len(groups))
if jobs <= 1:
for notation, group in groups.items():
finish(engrave_group(notation, group, root, fonts, cache_dir, fingerprint, progress))
else:
with concurrent.futures.ProcessPoolExecutor(max_workers=jobs, mp_context=multiprocessing.get_context('spawn')) as pool:
futures = [pool.submit(engrave_group, notation, group, root, fonts, cache_dir, fingerprint, progress) for notation, group in groups.items()]
for future in concurrent.futures.as_completed(futures):
finish(future.result())
if progress:
print(f'Engraving complete: {len(paths)} scores, {totals.fullscore} reused, {missing} missing, {rejected} rejected; fullscore {totals.fullscore}, bodyhits {totals.bodyhits}, bodies {totals.bodies}, renderedpages {totals.renderedpages}, rejected bodies {totals.rejected}', flush=True)
phases = {'prepare': prepare_time, **totals.timings, 'validation': validation_time}
print('Engraving timings: ' + ', '.join(f'{phase} {elapsed:.3f}s' for phase, elapsed in phases.items()) + f'; wall {time.monotonic() - started:.3f}s (worker phases summed)', flush=True)
return validated_pages
+55 -13
View File
@@ -1,13 +1,17 @@
from __future__ import annotations
import dataclasses
import math
import PIL.Image
import PIL.ImageDraw
import aggdraw
from fontTools.pens.basePen import BasePen
from PIL import Image, ImageDraw
import fontTools.pens.basePen
from generator.engraving.fonts import Fonts
from generator.engraving.types import Page
import generator.engraving.fonts
import generator.engraving.types
class OutlinePen(BasePen):
class OutlinePen(fontTools.pens.basePen.BasePen):
def __init__(self, glyphs, scale: float, x: float, y: float):
super().__init__(glyphs)
self.path = aggdraw.Path()
@@ -25,12 +29,15 @@ class OutlinePen(BasePen):
def _endPath(self):
pass
def rasterize_page(page: Page, fonts: Fonts) -> Image.Image:
def rasterize_page(page: generator.engraving.types.Page, fonts: generator.engraving.fonts.Fonts) -> PIL.Image.Image:
return rasterize_band(page, page.primitives, 0, page.height, fonts)
def rasterize_band(page: generator.engraving.types.Page, primitives: tuple[generator.engraving.types.Primitive, ...], top: int, bottom: int, fonts: generator.engraving.fonts.Fonts) -> PIL.Image.Image:
scale = page.supersampling
image = Image.new('RGB', (page.width * scale, page.height * scale), 'white')
image = PIL.Image.new('RGB', (page.width * scale, (bottom - top) * scale), 'white')
context = aggdraw.Draw(image)
for item in page.primitives:
points = [(x * scale, y * scale) for x, y in item.points]
for item in primitives:
points = [(x * scale, y * scale - top * scale) for x, y in item.points]
if item.kind == 'text':
continue
if item.kind == 'glyph':
@@ -52,8 +59,43 @@ def rasterize_page(page: Page, fonts: Fonts) -> Image.Image:
else:
context.path(path, aggdraw.Pen('black', item.thickness * scale))
context.flush()
drawing = ImageDraw.Draw(image)
for item in page.primitives:
drawing = PIL.ImageDraw.Draw(image)
for item in primitives:
if item.kind == 'text':
drawing.text(tuple(v * scale for v in item.points[0]), item.value, font=fonts.text_font(item.size * scale), anchor='ls', fill='black')
return image.resize((page.width, page.height), Image.Resampling.LANCZOS)
x, y = item.points[0]
drawing.text((x * scale, y * scale - top * scale), item.value, font=fonts.text_font(item.size * scale), anchor='ls', fill='black')
return image.resize((page.width, bottom - top), PIL.Image.Resampling.LANCZOS)
@dataclasses.dataclass(frozen=True)
class Band:
top: int
bottom: int
primitives: tuple[generator.engraving.types.Primitive, ...]
def page_bands(page: generator.engraving.types.Page) -> tuple[Band, Band, Band] | None:
body = tuple(p for system in page.systems for p in system.primitives)
header = tuple(p for p in page.primitives if p.role in ('title', 'subtitle'))
footer = tuple(p for p in page.primitives if p.role == 'page-number')
if not body or not header or not footer or page.primitives != header + body + footer:
return None
bands = []
for primitives in (header, body, footer):
box = generator.engraving.types.bounds(primitives)
if not box.finite() or box.left < 4 or box.right > page.width - 4 or box.top < 4 or box.bottom > page.height - 4:
return None
# Six output pixels cover raster antialiasing and the three-pixel Lanczos
# support. Integer crop origins preserve the full-page sampling phase.
bands.append(Band(max(0, math.floor(box.top) - 6), min(page.height, math.ceil(box.bottom) + 6), primitives))
if bands[0].bottom > bands[1].top or bands[1].bottom > bands[2].top:
return None
return tuple(bands)
def compose_page(page: generator.engraving.types.Page, fonts: generator.engraving.fonts.Fonts, bands: tuple[Band, Band, Band], body: PIL.Image.Image) -> PIL.Image.Image:
image = PIL.Image.new('RGB', (page.width, page.height), 'white')
if body.mode != 'RGB' or body.size != (page.width, bands[1].bottom - bands[1].top):
raise generator.engraving.types.EngravingError('notation band has incompatible geometry')
image.paste(body, (0, bands[1].top))
for band in (bands[0], bands[2]):
with rasterize_band(page, band.primitives, band.top, band.bottom, fonts) as decoration:
image.paste(decoration, (0, band.top))
return image
+34 -34
View File
@@ -1,13 +1,13 @@
from __future__ import annotations
from fractions import Fraction
import fractions
from generator.model import beat_lengths, fraction
from generator.engraving.fonts import Fonts
from generator.engraving.notation import ScoreIndex, flags
from generator.engraving.types import Anchor, Primitive, Style, bounds, curve, line, polygon
import generator.engraving.fonts
import generator.engraving.notation
import generator.engraving.types
import generator.model
def beam_groups(index: ScoreIndex) -> tuple[tuple[str, ...], ...]:
def beam_groups(index: generator.engraving.notation.ScoreIndex) -> tuple[tuple[str, ...], ...]:
if index._beam_groups is not None:
return index._beam_groups
groups = []
@@ -18,17 +18,17 @@ def beam_groups(index: ScoreIndex) -> tuple[tuple[str, ...], ...]:
note = event.value
primary = next((b['type'] for b in note.get('beams', []) if b['level'] == 1), None)
measure_start = next(start for start, _, measure in index.measures if measure['id'] == event.measure['id'])
beats = beat_lengths(index.state(event.start)['meter'])
beats = generator.model.beat_lengths(index.state(event.start)['meter'])
offset = event.start - measure_start
beat = 0
edge = Fraction()
edge = fractions.Fraction()
for duration in beats:
edge += duration
if offset < edge:
break
beat += 1
key = (event.measure['id'], beat, tuple(note.get('tuplet_ids', [])), bool(note.get('grace')))
eligible = note['kind'] == 'note' and flags(note['written']) and note.get('stem') != 'none' and not note.get('hidden')
eligible = note['kind'] == 'note' and generator.engraving.notation.flags(note['written']) and note.get('stem') != 'none' and not note.get('hidden')
if not eligible or primary == 'begin' or (primary is None and key != previous_key):
if current:
groups.append(current)
@@ -44,7 +44,7 @@ def beam_groups(index: ScoreIndex) -> tuple[tuple[str, ...], ...]:
index._beam_groups = tuple(tuple(g) for g in groups if len(g) > 1 or index.by_id[g[0]].value.get('beams'))
return index._beam_groups
def draw_beams(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive], start: Fraction, end: Fraction, left: float, right: float, style: Style) -> list[Primitive]:
def draw_beams(index: generator.engraving.notation.ScoreIndex, anchors: list[generator.engraving.types.Anchor], items: list[generator.engraving.types.Primitive], start: fractions.Fraction, end: fractions.Fraction, left: float, right: float, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]:
additions = []
removed = set()
for group in beam_groups(index):
@@ -65,7 +65,7 @@ def draw_beams(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive],
x1 = left if index.by_id[group[0]].start < start else first.stem_x
x2 = right if index.by_id[group[-1]].start >= end else last.stem_x
for anchor in selected:
additions.append(line('beam-stem', (anchor.event,), ((anchor.stem_x, anchor.stem_y), (anchor.stem_x, beam_y)), 1.4))
additions.append(generator.engraving.types.line('beam-stem', (anchor.event,), ((anchor.stem_x, anchor.stem_y), (anchor.stem_x, beam_y)), 1.4))
for level in range(1, max(a.flags for a in selected) + 1):
y = beam_y - direction * (level - 1) * style.space * 0.75
for i, anchor in enumerate(selected):
@@ -88,18 +88,18 @@ def draw_beams(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive],
b = a + (8 if next_anchor else -8)
else:
continue
additions.append(polygon('beam', sources, ((a, y), (b, y), (b, y - direction * style.space * 0.5), (a, y - direction * style.space * 0.5))))
additions.append(generator.engraving.types.polygon('beam', sources, ((a, y), (b, y), (b, y - direction * style.space * 0.5), (a, y - direction * style.space * 0.5))))
if selected and x1 < selected[0].stem_x and selected[0].flags >= level:
additions.append(line('beam', sources, ((x1, y), (selected[0].stem_x, y)), style.space * 0.5))
additions.append(generator.engraving.types.line('beam', sources, ((x1, y), (selected[0].stem_x, y)), style.space * 0.5))
return [p for p in items if not (p.role == 'flag' and any(s in removed for s in p.sources))] + additions
def draw_spans(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive], start: Fraction, end: Fraction, left: float, right: float, fonts: Fonts, style: Style) -> list[Primitive]:
def draw_spans(index: generator.engraving.notation.ScoreIndex, anchors: list[generator.engraving.types.Anchor], items: list[generator.engraving.types.Primitive], start: fractions.Fraction, end: fractions.Fraction, left: float, right: float, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]:
result = []
by_tone = {a.source: a for a in anchors}
by_event = {a.event: a for a in anchors}
for identifier, (event, tone) in index.tones.items():
target = tone.get('tie_to')
tone_end = event.start + fraction(tone['duration'])
tone_end = event.start + generator.model.fraction(tone['duration'])
anchor = by_tone.get(identifier)
if anchor and anchor.fragment.end < tone_end:
target = identifier
@@ -118,11 +118,11 @@ def draw_spans(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive],
y1 = anchor.y + sign * 8 if anchor else reference.y + sign * 8
y2 = destination.y + sign * 8 if destination else y1
if x2 > x1:
result.append(curve('tie', (identifier, target), ((x1, y1), (x1 + (x2 - x1) / 3, y1 + sign * 9), (x2 - (x2 - x1) / 3, y2 + sign * 9), (x2, y2)), 1.6))
result.append(generator.engraving.types.curve('tie', (identifier, target), ((x1, y1), (x1 + (x2 - x1) / 3, y1 + sign * 9), (x2 - (x2 - x1) / 3, y2 + sign * 9), (x2, y2)), 1.6))
if anchor and anchor.fragment.start > event.start:
y = anchor.y - anchor.direction * 8
if anchor.x - 2 > left:
result.append(curve('tie', (identifier,), ((left, y), (left + 5, y - anchor.direction * 9), (anchor.x - 7, y - anchor.direction * 9), (anchor.x - 2, y)), 1.6))
result.append(generator.engraving.types.curve('tie', (identifier,), ((left, y), (left + 5, y - anchor.direction * 9), (anchor.x - 7, y - anchor.direction * 9), (anchor.x - 2, y)), 1.6))
top = min(-style.space * 5, min((p.box.top for p in items if p.role not in ('harmony', 'lyric', 'section', 'tempo')), default=0))
tuplet_lanes = []
for span in index.staff.get('tuplets', []):
@@ -158,33 +158,33 @@ def draw_spans(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive],
if q <= p:
continue
if span.get('line_shape') == 'curved':
result.append(curve('tuplet-bracket', references, ((p, y + 3), (p + (q - p) / 3, y - 4), (q - (q - p) / 3, y - 4), (q, y + 3)), 1.3))
result.append(generator.engraving.types.curve('tuplet-bracket', references, ((p, y + 3), (p + (q - p) / 3, y - 4), (q - (q - p) / 3, y - 4), (q, y + 3)), 1.3))
else:
result.append(line('tuplet-bracket', references, ((p, y), (q, y)), 1.3))
result.append(generator.engraving.types.line('tuplet-bracket', references, ((p, y), (q, y)), 1.3))
if a.start >= start:
result.append(line('tuplet-bracket', references, ((x1, y), (x1, y + 5)), 1.3))
result.append(generator.engraving.types.line('tuplet-bracket', references, ((x1, y), (x1, y + 5)), 1.3))
if b.end <= end:
result.append(line('tuplet-bracket', references, ((x2, y), (x2, y + 5)), 1.3))
outer_top = min(top, bounds(result).top if result else top)
result.append(generator.engraving.types.line('tuplet-bracket', references, ((x2, y), (x2, y + 5)), 1.3))
outer_top = min(top, generator.engraving.types.bounds(result).top if result else top)
for span in index.staff.get('octave_shifts', []):
a, b = fraction(span['start']), fraction(span['end'])
a, b = generator.model.fraction(span['start']), generator.model.fraction(span['end'])
if b <= start or a >= end:
continue
members = [n for n in anchors if a <= n.time < b]
x1 = min((n.x for n in members), default=left) if a >= start else left
x2 = max((n.x + style.space * 2 for n in members), default=right) if b <= end else right
above = span['octaves'] < 0
y = outer_top - 18 if above else max(bounds(items + result).bottom, 0) + 24
y = outer_top - 18 if above else max(generator.engraving.types.bounds(items + result).bottom, 0) + 24
label = str(abs(span['octaves']) * 7 + 1) + ('va' if above else 'vb')
text = fonts.text(label, x1, y, 16, 'octave-shift', (span['id'],))
result.append(text)
outer_top = min(outer_top, text.box.top - 4)
cursor = text.box.right + 4
while cursor < x2:
result.append(line('octave-shift', (span['id'],), ((cursor, y - 5), (min(cursor + 6, x2), y - 5)), 1))
result.append(generator.engraving.types.line('octave-shift', (span['id'],), ((cursor, y - 5), (min(cursor + 6, x2), y - 5)), 1))
cursor += 10
if b <= end:
result.append(line('octave-shift', (span['id'],), ((x2, y - 5), (x2, y + (2 if above else -12))), 1))
result.append(generator.engraving.types.line('octave-shift', (span['id'],), ((x2, y - 5), (x2, y + (2 if above else -12))), 1))
for event in index.events:
tremolo = event.value.get('tremolo', {})
if tremolo.get('type') != 'start':
@@ -199,18 +199,18 @@ def draw_spans(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive],
x1, x2 = a.stem_x + 4 if a else left, b.stem_x - 4 if b else right
y = anchor.stem_y - anchor.direction * 10
for mark in range(tremolo['marks']):
result.append(line('tremolo', (event.value['id'], partner.value['id']), ((x1, y + mark * 6), (x2, y + mark * 6)), 3.5))
result.append(generator.engraving.types.line('tremolo', (event.value['id'], partner.value['id']), ((x1, y + mark * 6), (x2, y + mark * 6)), 3.5))
return items + result
def place_text_lanes(items: list[Primitive], fonts: Fonts, style: Style) -> list[Primitive]:
def place_text_lanes(items: list[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]:
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section', 'tempo')]
box = bounds(music)
box = generator.engraving.types.bounds(music)
result = list(music)
top = box.top - 12
for role in ('harmony', 'tempo', 'section'):
lane = [p for p in items if p.role == role]
if lane:
extent = bounds(lane)
extent = generator.engraving.types.bounds(lane)
result.extend(p.moved(0, top - extent.bottom) for p in lane)
top -= extent.bottom - extent.top + 10
lyrics = [p for p in items if p.role == 'lyric']
@@ -218,7 +218,7 @@ def place_text_lanes(items: list[Primitive], fonts: Fonts, style: Style) -> list
result.extend(p.moved(0, max(box.bottom, 0) + 20) for p in lyrics)
return result
def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[Anchor], start: Fraction, end: Fraction, left: float, right: float, fonts: Fonts) -> list[Primitive]:
def draw_lyric_spans(index: generator.engraving.notation.ScoreIndex, items: list[generator.engraving.types.Primitive], anchors: list[generator.engraving.types.Anchor], start: fractions.Fraction, end: fractions.Fraction, left: float, right: float, fonts: generator.engraving.fonts.Fonts) -> list[generator.engraving.types.Primitive]:
result = list(items)
labels = {p.sources[0]: p for p in items if p.role == 'lyric'}
positions = {a.event: a for a in anchors}
@@ -232,7 +232,7 @@ def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[An
if lyric_baseline is not None:
break
if lyric_baseline is None:
lyric_baseline = max(0, bounds(items).bottom) + 20
lyric_baseline = max(0, generator.engraving.types.bounds(items).bottom) + 20
for voice, number in index.lyric_lanes:
verses = [(e, v) for e in index.events if e.voice == voice and not e.value.get('hidden') for v in e.value.get('lyrics', []) if v['number'] == number]
for i, (event, verse) in enumerate(verses):
@@ -245,7 +245,7 @@ def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[An
continue
target = following[0].start if following else max(e.end for e in index.events if e.voice == voice)
if 'extend_to' in verse:
target = fraction(verse['extend_to'])
target = generator.model.fraction(verse['extend_to'])
if target <= start or event.start >= end:
continue
identifier = event.value['id'] + ':lyric:' + number
@@ -262,7 +262,7 @@ def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[An
if x2 <= x1:
continue
if extend in ('start', 'continue'):
result.append(line('lyric-extension', (identifier,), ((x1, y + 3), (x2, y + 3)), 1))
result.append(generator.engraving.types.line('lyric-extension', (identifier,), ((x1, y + 3), (x2, y + 3)), 1))
elif following:
count = max(1, int((x2 - x1) / 90))
for dash in range(count):
+9 -9
View File
@@ -1,8 +1,8 @@
from __future__ import annotations
from generator.model import fraction
from generator.engraving.notes import rest_glyph
from generator.engraving.types import bounds, curve, line
import generator.engraving.notes
import generator.engraving.types
import generator.model
def column(index, event, anchors, fonts, style):
if not index.staff.get('tablature') or not anchors:
@@ -11,7 +11,7 @@ def column(index, event, anchors, fonts, style):
sources = (event.value['id'],)
if not event.value['pitches']:
anchor = anchors[0]
glyph = 'noteheadSlashHorizontalEnds' if anchor.fragment.kind == 'slash' else rest_glyph(anchor.fragment.written_type)
glyph = 'noteheadSlashHorizontalEnds' if anchor.fragment.kind == 'slash' else generator.engraving.notes.rest_glyph(anchor.fragment.written_type)
return [fonts.glyph(glyph, x, -2 * style.space, style.space, 'tab-' + anchor.fragment.kind, sources)]
result = []
for anchor in anchors:
@@ -26,7 +26,7 @@ def staff(index, items, start, end, left, right, fonts, style):
numbers = [p for p in items if p.role.startswith('tab')]
if not index.staff.get('tablature'):
return items
offset = bounds(notation).bottom + 32 + 5 * style.space
offset = generator.engraving.types.bounds(notation).bottom + 32 + 5 * style.space
numbers = [p.moved(0, offset) for p in numbers]
drawing = list(numbers)
drawing.append(fonts.text('TAB', 0, offset - 2 * style.space, 15, 'tab-label', ()))
@@ -36,14 +36,14 @@ def staff(index, items, start, end, left, right, fonts, style):
cursor = 0
for a, b in cuts + [(right, right)]:
if a > cursor:
drawing.append(line('tab-staff', (), ((cursor, y), (a, y)), style.space * 0.13))
drawing.append(generator.engraving.types.line('tab-staff', (), ((cursor, y), (a, y)), style.space * 0.13))
cursor = max(cursor, b)
for bar in (p for p in notation if p.role == 'barline'):
drawing.append(line('tab-barline', bar.sources, tuple((x, offset + y * 5 / 4) for x, y in bar.points), bar.thickness))
drawing.append(generator.engraving.types.line('tab-barline', bar.sources, tuple((x, offset + y * 5 / 4) for x, y in bar.points), bar.thickness))
by_source = {source: p for p in numbers if p.role == 'tab' for source in p.sources[1:]}
for identifier, (event, tone) in index.tones.items():
target = tone.get('tie_to')
finish = event.start + fraction(tone['duration'])
finish = event.start + generator.model.fraction(tone['duration'])
if not target and not (event.start < start < finish or event.start < end < finish):
continue
if target:
@@ -55,5 +55,5 @@ def staff(index, items, start, end, left, right, fonts, style):
x2 = last.box.left - 2 if last else right
y = offset - (6 - tone['tablature']['string']) * style.space + 8
if x2 > x1:
drawing.append(curve('tab-tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y + 5), (x2 - (x2 - x1) / 3, y + 5), (x2, y))))
drawing.append(generator.engraving.types.curve('tab-tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y + 5), (x2 - (x2 - x1) / 3, y + 5), (x2, y))))
return notation + drawing
+22 -22
View File
@@ -1,13 +1,13 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from fractions import Fraction
import dataclasses
import fractions
import math
class EngravingError(ValueError):
pass
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class Style:
width: int = 976
height: int = 484
@@ -18,7 +18,7 @@ class Style:
column_gap: float = 10
system_gap: float = 26
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class Box:
left: float
top: float
@@ -31,7 +31,7 @@ class Box:
def finite(self) -> bool:
return all(math.isfinite(v) for v in (self.left, self.top, self.right, self.bottom)) and self.left <= self.right and self.top <= self.bottom
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class Primitive:
kind: str
role: str
@@ -42,30 +42,30 @@ class Primitive:
size: float = 0
thickness: float = 0
def moved(self, x: float, y: float) -> Primitive:
return replace(self, box=self.box.moved(x, y), points=tuple((a + x, b + y) for a, b in self.points))
return dataclasses.replace(self, box=self.box.moved(x, y), points=tuple((a + x, b + y) for a, b in self.points))
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class Fragment:
source: str
event: str
start: Fraction
end: Fraction
start: fractions.Fraction
end: fractions.Fraction
written_type: str
dots: int
ratio: Fraction
ratio: fractions.Fraction
visible: bool
kind: str
position: float | None
def length(self) -> Fraction:
def length(self) -> fractions.Fraction:
return self.end - self.start
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class Anchor:
source: str
event: str
voice: str
time: Fraction
end: Fraction
time: fractions.Fraction
end: fractions.Fraction
x: float
y: float
stem_x: float
@@ -74,18 +74,18 @@ class Anchor:
flags: int
fragment: Fragment
def moved(self, x: float, y: float) -> Anchor:
return replace(self, x=self.x + x, y=self.y + y, stem_x=self.stem_x + x, stem_y=self.stem_y + y)
return dataclasses.replace(self, x=self.x + x, y=self.y + y, stem_x=self.stem_x + x, stem_y=self.stem_y + y)
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class System:
start: Fraction
end: Fraction
start: fractions.Fraction
end: fractions.Fraction
primitives: tuple[Primitive, ...]
anchors: tuple[Anchor, ...]
fragments: tuple[Fragment, ...]
box: Box
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class Page:
number: int
width: int
@@ -94,7 +94,7 @@ class Page:
systems: tuple[System, ...]
primitives: tuple[Primitive, ...]
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class LayoutDocument:
pages: tuple[Page, ...]
style: Style
@@ -111,7 +111,7 @@ def line(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float],
return Primitive('line', role, sources, box, points, thickness=thickness)
def curve(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], ...], thickness: float = 1.6) -> Primitive:
return replace(line(role, sources, points, thickness), kind='curve')
return dataclasses.replace(line(role, sources, points, thickness), kind='curve')
def polygon(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], ...]) -> Primitive:
return replace(line(role, sources, points, 0), kind='polygon')
return dataclasses.replace(line(role, sources, points, 0), kind='polygon')
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import hashlib
import io
import pathlib
import re
import PIL.Image
import PIL.ImageChops
import generator.engraving.types
ENGINE_NAME = 'Vamp Python'
PAGE_WIDTH = 976
PAGE_HEIGHT = 484
def check_content(data: bytes, name: str) -> None:
with PIL.Image.open(io.BytesIO(data)) as image:
if image.format != 'PNG' or image.size != (PAGE_WIDTH, PAGE_HEIGHT):
raise generator.engraving.types.EngravingError(f'{name}: expected a {PAGE_WIDTH}x{PAGE_HEIGHT} PNG page')
image.load()
white = PIL.Image.new('RGBA', image.size, 'white')
white.alpha_composite(image.convert('RGBA'))
ink = PIL.ImageChops.difference(white.convert('RGB'), PIL.Image.new('RGB', image.size, 'white')).convert('L').point(lambda x: 255 if x > 32 else 0)
bounds = ink.getbbox()
if not bounds:
raise generator.engraving.types.EngravingError(f'{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 generator.engraving.types.EngravingError(f'{name}: notation reaches the page edge; refusing potentially clipped output')
def check_page(path: pathlib.Path, validated_pages: set[str] | None = None, *, expected_sha256: str | None = None) -> str:
# Trust content, never a filename or a caller-supplied checksum alone.
if path.is_symlink() or not path.is_file():
raise generator.engraving.types.EngravingError(f'{path.name}: expected a regular PNG file')
data = path.read_bytes()
digest = hashlib.sha256(data).hexdigest()
if expected_sha256 is not None and (not isinstance(expected_sha256, str) or not re.fullmatch('[0-9a-f]{64}', expected_sha256) or digest != expected_sha256):
raise generator.engraving.types.EngravingError(f'{path.name}: score page checksum mismatch')
if validated_pages is None or digest not in validated_pages:
check_content(data, path.name)
if validated_pages is not None:
validated_pages.add(digest)
return digest