109 lines
5.4 KiB
Python
109 lines
5.4 KiB
Python
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)
|