Improve Catalog
ci / checks (push) Successful in 46m42s

This commit is contained in:
khodowany
2026-09-27 11:03:43 -05:00
parent a416a80dd9
commit 142d843fac
418 changed files with 440 additions and 324488 deletions
+4 -9
View File
@@ -15,7 +15,6 @@ import generator.engraving.types
import generator.parts
import generator.pico_artwork
import generator.playback
import generator.practice.generate
import generator.rhythm
import generator.score
import generator.summary
@@ -63,10 +62,9 @@ def generate_assets(args: argparse.Namespace) -> None:
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')
if source.parent != data_dir / 'songs':
raise ValueError(f'{source}: MusicXML sources belong directly under data/songs')
generator.parts.source_paths(data_dir / 'songs')
generator.parts.source_paths(data_dir / 'practice', practice=True)
report('loading rhythm snippets')
rhythms = generator.rhythm.load_library(data_dir / 'rhythms')
output_dir.parent.mkdir(parents=True, exist_ok=True)
@@ -74,13 +72,11 @@ def generate_assets(args: argparse.Namespace) -> None:
staged = pathlib.Path(temporary) / 'assets'
staged.mkdir(parents=True, exist_ok=True)
copy_runtime_fonts(static_assets_dir, staged)
report('generating performance and practice parts')
report('generating performance parts')
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms)
practice_count = generator.practice.generate.generate_practice_scores(data_dir / 'practice', staged / 'practice', rhythms=rhythms)
generator.score.validate_generated_tree(staged)
if args.check:
generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
generator.summary.validate_practice_parts(staged / 'practice')
report('compiling playback')
generator.playback.compile_tree(staged)
report('engraving pages')
@@ -101,8 +97,7 @@ def generate_assets(args: argparse.Namespace) -> None:
backup.rename(output_dir)
raise
report('publishing complete')
print(f'Generated Product Vamp assets: {performance_count} performance scores, '
f'{practice_count} practice parts, {generator.score.count_json_files(output_dir)} total JSON files.')
print(f'Generated Product Vamp assets: {performance_count} performance scores.')
if args.check:
print('Generator check passed.')
+1 -1
View File
@@ -66,7 +66,7 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
if section.role == 'accompaniment':
plan = plans[section.section_index]
rhythm = (plan.acoustic_bass[len(segment)], plan.acoustic_upper[len(segment)]) if part_type == 'acoustic' else plan.electric[len(segment)]
result = generator.parts.build_measure(m, f'measure-{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '', rhythm, melody_octave=True)
result = generator.parts.build_measure(m, f'measure-{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '', rhythm)
segment.append(result)
if part_type == 'electric' and section.role in ('melody', 'accompaniment'):
voicing_origins.append(generator.electric.MeasureOrigin(source_starts[si], section.role, result))
+15 -34
View File
@@ -8,7 +8,7 @@ import sqlite3
import tempfile
APPLICATION_ID = 0x56414D50
COMMON_COLUMNS = """
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,
@@ -48,43 +48,24 @@ def metadata(path: pathlib.Path, root: pathlib.Path) -> dict:
def populate(connection: sqlite3.Connection, root: pathlib.Path) -> None:
if not root.is_dir():
raise ValueError(f'{root}: score asset directory not found')
connection.executescript(f'''
PRAGMA application_id = {APPLICATION_ID};
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);
CREATE INDEX practice_menu ON practice_exercises(exercise_id, id);
''')
practice_parts = {}
for category, table, depth in (('songs', 'songs', 1), ('practice', 'practice_exercises', 2)):
folder = root / category
if (folder.exists() or folder.is_symlink()) and not folder.is_dir():
raise ValueError(f'{folder}: score asset directory not found')
for path in sorted((root / category).rglob('*.json')):
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)')
for path in sorted(folder.rglob('*.json')):
try:
parts = path.relative_to(root / category).parts
if len(parts) != depth:
raise ValueError(f'unexpected {category} score path')
if path.parent != folder:
raise ValueError('unexpected songs score path')
row = metadata(path, root)
if category == 'practice':
identity = parts[0]
if path.stem not in ('acoustic', 'electric', 'vocal') or row['part'] != path.stem.capitalize():
raise ValueError('practice requires an acoustic, electric, or vocal part')
identity_metadata = (row['title'], row['composer'], row['key_signature'])
group = practice_parts.setdefault(identity, (identity_metadata, set()))
if group[0] != identity_metadata:
raise ValueError(f'practice parts disagree on metadata: {identity}')
group[1].add(path.stem)
row.update(id=identity + '-' + path.stem, exercise_id=identity)
connection.execute(f'INSERT INTO {table} ({", ".join(row)}) VALUES ({", ".join("?" for _ in row)})', tuple(row.values()))
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
for identity, (_, parts) in practice_parts.items():
if parts != {'acoustic', 'electric', 'vocal'}:
raise ValueError(f'practice exercise requires all three parts: {identity}')
connection.commit()
if connection.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
raise ValueError('catalog integrity check failed')
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) -> pathlib.Path:
if not root.is_dir():
+16 -20
View File
@@ -12,28 +12,25 @@ import generator.score
PART_TYPES = ('acoustic', 'electric', 'vocal')
def source_paths(directory: pathlib.Path, *, practice: bool = False) -> list[pathlib.Path]:
def source_paths(directory: pathlib.Path) -> list[pathlib.Path]:
if not directory.is_dir():
raise ValueError(f'{"practice" if practice else "performance"} source directory not found: {directory}')
raise ValueError(f'performance source directory not found: {directory}')
paths = sorted(directory.rglob('*.xml'))
identities = {}
for path in paths:
if practice:
if path.parent != directory or len(path.stem) != 4 or not path.stem.isascii() or not path.stem.isdecimal():
raise ValueError(f'{path}: practice sources require a four-digit filename directly under data/practice')
elif path.parent != directory:
if path.parent != directory:
raise ValueError(f'{path}: performance sources belong directly under data/songs')
identity = path.stem if practice else generator.score.slugify(path.stem)
identity = generator.score.slugify(path.stem)
if identity in identities:
raise ValueError(f'{identities[identity]} and {path}: conflicting output path {identity}-acoustic.json')
identities[identity] = path
return paths
def prepare_document(document: dict, *, practice: bool = False) -> dict:
validate_source(document, practice=practice)
def prepare_document(document: dict) -> dict:
validate_source(document)
return generator.pitch.concert_document(document, octave_transposing=True)
def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dict]:
def generated_voice(measure: dict, role: str) -> list[dict]:
length = generator.model.fraction(measure['duration'])
if role == 'rest':
return [generator.model.make_event('rest', fractions.Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'measure_rest': True}]
@@ -55,8 +52,7 @@ def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dic
beat_index += 1
return events
def validate_source(document: dict, *, practice: bool = False) -> None:
voices = set()
def validate_source(document: dict) -> None:
for measure in generator.score.source_staff(document)['measures']:
for state in [measure, *measure.get('attributes', [])]:
if state.get('clef', {}).get('sign') in ('TAB', 'percussion'):
@@ -64,26 +60,26 @@ def validate_source(document: dict, *, practice: bool = False) -> None:
if 'staff_details' in state:
generator.guitar.validate_tuning(state, measure['id'])
for voice in measure['voices']:
voices.add(voice['id'])
for note in voice['notes']:
if practice and len(note['pitches']) > 1:
raise ValueError(f'{measure["id"]}: practice requires a monophonic melody, not chord stacks')
if any('tablature' in p or 'unpitched' in p for p in note['pitches']):
raise ValueError(f'{measure["id"]}: authored TAB and percussion are unsupported')
if practice and len(voices) > 1:
raise ValueError('practice requires one monophonic voice')
def build_measure(source: dict, identifier: str, role: str, part: str, previous: dict | None, label: str = '', rhythm: generator.rhythm.Bar | tuple[generator.rhythm.Bar, generator.rhythm.Bar] | None = None, *, melody_octave: bool = False) -> dict:
def build_measure(source: dict, identifier: str, role: str, part: str, previous: dict | None, label: str = '', rhythm: generator.rhythm.Bar | tuple[generator.rhythm.Bar, generator.rhythm.Bar] | None = None) -> dict:
voices = generator.score.copy_source_voices(source, strip_lyrics=role == 'melody') if role in ('vocal', 'melody') else None
if role == 'accompaniment' and part == 'acoustic':
voices = [{'id': lane, 'notes': generator.accompaniment.realize(source, bar, previous, lane=lane)} for lane, bar in zip(('upper', 'bass'), (rhythm[1], rhythm[0]))]
for voice in voices:
for note in voice['notes']:
note.update(voice=voice['id'], stem='down' if voice['id'] == 'bass' else 'up')
notes = [n for v in voices for n in v['notes']] if voices else generator.accompaniment.realize(source, rhythm, previous, lane='electric') if rhythm is not None else generated_voice(source, role, previous)
if voices:
notes = [note for voice in voices for note in voice['notes']]
elif rhythm is not None:
notes = generator.accompaniment.realize(source, rhythm, previous, lane='electric')
else:
notes = generated_voice(source, role)
if role in ('melody', 'vocal'):
for note in notes:
octaves = 2 if melody_octave and role == 'melody' else 1
octaves = 2 if role == 'melody' else 1
note['pitches'] = [generator.pitch.shift_pitch(p, 7 * octaves, 12 * octaves) for p in note['pitches']]
if role == 'melody':
note['_melody'] = True
+11 -12
View File
@@ -125,18 +125,17 @@ def generate_titles(root: pathlib.Path, font_root: pathlib.Path = DEFAULT_FONT_R
destination = root / 'pico' / 'titles'
destination.mkdir(parents=True, exist_ok=True)
titles = {}
for category in ('songs', 'practice'):
for source in sorted((root / category).rglob('*.json')):
document = json.loads(source.read_text(encoding='utf-8'))
title = document['music']['metadata']['title']
if title not in titles:
if len(titles) >= MAX_TITLES:
raise ValueError('Too many Pico titles')
image = renderer.title(title)
titles[title] = hashlib.sha256(image).hexdigest()
(destination / f'{titles[title]}.mask').write_bytes(image)
document['presentation']['title_image'] = titles[title]
source.write_text(json.dumps(document, ensure_ascii=False, separators=(',', ':')) + '\n', encoding='utf-8')
for source in sorted((root / 'songs').rglob('*.json')):
document = json.loads(source.read_text(encoding='utf-8'))
title = document['music']['metadata']['title']
if title not in titles:
if len(titles) >= MAX_TITLES:
raise ValueError('Too many Pico titles')
image = renderer.title(title)
titles[title] = hashlib.sha256(image).hexdigest()
(destination / f'{titles[title]}.mask').write_bytes(image)
document['presentation']['title_image'] = titles[title]
source.write_text(json.dumps(document, ensure_ascii=False, separators=(',', ':')) + '\n', encoding='utf-8')
def validate_title(root: pathlib.Path, identifier: str) -> None:
if not isinstance(identifier, str) or len(identifier) != 64 or any(character not in '0123456789abcdef' for character in identifier):
-1
View File
@@ -1 +0,0 @@
"""Practice score generation."""
-58
View File
@@ -1,58 +0,0 @@
from __future__ import annotations
import pathlib
import generator.accompaniment
import generator.acoustic
import generator.guitar
import generator.melody
import generator.musicxml
import generator.parts
import generator.rhythm
import generator.scat
import generator.score
def practice_document(document: dict, part_type: str, *, rhythms: generator.rhythm.Library) -> dict:
document = generator.parts.prepare_document(document, practice=True)
plan = generator.accompaniment.plan_section(generator.score.source_staff(document)['measures'], rhythms, electric=False) if part_type == 'acoustic' else None
return _practice_document(document, part_type, plan)
def _practice_document(document: dict, part_type: str, plan: generator.accompaniment.Plan | None) -> dict:
if part_type not in generator.parts.PART_TYPES:
raise ValueError(f'unknown practice part: {part_type}')
staff = generator.score.source_staff(document)
previous = generator.accompaniment.carried_chords(staff['measures'])
role = {'acoustic': 'accompaniment', 'electric': 'melody', 'vocal': 'vocal'}[part_type]
if part_type == 'acoustic' and plan is None:
raise ValueError('acoustic practice requires a rhythm plan')
measures = [generator.parts.build_measure(m, f'measure-{i + 1}', role, part_type, previous[i], m.get('section_label', ''), (plan.acoustic_bass[i], plan.acoustic_upper[i]) if part_type == 'acoustic' else None) for i, m in enumerate(staff['measures'])]
for measure in measures:
for chord in measure['harmonies']:
chord.pop('chord_optional', None)
tuplets = generator.score.reidentify(measures, staff.get('tuplets', []))
result = generator.score.make_arranged_document(document, staff, staff_id='staff-practice-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=measures, tuplets=tuplets, octave_shifts=staff.get('octave_shifts', []) if role in ('melody', 'vocal') else [])
if part_type == 'vocal':
generator.scat.add_scat(result)
else:
generator.acoustic.prepare(result)
generator.melody.harmonize(result)
generator.guitar.place_guitar(result)
if generator.accompaniment.connect_continuations(result):
generator.guitar.place_guitar(result)
generator.acoustic.combine(result)
return result
def generate_practice_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library) -> int:
sources = generator.parts.source_paths(source_dir, practice=True)
for path in sources:
try:
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path), practice=True)
plan = generator.accompaniment.plan_section(generator.score.source_staff(document)['measures'], rhythms, electric=False)
except ValueError as error:
raise ValueError(f'{path}: {error}') from error
for part in generator.parts.PART_TYPES:
try:
generator.score.write_json(output_dir / path.stem / (part + '.json'), _practice_document(document, part, plan))
except ValueError as error:
raise ValueError(f'{path}, part {part}: {error}') from error
return len(sources) * len(generator.parts.PART_TYPES)
-76
View File
@@ -1,76 +0,0 @@
from __future__ import annotations
import fractions
import generator.model
def pulse(measure: dict, onset: fractions.Fraction) -> tuple[fractions.Fraction | None, bool]:
start, meter = fractions.Fraction(), measure['meter']
for change in measure.get('attributes', []):
if generator.model.fraction(change['onset']) <= onset and 'meter' in change:
start, meter = generator.model.fraction(change['onset']), change['meter']
beats = generator.model.beat_lengths(meter)
if not beats:
return None, False
# Pickups occupy the final beats of their meter.
phase = onset - start
if measure.get('implicit') and not start:
phase += max(fractions.Fraction(), sum(beats) - generator.model.fraction(measure['duration']))
phase %= sum(beats)
for beat in beats:
if phase < beat:
return beat, phase == 0
phase -= beat
raise ValueError('invalid meter pulse')
def syllable(duration: fractions.Fraction, beat: fractions.Fraction | None, onbeat: bool, final: bool, previous: str) -> str:
if duration >= (beat if beat is not None else 1):
return 'doo'
if final:
return 'dat'
result = 'dit' if beat is not None and not onbeat else 'ba'
return 'da' if result == previous else result
def add_scat(document: dict) -> None:
events, pitches = [], {}
absolute = fractions.Fraction()
boundaries = set()
for measure in document['staves'][0]['measures']:
if measure.get('section_label'):
boundaries.add(absolute)
for voice in measure['voices']:
for note in voice['notes']:
for field in ('lyrics', 'lyric', 'lyric_notation'):
note.pop(field, None)
onset = absolute + generator.model.fraction(note['onset'])
for tone in note['pitches']:
pitches[tone['id']] = (note, tone, onset)
events.append((note, measure, onset))
absolute += generator.model.fraction(measure['duration'])
attacks = [(n, m, t) for n, m, t in events if n['pitches'] and not n.get('cue') and not n['pitches'][0].get('cue') and not n['pitches'][0].get('tie_from')]
previous, previous_end = '', None
for index, (note, measure, onset) in enumerate(attacks):
tone = note['pitches'][0]
end = onset + generator.model.fraction(tone['duration'])
chain = []
while target := tone.get('tie_to'):
continuation, tone, time = pitches[target]
chain.append(continuation)
end = time + generator.model.fraction(tone['duration'])
following = attacks[index + 1][2] if index + 1 < len(attacks) else None
final = following is None or following > end or any(onset < b <= following for b in boundaries)
if previous_end is None or onset > previous_end or onset in boundaries:
previous = ''
beat, onbeat = pulse(measure, generator.model.fraction(note['onset']))
text = syllable(end - onset, beat, onbeat, final, previous)
values = [{'kind': 'syllabic', 'text': 'single', 'attributes': {}}, {'kind': 'text', 'text': text, 'attributes': {}}]
verse = {'number': '1', 'syllables': values}
if chain:
values.append({'kind': 'extend', 'text': '', 'attributes': {'type': 'start'}})
verse['extend_to'] = generator.model.rational(end)
for position, continuation in enumerate(chain):
continuation['lyrics'] = [{'number': '1', 'syllables': [{'kind': 'extend', 'text': '', 'attributes': {'type': 'stop' if position == len(chain) - 1 else 'continue'}}], 'extend_to': generator.model.rational(end)}]
note['lyrics'] = [verse]
note['lyric'] = text
note['lyric_notation'] = {'text': text, 'syllabic': 'single', 'extend': 'start' if chain else ''}
previous, previous_end = text, end
-10
View File
@@ -5,7 +5,6 @@ import pathlib
import typing
import generator.model
import generator.parts
import generator.score
def _arranged_staff(document: dict[str, typing.Any]) -> dict[str, typing.Any]:
@@ -84,12 +83,3 @@ def validate_expected_performance_summary(expected_path: pathlib.Path, generated
if actual[name] != expected[name]:
raise ValueError(f"performance generator drift in {name}; update generator logic or refresh the expected summary intentionally")
raise ValueError("performance generator drift")
def validate_practice_parts(generated_practice_dir: pathlib.Path) -> None:
if not generated_practice_dir.exists():
return
expected_names = {part + '.json' for part in generator.parts.PART_TYPES}
for exercise_dir in sorted(path for path in generated_practice_dir.iterdir() if path.is_dir()):
json_names = {path.name for path in exercise_dir.glob("*.json")}
if json_names != expected_names:
raise ValueError(f"practice generator drift in {exercise_dir}: expected {sorted(expected_names)}, found {sorted(json_names)}")