@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user