246 lines
14 KiB
Python
246 lines
14 KiB
Python
"""Explicit post-generation comparison. Never imports or runs a composer."""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import array
|
||
import collections
|
||
import html
|
||
import json
|
||
import os
|
||
import sys
|
||
import wave
|
||
from fractions import Fraction as F
|
||
from pathlib import Path
|
||
|
||
from audition.reference_review import read
|
||
from generator import fretboard
|
||
from generator.solo import placement, types
|
||
|
||
|
||
def events(review):
|
||
offset = F(review['alignment']['encoded_offset'] or 0)
|
||
return [dict(start=str(F(n['start'])-offset), end=str(F(n['end'])-offset),
|
||
pitch=n['sounding_midi'], spelling=n['pitch'], measure=n['measure']) for n in review['sounding_events']]
|
||
|
||
|
||
def rests(review):
|
||
offset = F(review['alignment']['encoded_offset'] or 0)
|
||
cursor = -offset
|
||
result = []
|
||
for n in sorted(events(review), key=lambda n:F(n['start'])):
|
||
start,end = F(n['start']),F(n['end'])
|
||
if start > cursor: result.append((str(cursor),str(start)))
|
||
cursor = max(cursor,end)
|
||
finish = max((F(m['start'])+F(m['duration']) for m in review['measures']),default=F())-offset
|
||
if cursor < finish: result.append((str(cursor),str(finish)))
|
||
return result
|
||
|
||
|
||
def playability(review, tempo=F(120)):
|
||
notes=[];conflicts=[]
|
||
for i,n in enumerate(review['sounding_events']):
|
||
p=n['pitch'];shift=F(n['sounding_midi'])-F(n['encoded_midi'])
|
||
if shift%12:
|
||
raise ValueError('Guitar audition expects octave-only transposition')
|
||
pitch=types.Pitch(p['step'].lower(),F(p['alter']),p['octave']+int(shift/12))
|
||
note=types.Note(F(n['start']),F(n['end']),pitch,event_id=str(i))
|
||
notes.append(note)
|
||
if not fretboard.single_positions_for_pitch(F(pitch.number())):
|
||
conflicts.append({'measure':n['measure'],'written':p,'sounding_midi':pitch.number(),
|
||
'constraint':'No standard-tuning position on strings 1–6, frets 1–12, without open strings.'})
|
||
overlaps=[{'first':a.event_id,'second':b.event_id} for a,b in zip(notes,notes[1:]) if a.end>b.start]
|
||
placed=placement.place(tuple(notes),tempo) if notes and not conflicts and not overlaps else None
|
||
return {'conflicts':conflicts,'overlaps':overlaps,'complete_path_found':placed is not None,
|
||
'range':[min(n.pitch.number() for n in notes),max(n.pitch.number() for n in notes)] if notes else [],
|
||
'positions':[list(n.position[:2]) for n in placed[1]] if placed else [],
|
||
'limitation':'Legal placement only. Shift cost is a preference, not a verified human speed limit.'}
|
||
|
||
|
||
def musical_summary(review):
|
||
ns=events(review)
|
||
durations=collections.Counter(str(F(n['end'])-F(n['start'])) for n in ns)
|
||
intervals=collections.Counter(str(F(b['pitch'])-F(a['pitch'])) for a,b in zip(ns,ns[1:]))
|
||
holds=[]
|
||
for event,n in zip(review['sounding_events'],ns):
|
||
if F(n['end'])-F(n['start'])<2:continue
|
||
spans=[s for i in event['notation_events'] for s in review['notes'][i]['harmonic_spans']]
|
||
holds.append({**n,'harmonic_spans':spans})
|
||
return {'attacks':len(ns),'duration_counts':dict(durations),'interval_counts':dict(intervals),
|
||
'rests':rests(review),'sustained_events':holds,
|
||
'annotations':review['annotations'],'interpretation':'Post-generation diagnostics, never fitted production targets.'}
|
||
|
||
|
||
def compare(generated,reference):
|
||
a,b=events(generated),events(reference)
|
||
def grouped(ns):
|
||
result=collections.defaultdict(list)
|
||
for n in ns:result[n['start']].append(n)
|
||
return result
|
||
left,right=grouped(a),grouped(b)
|
||
differences=[];spelling=[];equal=0
|
||
for start in sorted(left.keys()|right.keys(),key=F):
|
||
x,y=left[start],right[start]
|
||
sounded=lambda ns:sorted((n['pitch'],n['end']) for n in ns)
|
||
if sounded(x)!=sounded(y):
|
||
differences.append({'time':start,'generated':x,'reference':y,
|
||
'context':'reference lead-in before form' if F(start)<0 else 'aligned form'})
|
||
else:
|
||
equal+=len(x)
|
||
if [n['spelling'] for n in x]!=[n['spelling'] for n in y]:
|
||
spelling.append({'time':start,'generated':x,'reference':y})
|
||
rest_difference=rests(generated)!=rests(reference)
|
||
return {'exact_musical_match':not differences and not rest_difference,'exact_matching_attacks':equal,
|
||
'differences':differences,'spelling_differences':spelling,'rest_difference':rest_difference,
|
||
'generated':musical_summary(generated),'reference':musical_summary(reference),
|
||
'reference_playability':playability(reference),'reference_original':reference}
|
||
|
||
|
||
def gesture_coverage(raw):
|
||
"""Group timing-only retained-unit labels with their direct construction.
|
||
|
||
The source label can include rhythm edits such as '; diminution'. These
|
||
describe direct candidates, not additional harmonic proposal families.
|
||
Raw generation evidence remains untouched in the manifest.
|
||
"""
|
||
result={}
|
||
for label,counts in raw.items():
|
||
purpose=label if label.startswith(('direct','harmonic_arrival:','sustained_color')) else 'direct'
|
||
target=result.setdefault(purpose,dict.fromkeys(('proposed','retained_jobs','valid_routes','retained_units'),0))
|
||
for name,value in counts.items():target[name]+=value
|
||
return result
|
||
|
||
|
||
def render_events(notes, duration, tempo, root, cache):
|
||
"""Use the production neutral note envelopes; no expression edits."""
|
||
from generator import playback
|
||
factor=F(60*playback.SAMPLE_RATE)/tempo
|
||
output=array.array('f',[0])* (round(duration*factor)+2400)
|
||
for start,end,pitch in notes:
|
||
if pitch not in cache:
|
||
sample=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]
|
||
first,last=round(start*factor),round(end*factor)
|
||
for frame in range(max(0,first),min(len(output),last+2400)):
|
||
age=frame-first
|
||
index=age if age<sample['loop_end'] else sample['loop_start']+(age-sample['loop_start'])%(sample['loop_end']-sample['loop_start'])
|
||
release=1 if frame<last else (1-(frame-last)/2399)**2
|
||
output[frame]+=release*values[index]/32767
|
||
return output
|
||
|
||
|
||
def audition_bounds(*reviews):
|
||
points=[(F(m['start'])-F(r['alignment']['encoded_offset'] or 0),F(m['duration']))
|
||
for r in reviews for m in r['measures']]
|
||
return min(a for a,b in points),max(a+b for a,b in points)
|
||
|
||
|
||
def audition_pair(actual,expected,role,assets,output,cache):
|
||
from generator import model, playback, score
|
||
from generator.solo.export import excerpt,region
|
||
song,part=role.split('/')
|
||
other='electric' if part=='acoustic' else 'acoustic'
|
||
completed=score.load_json(Path(assets)/'songs'/f'{song}_{part}.json')
|
||
first,count=region(completed,part)
|
||
backing=excerpt(score.load_json(Path(assets)/'songs'/f'{song}_{other}.json'),first,count)
|
||
tempo=next((model.fraction(t['bpm'])*model.written_length(t['written']) for m in completed['staves'][0]['measures'] for t in m.get('tempos',[]) if 'bpm' in t),F(120))
|
||
# Include the entire notated lead-in, even when it is a full bar of rest.
|
||
# Using the first attack here would silently erase a silent initial measure.
|
||
origin,end=audition_bounds(actual,expected)
|
||
duration=end-origin
|
||
timeline=playback.compile_timeline(backing)
|
||
back=render_events([(model.fraction(e['onset'])-origin,model.fraction(e['onset'])+model.fraction(e['duration'])-origin,model.fraction(e['pitch'])) for e in timeline['events']],duration,tempo,output,cache)
|
||
clips=[]
|
||
for label,r in (('generated',actual),('reference',expected)):
|
||
audio=render_events([(F(n['start'])-origin,F(n['end'])-origin,F(n['pitch'])) for n in events(r)],duration,tempo,output,cache)
|
||
combined=array.array('f',(a+.55*b for a,b in zip(audio,back)))
|
||
for context,values in (('solo',audio),('fixed_backing',combined)):
|
||
peak=.65*max(map(abs,values),default=0)
|
||
if peak>=1:raise ValueError('Comparison would clip at the shared rendering gain')
|
||
name=role.replace('/','_')+'_'+label+'_'+context+'.wav'
|
||
playback.write_pcm(output/name,(.65*v for v in values))
|
||
clips.append({'file':name,'version':label,'context':context,'peak':peak})
|
||
if context=='fixed_backing':
|
||
# An excerpt uses the completed source meter, never reference-derived
|
||
# rhythm statistics. Full references and their lead-ins remain above.
|
||
beats=sum((model.fraction(m['duration']) for m in backing['staves'][0]['measures'][:8]),F())
|
||
stop=round((min(end,beats)-origin)*60*playback.SAMPLE_RATE/tempo)
|
||
short=name.replace('.wav','_opening.wav')
|
||
playback.write_pcm(output/short,(.65*v for v in values[:stop]))
|
||
clips.append({'file':short,'version':label,'context':'opening with lead-in context','peak':peak})
|
||
return {'clips':clips,'tempo':str(tempo),'gain':.65,'backing_gain':.55,'form_begins_at_beat':str(-origin),
|
||
'alignment':'Shared audition clock includes reference-only lead-in; no harmony or production notes were added.',
|
||
'listening_status':'Rendered and signal-checked, not subjectively auditioned.'}
|
||
|
||
|
||
def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None):
|
||
generated_dir,output=Path(generated_dir),Path(output)
|
||
manifest=json.loads((generated_dir/'manifest.solo').read_text())
|
||
output.mkdir(parents=True,exist_ok=True)
|
||
tone_cache={}
|
||
result={'comparison_only':True,'feeds_generation':False,'solos':{}}
|
||
for role,path in sorted(reference_paths.items()):
|
||
item=manifest['solos'][role]
|
||
actual=read(generated_dir/item['file'],role,item['form_start_measure'])
|
||
expected=read(path,role,starts.get(role),pitch_convention)
|
||
review=compare(actual,expected)
|
||
if assets:review['audition']=audition_pair(actual,expected,role,assets,output,tone_cache)
|
||
review['production_trace']=item['trace']
|
||
review['production_phrases']=item.get('phrase_report',{})
|
||
# Preserve the real role cutoff; reference material beyond it is not trimmed.
|
||
phrases=review['production_phrases'].get('phrases',[])
|
||
if phrases:
|
||
cutoff=max(F(p['end']) for p in phrases)
|
||
review['production_end']=str(cutoff)
|
||
review['reference_events_after_production_end']=[n for n in events(expected) if F(n['end'])>cutoff]
|
||
result['solos'][role]=review
|
||
output.mkdir(parents=True,exist_ok=True)
|
||
(output/'comparison.json').write_text(json.dumps(result,indent=2)+'\n')
|
||
rows=['<!doctype html><meta charset="utf-8"><title>Solo comparison</title>',
|
||
'<style>body{font:16px system-ui;max-width:1100px;margin:2rem auto}td,th{padding:.5rem;text-align:left}pre{white-space:pre-wrap}</style>',
|
||
'<h1>Completed solos and reference scores</h1><p>Differences are diagnostic. These files do not supply generation inputs or candidate scores. No listening verdict is claimed.</p>',
|
||
'<p><a href="comparison.json">Exact timing, notes, harmony, annotations, placement, and phrase reports</a> · <a href="analysis/theory.md">Complete musical relationship review</a></p>']
|
||
for role,r in result['solos'].items():
|
||
actual_path=generated_dir/manifest['solos'][role]['file']
|
||
reference_path=Path(reference_paths[role])
|
||
rows += ['<h2>'+html.escape(role)+'</h2>',f'<p>Exact event match: <strong>{"yes" if r["exact_musical_match"] else "no"}</strong>. {r["exact_matching_attacks"]} exactly matching attacks; {len(r["differences"])} differing onset positions. This is not a musical-quality score.</p>',
|
||
'<p><a href="'+html.escape(os.path.relpath(actual_path,output))+'">Generated MusicXML</a> · <a href="'+html.escape(os.path.relpath(reference_path,output))+'">Untouched reference MusicXML</a></p>']
|
||
selected=collections.Counter(c['kind'] for c in r['production_phrases'].get('expressions',[]))
|
||
rows.append('<p>Selected, validated production treatments: '+html.escape(str(dict(selected)))+'. Ordinary mapped material remains separate.</p>')
|
||
if r.get('reference_events_after_production_end'):
|
||
rows.append('<p>Reference material extends beyond the production vocal-protection window: '+str(len(r['reference_events_after_production_end']))+' events. The reference and vocal timing remain unchanged.</p>')
|
||
funnel=gesture_coverage(r['production_phrases'].get('selection_trace',{}).get('joint_gestures',{}))
|
||
if funnel:
|
||
rows.append('<details><summary>Construction coverage (not similarity scoring)</summary><table><tr><th>Purpose</th><th>Proposed structures</th><th>Retained jobs</th><th>Valid routes</th><th>Retained units</th></tr>')
|
||
for purpose,counts in sorted(funnel.items()):
|
||
rows.append('<tr><td>'+html.escape(purpose)+'</td>'+''.join('<td>'+str(counts[k])+'</td>' for k in ('proposed','retained_jobs','valid_routes','retained_units'))+'</tr>')
|
||
rows.append('</table></details>')
|
||
for clip in r.get('audition',{}).get('clips',[]):
|
||
rows.append('<p>'+html.escape(clip['version']+' / '+clip['context'])+'<br><audio controls preload="none" src="'+html.escape(clip['file'])+'"></audio></p>')
|
||
rows.append('<details><summary>Exact note differences</summary><table><tr><th>Beat relative to form</th><th>Generated</th><th>Reference</th></tr>')
|
||
for d in r['differences']:
|
||
def describe(ns):return '; '.join(f'MIDI {n["pitch"]}, until {n["end"]}' for n in ns) or 'No attack'
|
||
rows.append('<tr>'+''.join('<td>'+html.escape(v)+'</td>' for v in (d['time'],describe(d['generated']),describe(d['reference'])))+'</tr>')
|
||
rows.append('</table></details>')
|
||
(output/'index.html').write_text('\n'.join(rows))
|
||
return result
|
||
|
||
|
||
def main():
|
||
parser=argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument('--generated-dir',type=Path,required=True)
|
||
parser.add_argument('--reference',action='append',default=[],metavar='SONG/ROLE=PATH')
|
||
parser.add_argument('--form-start',action='append',default=[],metavar='SONG/ROLE=MEASURE')
|
||
parser.add_argument('--reference-pitch',choices=('encoded','guitar-written'),default='encoded')
|
||
parser.add_argument('--output',type=Path,required=True)
|
||
parser.add_argument('--assets',type=Path,help='Optional completed assets for neutral reference/generated audio with identical backing.')
|
||
args=parser.parse_args()
|
||
run(args.generated_dir,dict(x.split('=',1) for x in args.reference),
|
||
{k:int(v) for k,v in (x.split('=',1) for x in args.form_start)},args.reference_pitch,args.output,args.assets)
|
||
|
||
|
||
if __name__=='__main__':main()
|