Files
product-vamp/generator/solo/audition.py
T
khodowany 59afd78205 Soloing
2026-10-03 10:12:12 -05:00

385 lines
28 KiB
Python

"""Build-only snapshots and like-for-like listening comparisons; no engine presets."""
from __future__ import annotations
import argparse
import array
import collections
import copy
import dataclasses
import fractions
import hashlib
import html
import importlib
import json
import pathlib
import sys
import time
import wave
import generator.arrange.generate
import generator.model
import generator.musicxml
import generator.musicxml_writer
import generator.parts
import generator.playback
import generator.rhythm
import generator.scales.library
import generator.solo.arrangement
import generator.solo.report
import generator.solo.sequences
import generator.solo.realization
import generator.solo.references
import generator.solo.harmonic
STAGES = ('baseline', 'A', 'B', 'C', 'D')
def metrics(solo):
notes = solo.notes()
gaps, cursor, run, runs = [], fractions.Fraction(), 0, []
for note in notes:
if note.start > cursor:
gaps.append((cursor, note.start))
if note.end - note.start == fractions.Fraction(1, 2):
run = run + 1 if note.start == cursor else 1
else:
run = 0
runs.append(run)
cursor = note.end
if cursor < solo.end:
gaps.append((cursor, solo.end))
planned = [(p.start, p.end) for phrase in solo.phrases for p in getattr(phrase.plan, 'passages', ()) if p.intent == 'space']
planned.extend((p.plan.end - p.plan.release, p.plan.end) for p in solo.phrases if p.plan.release)
planned.extend((c.span.start, c.span.end) for c in solo.choices if c.scale is None)
unplanned = []
for start, end in gaps:
fragments = [(start, end)]
for a, b in planned:
fragments = [(x, y) for low, high in fragments for x, y in ((low, min(high, a)), (max(low, b), high)) if y > x]
unplanned.extend(b - a for a, b in fragments)
quoted = sum(n.end - n.start for n in notes if n.role == 'quote')
return {
'phrases': len(solo.phrases), 'attacks': len(notes),
'attacks_per_four_beats': float(4 * len(notes) / solo.end),
'sounding_occupancy': float(sum(n.end - n.start for n in notes) / solo.end),
'longest_unplanned_gap_beats': float(max(unplanned, default=0)),
'longest_connected_eighth_run': max(runs, default=0),
'quotation_coverage': float(quoted / solo.end),
'treatments': dict(collections.Counter(d.treatment for p in solo.phrases for d in p.decisions)),
'search': getattr(solo, 'diagnostics', {}),
}
def fingerprint():
digest = hashlib.sha256()
for path in sorted(pathlib.Path('generator').rglob('*.py')):
digest.update(path.as_posix().encode())
digest.update(path.read_bytes())
return digest.hexdigest()
def resources():
return (generator.rhythm.load_library(pathlib.Path('data/rhythms')), generator.scales.library.load_library(pathlib.Path('data/scales')), generator.solo.sequences.load_library(pathlib.Path('data/sequences')))
def capture(root, stage, libraries=None, contexts=None, *, enclosures_enabled=True):
destination = root / stage
if destination.exists():
raise ValueError(f'{destination}: snapshot already exists; choose a new output root')
rhythms, scales, sequences = libraries or resources()
reference_library = generator.solo.references.load_library(pathlib.Path('data/lead_phrases'))
result = {'enclosures_enabled':enclosures_enabled,'stage': stage, 'implementation': fingerprint(), 'reference_versions': [r.version for r in reference_library], 'songs': {}}
try:
result['policy'] = importlib.import_module('generator.solo.policy').describe()
except ModuleNotFoundError:
result['policy'] = {'baseline': True}
for path in generator.parts.source_paths(pathlib.Path('data/songs')):
source_hash = hashlib.sha256(path.read_bytes()).hexdigest()
checkpoint = root / f'.{stage}_{path.stem}_checkpoint.json'
if checkpoint.exists():
saved = json.loads(checkpoint.read_text())
if saved.get('enclosures_enabled',True)==enclosures_enabled and saved['implementation'] == result['implementation'] and saved.get('reference_versions') == result['reference_versions'] and saved['song']['source_sha256'] == source_hash:
result['songs'][path.stem] = saved['song']
result.setdefault('resumed_songs', []).append(path.stem)
continue
started = time.monotonic()
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
plans = generator.arrange.generate.plan_arrangement(document, rhythms)
parts = {part: generator.arrange.generate._arrange_document(document, part, plans) for part in generator.parts.PART_TYPES}
solos = []
shared = contexts.setdefault(path.stem, {p: generator.solo.realization.SearchContext() for p in ('acoustic', 'electric')}) if contexts is not None else None
if shared:
for context in shared.values():
context.diagnostics = generator.solo.realization.SearchContext().diagnostics
generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, solos=solos, revise=stage != 'phrase', contexts=shared, references=reference_library,enclosures_enabled=enclosures_enabled)
song = {'source_sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'parts': parts, 'solos': {}, 'elapsed_seconds': time.monotonic() - started}
regions = generator.solo.arrangement.regions(document)
for solo, index in zip(solos, (3, 4)):
first, count, offset, length = regions[index]
song['solos'][solo.part] = {'first': first, 'count': count, 'offset': str(offset), 'length': str(length), 'tempo': str(solo.tempo), 'metrics': metrics(solo), 'report': generator.solo.report.describe(solo, scales), 'structure': generator.solo.report.structure(solo)}
drafts = []
template = parts[solo.part]['staves'][0]['measures'][first:first + count]
boundaries = [fractions.Fraction()]
for measure in template:
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
for phrase in solo.phrases:
if not phrase.draft:
continue
begin = max(i for i,boundary in enumerate(boundaries) if boundary <= phrase.plan.start)
stop = next(i for i, boundary in enumerate(boundaries) if boundary >= phrase.plan.end)
view = excerpt(parts[solo.part], first + begin, stop - begin)
local = tuple(dataclasses.replace(n, start=n.start - boundaries[begin], end=n.end - boundaries[begin], continuation=False if i == 0 else n.continuation) for i, n in enumerate(phrase.draft.notes))
placed = generator.solo.placement.place(local, solo.tempo)
if placed is None:
raise ValueError(f'{phrase.plan.id}: draft cannot be placed for independent audition')
foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], 'draft_' + phrase.plan.id)
generator.solo.notation.replace_region(view, 0, foreground)
generator.model.validate_document(view)
generator.fretboard.validate_document(view)
drafts.append({'id': phrase.plan.id, 'draft_id': phrase.draft.id, 'first': begin, 'count': stop - begin, 'document': view})
song['solos'][solo.part]['draft_documents'] = drafts
stages = {}
for stage_name in ('initial', 'refined', 'before_enclosure'):
if not all(getattr(p, stage_name) for p in solo.phrases):
continue
notes = tuple(n for p in solo.phrases for n in getattr(p, stage_name).notes)
placed = generator.solo.placement.place(notes, solo.tempo)
if placed is None:
raise ValueError(stage_name + ': selected theme cannot be placed')
sounded = generator.solo.types.sounding_notes(placed[1])
contracts = tuple(dict.fromkeys(t for p in solo.phrases for t in p.state.treatments))
checked = generator.solo.harmonic.Harmony(solo.choices).evaluate(sounded, contracts, until=solo.end, complete=True)
if not checked.valid and stage_name != 'initial':
raise ValueError(stage_name + ': ' + checked.error)
view = excerpt(parts[solo.part], first, count)
foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], stage_name + '_' + solo.part)
generator.solo.notation.replace_region(view, 0, foreground)
generator.model.validate_document(view)
generator.fretboard.validate_document(view)
stages[stage_name] = {'document': view, 'attacks': len(sounded), 'occupancy': float(sum(n.end - n.start for n in sounded) / solo.end), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error}
song['solos'][solo.part]['lead_stages'] = stages
print(path.stem, solo.part, json.dumps({k: v for k, v in song['solos'][solo.part]['metrics'].items() if k != 'search'}), flush=True)
result['songs'][path.stem] = song
root.mkdir(parents=True, exist_ok=True)
checkpoint.write_text(json.dumps({'enclosures_enabled':enclosures_enabled,'implementation': result['implementation'], 'reference_versions': result['reference_versions'], 'song': song}) + '\n')
destination.mkdir(parents=True)
(destination / 'snapshot.json').write_text(json.dumps(result, indent=2) + '\n')
def excerpt(document, first, count):
result = copy.deepcopy(document)
staff = result['staves'][0]
staff['measures'] = staff['measures'][first:first + count]
notes = [n for m in staff['measures'] for v in m['voices'] for n in v['notes']]
identities = {p['id'] for n in notes for p in n['pitches']}
events = {n['id'] for n in notes}
for note in notes:
for pitch in note['pitches']:
for key in ('tie_from', 'tie_to'):
if pitch.get(key) not in identities:
pitch.pop(key, None)
staff['tuplets'] = [t for t in staff.get('tuplets', []) if t['start'] in events and t['end'] in events]
tuplets = {t['id'] for t in staff['tuplets']}
for note in notes:
if 'tuplet_ids' in note:
note['tuplet_ids'] = [i for i in note['tuplet_ids'] if i in tuplets]
offset = sum(generator.model.fraction(m['duration']) for m in document['staves'][0]['measures'][:first])
end = offset + sum(generator.model.fraction(m['duration']) for m in staff['measures'])
for key in ('scale_spans', 'octave_shifts'):
staff[key] = [{**s, 'start': generator.model.rational(max(offset, generator.model.fraction(s['start'])) - offset), 'end': generator.model.rational(min(end, generator.model.fraction(s['end'])) - offset)} for s in staff.get(key, []) if generator.model.fraction(s['start']) < end and generator.model.fraction(s['end']) > offset]
generator.model.validate_document(result)
return result
def mix(documents, tempo, root, cache):
timelines = [generator.playback.compile_timeline(document) for document in documents]
rate = generator.playback.SAMPLE_RATE
factor = fractions.Fraction(60 * rate, tempo)
frames = round(max(generator.model.fraction(t['duration']) for t in timelines) * factor)
output = array.array('f', [0]) * (frames + 2400)
for index, timeline in enumerate(timelines):
gain = 1.0 if index == 0 else 0.55
for event in timeline['events']:
pitch = generator.model.fraction(event['pitch'])
if pitch not in cache:
sample = generator.playback.generate_tone(root, pitch)
with wave.open(str(root / sample['path'])) as audio:
values = array.array('h', audio.readframes(audio.getnframes()))
if sys.byteorder != 'little':
values.byteswap()
cache[pitch] = sample, values
sample, values = cache[pitch]
start = round(generator.model.fraction(event['onset']) * factor)
end = round((generator.model.fraction(event['onset']) + generator.model.fraction(event['duration'])) * factor)
for frame in range(start, min(len(output), end + 2400)):
age = frame - start
position = age if age < sample['loop_end'] else sample['loop_start'] + (age - sample['loop_start']) % (sample['loop_end'] - sample['loop_start'])
release = 1 if frame < end else (1 - (frame - end) / 2399) ** 2
output[frame] += gain * release * values[position] / 32767
return output
def render(root):
stages = ('baseline', 'phrase', 'revised') if (root / 'revised' / 'snapshot.json').exists() else ('before', 'after') if (root / 'before' / 'snapshot.json').exists() else STAGES
snapshots = {stage: json.loads((root / stage / 'snapshot.json').read_text()) for stage in stages}
cache, clips, manifest = {}, [], {'sample_rate': generator.playback.SAMPLE_RATE, 'stages': {}, 'clips': []}
for stage, snapshot in snapshots.items():
manifest['stages'][stage] = {k: v for k, v in snapshot.items() if k != 'songs'}
for song_name, song in snapshot['songs'].items():
baseline = snapshots[stages[0]]['songs'][song_name]
if song['source_sha256'] != baseline['source_sha256']:
raise ValueError(f'{song_name}: source changed between audition stages')
for part, solo in song['solos'].items():
other = 'electric' if part == 'acoustic' else 'acoustic'
windows = [('full', 0, solo['count']), ('opening8', 0, min(8, solo['count'])), ('closing4', max(0, solo['count'] - 4), min(4, solo['count']))] if stages[0] == 'before' or stages[-1] == 'revised' else [('', 0, min(8, solo['count']))]
for label, offset, count in windows:
first = solo['first'] + offset
foreground = excerpt(song['parts'][part], first, count)
prefix = root / stage / (f'{song_name}_{part}' + ('_' + label if label else ''))
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), foreground)
prefix.with_suffix('.txt').write_text(solo['report'])
if 'structure' in solo:
prefix.with_suffix('.json').write_text(json.dumps(solo['structure'], indent=2) + '\n')
for responsive in [False, True] if stage == stages[-1] else [False]:
backing = excerpt((song if responsive else baseline)['parts'][other], first, count)
vocal = excerpt(baseline['parts']['vocal'], first, count)
path = prefix.with_name(prefix.name + ('_responsive' if responsive else '_fixed')).with_suffix('.wav')
samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)
clips.append((path, samples))
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'window': label, 'first': offset, 'count': count, 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']})
if stage == 'after' and 'before_enclosure' in solo.get('lead_stages', {}):
strict = excerpt(solo['lead_stages']['before_enclosure']['document'], offset, count)
strict_prefix=prefix.with_name(prefix.name+'_strict')
generator.musicxml_writer.write_musicxml(strict_prefix.with_suffix('.musicxml'),strict)
backing=excerpt(baseline['parts'][other],first,count)
vocal=excerpt(baseline['parts']['vocal'],first,count)
path=strict_prefix.with_name(strict_prefix.name+'_fixed').with_suffix('.wav')
clips.append((path,mix((strict,backing,vocal),fractions.Fraction(solo['tempo']),root,cache)))
manifest['clips'].append({'path':path.relative_to(root).as_posix(),'kind':'same_selected_theme_before_enclosures','window':label})
if label=='full':
path=strict_prefix.with_name(strict_prefix.name+'_solo').with_suffix('.wav')
clips.append((path,mix((strict,),fractions.Fraction(solo['tempo']),root,cache)))
manifest['clips'].append({'path':path.relative_to(root).as_posix(),'kind':'strict_unaccompanied','window':label})
if stage == 'after' and 'refined' in solo.get('lead_stages', {}):
plain = excerpt(solo['lead_stages']['refined']['document'], offset, count)
plain_prefix = prefix.with_name(prefix.name + '_plain')
generator.musicxml_writer.write_musicxml(plain_prefix.with_suffix('.musicxml'), plain)
backing = excerpt(baseline['parts'][other], first, count)
vocal = excerpt(baseline['parts']['vocal'], first, count)
path = plain_prefix.with_name(plain_prefix.name + '_fixed').with_suffix('.wav')
clips.append((path, mix((plain, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)))
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'plain_theme', 'window': label})
if label == 'full':
for kind, document in (('plain', plain), ('elaborated', foreground)):
path = prefix.with_name(prefix.name + '_' + kind + '_solo').with_suffix('.wav')
clips.append((path, mix((document,), fractions.Fraction(solo['tempo']), root, cache)))
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': kind + '_unaccompanied', 'window': label})
for draft in solo.get('draft_documents', ()):
prefix = root / stage / f'{song_name}_{part}_draft_{draft["id"]}'
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), draft['document'])
if draft['first'] < 8:
backing = excerpt(baseline['parts'][other], solo['first'] + draft['first'], draft['count'])
path = prefix.with_suffix('.wav')
clips.append((path, mix((draft['document'], backing), fractions.Fraction(solo['tempo']), root, cache)))
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'simple_phrase_draft', 'draft_id': draft['draft_id'], 'first': draft['first'], 'count': draft['count']})
if stage in ('phrase', 'revised'):
neutral = excerpt(song['parts'][part], solo['first'], min(8, solo['count']))
for measure in neutral['staves'][0]['measures']:
for voice in measure['voices']:
for event in voice['notes']:
for pitch in event['pitches']:
pitch.update({'letter': 'c', 'alter': generator.model.rational(0), 'octave': 5, 'tablature': {'string': 2, 'fret': 1}})
path = root / stage / f'{song_name}_{part}_rhythm_only.wav'
clips.append((path, mix((neutral,), fractions.Fraction(solo['tempo']), root, cache)))
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'rhythm_only_diagnostic', 'valid_harmonic_solo': False})
# One common attenuation preserves relative loudness and identical backing.
peak = max(abs(value) for _, samples in clips for value in samples)
gain = min(1, 0.95 / peak) if peak else 1
manifest['shared_gain'] = gain
for path, samples in clips:
generator.playback.write_pcm(path, (value * gain for value in samples))
(root / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
write_phrase_index(root, snapshots) if stages[-1] == 'revised' else write_passage_index(root, snapshots) if stages[0] == 'before' else write_index(root, snapshots)
print(f'Rendered {len(clips)} comparisons under {root}', flush=True)
def write_phrase_index(root, snapshots):
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Melodic phrase comparisons</title><style>body{font:16px system-ui;max-width:1250px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin-bottom:2rem}td,th{text-align:left;padding:.7rem;border-bottom:1px solid #ccd1c9}audio{width:250px}small{display:block}</style><h1>Melodic phrase comparisons</h1><p>Compare the current baseline, complete phrase construction, and phrase construction with two-sided join revision. Tempo, scale policy, activity targets and seed inputs are unchanged. Fixed mixes use baseline accompaniment and one shared gain. Revised mixes also include the normal responsive backing.</p><p>Draft pools are shared when incoming musical states match; earlier edits can legitimately change later incoming states. Pool fingerprints are recorded in the reports. Simple drafts are auditioned individually, with a held entrance restarted at the excerpt boundary. Rhythm-only clips use a neutral pitch and are not valid harmonic solos.</p><p>Listen for one recognizable idea developing toward a prepared destination. Metrics do not establish musical improvement. <a href="manifest.json">Manifest and diagnostics</a>.</p>']
for name, song in snapshots['revised']['songs'].items():
for part in song['solos']:
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part.title()) + '</h2>')
body.append('<table><tr><th>Excerpt</th><th>Baseline</th><th>Phrase construction</th><th>With join revision</th></tr>')
for window in ('opening8', 'full', 'closing4'):
body.append(f'<tr><th>{window}</th>')
for stage in ('baseline', 'phrase', 'revised'):
prefix = f'{stage}/{name}_{part}_{window}'
body.append(f'<td><audio controls preload="none" src="{prefix}_fixed.wav"></audio><small><a href="{prefix}.musicxml">MusicXML</a> · <a href="{prefix}.txt">Report</a> · <a href="{prefix}.json">Structure</a></small></td>')
body.append('</tr>')
body.append('</table>')
for stage in ('phrase', 'revised'):
detail = snapshots[stage]['songs'][name]['solos'][part]
metric = detail['metrics']
body.append(f'<p>{stage}: {metric["attacks_per_four_beats"]:.2f} attacks / four beats; {metric["sounding_occupancy"]:.1%} occupancy. Rhythm only: <audio controls preload="none" src="{stage}/{name}_{part}_rhythm_only.wav"></audio></p>')
for draft in detail.get('draft_documents', ()):
if draft['first'] < 8:
prefix = f'{stage}/{name}_{part}_draft_{draft["id"]}'
body.append(f'<p>Simple draft {draft["id"]}: <audio controls preload="none" src="{prefix}.wav"></audio> <a href="{prefix}.musicxml">MusicXML</a></p>')
body.append(f'<p>Complete solo with responsive backing: <audio controls preload="none" src="revised/{name}_{part}_full_responsive.wav"></audio></p>')
(root / 'index.html').write_text(''.join(body) + '</html>\n')
def write_index(root, snapshots):
labels = {'baseline': 'Original engine', 'A': 'Scoring repair', 'B': 'Activity shapes', 'C': 'Motifs and retrieval', 'D': 'Phrase lookahead'}
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Solo comparisons</title><style>body{font:16px system-ui;max-width:1200px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin:1rem 0 3rem}td,th{text-align:left;padding:1rem;border-bottom:1px solid #ccd1c9}audio{width:280px}small{display:block;margin-top:.5rem}</style><h1>Solo comparisons</h1><p>Eight bars from the same solo entrance at the source tempo. All stages use the original backing and one shared mix gain. D also includes the normal backing that responds to the new solo.</p><p>A: scoring and register. B: activity and breathing. C: transformation contracts, contextual motifs and diverse paths. D: one-phrase lookahead. Expressive playback is deferred.</p><p>Metrics below describe the complete solo, not just the excerpt. These are diagnostics; listening and playing determine musical quality. <a href="manifest.json">Build fingerprints and diagnostics</a>.</p>']
for name, song in snapshots['D']['songs'].items():
for part in song['solos']:
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part.title()) + '</h2><table><tr><th>Stage</th><th>Same backing</th><th>Complete solo</th></tr>')
for stage in STAGES:
prefix = stage + '/' + name + '_' + part
metrics = snapshots[stage]['songs'][name]['solos'][part]['metrics']
stats = f"{metrics['attacks_per_four_beats']:.2f} attacks / four beats<br>{metrics['sounding_occupancy']:.0%} sounding occupancy<br>{metrics['longest_connected_eighth_run']} connected eighths<br>{metrics['longest_unplanned_gap_beats']:g}-beat longest unplanned gap"
body.append(f'<tr><td>{stage}: {labels[stage]}<small><a href="{prefix}.musicxml">Notation</a> · <a href="{prefix}.txt">Phrase report</a></small></td><td><audio controls preload="none" src="{prefix}_fixed.wav"></audio></td><td>{stats}</td></tr>')
body.append(f'</table><p>D with responsive backing: <audio controls preload="none" src="D/{name}_{part}_responsive.wav"></audio></p>')
(root / 'index.html').write_text(''.join(body) + '</html>\n')
def write_passage_index(root, snapshots):
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Connected solo passages</title><style>body{font:16px system-ui;max-width:1250px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin:1rem 0 3rem}td,th{text-align:left;padding:.75rem;border-bottom:1px solid #ccd1c9}audio{width:260px}small{display:block;margin-top:.5rem}</style><h1>Connected solo passages</h1><p>Before and after use the same source songs, scale-selection settings, activity targets, tempo and root seed inputs. Fixed comparisons retain the original backing. Responsive comparisons include accompaniment recomposed around the new foreground. The returning vocal pickup and one shared mix gain are preserved.</p><p>Listen for a convincing settlement on the current chord, an idea developing across several bars, intermediate arrivals that continue, and a prepared ending. Metrics alone do not establish musical improvement. <a href="manifest.json">Fingerprints and diagnostics</a>.</p>']
for name, song in snapshots['after']['songs'].items():
for part in song['solos']:
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part.title()) + '</h2>')
for stage in ('before', 'after'):
metric = snapshots[stage]['songs'][name]['solos'][part]['metrics']
body.append(f"<p>{stage.title()}: {metric['attacks_per_four_beats']:.2f} attacks / four beats; {metric['sounding_occupancy']:.1%} occupancy; longest unplanned gap {metric['longest_unplanned_gap_beats']:g} beats.</p>")
strict_available='before_enclosure' in song['solos'][part].get('lead_stages',{})
plain_available='refined' in song['solos'][part].get('lead_stages',{})
body.append('<table><tr><th>Passage</th><th>Before, fixed backing</th><th>After, fixed backing</th><th>After, responsive backing</th>' + ('<th>Same selected solo, without enclosures</th>' if strict_available else '') + ('<th>Plain theme, fixed backing</th>' if plain_available else '') + '</tr>')
for window in ('full', 'opening8', 'closing4'):
body.append(f'<tr><th>{window}</th>')
for stage, mix_name in (('before', 'fixed'), ('after', 'fixed'), ('after', 'responsive')):
prefix = stage + '/' + name + '_' + part + '_' + window
body.append(f'<td><audio controls preload="none" src="{prefix}_{mix_name}.wav"></audio><small><a href="{prefix}.musicxml">MusicXML</a> · <a href="{prefix}.txt">Report</a>' + (f' · <a href="{prefix}.json">Passage data</a>' if stage == 'after' else '') + '</small></td>')
if strict_available:
prefix=f'after/{name}_{part}_{window}_strict'
body.append(f'<td><audio controls preload="none" src="{prefix}_fixed.wav"></audio><small><a href="{prefix}.musicxml">Strict notation</a></small></td>')
if plain_available:
prefix=f'after/{name}_{part}_{window}_plain'
body.append(f'<td><audio controls preload="none" src="{prefix}_fixed.wav"></audio><small><a href="{prefix}.musicxml">Plain theme notation</a></small></td>')
body.append('</tr>')
body.append('</table>')
if plain_available:
prefix=f'after/{name}_{part}_full'
body.append(f'<p>Unaccompanied plain theme: <audio controls preload="none" src="{prefix}_plain_solo.wav"></audio> Elaborated: <audio controls preload="none" src="{prefix}_elaborated_solo.wav"></audio></p>')
(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_auditions'))
action = parser.add_mutually_exclusive_group(required=True)
action.add_argument('--capture', choices=(*STAGES, 'before', 'after', 'phrase', 'revised'))
action.add_argument('--compare-phrases', action='store_true', help='capture both new stages using shared concrete draft pools')
action.add_argument('--render', action='store_true')
parser.add_argument('--no-enclosures',action='store_true',help='retain the identical selected strict melody before ornamentation')
args = parser.parse_args()
if args.compare_phrases:
libraries, contexts = resources(), {}
capture(args.output, 'phrase', libraries, contexts)
capture(args.output, 'revised', libraries, contexts)
elif args.capture:
capture(args.output, args.capture,enclosures_enabled=not args.no_enclosures)
else:
render(args.output)
if __name__ == '__main__':
main()