Files
product-vamp/generator/score.py
T
2026-10-01 12:06:02 -05:00

139 lines
6.7 KiB
Python

from __future__ import annotations
import copy
import json
import pathlib
import re
import generator.engraving.validation
import generator.fretboard
import generator.model
import generator.pico_artwork
import generator.playback
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: pathlib.Path, validated_pages: set[str] | None = None) -> dict:
if not isinstance(document, dict):
raise ValueError('expected a score object')
if 'music' in document:
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')) != (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 = 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')
generator.engraving.validation.check_page(resolved, validated_pages, expected_sha256=page['sha256'])
document = document['music']
generator.model.validate_document(document)
return document
def write_json(path: pathlib.Path, document: dict) -> None:
generator.model.validate_document(document)
generator.fretboard.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')
def slugify(value: str) -> str:
return re.sub(r'[^a-z0-9]+', '_', value.strip().lower()).strip('_') or 'score'
def source_staff(document: dict) -> dict:
if len(document.get('staves', [])) != 1:
raise ValueError('exactly one source staff is required')
return document['staves'][0]
def measure_notes(measure: dict) -> list[dict]:
return [note for voice in measure['voices'] for note in voice['notes']]
def leading_pickup_measure_index(measures: list[dict]) -> int | None:
return 0 if len(measures) > 1 and measures[0].get('implicit') else None
def body_start_measure_index(measures: list[dict]) -> int:
return 1 if leading_pickup_measure_index(measures) is not None else 0
def copy_source_voices(measure: dict, *, strip_lyrics: bool) -> list[dict]:
voices = copy.deepcopy(measure['voices'])
if strip_lyrics:
for note in (n for v in voices for n in v['notes']):
note.pop('lyric', None)
note.pop('lyric_notation', None)
note.pop('lyrics', None)
return voices
def make_measure(measure_id: str, source: dict, notes: list[dict], *, section_label: str = '') -> dict:
result = copy.deepcopy(source)
result.update({'id': measure_id, 'section_label': section_label, 'voices': [{'id': 'voice_arranged', 'notes': notes}]})
for note in notes:
note['voice'] = 'voice_arranged'
return result
def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
"""Clone-local links survive; links cut by an arrangement boundary become attacks."""
tone_map, event_map = {}, {}
tuplets = []
for measure in measures:
for hi, harmony in enumerate(measure.get('harmonies', [])):
harmony['id'] = f'{measure["id"]}_h{hi + 1}'
for ni, note in enumerate(measure_notes(measure)):
old = note['id']
note['id'] = f'{measure["id"]}_n{ni + 1}'
event_map[old] = note['id']
for pi, pitch in enumerate(note['pitches']):
old_pitch = pitch['id']
pitch['id'] = f'{note["id"]}_p{pi + 1}'
tone_map[old_pitch] = pitch['id']
for measure in measures:
for note in measure_notes(measure):
for p in note['pitches']:
for field in ('tie_from', 'tie_to'):
if p.get(field) in tone_map:
p[field] = tone_map[p[field]]
else:
p.pop(field, None)
if note.get('tremolo', {}).get('partner'):
partner = note['tremolo']['partner']
if partner in event_map:
note['tremolo']['partner'] = event_map[partner]
else:
note['tremolo'] = {**note['tremolo'], 'type': 'single'}
note['tremolo'].pop('partner', None)
for source in source_tuplets:
members = [n for m in measures for n in measure_notes(m) if source['id'] in n.get('tuplet_ids', [])]
if not members:
continue
t = copy.deepcopy(source)
t.update({'id': f'{measures[0]["id"]}_{source["id"]}', 'start': members[0]['id'], 'end': members[-1]['id'], 'continued_start': source['start'] not in event_map, 'continued_end': source['end'] not in event_map})
for n in members:
n['tuplet_ids'] = [t['id'] if v == source['id'] else v for v in n['tuplet_ids']]
tuplets.append(t)
return tuplets
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: pathlib.Path) -> int:
return sum(1 for p in root.rglob('*.json') if p.is_file())
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'))
music = validate_json(document, path, validated_pages)
generator.fretboard.validate_document(music)
if 'presentation' in document:
generator.pico_artwork.validate_title(root, document['presentation'].get('title_image'))
if 'playback' in document:
generator.playback.validate_assets(document['playback'], root, audio_cache)