211 lines
12 KiB
Python
211 lines
12 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 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
|
|
|
|
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 capture(root, stage):
|
|
destination = root / stage
|
|
if destination.exists():
|
|
raise ValueError(f'{destination}: snapshot already exists; choose a new output root')
|
|
rhythms = generator.rhythm.load_library(pathlib.Path('data/rhythms'))
|
|
scales = generator.scales.library.load_library(pathlib.Path('data/scales'))
|
|
sequences = generator.solo.sequences.load_library(pathlib.Path('data/sequences'))
|
|
result = {'stage': stage, 'implementation': fingerprint(), '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')):
|
|
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 = []
|
|
generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, solos=solos)
|
|
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)}
|
|
print(path.stem, solo.part, json.dumps(song['solos'][solo.part]['metrics']), flush=True)
|
|
result['songs'][path.stem] = song
|
|
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):
|
|
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['baseline']['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'
|
|
first, count = solo['first'], min(8, solo['count'])
|
|
foreground = excerpt(song['parts'][part], first, count)
|
|
prefix = root / stage / f'{song_name}_{part}'
|
|
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), foreground)
|
|
prefix.with_suffix('.txt').write_text(solo['report'])
|
|
for responsive in [False, True] if stage == 'D' else [False]:
|
|
backing = excerpt((song if responsive else baseline)['parts'][other], first, count)
|
|
path = prefix.with_name(prefix.name + ('_responsive' if responsive else '_fixed')).with_suffix('.wav')
|
|
samples = mix((foreground, backing), fractions.Fraction(solo['tempo']), root, cache)
|
|
clips.append((path, samples))
|
|
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']})
|
|
# 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_index(root, snapshots)
|
|
print(f'Rendered {len(clips)} comparisons under {root}', flush=True)
|
|
|
|
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 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)
|
|
action.add_argument('--render', action='store_true')
|
|
args = parser.parse_args()
|
|
capture(args.output, args.capture) if args.capture else render(args.output)
|
|
|
|
if __name__ == '__main__':
|
|
main()
|