+33
-11
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
@@ -9,6 +10,27 @@ from generator.model import SCHEMA_VERSION, validate_document
|
||||
|
||||
def load_json(path: Path) -> dict:
|
||||
document = json.loads(path.read_text(encoding='utf-8'))
|
||||
if 'music' in document:
|
||||
from generator.engraving import ENGINE_VERSION, PAGE_HEIGHT, PAGE_WIDTH, check_page
|
||||
presentation = document.get('presentation', {})
|
||||
if document.get('schema_version') != SCHEMA_VERSION 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('engine_version'), presentation.get('width'), presentation.get('height')) != ('MuseScore Studio', ENGINE_VERSION, PAGE_WIDTH, 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)
|
||||
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)
|
||||
document = {'schema_version': SCHEMA_VERSION, **document['music']}
|
||||
validate_document(document)
|
||||
return document
|
||||
|
||||
@@ -28,13 +50,8 @@ def source_staff(document: dict) -> dict:
|
||||
raise ValueError('exactly one source staff is required')
|
||||
return document['staves'][0]
|
||||
|
||||
def first_voice(measure: dict) -> dict:
|
||||
if len(measure.get('voices', [])) != 1:
|
||||
raise ValueError('exactly one source voice is required')
|
||||
return measure['voices'][0]
|
||||
|
||||
def measure_notes(measure: dict) -> list[dict]:
|
||||
return first_voice(measure)['notes']
|
||||
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
|
||||
@@ -42,17 +59,20 @@ def leading_pickup_measure_index(measures: list[dict]) -> int | 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_voice(measure: dict, *, strip_lyrics: bool) -> dict:
|
||||
voice = copy.deepcopy(first_voice(measure))
|
||||
def copy_source_voices(measure: dict, *, strip_lyrics: bool) -> list[dict]:
|
||||
voices = copy.deepcopy(measure['voices'])
|
||||
if strip_lyrics:
|
||||
for note in voice['notes']:
|
||||
for note in (n for v in voices for n in v['notes']):
|
||||
note.pop('lyric', None)
|
||||
note.pop('lyric_notation', None)
|
||||
return voice
|
||||
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]:
|
||||
@@ -60,6 +80,8 @@ def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
|
||||
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}'
|
||||
@@ -95,7 +117,7 @@ def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
|
||||
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 {'schema_version': SCHEMA_VERSION, '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': octave_shifts or []}]}
|
||||
return {'schema_version': SCHEMA_VERSION, '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': octave_shifts or [], 'instruments': copy.deepcopy(staff.get('instruments', []))}]}
|
||||
|
||||
def count_json_files(root: Path) -> int:
|
||||
return sum(1 for p in root.rglob('*.json') if p.is_file())
|
||||
|
||||
Reference in New Issue
Block a user