This commit is contained in:
khodowany
2026-10-04 18:48:02 -05:00
parent 68df7292ee
commit 0e68f5364b
58 changed files with 1416 additions and 7567 deletions
+21 -9
View File
@@ -138,7 +138,7 @@ def audition_bounds(*reviews):
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):
def audition_pair(actual,expected,role,assets,output,cache,baseline=None):
from generator import model, playback, score
from generator.solo.export import excerpt,region
song,part=role.split('/')
@@ -153,14 +153,15 @@ def audition_pair(actual,expected,role,assets,output,cache):
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)
(output/'audio').mkdir(parents=True,exist_ok=True)
clips=[]
for label,r in (('generated',actual),('reference',expected)):
for label,r in (('generated',actual),('reference',expected),*((('baseline',baseline),) if baseline else ())):
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'
name='audio/'+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':
@@ -176,18 +177,24 @@ def audition_pair(actual,expected,role,assets,output,cache):
'listening_status':'Rendered and signal-checked, not subjectively auditioned.'}
def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None):
def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None,baseline_dir=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={}
baseline_manifest=json.loads((Path(baseline_dir)/'manifest.solo').read_text()) if baseline_dir else None
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)
baseline=None
if baseline_manifest:
old=baseline_manifest['solos'][role]
baseline=read(Path(baseline_dir)/old['file'],role,old['form_start_measure'])
review['baseline']=musical_summary(baseline)
if assets:review['audition']=audition_pair(actual,expected,role,assets,output,tone_cache,baseline)
review['production_trace']=item['trace']
review['production_phrases']=item.get('phrase_report',{})
# Preserve the real role cutoff; reference material beyond it is not trimmed.
@@ -208,15 +215,19 @@ def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None
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>']
if baseline_manifest:
baseline_path=Path(baseline_dir)/baseline_manifest['solos'][role]['file']
rows.append('<p><a href="'+html.escape(os.path.relpath(baseline_path,output))+'">Previous generated 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',{}))
funnel=r['production_phrases'].get('selection_trace',{}).get('operation_coverage',{})
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>')
stages=('eligible_contexts','proposed','rhythm_valid','complete_routes','valid','finalists','placed','lookahead_valid','selected')
rows.append('<details><summary>Construction coverage (not similarity scoring)</summary><table><tr><th>Purpose</th>'+''.join('<th>'+k.replace('_',' ')+'</th>' for k in stages)+'</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('<tr><td>'+html.escape(purpose)+'</td>'+''.join('<td>'+str(counts.get(k,0))+'</td>' for k in stages)+'</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>')
@@ -236,10 +247,11 @@ def main():
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('--baseline-dir',type=Path,help='Optional previous completed solo exports; never generation inputs.')
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)
{k:int(v) for k,v in (x.split('=',1) for x in args.form_start)},args.reference_pitch,args.output,args.assets,args.baseline_dir)
if __name__=='__main__':main()