@@ -57,6 +57,8 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
|
||||
report('checking source paths')
|
||||
for source in sorted(data_dir.rglob('*.xml')):
|
||||
if source.is_relative_to(data_dir / 'rhythms'):
|
||||
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')
|
||||
|
||||
@@ -7,7 +7,6 @@ import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
APPLICATION_ID = 0x56414D50
|
||||
SCHEMA_VERSION = 3
|
||||
COMMON_COLUMNS = """
|
||||
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 160),
|
||||
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
|
||||
@@ -24,8 +23,8 @@ def metadata(path: Path, root: Path) -> dict:
|
||||
raise ValueError('score escapes the asset root')
|
||||
text = path.read_text(encoding='utf-8')
|
||||
document = json.loads(text)
|
||||
if document.get('schema_version') != 3:
|
||||
raise ValueError('expected a schema-v3 score bundle')
|
||||
if not isinstance(document, dict) or not isinstance(document.get('music'), dict) or not isinstance(document.get('presentation'), dict):
|
||||
raise ValueError('score bundle requires music and presentation objects')
|
||||
music, presentation = document['music'], document['presentation']
|
||||
if len(music['staves']) != 1 or (presentation['width'], presentation['height']) != (976, 484):
|
||||
raise ValueError('expected one staff and 976x484 pages')
|
||||
@@ -46,7 +45,6 @@ def populate(connection: sqlite3.Connection, root: Path) -> None:
|
||||
raise ValueError(f'{root}: score asset directory not found')
|
||||
connection.executescript(f'''
|
||||
PRAGMA application_id = {APPLICATION_ID};
|
||||
PRAGMA user_version = {SCHEMA_VERSION};
|
||||
CREATE TABLE songs ({COMMON_COLUMNS}, part TEXT NOT NULL) STRICT;
|
||||
CREATE TABLE practice_exercises ({COMMON_COLUMNS}, exercise_id TEXT NOT NULL, part TEXT NOT NULL) STRICT;
|
||||
CREATE INDEX songs_menu ON songs(title COLLATE NOCASE, composer COLLATE NOCASE, part COLLATE NOCASE);
|
||||
|
||||
@@ -17,7 +17,6 @@ from generator.engraving.types import EngravingError, Style
|
||||
from generator.model import validate_document
|
||||
|
||||
ENGINE_NAME = 'Vamp Python'
|
||||
ENGINE_VERSION = '1.0.0'
|
||||
PAGE_WIDTH = 976
|
||||
PAGE_HEIGHT = 484
|
||||
|
||||
@@ -70,8 +69,8 @@ def engrave_scores(paths: list[Path], root: Path, fonts: Path, cache_dir: Path |
|
||||
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 not in ('schema_version', 'playback')}
|
||||
bundle = {'schema_version': 3, 'music': music, 'presentation': {'engine': ENGINE_NAME, 'engine_version': ENGINE_VERSION, 'style_sha256': fingerprint, 'width': PAGE_WIDTH, 'height': PAGE_HEIGHT, 'pages': entries}}
|
||||
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')
|
||||
|
||||
@@ -22,7 +22,7 @@ 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:
|
||||
namespace = cache / 'v2' / renderer
|
||||
namespace = cache / renderer
|
||||
if namespace.parent.is_symlink() or namespace.is_symlink():
|
||||
raise ValueError('engraving cache namespaces must not be symlinks')
|
||||
return namespace
|
||||
|
||||
+7
-4
@@ -4,7 +4,6 @@ import copy
|
||||
from fractions import Fraction
|
||||
from typing import Any
|
||||
|
||||
SCHEMA_VERSION = 3
|
||||
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)}
|
||||
@@ -141,11 +140,15 @@ def make_event(identifier: str, onset: Fraction, duration: Fraction, kind: str,
|
||||
return event
|
||||
|
||||
def validate_document(document: dict) -> None:
|
||||
if document.get('schema_version') != SCHEMA_VERSION:
|
||||
raise ValueError('unsupported score schema; regenerate score assets with the current generator')
|
||||
if not isinstance(document, dict) or not isinstance(document.get('metadata'), dict):
|
||||
raise ValueError('score requires a metadata object')
|
||||
if any(not isinstance(document['metadata'].get(name), str) for name in ('title', 'composer', 'key_signature', 'time_signature')):
|
||||
raise ValueError('score metadata requires title, composer, key_signature and time_signature strings')
|
||||
staves = document.get('staves', [])
|
||||
if len(staves) != 1:
|
||||
if not isinstance(staves, list) or len(staves) != 1 or not isinstance(staves[0], dict):
|
||||
raise ValueError('exactly one staff is currently supported')
|
||||
if not isinstance(staves[0].get('measures'), list) or not staves[0]['measures']:
|
||||
raise ValueError('score requires measures')
|
||||
instruments = staves[0].get('instruments', [])
|
||||
instrument_ids = {i['id'] for i in instruments}
|
||||
if len(instruments) != len(instrument_ids) or any(not isinstance(i, str) or not i for i in instrument_ids):
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from defusedxml import ElementTree
|
||||
|
||||
from generator import harmony
|
||||
from generator.model import (ACCIDENTALS, SCHEMA_VERSION, TYPE_LENGTHS, alteration, fraction, key_from_fifths, key_label, meter_label, meter_length, pitch_number, rational, validate_document, written_for, written_length, )
|
||||
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
|
||||
|
||||
class MusicXMLError(ValueError):
|
||||
@@ -625,7 +625,7 @@ class Importer:
|
||||
for p in n['pitches']:
|
||||
p.pop('_absolute', None)
|
||||
metadata.update({'key_signature': measures[0]['key_signature'], 'time_signature': measures[0]['time_signature'], 'key': copy.deepcopy(measures[0]['key'])})
|
||||
result = {'schema_version': SCHEMA_VERSION, '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 = {'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)
|
||||
return result
|
||||
|
||||
@@ -13,7 +13,7 @@ from generator.model import TYPE_LENGTHS, fraction, rational
|
||||
from generator import pitch
|
||||
|
||||
SAMPLE_RATE = 48000
|
||||
PROFILE = 'soft-struck-1'
|
||||
PROFILE = 'soft-struck'
|
||||
MAX_EVENTS = 1000000
|
||||
|
||||
class PlaybackUnavailable(ValueError):
|
||||
@@ -145,7 +145,7 @@ def compile_timeline(document: dict) -> dict:
|
||||
if len(events) > MAX_EVENTS:
|
||||
raise PlaybackUnavailable('playback exceeds one million tone events')
|
||||
events.sort(key=lambda e: (fraction(e['onset']), e['id']))
|
||||
return {'version': 1, 'available': True, 'duration': rational(absolute + sum(insertions.values(), Fraction())), 'events': events}
|
||||
return {'available': True, 'duration': rational(absolute + sum(insertions.values(), Fraction())), 'events': events}
|
||||
|
||||
def write_pcm(path: Path, values) -> str:
|
||||
samples = array('h', (round(max(-1, min(1, sample)) * 32767) for sample in values))
|
||||
@@ -193,13 +193,13 @@ def compile_tree(root: Path) -> None:
|
||||
used[tone['id']] = tone
|
||||
playback.update({'samples': list(used.values()), 'release': release, 'profile': PROFILE})
|
||||
except PlaybackUnavailable as error:
|
||||
playback = {'version': 1, 'available': False, 'reason': str(error)}
|
||||
playback = {'available': False, 'reason': str(error)}
|
||||
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:
|
||||
if playback.get('version') != 1 or type(playback.get('available')) is not bool:
|
||||
raise ValueError('invalid playback manifest version/availability')
|
||||
if not isinstance(playback, dict) or type(playback.get('available')) is not bool:
|
||||
raise ValueError('playback manifest requires boolean availability')
|
||||
if not playback['available']:
|
||||
if not isinstance(playback.get('reason'), str) or not playback['reason']:
|
||||
raise ValueError('unavailable playback must explain why')
|
||||
|
||||
+8
-6
@@ -6,19 +6,21 @@ import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from generator.model import SCHEMA_VERSION, validate_document
|
||||
from generator.model import validate_document
|
||||
|
||||
def load_json(path: Path) -> dict:
|
||||
document = json.loads(path.read_text(encoding='utf-8'))
|
||||
return validate_json(document, path)
|
||||
|
||||
def validate_json(document: dict, path: Path) -> dict:
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError('expected a score object')
|
||||
if 'music' in document:
|
||||
from generator.engraving import ENGINE_NAME, ENGINE_VERSION, PAGE_HEIGHT, PAGE_WIDTH, check_page
|
||||
from generator.engraving import ENGINE_NAME, 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']:
|
||||
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('engine_version'), presentation.get('width'), presentation.get('height')) != (ENGINE_NAME, ENGINE_VERSION, PAGE_WIDTH, PAGE_HEIGHT):
|
||||
if (presentation.get('engine'), presentation.get('width'), presentation.get('height')) != (ENGINE_NAME, 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):
|
||||
@@ -33,7 +35,7 @@ def validate_json(document: dict, path: Path) -> dict:
|
||||
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']}
|
||||
document = document['music']
|
||||
validate_document(document)
|
||||
return document
|
||||
|
||||
@@ -117,7 +119,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': copy.deepcopy(octave_shifts or []), 'instruments': copy.deepcopy(staff.get('instruments', []))}]}
|
||||
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:
|
||||
return sum(1 for p in root.rglob('*.json') if p.is_file())
|
||||
|
||||
Reference in New Issue
Block a user