This commit is contained in:
khodowany
2026-10-01 16:41:18 -05:00
parent 28fc67fba1
commit f5861fcdf6
22 changed files with 1473 additions and 122 deletions
+155
View File
@@ -0,0 +1,155 @@
"""Audition selected lead stages and accepted controls through the normal output path."""
from __future__ import annotations
import argparse
import copy
import dataclasses
import fractions
import html
import json
import pathlib
import generator.model
import generator.musicxml
import generator.musicxml_writer
import generator.parts
import generator.playback
import generator.scales.timeline
import generator.scales.selection
import generator.solo.audition as audition
import generator.solo.drafts as drafts
import generator.solo.harmonic as harmonic
import generator.solo.notation as notation
import generator.solo.placement as placement
import generator.solo.realization as realization
import generator.solo.references as references
import generator.solo.types as types
F = fractions.Fraction
def document_for(template, notes, tempo, identifier):
placed = placement.place(notes, tempo)
if placed is None:
raise ValueError(identifier + ': no valid guitar placement')
document = copy.deepcopy(template)
notation.replace_region(document, 0, notation.measures(document['staves'][0]['measures'], placed[1], identifier))
generator.model.validate_document(document)
generator.fretboard.validate_document(document)
timeline = generator.playback.compile_timeline(document)
expected = [(n.start, n.end - n.start, n.pitch.number()) for n in types.sounding_notes(placed[1])]
actual = [(generator.model.fraction(e['onset']), generator.model.fraction(e['duration']), generator.model.fraction(e['pitch'])) for e in timeline['events']]
if actual != expected:
raise ValueError(identifier + ': notation/playback changed the sounding melody')
return document
def controls(song, libraries):
"""Literal reference injection is restricted to this diagnostic, never production."""
_, scales, sequences = libraries
reference, = references.load_library(pathlib.Path('data/lead_phrases'))
solo = song['solos']['acoustic']
measures = song['parts']['acoustic']['staves'][0]['measures'][solo['first']:solo['first'] + solo['count']]
choices = generator.scales.selection.select(generator.scales.timeline.read({'measures': measures}), generator.scales.timeline.Solo(F(), F(solo['length']), 'control'), scales)
harmony = harmonic.Harmony(choices)
template = audition.excerpt(song['parts']['acoustic'], solo['first'], 8)
result = []
for label, source in (('plain', reference.notes), ('runs', reference.decorated)):
checked = harmony.evaluate(source, until=reference.duration, complete=True)
if not checked.valid:
raise ValueError('accepted control: ' + checked.error)
theme = types.Motif(reference.id, source, reference.duration, tuple(c for _, c in reference.harmony), 'accepted control')
groups = tuple(types.Transformation(f'control_{i}', reference.id, 'control_0', 'statement' if i == 0 else 'answer', F(i * 16), F((i + 1) * 16), tuple(n.event_id for n in source if i * 16 <= n.start < (i + 1) * 16), tuple((n.start - i * 16, n.end - i * 16) for n in source if i * 16 <= n.start < (i + 1) * 16), preserves=(), source=reference.id) for i in range(2))
final = source[-1]
terminal = types.Anchor('accepted_terminal', final.start, final.start, final.pitch, frozenset({final.pitch.number() % 12}), (55, 79), final.end - final.start, 'terminal', 'Dm', hold_until=final.end)
home = realization.make_identity('control_0', reference.id, source[:len(groups[0].events)], F(), F(16), 'statement', harmony)
draft = types.MelodicDraft('accepted_' + label, F(), reference.duration, source, groups, (final.event_id,), (), (terminal,), home, 'statement', (F(16),), references.identifying_features(source, reference.id), reference.id, theme)
plan = types.PhrasePlan('accepted_' + label, F(), reference.duration, theme, 'statement', 'opening_reference', None, (55, 79), F(9, 8), F(1), passage_id='control', passage_start=F(), passage_end=reference.duration)
incoming = types.ContinuationState('')
phrase = realization.checked_phrase(plan, draft, source, (), incoming, harmony, F(solo['tempo']))
if phrase is None:
raise ValueError('accepted control rejected by phrase validation')
context = realization.SearchContext()
refined = realization.refine(phrase, (phrase,), incoming, harmony, sequences, F(solo['tempo']), context)
elaborated = realization.elaborate(refined, incoming, harmony, sequences, F(solo['tempo']), context)
for stage, notes in (('injected', source), ('processed', realization.emitted_notes(elaborated))):
identifier = 'accepted_' + label + '_' + stage
checked = harmony.evaluate(notes, until=reference.duration, complete=True)
if not checked.valid:
raise ValueError(identifier + ': ' + checked.error)
result.append({'id': identifier, 'document': document_for(template, notes, F(solo['tempo']), identifier), 'attacks': len(notes), 'harmonic_exposure': checked.exposure, 'changed_events': sum((a.start, a.end, a.pitch) != (b.start, b.end, b.pitch) for a, b in zip(source, notes)) + abs(len(source) - len(notes)), 'edits': [e.kind for e in elaborated.edits] if stage == 'processed' else [], 'acceptance': reference.acceptance, 'reference_version': reference.version})
return result
def render(root):
baseline = json.loads((root / 'baseline/snapshot.json').read_text())
current = json.loads((root / 'elaborated/snapshot.json').read_text())
cache, clips = {}, []
gain = 0.4 # Shared audition gain; every mixed clip is checked for clipping.
manifest = {'implementation': current['implementation'], 'baseline_implementation': baseline['implementation'], 'policy': current['policy'], 'shared_gain': gain, 'clips': clips, 'stage_semantics': 'Initial and refined are intermediate versions of the selected production branches, with the same incoming context. They are not independently rerun compositions.'}
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Lead melody comparisons</title><style>body{font:16px system-ui;max-width:1500px;margin:2rem auto;padding:1rem}table{border-collapse:collapse}td,th{padding:.6rem;border-bottom:1px solid #ccc;text-align:left}audio{width:240px}small{display:block}</style><h1>Lead melody comparisons</h1><p>Compare the tune before and after refinement, then hear whether the runs preserve it. All fixed mixes use the original backing, identical tempo and one shared gain. Solo-only clips remove the backing. Initial/refined exports follow the selected production branches; they are not independent searches.</p><p>These metrics verify behavior, not musical quality. Listen for a recognizable tune, prepared destinations, and runs that belong to the tune. <a href="manifest.json">Diagnostics manifest</a>.</p>']
def clip(prefix, foreground, tempo, backing=()):
path = root / (prefix + '.wav')
path.parent.mkdir(parents=True, exist_ok=True)
samples = audition.mix((foreground, *backing), tempo, root, cache)
peak = max(map(abs, samples), default=0) * gain
if peak >= 1:
raise ValueError(prefix + ': shared audition gain would clip')
generator.playback.write_pcm(path, (value * gain for value in samples))
clips.append({'path': path.relative_to(root).as_posix(), 'tempo': str(tempo), 'peak': peak, 'attacks': len(generator.playback.compile_timeline(foreground)['events'])})
return path.relative_to(root).as_posix()
for name, song in current['songs'].items():
old = baseline['songs'][name]
if old['source_sha256'] != song['source_sha256']:
raise ValueError(name + ': source changed during comparison')
for part, solo in song['solos'].items():
other = 'electric' if part == 'acoustic' else 'acoustic'
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part) + '</h2><table><tr><th>Excerpt</th><th>Baseline</th><th>Initial tune</th><th>Refined tune</th><th>With runs</th></tr>')
for window, offset, count in (('opening8', 0, 8), ('closing4', solo['count'] - 4, 4), ('full', 0, solo['count'])):
first = solo['first'] + offset
backing = tuple(audition.excerpt(old['parts'][p], first, count) for p in (other, 'vocal'))
body.append('<tr><th>' + window + '</th>')
for stage in ('baseline', 'initial', 'refined', 'elaborated'):
foreground = audition.excerpt((old if stage == 'baseline' else song)['parts'][part], first, count) if stage in ('baseline', 'elaborated') else audition.excerpt(solo['lead_stages'][stage]['document'], offset, count)
prefix = f'{stage}/{name}_{part}_{window}'
path = clip(prefix + '_fixed', foreground, F(solo['tempo']), backing)
solo_path = clip(prefix + '_solo', foreground, F(solo['tempo']))
generator.musicxml_writer.write_musicxml(root / (prefix + '.musicxml'), foreground)
body.append(f'<td><audio controls preload="none" src="{path}"></audio><small><a href="{solo_path}">Solo only</a> · <a href="{prefix}.musicxml">MusicXML</a></small></td>')
if stage == 'elaborated':
clip(prefix + '_responsive', foreground, F(solo['tempo']), tuple(audition.excerpt(song['parts'][p], first, count) for p in (other, 'vocal')))
body.append('</tr>')
body.append('</table>')
prefix = f'elaborated/{name}_{part}'
(root / (prefix + '.txt')).write_text(solo['report'])
(root / (prefix + '.json')).write_text(json.dumps(solo['structure'], indent=2) + '\n')
body.append(f'<p><a href="{prefix}.txt">Passage report</a> · <a href="{prefix}.json">Anchors, motif lineage, stages and routes</a> · <a href="{prefix}_full_responsive.wav">Full solo with responsive backing</a></p>')
print('Rendered', name, part, flush=True)
song = current['songs']['autumn_leaves']
solo = song['solos']['acoustic']
backing = tuple(audition.excerpt(baseline['songs']['autumn_leaves']['parts'][p], solo['first'], 8) for p in ('electric', 'vocal'))
body.append('<h2>Accepted reference controls</h2><p>The exact accepted notes enter the ordinary validation, guitar placement, notation and playback path. Processed controls additionally run refinement and elaboration. Literal injection is confined to these controls.</p>')
results = controls(song, audition.resources())
manifest['controls'] = [{k: v for k, v in r.items() if k != 'document'} for r in results]
for control in results:
prefix = 'controls/' + control['id']
path = clip(prefix + '_fixed', control['document'], F(solo['tempo']), backing)
single = clip(prefix + '_solo', control['document'], F(solo['tempo']))
generator.musicxml_writer.write_musicxml(root / (prefix + '.musicxml'), control['document'])
body.append(f'<p>{control["id"]}: <audio controls preload="none" src="{path}"></audio> <a href="{single}">Solo only</a> · <a href="{prefix}.musicxml">MusicXML</a></p>')
(root / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
(root / 'index.html').write_text(''.join(body) + '</html>\n')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_lead_auditions'))
action = parser.add_mutually_exclusive_group(required=True)
action.add_argument('--capture', action='store_true')
action.add_argument('--render', action='store_true')
args = parser.parse_args()
if args.capture:
audition.capture(args.output, 'elaborated')
else:
render(args.output)
if __name__ == '__main__':
main()