Accompaniment
ci / checks (push) Successful in 49m53s

This commit is contained in:
khodowany
2026-10-06 01:59:55 -05:00
parent 04bb44b9d0
commit df83cae38c
14 changed files with 916 additions and 156 deletions
+27 -6
View File
@@ -25,6 +25,7 @@ import generator.musicxml
import generator.parts
import generator.score
import generator.vocabulary
import generator.arrange.accompaniment
import generator.arrange.form
import generator.arrange.generate
import generator.arrange.pickup
@@ -62,6 +63,12 @@ class SourceAnalysis:
return cls(json.dumps(prepared, sort_keys=True), key)
def document(self):
return json.loads(self.document_json)
def accompaniment_key(self):
document = self.document()
document['metadata'].pop('tempo', None)
for measure in generator.score.source_staff(document)['measures']:
measure['tempos'] = []
return SourceAnalysis.read(document).musical_key
def windows(document, part):
source = generator.score.source_staff(document)['measures']
@@ -102,7 +109,9 @@ def _code_key(role):
paths += list((root / 'solo').glob('*.py'))
paths += [root / 'harmonic_time.py', root / 'musicxml.py', root / 'training_io.py']
elif role.endswith('_accompaniment'):
paths += [root / n for n in ('comping.py', 'accompaniment.py', 'guitar.py')]
paths += [root / n for n in ('comping.py', 'comping_rhythm.py', 'accompaniment.py', 'guitar.py')]
if role == 'acoustic_accompaniment':
paths += [root / 'acoustic_voicing.py', root / 'acoustic.py']
elif role == 'electric_melody':
paths += [root / 'guitar.py', root / 'melody.py']
return generator.vocabulary.fingerprint([(p.relative_to(root).as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted(set(paths))])
@@ -199,7 +208,8 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
part = role.split('_')[0]
trace = {'source': source.musical_key, 'role': role, 'production_resources': inputs['catalogs'],
'vocabulary': generator.vocabulary.fingerprint(inputs['vocabulary']), 'settings': settings, 'algorithm': algorithm, 'code': inputs['code'], 'cache_key': key}
seed = generator.vocabulary.fingerprint({'source': source.musical_key, 'role': role, 'settings': settings, 'data': inputs['vocabulary'], 'version': algorithm})
choice_source = source.accompaniment_key() if role.endswith('_accompaniment') else source.musical_key
seed = generator.vocabulary.fingerprint({'source': choice_source, 'role': role, 'settings': settings, 'data': inputs['vocabulary'], 'version': algorithm})
details = {}
solo_report = ''
composition_timings = {}
@@ -237,7 +247,7 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
elif role.endswith('_accompaniment'):
canonical = generator.comping.Canonical(document, part, seed)
result = generator.arrange.generate._arrange_document(document, part, {}, owned={'accompaniment'}, accompaniment=canonical)
details = {'canonical_comping': canonical.report}
details = {'canonical_comping': canonical.report, 'accompaniment_rhythm': canonical.rhythm_report, 'rhythm_source': choice_source}
else:
result = generator.arrange.generate._arrange_document(document, part, {}, owned={'vocal' if part == 'vocal' else 'melody'})
allowed = tuple((a, b) for a, b, r in windows(document, part) if r == role)
@@ -282,16 +292,24 @@ def assemble(source, results):
staff[field].extend(copy.deepcopy(other['staves'][0].get(field, [])))
for i, m in enumerate(staff['measures']):
# Tuplet rests belong to the source/role rhythm and must survive union.
sounding = [copy.deepcopy(n) for d in selected for n in generator.score.measure_notes(d['staves'][0]['measures'][i]) if n['pitches'] or n.get('tuplet_ids')]
m['voices'] = [{'id': 'voice_arranged', 'notes': _fill(sounding, generator.model.fraction(m['duration']), m['id'])}]
lanes = {}
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')]
if sounding:
lanes.setdefault(lane, []).extend(sounding)
lanes = lanes or {'voice_arranged': []}
m['voices'] = [{'id': lane, 'notes': _fill(notes, generator.model.fraction(m['duration']), m['id'] + '_' + lane, lane)} for lane, notes in lanes.items()]
# Attribute changes belong to source melody pickups and are assembly metadata.
for d in selected:
other = d['staves'][0]['measures'][i]
if other.get('attributes'):
m['attributes'] = copy.deepcopy(other['attributes'])
staff['tuplets'] = generator.arrange.pickup.retain_links(staff['measures'], staff['tuplets'])
owners = {n['id']: v['id'] for m in staff['measures'] for v in m['voices'] for n in v['notes']}
for span in staff['tuplets']:
span['voice'] = 'voice_arranged'
span['voice'] = owners[span['start']]
generator.model.validate_document(document)
generator.fretboard.validate_document(document)
documents[part] = document
@@ -336,6 +354,7 @@ def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=Fa
raise ValueError(f'{path.name}/{role}: {error}') from error
for path, (original, source) in sources.items():
assembled = assemble(source, results[path])
reuse = generator.arrange.accompaniment.audit(original, assembled)
for part, document in assembled.items():
document['metadata'] = copy.deepcopy(original['metadata'])
document['metadata']['song_id'] = generator.score.slugify(path.stem)
@@ -345,5 +364,7 @@ def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=Fa
(analysis_dir / (generator.score.slugify(path.stem) + '.txt')).write_text('\n'.join(results[path][r]['report'] for r in ROLES))
# Not a score file: diagnostics are deliberately outside the songs schema.
stable = {role: {k: v for k, v in value.items() if k not in ('seconds', 'composition_timings', 'cache_hit', 'observed_reads')} for role, value in results[path].items()}
for part, audit in reuse.items():
stable[part + '_accompaniment']['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