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

This commit is contained in:
khodowany
2026-09-26 02:50:22 -05:00
parent 4d6348d354
commit 9fd5abd075
91 changed files with 4692 additions and 2484 deletions
+41 -33
View File
@@ -1,24 +1,28 @@
from __future__ import annotations
import argparse
import pathlib
import shutil
import tempfile
from pathlib import Path
from time import monotonic
import time
from generator.arrange.generate import generate_performance_scores
from generator.practice.generate import generate_practice_scores
from generator.score import count_json_files, validate_generated_tree
from generator.summary import validate_expected_performance_summary, validate_practice_parts
from generator.parts import source_paths
from generator.engraving import Fonts, Style, engrave_tree
from generator.engraving.cache import renderer_fingerprint
from generator.playback import compile_tree
from generator.catalog import build_catalog
import generator.arrange.generate
import generator.catalog
import generator.engraving.cache
import generator.engraving.fonts
import generator.engraving.pipeline
import generator.engraving.types
import generator.parts
import generator.pico_artwork
import generator.playback
import generator.practice.generate
import generator.rhythm
import generator.score
import generator.summary
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
def copy_runtime_fonts(static_assets_dir: Path, output_dir: Path) -> None:
def copy_runtime_fonts(static_assets_dir: pathlib.Path, output_dir: pathlib.Path) -> None:
source = static_assets_dir / "fonts" / "petaluma"
destination = output_dir / "fonts" / "petaluma"
destination.mkdir(parents=True, exist_ok=True)
@@ -27,12 +31,12 @@ def copy_runtime_fonts(static_assets_dir: Path, output_dir: Path) -> None:
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate Product Vamp package score assets.")
parser.add_argument("--data-dir", type=Path)
parser.add_argument("--output-dir", type=Path)
parser.add_argument("--data-dir", type=pathlib.Path)
parser.add_argument("--output-dir", type=pathlib.Path)
parser.add_argument("--renderer-fingerprint", action="store_true", help="Print the engraving cache identity without generating assets.")
parser.add_argument("--static-assets-dir", type=Path, default=Path("package/assets"))
parser.add_argument("--static-assets-dir", type=pathlib.Path, default=pathlib.Path("package/assets"))
parser.add_argument("--check", action="store_true")
parser.add_argument("--engraving-cache", type=Path)
parser.add_argument("--engraving-cache", type=pathlib.Path)
parser.add_argument("--jobs", type=int, default=1, choices=range(1, 17), metavar="1-16")
parser.add_argument("--progress", action="store_true")
args = parser.parse_args()
@@ -49,11 +53,11 @@ def generate_assets(args: argparse.Namespace) -> None:
if cache_dir and (cache_dir.is_relative_to(output_dir) or output_dir.is_relative_to(cache_dir)):
raise ValueError('engraving cache and generated assets must not overlap')
progress = getattr(args, 'progress', False)
started = monotonic()
started = time.monotonic()
def report(message):
if progress:
print(f'Assets [{monotonic() - started:.0f}s]: {message}', flush=True)
print(f'Assets [{time.monotonic() - started:.0f}s]: {message}', flush=True)
report('checking source paths')
for source in sorted(data_dir.rglob('*.xml')):
@@ -61,29 +65,33 @@ def generate_assets(args: argparse.Namespace) -> None:
continue
if source.parent not in (data_dir / 'practice', data_dir / 'songs'):
raise ValueError(f'{source}: MusicXML sources belong directly under data/practice or data/songs')
source_paths(data_dir / 'songs')
source_paths(data_dir / 'practice', practice=True)
generator.parts.source_paths(data_dir / 'songs')
generator.parts.source_paths(data_dir / 'practice', practice=True)
report('loading rhythm snippets')
rhythms = generator.rhythm.load_library(data_dir / 'rhythms')
output_dir.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.vamp-assets-', dir=output_dir.parent) as temporary:
staged = Path(temporary) / 'assets'
staged = pathlib.Path(temporary) / 'assets'
staged.mkdir(parents=True, exist_ok=True)
copy_runtime_fonts(static_assets_dir, staged)
report('generating performance and practice parts')
performance_count = generate_performance_scores(data_dir / 'songs', staged / 'songs')
practice_count = generate_practice_scores(data_dir / 'practice', staged / 'practice')
validate_generated_tree(staged)
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms)
practice_count = generator.practice.generate.generate_practice_scores(data_dir / 'practice', staged / 'practice', rhythms=rhythms)
generator.score.validate_generated_tree(staged)
if args.check:
validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
validate_practice_parts(staged / 'practice')
generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
generator.summary.validate_practice_parts(staged / 'practice')
report('compiling playback')
compile_tree(staged)
generator.playback.compile_tree(staged)
report('engraving pages')
engrave_tree(staged, static_assets_dir / 'fonts' / 'petaluma', cache_dir=cache_dir, jobs=getattr(args, 'jobs', 1), progress=progress)
validated_pages = generator.engraving.pipeline.engrave_tree(staged, static_assets_dir / 'fonts' / 'petaluma', cache_dir=cache_dir, jobs=getattr(args, 'jobs', 1), progress=progress)
report('rendering Pico titles')
generator.pico_artwork.generate_titles(staged, static_assets_dir / 'fonts' / 'petaluma')
report('validating score, page, and audio assets')
validate_generated_tree(staged)
generator.score.validate_generated_tree(staged, validated_pages)
report('building catalog database')
build_catalog(staged)
backup = Path(temporary) / 'previous'
generator.catalog.build_catalog(staged)
backup = pathlib.Path(temporary) / 'previous'
if output_dir.exists():
output_dir.rename(backup)
try:
@@ -94,14 +102,14 @@ def generate_assets(args: argparse.Namespace) -> None:
raise
report('publishing complete')
print(f'Generated Product Vamp assets: {performance_count} performance scores, '
f'{practice_count} practice parts, {count_json_files(output_dir)} total JSON files.')
f'{practice_count} practice parts, {generator.score.count_json_files(output_dir)} total JSON files.')
if args.check:
print('Generator check passed.')
def main() -> int:
args = parse_args()
if args.renderer_fingerprint:
print(renderer_fingerprint(Fonts(args.static_assets_dir / 'fonts' / 'petaluma'), Style()))
print(generator.engraving.cache.renderer_fingerprint(generator.engraving.fonts.Fonts(args.static_assets_dir / 'fonts' / 'petaluma'), generator.engraving.types.Style()))
else:
generate_assets(args)
return 0
+221
View File
@@ -0,0 +1,221 @@
from __future__ import annotations
import copy
import dataclasses
import fractions
import generator.model
import generator.rhythm
@dataclasses.dataclass(frozen=True)
class Context:
measure: dict
previous: dict | None
offset: fractions.Fraction
intervals: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
attacks: frozenset[fractions.Fraction]
occupied: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
@dataclasses.dataclass(frozen=True)
class Plan:
acoustic: tuple[generator.rhythm.Bar, ...]
electric: tuple[generator.rhythm.Bar, ...]
def active_chord(measure: dict, onset: fractions.Fraction, previous: dict | None) -> dict | None:
active = previous
for chord in measure['harmonies']:
if generator.model.fraction(chord['onset']) > onset:
break
active = chord
return active if active and active['kind'] != 'none' else None
def carried_chords(measures: list[dict], initial: dict | None = None) -> list[dict | None]:
result = []
for measure in measures:
result.append(initial)
if measure['harmonies']:
initial = measure['harmonies'][-1]
return result
def context(measure: dict, previous: dict | None) -> Context:
length = generator.model.fraction(measure['duration'])
nominal = generator.model.meter_length(measure['meter'])
offset = nominal - length if measure.get('implicit') and length < nominal else fractions.Fraction()
boundaries = sorted({fractions.Fraction(), length} | {generator.model.fraction(h['onset']) for h in measure['harmonies']})
intervals = tuple((a, b) for a, b in zip(boundaries, boundaries[1:]) if b > a and active_chord(measure, a, previous))
attacks, occupied = set(), []
for voice in measure['voices']:
for note in voice['notes']:
pitches = [p for p in note['pitches'] if not p.get('cue')]
if not pitches or note.get('cue'):
continue
start = generator.model.fraction(note['onset'])
end = start + generator.model.fraction(note['duration'])
if not all('tie_from' in p for p in pitches):
attacks.add(start)
occupied.append((start, end))
return Context(measure, previous, offset, intervals, frozenset(attacks), tuple(occupied))
def sounding_attacks(bar: generator.rhythm.Bar, item: Context) -> tuple[fractions.Fraction, ...]:
return tuple(t - item.offset for t in bar.attacks() if any(a <= t - item.offset < b for a, b in item.intervals))
def sounding_spans(bar: generator.rhythm.Bar, item: Context) -> tuple[tuple[fractions.Fraction, fractions.Fraction], ...]:
return tuple((max(event.onset - item.offset, a), min(event.onset + event.duration - item.offset, b)) for event in bar.events if event.sounding and event.onset + event.duration > item.offset for a, b in item.intervals if a <= max(event.onset - item.offset, 0) < b)
def rank(bar: generator.rhythm.Bar, contexts: list[tuple[Context, tuple[fractions.Fraction, ...]]], electric: bool) -> tuple:
missing_bars, uncovered, density, collisions, occupied, pulse = 0, 0, fractions.Fraction(), 0, 0, 0
for item, acoustic in contexts:
attacks = sounding_attacks(bar, item)
spans = sounding_spans(bar, item)
missing_bars += bool(item.intervals) and not spans and not electric
uncovered += sum(not any(start < b and end > a for start, end in spans) for a, b in item.intervals)
duration = sum((b - a for a, b in item.intervals), fractions.Fraction())
density += abs(len(attacks) - duration / (4 if electric else 2))
collisions += sum((t in item.attacks) + (t in acoustic) for t in attacks)
occupied += sum(any(a <= t < b for a, b in item.occupied) for t in attacks)
pulse -= sum(2 if t == 0 else 1 if t.denominator == 1 else 0 for t in attacks)
return missing_bars, uncovered, density, collisions, occupied, pulse
def add_ranks(left: tuple, right: tuple) -> tuple:
return tuple(a + b for a, b in zip(left, right))
def cycle(snippets: tuple[generator.rhythm.Snippet, ...], contexts: list[Context], acoustic: list[generator.rhythm.Bar] | None = None) -> tuple[generator.rhythm.Bar, ...]:
electric = acoustic is not None
bars = {}
for snippet in snippets:
for index, bar in enumerate(snippet.bars):
bars.setdefault(bar, f'{snippet.id}:{index}')
phases = [[(item, sounding_attacks(acoustic[i], item) if acoustic else ()) for i, item in enumerate(contexts) if i % 2 == phase] for phase in range(2)]
scores = [{bar: rank(bar, phase, electric) for bar in bars} for phase in phases]
candidates = [(add_ranks(scores[0][s.bars[0]], scores[1][s.bars[1]]), s.id, s.bars) for s in snippets if len(s.bars) == 2]
single = [s for s in snippets if len(s.bars) == 1]
if single:
pair = tuple(min(single, key=lambda s: (scores[phase][s.bars[0]], s.id)) for phase in range(2))
candidates.append((add_ranks(scores[0][pair[0].bars[0]], scores[1][pair[1].bars[0]]), '|'.join(s.id for s in pair), tuple(s.bars[0] for s in pair)))
score, _, pair = min(candidates, key=lambda candidate: candidate[:2])
if score[0]:
failing = next(item.measure for i, item in enumerate(contexts) if item.intervals and not sounding_spans(pair[i % 2], item))
raise ValueError(f'measure {failing["number"]}: no rhythm motif provides acoustic accompaniment in every harmonically active bar')
fourth = [(item, sounding_attacks(acoustic[i], item) if acoustic else ()) for i, item in enumerate(contexts) if i % 4 == 3]
second = pair[1]
alternatives = [bar for bar in bars if sum(bar.beat(n) != second.beat(n) for n in range(int(second.length))) == 1]
variation = min(alternatives, key=lambda bar: (rank(bar, fourth, electric), bars[bar])) if alternatives and fourth else second
if rank(variation, fourth, electric)[0]:
variation = second
result = (pair[0], second, pair[0], variation)
if electric and any(item.intervals for item in contexts) and not any(sounding_spans(result[i % 4], item) for i, item in enumerate(contexts)):
raise ValueError(f'measure {contexts[0].measure["number"]}: rhythm library cannot provide an audible electric response')
return result
def plan_section(measures: list[dict], library: generator.rhythm.Library, *, initial: dict | None = None, electric: bool = True) -> Plan:
previous = carried_chords(measures, initial)
acoustic, responses = [], []
start = 0
while start < len(measures):
meter = measures[start]['time_signature']
snippets = library.for_meter(meter)
end = start + 1
while end < len(measures) and measures[end]['time_signature'] == meter and not measures[end].get('section_label'):
end += 1
segment = measures[start:end]
for measure in segment:
if any('meter' in change and generator.model.fraction(change['onset']) != 0 for change in measure.get('attributes', [])):
raise ValueError(f'measure {measure["number"]}: accompaniment does not support mid-measure meter changes')
contexts = [context(m, previous[start + i]) for i, m in enumerate(segment)]
foundation = cycle(snippets, contexts)
chosen = [foundation[i % 4] for i in range(len(segment))]
response = cycle(snippets, contexts, chosen) if electric else foundation
acoustic.extend(chosen)
responses.extend(response[i % 4] for i in range(len(segment)))
start = end
return Plan(tuple(acoustic), tuple(responses))
def same_chord(left: dict | None, right: dict | None) -> bool:
return left is not None and right is not None and all(left.get(k) == right.get(k) for k in ('root', 'kind', 'degrees', 'bass', 'inversion'))
def normalize_beams(events: list[dict]) -> None:
for level in sorted({beam['level'] for event in events for beam in event.get('beams', [])}):
groups, group = [], []
for event in events:
beam = next((b for b in event.get('beams', []) if b['level'] == level), None)
if not beam or beam['type'] in ('begin', 'forward hook', 'backward hook'):
groups.append(group)
group = []
if beam:
group.append((event, beam))
if beam['type'] in ('end', 'forward hook', 'backward hook'):
groups.append(group)
group = []
groups.append(group)
for group in groups:
for index, (event, beam) in enumerate(group):
if len(group) > 1:
beam['type'] = 'begin' if index == 0 else 'end' if index == len(group) - 1 else 'continue'
elif level > 1 and any(b['level'] == level - 1 for b in event['beams']):
beam['type'] = 'backward hook' if beam['type'] in ('end', 'backward hook') else 'forward hook'
else:
event['beams'].remove(beam)
for event in events:
if not event.get('beams'):
event.pop('beams', None)
def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None) -> list[dict]:
item = context(measure, previous)
length = generator.model.fraction(measure['duration'])
events = []
for source in bar.events:
start, end = max(fractions.Fraction(), source.onset - item.offset), min(length, source.onset + source.duration - item.offset)
if start >= end:
continue
chord = active_chord(measure, start, previous) if source.sounding else None
continuation = source.continuation and source.onset - item.offset == start
preceding = previous
if continuation:
for change in measure['harmonies']:
if generator.model.fraction(change['onset']) >= start:
break
preceding = change
if not same_chord(chord, preceding):
chord = None
sounding_end = end
for change in measure['harmonies']:
time = generator.model.fraction(change['onset'])
if chord and start < time < sounding_end and not same_chord(chord, active_chord(measure, time, previous)):
sounding_end = time
written = {'type': source.written_type, 'dots': source.dots} if end - start == source.duration and sounding_end == end else None
event = generator.model.make_event(f'{measure["id"]}-rhythm-{len(events)}', start, sounding_end - start, 'note' if chord else 'rest', written=written)
if chord:
event['_harmony'] = chord
if continuation:
event['_rhythm_continuation'] = True
if source.beams and written and chord:
event['beams'] = [{'level': level, 'type': kind} for level, kind in source.beams]
events.append(event)
if sounding_end < end:
events.append(generator.model.make_event(f'{measure["id"]}-rhythm-{len(events)}', sounding_end, end - sounding_end, 'rest'))
normalize_beams(events)
return events
def connect_continuations(document: dict) -> bool:
previous = None
changed = False
for measure in document['staves'][0]['measures']:
if measure.get('section_label'):
previous = None
for voice in measure['voices']:
for note in voice['notes']:
continuation = note.pop('_rhythm_continuation', False)
if continuation and previous and previous['pitches'] and note['pitches']:
note['pitches'] = copy.deepcopy(previous['pitches'])
for index, tone in enumerate(note['pitches']):
source = previous['pitches'][index]
tone.update(id=f'{note["id"]}-p{index + 1}', duration=copy.deepcopy(note['duration']), written=copy.deepcopy(note['written']), tie_from=source['id'])
tone.pop('tie_to', None)
source['tie_to'] = tone['id']
changed = True
elif continuation:
note.update(kind='rest', pitches=[])
note.pop('beams', None)
changed = True
previous = note
return changed
+8 -8
View File
@@ -1,17 +1,17 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
import dataclasses
import typing
from generator.score import body_start_measure_index
import generator.score
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class ArrangementSection:
label: str
source_measure_index: int
measure_count: int
section_index: int
role: Literal["rhythm", "accompaniment", "melody", "vocal", "rest", "slash"]
role: typing.Literal["accompaniment", "melody", "vocal", "rest", "slash"]
def phrase_aware_edge_measure_count(body_measure_count: int) -> int:
if body_measure_count >= 8:
@@ -20,9 +20,9 @@ def phrase_aware_edge_measure_count(body_measure_count: int) -> int:
return 4
return body_measure_count
def section_role(part_type: str, section_index: int) -> Literal["rhythm", "accompaniment", "melody", "vocal", "rest", "slash"]:
def section_role(part_type: str, section_index: int) -> typing.Literal["accompaniment", "melody", "vocal", "rest", "slash"]:
if part_type == "acoustic":
return "slash" if section_index == 3 else "rhythm"
return "slash" if section_index == 3 else "accompaniment"
if part_type == "electric":
if section_index == 4:
return "slash"
@@ -36,7 +36,7 @@ def section_role(part_type: str, section_index: int) -> Literal["rhythm", "accom
def full_arrangement_sections(measures: list[dict], part_type: str) -> list[ArrangementSection]:
if not measures:
return []
body_start = body_start_measure_index(measures)
body_start = generator.score.body_start_measure_index(measures)
if body_start >= len(measures):
return []
body_measure_count = len(measures) - body_start
+77 -48
View File
@@ -1,104 +1,133 @@
from __future__ import annotations
import copy
from fractions import Fraction
from pathlib import Path
import fractions
import pathlib
from generator.arrange.form import full_arrangement_sections
from generator.guitar import place_guitar
from generator.parts import PART_TYPES, build_measure, prepare_document, propagated_chords, source_paths
from generator.model import fraction, key_label, make_event, meter_label, rational
from generator.musicxml import load_musicxml
from generator.score import copy_source_voices, make_arranged_document, reidentify, slugify, source_staff, write_json
import generator.accompaniment
import generator.arrange.form
import generator.guitar
import generator.model
import generator.musicxml
import generator.parts
import generator.rhythm
import generator.score
FINAL_SECTION_INDEX = 6
def arrange_document(document: dict, part_type: str) -> dict:
return _arrange_document(prepare_document(document), part_type)
def arrange_document(document: dict, part_type: str, *, rhythms: generator.rhythm.Library) -> dict:
document = generator.parts.prepare_document(document)
plans = plan_arrangement(document, rhythms) if part_type != 'vocal' else {}
return _arrange_document(document, part_type, plans)
def _arrange_document(document: dict, part_type: str) -> dict:
if part_type not in PART_TYPES:
def section_source(source: list[dict], final: bool) -> list[dict]:
result = copy.deepcopy(source)
for measure in result:
measure['harmonies'] = [h for h in measure['harmonies'] if not final or not h.get('chord_optional')]
for chord in measure['harmonies']:
chord.pop('chord_optional', None)
return result
def plan_arrangement(document: dict, rhythms: generator.rhythm.Library) -> dict[int, generator.accompaniment.Plan]:
source = generator.score.source_staff(document)['measures']
result = {}
for section in generator.arrange.form.full_arrangement_sections(source, 'acoustic'):
if section.role == 'slash':
continue
measures = section_source(source, section.section_index == FINAL_SECTION_INDEX)
previous = generator.accompaniment.carried_chords(measures)
start = section.source_measure_index
result[section.section_index] = generator.accompaniment.plan_section(measures[start:start + section.measure_count], rhythms, initial=previous[start], electric=section.section_index in (1, 5, 6))
return result
def _arrange_document(document: dict, part_type: str, plans: dict[int, generator.accompaniment.Plan]) -> dict:
if part_type not in generator.parts.PART_TYPES:
raise ValueError(f'unknown arrangement part: {part_type}')
staff = source_staff(document)
staff = generator.score.source_staff(document)
source = staff['measures']
sections = full_arrangement_sections(source, part_type)
sections = generator.arrange.form.full_arrangement_sections(source, part_type)
output, tuplets, octave_shifts = [], [], []
absolute = Fraction()
absolute = fractions.Fraction()
source_starts = []
source_time = Fraction()
source_time = fractions.Fraction()
for m in source:
source_starts.append(source_time)
source_time += fraction(m['duration'])
source_time += generator.model.fraction(m['duration'])
for section in sections:
previous = propagated_chords(source, include_optional=section.section_index != FINAL_SECTION_INDEX)
section_measures = section_source(source, section.section_index == FINAL_SECTION_INDEX)
previous = generator.accompaniment.carried_chords(section_measures)
segment = []
for si in range(section.source_measure_index, section.source_measure_index + section.measure_count):
m = copy.deepcopy(source[si])
m['harmonies'] = [h for h in m['harmonies'] if section.section_index != FINAL_SECTION_INDEX or not h.get('chord_optional')]
for h in m['harmonies']:
h.pop('chord_optional', None)
result = build_measure(m, f'measure-{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '')
m = section_measures[si]
rhythm = None
if section.role == 'accompaniment':
plan = plans[section.section_index]
rhythm = (plan.acoustic if part_type == 'acoustic' else plan.electric)[len(segment)]
result = generator.parts.build_measure(m, f'measure-{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '', rhythm)
segment.append(result)
identity_segment = segment
segment_length = sum((fraction(m['duration']) for m in segment), Fraction())
segment_length = sum((generator.model.fraction(m['duration']) for m in segment), fractions.Fraction())
if section.role in ('vocal', 'melody'):
start = source_starts[section.source_measure_index]
for shift in staff.get('octave_shifts', []):
low, high = max(start, fraction(shift['start'])), min(start + segment_length, fraction(shift['end']))
low, high = max(start, generator.model.fraction(shift['start'])), min(start + segment_length, generator.model.fraction(shift['end']))
if low < high:
octave_shifts.append({**shift, 'id': f'section-{section.section_index}-{shift["id"]}', 'start': rational(absolute + low - start), 'end': rational(absolute + high - start)})
octave_shifts.append({**shift, 'id': f'section-{section.section_index}-{shift["id"]}', 'start': generator.model.rational(absolute + low - start), 'end': generator.model.rational(absolute + high - start)})
if part_type == 'vocal' and section.section_index in (1, 5) and section.source_measure_index == 1 and source[0].get('implicit') and output:
pickup = build_measure(source[0], source[0]['id'], 'vocal', part_type, None)
pickup = generator.parts.build_measure(source[0], source[0]['id'], 'vocal', part_type, None)
destination = output[-1]
pad = fraction(destination['duration']) - fraction(pickup['duration'])
pad = generator.model.fraction(destination['duration']) - generator.model.fraction(pickup['duration'])
if pad < 0:
raise ValueError('pickup is longer than the preceding arrangement measure')
voices = copy_source_voices(pickup, strip_lyrics=False)
voices = generator.score.copy_source_voices(pickup, strip_lyrics=False)
for voice in voices:
for n in voice['notes']:
n['onset'] = rational(fraction(n['onset']) + pad)
n['onset'] = generator.model.rational(generator.model.fraction(n['onset']) + pad)
if pad:
voice['notes'].insert(0, make_event(f'pickup-rest-{voice["id"]}', Fraction(), pad, 'rest') | {'voice': voice['id']})
voice['notes'].insert(0, generator.model.make_event(f'pickup-rest-{voice["id"]}', fractions.Fraction(), pad, 'rest') | {'voice': voice['id']})
destination['voices'] = voices
identity_segment = [destination, *segment]
if pickup['harmonies']:
destination['harmonies'] = [h for h in destination['harmonies'] if fraction(h['onset']) < pad]
destination['harmonies'] = [h for h in destination['harmonies'] if generator.model.fraction(h['onset']) < pad]
for h in pickup['harmonies']:
h['onset'] = rational(fraction(h['onset']) + pad)
h['onset'] = generator.model.rational(generator.model.fraction(h['onset']) + pad)
destination['harmonies'].append(h)
state = {k: copy.deepcopy(pickup[k]) for k in ('key', 'clef', 'meter', 'transpose')}
if pad:
destination['attributes'].append({'onset': rational(pad), **state})
destination['attributes'].append({'onset': generator.model.rational(pad), **state})
else:
destination.update(state)
destination['key_signature'] = key_label(state['key'])
destination['time_signature'] = meter_label(state['meter'])
destination['key_signature'] = generator.model.key_label(state['key'])
destination['time_signature'] = generator.model.meter_label(state['meter'])
for change in pickup.get('attributes', []):
destination['attributes'].append({**change, 'onset': rational(fraction(change['onset']) + pad)})
destination['attributes'].append({**change, 'onset': generator.model.rational(generator.model.fraction(change['onset']) + pad)})
for shift in staff.get('octave_shifts', []):
low, high = fraction(shift['start']), min(fraction(pickup['duration']), fraction(shift['end']))
low, high = generator.model.fraction(shift['start']), min(generator.model.fraction(pickup['duration']), generator.model.fraction(shift['end']))
if low < high:
offset = absolute - fraction(pickup['duration'])
octave_shifts.append({**shift, 'id': f'pickup-{section.section_index}-{shift["id"]}', 'start': rational(offset + low), 'end': rational(offset + high)})
tuplets.extend(reidentify(identity_segment, staff.get('tuplets', [])))
offset = absolute - generator.model.fraction(pickup['duration'])
octave_shifts.append({**shift, 'id': f'pickup-{section.section_index}-{shift["id"]}', 'start': generator.model.rational(offset + low), 'end': generator.model.rational(offset + high)})
tuplets.extend(generator.score.reidentify(identity_segment, staff.get('tuplets', [])))
output.extend(segment)
absolute += segment_length
result = make_arranged_document(document, staff, staff_id='staff-arranged-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts)
result = generator.score.make_arranged_document(document, staff, staff_id='staff-arranged-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts)
result['metadata'].setdefault('tempo', 124)
if part_type != 'vocal':
place_guitar(result)
generator.guitar.place_guitar(result)
if generator.accompaniment.connect_continuations(result):
generator.guitar.place_guitar(result)
return result
def generate_performance_scores(source_dir: Path, output_dir: Path) -> int:
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library) -> int:
count = 0
for path in source_paths(source_dir):
for path in generator.parts.source_paths(source_dir):
try:
document = prepare_document(load_musicxml(path))
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
plans = plan_arrangement(document, rhythms)
except ValueError as error:
raise ValueError(f'{path}: {error}') from error
for part in PART_TYPES:
for part in generator.parts.PART_TYPES:
try:
write_json(output_dir / f'{slugify(path.stem)}-{part}.json', _arrange_document(document, part))
generator.score.write_json(output_dir / f'{generator.score.slugify(path.stem)}-{part}.json', _arrange_document(document, part, plans))
except ValueError as error:
raise ValueError(f'{path}, part {part}: {error}') from error
count += 1
+14 -9
View File
@@ -2,9 +2,10 @@ from __future__ import annotations
import argparse
import json
import pathlib
import re
import sqlite3
import tempfile
from pathlib import Path
APPLICATION_ID = 0x56414D50
COMMON_COLUMNS = """
@@ -15,10 +16,11 @@ COMMON_COLUMNS = """
source_path TEXT NOT NULL UNIQUE CHECK(length(source_path) > 0),
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
measure_count INTEGER NOT NULL CHECK(measure_count > 0),
total_pages INTEGER NOT NULL CHECK(total_pages BETWEEN 1 AND 10000)
total_pages INTEGER NOT NULL CHECK(total_pages BETWEEN 1 AND 10000),
title_image TEXT NOT NULL CHECK(length(title_image) = 64 AND title_image NOT GLOB '*[^0-9a-f]*')
"""
def metadata(path: Path, root: Path) -> dict:
def metadata(path: pathlib.Path, root: pathlib.Path) -> dict:
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
raise ValueError('score escapes the asset root')
text = path.read_text(encoding='utf-8')
@@ -33,14 +35,17 @@ def metadata(path: Path, root: Path) -> dict:
if not isinstance(meta[name], str):
raise ValueError(f'{name} must be a string')
key = meta['key_signature']
title_image = presentation.get('title_image')
if not isinstance(title_image, str) or not re.fullmatch('[0-9a-f]{64}', title_image):
raise ValueError('presentation requires a generated Pico title image; regenerate assets')
if not isinstance(staff['name'], str):
raise ValueError('staff name must be a string')
relative = path.relative_to(root)
return dict(id=path.stem, title=meta['title'], composer=meta['composer'], key_signature=key,
source_path=relative.as_posix(), source_json=text,
measure_count=len(staff['measures']), total_pages=len(presentation['pages']), part=staff['name'])
measure_count=len(staff['measures']), total_pages=len(presentation['pages']), title_image=title_image, part=staff['name'])
def populate(connection: sqlite3.Connection, root: Path) -> None:
def populate(connection: sqlite3.Connection, root: pathlib.Path) -> None:
if not root.is_dir():
raise ValueError(f'{root}: score asset directory not found')
connection.executescript(f'''
@@ -81,13 +86,13 @@ def populate(connection: sqlite3.Connection, root: Path) -> None:
if connection.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
raise ValueError('catalog integrity check failed')
def build_catalog(root: Path, destination: Path | None = None) -> Path:
def build_catalog(root: pathlib.Path, destination: pathlib.Path | None = None) -> pathlib.Path:
if not root.is_dir():
raise ValueError(f'{root}: score asset directory not found')
destination = destination or root / 'catalog.db'
destination.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.vamp-catalog-', dir=destination.parent) as temporary:
staged = Path(temporary) / 'catalog.db'
staged = pathlib.Path(temporary) / 'catalog.db'
connection = sqlite3.connect(staged)
try:
populate(connection, root)
@@ -98,7 +103,7 @@ def build_catalog(root: Path, destination: Path | None = None) -> Path:
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Compile generated score bundles into a read-only runtime catalog.')
parser.add_argument('assets', type=Path)
parser.add_argument('destination', type=Path, nargs='?')
parser.add_argument('assets', type=pathlib.Path)
parser.add_argument('destination', type=pathlib.Path, nargs='?')
arguments = parser.parse_args()
build_catalog(arguments.assets, arguments.destination)
+1 -108
View File
@@ -1,108 +1 @@
from __future__ import annotations
from concurrent.futures import ProcessPoolExecutor
import hashlib
import json
import multiprocessing
from pathlib import Path
import shutil
from PIL import Image, ImageChops
from generator.engraving.fonts import Fonts
from generator.engraving.cache import renderer_fingerprint, renderer_namespace, restore_pages, score_fingerprint, store_pages
from generator.engraving.layout import layout_score
from generator.engraving.raster import rasterize_page
from generator.engraving.types import EngravingError, Style
from generator.model import validate_document
ENGINE_NAME = 'Vamp Python'
PAGE_WIDTH = 976
PAGE_HEIGHT = 484
def check_page(path: Path) -> None:
with Image.open(path) as image:
image.load()
if image.format != 'PNG' or image.size != (PAGE_WIDTH, PAGE_HEIGHT):
raise EngravingError(f'{path.name}: expected a {PAGE_WIDTH}x{PAGE_HEIGHT} PNG page')
white = Image.new('RGBA', image.size, 'white')
white.alpha_composite(image.convert('RGBA'))
ink = ImageChops.difference(white.convert('RGB'), Image.new('RGB', image.size, 'white')).convert('L').point(lambda x: 255 if x > 32 else 0)
bounds = ink.getbbox()
if not bounds:
raise EngravingError(f'{path.name}: engraver produced a blank page')
if bounds[0] < 4 or bounds[1] < 4 or bounds[2] > PAGE_WIDTH - 4 or bounds[3] > PAGE_HEIGHT - 4:
raise EngravingError(f'{path.name}: notation reaches the page edge; refusing potentially clipped output')
def engrave_scores(paths: list[Path], root: Path, fonts: Path, cache_dir: Path | None, progress: bool, worker: int, renderer: str) -> tuple[int, int, int, int]:
family = Fonts(fonts)
style = Style()
fingerprint = family.fingerprint(style)
reused, missing, rejected = 0, 0, 0
for index, path in enumerate(paths, 1):
document = json.loads(path.read_text(encoding='utf-8'))
try:
validate_document(document)
destination = path.with_suffix('.pages')
if destination.exists():
shutil.rmtree(destination)
entry = cache_dir / score_fingerprint(document, renderer) if cache_dir else None
present = entry is not None and (entry.exists() or entry.is_symlink())
pages = restore_pages(entry, destination) if entry else None
if pages is None:
if entry:
rejected += int(present)
missing += int(not present)
if destination.exists():
shutil.rmtree(destination)
destination.mkdir()
layout = layout_score(document, style, family)
pages = []
for page in layout.pages:
name = f'{page.number:04}.png'
target = destination / name
rasterize_page(page, family).save(target, format='PNG')
check_page(target)
pages.append({'path': name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
if entry:
store_pages(entry, destination, pages)
else:
reused += 1
entries = [{**page, 'path': destination.name + '/' + page['path']} for page in pages]
music = {k: v for k, v in document.items() if k != 'playback'}
bundle = {'music': music, 'presentation': {'engine': ENGINE_NAME, 'style_sha256': fingerprint, 'width': PAGE_WIDTH, 'height': PAGE_HEIGHT, 'pages': entries}}
if 'playback' in document:
bundle['playback'] = document['playback']
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
if progress and (index % 50 == 0 or index == len(paths)):
print(f'Engraving worker {worker}: {index}/{len(paths)} scores, {reused} reused, {missing} missing, {rejected} rejected', flush=True)
except ValueError as error:
raise EngravingError(f'{path.relative_to(root)}: {error}') from error
return len(paths), reused, missing, rejected
def engrave_tree(root: Path, fonts: Path, *, cache_dir: Path | None = None, jobs: int = 1, progress: bool = False) -> None:
if type(jobs) is not int or not 1 <= jobs <= 16:
raise ValueError('engraving jobs must be between 1 and 16')
if cache_dir and (cache_dir.resolve().is_relative_to(root.resolve()) or root.resolve().is_relative_to(cache_dir.resolve())):
raise ValueError('engraving cache and generated assets must not overlap')
paths = sorted(root.rglob('*.json'))
if not paths:
return
renderer = renderer_fingerprint(Fonts(fonts), Style()) if cache_dir else ''
if cache_dir:
cache_dir = renderer_namespace(cache_dir.resolve(), renderer)
if progress:
state = 'warm namespace' if cache_dir.is_dir() else 'cold namespace'
print(f'Engraving cache enabled: {cache_dir} ({state}; renderer {renderer})', flush=True)
elif progress:
print('Engraving cache disabled', flush=True)
jobs = min(jobs, len(paths))
if jobs == 1:
results = [engrave_scores(paths, root, fonts, cache_dir, progress, 1, renderer)]
else:
with ProcessPoolExecutor(max_workers=jobs, mp_context=multiprocessing.get_context('spawn')) as pool:
futures = [pool.submit(engrave_scores, paths[index::jobs], root, fonts, cache_dir, progress, index + 1, renderer) for index in range(jobs)]
results = [future.result() for future in futures]
if progress:
total, reused, missing, rejected = (sum(row[index] for row in results) for index in range(4))
print(f'Engraving complete: {total} scores, {reused} reused, {missing} missing, {rejected} rejected', flush=True)
"""Score engraving implementation; import the owning modules explicitly."""
+25 -26
View File
@@ -1,13 +1,13 @@
from __future__ import annotations
from fractions import Fraction
import fractions
from generator.model import LETTERS, TYPE_LENGTHS, alteration, fraction, key_from_fifths
from generator.engraving.fonts import Fonts
from generator.engraving.notation import ScoreIndex, accidental
from generator.engraving.types import Primitive, bounds, line
import generator.engraving.fonts
import generator.engraving.notation
import generator.engraving.types
import generator.model
def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Fonts, space: float, source: str, fields: tuple[str, ...]) -> list[Primitive]:
def staff_attributes(index: generator.engraving.notation.ScoreIndex, state: dict, time: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, space: float, source: str, fields: tuple[str, ...]) -> list[generator.engraving.types.Primitive]:
result = []
x = 0
references = (source,)
@@ -22,18 +22,18 @@ def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Font
if octave := clef.get('octave_change', 0):
label = str(abs(octave) * 7 + 1)
result.append(fonts.text(label, (glyph.box.left + glyph.box.right) / 2, glyph.box.top - 2 if octave > 0 else glyph.box.bottom + 14, 14, 'clef-octave', references, True))
x = bounds(result).right + space
x = generator.engraving.types.bounds(result).right + space
if 'key' in fields and clef['sign'] != 'percussion':
key = state['key']
steps = key.get('steps')
if steps is None:
order = 'fcgdaeb' if key.get('fifths', 0) >= 0 else 'beadgcf'
steps = [{'letter': c, 'alter': key['alterations'][LETTERS.index(c)]} for c in order if alteration(key['alterations'][LETTERS.index(c)])]
steps = [{'letter': c, 'alter': key['alterations'][generator.model.LETTERS.index(c)]} for c in order if generator.model.alteration(key['alterations'][generator.model.LETTERS.index(c)])]
cancellation = key.get('cancel', 0)
if cancellation:
order = 'fcgdaeb' if cancellation > 0 else 'beadgcf'
old = key_from_fifths(cancellation)['alterations']
steps = [{'letter': c, 'alter': 0} for c in order if alteration(old[LETTERS.index(c)]) and alteration(old[LETTERS.index(c)]) != alteration(key['alterations'][LETTERS.index(c)])] + steps
old = generator.model.key_from_fifths(cancellation)['alterations']
steps = [{'letter': c, 'alter': 0} for c in order if generator.model.alteration(old[generator.model.LETTERS.index(c)]) and generator.model.alteration(old[generator.model.LETTERS.index(c)]) != generator.model.alteration(key['alterations'][generator.model.LETTERS.index(c)])] + steps
for step in steps:
pitch = {'letter': step['letter'], 'octave': step.get('octave', 5), 'id': source}
position = index.pitch_position(pitch, time)
@@ -42,9 +42,9 @@ def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Font
position -= 7
while position < 2:
position += 7
items = accidental(fonts, alteration(step['alter']), {'name': step.get('accidental', '')}, space, references)
items = generator.engraving.notation.accidental(fonts, generator.model.alteration(step['alter']), {'name': step.get('accidental', '')}, space, references)
result.extend(p.moved(x, -position * space / 2) for p in items)
x += bounds(items).right - bounds(items).left + 4
x += generator.engraving.types.bounds(items).right - generator.engraving.types.bounds(items).left + 4
if steps:
x += space / 2
if 'meter' in fields:
@@ -67,8 +67,8 @@ def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Font
items = []
cursor = 0
if y == -space and meter.get('symbol') in ('note', 'dotted-note'):
unit = Fraction(4, group['unit'])
kind = next((name for name, length in TYPE_LENGTHS.items() if length == unit), None)
unit = fractions.Fraction(4, group['unit'])
kind = next((name for name, length in generator.model.TYPE_LENGTHS.items() if length == unit), None)
glyph_name = {'whole': 'noteWhole', 'half': 'noteHalfUp', 'quarter': 'noteQuarterUp', 'eighth': 'note8thUp'}.get(kind, 'note' + str(kind) + 'Up')
if kind and glyph_name in fonts.names:
glyph = fonts.glyph(glyph_name, 0, y + space, space * 0.55, 'meter', references)
@@ -82,36 +82,35 @@ def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Font
items.append(glyph)
cursor = glyph.box.right + 1
groups.append(items)
width = max(bounds(items).right for items in groups)
width = max(generator.engraving.types.bounds(items).right for items in groups)
for items in groups:
result.extend(p.moved(x + (width - bounds(items).right) / 2, 0) for p in items)
result.extend(p.moved(x + (width - generator.engraving.types.bounds(items).right) / 2, 0) for p in items)
x += width + 6
if 'staff_details' in fields and state['staff_details'].get('capo'):
result.append(fonts.text(f'Capo {state["staff_details"]["capo"]}', x, -space * (lines + 1), 16, 'staff-details', references))
return result
def barline(style: str, x: float, top: float, source: str) -> list[Primitive]:
def barline(style: str, x: float, top: float, source: str) -> list[generator.engraving.types.Primitive]:
if style == 'none':
return []
references = (source,)
if style in ('dashed', 'dotted'):
dash, gap = (5, 4) if style == 'dashed' else (1, 4)
return [line('barline', references, ((x, y), (x, min(y + dash, 0)))) for y in range(round(top), 0, dash + gap)]
return [generator.engraving.types.line('barline', references, ((x, y), (x, min(y + dash, 0)))) for y in range(round(top), 0, dash + gap)]
if style in ('tick', 'short'):
return [line('barline', references, ((x, top - 4 if style == 'tick' else top * 0.75), (x, top + 5 if style == 'tick' else top * 0.25)))]
return [generator.engraving.types.line('barline', references, ((x, top - 4 if style == 'tick' else top * 0.75), (x, top + 5 if style == 'tick' else top * 0.25)))]
weights = {'regular': [1.4], 'heavy': [4], 'light-light': [1.4, 1.4], 'light-heavy': [1.4, 4], 'heavy-light': [4, 1.4], 'heavy-heavy': [4, 4]}[style]
return [line('barline', references, ((x - (len(weights) - 1 - i) * 5, top), (x - (len(weights) - 1 - i) * 5, 0)), width) for i, width in enumerate(weights)]
return [generator.engraving.types.line('barline', references, ((x - (len(weights) - 1 - i) * 5, top), (x - (len(weights) - 1 - i) * 5, 0)), width) for i, width in enumerate(weights)]
def tempo(value: dict, fonts: Fonts, x: float, y: float, space: float, source: str) -> list[Primitive]:
from generator.engraving.notation import flags, head_glyph
def tempo(value: dict, fonts: generator.engraving.fonts.Fonts, x: float, y: float, space: float, source: str) -> list[generator.engraving.types.Primitive]:
written = value['written']
references = (source,)
glyph = fonts.glyph(head_glyph(written, None), x, y, space * 0.7, 'tempo', references)
glyph = fonts.glyph(generator.engraving.notation.head_glyph(written, None), x, y, space * 0.7, 'tempo', references)
items = [glyph]
stem_x = glyph.box.right - 0.5
if written['type'] not in ('whole', 'breve', 'long', 'maxima'):
items.append(line('tempo', references, ((stem_x, y), (stem_x, y - 23)), 1))
if flags(written):
items.append(generator.engraving.types.line('tempo', references, ((stem_x, y), (stem_x, y - 23)), 1))
if generator.engraving.notation.flags(written):
name = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + 'Up'
items.append(fonts.glyph(name, stem_x, y - 23, space * 0.7, 'tempo', references))
cursor = max(p.box.right for p in items) + 3
@@ -119,5 +118,5 @@ def tempo(value: dict, fonts: Fonts, x: float, y: float, space: float, source: s
dot = fonts.glyph('augmentationDot', cursor, y - 3, space * 0.7, 'tempo', references)
items.append(dot)
cursor = dot.box.right + 3
items.append(fonts.text(' = ' + str(fraction(value['bpm'])), cursor, y + 3, 16, 'tempo', references))
items.append(fonts.text(' = ' + str(generator.model.fraction(value['bpm'])), cursor, y + 3, 16, 'tempo', references))
return items
+45 -41
View File
@@ -1,48 +1,52 @@
from __future__ import annotations
from fractions import Fraction
import fractions
from generator.model import TYPE_LENGTHS, fraction
from generator import harmony
from generator.engraving.fonts import Fonts
from generator.engraving.notation import ScoreIndex, head_glyph
from generator.engraving.types import EngravingError, LayoutDocument
import generator.engraving.fonts
import generator.engraving.notation
import generator.engraving.types
import generator.harmony
import generator.model
def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
index = ScoreIndex(document)
def audit_primitives(primitives, width, height, fonts):
for item in primitives:
if not item.box.finite():
raise generator.engraving.types.EngravingError(f'{item.sources}: non-finite or inverted primitive geometry')
if item.box.left < 4 or item.box.top < 4 or item.box.right > width - 4 or item.box.bottom > height - 4:
raise generator.engraving.types.EngravingError(f'{item.sources}, {item.role}: primitive outside page bounds')
if item.kind == 'glyph':
actual = fonts.glyph_box(item.value, item.size).moved(*item.points[0])
if any(abs(a - b) > 0.001 for a, b in zip((actual.left, actual.top, actual.right, actual.bottom), (item.box.left, item.box.top, item.box.right, item.box.bottom))):
raise generator.engraving.types.EngravingError(f'{item.sources}: glyph bounds disagree with its actual outline')
def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocument, fonts: generator.engraving.fonts.Fonts) -> None:
index = generator.engraving.notation.ScoreIndex(document)
fragments, primitives = {}, {}
previous_end = Fraction()
previous_end = fractions.Fraction()
for page in layout.pages:
audit_primitives(page.primitives, page.width, page.height, fonts)
for item in page.primitives:
if not item.box.finite():
raise EngravingError(f'{item.sources}: non-finite or inverted primitive geometry')
if item.box.left < 4 or item.box.top < 4 or item.box.right > page.width - 4 or item.box.bottom > page.height - 4:
raise EngravingError(f'{item.sources}, {item.role}: primitive outside page bounds')
if item.kind == 'glyph':
actual = fonts.glyph_box(item.value, item.size).moved(*item.points[0])
if any(abs(a - b) > 0.001 for a, b in zip((actual.left, actual.top, actual.right, actual.bottom), (item.box.left, item.box.top, item.box.right, item.box.bottom))):
raise EngravingError(f'{item.sources}: glyph bounds disagree with its actual outline')
for source in item.sources:
primitives.setdefault(source, []).append(item)
for system in page.systems:
if system.start != previous_end or system.end <= system.start:
raise EngravingError('layout systems do not partition the score timeline')
raise generator.engraving.types.EngravingError('layout systems do not partition the score timeline')
previous_end = system.end
baseline = max(p.points[0][1] for p in system.primitives if p.role == 'staff')
for anchor in system.anchors:
if anchor.fragment.kind == 'tone':
expected = index.pitch_position(index.tones[anchor.source][1], anchor.time)
if abs(anchor.y - (baseline - expected * layout.style.space / 2)) > 0.01:
raise EngravingError(f'{anchor.source}: displayed staff position changed pitch')
raise generator.engraving.types.EngravingError(f'{anchor.source}: displayed staff position changed pitch')
role = 'head' if anchor.fragment.kind == 'tone' else anchor.fragment.kind
candidates = [p for p in system.primitives if p.role == role and anchor.source in p.sources and abs(p.points[0][0] - anchor.x) < 0.01]
if not candidates:
raise EngravingError(f'{anchor.source}: visible anchor has no corresponding drawing primitive')
raise generator.engraving.types.EngravingError(f'{anchor.source}: visible anchor has no corresponding drawing primitive')
if role == 'head' and candidates[0].kind == 'glyph':
event, tone = index.tones[anchor.source]
expected_glyph = head_glyph({'type': anchor.fragment.written_type}, tone.get('notehead', event.value.get('notehead')))
expected_glyph = generator.engraving.notation.head_glyph({'type': anchor.fragment.written_type}, tone.get('notehead', event.value.get('notehead')))
if candidates[0].value != expected_glyph:
raise EngravingError(f'{anchor.source}: notehead changed written duration or shape')
raise generator.engraving.types.EngravingError(f'{anchor.source}: notehead changed written duration or shape')
if index.staff.get('tablature') and anchor.fragment.kind == 'tone':
tone = index.tones[anchor.source][1]
tab = tone['tablature']
@@ -51,7 +55,7 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
expected_x = min(a.x for a in system.anchors if a.event == anchor.event and a.time == anchor.time)
tabs = [p for p in system.primitives if p.role == 'tab' and anchor.source in p.sources]
if len(tabs) != 1 or tabs[0].value != str(tab['fret']) or abs(tabs[0].points[0][1] - expected_y) > 0.01 or abs(tabs[0].points[0][0] - expected_x) > 0.01:
raise EngravingError(f'{anchor.source}: tablature changed position or rhythmic alignment')
raise generator.engraving.types.EngravingError(f'{anchor.source}: tablature changed position or rhythmic alignment')
for fragment in system.fragments:
fragments.setdefault(fragment.source, []).append(fragment)
# Staff, ledger, stem, beam and tie contacts are intentional. Distinct
@@ -64,57 +68,57 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
if item.role != other.role and {item.role, other.role} - {'head', 'tab', 'accidental'}:
continue
if item.box.overlaps(other.box, 0.1):
raise EngravingError(f'{item.sources} and {other.sources}: colliding {item.role}/{other.role}')
raise generator.engraving.types.EngravingError(f'{item.sources} and {other.sources}: colliding {item.role}/{other.role}')
if previous_end != index.end:
raise EngravingError('layout omits the end of the score')
raise generator.engraving.types.EngravingError('layout omits the end of the score')
for event in index.events:
note = event.value
for source in note['pitches'] or [note]:
identifier = source['id']
pieces = sorted(fragments.get(identifier, []), key=lambda f: f.start)
if not pieces:
raise EngravingError(f'{identifier}: missing notation fragments')
raise generator.engraving.types.EngravingError(f'{identifier}: missing notation fragments')
cursor = event.start
for fragment in pieces:
expected_visible = not note.get('hidden') and note['kind'] != 'spacer' and source.get('print', {}).get('print-object') != 'no'
if isinstance(source.get('notehead'), dict) and source['notehead'].get('name') == 'none':
expected_visible = False
if fragment.visible != expected_visible:
raise EngravingError(f'{identifier}: notation visibility changed')
raise generator.engraving.types.EngravingError(f'{identifier}: notation visibility changed')
if fragment.start != cursor:
raise EngravingError(f'{identifier}: fragments overlap or omit metric time')
raise generator.engraving.types.EngravingError(f'{identifier}: fragments overlap or omit metric time')
cursor = fragment.end
expected = TYPE_LENGTHS[fragment.written_type] * (2 - Fraction(1, 2 ** fragment.dots)) * fragment.ratio
expected = generator.model.TYPE_LENGTHS[fragment.written_type] * (2 - fractions.Fraction(1, 2 ** fragment.dots)) * fragment.ratio
if not note.get('grace') and not note.get('measure_rest') and fragment.length() != expected:
raise EngravingError(f'{identifier}: written fragment duration is inexact')
raise generator.engraving.types.EngravingError(f'{identifier}: written fragment duration is inexact')
if fragment.visible and not any(p.role in ('head', 'tab', 'rest', 'slash') for p in primitives.get(identifier, [])):
raise EngravingError(f'{identifier}: visible notation has no drawing primitive')
if cursor != event.start + fraction(source['duration']):
raise EngravingError(f'{identifier}: rendered duration changed')
raise generator.engraving.types.EngravingError(f'{identifier}: visible notation has no drawing primitive')
if cursor != event.start + generator.model.fraction(source['duration']):
raise generator.engraving.types.EngravingError(f'{identifier}: rendered duration changed')
if any(f.visible for f in pieces) and identifier in index.tones and index.accidentals[identifier]:
if not any(p.role == 'accidental' for p in primitives.get(identifier, [])):
raise EngravingError(f'{identifier}: required accidental was omitted')
raise generator.engraving.types.EngravingError(f'{identifier}: required accidental was omitted')
if identifier in index.tones and source.get('tie_to') and any(f.visible for f in pieces):
if not any(p.role == 'tie' for p in primitives.get(identifier, [])):
raise EngravingError(f'{identifier}: tie was omitted')
raise generator.engraving.types.EngravingError(f'{identifier}: tie was omitted')
if not note.get('hidden'):
for verse in note.get('lyrics', []):
text = ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision'))
identifier = note['id'] + ':lyric:' + verse['number']
if text and not any(p.role == 'lyric' and p.value == text for p in primitives.get(identifier, [])):
raise EngravingError(f'{identifier}: lyric text was omitted or changed')
raise generator.engraving.types.EngravingError(f'{identifier}: lyric text was omitted or changed')
for _, _, measure in index.measures:
for value in measure['harmonies']:
label = harmony.symbol(value)
label = generator.harmony.symbol(value)
if not any(p.role == 'harmony' and p.value == label for p in primitives.get(value['id'], [])):
raise EngravingError(f'{value["id"]}: harmony was omitted')
raise generator.engraving.types.EngravingError(f'{value["id"]}: harmony was omitted')
for i, _ in enumerate(measure.get('tempos', [])):
if not any(p.role == 'tempo' for p in primitives.get(measure['id'] + f':tempo:{i}', [])):
raise EngravingError(f'{measure["id"]}: tempo was omitted')
raise generator.engraving.types.EngravingError(f'{measure["id"]}: tempo was omitted')
for span in index.staff.get('tuplets', []):
if span.get('show_number', 'actual') != 'none' or span.get('bracket', 'auto') != 'no':
if not any(p.role.startswith('tuplet-') for p in primitives.get(span['id'], [])):
raise EngravingError(f'{span["id"]}: tuplet was omitted')
raise generator.engraving.types.EngravingError(f'{span["id"]}: tuplet was omitted')
for span in index.staff.get('octave_shifts', []):
if not any(p.role == 'octave-shift' for p in primitives.get(span['id'], [])):
raise EngravingError(f'{span["id"]}: octave shift was omitted')
raise generator.engraving.types.EngravingError(f'{span["id"]}: octave shift was omitted')
+79 -16
View File
@@ -1,19 +1,26 @@
from __future__ import annotations
import dataclasses
import hashlib
import importlib.metadata
import io
import json
from pathlib import Path
import shutil
import pathlib
import platform
import re
import shutil
import tempfile
from PIL import features
import PIL.Image
import PIL.ImageChops
import PIL.features
import generator.engraving.validation
def renderer_identity(fonts, style) -> dict:
root = Path(__file__).resolve().parents[1]
root = pathlib.Path(__file__).resolve().parents[1]
versions = {name: importlib.metadata.version(name) for name in ('Pillow', 'fonttools', 'aggdraw')}
versions.update({name: features.version(name) for name in features.get_supported()})
versions.update({name: PIL.features.version(name) for name in PIL.features.get_supported()})
sources = [*root.joinpath('engraving').glob('*.py'), root / 'model.py', root / 'harmony.py', root / 'requirements.txt']
return {'fonts_style': fonts.fingerprint(style), 'python': {'implementation': platform.python_implementation(), 'version': platform.python_version()}, 'dependencies': versions,
'sources': {path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(sources)}}
@@ -21,7 +28,7 @@ def renderer_identity(fonts, style) -> dict:
def renderer_fingerprint(fonts, style) -> str:
return hashlib.sha256(json.dumps(renderer_identity(fonts, style), sort_keys=True).encode()).hexdigest()
def renderer_namespace(cache: Path, renderer: str) -> Path:
def renderer_namespace(cache: pathlib.Path, renderer: str) -> pathlib.Path:
namespace = cache / renderer
if namespace.parent.is_symlink() or namespace.is_symlink():
raise ValueError('engraving cache namespaces must not be symlinks')
@@ -31,10 +38,19 @@ def score_fingerprint(document: dict, renderer: str) -> str:
music = {key: value for key, value in document.items() if key != 'playback'}
return hashlib.sha256(json.dumps([renderer, music], sort_keys=True, ensure_ascii=False).encode()).hexdigest()
def restore_pages(entry: Path, destination: Path) -> list[dict] | None:
from generator.engraving import check_page
def notation_fingerprint(document: dict, renderer: str) -> str:
return hashlib.sha256(json.dumps([renderer, document['staves']], sort_keys=True, ensure_ascii=False).encode()).hexdigest()
def band_fingerprint(notation: str, page, band) -> str:
context = [notation, page.width, page.height, page.supersampling, dataclasses.asdict(band)]
return hashlib.sha256(json.dumps(context, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
def has_symlink(path: pathlib.Path) -> bool:
return any(parent.is_symlink() for parent in (path, *path.parents))
def restore_pages(entry: pathlib.Path, destination: pathlib.Path, validated_pages: set[str] | None = None) -> list[dict] | None:
try:
if entry.is_symlink():
if has_symlink(entry) or has_symlink(destination):
return None
manifest = entry / 'pages.json'
if manifest.is_symlink():
@@ -44,27 +60,29 @@ def restore_pages(entry: Path, destination: Path) -> list[dict] | None:
return None
destination.mkdir(parents=True, exist_ok=True)
for index, page in enumerate(pages, 1):
if not isinstance(page, dict) or set(page) != {'path', 'sha256'} or page['path'] != f'{index:04}.png':
if not isinstance(page, dict) or set(page) != {'path', 'sha256'} or page['path'] != f'{index:04}.png' or not isinstance(page['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', page['sha256']):
return None
path = entry / page['path']
if path.is_symlink():
if path.is_symlink() or not path.is_file():
return None
target = destination / page['path']
shutil.copyfile(path, target)
if hashlib.sha256(target.read_bytes()).hexdigest() != page['sha256']:
if target.is_symlink():
return None
check_page(target)
shutil.copyfile(path, target)
generator.engraving.validation.check_page(target, validated_pages, expected_sha256=page['sha256'])
return pages
except (OSError, ValueError, TypeError):
return None
def store_pages(entry: Path, destination: Path, pages: list[dict]) -> None:
def store_pages(entry: pathlib.Path, destination: pathlib.Path, pages: list[dict]) -> None:
if has_symlink(entry.parent):
raise ValueError('engraving cache directories must not be symlinks')
entry.parent.mkdir(parents=True, exist_ok=True)
if entry.is_symlink() or entry.is_file():
entry.unlink(missing_ok=True)
entry.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.engraving-', dir=entry.parent) as temporary:
staged = Path(temporary)
staged = pathlib.Path(temporary)
for page in pages:
shutil.copyfile(destination / page['path'], staged / page['path'])
(staged / 'pages.json').write_text(json.dumps(pages), encoding='utf-8')
@@ -74,3 +92,48 @@ def store_pages(entry: Path, destination: Path, pages: list[dict]) -> None:
if target.is_dir() and not target.is_symlink():
shutil.rmtree(target, ignore_errors=True)
(staged / name).replace(target)
def restore_band(entry: pathlib.Path, key: str, width: int, height: int) -> PIL.Image.Image | None:
try:
manifest, asset = entry / 'band.json', entry / 'band.png'
if has_symlink(entry) or manifest.is_symlink() or asset.is_symlink():
return None
metadata = json.loads(manifest.read_text(encoding='utf-8'))
if not isinstance(metadata, dict) or set(metadata) != {'context', 'width', 'height', 'sha256'}:
return None
if metadata['context'] != key or type(metadata['width']) is not int or type(metadata['height']) is not int or (metadata['width'], metadata['height']) != (width, height):
return None
digest = metadata['sha256']
if not isinstance(digest, str) or not re.fullmatch('[0-9a-f]{64}', digest):
return None
data = asset.read_bytes()
if hashlib.sha256(data).hexdigest() != digest:
return None
with PIL.Image.open(io.BytesIO(data)) as image:
if image.format != 'PNG' or image.size != (width, height) or image.mode != 'RGB':
return None
image.load()
ink = PIL.ImageChops.difference(image, PIL.Image.new('RGB', image.size, 'white')).getbbox()
if not ink or ink[0] < 3 or ink[1] < 3 or ink[2] > width - 3 or ink[3] > height - 3:
return None
return image.copy()
except (OSError, ValueError, TypeError):
return None
def store_band(entry: pathlib.Path, key: str, image: PIL.Image.Image) -> None:
if has_symlink(entry.parent):
raise ValueError('engraving cache directories must not be symlinks')
entry.parent.mkdir(parents=True, exist_ok=True)
if entry.is_symlink() or entry.is_file():
entry.unlink()
entry.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.engraving-', dir=entry.parent) as temporary:
staged = pathlib.Path(temporary)
image.save(staged / 'band.png', format='PNG')
metadata = {'context': key, 'width': image.width, 'height': image.height, 'sha256': hashlib.sha256((staged / 'band.png').read_bytes()).hexdigest()}
(staged / 'band.json').write_text(json.dumps(metadata), encoding='utf-8')
for name in ('band.png', 'band.json'):
target = entry / name
if target.is_dir() and not target.is_symlink():
shutil.rmtree(target)
(staged / name).replace(target)
+26 -26
View File
@@ -1,27 +1,27 @@
from __future__ import annotations
from dataclasses import asdict
import dataclasses
import hashlib
import json
from pathlib import Path
import pathlib
from fontTools.pens.boundsPen import BoundsPen
from fontTools.pens.recordingPen import DecomposingRecordingPen
from fontTools.ttLib import TTFont
from PIL import ImageFont
import PIL.ImageFont
import fontTools.pens.boundsPen
import fontTools.pens.recordingPen
import fontTools.ttLib
from generator.engraving.types import Box, EngravingError, Primitive, Style
import generator.engraving.types
class Fonts:
def __init__(self, root: Path):
def __init__(self, root: pathlib.Path):
self.root = root
try:
self.music = TTFont(root / 'Petaluma.otf')
self.script = TTFont(root / 'PetalumaScript.otf')
self.music = fontTools.ttLib.TTFont(root / 'Petaluma.otf')
self.script = fontTools.ttLib.TTFont(root / 'PetalumaScript.otf')
self.metadata = json.loads((root / 'petaluma_metadata.json').read_text())
self.names = json.loads((root / 'smufl_glyphnames.json').read_text())
except (OSError, ValueError) as error:
raise EngravingError(f'cannot load bundled engraving fonts: {error}') from error
raise generator.engraving.types.EngravingError(f'cannot load bundled engraving fonts: {error}') from error
self.names.update(self.metadata.get('optionalGlyphs', {}))
self.cmap = self.music.getBestCmap()
self.text_cmap = self.script.getBestCmap()
@@ -30,37 +30,37 @@ class Fonts:
self.metrics = {}
self.outlines = {}
self.text_fonts = {}
def fingerprint(self, style: Style) -> str:
digest = hashlib.sha256(json.dumps(asdict(style), sort_keys=True).encode())
def fingerprint(self, style: generator.engraving.types.Style) -> str:
digest = hashlib.sha256(json.dumps(dataclasses.asdict(style), sort_keys=True).encode())
for name in ('Petaluma.otf', 'PetalumaScript.otf', 'petaluma_metadata.json', 'smufl_glyphnames.json'):
digest.update(name.encode())
digest.update((self.root / name).read_bytes())
return digest.hexdigest()
def glyph_name(self, name: str) -> str:
if name not in self.names:
raise EngravingError(f'unknown SMuFL glyph {name!r}')
raise generator.engraving.types.EngravingError(f'unknown SMuFL glyph {name!r}')
code = int(self.names[name]['codepoint'].removeprefix('U+'), 16)
if code not in self.cmap:
raise EngravingError(f'Petaluma is missing requested SMuFL glyph {name!r}')
raise generator.engraving.types.EngravingError(f'Petaluma is missing requested SMuFL glyph {name!r}')
return self.cmap[code]
def glyph_box(self, name: str, size: float) -> Box:
def glyph_box(self, name: str, size: float) -> generator.engraving.types.Box:
if name not in self.metrics:
pen = BoundsPen(self.glyphs)
pen = fontTools.pens.boundsPen.BoundsPen(self.glyphs)
self.glyphs[self.glyph_name(name)].draw(pen)
if pen.bounds is None:
raise EngravingError(f'empty SMuFL glyph {name!r}')
raise generator.engraving.types.EngravingError(f'empty SMuFL glyph {name!r}')
self.metrics[name] = pen.bounds
left, bottom, right, top = self.metrics[name]
scale = size / self.units
return Box(left * scale, -top * scale, right * scale, -bottom * scale)
return generator.engraving.types.Box(left * scale, -top * scale, right * scale, -bottom * scale)
def outline(self, name: str):
if name not in self.outlines:
pen = DecomposingRecordingPen(self.glyphs)
pen = fontTools.pens.recordingPen.DecomposingRecordingPen(self.glyphs)
self.glyphs[self.glyph_name(name)].draw(pen)
self.outlines[name] = pen
return self.outlines[name]
def glyph(self, name: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...]) -> Primitive:
return Primitive('glyph', role, sources, self.glyph_box(name, size).moved(x, y), ((x, y),), name, size)
def glyph(self, name: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...]) -> generator.engraving.types.Primitive:
return generator.engraving.types.Primitive('glyph', role, sources, self.glyph_box(name, size).moved(x, y), ((x, y),), name, size)
def anchor(self, name: str, anchor: str, size: float) -> tuple[float, float]:
value = self.metadata.get('glyphsWithAnchors', {}).get(name, {}).get(anchor)
if value is None:
@@ -70,14 +70,14 @@ class Fonts:
def text_font(self, size: float):
pixels = max(1, round(size))
if pixels not in self.text_fonts:
self.text_fonts[pixels] = ImageFont.truetype(str(self.root / 'PetalumaScript.otf'), pixels)
self.text_fonts[pixels] = PIL.ImageFont.truetype(str(self.root / 'PetalumaScript.otf'), pixels)
return self.text_fonts[pixels]
def text(self, value: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...], center: bool = False) -> Primitive:
def text(self, value: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...], center: bool = False) -> generator.engraving.types.Primitive:
missing = [c for c in value if ord(c) not in self.text_cmap and not c.isspace()]
if missing:
raise EngravingError(f'Petaluma Script is missing text glyph {missing[0]!r}')
raise generator.engraving.types.EngravingError(f'Petaluma Script is missing text glyph {missing[0]!r}')
font = self.text_font(size * 4)
left, top, right, bottom = font.getbbox(value, anchor='ls')
offset = (left + right) / 8 if center else 0
x -= offset
return Primitive('text', role, sources, Box(x + left / 4, y + top / 4, x + right / 4, y + bottom / 4), ((x, y),), value, size)
return generator.engraving.types.Primitive('text', role, sources, generator.engraving.types.Box(x + left / 4, y + top / 4, x + right / 4, y + bottom / 4), ((x, y),), value, size)
+81 -61
View File
@@ -1,33 +1,35 @@
from __future__ import annotations
from dataclasses import replace
from bisect import bisect_left, bisect_right
from fractions import Fraction
import bisect
import collections
import dataclasses
import fractions
import math
from generator import harmony
from generator.engraving import tablature
from generator.model import validate_document
from generator.engraving.attributes import barline, staff_attributes, tempo
from generator.engraving.fonts import Fonts
from generator.engraving.notation import ScoreIndex
from generator.engraving.notes import draw_event
from generator.engraving.spans import draw_beams, draw_lyric_spans, draw_spans, place_text_lanes
from generator.engraving.types import Anchor, EngravingError, Fragment, LayoutDocument, Page, Primitive, Style, System, bounds, line
import generator.engraving.attributes
import generator.engraving.audit
import generator.engraving.fonts
import generator.engraving.notation
import generator.engraving.notes
import generator.engraving.spans
import generator.engraving.tablature
import generator.engraving.types
import generator.harmony
import generator.model
def lyric_text(verse: dict) -> str:
return ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision'))
def column(index: ScoreIndex, time: Fraction, start: Fraction, end: Fraction, fonts: Fonts, style: Style) -> tuple[list[Primitive], list[Anchor], list[Fragment]]:
def column(index: generator.engraving.notation.ScoreIndex, time: fractions.Fraction, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[list[generator.engraving.types.Primitive], list[generator.engraving.types.Anchor], list[generator.engraving.types.Fragment]]:
items, anchors, fragments = [], [], []
events = index.events_at(time, include_sustained=time == start)
grace_width = 0
occupied = []
for event in events:
try:
drawn, placed, pieces = draw_event(index, event, start, end, fonts, style)
drawn, placed, pieces = generator.engraving.notes.draw_event(index, event, start, end, fonts, style)
except ValueError as error:
raise EngravingError(f'measure {event.measure["number"]}, voice {event.voice}, {event.value["id"]}: {error}') from error
raise generator.engraving.types.EngravingError(f'measure {event.measure["number"]}, voice {event.voice}, {event.value["id"]}: {error}') from error
x = grace_width
collidable = [p for p in drawn if p.role in ('head', 'tab', 'rest', 'slash', 'accidental', 'dot')]
while any(p.box.moved(x, 0).overlaps(q.box, 2) for p in collidable for q in occupied):
@@ -38,7 +40,7 @@ def column(index: ScoreIndex, time: Fraction, start: Fraction, end: Fraction, fo
grace_width = max((p.box.right for p in drawn), default=grace_width) + 9
occupied.extend(p for p in drawn if p.role in ('head', 'tab', 'rest', 'slash', 'accidental', 'dot'))
items.extend(drawn)
items.extend(tablature.column(index, event, placed, fonts, style))
items.extend(generator.engraving.tablature.column(index, event, placed, fonts, style))
anchors.extend(placed)
fragments.extend(pieces)
if event.start == time and not event.value.get('hidden'):
@@ -51,47 +53,61 @@ def column(index: ScoreIndex, time: Fraction, start: Fraction, end: Fraction, fo
measure_start, _, measure = index.measure_at(time)
for owner, _, item in index.harmonies_by_onset.get(time, []):
if owner is measure:
items.append(fonts.text(harmony.symbol(item), 0, 0, 21, 'harmony', (item['id'],)))
items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 21, 'harmony', (item['id'],)))
for owner, i, value in index.tempos_by_onset.get(time, []):
if owner is measure:
items.extend(tempo(value, fonts, 0, 0, style.space, measure['id'] + f':tempo:{i}'))
items.extend(generator.engraving.attributes.tempo(value, fonts, 0, 0, style.space, measure['id'] + f':tempo:{i}'))
if time == measure_start:
items.append(fonts.text(str(measure['number']), 0, -style.space * (index.state(time)['staff_details']['lines'] + 0.5), 12, 'measure-number', (measure['id'],)))
if label := measure.get('section_label'):
items.append(fonts.text(label, 0, 0, 19, 'section', (measure['id'] + ':section',)))
return items, anchors, fragments
def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts, style: Style, justify: bool = False) -> System:
def column_metrics(index, time, start, end, fonts, style, columns):
events = index.events_at(time, include_sustained=time == start)
finish = min(end, max((event.end for event in events), default=time))
key = (time, start, finish)
if columns is not None and key in columns:
columns.move_to_end(key)
return columns[key]
drawn, placed, pieces = column(index, time, start, end, fonts, style)
result = drawn, placed, pieces, generator.engraving.types.bounds(drawn)
if columns is not None:
columns[key] = result
if len(columns) > 256:
columns.popitem(last=False)
return result
def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, justify: bool = False, *, columns=None) -> generator.engraving.types.System:
items, anchors, fragments = [], [], []
state = index.state(start)
measure = index.measure_at(start)[2]
header = staff_attributes(index, state, start, fonts, style.space, measure['id'] + ':initial', ('clef', 'key', 'meter', 'staff_details'))
header = generator.engraving.attributes.staff_attributes(index, state, start, fonts, style.space, measure['id'] + ':initial', ('clef', 'key', 'meter', 'staff_details'))
items.extend(header)
cursor = max((p.box.right for p in header), default=0) + 22
content_left = cursor - 9
times = [start] + index.column_times[bisect_right(index.column_times, start):bisect_left(index.column_times, end)]
times = [start] + index.column_times[bisect.bisect_right(index.column_times, start):bisect.bisect_left(index.column_times, end)]
placements = []
groups = []
for ti, time in enumerate(times):
item_start, anchor_start = len(items), len(anchors)
drawn, placed, pieces = column(index, time, start, end, fonts, style)
drawn, placed, pieces, box = column_metrics(index, time, start, end, fonts, style, columns)
leading = []
measure_start, _, m = index.measure_at(time)
if time == measure_start and time > start:
previous = index.measure_at(time, before=True)[2]
leading.extend(barline(previous.get('barlines', {}).get('right', 'regular'), 0, -style.space * (index.state(time)['staff_details']['lines'] - 1), previous['id']))
leading.extend(generator.engraving.attributes.barline(previous.get('barlines', {}).get('right', 'regular'), 0, -style.space * (index.state(time)['staff_details']['lines'] - 1), previous['id']))
old_state = index.state(time, before=True)
fields = tuple(k for k in ('clef', 'key', 'meter', 'staff_details') if old_state[k] != index.state(time)[k])
leading.extend(p.moved(12, 0) for p in staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + ':initial', fields))
leading.extend(p.moved(12, 0) for p in generator.engraving.attributes.staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + ':initial', fields))
if time > start:
for owner, ci, change in index.attributes_by_onset.get(time, []):
extra = staff_attributes(index, index.state(time), time, fonts, style.space, owner['id'] + f':attributes:{ci}', tuple(change))
extra = generator.engraving.attributes.staff_attributes(index, index.state(time), time, fonts, style.space, owner['id'] + f':attributes:{ci}', tuple(change))
leading.extend(p.moved(max((p.box.right for p in leading), default=0) + 10, 0) for p in extra)
if leading:
width = bounds(leading).right - bounds(leading).left + 14
items.extend(p.moved(cursor - bounds(leading).left, 0) for p in leading)
width = generator.engraving.types.bounds(leading).right - generator.engraving.types.bounds(leading).left + 14
items.extend(p.moved(cursor - generator.engraving.types.bounds(leading).left, 0) for p in leading)
cursor += width
box = bounds(drawn)
x = cursor - min(0, box.left)
placements.append((time, x))
items.extend(p.moved(x, 0) for p in drawn)
@@ -114,10 +130,10 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
last_measure = index.measure_at(end, before=True)[2]
tab_items = [p for p in items if p.role.startswith('tab')]
items = [p for p in items if not p.role.startswith('tab')]
items = draw_beams(index, anchors, items, start, end, content_left, right, style)
items = draw_spans(index, anchors, items, start, end, content_left, right, fonts, style)
items = place_text_lanes(items, fonts, style)
items = draw_lyric_spans(index, items, anchors, start, end, content_left, right, fonts)
items = generator.engraving.spans.draw_beams(index, anchors, items, start, end, content_left, right, style)
items = generator.engraving.spans.draw_spans(index, anchors, items, start, end, content_left, right, fonts, style)
items = generator.engraving.spans.place_text_lanes(items, fonts, style)
items = generator.engraving.spans.draw_lyric_spans(index, items, anchors, start, end, content_left, right, fonts)
changes = [(0, state['staff_details']['lines'])]
for time, x in placements:
lines = index.state(time)['staff_details']['lines']
@@ -128,18 +144,18 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
x2 = changes[i + 1][0] if i + 1 < len(changes) else width - 1
for number in range(lines):
y = -number * style.space
staff_lines.append(line('staff', (), ((x1, y), (x2, y)), style.space * 0.13))
staff_lines.append(generator.engraving.types.line('staff', (), ((x1, y), (x2, y)), style.space * 0.13))
items = staff_lines + items
if end in index.boundaries:
items.extend(barline(last_measure.get('barlines', {}).get('right', 'regular'), width - 1, -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), last_measure['id']))
items.extend(generator.engraving.attributes.barline(last_measure.get('barlines', {}).get('right', 'regular'), width - 1, -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), last_measure['id']))
if start in index.boundaries:
items.extend(barline(measure.get('barlines', {}).get('left', 'none'), 0, -style.space * (state['staff_details']['lines'] - 1), measure['id']))
items.extend(generator.engraving.attributes.barline(measure.get('barlines', {}).get('left', 'none'), 0, -style.space * (state['staff_details']['lines'] - 1), measure['id']))
else:
items.append(fonts.text(str(measure['number']) + ' (cont.)', 0, min(p.box.top for p in items) - 6, 12, 'continuation', (measure['id'],)))
items = tablature.staff(index, items + tab_items, start, end, content_left, width - 1, fonts, style)
return System(start, end, tuple(items), tuple(anchors), tuple(fragments), bounds(items))
items = generator.engraving.tablature.staff(index, items + tab_items, start, end, content_left, width - 1, fonts, style)
return generator.engraving.types.System(start, end, tuple(items), tuple(anchors), tuple(fragments), generator.engraving.types.bounds(items))
def paginate(systems: list[System], document: dict, fonts: Fonts, style: Style) -> tuple[Page, ...]:
def paginate(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[generator.engraving.types.Page, ...]:
pages = []
placed = []
header = []
@@ -154,10 +170,10 @@ def paginate(systems: list[System], document: dict, fonts: Fonts, style: Style)
size -= 1
item = fonts.text(title, style.margin_x, style.margin_y + size, size, 'title', ())
if item.box.right > style.width - style.margin_x:
raise EngravingError('score title cannot fit the page without clipping')
raise generator.engraving.types.EngravingError('score title cannot fit the page without clipping')
result = [item]
if number == 1:
subtitle = ' - '.join(str(value) for value in (document['metadata'].get('composer'), document['staves'][0].get('name'), document['metadata'].get('style')) if value)
subtitle = ' - '.join(str(value) for value in (document['metadata'].get('composer'), document['staves'][0].get('name')) if value)
if subtitle:
result.append(fonts.text(subtitle, style.margin_x, item.box.bottom + 22, 16, 'subtitle', ()))
return result
@@ -166,61 +182,65 @@ def paginate(systems: list[System], document: dict, fonts: Fonts, style: Style)
height = system.box.bottom - system.box.top
if first:
header = page_header(len(pages) + 1)
cursor = bounds(header).bottom + 24
cursor = generator.engraving.types.bounds(header).bottom + 24
first = False
if cursor + height > style.height - style.margin_y - 18:
if not placed:
raise EngravingError(f'at musical offset {system.start}: simultaneous notation needs {height:.1f}px vertically; fixed-size page cannot fit it')
raise generator.engraving.types.EngravingError(f'at musical offset {system.start}: simultaneous notation needs {height:.1f}px vertically; fixed-size page cannot fit it')
number = len(pages) + 1
footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True)
pages.append(Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
placed = []
header = page_header(len(pages) + 1)
cursor = bounds(header).bottom + 24
cursor = generator.engraving.types.bounds(header).bottom + 24
if cursor + height > style.height - style.margin_y - 18:
raise EngravingError(f'at musical offset {system.start}: simultaneous notation is too tall for a page')
raise generator.engraving.types.EngravingError(f'at musical offset {system.start}: simultaneous notation is too tall for a page')
dx, dy = style.margin_x + 1, cursor - system.box.top
moved = replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy))
moved = dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy))
placed.append(moved)
cursor += height + style.system_gap
if placed:
number = len(pages) + 1
footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True)
pages.append(Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
return tuple(pages)
def layout_score(document: dict, style: Style, fonts: Fonts) -> LayoutDocument:
validate_document(document)
index = ScoreIndex(document)
def layout_systems(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> tuple[generator.engraving.types.System, ...]:
generator.model.validate_document(document)
index = generator.engraving.notation.ScoreIndex(document)
if not index.measures:
raise EngravingError('score has no measures')
raise generator.engraving.types.EngravingError('score has no measures')
systems = []
start = Fraction()
start = fractions.Fraction()
available = style.width - style.margin_x * 2
while start < index.end:
candidates = index.boundary_times[bisect_right(index.boundary_times, start):]
columns = collections.OrderedDict()
candidates = index.boundary_times[bisect.bisect_right(index.boundary_times, start):]
selected = None
# Reserve the same title/credit and footer bands when considering a split,
# so a wide pitch range can break vertically as well as horizontally.
height_limit = style.height - style.margin_y * 2 - 110
for end in candidates:
trial = make_system(index, start, end, fonts, style)
trial = make_system(index, start, end, fonts, style, columns=columns)
if trial.box.right - trial.box.left > available or trial.box.bottom - trial.box.top > height_limit:
break
selected = trial
if selected is None:
measure_end = candidates[0]
for end in index.column_times[bisect_right(index.column_times, start):bisect_left(index.column_times, measure_end)]:
trial = make_system(index, start, end, fonts, style)
for end in index.column_times[bisect.bisect_right(index.column_times, start):bisect.bisect_left(index.column_times, measure_end)]:
trial = make_system(index, start, end, fonts, style, columns=columns)
if trial.box.right - trial.box.left <= available and trial.box.bottom - trial.box.top <= height_limit:
selected = trial
else:
break
if selected is None:
raise EngravingError(f'at musical offset {start}: irreducible simultaneous content exceeds the page width or height')
systems.append(make_system(index, start, selected.end, fonts, style, justify=selected.end < index.end))
raise generator.engraving.types.EngravingError(f'at musical offset {start}: irreducible simultaneous content exceeds the page width or height')
systems.append(make_system(index, start, selected.end, fonts, style, justify=True, columns=columns) if selected.end < index.end else selected)
start = selected.end
layout = LayoutDocument(paginate(systems, document, fonts, style), style, fonts.fingerprint(style))
from generator.engraving.audit import audit_layout
audit_layout(document, layout, fonts)
return tuple(systems)
def layout_score(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> generator.engraving.types.LayoutDocument:
systems = layout_systems(document, style, fonts)
layout = generator.engraving.types.LayoutDocument(paginate(systems, document, fonts, style), style, fonts.fingerprint(style))
generator.engraving.audit.audit_layout(document, layout, fonts)
return layout
+36 -36
View File
@@ -1,24 +1,24 @@
from __future__ import annotations
from dataclasses import dataclass
from bisect import bisect_left, bisect_right
from fractions import Fraction
import bisect
import dataclasses
import fractions
from generator.model import LETTERS, NOTE_TYPES, alteration, fraction, written_for
from generator.engraving.fonts import Fonts
from generator.engraving.types import EngravingError, Primitive, bounds
import generator.engraving.fonts
import generator.engraving.types
import generator.model
ACCIDENTAL_GLYPHS = {Fraction(0): 'accidentalNatural', Fraction(1): 'accidentalSharp', Fraction(-1): 'accidentalFlat', Fraction(2): 'accidentalDoubleSharp', Fraction(-2): 'accidentalDoubleFlat', Fraction(3): 'accidentalTripleSharp', Fraction(-3): 'accidentalTripleFlat', Fraction(1, 2): 'accidentalQuarterToneSharpStein', Fraction(-1, 2): 'accidentalQuarterToneFlatStein', Fraction(3, 2): 'accidentalThreeQuarterTonesSharpStein', Fraction(-3, 2): 'accidentalThreeQuarterTonesFlatZimmermann'}
ACCIDENTAL_GLYPHS = {fractions.Fraction(0): 'accidentalNatural', fractions.Fraction(1): 'accidentalSharp', fractions.Fraction(-1): 'accidentalFlat', fractions.Fraction(2): 'accidentalDoubleSharp', fractions.Fraction(-2): 'accidentalDoubleFlat', fractions.Fraction(3): 'accidentalTripleSharp', fractions.Fraction(-3): 'accidentalTripleFlat', fractions.Fraction(1, 2): 'accidentalQuarterToneSharpStein', fractions.Fraction(-1, 2): 'accidentalQuarterToneFlatStein', fractions.Fraction(3, 2): 'accidentalThreeQuarterTonesSharpStein', fractions.Fraction(-3, 2): 'accidentalThreeQuarterTonesFlatZimmermann'}
HEADS = {'x': 'X', 'diamond': 'Diamond', 'triangle': 'TriangleUp', 'inverted triangle': 'TriangleDown', 'circle-x': 'CircleX', 'cross': 'Plus', 'slash': 'Slash', 'normal': '', 'do': 'TriangleUp', 're': 'Moon', 'mi': 'Diamond', 'fa': 'TriangleRight', 'fa up': 'TriangleLeft', 'so': 'Round', 'la': 'Square', 'ti': 'TriangleRoundDown', 'rectangle': 'Square', 'square': 'Square', 'arrow down': 'LargeArrowDown', 'arrow up': 'LargeArrowUp'}
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class Event:
measure: dict
voice: str
lane: int
lanes: int
start: Fraction
end: Fraction
start: fractions.Fraction
end: fractions.Fraction
value: dict
class ScoreIndex:
@@ -32,20 +32,20 @@ class ScoreIndex:
self.tempos_by_onset = {}
self.by_id = {}
self.tones = {}
self.boundaries = {Fraction()}
self.columns = {Fraction()}
self.boundaries = {fractions.Fraction()}
self.columns = {fractions.Fraction()}
self._states = {}
self._beam_groups = None
time = Fraction()
time = fractions.Fraction()
for measure in self.staff['measures']:
end = time + fraction(measure['duration'])
end = time + generator.model.fraction(measure['duration'])
self.measures.append((time, end, measure))
self.boundaries.add(end)
self.columns.update((time, end))
for lane, voice in enumerate(measure['voices']):
for note in voice['notes']:
onset = time + fraction(note['onset'])
event = Event(measure, voice['id'], lane, len(measure['voices']), onset, onset + fraction(note['duration']), note)
onset = time + generator.model.fraction(note['onset'])
event = Event(measure, voice['id'], lane, len(measure['voices']), onset, onset + generator.model.fraction(note['duration']), note)
self.events.append(event)
self.by_id[note['id']] = event
self.columns.add(onset)
@@ -54,7 +54,7 @@ class ScoreIndex:
for field in ('attributes', 'harmonies', 'tempos'):
bucket = {'attributes': self.attributes_by_onset, 'harmonies': self.harmonies_by_onset, 'tempos': self.tempos_by_onset}[field]
for index, item in enumerate(measure.get(field, [])):
onset = time + fraction(item['onset'])
onset = time + generator.model.fraction(item['onset'])
self.columns.add(onset)
bucket.setdefault(onset, []).append((measure, index, item))
time = end
@@ -72,15 +72,15 @@ class ScoreIndex:
self.boundary_times = sorted(self.boundaries)
self.lyric_lanes = list(dict.fromkeys((e.voice, verse['number']) for e in self.events for verse in e.value.get('lyrics', [])))
self.accidentals = self.accidental_state()
def measure_at(self, time: Fraction, before: bool = False) -> tuple:
position = bisect_left(self.measure_starts, time) if before else bisect_right(self.measure_starts, time)
def measure_at(self, time: fractions.Fraction, before: bool = False) -> tuple:
position = bisect.bisect_left(self.measure_starts, time) if before else bisect.bisect_right(self.measure_starts, time)
return self.measures[max(0, position - 1)]
def events_at(self, time: Fraction, include_sustained: bool = False) -> list[Event]:
def events_at(self, time: fractions.Fraction, include_sustained: bool = False) -> list[Event]:
attacks = self.events_by_onset.get(time, [])
if not include_sustained:
return attacks
sustained = []
position = bisect_left(self.event_starts, time) - 1
position = bisect.bisect_left(self.event_starts, time) - 1
# Prefix maxima let system continuations stop searching once every earlier note has ended.
while position >= 0 and self.event_max_ends[position] > time:
event = self.events[position]
@@ -88,18 +88,18 @@ class ScoreIndex:
sustained.append(event)
position -= 1
return list(reversed(sustained)) + attacks
def state(self, time: Fraction, before: bool = False) -> dict:
def state(self, time: fractions.Fraction, before: bool = False) -> dict:
if (time, before) in self._states:
return self._states[time, before]
start, _, measure = self.measure_at(time, before)
state = {key: measure[key] for key in ('key', 'meter', 'clef', 'transpose', 'staff_details')}
for change in measure.get('attributes', []):
onset = start + fraction(change['onset'])
onset = start + generator.model.fraction(change['onset'])
if onset < time or (onset == time and not before):
state.update({key: value for key, value in change.items() if key != 'onset'})
self._states[time, before] = state
return state
def pitch_position(self, tone: dict, time: Fraction) -> float:
def pitch_position(self, tone: dict, time: fractions.Fraction) -> float:
state = self.state(time)
clef = state['clef']
pitch = tone.get('unpitched', tone)
@@ -107,14 +107,14 @@ class ScoreIndex:
reference = {'G': 4 * 7 + 4, 'F': 3 * 7 + 3, 'C': 4 * 7}.get(sign, 4 * 7 + 4)
clef_line = clef.get('line', 2) if sign in ('G', 'F', 'C') else 2
octave = 0 if 'unpitched' in tone else clef.get('octave_change', 0)
shift = sum(s['octaves'] for s in self.staff.get('octave_shifts', []) if fraction(s['start']) <= time < fraction(s['end'])) if 'unpitched' not in tone else 0
return pitch['octave'] * 7 + LETTERS.index(pitch['letter']) + 7 * (shift - octave) - reference + 2 * (clef_line - 1)
shift = sum(s['octaves'] for s in self.staff.get('octave_shifts', []) if generator.model.fraction(s['start']) <= time < generator.model.fraction(s['end'])) if 'unpitched' not in tone else 0
return pitch['octave'] * 7 + generator.model.LETTERS.index(pitch['letter']) + 7 * (shift - octave) - reference + 2 * (clef_line - 1)
def accidental_state(self) -> dict[str, bool]:
result = {}
for start, end, measure in self.measures:
memory = {}
key = None
onsets = self.onsets[bisect_left(self.onsets, start):bisect_left(self.onsets, end)]
onsets = self.onsets[bisect.bisect_left(self.onsets, start):bisect.bisect_left(self.onsets, end)]
for onset in onsets:
state = self.state(onset)
if key != state['key']:
@@ -130,11 +130,11 @@ class ScoreIndex:
result[tone['id']] = False
continue
identity = (tone['letter'], tone['octave'])
value = alteration(tone['alter'])
signature = Fraction() if key.get('custom') else alteration(key['alterations'][LETTERS.index(tone['letter'])])
value = generator.model.alteration(tone['alter'])
signature = fractions.Fraction() if key.get('custom') else generator.model.alteration(key['alterations'][generator.model.LETTERS.index(tone['letter'])])
for step in key.get('steps', []):
if step['letter'] == tone['letter'] and step.get('octave', tone['octave']) == tone['octave']:
signature = alteration(step['alter'])
signature = generator.model.alteration(step['alter'])
result[tone['id']] = bool(tone.get('accidental_display')) or (not tone.get('tie_from') and memory.get(identity, signature) != value)
simultaneous.setdefault(identity, set()).add(value)
for identity, values in simultaneous.items():
@@ -146,11 +146,11 @@ class ScoreIndex:
result[tone['id']] = True
return result
def fragment_written(written: dict, original_start: Fraction, original_end: Fraction, start: Fraction, end: Fraction) -> dict:
return written if (original_start, original_end) == (start, end) else written_for(end - start)
def fragment_written(written: dict, original_start: fractions.Fraction, original_end: fractions.Fraction, start: fractions.Fraction, end: fractions.Fraction) -> dict:
return written if (original_start, original_end) == (start, end) else generator.model.written_for(end - start)
def flags(written: dict) -> int:
return max(0, NOTE_TYPES.index(written['type']) - NOTE_TYPES.index('quarter'))
return max(0, generator.model.NOTE_TYPES.index(written['type']) - generator.model.NOTE_TYPES.index('quarter'))
def head_glyph(written: dict, head: dict | str | None) -> str:
head = {'name': head} if isinstance(head, str) else head or {'name': 'normal'}
@@ -170,13 +170,13 @@ def head_glyph(written: dict, head: dict | str | None) -> str:
if name in ('circled', 'circle dot'):
return 'noteheadCircled' + suffix
if name not in HEADS:
raise EngravingError(f'no SMuFL mapping for notehead {name!r}; specify its smufl glyph name')
raise generator.engraving.types.EngravingError(f'no SMuFL mapping for notehead {name!r}; specify its smufl glyph name')
shape = HEADS[name]
if shape in ('Moon', 'Round', 'Square', 'TriangleRight', 'TriangleLeft', 'TriangleRoundDown'):
suffix = 'Black' if suffix == 'Black' else 'White'
return 'notehead' + shape + suffix
def accidental(fonts: Fonts, value: Fraction, display: dict, size: float, sources: tuple[str, ...]) -> list[Primitive]:
def accidental(fonts: generator.engraving.fonts.Fonts, value: fractions.Fraction, display: dict, size: float, sources: tuple[str, ...]) -> list[generator.engraving.types.Primitive]:
glyph = display.get('smufl') or ACCIDENTAL_GLYPHS.get(value)
special = {'natural-sharp': ('accidentalNatural', 'accidentalSharp'), 'natural-flat': ('accidentalNatural', 'accidentalFlat')}
if display.get('name') in special and not display.get('smufl'):
@@ -192,7 +192,7 @@ def accidental(fonts: Fonts, value: Fraction, display: dict, size: float, source
sign = '+' if value > 0 else ''
items = [fonts.text(f'({sign}{value} st)', 0, 0, size * 1.15, 'accidental', sources)]
if display.get('parentheses') == 'yes' or display.get('bracket') == 'yes':
box = bounds(items)
box = generator.engraving.types.bounds(items)
left, right = ('[', ']') if display.get('bracket') == 'yes' else ('(', ')')
items.extend((fonts.text(left, box.left - 7, size / 2, size * 1.8, 'accidental', sources), fonts.text(right, box.right + 2, size / 2, size * 1.8, 'accidental', sources)))
return items
+35 -35
View File
@@ -1,13 +1,14 @@
from __future__ import annotations
from fractions import Fraction
import dataclasses
import fractions
from generator.model import alteration, fraction
from generator.engraving.fonts import Fonts
from generator.engraving.notation import Event, ScoreIndex, accidental, flags, fragment_written, head_glyph
from generator.engraving.types import Anchor, Fragment, Primitive, Style, bounds, line
import generator.engraving.fonts
import generator.engraving.notation
import generator.engraving.types
import generator.model
def dots(fonts: Fonts, count: int, x: float, y: float, size: float, sources: tuple[str, ...]) -> list[Primitive]:
def dots(fonts: generator.engraving.fonts.Fonts, count: int, x: float, y: float, size: float, sources: tuple[str, ...]) -> list[generator.engraving.types.Primitive]:
result = []
for i in range(count):
result.append(fonts.glyph('augmentationDot', x + i * size * 0.6, y, size, 'dot', sources))
@@ -16,13 +17,13 @@ def dots(fonts: Fonts, count: int, x: float, y: float, size: float, sources: tup
def rest_glyph(kind: str) -> str:
return {'maxima': 'restMaxima', 'long': 'restLonga', 'breve': 'restDoubleWhole', 'whole': 'restWhole', 'half': 'restHalf', 'quarter': 'restQuarter', 'eighth': 'rest8th'}.get(kind, 'rest' + kind)
def time_modification(written: dict, items: list[Primitive], fonts: Fonts, sources: tuple[str, ...]) -> None:
ratio = fraction(written.get('ratio', {'numerator': 1, 'denominator': 1}))
def time_modification(written: dict, items: list[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, sources: tuple[str, ...]) -> None:
ratio = generator.model.fraction(written.get('ratio', {'numerator': 1, 'denominator': 1}))
if ratio != 1 and items:
box = bounds(items)
box = generator.engraving.types.bounds(items)
items.append(fonts.text(f'{ratio.denominator}:{ratio.numerator}', (box.left + box.right) / 2, box.top - 6, 13, 'time-modification', sources, True))
def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction, fonts: Fonts, style: Style) -> tuple[list[Primitive], list[Anchor], list[Fragment]]:
def draw_event(index: generator.engraving.notation.ScoreIndex, event: generator.engraving.notation.Event, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[list[generator.engraving.types.Primitive], list[generator.engraving.types.Anchor], list[generator.engraving.types.Fragment]]:
note = event.value
onset = max(start, event.start)
finish = min(end, event.end)
@@ -33,17 +34,17 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction,
state = index.state(onset)
lines = state['staff_details']['lines']
if not note['pitches']:
written = fragment_written(note['written'], event.start, event.end, onset, finish)
written = generator.engraving.notation.fragment_written(note['written'], event.start, event.end, onset, finish)
y = -style.space * (lines - 1) / 2
if position := note.get('rest_position'):
y = -index.pitch_position(position, onset) * style.space / 2
elif event.lanes > 1:
y += (-1 if event.lane % 2 == 0 else 1) * (event.lane // 2 + 1) * style.space * 3
kind = 'slash' if note.get('notehead') == 'slash' else 'rest'
fragment = Fragment(note['id'], note['id'], onset, finish, written['type'], written.get('dots', 0), fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), visible, kind, None)
fragment = generator.engraving.types.Fragment(note['id'], note['id'], onset, finish, written['type'], written.get('dots', 0), generator.model.fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), visible, kind, None)
fragments.append(fragment)
if visible:
glyph = head_glyph(written, 'slash') if kind == 'slash' else rest_glyph('whole' if note.get('measure_rest') and onset == event.start and finish == event.end else written['type'])
glyph = generator.engraving.notation.head_glyph(written, 'slash') if kind == 'slash' else rest_glyph('whole' if note.get('measure_rest') and onset == event.start and finish == event.end else written['type'])
if written['type'] == 'whole' and not note.get('rest_position') and event.lanes == 1:
y -= style.space
items.append(fonts.glyph(glyph, 0, y, size, kind, references))
@@ -51,20 +52,20 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction,
if kind == 'slash' and written['type'] not in ('whole', 'breve', 'long', 'maxima'):
stem_x = items[0].box.right
stem_y = y - 3 * size
items.append(line('stem', references, ((stem_x, y), (stem_x, stem_y))))
if flags(written):
items.append(generator.engraving.types.line('stem', references, ((stem_x, y), (stem_x, stem_y))))
if generator.engraving.notation.flags(written):
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + 'Up'
items.append(fonts.glyph(flag, stem_x, stem_y, size, 'flag', references))
anchors.append(Anchor(note['id'], note['id'], event.voice, onset, finish, 0, y, 0, y - size * 3, -1, 0, fragment))
anchors.append(generator.engraving.types.Anchor(note['id'], note['id'], event.voice, onset, finish, 0, y, 0, y - size * 3, -1, 0, fragment))
if not note.get('tuplet_ids') or written != note['written']:
time_modification(written, items, fonts, references)
return items, anchors, fragments
groups = {}
for tone in note['pitches']:
tone_end = event.start + fraction(tone['duration'])
tone_end = event.start + generator.model.fraction(tone['duration'])
if tone_end <= start and not note.get('grace'):
continue
written = fragment_written(tone['written'], event.start, tone_end, onset, min(end, tone_end))
written = generator.engraving.notation.fragment_written(tone['written'], event.start, tone_end, onset, min(end, tone_end))
stem = tone.get('stem', note.get('stem', 'auto'))
identity = (written['type'], written.get('dots', 0), str(written.get('ratio')), stem)
groups.setdefault(identity, []).append((tone, written, tone_end))
@@ -82,7 +83,7 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction,
for position, tone, written, tone_end in positioned:
sources = (note['id'], tone['id'])
tone_size = size * (0.7 if tone.get('cue') and not note.get('cue') else 1)
glyph = head_glyph(written, tone.get('notehead', note.get('notehead')))
glyph = generator.engraving.notation.head_glyph(written, tone.get('notehead', note.get('notehead')))
displayed = visible and tone.get('print', {}).get('print-object') != 'no' and bool(glyph)
y = -position * style.space / 2
if previous is not None and abs(position - previous) < 2:
@@ -91,13 +92,13 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction,
displaced = False
x = group_x + (tone_size * 1.2 * -direction if displaced else 0)
previous = position
fragment = Fragment(tone['id'], note['id'], onset, min(end, tone_end), written['type'], written.get('dots', 0), fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), displayed, 'tone', position)
fragment = generator.engraving.types.Fragment(tone['id'], note['id'], onset, min(end, tone_end), written['type'], written.get('dots', 0), generator.model.fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), displayed, 'tone', position)
fragments.append(fragment)
if not displayed:
continue
if written['type'] in ('long', 'maxima') and tone.get('notehead', {}).get('name', 'normal') == 'normal':
width = tone_size * (2.8 if written['type'] == 'maxima' else 1.4)
head = line('head', sources, ((x, y - tone_size / 2), (x + width, y - tone_size / 2), (x + width, y + tone_size / 2), (x, y + tone_size / 2), (x, y - tone_size / 2)), 2)
head = generator.engraving.types.line('head', sources, ((x, y - tone_size / 2), (x + width, y - tone_size / 2), (x + width, y + tone_size / 2), (x, y + tone_size / 2), (x, y - tone_size / 2)), 2)
else:
head = fonts.glyph(glyph, x, y, tone_size, 'head', sources)
group_items.append(head)
@@ -105,15 +106,15 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction,
group_items.extend((fonts.text('(', head.box.left - 6, y + 5, 18, 'head-parentheses', sources), fonts.text(')', head.box.right + 2, y + 5, 18, 'head-parentheses', sources)))
ledger = -2
while position <= ledger:
group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
group_items.append(generator.engraving.types.line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
ledger -= 2
ledger = lines * 2
while position >= ledger:
group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
group_items.append(generator.engraving.types.line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
ledger += 2
if 'alter' in tone and index.accidentals[tone['id']] and onset == event.start:
accidental_items = accidental(fonts, alteration(tone['alter']), tone.get('accidental_display', {}), tone_size, sources)
box = bounds(accidental_items)
accidental_items = generator.engraving.notation.accidental(fonts, generator.model.alteration(tone['alter']), tone.get('accidental_display', {}), tone_size, sources)
box = generator.engraving.types.bounds(accidental_items)
dx = min(group_x, head.box.left) - 5 - box.right
candidate = box.moved(dx, y)
while any(candidate.overlaps(other, 2) for other in accidental_boxes):
@@ -128,32 +129,31 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction,
stem_x = x + fonts.anchor(glyph, 'stemUpSE' if direction == -1 else 'stemDownNW', tone_size)[0]
else:
stem_x = head.box.right - 0.6 if direction == -1 else head.box.left + 0.6
stem_y = y + direction * max(3.5 * size, (flags(written) + 1) * size * 0.75)
group_anchors.append(Anchor(tone['id'], note['id'], event.voice, onset, min(end, tone_end), x, y, stem_x, stem_y, direction, flags(written), fragment))
stem_y = y + direction * max(3.5 * size, (generator.engraving.notation.flags(written) + 1) * size * 0.75)
group_anchors.append(generator.engraving.types.Anchor(tone['id'], note['id'], event.voice, onset, min(end, tone_end), x, y, stem_x, stem_y, direction, generator.engraving.notation.flags(written), fragment))
if group_anchors:
stem_y = min(a.stem_y for a in group_anchors) if direction == -1 else max(a.stem_y for a in group_anchors)
anchor = min(group_anchors, key=lambda a: a.y) if direction == 1 else max(group_anchors, key=lambda a: a.y)
written = members[0][1]
if stem != 'none' and written['type'] not in ('whole', 'breve'):
group_items.append(line('stem', references, ((anchor.stem_x, anchor.y), (anchor.stem_x, stem_y)), size * 0.12))
group_items.append(generator.engraving.types.line('stem', references, ((anchor.stem_x, anchor.y), (anchor.stem_x, stem_y)), size * 0.12))
if stem == 'double':
other_x = anchor.x if direction == -1 else anchor.x + size * 1.3
other_y = anchor.y - direction * size * 3.5
group_items.append(line('stem', references, ((other_x, anchor.y), (other_x, other_y)), size * 0.12))
if flags(written):
group_items.append(generator.engraving.types.line('stem', references, ((other_x, anchor.y), (other_x, other_y)), size * 0.12))
if generator.engraving.notation.flags(written):
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + ('Down' if direction == -1 else 'Up')
group_items.append(fonts.glyph(flag, other_x, other_y, size, 'flag', references))
if flags(written):
if generator.engraving.notation.flags(written):
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + ('Up' if direction == -1 else 'Down')
group_items.append(fonts.glyph(flag, anchor.stem_x, stem_y, size, 'flag', references))
if note.get('grace', {}).get('slash'):
group_items.append(line('grace-slash', references, ((anchor.stem_x - 5, stem_y + 14), (anchor.stem_x + 9, stem_y + 4)), 1.5))
group_items.append(generator.engraving.types.line('grace-slash', references, ((anchor.stem_x - 5, stem_y + 14), (anchor.stem_x + 9, stem_y + 4)), 1.5))
if note.get('tremolo', {}).get('type') == 'single':
for mark in range(note['tremolo']['marks']):
ty = stem_y - direction * (10 + mark * 5)
group_items.append(line('tremolo', references, ((anchor.stem_x - 6, ty + 2), (anchor.stem_x + 6, ty - 2)), 3))
from dataclasses import replace
anchors.extend(replace(a, stem_x=anchor.stem_x, stem_y=stem_y) for a in group_anchors)
group_items.append(generator.engraving.types.line('tremolo', references, ((anchor.stem_x - 6, ty + 2), (anchor.stem_x + 6, ty - 2)), 3))
anchors.extend(dataclasses.replace(a, stem_x=anchor.stem_x, stem_y=stem_y) for a in group_anchors)
if not note.get('tuplet_ids') or members[0][1] != members[0][0]['written']:
time_modification(members[0][1], group_items, fonts, references)
items.extend(group_items)
+261
View File
@@ -0,0 +1,261 @@
from __future__ import annotations
import collections
import concurrent.futures
import dataclasses
import hashlib
import json
import multiprocessing
import pathlib
import shutil
import tempfile
import time
import PIL.Image
import generator.engraving.audit
import generator.engraving.cache
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.raster
import generator.engraving.types
import generator.engraving.validation
import generator.model
@dataclasses.dataclass
class GroupResult:
scores: list = dataclasses.field(default_factory=list)
fullscore: int = 0
bodyhits: int = 0
bodies: int = 0
renderedpages: int = 0
rejected: int = 0
timings: dict = dataclasses.field(default_factory=lambda: dict.fromkeys(('layout', 'pagination', 'raster', 'cache'), 0.0))
class BodyBands:
def __init__(self, root, persistent, result, limit=16 * 1024 * 1024):
self.root, self.persistent, self.result, self.limit = root, persistent, result, limit
self.images = collections.OrderedDict()
self.size = 0
self.known = {}
self.root.mkdir(parents=True, exist_ok=True)
def get(self, notation, page, band, fonts):
key = generator.engraving.cache.band_fingerprint(notation, page, band)
if key in self.images:
self.images.move_to_end(key)
self.result.bodyhits += 1
return self.images[key]
started = time.monotonic()
entry = self.root / (key + '.rgb')
image = None
if key in self.known:
if entry.is_symlink() or entry.stat().st_size != page.width * (band.bottom - band.top) * 3:
raise generator.engraving.types.EngravingError('temporary notation band was corrupted')
data = entry.read_bytes()
if hashlib.sha256(data).hexdigest() != self.known[key]:
raise generator.engraving.types.EngravingError('temporary notation band was corrupted')
image = PIL.Image.frombytes('RGB', (page.width, band.bottom - band.top), data)
elif self.persistent:
cached = self.persistent / 'notation' / key
image = generator.engraving.cache.restore_band(cached, key, page.width, band.bottom - band.top)
self.result.rejected += int(image is None and (cached.exists() or cached.is_symlink()))
self.result.timings['cache'] += time.monotonic() - started
if image is None:
started = time.monotonic()
image = generator.engraving.raster.rasterize_band(page, band.primitives, band.top, band.bottom, fonts)
self.result.timings['raster'] += time.monotonic() - started
self.result.bodies += 1
if self.persistent:
started = time.monotonic()
generator.engraving.cache.store_band(self.persistent / 'notation' / key, key, image)
self.result.timings['cache'] += time.monotonic() - started
else:
self.result.bodyhits += 1
if key not in self.known:
started = time.monotonic()
# Spill finalized pixels, so eviction never repeats rasterization or PNG decoding.
data = image.tobytes()
entry.write_bytes(data)
self.result.timings['cache'] += time.monotonic() - started
self.known[key] = hashlib.sha256(data).hexdigest()
size = image.width * image.height * 3
while self.images and self.size + size > self.limit:
_, old = self.images.popitem(last=False)
self.size -= old.width * old.height * 3
old.close()
self.images[key] = image
self.size += size
return image
def close(self):
for image in self.images.values():
image.close()
self.images.clear()
self.size = 0
def clear_pages(destination):
if destination.is_symlink() or destination.is_file():
destination.unlink()
elif destination.exists():
shutil.rmtree(destination)
def write_bundle(path, document, pages, fingerprint):
entries = [{**page, 'path': path.with_suffix('.pages').name + '/' + page['path']} for page in pages]
music = {key: value for key, value in document.items() if key != 'playback'}
bundle = {'music': music, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'style_sha256': fingerprint,
'width': generator.engraving.validation.PAGE_WIDTH, 'height': generator.engraving.validation.PAGE_HEIGHT, 'pages': entries}}
if 'playback' in document:
bundle['playback'] = document['playback']
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, progress=False):
family = generator.engraving.fonts.Fonts(font_path)
style = generator.engraving.types.Style()
systems = None
scores = {}
audited = set()
result = GroupResult()
with tempfile.TemporaryDirectory(prefix='vamp-notation-') as temporary:
bodies = BodyBands(pathlib.Path(temporary), cache_dir, result)
try:
for path, score_key in paths:
try:
document = json.loads(path.read_text(encoding='utf-8'))
destination = path.with_suffix('.pages')
clear_pages(destination)
destination.mkdir()
if score_key in scores:
source, pages = scores[score_key]
for page in pages:
shutil.copyfile(source / page['path'], destination / page['path'])
result.fullscore += 1
else:
if systems is None:
started = time.monotonic()
systems = generator.engraving.layout.layout_systems(document, style, family)
result.timings['layout'] += time.monotonic() - started
started = time.monotonic()
pages_layout = generator.engraving.layout.paginate(systems, document, family, style)
placement = tuple((page.width, page.height, tuple((system.start, system.end, system.box) for system in page.systems)) for page in pages_layout)
if placement not in audited:
layout = generator.engraving.types.LayoutDocument(pages_layout, style, fingerprint)
generator.engraving.audit.audit_layout(document, layout, family)
audited.add(placement)
else:
for page in pages_layout:
decoration = tuple(p for p in page.primitives if p.role in ('title', 'subtitle', 'page-number'))
generator.engraving.audit.audit_primitives(decoration, page.width, page.height, family)
result.timings['pagination'] += time.monotonic() - started
pages = []
for page in pages_layout:
bands = generator.engraving.raster.page_bands(page)
if bands is None:
started = time.monotonic()
image = generator.engraving.raster.rasterize_page(page, family)
else:
body = bodies.get(notation, page, bands[1], family)
started = time.monotonic()
image = generator.engraving.raster.compose_page(page, family, bands, body)
target = destination / f'{page.number:04}.png'
with image:
image.save(target, format='PNG')
result.timings['raster'] += time.monotonic() - started
result.renderedpages += 1
pages.append({'path': target.name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
scores[score_key] = (destination, pages)
write_bundle(path, document, pages, fingerprint)
result.scores.append((path, score_key, pages))
if progress and len(result.scores) % 50 == 0:
print(f'Engraving body {notation[:12]}: {len(result.scores)}/{len(paths)} scores, fullscore {result.fullscore}, bodyhits {result.bodyhits}, bodies {result.bodies}, renderedpages {result.renderedpages}', flush=True)
except ValueError as error:
raise generator.engraving.types.EngravingError(f'{path.relative_to(root)}: {error}') from error
finally:
bodies.close()
return result
def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.Path | None = None, jobs: int = 1, progress: bool = False) -> set[str]:
if type(jobs) is not int or not 1 <= jobs <= 16:
raise ValueError('engraving jobs must be between 1 and 16')
if cache_dir and (cache_dir.resolve().is_relative_to(root.resolve()) or root.resolve().is_relative_to(cache_dir.resolve())):
raise ValueError('engraving cache and generated assets must not overlap')
started = time.monotonic()
paths = sorted(root.rglob('*.json'))
validated_pages = set()
if not paths:
return validated_pages
family, style = generator.engraving.fonts.Fonts(fonts), generator.engraving.types.Style()
fingerprint = family.fingerprint(style)
renderer = generator.engraving.cache.renderer_fingerprint(family, style)
if cache_dir:
cache_dir = generator.engraving.cache.renderer_namespace(cache_dir, renderer)
if progress:
state = 'warm namespace' if cache_dir.is_dir() else 'cold namespace'
print(f'Engraving cache enabled: {cache_dir} ({state}; renderer {renderer})', flush=True)
elif progress:
print('Engraving cache disabled', flush=True)
totals = GroupResult()
missing, rejected, completed = 0, 0, 0
groups = {}
for path in paths:
try:
document = json.loads(path.read_text(encoding='utf-8'))
generator.model.validate_document(document)
score_key = generator.engraving.cache.score_fingerprint(document, renderer)
destination = path.with_suffix('.pages')
clear_pages(destination)
entry = cache_dir / 'fullscore' / score_key if cache_dir else None
present = entry is not None and (entry.exists() or entry.is_symlink())
pages = generator.engraving.cache.restore_pages(entry, destination, validated_pages) if entry else None
if pages is None:
rejected += int(present)
missing += int(entry is not None and not present)
notation = generator.engraving.cache.notation_fingerprint(document, renderer)
groups.setdefault(notation, []).append((path, score_key))
else:
write_bundle(path, document, pages, fingerprint)
totals.fullscore += 1
completed += 1
except ValueError as error:
raise generator.engraving.types.EngravingError(f'{path.relative_to(root)}: {error}') from error
prepare_time = time.monotonic() - started
if progress:
print(f'Engraving grouped: {len(paths)} scores, {len(groups)} notation bodies, {totals.fullscore} fullscore hits', flush=True)
validation_time = 0.0
def finish(result):
nonlocal completed, validation_time
started = time.monotonic()
for path, score_key, pages in result.scores:
destination = path.with_suffix('.pages')
for page in pages:
generator.engraving.validation.check_page(destination / page['path'], validated_pages, expected_sha256=page['sha256'])
validation_time += time.monotonic() - started
if cache_dir:
started = time.monotonic()
stored = set()
for path, score_key, pages in result.scores:
if score_key not in stored:
generator.engraving.cache.store_pages(cache_dir / 'fullscore' / score_key, path.with_suffix('.pages'), pages)
stored.add(score_key)
result.timings['cache'] += time.monotonic() - started
for field in ('fullscore', 'bodyhits', 'bodies', 'renderedpages', 'rejected'):
setattr(totals, field, getattr(totals, field) + getattr(result, field))
for phase, elapsed in result.timings.items():
totals.timings[phase] += elapsed
completed += len(result.scores)
if progress:
print(f'Engraving: {completed}/{len(paths)} scores, fullscore {totals.fullscore}, bodyhits {totals.bodyhits}, bodies {totals.bodies}, renderedpages {totals.renderedpages}', flush=True)
jobs = min(jobs, len(groups))
if jobs <= 1:
for notation, group in groups.items():
finish(engrave_group(notation, group, root, fonts, cache_dir, fingerprint, progress))
else:
with concurrent.futures.ProcessPoolExecutor(max_workers=jobs, mp_context=multiprocessing.get_context('spawn')) as pool:
futures = [pool.submit(engrave_group, notation, group, root, fonts, cache_dir, fingerprint, progress) for notation, group in groups.items()]
for future in concurrent.futures.as_completed(futures):
finish(future.result())
if progress:
print(f'Engraving complete: {len(paths)} scores, {totals.fullscore} reused, {missing} missing, {rejected} rejected; fullscore {totals.fullscore}, bodyhits {totals.bodyhits}, bodies {totals.bodies}, renderedpages {totals.renderedpages}, rejected bodies {totals.rejected}', flush=True)
phases = {'prepare': prepare_time, **totals.timings, 'validation': validation_time}
print('Engraving timings: ' + ', '.join(f'{phase} {elapsed:.3f}s' for phase, elapsed in phases.items()) + f'; wall {time.monotonic() - started:.3f}s (worker phases summed)', flush=True)
return validated_pages
+55 -13
View File
@@ -1,13 +1,17 @@
from __future__ import annotations
import dataclasses
import math
import PIL.Image
import PIL.ImageDraw
import aggdraw
from fontTools.pens.basePen import BasePen
from PIL import Image, ImageDraw
import fontTools.pens.basePen
from generator.engraving.fonts import Fonts
from generator.engraving.types import Page
import generator.engraving.fonts
import generator.engraving.types
class OutlinePen(BasePen):
class OutlinePen(fontTools.pens.basePen.BasePen):
def __init__(self, glyphs, scale: float, x: float, y: float):
super().__init__(glyphs)
self.path = aggdraw.Path()
@@ -25,12 +29,15 @@ class OutlinePen(BasePen):
def _endPath(self):
pass
def rasterize_page(page: Page, fonts: Fonts) -> Image.Image:
def rasterize_page(page: generator.engraving.types.Page, fonts: generator.engraving.fonts.Fonts) -> PIL.Image.Image:
return rasterize_band(page, page.primitives, 0, page.height, fonts)
def rasterize_band(page: generator.engraving.types.Page, primitives: tuple[generator.engraving.types.Primitive, ...], top: int, bottom: int, fonts: generator.engraving.fonts.Fonts) -> PIL.Image.Image:
scale = page.supersampling
image = Image.new('RGB', (page.width * scale, page.height * scale), 'white')
image = PIL.Image.new('RGB', (page.width * scale, (bottom - top) * scale), 'white')
context = aggdraw.Draw(image)
for item in page.primitives:
points = [(x * scale, y * scale) for x, y in item.points]
for item in primitives:
points = [(x * scale, y * scale - top * scale) for x, y in item.points]
if item.kind == 'text':
continue
if item.kind == 'glyph':
@@ -52,8 +59,43 @@ def rasterize_page(page: Page, fonts: Fonts) -> Image.Image:
else:
context.path(path, aggdraw.Pen('black', item.thickness * scale))
context.flush()
drawing = ImageDraw.Draw(image)
for item in page.primitives:
drawing = PIL.ImageDraw.Draw(image)
for item in primitives:
if item.kind == 'text':
drawing.text(tuple(v * scale for v in item.points[0]), item.value, font=fonts.text_font(item.size * scale), anchor='ls', fill='black')
return image.resize((page.width, page.height), Image.Resampling.LANCZOS)
x, y = item.points[0]
drawing.text((x * scale, y * scale - top * scale), item.value, font=fonts.text_font(item.size * scale), anchor='ls', fill='black')
return image.resize((page.width, bottom - top), PIL.Image.Resampling.LANCZOS)
@dataclasses.dataclass(frozen=True)
class Band:
top: int
bottom: int
primitives: tuple[generator.engraving.types.Primitive, ...]
def page_bands(page: generator.engraving.types.Page) -> tuple[Band, Band, Band] | None:
body = tuple(p for system in page.systems for p in system.primitives)
header = tuple(p for p in page.primitives if p.role in ('title', 'subtitle'))
footer = tuple(p for p in page.primitives if p.role == 'page-number')
if not body or not header or not footer or page.primitives != header + body + footer:
return None
bands = []
for primitives in (header, body, footer):
box = generator.engraving.types.bounds(primitives)
if not box.finite() or box.left < 4 or box.right > page.width - 4 or box.top < 4 or box.bottom > page.height - 4:
return None
# Six output pixels cover raster antialiasing and the three-pixel Lanczos
# support. Integer crop origins preserve the full-page sampling phase.
bands.append(Band(max(0, math.floor(box.top) - 6), min(page.height, math.ceil(box.bottom) + 6), primitives))
if bands[0].bottom > bands[1].top or bands[1].bottom > bands[2].top:
return None
return tuple(bands)
def compose_page(page: generator.engraving.types.Page, fonts: generator.engraving.fonts.Fonts, bands: tuple[Band, Band, Band], body: PIL.Image.Image) -> PIL.Image.Image:
image = PIL.Image.new('RGB', (page.width, page.height), 'white')
if body.mode != 'RGB' or body.size != (page.width, bands[1].bottom - bands[1].top):
raise generator.engraving.types.EngravingError('notation band has incompatible geometry')
image.paste(body, (0, bands[1].top))
for band in (bands[0], bands[2]):
with rasterize_band(page, band.primitives, band.top, band.bottom, fonts) as decoration:
image.paste(decoration, (0, band.top))
return image
+34 -34
View File
@@ -1,13 +1,13 @@
from __future__ import annotations
from fractions import Fraction
import fractions
from generator.model import beat_lengths, fraction
from generator.engraving.fonts import Fonts
from generator.engraving.notation import ScoreIndex, flags
from generator.engraving.types import Anchor, Primitive, Style, bounds, curve, line, polygon
import generator.engraving.fonts
import generator.engraving.notation
import generator.engraving.types
import generator.model
def beam_groups(index: ScoreIndex) -> tuple[tuple[str, ...], ...]:
def beam_groups(index: generator.engraving.notation.ScoreIndex) -> tuple[tuple[str, ...], ...]:
if index._beam_groups is not None:
return index._beam_groups
groups = []
@@ -18,17 +18,17 @@ def beam_groups(index: ScoreIndex) -> tuple[tuple[str, ...], ...]:
note = event.value
primary = next((b['type'] for b in note.get('beams', []) if b['level'] == 1), None)
measure_start = next(start for start, _, measure in index.measures if measure['id'] == event.measure['id'])
beats = beat_lengths(index.state(event.start)['meter'])
beats = generator.model.beat_lengths(index.state(event.start)['meter'])
offset = event.start - measure_start
beat = 0
edge = Fraction()
edge = fractions.Fraction()
for duration in beats:
edge += duration
if offset < edge:
break
beat += 1
key = (event.measure['id'], beat, tuple(note.get('tuplet_ids', [])), bool(note.get('grace')))
eligible = note['kind'] == 'note' and flags(note['written']) and note.get('stem') != 'none' and not note.get('hidden')
eligible = note['kind'] == 'note' and generator.engraving.notation.flags(note['written']) and note.get('stem') != 'none' and not note.get('hidden')
if not eligible or primary == 'begin' or (primary is None and key != previous_key):
if current:
groups.append(current)
@@ -44,7 +44,7 @@ def beam_groups(index: ScoreIndex) -> tuple[tuple[str, ...], ...]:
index._beam_groups = tuple(tuple(g) for g in groups if len(g) > 1 or index.by_id[g[0]].value.get('beams'))
return index._beam_groups
def draw_beams(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive], start: Fraction, end: Fraction, left: float, right: float, style: Style) -> list[Primitive]:
def draw_beams(index: generator.engraving.notation.ScoreIndex, anchors: list[generator.engraving.types.Anchor], items: list[generator.engraving.types.Primitive], start: fractions.Fraction, end: fractions.Fraction, left: float, right: float, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]:
additions = []
removed = set()
for group in beam_groups(index):
@@ -65,7 +65,7 @@ def draw_beams(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive],
x1 = left if index.by_id[group[0]].start < start else first.stem_x
x2 = right if index.by_id[group[-1]].start >= end else last.stem_x
for anchor in selected:
additions.append(line('beam-stem', (anchor.event,), ((anchor.stem_x, anchor.stem_y), (anchor.stem_x, beam_y)), 1.4))
additions.append(generator.engraving.types.line('beam-stem', (anchor.event,), ((anchor.stem_x, anchor.stem_y), (anchor.stem_x, beam_y)), 1.4))
for level in range(1, max(a.flags for a in selected) + 1):
y = beam_y - direction * (level - 1) * style.space * 0.75
for i, anchor in enumerate(selected):
@@ -88,18 +88,18 @@ def draw_beams(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive],
b = a + (8 if next_anchor else -8)
else:
continue
additions.append(polygon('beam', sources, ((a, y), (b, y), (b, y - direction * style.space * 0.5), (a, y - direction * style.space * 0.5))))
additions.append(generator.engraving.types.polygon('beam', sources, ((a, y), (b, y), (b, y - direction * style.space * 0.5), (a, y - direction * style.space * 0.5))))
if selected and x1 < selected[0].stem_x and selected[0].flags >= level:
additions.append(line('beam', sources, ((x1, y), (selected[0].stem_x, y)), style.space * 0.5))
additions.append(generator.engraving.types.line('beam', sources, ((x1, y), (selected[0].stem_x, y)), style.space * 0.5))
return [p for p in items if not (p.role == 'flag' and any(s in removed for s in p.sources))] + additions
def draw_spans(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive], start: Fraction, end: Fraction, left: float, right: float, fonts: Fonts, style: Style) -> list[Primitive]:
def draw_spans(index: generator.engraving.notation.ScoreIndex, anchors: list[generator.engraving.types.Anchor], items: list[generator.engraving.types.Primitive], start: fractions.Fraction, end: fractions.Fraction, left: float, right: float, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]:
result = []
by_tone = {a.source: a for a in anchors}
by_event = {a.event: a for a in anchors}
for identifier, (event, tone) in index.tones.items():
target = tone.get('tie_to')
tone_end = event.start + fraction(tone['duration'])
tone_end = event.start + generator.model.fraction(tone['duration'])
anchor = by_tone.get(identifier)
if anchor and anchor.fragment.end < tone_end:
target = identifier
@@ -118,11 +118,11 @@ def draw_spans(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive],
y1 = anchor.y + sign * 8 if anchor else reference.y + sign * 8
y2 = destination.y + sign * 8 if destination else y1
if x2 > x1:
result.append(curve('tie', (identifier, target), ((x1, y1), (x1 + (x2 - x1) / 3, y1 + sign * 9), (x2 - (x2 - x1) / 3, y2 + sign * 9), (x2, y2)), 1.6))
result.append(generator.engraving.types.curve('tie', (identifier, target), ((x1, y1), (x1 + (x2 - x1) / 3, y1 + sign * 9), (x2 - (x2 - x1) / 3, y2 + sign * 9), (x2, y2)), 1.6))
if anchor and anchor.fragment.start > event.start:
y = anchor.y - anchor.direction * 8
if anchor.x - 2 > left:
result.append(curve('tie', (identifier,), ((left, y), (left + 5, y - anchor.direction * 9), (anchor.x - 7, y - anchor.direction * 9), (anchor.x - 2, y)), 1.6))
result.append(generator.engraving.types.curve('tie', (identifier,), ((left, y), (left + 5, y - anchor.direction * 9), (anchor.x - 7, y - anchor.direction * 9), (anchor.x - 2, y)), 1.6))
top = min(-style.space * 5, min((p.box.top for p in items if p.role not in ('harmony', 'lyric', 'section', 'tempo')), default=0))
tuplet_lanes = []
for span in index.staff.get('tuplets', []):
@@ -158,33 +158,33 @@ def draw_spans(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive],
if q <= p:
continue
if span.get('line_shape') == 'curved':
result.append(curve('tuplet-bracket', references, ((p, y + 3), (p + (q - p) / 3, y - 4), (q - (q - p) / 3, y - 4), (q, y + 3)), 1.3))
result.append(generator.engraving.types.curve('tuplet-bracket', references, ((p, y + 3), (p + (q - p) / 3, y - 4), (q - (q - p) / 3, y - 4), (q, y + 3)), 1.3))
else:
result.append(line('tuplet-bracket', references, ((p, y), (q, y)), 1.3))
result.append(generator.engraving.types.line('tuplet-bracket', references, ((p, y), (q, y)), 1.3))
if a.start >= start:
result.append(line('tuplet-bracket', references, ((x1, y), (x1, y + 5)), 1.3))
result.append(generator.engraving.types.line('tuplet-bracket', references, ((x1, y), (x1, y + 5)), 1.3))
if b.end <= end:
result.append(line('tuplet-bracket', references, ((x2, y), (x2, y + 5)), 1.3))
outer_top = min(top, bounds(result).top if result else top)
result.append(generator.engraving.types.line('tuplet-bracket', references, ((x2, y), (x2, y + 5)), 1.3))
outer_top = min(top, generator.engraving.types.bounds(result).top if result else top)
for span in index.staff.get('octave_shifts', []):
a, b = fraction(span['start']), fraction(span['end'])
a, b = generator.model.fraction(span['start']), generator.model.fraction(span['end'])
if b <= start or a >= end:
continue
members = [n for n in anchors if a <= n.time < b]
x1 = min((n.x for n in members), default=left) if a >= start else left
x2 = max((n.x + style.space * 2 for n in members), default=right) if b <= end else right
above = span['octaves'] < 0
y = outer_top - 18 if above else max(bounds(items + result).bottom, 0) + 24
y = outer_top - 18 if above else max(generator.engraving.types.bounds(items + result).bottom, 0) + 24
label = str(abs(span['octaves']) * 7 + 1) + ('va' if above else 'vb')
text = fonts.text(label, x1, y, 16, 'octave-shift', (span['id'],))
result.append(text)
outer_top = min(outer_top, text.box.top - 4)
cursor = text.box.right + 4
while cursor < x2:
result.append(line('octave-shift', (span['id'],), ((cursor, y - 5), (min(cursor + 6, x2), y - 5)), 1))
result.append(generator.engraving.types.line('octave-shift', (span['id'],), ((cursor, y - 5), (min(cursor + 6, x2), y - 5)), 1))
cursor += 10
if b <= end:
result.append(line('octave-shift', (span['id'],), ((x2, y - 5), (x2, y + (2 if above else -12))), 1))
result.append(generator.engraving.types.line('octave-shift', (span['id'],), ((x2, y - 5), (x2, y + (2 if above else -12))), 1))
for event in index.events:
tremolo = event.value.get('tremolo', {})
if tremolo.get('type') != 'start':
@@ -199,18 +199,18 @@ def draw_spans(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive],
x1, x2 = a.stem_x + 4 if a else left, b.stem_x - 4 if b else right
y = anchor.stem_y - anchor.direction * 10
for mark in range(tremolo['marks']):
result.append(line('tremolo', (event.value['id'], partner.value['id']), ((x1, y + mark * 6), (x2, y + mark * 6)), 3.5))
result.append(generator.engraving.types.line('tremolo', (event.value['id'], partner.value['id']), ((x1, y + mark * 6), (x2, y + mark * 6)), 3.5))
return items + result
def place_text_lanes(items: list[Primitive], fonts: Fonts, style: Style) -> list[Primitive]:
def place_text_lanes(items: list[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]:
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section', 'tempo')]
box = bounds(music)
box = generator.engraving.types.bounds(music)
result = list(music)
top = box.top - 12
for role in ('harmony', 'tempo', 'section'):
lane = [p for p in items if p.role == role]
if lane:
extent = bounds(lane)
extent = generator.engraving.types.bounds(lane)
result.extend(p.moved(0, top - extent.bottom) for p in lane)
top -= extent.bottom - extent.top + 10
lyrics = [p for p in items if p.role == 'lyric']
@@ -218,7 +218,7 @@ def place_text_lanes(items: list[Primitive], fonts: Fonts, style: Style) -> list
result.extend(p.moved(0, max(box.bottom, 0) + 20) for p in lyrics)
return result
def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[Anchor], start: Fraction, end: Fraction, left: float, right: float, fonts: Fonts) -> list[Primitive]:
def draw_lyric_spans(index: generator.engraving.notation.ScoreIndex, items: list[generator.engraving.types.Primitive], anchors: list[generator.engraving.types.Anchor], start: fractions.Fraction, end: fractions.Fraction, left: float, right: float, fonts: generator.engraving.fonts.Fonts) -> list[generator.engraving.types.Primitive]:
result = list(items)
labels = {p.sources[0]: p for p in items if p.role == 'lyric'}
positions = {a.event: a for a in anchors}
@@ -232,7 +232,7 @@ def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[An
if lyric_baseline is not None:
break
if lyric_baseline is None:
lyric_baseline = max(0, bounds(items).bottom) + 20
lyric_baseline = max(0, generator.engraving.types.bounds(items).bottom) + 20
for voice, number in index.lyric_lanes:
verses = [(e, v) for e in index.events if e.voice == voice and not e.value.get('hidden') for v in e.value.get('lyrics', []) if v['number'] == number]
for i, (event, verse) in enumerate(verses):
@@ -245,7 +245,7 @@ def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[An
continue
target = following[0].start if following else max(e.end for e in index.events if e.voice == voice)
if 'extend_to' in verse:
target = fraction(verse['extend_to'])
target = generator.model.fraction(verse['extend_to'])
if target <= start or event.start >= end:
continue
identifier = event.value['id'] + ':lyric:' + number
@@ -262,7 +262,7 @@ def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[An
if x2 <= x1:
continue
if extend in ('start', 'continue'):
result.append(line('lyric-extension', (identifier,), ((x1, y + 3), (x2, y + 3)), 1))
result.append(generator.engraving.types.line('lyric-extension', (identifier,), ((x1, y + 3), (x2, y + 3)), 1))
elif following:
count = max(1, int((x2 - x1) / 90))
for dash in range(count):
+9 -9
View File
@@ -1,8 +1,8 @@
from __future__ import annotations
from generator.model import fraction
from generator.engraving.notes import rest_glyph
from generator.engraving.types import bounds, curve, line
import generator.engraving.notes
import generator.engraving.types
import generator.model
def column(index, event, anchors, fonts, style):
if not index.staff.get('tablature') or not anchors:
@@ -11,7 +11,7 @@ def column(index, event, anchors, fonts, style):
sources = (event.value['id'],)
if not event.value['pitches']:
anchor = anchors[0]
glyph = 'noteheadSlashHorizontalEnds' if anchor.fragment.kind == 'slash' else rest_glyph(anchor.fragment.written_type)
glyph = 'noteheadSlashHorizontalEnds' if anchor.fragment.kind == 'slash' else generator.engraving.notes.rest_glyph(anchor.fragment.written_type)
return [fonts.glyph(glyph, x, -2 * style.space, style.space, 'tab-' + anchor.fragment.kind, sources)]
result = []
for anchor in anchors:
@@ -26,7 +26,7 @@ def staff(index, items, start, end, left, right, fonts, style):
numbers = [p for p in items if p.role.startswith('tab')]
if not index.staff.get('tablature'):
return items
offset = bounds(notation).bottom + 32 + 5 * style.space
offset = generator.engraving.types.bounds(notation).bottom + 32 + 5 * style.space
numbers = [p.moved(0, offset) for p in numbers]
drawing = list(numbers)
drawing.append(fonts.text('TAB', 0, offset - 2 * style.space, 15, 'tab-label', ()))
@@ -36,14 +36,14 @@ def staff(index, items, start, end, left, right, fonts, style):
cursor = 0
for a, b in cuts + [(right, right)]:
if a > cursor:
drawing.append(line('tab-staff', (), ((cursor, y), (a, y)), style.space * 0.13))
drawing.append(generator.engraving.types.line('tab-staff', (), ((cursor, y), (a, y)), style.space * 0.13))
cursor = max(cursor, b)
for bar in (p for p in notation if p.role == 'barline'):
drawing.append(line('tab-barline', bar.sources, tuple((x, offset + y * 5 / 4) for x, y in bar.points), bar.thickness))
drawing.append(generator.engraving.types.line('tab-barline', bar.sources, tuple((x, offset + y * 5 / 4) for x, y in bar.points), bar.thickness))
by_source = {source: p for p in numbers if p.role == 'tab' for source in p.sources[1:]}
for identifier, (event, tone) in index.tones.items():
target = tone.get('tie_to')
finish = event.start + fraction(tone['duration'])
finish = event.start + generator.model.fraction(tone['duration'])
if not target and not (event.start < start < finish or event.start < end < finish):
continue
if target:
@@ -55,5 +55,5 @@ def staff(index, items, start, end, left, right, fonts, style):
x2 = last.box.left - 2 if last else right
y = offset - (6 - tone['tablature']['string']) * style.space + 8
if x2 > x1:
drawing.append(curve('tab-tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y + 5), (x2 - (x2 - x1) / 3, y + 5), (x2, y))))
drawing.append(generator.engraving.types.curve('tab-tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y + 5), (x2 - (x2 - x1) / 3, y + 5), (x2, y))))
return notation + drawing
+22 -22
View File
@@ -1,13 +1,13 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from fractions import Fraction
import dataclasses
import fractions
import math
class EngravingError(ValueError):
pass
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class Style:
width: int = 976
height: int = 484
@@ -18,7 +18,7 @@ class Style:
column_gap: float = 10
system_gap: float = 26
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class Box:
left: float
top: float
@@ -31,7 +31,7 @@ class Box:
def finite(self) -> bool:
return all(math.isfinite(v) for v in (self.left, self.top, self.right, self.bottom)) and self.left <= self.right and self.top <= self.bottom
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class Primitive:
kind: str
role: str
@@ -42,30 +42,30 @@ class Primitive:
size: float = 0
thickness: float = 0
def moved(self, x: float, y: float) -> Primitive:
return replace(self, box=self.box.moved(x, y), points=tuple((a + x, b + y) for a, b in self.points))
return dataclasses.replace(self, box=self.box.moved(x, y), points=tuple((a + x, b + y) for a, b in self.points))
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class Fragment:
source: str
event: str
start: Fraction
end: Fraction
start: fractions.Fraction
end: fractions.Fraction
written_type: str
dots: int
ratio: Fraction
ratio: fractions.Fraction
visible: bool
kind: str
position: float | None
def length(self) -> Fraction:
def length(self) -> fractions.Fraction:
return self.end - self.start
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class Anchor:
source: str
event: str
voice: str
time: Fraction
end: Fraction
time: fractions.Fraction
end: fractions.Fraction
x: float
y: float
stem_x: float
@@ -74,18 +74,18 @@ class Anchor:
flags: int
fragment: Fragment
def moved(self, x: float, y: float) -> Anchor:
return replace(self, x=self.x + x, y=self.y + y, stem_x=self.stem_x + x, stem_y=self.stem_y + y)
return dataclasses.replace(self, x=self.x + x, y=self.y + y, stem_x=self.stem_x + x, stem_y=self.stem_y + y)
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class System:
start: Fraction
end: Fraction
start: fractions.Fraction
end: fractions.Fraction
primitives: tuple[Primitive, ...]
anchors: tuple[Anchor, ...]
fragments: tuple[Fragment, ...]
box: Box
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class Page:
number: int
width: int
@@ -94,7 +94,7 @@ class Page:
systems: tuple[System, ...]
primitives: tuple[Primitive, ...]
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class LayoutDocument:
pages: tuple[Page, ...]
style: Style
@@ -111,7 +111,7 @@ def line(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float],
return Primitive('line', role, sources, box, points, thickness=thickness)
def curve(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], ...], thickness: float = 1.6) -> Primitive:
return replace(line(role, sources, points, thickness), kind='curve')
return dataclasses.replace(line(role, sources, points, thickness), kind='curve')
def polygon(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], ...]) -> Primitive:
return replace(line(role, sources, points, 0), kind='polygon')
return dataclasses.replace(line(role, sources, points, 0), kind='polygon')
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import hashlib
import io
import pathlib
import re
import PIL.Image
import PIL.ImageChops
import generator.engraving.types
ENGINE_NAME = 'Vamp Python'
PAGE_WIDTH = 976
PAGE_HEIGHT = 484
def check_content(data: bytes, name: str) -> None:
with PIL.Image.open(io.BytesIO(data)) as image:
if image.format != 'PNG' or image.size != (PAGE_WIDTH, PAGE_HEIGHT):
raise generator.engraving.types.EngravingError(f'{name}: expected a {PAGE_WIDTH}x{PAGE_HEIGHT} PNG page')
image.load()
white = PIL.Image.new('RGBA', image.size, 'white')
white.alpha_composite(image.convert('RGBA'))
ink = PIL.ImageChops.difference(white.convert('RGB'), PIL.Image.new('RGB', image.size, 'white')).convert('L').point(lambda x: 255 if x > 32 else 0)
bounds = ink.getbbox()
if not bounds:
raise generator.engraving.types.EngravingError(f'{name}: engraver produced a blank page')
if bounds[0] < 4 or bounds[1] < 4 or bounds[2] > PAGE_WIDTH - 4 or bounds[3] > PAGE_HEIGHT - 4:
raise generator.engraving.types.EngravingError(f'{name}: notation reaches the page edge; refusing potentially clipped output')
def check_page(path: pathlib.Path, validated_pages: set[str] | None = None, *, expected_sha256: str | None = None) -> str:
# Trust content, never a filename or a caller-supplied checksum alone.
if path.is_symlink() or not path.is_file():
raise generator.engraving.types.EngravingError(f'{path.name}: expected a regular PNG file')
data = path.read_bytes()
digest = hashlib.sha256(data).hexdigest()
if expected_sha256 is not None and (not isinstance(expected_sha256, str) or not re.fullmatch('[0-9a-f]{64}', expected_sha256) or digest != expected_sha256):
raise generator.engraving.types.EngravingError(f'{path.name}: score page checksum mismatch')
if validated_pages is None or digest not in validated_pages:
check_content(data, path.name)
if validated_pages is not None:
validated_pages.add(digest)
return digest
-192
View File
@@ -1,192 +0,0 @@
#!/usr/bin/env python3
import argparse
import difflib
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, features
REPO = Path(__file__).resolve().parents[1]
FONT_PATH = REPO / "package/assets/fonts/petaluma/PetalumaScript.otf"
FONT_SIZE = 32
FONT_HEIGHT = 36
FONT_MAX_WIDTH = 28
FIRST_CODEPOINT = 32
LAST_CODEPOINT = 126
def cpp_char_literal(value: str) -> str:
if value == "'":
return "'\\''"
if value == "\\":
return "'\\\\'"
return f"'{value}'"
def render_glyph(font: ImageFont.FreeTypeFont, value: str, y_offset: int) -> tuple[list[int], int, int]:
image = Image.new("L", (FONT_MAX_WIDTH, FONT_HEIGHT), 0)
draw = ImageDraw.Draw(image)
bbox = font.getbbox(value)
x = max(0, -bbox[0])
draw.text((x, y_offset), value, font=font, fill=255)
pixels = [min(15, max(0, round(pixel / 17))) for pixel in image.get_flattened_data()]
visible = image.getbbox()
width = 0 if visible is None else min(FONT_MAX_WIDTH, visible[2])
advance = min(255, max(0, round(font.getlength(value))))
return pixels, width, advance
def render_alpha_rows(values: list[int]) -> list[str]:
lines = []
for row in range(FONT_HEIGHT):
start = row * FONT_MAX_WIDTH
row_values = values[start: start + FONT_MAX_WIDTH]
lines.append(" " + ", ".join(f"0x{value:02x}" for value in row_values) + ",")
return lines
def pair_adjustments(font: ImageFont.FreeTypeFont) -> list[tuple[int, int]]:
chars = [chr(value) for value in range(FIRST_CODEPOINT, LAST_CODEPOINT + 1)]
adjustments = []
for left in chars:
left_width = font.getlength(left)
for right in chars:
adjustment = round(font.getlength(left + right) - left_width - font.getlength(right))
if adjustment == 0:
continue
key = (ord(left) << 8) | ord(right)
adjustments.append((key, int(adjustment)))
return sorted(adjustments)
def generate() -> str:
if not features.check_feature("raqm"):
raise RuntimeError("Font generation requires Pillow with Raqm support; install libfribidi0 on Debian/Ubuntu.")
font = ImageFont.truetype(str(FONT_PATH), FONT_SIZE, layout_engine=ImageFont.Layout.RAQM)
min_top = min(font.getbbox(chr(value))[1] for value in range(FIRST_CODEPOINT, LAST_CODEPOINT + 1))
y_offset = max(0, -min_top)
adjustments = pair_adjustments(font)
lines = [
"#include <vamp_style/font.hpp>",
"",
"#include <algorithm>",
"#include <array>",
"#include <cstdint>",
"#include <string_view>",
"",
"namespace {",
"",
"struct pair_adjustment {",
" std::uint16_t key{};",
" std::int8_t adjustment{};",
"};",
"",
f"constexpr auto pair_adjustments = std::array<pair_adjustment, {len(adjustments)}>{{{{",
]
for key, adjustment in adjustments:
lines.append(f" {{0x{key:04x}, {adjustment}}},")
lines.extend(
[
"}};",
"",
"auto pair_key(std::char_t left, std::char_t right) -> std::uint16_t {",
" auto l = static_cast<std::uint16_t>(static_cast<std::uint8_t>(left));",
" auto r = static_cast<std::uint16_t>(static_cast<std::uint8_t>(right));",
" return static_cast<std::uint16_t>((l << 8U) | r);",
"}",
"",
"auto empty_glyph() -> vamp_style::font_glyph {",
" return {",
" .alpha = {},",
" .width = 0,",
" .advance = 8,",
" };",
"}",
"",
"}",
"",
"auto vamp_style::ui_font_glyph(std::char_t value) -> vamp_style::font_glyph {",
" switch (value) {",
]
)
for codepoint in range(FIRST_CODEPOINT, LAST_CODEPOINT + 1):
value = chr(codepoint)
alpha, width, advance = render_glyph(font, value, y_offset)
lines.extend(
[
f" case {cpp_char_literal(value)}:",
" return {",
" .alpha = {",
]
)
lines.extend(render_alpha_rows(alpha))
lines.extend(
[
" },",
f" .width = {width},",
f" .advance = {advance},",
" };",
]
)
lines.extend(
[
" default:",
" return empty_glyph();",
" }",
"}",
"",
"auto vamp_style::ui_font_pair_adjust(std::char_t left, std::char_t right) -> std::int32_t {",
" auto key = pair_key(left, right);",
" auto found = std::lower_bound(pair_adjustments.begin(), pair_adjustments.end(), key, [](pair_adjustment const& item, std::uint16_t value) {",
" return item.key < value;",
" });",
" if (found == pair_adjustments.end() || found->key != key) {",
" return 0;",
" }",
" return found->adjustment;",
"}",
"",
"auto vamp_style::ui_text_width(std::string_view text, std::int32_t scale) -> std::int32_t {",
" if (text.empty() || scale <= 0) {",
" return 0;",
" }",
" auto width = std::int32_t{};",
" auto previous = std::char_t{};",
" for (auto ch : text) {",
" if (previous != std::char_t{}) {",
" width += vamp_style::ui_font_pair_adjust(previous, ch) * scale;",
" }",
" width += static_cast<std::int32_t>(vamp_style::ui_font_glyph(ch).advance) * scale;",
" previous = ch;",
" }",
" return std::max(std::int32_t{}, width);",
"}",
"",
]
)
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
generated = generate()
if args.check:
if not args.output.is_file():
parser.error(f"generated font is missing: {args.output}")
current = args.output.read_text(encoding="utf-8")
if current == generated:
print("pico font is up to date")
return 0
diff = difflib.unified_diff(
current.splitlines(),
generated.splitlines(),
fromfile=str(args.output),
tofile=f"{args.output} (generated)",
lineterm="",
)
print("\n".join(diff))
return 1
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(generated, encoding="utf-8")
print(f"generated {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+40 -39
View File
@@ -1,13 +1,14 @@
from __future__ import annotations
from fractions import Fraction
from dataclasses import dataclass
from functools import lru_cache
from itertools import groupby
from typing import NamedTuple
import dataclasses
import fractions
import functools
import itertools
import typing
from generator import harmony, pitch
from generator.model import NATURAL, alteration, fraction, make_event, pitch_number
import generator.harmony
import generator.model
import generator.pitch
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
MIN_FRET = 0
@@ -15,7 +16,7 @@ MAX_FRET = 20
MAX_FRET_SPAN = 4
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
class TabPosition(NamedTuple):
class TabPosition(typing.NamedTuple):
string_number: int
fret: int
midi: int
@@ -24,7 +25,7 @@ def within_fret_span(positions) -> bool:
fretted = [position.fret for position in positions if position.fret > 0]
return not fretted or max(fretted) - min(fretted) <= MAX_FRET_SPAN
def positions_for_pitch(midi: Fraction) -> tuple[TabPosition, ...]:
def positions_for_pitch(midi: fractions.Fraction) -> tuple[TabPosition, ...]:
if midi.denominator != 1:
return ()
return tuple(TabPosition(string, int(midi - open_midi), int(midi)) for string, open_midi in STANDARD_TUNING if MIN_FRET <= midi - open_midi <= MAX_FRET)
@@ -35,20 +36,20 @@ def validate_tuning(state: dict, location: str) -> None:
raise ValueError(f'{location}: guitar generation does not support capo configurations')
tunings = details.get('tunings', [])
expected = {7 - string: midi for string, midi in STANDARD_TUNING}
if tunings and (len(tunings) != 6 or {t['line']: pitch_number(t) for t in tunings} != expected):
if tunings and (len(tunings) != 6 or {t['line']: generator.model.pitch_number(t) for t in tunings} != expected):
raise ValueError(f'{location}: guitar generation requires standard six-string tuning')
def chord_requirements(definition: dict) -> tuple[tuple[int, ...], tuple[int, ...], int | None]:
degrees = harmony.degrees(definition)
degrees = generator.harmony.degrees(definition)
root = definition['root']
root_pc = NATURAL[root['letter']] + alteration(root['alter'])
root_pc = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])
explicit = {d['value'] for d in definition.get('degrees', []) if d['type'] != 'subtract'}
optional = {d for d, a in degrees.items() if not a and d not in explicit and (d == 5 and len(degrees) > 3 or d in (9, 11) and d < max(degrees))}
classes = {d: (root_pc + harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
classes = {d: (root_pc + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
required = {value for d, value in classes.items() if d not in optional}
allowed = set(classes.values())
bass = definition.get('bass')
bass_pc = (NATURAL[bass['letter']] + alteration(bass['alter'])) % 12 if bass else None
bass_pc = (generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12 if bass else None
if bass_pc is None and 'inversion' in definition:
ordered = sorted(degrees)
inversion = definition['inversion']
@@ -60,7 +61,7 @@ def chord_requirements(definition: dict) -> tuple[tuple[int, ...], tuple[int, ..
required.add(bass_pc)
return tuple(sorted(required)), tuple(sorted(allowed)), bass_pc
@lru_cache(maxsize=256)
@functools.lru_cache(maxsize=256)
def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: int | None, prune: bool = True) -> tuple[tuple[TabPosition, ...], ...]:
result = []
options = [tuple(TabPosition(string, fret, open_midi + fret) for fret in range(MIN_FRET, MAX_FRET + 1) if (open_midi + fret) % 12 in allowed) for string, open_midi in STANDARD_TUNING]
@@ -92,26 +93,26 @@ def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: i
def chord_pitches(definition: dict, positions: tuple[TabPosition, ...]) -> list[dict]:
spellings = {}
for degree, alter in harmony.degrees(definition).items():
value = pitch.shift_pitch(definition['root'], degree - 1, harmony.degree_semitones(degree, alter))
spellings[(NATURAL[value['letter']] + alteration(value['alter'])) % 12] = value
for degree, alter in generator.harmony.degrees(definition).items():
value = generator.pitch.shift_pitch(definition['root'], degree - 1, generator.harmony.degree_semitones(degree, alter))
spellings[(generator.model.NATURAL[value['letter']] + generator.model.alteration(value['alter'])) % 12] = value
if bass := definition.get('bass'):
spellings[(NATURAL[bass['letter']] + alteration(bass['alter'])) % 12] = bass
spellings[(generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12] = bass
result = []
for position in sorted(positions, key=lambda p: p.midi):
value = spellings[position.midi % 12].copy()
value['octave'] = int((position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET - NATURAL[value['letter']] - alteration(value['alter'])) // 12 - 1)
value['octave'] = int((position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET - generator.model.NATURAL[value['letter']] - generator.model.alteration(value['alter'])) // 12 - 1)
value['tablature'] = {'string': position.string_number, 'fret': position.fret}
result.append(value)
return result
@lru_cache(maxsize=16384)
@functools.lru_cache(maxsize=16384)
def movement(previous: tuple[TabPosition, ...], current: tuple[TabPosition, ...]) -> int:
if not previous or not current:
return 0
return sum(abs(a - b) for a, b in zip(position_center(previous), position_center(current)))
@lru_cache(maxsize=4096)
@functools.lru_cache(maxsize=4096)
def position_center(positions: tuple[TabPosition, ...]) -> tuple[int, int]:
frets = [p.fret for p in positions if p.fret] or [0]
strings = [p.string_number for p in positions]
@@ -131,7 +132,7 @@ def extend_positions(held, pending):
if position.string_number not in occupied and within_fret_span((*held.values(), position)):
yield from extend_positions({**held, identifier: position}, pending[1:])
@dataclass
@dataclasses.dataclass
class Placement:
cost: tuple[int, int, int]
active: tuple
@@ -169,13 +170,13 @@ def nearest_positions(states: dict) -> list[list[tuple | None]]:
relax(x, y, x, y + 1)
return grid
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class GuitarTone:
pitch: dict
end: Fraction
end: fractions.Fraction
positions: tuple[TabPosition, ...]
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class AttackCandidate:
active: tuple
choices: tuple
@@ -208,38 +209,38 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
events, tones = [], {}
absolute = Fraction()
absolute = fractions.Fraction()
for measure in document['staves'][0]['measures']:
changes = sorted(measure.get('attributes', []), key=lambda a: fraction(a['onset']))
changes = sorted(measure.get('attributes', []), key=lambda a: generator.model.fraction(a['onset']))
state = {k: measure[k] for k in ('transpose', 'staff_details')}
for change in [state, *changes]:
state.update({k: change[k] for k in state if k in change})
validate_tuning(state, measure['id'])
for voice in measure['voices']:
for order, note in enumerate(voice['notes']):
onset = fraction(note['onset'])
onset = generator.model.fraction(note['onset'])
state = {k: measure[k] for k in ('transpose', 'staff_details')}
for change in changes:
if fraction(change['onset']) <= onset:
if generator.model.fraction(change['onset']) <= onset:
state.update({k: change[k] for k in state if k in change})
for tone in note['pitches']:
sounding = pitch.sounding_pitch(tone, state)
sounding = generator.pitch.sounding_pitch(tone, state)
options = positions_for_pitch(sounding)
if not options:
raise ValueError(f'{measure["id"]}, event {note["id"]}: sounding pitch {sounding} has no string/fret in {MIN_FRET}-{MAX_FRET}')
tones[tone['id']] = GuitarTone(tone, absolute + onset + fraction(tone['duration']), options)
tones[tone['id']] = GuitarTone(tone, absolute + onset + generator.model.fraction(tone['duration']), options)
if '_harmony' in note:
for string, _ in STANDARD_TUNING:
tones[f'{note["id"]}-s{string}'] = GuitarTone({}, absolute + onset + fraction(note['duration']), ())
tones[f'{note["id"]}-s{string}'] = GuitarTone({}, absolute + onset + generator.model.fraction(note['duration']), ())
if note['pitches'] or '_harmony' in note:
events.append((absolute + onset, not bool(note.get('grace')), order if note.get('grace') else -1, note))
absolute += fraction(measure['duration'])
absolute += generator.model.fraction(measure['duration'])
return sorted(events, key=lambda e: e[:3]), tones
def retain_tone(tone: GuitarTone, onset: Fraction) -> bool:
def retain_tone(tone: GuitarTone, onset: fractions.Fraction) -> bool:
return tone.end > onset or tone.end == onset and bool(tone.pitch.get('tie_to'))
def advance_positions(notes: list[dict], onset: Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool) -> dict:
def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool) -> dict:
continued = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p}
held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)}
result = {}
@@ -269,17 +270,17 @@ def advance_positions(notes: list[dict], onset: Fraction, tones: dict[str, Guita
def place_guitar(document: dict) -> None:
"""Choose a minimum-cost path, retaining held strings in each search state."""
events, tones = guitar_events(document)
groups = [(key[0], [item[3] for item in values]) for key, values in groupby(events, key=lambda e: e[:3])]
groups = [(key[0], [item[3] for item in values]) for key, values in itertools.groupby(events, key=lambda e: e[:3])]
states = {(): Placement((0, 0, 0), (), None, ())}
for index, (onset, notes) in enumerate(groups):
next_onset = groups[index + 1][0] if index + 1 < len(groups) else None
preserve_strings = next_onset is not None and any('_harmony' in n and onset + fraction(n['duration']) > next_onset for n in notes)
preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes)
states = advance_positions(notes, onset, tones, states, preserve_strings)
selected = min(states.values(), key=lambda s: (s.cost, s.active))
while selected.previous is not None:
for note, choices in selected.choices:
if definition := note.pop('_harmony', None):
note['pitches'] = make_event(note['id'], fraction(note['onset']), fraction(note['duration']), 'note', chord_pitches(definition, choices))['pitches']
note['pitches'] = generator.model.make_event(note['id'], generator.model.fraction(note['onset']), generator.model.fraction(note['duration']), 'note', chord_pitches(definition, choices))['pitches']
else:
for tone, position in choices:
tone['tablature'] = {'string': position.string_number, 'fret': position.fret}
+12 -12
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import re
from generator.model import NATURAL, alteration, parse_pitch_name, pitch_name, rational
import generator.model
# Semantic degrees are independent of guitar voicing omissions.
KINDS = {
@@ -49,7 +49,7 @@ def degrees(harmony: dict) -> dict[int, int]:
raise ValueError(f'unsupported harmony kind: {kind}')
result = KINDS[kind].copy()
for change in harmony.get('degrees', []):
degree, alter, operation = change['value'], alteration(change['alter']), change['type']
degree, alter, operation = change['value'], generator.model.alteration(change['alter']), change['type']
if type(degree) is not int or degree < 1:
raise ValueError('invalid harmony degree')
if operation == 'subtract':
@@ -79,10 +79,10 @@ def pitch_classes(harmony: dict) -> tuple[int, ...]:
if harmony['kind'] == 'none':
return ()
root = harmony['root']
root_pc = NATURAL[root['letter']] + alteration(root['alter'])
root_pc = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])
values = [(root_pc + degree_semitones(d, a)) % 12 for d, a in degrees(harmony).items()]
if bass := harmony.get('bass'):
values.append((NATURAL[bass['letter']] + alteration(bass['alter'])) % 12)
values.append((generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12)
return tuple(dict.fromkeys(values))
def chord_suffix(harmony: dict) -> str:
@@ -98,7 +98,7 @@ def chord_suffix(harmony: dict) -> str:
return suffix
suffix = SUFFIXES.get(kind, kind)
for change in sorted(harmony.get('degrees', []), key=lambda change: change['value']):
amount = alteration(change['alter'])
amount = generator.model.alteration(change['alter'])
sign = '#' * max(int(amount), 0) + 'b' * max(int(-amount), 0) if amount.denominator == 1 else f'({amount})'
suffix += ('no' if change['type'] == 'subtract' else ('add' if change['type'] == 'add' and not sign else '') + sign) + str(change['value'])
return suffix
@@ -107,9 +107,9 @@ def symbol(harmony: dict) -> str:
if harmony['kind'] == 'none':
return 'N.C.'
suffix = chord_suffix(harmony)
result = pitch_name(harmony['root']) + suffix
result = generator.model.pitch_name(harmony['root']) + suffix
if bass := harmony.get('bass'):
result += '/' + pitch_name(bass)
result += '/' + generator.model.pitch_name(bass)
return '(' + result + ')' if harmony.get('parenthesized') else result
def parse_symbol(text: str) -> dict:
@@ -137,11 +137,11 @@ def parse_symbol(text: str) -> dict:
operation = 'subtract' if operation == 'no' else 'alter' if degree in degrees(result) and operation != 'add' else 'add'
result['degrees'].append({'value': degree, 'alter': accidental.count('#') - accidental.count('b'), 'type': operation})
remainder = remainder[modifier.end():]
result['root'] = parse_pitch_name(root)
result['root'] = generator.model.parse_pitch_name(root)
for change in result['degrees']:
change['alter'] = rational(change['alter'])
change['alter'] = generator.model.rational(change['alter'])
if bass:
result['bass'] = parse_pitch_name(bass)
result['bass'] = generator.model.parse_pitch_name(bass)
if parenthesized:
result['parenthesized'] = True
degrees(result)
@@ -152,12 +152,12 @@ def normalize_text(harmony: dict, text: str) -> None:
parenthesized = text.startswith('(') and text.endswith(')')
content = text[1:-1] if parenthesized else text
try:
displayed = parse_symbol(pitch_name(harmony['root']) + content)
displayed = parse_symbol(generator.model.pitch_name(harmony['root']) + content)
except ValueError:
displayed = parse_symbol(content)
text_only = harmony['kind'] == 'other' and not harmony['degrees']
if displayed['root'] != harmony['root']:
if not text_only or harmony['root'] != parse_pitch_name('C'):
if not text_only or harmony['root'] != generator.model.parse_pitch_name('C'):
raise ValueError(f'contradictory harmony root in {text!r}')
if 'bass' in displayed and 'bass' in harmony and displayed['bass'] != harmony['bass']:
raise ValueError(f'contradictory harmony bass in {text!r}')
+32 -32
View File
@@ -1,56 +1,56 @@
from __future__ import annotations
import copy
from fractions import Fraction
from typing import Any
import fractions
import typing
MAX_INTEGER = (1 << 63) - 1
NOTE_TYPES = ('maxima', 'long', 'breve', 'whole', 'half', 'quarter', 'eighth', '16th', '32nd', '64th', '128th', '256th', '512th', '1024th')
TYPE_LENGTHS = {name: Fraction(32, 2 ** index) for index, name in enumerate(NOTE_TYPES)}
TYPE_LENGTHS = {name: fractions.Fraction(32, 2 ** index) for index, name in enumerate(NOTE_TYPES)}
NATURAL = {'c': 0, 'd': 2, 'e': 4, 'f': 5, 'g': 7, 'a': 9, 'b': 11}
LETTERS = 'cdefgab'
MODE_STEPS = {'major': 0, 'ionian': 0, 'dorian': 1, 'phrygian': 2, 'lydian': 3, 'mixolydian': 4, 'minor': 5, 'aeolian': 5, 'locrian': 6}
ACCIDENTALS = {'natural': Fraction(0), 'sharp': Fraction(1), 'flat': Fraction(-1), 'double-sharp': Fraction(2), 'sharp-sharp': Fraction(2), 'flat-flat': Fraction(-2), 'triple-sharp': Fraction(3), 'triple-flat': Fraction(-3), 'natural-sharp': Fraction(1), 'natural-flat': Fraction(-1), 'quarter-sharp': Fraction(1, 2), 'quarter-flat': Fraction(-1, 2), 'three-quarters-sharp': Fraction(3, 2), 'three-quarters-flat': Fraction(-3, 2)}
ACCIDENTALS = {'natural': fractions.Fraction(0), 'sharp': fractions.Fraction(1), 'flat': fractions.Fraction(-1), 'double-sharp': fractions.Fraction(2), 'sharp-sharp': fractions.Fraction(2), 'flat-flat': fractions.Fraction(-2), 'triple-sharp': fractions.Fraction(3), 'triple-flat': fractions.Fraction(-3), 'natural-sharp': fractions.Fraction(1), 'natural-flat': fractions.Fraction(-1), 'quarter-sharp': fractions.Fraction(1, 2), 'quarter-flat': fractions.Fraction(-1, 2), 'three-quarters-sharp': fractions.Fraction(3, 2), 'three-quarters-flat': fractions.Fraction(-3, 2)}
def accidental_name(value: Fraction) -> str:
def accidental_name(value: fractions.Fraction) -> str:
for name, amount in ACCIDENTALS.items():
if amount == value:
return name
return 'other'
def rational(value: Fraction | int) -> dict[str, int]:
value = Fraction(value)
def rational(value: fractions.Fraction | int) -> dict[str, int]:
value = fractions.Fraction(value)
if abs(value.numerator) > MAX_INTEGER or value.denominator > MAX_INTEGER:
raise ValueError('musical time exceeds the supported 64-bit rational range')
return {'numerator': value.numerator, 'denominator': value.denominator}
def fraction(value: Any) -> Fraction:
def fraction(value: typing.Any) -> fractions.Fraction:
if not isinstance(value, dict) or set(value) != {'numerator', 'denominator'}:
raise ValueError('musical time must be a numerator/denominator object')
n, d = value['numerator'], value['denominator']
if type(n) is not int or type(d) is not int or d <= 0 or abs(n) > MAX_INTEGER or d > MAX_INTEGER:
raise ValueError('invalid musical time')
result = Fraction(n, d)
result = fractions.Fraction(n, d)
if rational(result) != value:
raise ValueError('musical time must be reduced')
return result
def written_length(written: dict) -> Fraction:
def written_length(written: dict) -> fractions.Fraction:
kind, dots = written['type'], written.get('dots', 0)
if kind not in TYPE_LENGTHS or type(dots) is not int or not 0 <= dots <= 16:
raise ValueError('invalid written duration or augmentation-dot count (maximum 16)')
return TYPE_LENGTHS[kind] * (2 - Fraction(1, 2 ** dots))
return TYPE_LENGTHS[kind] * (2 - fractions.Fraction(1, 2 ** dots))
def written_for(length: Fraction) -> dict:
def written_for(length: fractions.Fraction) -> dict:
for name, base in TYPE_LENGTHS.items():
for dots in range(17):
if length == base * (2 - Fraction(1, 2 ** dots)):
if length == base * (2 - fractions.Fraction(1, 2 ** dots)):
return {'type': name, 'dots': dots}
# Generated rational spans need an explicit time modification, not rounded ticks.
base = next((name for name, size in TYPE_LENGTHS.items() if size <= length), '1024th')
return {'type': base, 'dots': 0, 'ratio': rational(length / TYPE_LENGTHS[base])}
def metric_length(written: dict) -> Fraction:
def metric_length(written: dict) -> fractions.Fraction:
ratio = fraction(written.get('ratio', rational(1)))
if ratio <= 0:
raise ValueError('written duration ratio must be positive')
@@ -58,10 +58,10 @@ def metric_length(written: dict) -> Fraction:
written_length(written['normal'])
return written_length(written) * ratio
def alteration(value: int | dict) -> Fraction:
return Fraction(value) if type(value) is int else fraction(value)
def alteration(value: int | dict) -> fractions.Fraction:
return fractions.Fraction(value) if type(value) is int else fraction(value)
def pitch_number(pitch: dict) -> Fraction:
def pitch_number(pitch: dict) -> fractions.Fraction:
letter = pitch['letter'].lower()
alter, octave = alteration(pitch.get('alter', 0)), pitch['octave']
if letter not in NATURAL or type(octave) is not int or not -4 <= octave <= 16:
@@ -98,24 +98,24 @@ def key_label(key: dict) -> str:
label = pitch_name(key['tonic'])
return label if key['mode'] in ('major', 'ionian') else label + ' ' + key['mode']
def meter_length(meter: dict) -> Fraction:
return sum((Fraction(sum(group['beats']) * 4, group['unit']) for group in meter.get('groups', [])), Fraction())
def meter_length(meter: dict) -> fractions.Fraction:
return sum((fractions.Fraction(sum(group['beats']) * 4, group['unit']) for group in meter.get('groups', [])), fractions.Fraction())
def meter_label(meter: dict) -> str:
if meter.get('unmetered'):
return 'senza misura'
return '+'.join('+'.join(map(str, group['beats'])) + '/' + str(group['unit']) for group in meter['groups'])
def beat_lengths(meter: dict) -> list[Fraction]:
def beat_lengths(meter: dict) -> list[fractions.Fraction]:
result = []
for group in meter.get('groups', []):
beats, unit = group['beats'], group['unit']
if len(beats) > 1:
result.extend(Fraction(b * 4, unit) for b in beats)
result.extend(fractions.Fraction(b * 4, unit) for b in beats)
elif beats[0] > 3 and beats[0] % 3 == 0 and unit >= 8:
result.extend([Fraction(12, unit)] * (beats[0] // 3))
result.extend([fractions.Fraction(12, unit)] * (beats[0] // 3))
else:
result.extend([Fraction(4, unit)] * beats[0])
result.extend([fractions.Fraction(4, unit)] * beats[0])
return result
def validate_meter(meter: dict) -> None:
@@ -128,7 +128,7 @@ def validate_meter(meter: dict) -> None:
if any(type(b) is not int or not 0 < b <= 1024 for b in group['beats']):
raise ValueError('meter count must be between 1 and 1024')
def make_event(identifier: str, onset: Fraction, duration: Fraction, kind: str, pitches: list[dict] | None = None, written: dict | None = None) -> dict:
def make_event(identifier: str, onset: fractions.Fraction, duration: fractions.Fraction, kind: str, pitches: list[dict] | None = None, written: dict | None = None) -> dict:
if duration <= 0:
raise ValueError('generated event duration must be positive')
notation = written or written_for(duration)
@@ -155,9 +155,9 @@ def validate_document(document: dict) -> None:
raise ValueError('score instruments require unique nonempty IDs')
ids: set[str] = set()
events: dict[str, dict] = {}
tones: dict[str, tuple[dict, Fraction, Fraction, str]] = {}
tones: dict[str, tuple[dict, fractions.Fraction, fractions.Fraction, str]] = {}
ownership: dict[str, str] = {}
absolute = Fraction()
absolute = fractions.Fraction()
for measure in staves[0]['measures']:
if {'repeats', 'endings', 'initial_attributes'} & measure.keys():
raise ValueError('repeat notation is unsupported; regenerate scores from music written out in reading order')
@@ -167,7 +167,7 @@ def validate_document(document: dict) -> None:
voices = measure.get('voices', [])
if not voices or len({v['id'] for v in voices}) != len(voices) or any(not v['id'] for v in voices):
raise ValueError('measure requires uniquely identified voice lanes')
previous_ends = {v['id']: Fraction() for v in voices}
previous_ends = {v['id']: fractions.Fraction() for v in voices}
for voice, note in ((v, n) for v in voices for n in v['notes']):
if any({'sound_ties', 'printed_ties'} & pitch.keys() for pitch in note['pitches']):
raise ValueError('pass-specific tie metadata is unsupported; regenerate scores')
@@ -206,17 +206,17 @@ def validate_document(document: dict) -> None:
else:
pitch_number(p)
if staves[0].get('tablature'):
from generator.guitar import MAX_FRET, MIN_FRET, STANDARD_TUNING
from generator.pitch import sounding_pitch
import generator.guitar
import generator.pitch
tab = p.get('tablature', {})
string, fret = tab.get('string'), tab.get('fret')
if type(string) is not int or type(fret) is not int or string not in dict(STANDARD_TUNING) or not MIN_FRET <= fret <= MAX_FRET:
if type(string) is not int or type(fret) is not int or string not in dict(generator.guitar.STANDARD_TUNING) or not generator.guitar.MIN_FRET <= fret <= generator.guitar.MAX_FRET:
raise ValueError(f'{p["id"]}: invalid generated tablature position')
state = dict(measure)
for change in measure.get('attributes', []):
if fraction(change['onset']) <= onset:
state.update(change)
if dict(STANDARD_TUNING)[string] + fret != sounding_pitch(p, state):
if dict(generator.guitar.STANDARD_TUNING)[string] + fret != generator.pitch.sounding_pitch(p, state):
raise ValueError(f'{p["id"]}: tablature disagrees with sounding pitch')
pd = fraction(p['duration'])
if pd < 0 or (pd == 0 and not note.get('grace')) or pd > duration or not p['id'] or p['id'] in tones:
@@ -269,7 +269,7 @@ def validate_document(document: dict) -> None:
span_ids.add(span['id'])
for note in events.values():
for verse in note.get('lyrics', []):
start = tones[note['pitches'][0]['id']][1] if note['pitches'] else Fraction()
start = tones[note['pitches'][0]['id']][1] if note['pitches'] else fractions.Fraction()
if 'extend_to' in verse and not start < fraction(verse['extend_to']) <= absolute:
raise ValueError('lyric extension endpoint is outside the score')
if any(t not in span_ids for t in note.get('tuplet_ids', [])):
+68 -74
View File
@@ -1,14 +1,13 @@
from __future__ import annotations
import copy
from fractions import Fraction
from pathlib import Path
import fractions
import pathlib
from defusedxml import ElementTree
import defusedxml.ElementTree
from generator import harmony
from generator.model import (ACCIDENTALS, TYPE_LENGTHS, alteration, fraction, key_from_fifths, key_label, meter_label, meter_length, pitch_number, rational, validate_document, written_for, written_length, )
from generator.styles import canonical_style
import generator.harmony
import generator.model
class MusicXMLError(ValueError):
pass
@@ -18,11 +17,11 @@ LAYOUT = {'default-x', 'default-y', 'relative-x', 'relative-y', 'color', 'font-f
IGNORED_TOP = {'defaults', 'credit', 'work', 'movement-number', 'movement-title', 'identification', 'part-list'}
class Importer:
def __init__(self, path: Path) -> None:
def __init__(self, path: pathlib.Path) -> None:
self.path = path
self.location = str(path)
self.divisions = Fraction(1)
self.key = key_from_fifths(0)
self.divisions = fractions.Fraction(1)
self.key = generator.model.key_from_fifths(0)
self.meter = {'groups': [{'beats': [4], 'unit': 4}], 'symbol': 'normal'}
self.clef = {'sign': 'G', 'line': 2, 'octave_change': 0}
self.transpose = {'diatonic': 0, 'chromatic': 0, 'octave_change': 0}
@@ -36,7 +35,7 @@ class Importer:
self.octaves: list[dict] = []
self.voice_id = '1'
self.staff_details = {'lines': 5, 'tunings': []}
self.absolute = Fraction()
self.absolute = fractions.Fraction()
self.tremolo_starts = {}
def fail(self, text: str):
raise MusicXMLError(f'{self.location}: {text}')
@@ -48,16 +47,16 @@ class Importer:
for name in node.attrib:
if name not in LAYOUT and name not in allowed:
self.fail(f'unsupported {node.tag} attribute {name!r}')
def number(self, value: str | None, *, integer: bool = False, positive: bool = False) -> Fraction | int:
def number(self, value: str | None, *, integer: bool = False, positive: bool = False) -> fractions.Fraction | int:
try:
result = Fraction(value or '')
result = fractions.Fraction(value or '')
if integer and result.denominator != 1 or positive and result <= 0:
raise ValueError()
rational(result)
generator.model.rational(result)
return int(result) if integer else result
except (ValueError, ZeroDivisionError, OverflowError):
self.fail(f'invalid {"integer" if integer else "number"}: {value!r}')
def offset(self, node, cursor: Fraction) -> Fraction:
def offset(self, node, cursor: fractions.Fraction) -> fractions.Fraction:
return cursor + self.number(node.findtext('offset', '0')) / self.divisions
def identity(self, node) -> None:
voice = node.findtext('voice', '1' if node.tag in ('note', 'forward') else self.voice_id)
@@ -69,9 +68,9 @@ class Importer:
if not voice.strip():
self.fail('empty voice identifier')
self.voice_id = voice
def attributes(self, node, onset: Fraction) -> dict:
def attributes(self, node, onset: fractions.Fraction) -> dict:
self.children(node, {'divisions', 'key', 'time', 'clef', 'staves', 'transpose', 'instruments', 'staff-details'})
result = {'onset': rational(onset)}
result = {'onset': generator.model.rational(onset)}
for item in node:
if item.get('number', '1') != '1':
self.fail('attributes for multiple staves are not supported')
@@ -84,9 +83,9 @@ class Importer:
self.attrs(item, {'number', 'print-object'})
self.children(item, {'cancel', 'fifths', 'mode', 'key-step', 'key-alter', 'key-accidental', 'key-octave'})
if item.find('fifths') is not None:
self.key = key_from_fifths(self.number(item.findtext('fifths'), integer=True), item.findtext('mode', 'major'))
self.key = generator.model.key_from_fifths(self.number(item.findtext('fifths'), integer=True), item.findtext('mode', 'major'))
else:
self.key = {'fifths': 0, 'mode': 'none', 'tonic': None, 'alterations': [rational(0) for _ in range(7)], 'custom': True, 'steps': []}
self.key = {'fifths': 0, 'mode': 'none', 'tonic': None, 'alterations': [generator.model.rational(0) for _ in range(7)], 'custom': True, 'steps': []}
pending = None
for field in item:
if field.tag == 'key-step':
@@ -96,7 +95,7 @@ class Importer:
elif field.tag == 'key-alter':
if pending is None:
self.fail('key alteration without a step')
amount = rational(self.number(field.text))
amount = generator.model.rational(self.number(field.text))
self.key['alterations']['cdefgab'.index(pending)] = amount
self.key['steps'].append({'letter': pending, 'alter': amount})
pending = None
@@ -155,7 +154,7 @@ class Importer:
self.staff_details = {'lines': self.number(item.findtext('staff-lines', '5'), integer=True, positive=True), 'tunings': [], 'capo': self.number(item.findtext('capo', '0'), integer=True)}
for tuning in item.findall('staff-tuning'):
self.children(tuning, {'tuning-step', 'tuning-alter', 'tuning-octave'})
self.staff_details['tunings'].append({'line': self.number(tuning.get('line'), integer=True, positive=True), 'letter': tuning.findtext('tuning-step', '').lower(), 'alter': rational(self.number(tuning.findtext('tuning-alter', '0'))), 'octave': self.number(tuning.findtext('tuning-octave'), integer=True)})
self.staff_details['tunings'].append({'line': self.number(tuning.get('line'), integer=True, positive=True), 'letter': tuning.findtext('tuning-step', '').lower(), 'alter': generator.model.rational(self.number(tuning.findtext('tuning-alter', '0'))), 'octave': self.number(tuning.findtext('tuning-octave'), integer=True)})
result['staff_details'] = copy.deepcopy(self.staff_details)
elif item.tag == 'transpose':
self.children(item, {'diatonic', 'chromatic', 'octave-change'})
@@ -169,7 +168,7 @@ class Importer:
self.transpose['diatonic'] = (1 if semitones >= 0 else -1) * (7 * (magnitude // 12) + (0, 0, 1, 2, 2, 3, 3, 4, 5, 5, 6, 6)[magnitude % 12])
result['transpose'] = copy.deepcopy(self.transpose)
return result
def read_harmony(self, node, cursor: Fraction) -> dict:
def read_harmony(self, node, cursor: fractions.Fraction) -> dict:
self.attrs(node, {'print-object', 'print-frame', 'type'})
self.children(node, {'root', 'kind', 'degree', 'bass', 'inversion', 'offset', 'staff'})
if len(node.findall('kind')) != 1 or len(node.findall('root')) > 1:
@@ -177,7 +176,7 @@ class Importer:
self.identity(node)
kind = node.find('kind')
self.harmony_sequence += 1
result = {'id': f'harmony-{self.harmony_sequence}', 'onset': rational(self.offset(node, cursor)), 'kind': kind.text, 'degrees': []}
result = {'id': f'harmony-{self.harmony_sequence}', 'onset': generator.model.rational(self.offset(node, cursor)), 'kind': kind.text, 'degrees': []}
self.attrs(kind, {'text', 'use-symbols', 'stack-degrees', 'parentheses-degrees', 'bracket-degrees'})
for tag in ('root', 'bass'):
item = node.find(tag)
@@ -189,7 +188,7 @@ class Importer:
step = item.findtext(tag + '-step', '').lower()
if len(step) != 1 or step not in 'cdefgab':
self.fail('invalid harmony root/bass')
result[tag] = {'letter': step, 'alter': rational(self.number(item.findtext(tag + '-alter', '0')))}
result[tag] = {'letter': step, 'alter': generator.model.rational(self.number(item.findtext(tag + '-alter', '0')))}
for degree in node.findall('degree'):
self.children(degree, {'degree-value', 'degree-alter', 'degree-type'})
value = self.number(degree.findtext('degree-value'), integer=True, positive=True)
@@ -197,15 +196,15 @@ class Importer:
operation = degree.findtext('degree-type')
# MusicXML alterations are relative to the kind; additions use dominant intervals.
if operation == 'alter':
degree_alter += harmony.KINDS.get(result['kind'], {}).get(value, 0)
degree_alter += generator.harmony.KINDS.get(result['kind'], {}).get(value, 0)
elif operation == 'add' and (value - 1) % 7 == 6:
degree_alter -= 1
elif operation == 'subtract' and degree_alter:
self.fail('cannot subtract a degree different from the one in the chord kind')
result['degrees'].append({'value': value, 'alter': rational(degree_alter), 'type': operation})
result['degrees'].append({'value': value, 'alter': generator.model.rational(degree_alter), 'type': operation})
if kind.get('text') and result['kind'] != 'none':
harmony.normalize_text(result, kind.get('text'))
chord_degrees = harmony.degrees(result)
generator.harmony.normalize_text(result, kind.get('text'))
chord_degrees = generator.harmony.degrees(result)
if node.find('inversion') is not None:
inversion = self.number(node.findtext('inversion'), integer=True)
result['inversion'] = inversion
@@ -216,12 +215,11 @@ class Importer:
root = result['root']
letter_index = 'cdefgab'.index(root['letter']) + degree - 1
bass_letter = 'cdefgab'[letter_index % 7]
from generator.model import NATURAL
target = NATURAL[root['letter']] + alteration(root['alter']) + harmony.degree_semitones(degree, chord_degrees[degree])
result['bass'] = {'letter': bass_letter, 'alter': rational(target - (12 * (letter_index // 7) + NATURAL[bass_letter]))}
result['symbol'] = harmony.symbol(result)
target = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter']) + generator.harmony.degree_semitones(degree, chord_degrees[degree])
result['bass'] = {'letter': bass_letter, 'alter': generator.model.rational(target - (12 * (letter_index // 7) + generator.model.NATURAL[bass_letter]))}
result['symbol'] = generator.harmony.symbol(result)
return result
def direction(self, node, cursor: Fraction, measure: dict, optional: list[Fraction]) -> None:
def direction(self, node, cursor: fractions.Fraction, measure: dict, optional: list[fractions.Fraction]) -> None:
self.attrs(node, {'print-object', 'directive', 'system'})
self.children(node, {'direction-type', 'offset', 'staff', 'voice', 'sound'})
onset = self.offset(node, cursor)
@@ -245,7 +243,7 @@ class Importer:
size = self.number(item.get('size', '8'), integer=True)
if size not in (8, 15, 22):
self.fail('octave shifts support 8, 15, or 22')
position = rational(self.absolute + onset)
position = generator.model.rational(self.absolute + onset)
if action in ('up', 'down'):
if number in self.open_octaves:
self.fail('duplicate octave-shift start')
@@ -261,20 +259,20 @@ class Importer:
elif item.tag == 'metronome':
self.children(item, {'beat-unit', 'beat-unit-dot', 'per-minute'})
written = {'type': item.findtext('beat-unit'), 'dots': len(item.findall('beat-unit-dot'))}
written_length(written)
generator.model.written_length(written)
bpm = self.number(item.findtext('per-minute'), positive=True)
measure.setdefault('tempos', []).append({'onset': rational(onset), 'written': written, 'bpm': rational(bpm)})
measure.setdefault('tempos', []).append({'onset': generator.model.rational(onset), 'written': written, 'bpm': generator.model.rational(bpm)})
sound = node.find('sound')
if sound is not None:
self.sound(sound, onset, measure)
def sound(self, node, onset: Fraction, measure: dict) -> None:
def sound(self, node, onset: fractions.Fraction, measure: dict) -> None:
if {'dacapo', 'dalsegno', 'segno', 'coda', 'tocoda', 'fine', 'forward-repeat', 'time-only'} & node.attrib.keys():
self.fail('navigation jumps are unsupported; write the music out in reading order')
self.attrs(node, {'tempo'})
self.children(node, set())
if node.get('tempo') and not any(fraction(t['onset']) == onset for t in measure.get('tempos', [])):
measure.setdefault('tempos', []).append({'onset': rational(onset), 'written': {'type': 'quarter', 'dots': 0}, 'bpm': rational(self.number(node.get('tempo'), positive=True))})
def read_note(self, node, cursor: Fraction, previous: dict | None) -> tuple[dict, Fraction, bool]:
if node.get('tempo') and not any(generator.model.fraction(t['onset']) == onset for t in measure.get('tempos', [])):
measure.setdefault('tempos', []).append({'onset': generator.model.rational(onset), 'written': {'type': 'quarter', 'dots': 0}, 'bpm': generator.model.rational(self.number(node.get('tempo'), positive=True))})
def read_note(self, node, cursor: fractions.Fraction, previous: dict | None) -> tuple[dict, fractions.Fraction, bool]:
self.attrs(node, {'print-object', 'print-dot', 'print-spacing', 'default-x', 'dynamics', 'end-dynamics'})
if node.get('dynamics') or node.get('end-dynamics'):
self.fail('note dynamics are not supported')
@@ -288,34 +286,34 @@ class Importer:
voice = self.voice_id
is_chord = node.find('chord') is not None
grace = node.find('grace')
duration = Fraction() if grace is not None else self.number(node.findtext('duration'), positive=True) / self.divisions
onset = fraction(previous['onset']) if is_chord and previous else cursor
if is_chord and (previous is None or previous['voice'] != voice or previous['kind'] != 'note' or duration > fraction(previous['duration'])):
duration = fractions.Fraction() if grace is not None else self.number(node.findtext('duration'), positive=True) / self.divisions
onset = generator.model.fraction(previous['onset']) if is_chord and previous else cursor
if is_chord and (previous is None or previous['voice'] != voice or previous['kind'] != 'note' or duration > generator.model.fraction(previous['duration'])):
self.fail('invalid chord tone or chord tone longer than its anchor')
if is_chord and (grace is not None) != ('grace' in previous):
self.fail('chord tones must share grace/regular timing')
written = {'type': node.findtext('type'), 'dots': len(node.findall('dot'))}
if written['type'] is None:
written = {'type': 'whole', 'dots': 0} if node.find('rest') is not None and node.find('rest').get('measure') == 'yes' else written_for(duration)
written_length(written)
written = {'type': 'whole', 'dots': 0} if node.find('rest') is not None and node.find('rest').get('measure') == 'yes' else generator.model.written_for(duration)
generator.model.written_length(written)
modulation = node.find('time-modification')
if modulation is not None:
self.children(modulation, {'actual-notes', 'normal-notes', 'normal-type', 'normal-dot'})
actual = self.number(modulation.findtext('actual-notes'), integer=True, positive=True)
normal = self.number(modulation.findtext('normal-notes'), integer=True, positive=True)
written['ratio'] = rational(Fraction(normal, actual))
written['ratio'] = generator.model.rational(fractions.Fraction(normal, actual))
if modulation.find('normal-type') is not None:
written['normal'] = {'type': modulation.findtext('normal-type'), 'dots': len(modulation.findall('normal-dot'))}
written_length(written['normal'])
generator.model.written_length(written['normal'])
elif modulation.findall('normal-dot'):
self.fail('<normal-dot> requires <normal-type>')
measure_rest = node.find('rest') is not None and node.find('rest').get('measure') == 'yes'
if grace is None and not measure_rest:
expected = written_length(written) * fraction(written.get('ratio', rational(1)))
expected = generator.model.written_length(written) * generator.model.fraction(written.get('ratio', generator.model.rational(1)))
if duration != expected:
self.fail(f'<note> duration {duration} disagrees with written duration/time-modification {expected}')
self.sequence += 1
note = {'id': f'note-{self.sequence}', 'voice': voice, 'kind': 'rest' if node.find('rest') is not None else 'note', 'onset': rational(onset), 'duration': rational(duration), 'written': written, 'hidden': node.get('print-object') == 'no', 'pitches': []}
note = {'id': f'note-{self.sequence}', 'voice': voice, 'kind': 'rest' if node.find('rest') is not None else 'note', 'onset': generator.model.rational(onset), 'duration': generator.model.rational(duration), 'written': written, 'hidden': node.get('print-object') == 'no', 'pitches': []}
if is_chord:
note = previous
if grace is not None and not is_chord:
@@ -324,7 +322,7 @@ class Importer:
for name in ('steal-time-previous', 'steal-time-following', 'make-time'):
if name in grace.attrib:
value = self.number(grace.get(name), positive=True)
note['grace'][name] = rational(value / self.divisions if name == 'make-time' else value)
note['grace'][name] = generator.model.rational(value / self.divisions if name == 'make-time' else value)
if node.find('cue') is not None and not is_chord:
note['cue'] = True
if node.find('rest') is not None:
@@ -335,12 +333,12 @@ class Importer:
note['rest_position'] = {'letter': node.findtext('rest/display-step').lower(), 'octave': self.number(node.findtext('rest/display-octave'), integer=True)}
else:
pitch_node = node.find('pitch')
p = {'id': f'{note["id"]}-p{len(note["pitches"]) + 1}', 'duration': rational(duration), 'written': written}
p = {'id': f'{note["id"]}-p{len(note["pitches"]) + 1}', 'duration': generator.model.rational(duration), 'written': written}
if pitch_node is not None:
self.children(pitch_node, {'step', 'alter', 'octave'})
p.update({'letter': pitch_node.findtext('step', '').lower(), 'alter': rational(self.number(pitch_node.findtext('alter', '0'))), 'octave': self.number(pitch_node.findtext('octave'), integer=True)})
pitch_number(p)
value = (voice, p['letter'], alteration(p['alter']), p['octave'])
p.update({'letter': pitch_node.findtext('step', '').lower(), 'alter': generator.model.rational(self.number(pitch_node.findtext('alter', '0'))), 'octave': self.number(pitch_node.findtext('octave'), integer=True)})
generator.model.pitch_number(p)
value = (voice, p['letter'], generator.model.alteration(p['alter']), p['octave'])
elif node.find('unpitched') is not None:
self.children(node.find('unpitched'), {'display-step', 'display-octave'})
p['unpitched'] = {'letter': node.findtext('unpitched/display-step', 'B').lower(), 'octave': self.number(node.findtext('unpitched/display-octave', '4'), integer=True)}
@@ -362,7 +360,7 @@ class Importer:
accidental = node.find('accidental')
if accidental is not None:
self.attrs(accidental, {'cautionary', 'editorial', 'parentheses', 'bracket', 'size', 'smufl'})
if 'unpitched' in p or accidental.text in ACCIDENTALS and ACCIDENTALS[accidental.text] != alteration(p['alter']):
if 'unpitched' in p or accidental.text in generator.model.ACCIDENTALS and generator.model.ACCIDENTALS[accidental.text] != generator.model.alteration(p['alter']):
self.fail('contradictory accidental')
p['accidental_display'] = {'name': accidental.text, **{k: v for k, v in accidental.attrib.items() if k not in LAYOUT}}
if node.find('notehead') is not None:
@@ -384,7 +382,7 @@ class Importer:
self.fail('unsupported tie notation')
if 'stop' in ties:
source = self.open_ties.pop(value, None)
if source is None or source['_absolute'] + fraction(source['duration']) != p['_absolute']:
if source is None or source['_absolute'] + generator.model.fraction(source['duration']) != p['_absolute']:
self.fail('tie stop has no contiguous matching start')
source['tie_to'], p['tie_from'] = p['id'], source['id']
if 'start' in ties:
@@ -491,7 +489,7 @@ class Importer:
self.active_tuplets.pop(number)['end'] = note['id']
return note, cursor if is_chord else cursor + duration, is_chord
def read(self) -> dict:
root = ElementTree.parse(self.path, forbid_entities=True, forbid_external=True).getroot()
root = defusedxml.ElementTree.parse(self.path, forbid_entities=True, forbid_external=True).getroot()
if root.tag != 'score-partwise' or root.get('version') not in ('3.1', '4.0'):
self.fail('expected uncompressed score-partwise MusicXML 3.1 or 4.0')
self.children(root, IGNORED_TOP | {'part'})
@@ -519,21 +517,18 @@ class Importer:
instruments.append(instrument)
if any(item.get('id') not in {i['id'] for i in instruments} for item in definitions[0].findall('midi-instrument')):
self.fail('MIDI definition refers to an unknown score instrument')
fields = {}
for item in root.findall('identification/miscellaneous/miscellaneous-field'):
name = item.get('name', '')
if name.startswith('vamp:'):
if name != 'vamp:style' or name in fields:
self.fail(f'unknown or duplicate Vamp property {name!r}')
fields[name] = item.text or ''
metadata = {'title': root.findtext('work/work-title') or root.findtext('movement-title') or self.path.stem, 'composer': ' / '.join(c.text for c in root.findall('identification/creator') if c.get('type') == 'composer' and c.text), 'style': canonical_style(fields.get('vamp:style'))}
self.fail(f'unsupported Vamp property {name!r}')
metadata = {'title': root.findtext('work/work-title') or root.findtext('movement-title') or self.path.stem, 'composer': ' / '.join(c.text for c in root.findall('identification/creator') if c.get('type') == 'composer' and c.text)}
measures = []
self.children(parts[0], {'measure'})
for index, bar in enumerate(parts[0]):
self.location = f'{self.path}: part {parts[0].get("id")}, measure {bar.get("number", str(index + 1))}'
self.children(bar, {'attributes', 'note', 'direction', 'sound', 'harmony', 'forward', 'backup', 'barline', 'print'})
self.attrs(bar, {'number', 'implicit', 'non-controlling', 'width', 'text'})
cursor, extent = Fraction(), Fraction()
cursor, extent = fractions.Fraction(), fractions.Fraction()
voices = {}
previous = None
optional = []
@@ -567,10 +562,9 @@ class Importer:
self.children(item, {'duration', 'voice', 'staff'})
self.identity(item)
duration = self.number(item.findtext('duration'), positive=True) / self.divisions
from generator.model import make_event
if item.find('voice') is not None:
self.sequence += 1
voices.setdefault(self.voice_id, []).append(make_event(f'spacer-{self.sequence}', cursor, duration, 'spacer') | {'voice': self.voice_id})
voices.setdefault(self.voice_id, []).append(generator.model.make_event(f'spacer-{self.sequence}', cursor, duration, 'spacer') | {'voice': self.voice_id})
cursor += duration
extent = max(extent, cursor)
previous = None
@@ -597,22 +591,22 @@ class Importer:
cursor = extent
if measure.pop('_right_barline_onset', cursor) != cursor:
self.fail('right barline must occur at the end of its measure')
expected = meter_length(measure['meter'])
expected = generator.model.meter_length(measure['meter'])
meter_changed = any('meter' in a for a in measure['attributes'])
if expected and cursor != expected and not measure['implicit'] and not meter_changed:
self.fail(f'measure has {cursor} quarter notes, expected {expected}; mark a partial measure implicit')
measure['duration'] = rational(cursor)
measure['key_signature'], measure['time_signature'] = key_label(measure['key']), meter_label(measure['meter'])
measure['voices'] = [{'id': voice, 'notes': sorted(events, key=lambda n: fraction(n['onset']))} for voice, events in voices.items()]
measure['harmonies'].sort(key=lambda h: fraction(h['onset']))
measure['duration'] = generator.model.rational(cursor)
measure['key_signature'], measure['time_signature'] = generator.model.key_label(measure['key']), generator.model.meter_label(measure['meter'])
measure['voices'] = [{'id': voice, 'notes': sorted(events, key=lambda n: generator.model.fraction(n['onset']))} for voice, events in voices.items()]
measure['harmonies'].sort(key=lambda h: generator.model.fraction(h['onset']))
used = set()
for onset in optional:
matches = [h for h in measure['harmonies'] if fraction(h['onset']) == onset]
matches = [h for h in measure['harmonies'] if generator.model.fraction(h['onset']) == onset]
if len(matches) != 1 or onset in used:
self.fail('optional-chord marker must identify exactly one harmony and occur only once')
matches[0]['chord_optional'] = True
matches[0]['parenthesized'] = True
matches[0]['symbol'] = harmony.symbol(matches[0])
matches[0]['symbol'] = generator.harmony.symbol(matches[0])
used.add(onset)
measures.append(measure)
self.absolute += cursor
@@ -627,11 +621,11 @@ class Importer:
metadata.update({'key_signature': measures[0]['key_signature'], 'time_signature': measures[0]['time_signature'], 'key': copy.deepcopy(measures[0]['key'])})
result = {'metadata': metadata, 'staves': [{'id': 'staff-source', 'name': definitions[0].findtext('part-name', 'Source'), 'role': 'vocal', 'clef': copy.deepcopy(measures[0]['clef']), 'tuplets': self.tuplets, 'octave_shifts': self.octaves, 'measures': measures}]}
result['staves'][0]['instruments'] = instruments
validate_document(result)
generator.model.validate_document(result)
return result
def load_musicxml(path: Path) -> dict:
importer = Importer(Path(path))
def load_musicxml(path: pathlib.Path) -> dict:
importer = Importer(pathlib.Path(path))
try:
return importer.read()
except MusicXMLError:
+44 -46
View File
@@ -1,21 +1,21 @@
from __future__ import annotations
from fractions import Fraction
from math import lcm
from pathlib import Path
from xml.etree import ElementTree
import fractions
import math
import pathlib
import xml.etree.ElementTree
from generator import harmony
from generator.model import alteration, fraction, validate_document
import generator.harmony
import generator.model
def element(parent, tag, content=None, **attributes):
node = ElementTree.SubElement(parent, tag, {k.replace('_', '-'): str(v) for k, v in attributes.items() if v is not None})
node = xml.etree.ElementTree.SubElement(parent, tag, {k.replace('_', '-'): str(v) for k, v in attributes.items() if v is not None})
if content is not None:
node.text = str(content)
return node
def decimal(value) -> str:
value = alteration(value) if isinstance(value, dict) else Fraction(value)
value = generator.model.alteration(value) if isinstance(value, dict) else fractions.Fraction(value)
denominator = value.denominator
for prime in (2, 5):
while denominator % prime == 0:
@@ -37,7 +37,7 @@ def write_attributes(parent, state: dict, divisions=None) -> None:
if 'cancel' in key:
element(item, 'cancel', key['cancel'])
if key.get('custom'):
steps = key.get('steps', [{'letter': letter, 'alter': amount} for letter, amount in zip('cdefgab', key['alterations']) if alteration(amount)])
steps = key.get('steps', [{'letter': letter, 'alter': amount} for letter, amount in zip('cdefgab', key['alterations']) if generator.model.alteration(amount)])
for step in steps:
element(item, 'key-step', step['letter'].upper())
element(item, 'key-alter', decimal(step['alter']))
@@ -85,7 +85,7 @@ def write_harmony(parent, value: dict, divisions: int) -> None:
item = element(node, 'root')
element(item, 'root-step', root['letter'].upper())
element(item, 'root-alter', decimal(root['alter']))
element(node, 'kind', value['kind'], text=harmony.symbol(value) if value.get('parenthesized') else None)
element(node, 'kind', value['kind'], text=generator.harmony.symbol(value) if value.get('parenthesized') else None)
if 'inversion' in value:
element(node, 'inversion', value['inversion'])
if 'bass' in value:
@@ -94,20 +94,20 @@ def write_harmony(parent, value: dict, divisions: int) -> None:
element(item, 'bass-alter', decimal(value['bass']['alter']))
for degree in value['degrees']:
item = element(node, 'degree')
amount = alteration(degree['alter'])
amount = generator.model.alteration(degree['alter'])
if degree['type'] == 'alter':
amount -= harmony.KINDS[value['kind']].get(degree['value'], 0)
amount -= generator.harmony.KINDS[value['kind']].get(degree['value'], 0)
elif degree['type'] == 'add' and (degree['value'] - 1) % 7 == 6:
amount += 1
element(item, 'degree-value', degree['value'])
element(item, 'degree-alter', decimal(amount))
element(item, 'degree-type', degree['type'])
element(node, 'offset', int(fraction(value['onset']) * divisions))
element(node, 'offset', int(generator.model.fraction(value['onset']) * divisions))
def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[dict]) -> None:
if event['kind'] == 'spacer':
item = element(parent, 'forward')
element(item, 'duration', int(fraction(event['duration']) * divisions))
element(item, 'duration', int(generator.model.fraction(event['duration']) * divisions))
element(item, 'voice', voice)
return
pitches = event['pitches'] or [None]
@@ -117,7 +117,7 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di
printing.update(pitch.get('print', {}))
node = element(parent, 'note', **printing)
if grace := event.get('grace'):
element(node, 'grace', **{k: ('yes' if v else 'no') if isinstance(v, bool) else decimal(fraction(v) * divisions if k == 'make-time' else v) for k, v in grace.items()})
element(node, 'grace', **{k: ('yes' if v else 'no') if isinstance(v, bool) else decimal(generator.model.fraction(v) * divisions if k == 'make-time' else v) for k, v in grace.items()})
if (pitch or event).get('cue', event.get('cue')):
element(node, 'cue')
if index:
@@ -137,7 +137,7 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di
element(item, 'alter', decimal(pitch['alter']) if pitch else '0')
element(item, 'octave', pitch['octave'] if pitch else 4)
if not event.get('grace'):
element(node, 'duration', int(fraction(pitch['duration'] if pitch else event['duration']) * divisions))
element(node, 'duration', int(generator.model.fraction(pitch['duration'] if pitch else event['duration']) * divisions))
for field, action in (('tie_from', 'stop'), ('tie_to', 'start')):
if pitch and pitch.get(field):
element(node, 'tie', type=action)
@@ -151,7 +151,7 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di
if pitch and (display := pitch.get('accidental_display')):
element(node, 'accidental', display['name'], **{k: v for k, v in display.items() if k != 'name'})
if 'ratio' in written:
ratio = fraction(written['ratio'])
ratio = generator.model.fraction(written['ratio'])
item = element(node, 'time-modification')
element(item, 'actual-notes', ratio.denominator)
element(item, 'normal-notes', ratio.numerator)
@@ -204,15 +204,13 @@ def write_barline(bar, measure, location):
node = element(bar, 'barline', location=location)
element(node, 'bar-style', measure.get('barlines', {}).get(location, 'regular'))
def musicxml_tree(document: dict) -> ElementTree.ElementTree:
validate_document(document)
root = ElementTree.Element('score-partwise', {'version': '4.0'})
def musicxml_tree(document: dict) -> xml.etree.ElementTree.ElementTree:
generator.model.validate_document(document)
root = xml.etree.ElementTree.Element('score-partwise', {'version': '4.0'})
metadata, staff = document['metadata'], document['staves'][0]
element(element(root, 'work'), 'work-title', metadata['title'])
identification = element(root, 'identification')
element(identification, 'creator', metadata.get('composer', ''), type='composer')
miscellaneous = element(identification, 'miscellaneous')
element(miscellaneous, 'miscellaneous-field', metadata.get('style', 'Medium Swing'), name='vamp:style')
defaults = element(root, 'defaults')
element(defaults, 'music-font', font_family='Petaluma')
element(defaults, 'word-font', font_family='Petaluma Script')
@@ -240,23 +238,23 @@ def musicxml_tree(document: dict) -> ElementTree.ElementTree:
for tag, value in instrument['midi'].items():
element(item, tag, value)
part = element(root, 'part', id='P1')
absolute = Fraction()
absolute = fractions.Fraction()
previous_state = {}
for index, measure in enumerate(staff['measures']):
bar = element(part, 'measure', number=index + 1, implicit='yes' if measure['implicit'] else 'no')
write_barline(bar, measure, 'left')
times = [fraction(measure['duration'])]
times.extend(fraction(n[field]) for v in measure['voices'] for n in v['notes'] for field in ('onset', 'duration'))
times.extend(fraction(p['duration']) for v in measure['voices'] for n in v['notes'] for p in n['pitches'])
times.extend(fraction(item['onset']) for field in ('attributes', 'harmonies', 'tempos') for item in measure.get(field, []))
times.extend(fraction(s[field]) - absolute for s in staff.get('octave_shifts', []) for field in ('start', 'end'))
divisions = lcm(*(t.denominator for t in times))
times = [generator.model.fraction(measure['duration'])]
times.extend(generator.model.fraction(n[field]) for v in measure['voices'] for n in v['notes'] for field in ('onset', 'duration'))
times.extend(generator.model.fraction(p['duration']) for v in measure['voices'] for n in v['notes'] for p in n['pitches'])
times.extend(generator.model.fraction(item['onset']) for field in ('attributes', 'harmonies', 'tempos') for item in measure.get(field, []))
times.extend(generator.model.fraction(s[field]) - absolute for s in staff.get('octave_shifts', []) for field in ('start', 'end'))
divisions = math.lcm(*(t.denominator for t in times))
state = {k: measure[k] for k in ('key', 'meter', 'clef', 'transpose', 'staff_details') if k in measure}
write_attributes(bar, {k: v for k, v in state.items() if previous_state.get(k) != v}, divisions)
previous_state.update(state)
cursor = Fraction()
cursor = fractions.Fraction()
for change in measure.get('attributes', []):
onset = fraction(change['onset'])
onset = generator.model.fraction(change['onset'])
if onset != cursor:
element(element(bar, 'forward' if onset > cursor else 'backup'), 'duration', int(abs(onset - cursor) * divisions))
write_attributes(bar, change)
@@ -269,7 +267,7 @@ def musicxml_tree(document: dict) -> ElementTree.ElementTree:
if value.get('chord_optional'):
direction = element(bar, 'direction', print_object='no')
element(element(direction, 'direction-type'), 'words', 'vamp:optional-chord')
element(direction, 'offset', int(fraction(value['onset']) * divisions))
element(direction, 'offset', int(generator.model.fraction(value['onset']) * divisions))
if measure.get('section_label'):
element(element(element(bar, 'direction'), 'direction-type'), 'rehearsal', measure['section_label'], halign='left')
for tempo in measure.get('tempos', []):
@@ -279,35 +277,35 @@ def musicxml_tree(document: dict) -> ElementTree.ElementTree:
for _ in range(tempo['written'].get('dots', 0)):
element(metronome, 'beat-unit-dot')
element(metronome, 'per-minute', decimal(tempo['bpm']))
element(direction, 'offset', int(fraction(tempo['onset']) * divisions))
element(direction, 'offset', int(generator.model.fraction(tempo['onset']) * divisions))
for si, span in enumerate(staff.get('octave_shifts', [])):
for field in ('start', 'end'):
onset = fraction(span[field]) - absolute
inside = 0 <= onset < fraction(measure['duration']) if field == 'start' else 0 < onset <= fraction(measure['duration'])
onset = generator.model.fraction(span[field]) - absolute
inside = 0 <= onset < generator.model.fraction(measure['duration']) if field == 'start' else 0 < onset <= generator.model.fraction(measure['duration'])
if not inside:
continue
direction = element(bar, 'direction')
element(element(direction, 'direction-type'), 'octave-shift', type='stop' if field == 'end' else 'up' if span['octaves'] > 0 else 'down', size=abs(span['octaves']) * 7 + 1, number=si + 1)
element(direction, 'offset', int(onset * divisions))
cursor = Fraction()
cursor = fractions.Fraction()
for voice in measure['voices']:
if cursor:
element(element(bar, 'backup'), 'duration', int(cursor * divisions))
cursor = Fraction()
cursor = fractions.Fraction()
for event in voice['notes']:
onset = fraction(event['onset'])
onset = generator.model.fraction(event['onset'])
if onset > cursor:
element(element(bar, 'forward'), 'duration', int((onset - cursor) * divisions))
write_note(bar, event, voice['id'], divisions, staff.get('tuplets', []))
cursor = onset + fraction(event['duration'])
if cursor < fraction(measure['duration']):
element(element(bar, 'forward'), 'duration', int((fraction(measure['duration']) - cursor) * divisions))
cursor = fraction(measure['duration'])
cursor = onset + generator.model.fraction(event['duration'])
if cursor < generator.model.fraction(measure['duration']):
element(element(bar, 'forward'), 'duration', int((generator.model.fraction(measure['duration']) - cursor) * divisions))
cursor = generator.model.fraction(measure['duration'])
write_barline(bar, measure, 'right')
absolute += fraction(measure['duration'])
return ElementTree.ElementTree(root)
absolute += generator.model.fraction(measure['duration'])
return xml.etree.ElementTree.ElementTree(root)
def write_musicxml(path: Path, document: dict) -> None:
def write_musicxml(path: pathlib.Path, document: dict) -> None:
tree = musicxml_tree(document)
ElementTree.indent(tree, space=' ')
xml.etree.ElementTree.indent(tree, space=' ')
tree.write(path, encoding='utf-8', xml_declaration=True)
+34 -75
View File
@@ -1,16 +1,18 @@
from __future__ import annotations
from fractions import Fraction
from pathlib import Path
import fractions
import pathlib
from generator.guitar import validate_tuning
from generator.model import beat_lengths, fraction, make_event
from generator.pitch import concert_document, shift_pitch
from generator.score import copy_source_voices, make_measure, slugify, source_staff
import generator.accompaniment
import generator.guitar
import generator.model
import generator.pitch
import generator.rhythm
import generator.score
PART_TYPES = ('acoustic', 'electric', 'vocal')
def source_paths(directory: Path, *, practice: bool = False) -> list[Path]:
def source_paths(directory: pathlib.Path, *, practice: bool = False) -> list[pathlib.Path]:
if not directory.is_dir():
raise ValueError(f'{"practice" if practice else "performance"} source directory not found: {directory}')
paths = sorted(directory.rglob('*.xml'))
@@ -21,7 +23,7 @@ def source_paths(directory: Path, *, practice: bool = False) -> list[Path]:
raise ValueError(f'{path}: practice sources require a four-digit filename directly under data/practice')
elif path.parent != directory:
raise ValueError(f'{path}: performance sources belong directly under data/songs')
identity = path.stem if practice else slugify(path.stem)
identity = path.stem if practice else generator.score.slugify(path.stem)
if identity in identities:
raise ValueError(f'{identities[identity]} and {path}: conflicting output path {identity}-acoustic.json')
identities[identity] = path
@@ -29,81 +31,38 @@ def source_paths(directory: Path, *, practice: bool = False) -> list[Path]:
def prepare_document(document: dict, *, practice: bool = False) -> dict:
validate_source(document, practice=practice)
return concert_document(document, octave_transposing=True)
def propagated_chords(measures: list[dict], *, include_optional: bool = True) -> list[dict | None]:
result = []
current = None
for measure in measures:
result.append(current)
for h in measure.get('harmonies', []):
if include_optional or not h.get('chord_optional'):
current = h
return result
def active_chord(measure: dict, onset: Fraction, previous: dict | None) -> dict | None:
active = previous
for h in measure['harmonies']:
if fraction(h['onset']) > onset:
break
active = h
return active if active and active['kind'] != 'none' else None
return generator.pitch.concert_document(document, octave_transposing=True)
def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dict]:
length = fraction(measure['duration'])
length = generator.model.fraction(measure['duration'])
if role == 'rest':
return [make_event('rest', Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'measure_rest': True}]
common_time = measure['time_signature'] == '4/4' and not any('meter' in a for a in measure.get('attributes', []))
if role == 'rhythm' and common_time:
attacks = [(Fraction(n, 2), Fraction(1, 2)) for n in (0, 3, 4, 5, 7)]
elif role == 'accompaniment':
boundaries = sorted({Fraction(), length} | {fraction(h['onset']) for h in measure['harmonies'] if fraction(h['onset']) < length})
attacks = [(start, end - start) for start, end in zip(boundaries, boundaries[1:])]
else:
attacks = []
states = [(Fraction(), measure['meter'])]
states.extend((fraction(a['onset']), a['meter']) for a in measure.get('attributes', []) if 'meter' in a)
for index, (onset, meter) in enumerate(states):
end = states[index + 1][0] if index + 1 < len(states) else length
beats = beat_lengths(meter)
beat_index = 0
while onset < end:
beat = min(beats[beat_index % len(beats)] if beats else end - onset, end - onset)
duration = beat if role == 'slash' or not beats else min(beat / 2, Fraction(1, 2))
attacks.append((onset, duration))
onset += beat
beat_index += 1
return [generator.model.make_event('rest', fractions.Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'measure_rest': True}]
if role != 'slash':
raise ValueError(f'{role} requires a preplanned rhythm')
events = []
cursor = Fraction()
for onset, duration in attacks:
if onset >= length:
break
if onset > cursor:
events.append(make_event(f'rest-{len(events)}', cursor, onset - cursor, 'rest'))
duration = min(duration, length - onset)
chord = active_chord(measure, onset, previous)
if role == 'slash':
event = make_event(f'slash-{len(events)}', onset, duration, 'note')
states = [(fractions.Fraction(), measure['meter'])]
states.extend((generator.model.fraction(a['onset']), a['meter']) for a in measure.get('attributes', []) if 'meter' in a)
for index, (onset, meter) in enumerate(states):
end = states[index + 1][0] if index + 1 < len(states) else length
beats = generator.model.beat_lengths(meter)
beat_index = 0
while onset < end:
duration = min(beats[beat_index % len(beats)] if beats else end - onset, end - onset)
event = generator.model.make_event(f'slash-{len(events)}', onset, duration, 'note')
event['notehead'] = 'slash'
elif not chord:
event = make_event(f'rest-{len(events)}', onset, duration, 'rest')
else:
event = make_event(f'chord-{len(events)}', onset, duration, 'note')
event['_harmony'] = chord
events.append(event)
cursor = onset + duration
if cursor < length:
events.append(make_event(f'rest-{len(events)}', cursor, length - cursor, 'rest'))
events.append(event)
onset += duration
beat_index += 1
return events
def validate_source(document: dict, *, practice: bool = False) -> None:
voices = set()
for measure in source_staff(document)['measures']:
for measure in generator.score.source_staff(document)['measures']:
for state in [measure, *measure.get('attributes', [])]:
if state.get('clef', {}).get('sign') in ('TAB', 'percussion'):
raise ValueError(f'{measure["id"]}: parts require a pitched, non-TAB source')
if 'staff_details' in state:
validate_tuning(state, measure['id'])
generator.guitar.validate_tuning(state, measure['id'])
for voice in measure['voices']:
voices.add(voice['id'])
for note in voice['notes']:
@@ -114,13 +73,13 @@ def validate_source(document: dict, *, practice: bool = False) -> None:
if practice and len(voices) > 1:
raise ValueError('practice requires one monophonic voice')
def build_measure(source: dict, identifier: str, role: str, part: str, previous: dict | None, label: str = '') -> dict:
voices = copy_source_voices(source, strip_lyrics=role == 'melody') if role in ('vocal', 'melody') else None
notes = [n for v in voices for n in v['notes']] if voices else generated_voice(source, role, previous)
def build_measure(source: dict, identifier: str, role: str, part: str, previous: dict | None, label: str = '', rhythm: generator.rhythm.Bar | None = None) -> dict:
voices = generator.score.copy_source_voices(source, strip_lyrics=role == 'melody') if role in ('vocal', 'melody') else None
notes = [n for v in voices for n in v['notes']] if voices else generator.accompaniment.realize(source, rhythm, previous) if rhythm is not None else generated_voice(source, role, previous)
if role in ('melody', 'vocal'):
for note in notes:
note['pitches'] = [shift_pitch(p, 7, 12) for p in note['pitches']]
result = make_measure(identifier, source, [] if voices else notes, section_label=label)
note['pitches'] = [generator.pitch.shift_pitch(p, 7, 12) for p in note['pitches']]
result = generator.score.make_measure(identifier, source, [] if voices else notes, section_label=label)
if voices:
result['voices'] = voices
result['transpose'] = {'diatonic': 0, 'chromatic': 0, 'octave_change': -1, 'explicit': True}
+172
View File
@@ -0,0 +1,172 @@
"""Build-only PetalumaScript sprites and content-addressed Pico title images."""
from __future__ import annotations
import argparse
import hashlib
import json
import pathlib
import struct
import PIL.Image
import PIL.ImageDraw
import PIL.ImageFont
import PIL.features
import fontTools.ttLib
TITLE_WIDTH = 296
TITLE_HEIGHT = 64
TITLE_HEADER = struct.pack('<4sHH', b'VPTI', TITLE_WIDTH, TITLE_HEIGHT)
TITLE_BYTES = len(TITLE_HEADER) + TITLE_WIDTH * TITLE_HEIGHT // 2
MAX_TITLES = 8192
LABELS = ('VAMP', 'CONNECTING', 'OFFLINE', 'BUSY', 'READY', 'LOADING', 'WIFI', 'ERROR', 'PAGE', 'Acoustic', 'Electric', 'Vocal', 'UNAVAILABLE')
SPRITES = LABELS + tuple('0123456789/')
DIGIT_WIDTH = 22
DEFAULT_FONT_ROOT = pathlib.Path(__file__).resolve().parents[1] / 'package/assets/fonts/petaluma'
class Renderer:
def __init__(self, font_path: pathlib.Path):
if not PIL.features.check_feature('raqm'):
raise RuntimeError('Pico artwork requires Pillow with Raqm support')
self.font_path = font_path
with fontTools.ttLib.TTFont(font_path) as font:
self.characters = set(font.getBestCmap())
self.fonts = {}
def font(self, size: int) -> PIL.ImageFont.FreeTypeFont:
if size not in self.fonts:
self.fonts[size] = PIL.ImageFont.truetype(str(self.font_path), size, layout_engine=PIL.ImageFont.Layout.RAQM)
return self.fonts[size]
def validate(self, text: str) -> None:
if not isinstance(text, str) or not text.strip() or len(text) > 200:
raise ValueError('Pico title and part must contain 1 to 200 characters')
if any(ord(character) < 32 or ord(character) not in self.characters for character in text):
raise ValueError(f'Unsupported PetalumaScript text: {text!r}')
def line(self, text: str, width: int, height: int, size: int) -> PIL.Image.Image:
self.validate(text)
if width < 5 or height < 3:
raise ValueError('Pico text tile is too small')
font = self.font(size)
while size > 14:
box = font.getbbox(text)
if box[2] - box[0] <= width - 4 and box[3] - box[1] <= height - 2:
break
size -= 1
font = self.font(size)
if font.getbbox(text)[2] - font.getbbox(text)[0] > width - 4:
if font.getbbox('...')[2] - font.getbbox('...')[0] > width - 4:
raise ValueError('Pico text tile cannot fit an ellipsis')
original = text
while original and font.getbbox(original + '...')[2] - font.getbbox(original + '...')[0] > width - 4:
original = original[:-1]
text = original + '...'
box = font.getbbox(text)
if box[3] - box[1] > height - 2:
raise ValueError('Pico text tile is too short')
image = PIL.Image.new('L', (width, height))
PIL.ImageDraw.Draw(image).text(((width - box[2] + box[0]) // 2 - box[0],
(height - box[3] + box[1]) // 2 - box[1]), text, font=font, fill=255)
return image
def digit(self, text: str) -> PIL.Image.Image:
font = self.font(24)
box = font.getbbox(text)
if text not in '0123456789/' or len(text) != 1 or box[2] - box[0] > DIGIT_WIDTH - 4 or box[1] + 2 < 0 or box[3] + 2 > 28:
raise ValueError('Invalid Pico counter tile')
image = PIL.Image.new('L', (DIGIT_WIDTH, 28))
PIL.ImageDraw.Draw(image).text(((DIGIT_WIDTH - box[2] + box[0]) // 2 - box[0], 2), text, font=font, fill=255)
return image
def title(self, title: str) -> bytes:
self.validate(title)
for size in range(32, 15, -1):
lines = ['']
for word in title.split():
candidate = (lines[-1] + ' ' + word).strip()
box = self.font(size).getbbox(candidate)
if lines[-1] and box[2] - box[0] > TITLE_WIDTH - 4:
lines.append(word)
else:
lines[-1] = candidate
if len(lines) <= 2:
break
if len(lines) > 2:
lines = [lines[0], ' '.join(lines[1:])]
image = PIL.Image.new('L', (TITLE_WIDTH, TITLE_HEIGHT))
for index, line in enumerate(lines):
image.paste(self.line(line, TITLE_WIDTH, 32, size), (0, (TITLE_HEIGHT - len(lines) * 32) // 2 + index * 32))
return TITLE_HEADER + pack_coverage(image)
def pack_coverage(image: PIL.Image.Image) -> bytes:
pixels = image.tobytes()
if len(pixels) % 2:
pixels += b'\0'
levels = [(pixel + 8) // 17 for pixel in pixels]
return bytes((levels[index] << 4) | levels[index + 1] for index in range(0, len(levels), 2))
def generate_static(output: pathlib.Path, font_root: pathlib.Path = DEFAULT_FONT_ROOT) -> None:
renderer = Renderer(font_root / 'PetalumaScript.otf')
directory = bytearray(struct.pack('<4sHH', b'VPSA', len(SPRITES), 0))
payload = bytearray()
for text in SPRITES:
image = renderer.line(text, 176, 28, 24) if text in LABELS else renderer.digit(text)
if text in LABELS:
box = image.getbbox()
image = image.crop((max(0, box[0] - 1), 0, min(image.width, box[2] + 1), image.height))
directory.extend(struct.pack('<HHI', image.width, image.height, 8 + 8 * len(SPRITES) + len(payload)))
payload.extend(pack_coverage(image))
output.parent.mkdir(parents=True, exist_ok=True)
output.write_bytes(directory + payload)
def generate_titles(root: pathlib.Path, font_root: pathlib.Path = DEFAULT_FONT_ROOT) -> None:
"""Call after score generation, before catalog validation and atomic promotion.
Adds presentation.title_image to score bundles. The identifier is SHA-256 of
the complete fixed-size .mask file, shared across parts with the same title.
"""
renderer = Renderer(font_root / 'PetalumaScript.otf')
destination = root / 'pico' / 'titles'
destination.mkdir(parents=True, exist_ok=True)
titles = {}
for category in ('songs', 'practice'):
for source in sorted((root / category).rglob('*.json')):
document = json.loads(source.read_text(encoding='utf-8'))
title = document['music']['metadata']['title']
if title not in titles:
if len(titles) >= MAX_TITLES:
raise ValueError('Too many Pico titles')
image = renderer.title(title)
titles[title] = hashlib.sha256(image).hexdigest()
(destination / f'{titles[title]}.mask').write_bytes(image)
document['presentation']['title_image'] = titles[title]
source.write_text(json.dumps(document, ensure_ascii=False, separators=(',', ':')) + '\n', encoding='utf-8')
def validate_title(root: pathlib.Path, identifier: str) -> None:
if not isinstance(identifier, str) or len(identifier) != 64 or any(character not in '0123456789abcdef' for character in identifier):
raise ValueError('Invalid Pico title identifier; regenerate assets')
try:
asset_root = root.resolve(strict=True)
directory = (root / 'pico' / 'titles').resolve(strict=True)
source = (directory / f'{identifier}.mask').resolve(strict=True)
if not directory.is_relative_to(asset_root) or source.parent != directory:
raise ValueError('Pico title escapes the generated title directory')
if source.stat().st_size != TITLE_BYTES:
raise ValueError('Invalid Pico title size')
with source.open('rb') as stream:
image = stream.read(TITLE_BYTES + 1)
if len(image) != TITLE_BYTES or image[:len(TITLE_HEADER)] != TITLE_HEADER or hashlib.sha256(image).hexdigest() != identifier:
raise ValueError('Invalid Pico title header or digest')
except OSError as error:
raise ValueError(f'Pico title unavailable: {error}') from error
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--font-root', type=pathlib.Path, default=DEFAULT_FONT_ROOT)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('--static-output', type=pathlib.Path)
group.add_argument('--assets', type=pathlib.Path)
arguments = parser.parse_args()
if arguments.static_output:
generate_static(arguments.static_output, arguments.font_root)
else:
generate_titles(arguments.assets, arguments.font_root)
if __name__ == '__main__':
main()
+17 -18
View File
@@ -2,38 +2,37 @@ from __future__ import annotations
import copy
from generator import harmony
from generator.model import LETTERS, NATURAL, alteration, fraction, key_from_fifths, key_label, pitch_number, rational
import generator.harmony
import generator.model
def shift_pitch(pitch: dict, steps: int, semitones: int) -> dict:
output = copy.deepcopy(pitch)
if 'unpitched' in pitch:
return output
original_octave = pitch.get('octave', 4)
diatonic = original_octave * 7 + LETTERS.index(pitch['letter']) + steps
diatonic = original_octave * 7 + generator.model.LETTERS.index(pitch['letter']) + steps
octave, index = divmod(diatonic, 7)
output['letter'] = LETTERS[index]
output['letter'] = generator.model.LETTERS[index]
output['octave'] = octave
output['alter'] = rational(pitch_number({**pitch, 'octave': original_octave}) + semitones - (12 * (octave + 1) + NATURAL[output['letter']]))
output['alter'] = generator.model.rational(generator.model.pitch_number({**pitch, 'octave': original_octave}) + semitones - (12 * (octave + 1) + generator.model.NATURAL[output['letter']]))
if 'octave' not in pitch:
output.pop('octave')
if 'accidental_display' in output:
from generator.model import accidental_name
output['accidental_display']['name'] = accidental_name(alteration(output['alter']))
output['accidental_display']['name'] = generator.model.accidental_name(generator.model.alteration(output['alter']))
return output
def shift_key(key: dict, steps: int, semitones: int) -> dict:
tonic = shift_pitch(key['tonic'], steps, semitones) if key.get('tonic') else None
alterations = [0] * 7
for index, letter in enumerate(LETTERS):
for index, letter in enumerate(generator.model.LETTERS):
p = shift_pitch({'letter': letter, 'alter': key['alterations'][index]}, steps, semitones)
alterations[LETTERS.index(p['letter'])] = p['alter']
alterations[generator.model.LETTERS.index(p['letter'])] = p['alter']
result = {**copy.deepcopy(key), 'tonic': tonic, 'alterations': alterations}
if 'steps' in key:
result['steps'] = [shift_pitch(step, steps, semitones) for step in key['steps']]
if not key.get('custom') and tonic:
for fifths in range(-14, 15):
candidate = key_from_fifths(fifths, key['mode'])
candidate = generator.model.key_from_fifths(fifths, key['mode'])
if candidate['tonic'] == tonic and candidate['alterations'] == alterations:
result['fifths'] = fifths
break
@@ -41,7 +40,7 @@ def shift_key(key: dict, steps: int, semitones: int) -> dict:
result['custom'] = True
if 'cancel' in key:
# A cancellation references the old signature, not a count of naturals.
canceled = shift_key(key_from_fifths(key['cancel']), steps, semitones)
canceled = shift_key(generator.model.key_from_fifths(key['cancel']), steps, semitones)
if canceled.get('custom'):
raise ValueError('transposed key cancellation cannot be represented by fifths')
result['cancel'] = canceled['fifths']
@@ -59,7 +58,7 @@ def concert_document(document: dict, *, octave_transposing: bool = False) -> dic
def interval(onset):
state = dict(initial)
for change in changes:
if fraction(change['onset']) <= onset:
if generator.model.fraction(change['onset']) <= onset:
state.update({key: change[key] for key in initial if key in change})
transpose, clef = state['transpose'], state['clef']
steps = transpose['diatonic'] + 7 * transpose['octave_change']
@@ -69,17 +68,17 @@ def concert_document(document: dict, *, octave_transposing: bool = False) -> dic
return steps, semitones
measure['key'] = shift_key(measure['key'], *interval(0))
measure['key_signature'] = key_label(measure['key'])
measure['key_signature'] = generator.model.key_label(measure['key'])
for n in (n for v in measure['voices'] for n in v['notes']):
n['pitches'] = [shift_pitch(p, *interval(fraction(n['onset']))) for p in n['pitches']]
n['pitches'] = [shift_pitch(p, *interval(generator.model.fraction(n['onset']))) for p in n['pitches']]
for h in measure['harmonies']:
for field in ('root', 'bass'):
if field in h:
h[field] = shift_pitch(h[field], *interval(fraction(h['onset'])))
h['symbol'] = harmony.symbol(h)
h[field] = shift_pitch(h[field], *interval(generator.model.fraction(h['onset'])))
h['symbol'] = generator.harmony.symbol(h)
for change in measure.get('attributes', []):
if 'key' in change:
change['key'] = shift_key(change['key'], *interval(fraction(change['onset'])))
change['key'] = shift_key(change['key'], *interval(generator.model.fraction(change['onset'])))
if 'transpose' in change:
change['transpose'] = copy.deepcopy(zero)
measure['transpose'] = copy.deepcopy(zero)
@@ -92,4 +91,4 @@ def sounding_pitch(pitch: dict, state: dict):
if 'unpitched' in pitch:
raise ValueError('unpitched notes have no fretted or generated-tone interpretation')
transpose = state['transpose']
return pitch_number(pitch) + transpose['chromatic'] + 12 * transpose['octave_change']
return generator.model.pitch_number(pitch) + transpose['chromatic'] + 12 * transpose['octave_change']
+47 -47
View File
@@ -1,16 +1,16 @@
from __future__ import annotations
from array import array
from fractions import Fraction
import array
import fractions
import hashlib
import json
import math
from pathlib import Path
import pathlib
import sys
import wave
from generator.model import TYPE_LENGTHS, fraction, rational
from generator import pitch
import generator.model
import generator.pitch
SAMPLE_RATE = 48000
PROFILE = 'soft-struck'
@@ -19,10 +19,10 @@ MAX_EVENTS = 1000000
class PlaybackUnavailable(ValueError):
pass
def sounding_pitch(tone: dict, state: dict) -> Fraction:
def sounding_pitch(tone: dict, state: dict) -> fractions.Fraction:
if 'unpitched' in tone:
raise PlaybackUnavailable('unpitched notation has no generated-tone interpretation')
value = pitch.sounding_pitch(tone, state)
value = generator.pitch.sounding_pitch(tone, state)
frequency = 440 * 2 ** (float(value - 69) / 12)
if not 20 <= frequency <= 18000:
raise PlaybackUnavailable(f'sounding pitch {value} is outside the generated-tone range (20-18000 Hz)')
@@ -30,19 +30,19 @@ def sounding_pitch(tone: dict, state: dict) -> Fraction:
def compile_timeline(document: dict) -> dict:
events, notes, linked = [], {}, {}
absolute = Fraction()
absolute = fractions.Fraction()
for measure in document['staves'][0]['measures']:
for voice in measure['voices']:
state = {key: measure[key] for key in ('transpose', 'clef', 'staff_details')}
changes = iter(sorted(measure.get('attributes', []), key=lambda a: fraction(a['onset'])))
changes = iter(sorted(measure.get('attributes', []), key=lambda a: generator.model.fraction(a['onset'])))
change = next(changes, None)
for note in voice['notes']:
onset = fraction(note['onset'])
while change is not None and fraction(change['onset']) <= onset:
onset = generator.model.fraction(note['onset'])
while change is not None and generator.model.fraction(change['onset']) <= onset:
state.update({key: change[key] for key in state if key in change})
change = next(changes, None)
notes[note['id']] = (note, absolute + onset, dict(state), voice['id'])
absolute += fraction(measure['duration'])
absolute += generator.model.fraction(measure['duration'])
adjustments = {}
insertions = {}
lanes = {}
@@ -63,25 +63,25 @@ def compile_timeline(document: dict) -> dict:
raise PlaybackUnavailable('grace group mixes conflicting timing modes')
mode = next(iter(modes), 'steal-time-following')
if mode == 'make-time':
lengths = [fraction(g.get(mode, rational(0))) for g in specified]
total = sum(lengths, Fraction())
insertions[onset] = max(insertions.get(onset, Fraction()), total)
lengths = [generator.model.fraction(g.get(mode, generator.model.rational(0))) for g in specified]
total = sum(lengths, fractions.Fraction())
insertions[onset] = max(insertions.get(onset, fractions.Fraction()), total)
else:
donor = previous[0] if mode == 'steal-time-previous' and previous else note if mode == 'steal-time-following' else None
if donor is None:
raise PlaybackUnavailable('grace notes have no preceding duration to steal')
percentages = [fraction(g[mode]) if mode in g else Fraction(25, len(pending)) for g in specified]
percentages = [generator.model.fraction(g[mode]) if mode in g else fractions.Fraction(25, len(pending)) for g in specified]
if not 0 < sum(percentages) < 100:
raise PlaybackUnavailable('grace group must steal between zero and 100 percent')
lengths = [fraction(donor['duration']) * amount / 100 for amount in percentages]
total = sum(lengths, Fraction())
delay, extension = adjustments.get(donor['id'], (Fraction(), Fraction()))
lengths = [generator.model.fraction(donor['duration']) * amount / 100 for amount in percentages]
total = sum(lengths, fractions.Fraction())
delay, extension = adjustments.get(donor['id'], (fractions.Fraction(), fractions.Fraction()))
if mode == 'steal-time-previous':
cursor -= total
else:
delay += total
extension -= total
if fraction(donor['duration']) + extension <= 0 or any(fraction(p['duration']) + extension <= 0 for p in donor['pitches']):
if generator.model.fraction(donor['duration']) + extension <= 0 or any(generator.model.fraction(p['duration']) + extension <= 0 for p in donor['pitches']):
raise PlaybackUnavailable(f'{donor["id"]}: grace timing consumes a donor or chord tone')
adjustments[donor['id']] = (delay, extension)
for item, length in zip(pending, lengths):
@@ -95,60 +95,60 @@ def compile_timeline(document: dict) -> dict:
for identifier, (note, onset, state, voice) in notes.items():
if note.get('cue'):
continue
delay, extension = adjustments.get(identifier, (Fraction(), Fraction()))
performed = onset + delay + sum((length for time, length in insertions.items() if time < onset or time == onset and not note.get('grace')), Fraction())
delay, extension = adjustments.get(identifier, (fractions.Fraction(), fractions.Fraction()))
performed = onset + delay + sum((length for time, length in insertions.items() if time < onset or time == onset and not note.get('grace')), fractions.Fraction())
tremolo = note.get('tremolo')
if tremolo and tremolo['type'] == 'stop':
continue
for pitch in note['pitches']:
if pitch.get('cue'):
continue
length = fraction(pitch['duration']) + extension
length = generator.model.fraction(pitch['duration']) + extension
if not note.get('grace'):
length += sum((amount for time, amount in insertions.items() if onset < time < onset + fraction(pitch['duration'])), Fraction())
length += sum((amount for time, amount in insertions.items() if onset < time < onset + generator.model.fraction(pitch['duration'])), fractions.Fraction())
if length <= 0:
raise PlaybackUnavailable(f'{identifier}: grace timing consumes a chord tone')
value = sounding_pitch(pitch, state)
if pitch.get('tie_from'):
previous = linked.get(pitch['tie_from'])
if previous is None or fraction(previous['onset']) + fraction(previous['duration']) != performed or fraction(previous['pitch']) != value:
if previous is None or generator.model.fraction(previous['onset']) + generator.model.fraction(previous['duration']) != performed or generator.model.fraction(previous['pitch']) != value:
raise PlaybackUnavailable(f'{identifier}: tie has inconsistent performed timing or pitch')
previous['duration'] = rational(fraction(previous['duration']) + length)
previous['duration'] = generator.model.rational(generator.model.fraction(previous['duration']) + length)
linked[pitch['id']] = previous
continue
if tremolo and tremolo['marks']:
if pitch.get('tie_to'):
raise PlaybackUnavailable('tied measured tremolo is not yet qualified for playback')
written = pitch['written']
step = min(Fraction(1), TYPE_LENGTHS[written['type']]) * fraction(written.get('ratio', rational(1))) / 2 ** tremolo['marks']
step = min(fractions.Fraction(1), generator.model.TYPE_LENGTHS[written['type']]) * generator.model.fraction(written.get('ratio', generator.model.rational(1))) / 2 ** tremolo['marks']
if tremolo['type'] == 'start':
partner = notes[tremolo['partner']]
if partner[1] != onset + fraction(note['duration']) or len(partner[0]['pitches']) != len(note['pitches']):
if partner[1] != onset + generator.model.fraction(note['duration']) or len(partner[0]['pitches']) != len(note['pitches']):
raise PlaybackUnavailable('paired tremolo requires contiguous equal-size chords')
step *= 2
length += fraction(partner[0]['pitches'][note['pitches'].index(pitch)]['duration'])
cursor, attack = Fraction(), 0
length += generator.model.fraction(partner[0]['pitches'][note['pitches'].index(pitch)]['duration'])
cursor, attack = fractions.Fraction(), 0
while cursor < length:
amount = value
if tremolo['type'] == 'start' and attack % 2:
other = partner[0]['pitches'][note['pitches'].index(pitch)]
amount = sounding_pitch(other, partner[2])
events.append({'id': f'{pitch["id"]}-{attack}', 'pitch': rational(amount), 'onset': rational(performed + cursor), 'duration': rational(min(step, length - cursor))})
events.append({'id': f'{pitch["id"]}-{attack}', 'pitch': generator.model.rational(amount), 'onset': generator.model.rational(performed + cursor), 'duration': generator.model.rational(min(step, length - cursor))})
cursor += step
attack += 1
if len(events) > MAX_EVENTS:
raise PlaybackUnavailable('playback exceeds one million tone events')
else:
event = {'id': pitch['id'], 'pitch': rational(value), 'onset': rational(performed), 'duration': rational(length)}
event = {'id': pitch['id'], 'pitch': generator.model.rational(value), 'onset': generator.model.rational(performed), 'duration': generator.model.rational(length)}
events.append(event)
linked[pitch['id']] = event
if len(events) > MAX_EVENTS:
raise PlaybackUnavailable('playback exceeds one million tone events')
events.sort(key=lambda e: (fraction(e['onset']), e['id']))
return {'available': True, 'duration': rational(absolute + sum(insertions.values(), Fraction())), 'events': events}
events.sort(key=lambda e: (generator.model.fraction(e['onset']), e['id']))
return {'available': True, 'duration': generator.model.rational(absolute + sum(insertions.values(), fractions.Fraction())), 'events': events}
def write_pcm(path: Path, values) -> str:
samples = array('h', (round(max(-1, min(1, sample)) * 32767) for sample in values))
def write_pcm(path: pathlib.Path, values) -> str:
samples = array.array('h', (round(max(-1, min(1, sample)) * 32767) for sample in values))
if sys.byteorder != 'little':
samples.byteswap()
path.parent.mkdir(parents=True, exist_ok=True)
@@ -157,9 +157,9 @@ def write_pcm(path: Path, values) -> str:
output.writeframes(samples.tobytes())
return hashlib.sha256(path.read_bytes()).hexdigest()
def generate_tone(root: Path, pitch: Fraction) -> dict:
def generate_tone(root: pathlib.Path, pitch: fractions.Fraction) -> dict:
frequency = 440 * 2 ** (float(pitch - 69) / 12)
period = Fraction(frequency / SAMPLE_RATE).limit_denominator(SAMPLE_RATE)
period = fractions.Fraction(frequency / SAMPLE_RATE).limit_denominator(SAMPLE_RATE)
loop_length, cycles = period.denominator, period.numerator
attack = 12000
name = hashlib.sha256(f'{PROFILE}:{pitch}'.encode('ascii')).hexdigest()[:24]
@@ -174,7 +174,7 @@ def generate_tone(root: Path, pitch: Fraction) -> dict:
checksum = write_pcm(root / relative, (sample(i) for i in range(attack + loop_length)))
return {'id': name, 'path': relative, 'sha256': checksum, 'sample_rate': SAMPLE_RATE, 'frames': attack + loop_length, 'loop_start': attack, 'loop_end': attack + loop_length}
def compile_tree(root: Path) -> None:
def compile_tree(root: pathlib.Path) -> None:
tones = {}
release = {'path': 'audio/release.wav', 'sample_rate': SAMPLE_RATE, 'frames': 2400}
release['sha256'] = write_pcm(root / release['path'], ((1 - i / 2399) ** 2 for i in range(2400)))
@@ -184,8 +184,8 @@ def compile_tree(root: Path) -> None:
playback = compile_timeline(document)
used = {}
for event in playback['events']:
event['end'] = rational(fraction(event['onset']) + fraction(event.pop('duration')))
pitch = fraction(event.pop('pitch'))
event['end'] = generator.model.rational(generator.model.fraction(event['onset']) + generator.model.fraction(event.pop('duration')))
pitch = generator.model.fraction(event.pop('pitch'))
if pitch not in tones:
tones[pitch] = generate_tone(root, pitch)
tone = tones[pitch]
@@ -197,7 +197,7 @@ def compile_tree(root: Path) -> None:
document['playback'] = playback
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
def validate_assets(playback: dict, root: Path, cache: dict | None = None) -> None:
def validate_assets(playback: dict, root: pathlib.Path, cache: dict | None = None) -> None:
if not isinstance(playback, dict) or type(playback.get('available')) is not bool:
raise ValueError('playback manifest requires boolean availability')
if not playback['available']:
@@ -208,7 +208,7 @@ def validate_assets(playback: dict, root: Path, cache: dict | None = None) -> No
def check_sample(value):
name = value['path']
relative = Path(name)
relative = pathlib.Path(name)
if not name.startswith('audio/') or relative.is_absolute() or relative.suffix != '.wav' or any(p in ('', '.', '..') for p in name.split('/')) or '\\' in name or '\x00' in name:
raise ValueError('invalid generated audio path')
path = (root / relative).resolve()
@@ -225,7 +225,7 @@ def validate_assets(playback: dict, root: Path, cache: dict | None = None) -> No
pcm = audio.readframes(value['frames'])
if (audio.getnchannels(), audio.getsampwidth(), audio.getframerate(), audio.getnframes()) != (1, 2, SAMPLE_RATE, value['frames']) or len(pcm) != value['frames'] * 2:
raise ValueError('invalid or truncated generated PCM WAV')
samples = array('h')
samples = array.array('h')
samples.frombytes(pcm)
if sys.byteorder != 'little':
samples.byteswap()
@@ -234,7 +234,7 @@ def validate_assets(playback: dict, root: Path, cache: dict | None = None) -> No
cache[key] = samples
return samples
duration = fraction(playback['duration'])
duration = generator.model.fraction(playback['duration'])
if duration <= 0 or len(playback['events']) > MAX_EVENTS:
raise ValueError('invalid playback timeline length')
release = check_sample(playback['release'])
@@ -250,10 +250,10 @@ def validate_assets(playback: dict, root: Path, cache: dict | None = None) -> No
raise ValueError('invalid sample identity or loop')
check_sample(sample)
samples[sample['id']] = sample
previous = Fraction()
previous = fractions.Fraction()
identities = set()
for event in playback['events']:
onset, end = fraction(event['onset']), fraction(event['end'])
onset, end = generator.model.fraction(event['onset']), generator.model.fraction(event['end'])
if event['id'] in identities or event['sample'] not in samples or not previous <= onset < end <= duration:
raise ValueError('invalid playback event identity, reference or timing')
identities.add(event['id'])
+32 -23
View File
@@ -1,44 +1,53 @@
from __future__ import annotations
from pathlib import Path
import pathlib
from generator.guitar import place_guitar
from generator.musicxml import load_musicxml
from generator.parts import PART_TYPES, build_measure, prepare_document, propagated_chords, source_paths
from generator.scat import add_scat
from generator.score import make_arranged_document, reidentify, source_staff, write_json
import generator.accompaniment
import generator.guitar
import generator.musicxml
import generator.parts
import generator.rhythm
import generator.scat
import generator.score
def practice_document(document: dict, part_type: str) -> dict:
return _practice_document(prepare_document(document, practice=True), part_type)
def practice_document(document: dict, part_type: str, *, rhythms: generator.rhythm.Library) -> dict:
document = generator.parts.prepare_document(document, practice=True)
plan = generator.accompaniment.plan_section(generator.score.source_staff(document)['measures'], rhythms, electric=False) if part_type == 'acoustic' else None
return _practice_document(document, part_type, plan)
def _practice_document(document: dict, part_type: str) -> dict:
if part_type not in PART_TYPES:
def _practice_document(document: dict, part_type: str, plan: generator.accompaniment.Plan | None) -> dict:
if part_type not in generator.parts.PART_TYPES:
raise ValueError(f'unknown practice part: {part_type}')
staff = source_staff(document)
previous = propagated_chords(staff['measures'])
staff = generator.score.source_staff(document)
previous = generator.accompaniment.carried_chords(staff['measures'])
role = {'acoustic': 'accompaniment', 'electric': 'melody', 'vocal': 'vocal'}[part_type]
measures = [build_measure(m, f'measure-{i + 1}', role, part_type, previous[i], m.get('section_label', '')) for i, m in enumerate(staff['measures'])]
if part_type == 'acoustic' and plan is None:
raise ValueError('acoustic practice requires a rhythm plan')
measures = [generator.parts.build_measure(m, f'measure-{i + 1}', role, part_type, previous[i], m.get('section_label', ''), plan.acoustic[i] if part_type == 'acoustic' else None) for i, m in enumerate(staff['measures'])]
for measure in measures:
for chord in measure['harmonies']:
chord.pop('chord_optional', None)
tuplets = reidentify(measures, staff.get('tuplets', []))
result = make_arranged_document(document, staff, staff_id='staff-practice-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=measures, tuplets=tuplets, octave_shifts=staff.get('octave_shifts', []) if role in ('melody', 'vocal') else [])
tuplets = generator.score.reidentify(measures, staff.get('tuplets', []))
result = generator.score.make_arranged_document(document, staff, staff_id='staff-practice-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=measures, tuplets=tuplets, octave_shifts=staff.get('octave_shifts', []) if role in ('melody', 'vocal') else [])
if part_type == 'vocal':
add_scat(result)
generator.scat.add_scat(result)
else:
place_guitar(result)
generator.guitar.place_guitar(result)
if generator.accompaniment.connect_continuations(result):
generator.guitar.place_guitar(result)
return result
def generate_practice_scores(source_dir: Path, output_dir: Path) -> int:
sources = source_paths(source_dir, practice=True)
def generate_practice_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library) -> int:
sources = generator.parts.source_paths(source_dir, practice=True)
for path in sources:
try:
document = prepare_document(load_musicxml(path), practice=True)
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path), practice=True)
plan = generator.accompaniment.plan_section(generator.score.source_staff(document)['measures'], rhythms, electric=False)
except ValueError as error:
raise ValueError(f'{path}: {error}') from error
for part in PART_TYPES:
for part in generator.parts.PART_TYPES:
try:
write_json(output_dir / path.stem / (part + '.json'), _practice_document(document, part))
generator.score.write_json(output_dir / path.stem / (part + '.json'), _practice_document(document, part, plan))
except ValueError as error:
raise ValueError(f'{path}, part {part}: {error}') from error
return len(sources) * len(PART_TYPES)
return len(sources) * len(generator.parts.PART_TYPES)
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
import dataclasses
import fractions
import pathlib
import types
import generator.model
import generator.musicxml
@dataclasses.dataclass(frozen=True)
class Event:
onset: fractions.Fraction
duration: fractions.Fraction
sounding: bool
written_type: str
dots: int = 0
beams: tuple[tuple[int, str], ...] = ()
continuation: bool = False
@dataclasses.dataclass(frozen=True)
class Bar:
events: tuple[Event, ...]
length: fractions.Fraction
def attacks(self) -> tuple[fractions.Fraction, ...]:
return tuple(e.onset for e in self.events if e.sounding and not e.continuation)
def beat(self, number: int) -> tuple:
return tuple((max(e.onset, number) - number, min(e.onset + e.duration, number + 1) - max(e.onset, number), e.sounding, e.continuation or e.onset < number) for e in self.events if e.onset < number + 1 and e.onset + e.duration > number)
@dataclasses.dataclass(frozen=True)
class Snippet:
id: str
meter: str
bars: tuple[Bar, ...]
@dataclasses.dataclass(frozen=True, init=False)
class Library:
snippets: types.MappingProxyType
def __init__(self, snippets: tuple[Snippet, ...]):
grouped = {}
identities = set()
for snippet in sorted(snippets, key=lambda s: s.id):
if snippet.id in identities:
raise ValueError(f'duplicate rhythm identity: {snippet.id}')
identities.add(snippet.id)
grouped.setdefault(snippet.meter, []).append(snippet)
object.__setattr__(self, 'snippets', types.MappingProxyType({meter: tuple(values) for meter, values in grouped.items()}))
def for_meter(self, meter: str) -> tuple[Snippet, ...]:
if meter not in ('2/4', '3/4', '4/4', '5/4') or meter not in self.snippets:
raise ValueError(f'accompaniment requires rhythm snippets for {meter}; supported meters are 2/4, 3/4, 4/4, and 5/4')
return self.snippets[meter]
def read_snippet(path: pathlib.Path, identifier: str) -> Snippet:
document = generator.musicxml.load_musicxml(path)
staff = document['staves'][0]
if len(staff['measures']) not in (1, 2) or staff.get('tuplets'):
raise ValueError(f'{path}: rhythm snippets require one or two bars without tuplets')
meter = staff['measures'][0]['time_signature']
bars = []
for measure in staff['measures']:
length = generator.model.fraction(measure['duration'])
if measure['time_signature'] != meter or length != generator.model.meter_length(measure['meter']):
raise ValueError(f'{path}, measure {measure["number"]}: rhythm bars must be complete and use one meter')
if len(measure['voices']) != 1 or any('meter' in a for a in measure.get('attributes', [])):
raise ValueError(f'{path}: rhythm snippets require one voice without meter changes')
events = []
cursor = fractions.Fraction()
for note in measure['voices'][0]['notes']:
onset, duration = generator.model.fraction(note['onset']), generator.model.fraction(note['duration'])
if onset != cursor or duration <= 0 or note.get('grace') or note['kind'] not in ('note', 'rest') or note.get('tremolo'):
raise ValueError(f'{path}, measure {measure["number"]}: rhythm events must fill the bar sequentially')
if 'ratio' in note['written']:
raise ValueError(f'{path}, measure {measure["number"]}: rhythm snippets do not support time modifications')
events.append(Event(onset, duration, note['kind'] == 'note', note['written']['type'], note['written'].get('dots', 0), tuple((b['level'], b['type']) for b in note.get('beams', [])), any('tie_from' in p for p in note['pitches'])))
cursor += duration
if cursor != length:
raise ValueError(f'{path}: rhythm events do not fill the bar')
bars.append(Bar(tuple(events), length))
return Snippet(identifier, meter, tuple(bars))
def load_library(root: pathlib.Path) -> Library:
if not root.is_dir():
raise ValueError(f'{root}: rhythm library not found')
snippets = []
for path in sorted(root.rglob('*.xml')):
relative = path.relative_to(root)
if len(relative.parts) != 3 or relative.parts[0] not in ('2-4', '3-4', '4-4', '5-4') or relative.parts[1] not in ('1', '2'):
raise ValueError(f'{path}: rhythm sources belong under meter/series')
snippet = read_snippet(path, relative.as_posix())
if snippet.meter != relative.parts[0].replace('-', '/'):
raise ValueError(f'{path}: rhythm meter disagrees with its directory')
snippets.append(snippet)
if not snippets:
raise ValueError(f'{root}: rhythm library is empty')
return Library(tuple(snippets))
+17 -17
View File
@@ -1,21 +1,21 @@
from __future__ import annotations
from fractions import Fraction
import fractions
from generator.model import beat_lengths, fraction, rational
import generator.model
def pulse(measure: dict, onset: Fraction) -> tuple[Fraction | None, bool]:
start, meter = Fraction(), measure['meter']
def pulse(measure: dict, onset: fractions.Fraction) -> tuple[fractions.Fraction | None, bool]:
start, meter = fractions.Fraction(), measure['meter']
for change in measure.get('attributes', []):
if fraction(change['onset']) <= onset and 'meter' in change:
start, meter = fraction(change['onset']), change['meter']
beats = beat_lengths(meter)
if generator.model.fraction(change['onset']) <= onset and 'meter' in change:
start, meter = generator.model.fraction(change['onset']), change['meter']
beats = generator.model.beat_lengths(meter)
if not beats:
return None, False
# Pickups occupy the final beats of their meter.
phase = onset - start
if measure.get('implicit') and not start:
phase += max(Fraction(), sum(beats) - fraction(measure['duration']))
phase += max(fractions.Fraction(), sum(beats) - generator.model.fraction(measure['duration']))
phase %= sum(beats)
for beat in beats:
if phase < beat:
@@ -23,7 +23,7 @@ def pulse(measure: dict, onset: Fraction) -> tuple[Fraction | None, bool]:
phase -= beat
raise ValueError('invalid meter pulse')
def syllable(duration: Fraction, beat: Fraction | None, onbeat: bool, final: bool, previous: str) -> str:
def syllable(duration: fractions.Fraction, beat: fractions.Fraction | None, onbeat: bool, final: bool, previous: str) -> str:
if duration >= (beat if beat is not None else 1):
return 'doo'
if final:
@@ -33,7 +33,7 @@ def syllable(duration: Fraction, beat: Fraction | None, onbeat: bool, final: boo
def add_scat(document: dict) -> None:
events, pitches = [], {}
absolute = Fraction()
absolute = fractions.Fraction()
boundaries = set()
for measure in document['staves'][0]['measures']:
if measure.get('section_label'):
@@ -42,34 +42,34 @@ def add_scat(document: dict) -> None:
for note in voice['notes']:
for field in ('lyrics', 'lyric', 'lyric_notation'):
note.pop(field, None)
onset = absolute + fraction(note['onset'])
onset = absolute + generator.model.fraction(note['onset'])
for tone in note['pitches']:
pitches[tone['id']] = (note, tone, onset)
events.append((note, measure, onset))
absolute += fraction(measure['duration'])
absolute += generator.model.fraction(measure['duration'])
attacks = [(n, m, t) for n, m, t in events if n['pitches'] and not n.get('cue') and not n['pitches'][0].get('cue') and not n['pitches'][0].get('tie_from')]
previous, previous_end = '', None
for index, (note, measure, onset) in enumerate(attacks):
tone = note['pitches'][0]
end = onset + fraction(tone['duration'])
end = onset + generator.model.fraction(tone['duration'])
chain = []
while target := tone.get('tie_to'):
continuation, tone, time = pitches[target]
chain.append(continuation)
end = time + fraction(tone['duration'])
end = time + generator.model.fraction(tone['duration'])
following = attacks[index + 1][2] if index + 1 < len(attacks) else None
final = following is None or following > end or any(onset < b <= following for b in boundaries)
if previous_end is None or onset > previous_end or onset in boundaries:
previous = ''
beat, onbeat = pulse(measure, fraction(note['onset']))
beat, onbeat = pulse(measure, generator.model.fraction(note['onset']))
text = syllable(end - onset, beat, onbeat, final, previous)
values = [{'kind': 'syllabic', 'text': 'single', 'attributes': {}}, {'kind': 'text', 'text': text, 'attributes': {}}]
verse = {'number': '1', 'syllables': values}
if chain:
values.append({'kind': 'extend', 'text': '', 'attributes': {'type': 'start'}})
verse['extend_to'] = rational(end)
verse['extend_to'] = generator.model.rational(end)
for position, continuation in enumerate(chain):
continuation['lyrics'] = [{'number': '1', 'syllables': [{'kind': 'extend', 'text': '', 'attributes': {'type': 'stop' if position == len(chain) - 1 else 'continue'}}], 'extend_to': rational(end)}]
continuation['lyrics'] = [{'number': '1', 'syllables': [{'kind': 'extend', 'text': '', 'attributes': {'type': 'stop' if position == len(chain) - 1 else 'continue'}}], 'extend_to': generator.model.rational(end)}]
note['lyrics'] = [verse]
note['lyric'] = text
note['lyric_notation'] = {'text': text, 'syllabic': 'single', 'extend': 'start' if chain else ''}
+20 -19
View File
@@ -1,46 +1,45 @@
from __future__ import annotations
import copy
import hashlib
import json
import pathlib
import re
from pathlib import Path
from generator.model import validate_document
import generator.engraving.validation
import generator.model
import generator.pico_artwork
import generator.playback
def load_json(path: Path) -> dict:
def load_json(path: pathlib.Path) -> dict:
document = json.loads(path.read_text(encoding='utf-8'))
return validate_json(document, path)
def validate_json(document: dict, path: Path) -> dict:
def validate_json(document: dict, path: pathlib.Path, validated_pages: set[str] | None = None) -> dict:
if not isinstance(document, dict):
raise ValueError('expected a score object')
if 'music' in document:
from generator.engraving import ENGINE_NAME, PAGE_HEIGHT, PAGE_WIDTH, check_page
presentation = document.get('presentation', {})
if not isinstance(document['music'], dict) or not isinstance(presentation, dict) or not isinstance(presentation.get('pages'), list) or not presentation['pages']:
raise ValueError('invalid score bundle; regenerate score assets')
if (presentation.get('engine'), presentation.get('width'), presentation.get('height')) != (ENGINE_NAME, PAGE_WIDTH, PAGE_HEIGHT):
if (presentation.get('engine'), presentation.get('width'), presentation.get('height')) != (generator.engraving.validation.ENGINE_NAME, generator.engraving.validation.PAGE_WIDTH, generator.engraving.validation.PAGE_HEIGHT):
raise ValueError('unsupported score presentation; regenerate score assets')
for page in presentation['pages']:
if not isinstance(page, dict) or not isinstance(page.get('path'), str) or not isinstance(page.get('sha256'), str):
raise ValueError('invalid score page manifest')
name = page['path']
relative = Path(name)
relative = pathlib.Path(name)
if '\x00' in name or '\\' in name or relative.is_absolute() or any(part in ('', '.', '..') for part in name.split('/')) or relative.suffix != '.png':
raise ValueError('invalid score page path')
resolved = (path.parent / relative).resolve()
if not resolved.is_relative_to(path.parent.resolve()):
raise ValueError('score page escapes its asset directory')
if not re.fullmatch('[0-9a-f]{64}', page['sha256']) or hashlib.sha256(resolved.read_bytes()).hexdigest() != page['sha256']:
raise ValueError('score page checksum mismatch')
check_page(resolved)
generator.engraving.validation.check_page(resolved, validated_pages, expected_sha256=page['sha256'])
document = document['music']
validate_document(document)
generator.model.validate_document(document)
return document
def write_json(path: Path, document: dict) -> None:
validate_document(document)
def write_json(path: pathlib.Path, document: dict) -> None:
generator.model.validate_document(document)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
@@ -121,14 +120,16 @@ def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
def make_arranged_document(source_document: dict, staff: dict, *, staff_id: str, staff_name: str, role: str, arrangement_part_type: str, measures: list[dict], tuplets=None, octave_shifts=None) -> dict:
return {'metadata': copy.deepcopy(source_document['metadata']), 'staves': [{'id': staff_id, 'name': staff_name, 'clef': copy.deepcopy(staff['clef']), 'role': role, 'arranged': True, 'arrangement_part_type': arrangement_part_type, 'measures': measures, 'tuplets': tuplets or [], 'octave_shifts': copy.deepcopy(octave_shifts or []), 'instruments': copy.deepcopy(staff.get('instruments', []))}]}
def count_json_files(root: Path) -> int:
def count_json_files(root: pathlib.Path) -> int:
return sum(1 for p in root.rglob('*.json') if p.is_file())
def validate_generated_tree(root: Path) -> None:
def validate_generated_tree(root: pathlib.Path, validated_pages: set[str] | None = None) -> None:
audio_cache = {}
validated_pages = set() if validated_pages is None else validated_pages
for path in root.rglob('*.json'):
document = json.loads(path.read_text(encoding='utf-8'))
validate_json(document, path)
validate_json(document, path, validated_pages)
if 'presentation' in document:
generator.pico_artwork.validate_title(root, document['presentation'].get('title_image'))
if 'playback' in document:
from generator.playback import validate_assets
validate_assets(document['playback'], root, audio_cache)
generator.playback.validate_assets(document['playback'], root, audio_cache)
-80
View File
@@ -1,80 +0,0 @@
from __future__ import annotations
DEFAULT_STYLE = "Medium Swing"
BUILT_IN_STYLES = (
# Jazz
"Afro 12/8",
"Ballad Double Time Feel",
"Ballad Even",
"Ballad Melodic",
"Ballad Swing",
"Blue Note",
"Bossa Nova",
"Doo Doo Cats",
"Double Time Swing",
"Even 8ths",
"Even 8ths Open",
"Even 16ths",
"Guitar Trio",
"Gypsy Jazz",
"Latin",
"Latin/Swing",
"Long Notes",
"Medium Swing",
"Medium Up Swing",
"Medium Up Swing 2",
"New Orleans Swing",
"Second Line",
"Slow Swing",
"Swing Two/Four",
"Trad Jazz",
"Up Tempo Swing",
"Up Tempo Swing 2",
# Latin
"Argentina: Tango",
"Brazil: Bossa Acoustic",
"Brazil: Bossa Electric",
"Brazil: Samba",
"Cuba: Bolero",
"Cuba: Cha Cha Cha",
"Cuba: Son Montuno 2-3",
"Cuba: Son Montuno 3-2",
# Pop
"Bluegrass",
"Country",
"Disco",
"Funk",
"Glam Funk",
"House",
"Reggae",
"RnB",
"Rock",
"Rock 12/8",
"Shuffle",
"Slow Rock",
"Smooth",
"Soul",
"Virtual Funk",
)
BUILT_IN_STYLE_SET = frozenset(BUILT_IN_STYLES)
STYLE_ALIASES = {
"Med.": DEFAULT_STYLE,
"Med. Swing": DEFAULT_STYLE,
"Medium": DEFAULT_STYLE,
}
def canonical_style(value: object) -> str:
if value is None:
return DEFAULT_STYLE
if not isinstance(value, str):
raise ValueError("style must be a string")
style = value.strip()
if not style:
return DEFAULT_STYLE
style = STYLE_ALIASES.get(style, style)
if style not in BUILT_IN_STYLE_SET:
raise ValueError(f"style {value!r} is not a supported player style")
return style
+19 -19
View File
@@ -1,39 +1,39 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pathlib
import typing
from generator.score import load_json, measure_notes
from generator.parts import PART_TYPES
from generator.model import fraction, pitch_name
import generator.model
import generator.parts
import generator.score
def _arranged_staff(document: dict[str, Any]) -> dict[str, Any]:
def _arranged_staff(document: dict[str, typing.Any]) -> dict[str, typing.Any]:
staves = document.get("staves")
if not isinstance(staves, list) or not staves or not isinstance(staves[0], dict):
raise ValueError("generated score document must contain an arranged staff")
return staves[0]
def _measure_summary(staff: dict[str, Any], measure: dict[str, Any], measure_number: int) -> dict[str, Any]:
notes = measure_notes(measure)
summary: dict[str, Any] = {
def _measure_summary(staff: dict[str, typing.Any], measure: dict[str, typing.Any], measure_number: int) -> dict[str, typing.Any]:
notes = generator.score.measure_notes(measure)
summary: dict[str, typing.Any] = {
"measure": measure_number,
"section_label": str(measure.get("section_label", "")),
"harmonies": [{"onset": str(fraction(h["onset"])), "symbol": h["symbol"]} for h in measure["harmonies"]],
"duration": str(fraction(measure["duration"])),
"harmonies": [{"onset": str(generator.model.fraction(h["onset"])), "symbol": h["symbol"]} for h in measure["harmonies"]],
"duration": str(generator.model.fraction(measure["duration"])),
"slash_count": sum(1 for note in notes if note.get("kind", "note") == "note" and note.get("notehead") == "slash"),
}
if staff.get("arrangement_part_type") == "vocal":
summary["vocal_skeleton"] = [
f'{fraction(note["onset"])}+{fraction(note["duration"])} '
f'{generator.model.fraction(note["onset"])}+{generator.model.fraction(note["duration"])} '
+ note['written']['type'] + '.' * note['written'].get('dots', 0)
+ ' ' + note['kind'] + ' ' + ','.join(pitch_name(p) + str(p['octave']) for p in note['pitches'])
+ ' ' + note['kind'] + ' ' + ','.join(generator.model.pitch_name(p) + str(p['octave']) for p in note['pitches'])
+ ' ' + note.get('lyric', '')
for note in notes
]
return summary
def performance_document_summary(document: dict[str, Any]) -> dict[str, Any]:
def performance_document_summary(document: dict[str, typing.Any]) -> dict[str, typing.Any]:
metadata = document.get("metadata")
if not isinstance(metadata, dict):
metadata = {}
@@ -60,13 +60,13 @@ def performance_document_summary(document: dict[str, Any]) -> dict[str, Any]:
"measures": measure_summaries,
}
def performance_tree_summary(root: Path) -> dict[str, Any]:
def performance_tree_summary(root: pathlib.Path) -> dict[str, typing.Any]:
return {
path.name: performance_document_summary(load_json(path))
path.name: performance_document_summary(generator.score.load_json(path))
for path in sorted(root.glob("*.json"))
}
def validate_expected_performance_summary(expected_path: Path, generated_songs_dir: Path) -> None:
def validate_expected_performance_summary(expected_path: pathlib.Path, generated_songs_dir: pathlib.Path) -> None:
if not expected_path.exists():
raise ValueError(f"missing generator compatibility summary: {expected_path}")
with expected_path.open("r", encoding="utf-8") as stream:
@@ -85,10 +85,10 @@ def validate_expected_performance_summary(expected_path: Path, generated_songs_d
raise ValueError(f"performance generator drift in {name}; update generator logic or refresh the expected summary intentionally")
raise ValueError("performance generator drift")
def validate_practice_parts(generated_practice_dir: Path) -> None:
def validate_practice_parts(generated_practice_dir: pathlib.Path) -> None:
if not generated_practice_dir.exists():
return
expected_names = {part + '.json' for part in PART_TYPES}
expected_names = {part + '.json' for part in generator.parts.PART_TYPES}
for exercise_dir in sorted(path for path in generated_practice_dir.iterdir() if path.is_dir()):
json_names = {path.name for path in exercise_dir.glob("*.json")}
if json_names != expected_names: