Files
product-vamp/audition/solo_compare.py
T
khodowany 405066eb43 Revamp
2026-10-04 12:27:43 -05:00

221 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 render_events(notes, duration, tempo, root, cache):
"""The same neutral note envelopes as production_review; 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></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>{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>']
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()