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