Toggle Solos
ci / checks (push) Failing after 24m18s

This commit is contained in:
khodowany
2026-10-08 22:11:34 -05:00
parent 1bacfd0f33
commit 150394d598
31 changed files with 8707 additions and 372 deletions
+22 -3
View File
@@ -4,11 +4,13 @@ import copy
import fractions
import generator.accompaniment
import generator.arrange.form
import generator.arrange.pickup
import generator.composition_types
import generator.fretboard
import generator.model
import generator.parts
import generator.score
import generator.validation
def mask(document, allowed):
@@ -58,11 +60,28 @@ def fill(notes, length, prefix, voice='voice_arranged'):
rests(length)
return result
def assemble(source, results):
def improvisation(source, template, part):
"""Fill the source's solo allocation, including pickups, with beat slashes."""
document = copy.deepcopy(template)
staff = document['staves'][0]
for field in ('tuplets', 'octave_shifts', 'scale_spans'):
staff[field] = []
for measure in staff['measures']:
measure['id'] = part + '_improv_' + measure['id']
measure['voices'] = [{'id': 'voice_arranged', 'notes': generator.parts.generated_voice(measure, 'slash')}]
generator.score.reidentify(staff['measures'], [])
plan = generator.arrange.form.FormPlan.read(source.document(), part)
mask(document, [(a, b) for a, b, role in plan.windows if role == part + '_solo'])
return document
def assemble(source, results, *, solos_enabled=True):
"""Union prevalidated role windows; no selection or pitch/fingering rewrite."""
documents = {}
for part in generator.parts.PART_TYPES:
selected = [results[r]['document'] for r in generator.composition_types.ROLES if r.startswith(part + '_')]
selected = [results[r]['document'] for r in generator.composition_types.ROLES
if r.startswith(part + '_') and (solos_enabled or not r.endswith('_solo'))]
if not solos_enabled and part != 'vocal':
selected.append(improvisation(source, selected[0], part))
document = copy.deepcopy(selected[0])
staff = document['staves'][0]
if part == 'vocal':
@@ -80,7 +99,7 @@ def assemble(source, results):
for d in selected:
for voice in d['staves'][0]['measures'][i]['voices']:
lane = voice['id'] if part == 'acoustic' and voice['id'] in ('upper', 'bass') else 'voice_arranged'
sounding = [copy.deepcopy(n) for n in voice['notes'] if n['pitches'] or n.get('tuplet_ids')]
sounding = [copy.deepcopy(n) for n in voice['notes'] if n['pitches'] or n.get('tuplet_ids') or n.get('notehead') == 'slash']
if sounding:
lanes.setdefault(lane, []).extend(sounding)
lanes = lanes or {'voice_arranged': []}
+15 -2
View File
@@ -45,10 +45,21 @@ def metadata(path: pathlib.Path, root: pathlib.Path) -> dict:
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')
solos_enabled = meta.get('solos_enabled', True)
if type(solos_enabled) is not bool:
raise ValueError('solos_enabled must be a boolean')
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'])
measure_count=len(staff['measures']), total_pages=len(presentation['pages']), title_image=title_image, part=staff['name'], solos_enabled=int(solos_enabled))
VARIANT_ERRORS = """
SELECT json_extract(source_json, '$.music.metadata.song_id'), part FROM songs
WHERE json_extract(source_json, '$.music.staves[0].arranged') = 1
GROUP BY json_extract(source_json, '$.music.metadata.song_id'), part
HAVING (part IN ('Acoustic', 'Electric') AND (count(*) != 2 OR sum(solos_enabled) != 1))
OR (part = 'Vocal' AND (count(*) != 1 OR sum(solos_enabled) != 1))
"""
def populate(connection: sqlite3.Connection, root: pathlib.Path, audio_assets=None) -> None:
if not root.is_dir():
@@ -70,7 +81,7 @@ def populate(connection: sqlite3.Connection, root: pathlib.Path, audio_assets=No
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(f'CREATE TABLE songs ({SONG_COLUMNS}, part TEXT NOT NULL, solos_enabled INTEGER NOT NULL CHECK(solos_enabled IN (0, 1))) 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)))
@@ -82,6 +93,8 @@ def populate(connection: sqlite3.Connection, root: pathlib.Path, audio_assets=No
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(VARIANT_ERRORS).fetchall():
raise ValueError('missing or duplicate solo variants; regenerate score assets')
if connection.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
raise ValueError('catalog integrity check failed')
+2
View File
@@ -194,6 +194,8 @@ def part_header(document, fonts, style):
raise generator.engraving.types.EngravingError('score title cannot fit the page without clipping')
result = [item]
subtitle = ' - '.join(str(value) for value in (document['metadata'].get('composer'), document['staves'][0].get('name')) if value)
if document['metadata'].get('solos_enabled') is False:
subtitle += ' - Improvisation'
if not subtitle:
return result
credit = fonts.text(subtitle, 0, style.margin_y + size, 12, 'subtitle', ())
+1 -1
View File
@@ -20,7 +20,7 @@ TITLE_HEADER = struct.pack('<4sHH', b'VPTI', TITLE_WIDTH, TITLE_HEIGHT)
TITLE_BYTES = len(TITLE_HEADER) + TITLE_WIDTH * TITLE_HEIGHT // 2
MAX_TITLES = 8192
LABELS = ('VAMP', 'CONNECTING', 'OFFLINE', 'BUSY', 'READY', 'LOADING', 'WIFI', 'ERROR', 'PAGE', 'Acoustic', 'Electric', 'Vocal', 'UNAVAILABLE',
'Main Menu', 'Performance', 'Settings', 'Backing', 'Metronome', 'Tempo', 'Back', 'On', 'Off', 'BPM', 'Manual', 'Preparing', 'Count-in', 'Playing', 'Sustain', 'Hold to exit')
'Main Menu', 'Performance', 'Settings', 'Backing', 'Metronome', 'Solos', 'Tempo', 'Back', 'On', 'Off', 'BPM', 'Manual', 'Preparing', 'Count-in', 'Playing', 'Sustain', 'Hold to exit')
SPRITES = LABELS + tuple('0123456789/')
DIGIT_WIDTH = 22
DEFAULT_FONT_ROOT = pathlib.Path(__file__).resolve().parents[1] / 'package/assets/fonts/petaluma'
+11 -7
View File
@@ -53,13 +53,17 @@ def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=Fa
except Exception as error:
raise ValueError(f'{path.name}/{role}: {error}') from error
for path, (original, source) in sources.items():
assembled = generator.arrange.assembly.assemble(source, results[path])
generator.arrange.accompaniment.audit(original, assembled)
reuse = generator.arrange.accompaniment.audit(original, {role: value['document'] for role, value in results[path].items()}, role_documents=True)
for part, document in assembled.items():
document['metadata'] = copy.deepcopy(original['metadata'])
document['metadata']['song_id'] = generator.score.slugify(path.stem)
generator.score_io.write_json(output_dir / (generator.score.slugify(path.stem) + '_' + part + '.json'), document)
for solos_enabled in (True, False):
assembled = generator.arrange.assembly.assemble(source, results[path], solos_enabled=solos_enabled)
generator.arrange.accompaniment.audit(original, assembled)
for part, document in assembled.items():
if part == 'vocal' and not solos_enabled:
continue
document['metadata'] = copy.deepcopy(original['metadata'])
document['metadata'].update(song_id=generator.score.slugify(path.stem), solos_enabled=solos_enabled)
suffix = '' if solos_enabled else '_improv'
generator.score_io.write_json(output_dir / (generator.score.slugify(path.stem) + '_' + part + suffix + '.json'), document)
analysis_dir = output_dir.parent / 'analysis'
analysis_dir.mkdir(parents=True, exist_ok=True)
(analysis_dir / (generator.score.slugify(path.stem) + '.txt')).write_text('\n'.join(results[path][r]['report'] for r in generator.composition_types.ROLES))
@@ -68,4 +72,4 @@ def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=Fa
for role, audit in reuse.items():
stable[role]['details']['reuse_audit'] = audit
(analysis_dir / (generator.score.slugify(path.stem) + '.roles')).write_text(json.dumps(stable, sort_keys=True, indent=2))
return len(sources) * 3
return len(sources) * 5