from __future__ import annotations import argparse import json import pathlib import re import sqlite3 import tempfile import sys if __name__ == '__main__' and __package__ is None: sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) import generator.performance APPLICATION_ID = 0x56414D50 SONG_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), composer TEXT NOT NULL, key_signature TEXT NOT NULL, 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), title_image TEXT NOT NULL CHECK(length(title_image) = 64 AND title_image NOT GLOB '*[^0-9a-f]*') """ 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') document = json.loads(text) 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') staff, meta = music['staves'][0], music['metadata'] for name in ('title', 'composer', 'key_signature', 'time_signature'): 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']), title_image=title_image, part=staff['name']) def populate(connection: sqlite3.Connection, root: pathlib.Path, audio_assets=None) -> None: if not root.is_dir(): raise ValueError(f'{root}: score asset directory not found') if audio_assets is None: audio_assets = [] for path in sorted(root.glob('sounds/*.wav')): if path.name not in ('beat1.wav', 'beat2.wav'): raise ValueError(f'{path}: unknown metronome sample') audio_assets.append(generator.performance.describe_wav(path, root)) for path in sorted(root.glob('backing/*.wav')): match = re.fullmatch(r'([a-z0-9]+(?:_[a-z0-9]+)*)_(60|70|80|90|100|110|120)\.wav', path.name) if not match: raise ValueError(f'{path}: invalid backing filename') audio_assets.append(generator.performance.describe_wav(path, root, song_id=match[1], bpm=int(match[2]))) folder = root / 'songs' if (folder.exists() or folder.is_symlink()) and not folder.is_dir(): raise ValueError(f'{folder}: score asset directory not found') with connection: connection.execute('BEGIN') connection.execute(f'PRAGMA application_id = {APPLICATION_ID}') connection.execute(f'CREATE TABLE songs ({SONG_COLUMNS}, part TEXT NOT NULL) STRICT') connection.execute('CREATE INDEX songs_menu ON songs(title, composer, part, id)') connection.execute('CREATE TABLE resources (id TEXT PRIMARY KEY, value TEXT NOT NULL CHECK(json_valid(value))) STRICT') connection.execute('INSERT INTO resources VALUES (?, ?)', ('audio', json.dumps(audio_assets))) for path in sorted(folder.rglob('*.json')): try: if path.parent != folder: raise ValueError('unexpected songs score path') row = metadata(path, root) connection.execute(f'INSERT INTO songs ({", ".join(row)}) VALUES ({", ".join("?" for _ in row)})', tuple(row.values())) except (ValueError, KeyError, TypeError, sqlite3.Error) as error: raise ValueError(f'{path}: {error}') from error if connection.execute('PRAGMA integrity_check').fetchall() != [('ok',)]: raise ValueError('catalog integrity check failed') def build_catalog(root: pathlib.Path, destination: pathlib.Path | None = None, *, audio_assets=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 = pathlib.Path(temporary) / 'catalog.db' connection = sqlite3.connect(staged) try: populate(connection, root, audio_assets) finally: connection.close() staged.replace(destination) return destination if __name__ == '__main__': parser = argparse.ArgumentParser(description='Compile generated score bundles into a read-only runtime catalog.') 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)