156 lines
11 KiB
Python
156 lines
11 KiB
Python
"""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 is the selected plain tune; refined adds complete diatonic runs; elaborated adds validated chromatic approaches. All stages share the same underlying selected tune.'}
|
|
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 plain tune, its diatonic runs, and its chromatic approaches. 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>Plain lead</th><th>Diatonic runs</th><th>Chromatic approaches</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_melody_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()
|