Files
product-vamp/tests/solo_compare.py
T
2026-10-05 16:40:22 -05:00

419 lines
27 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.
"""Read optional reference MusicXML literally, without repairing it or composing."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
from fractions import Fraction as F
import xml.etree.ElementTree as ET
def read(path, role, form_start=None, pitch_convention='encoded'):
if pitch_convention not in ('encoded','guitar-written'):
raise ValueError('Unknown reference pitch convention: '+pitch_convention)
raw=Path(path).read_bytes();tree=ET.fromstring(raw)
result={'path':str(Path(path).resolve()),'sha256':hashlib.sha256(raw).hexdigest(),'validation_role':role,
'pitch_convention':pitch_convention,'part_names':[n.text for n in tree.findall('./part-list/score-part/part-name')],
'measures':[],'notes':[],'harmonies':[],'annotations':[],'discrepancies':[]}
divisions=F(1);absolute=F();nominal=F(4);pending={};active=None;transpose=F()
for index,m in enumerate(tree.findall('./part/measure')):
cursor=F();extent=F();last_start=F();last_duration=F()
if (value:=m.findtext('attributes/divisions')) is not None:divisions=F(value)
meter=m.find('attributes/time')
if meter is not None and meter.findtext('beats'):
nominal=sum((F(v) for v in meter.findtext('beats').split('+')),F())*4/F(meter.findtext('beat-type'))
for item in m:
offset=F(item.findtext('offset','0'))/divisions
if item.tag=='attributes' and item.find('transpose') is not None:
transpose=F(item.findtext('transpose/chromatic','0'))+12*F(item.findtext('transpose/octave-change','0'))
elif item.tag=='backup':cursor-=F(item.findtext('duration'))/divisions
elif item.tag=='forward':cursor+=F(item.findtext('duration'))/divisions;extent=max(extent,cursor)
elif item.tag=='harmony':
active={'time':str(absolute+cursor+offset),'measure':index+1,'offset':str(cursor+offset),
'root':item.findtext('root/root-step'),'alter':item.findtext('root/root-alter','0'),
'kind':item.findtext('kind'),'display':item.find('kind').get('text') if item.find('kind') is not None else None,
'bass':ET.tostring(item.find('bass'),encoding='unicode') if item.find('bass') is not None else None,
'degrees':[{'value':d.findtext('degree-value'),'alter':d.findtext('degree-alter'),'type':d.findtext('degree-type')} for d in item.findall('degree')]}
result['harmonies'].append(active)
elif item.tag=='direction':
for words in item.findall('direction-type/words'):
result['annotations'].append({'time':str(absolute+cursor+offset),'measure':index+1,'text':''.join(words.itertext()),'interpretation':'descriptive; no production permission'})
elif item.tag=='note':
duration=F(item.findtext('duration','0'))/divisions
start=last_start if item.find('chord') is not None else cursor
end=start+duration;voice=item.findtext('voice','1');p=item.find('pitch')
encoded={'step':p.findtext('step'),'alter':p.findtext('alter','0'),'octave':int(p.findtext('octave'))} if p is not None else None
kinds={t.get('type') for t in item.findall('tie')} | {t.get('type') for t in item.findall('notations/tied')}
event={'start':str(absolute+start),'end':str(absolute+end),'measure':index+1,'offset':str(start),'pitch':encoded,'rest':item.find('rest') is not None,
'written_type':item.findtext('type'),'dots':len(item.findall('dot')),'voice':voice,'ties':sorted(kinds),
'sounding_transposition':str(-12 if pitch_convention=='guitar-written' else transpose),
'tablature':{'string':int(item.findtext('notations/technical/string')),'fret':int(item.findtext('notations/technical/fret'))} if item.find('notations/technical/string') is not None and item.find('notations/technical/fret') is not None else None,
'time_modification':ET.tostring(item.find('time-modification'),encoding='unicode') if item.find('time-modification') is not None else None,
'tuplets':[dict(t.attrib) for t in item.findall('notations/tuplet')],'grace':item.find('grace') is not None,'new_attack':'stop' not in kinds and p is not None}
key=(voice,json.dumps(encoded,sort_keys=True))
if 'stop' in kinds:
old=pending.get(key)
if old is None or result['notes'][old]['end']!=event['start']:
result['discrepancies'].append({'measure':index+1,'issue':'unmatched/noncontiguous tie; retained literally'})
else:event['continuation_of']=old
if 'start' not in kinds:pending.pop(key,None)
if 'start' in kinds:pending[key]=len(result['notes'])
result['notes'].append(event)
for container in ('notations/technical/fingering','notations/technical/other-technical','notations/other-notation'):
for label in item.findall(container):
text=''.join(label.itertext()).strip()
if text:result['annotations'].append({'time':str(absolute+start),'measure':index+1,'text':text,
'container':container,'interpretation':'Original note annotation; container does not establish musical permission or guitar fingering.'})
if item.find('chord') is None and item.find('grace') is None:cursor=end
last_start,last_duration=start,duration;extent=max(extent,end,cursor)
if extent!=nominal and m.get('implicit')!='yes':result['discrepancies'].append({'measure':index+1,'issue':f'encoded span {extent} differs from nominal {nominal}; no padding applied'})
result['measures'].append({'number':m.get('number'),'start':str(absolute),'duration':str(extent),'implicit':m.get('implicit'),'initial_harmony_known':any(F(h['time'])<=absolute for h in result['harmonies'])})
absolute+=extent
if pending:result['discrepancies'].append({'issue':'unterminated ties retained'})
result['harmonies'].sort(key=lambda h:F(h['time']))
# Do not run the strict score importer over these references: annotations,
# unusual timing and high encoded pitches are evidence, not repair requests.
from generator import harmony, model, fretboard
def semantic(h):
return {'root':{'letter':h['root'].lower(),'alter':model.rational(F(h['alter']))},'kind':h['kind'],
'degrees':[{'value':int(d['value']),'alter':model.rational(F(d['alter'])),'type':d['type']} for d in h['degrees']]}
for event in result['notes']:
if not event['pitch']:continue
p=event['pitch'];number=12*(p['octave']+1)+model.NATURAL[p['step'].lower()]+F(p['alter'])
event['encoded_midi']=str(number)
event['sounding_midi']=str(number+F(event['sounding_transposition']))
event['legal_positions_at_encoded_pitch']=len(fretboard.single_positions_for_pitch(number))
event['legal_positions_at_sounding_pitch']=len(fretboard.single_positions_for_pitch(F(event['sounding_midi'])))
event['harmonic_spans']=[]
start,end=F(event['start']),F(event['end'])
changes=[F(h['time']) for h in result['harmonies'] if start<F(h['time'])<end]
for a,b in zip([start,*changes],[*changes,end]):
current=next((h for h in reversed(result['harmonies']) if F(h['time'])<=a),None)
info={'start':str(a),'end':str(b),'chord':current,'chord_degrees':[]}
if current and current['root']:
try:
chord=semantic(current);root=model.NATURAL[chord['root']['letter']]+F(current['alter'])
info['chord_degrees']=[d for d,alter in harmony.degrees(chord).items() if (root+harmony.degree_semitones(d,alter)-number)%12==0]
info['root_relative_semitones']=str((number-root)%12)
info['spelled_simple_degree']=(model.LETTERS.index(p['step'].lower())-model.LETTERS.index(chord['root']['letter']))%7+1
except (ValueError,KeyError,AttributeError) as error:
info['uncertainty']=str(error)
else:info['uncertainty']='No written/carried harmony; none invented.'
event['harmonic_spans'].append(info)
sounded=[];tie_roots={}
for i,n in enumerate(result['notes']):
if not n['pitch']:continue
previous=n.get('continuation_of')
if previous is not None and previous in tie_roots:
root=tie_roots[previous];sounded[root]['end']=n['end'];sounded[root]['notation_events'].append(i);tie_roots[i]=root
else:
tie_roots[i]=len(sounded);sounded.append({'start':n['start'],'end':n['end'],'pitch':n['pitch'],
'encoded_midi':n['encoded_midi'],'sounding_midi':n['sounding_midi'],'measure':n['measure'],'notation_events':[i]})
result['sounding_events']=sounded
first=form_start or next((h['measure'] for h in result['harmonies']),None)
offset=F(result['measures'][first-1]['start']) if first else None
result['alignment']={'form_start_measure':first,'encoded_offset':str(offset) if offset is not None else None,'basis':'explicit override' if form_start else 'first written harmony; review required','preceding_material':'retained, without invented harmony'}
for label in result['annotations']:
t=F(label['time']); current=[h for h in result['harmonies'] if F(h['time'])<=t]
future=next((h for h in result['harmonies'] if F(h['time'])>t),None)
label['current_harmony']=current[-1] if current else None
label['following_harmony']=future
label['events']=[n for n in result['notes'] if F(n['start'])<=t<F(n['end']) or t<=F(n['start'])<t+1]
label['uncertainty']='Label meaning must be assessed from these events and harmonic time; label preserved verbatim.'
expected={'b9':F(1),'#9':F(3),'b13':F(8),'#11':F(6),'6':F(9)}.get(label['text'].strip())
if expected is not None:
relationships=[s.get('root_relative_semitones') for n in label['events'] for s in n.get('harmonic_spans',[])]
label['literal_interval_present']=str(expected) in relationships
if not label['literal_interval_present']:label['uncertainty']='No matching interval in the immediate annotated context; label retained, intended scope uncertain.'
# Lead in and Bluesy stay contextual descriptions, never enclosure permits.
return result
"""Explicit post-generation comparison. Never imports or runs a composer."""
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 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,
'relationships':relationships(a,b),
'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 relationships(a,b):
"""Read-only comparisons on the aligned clock; no fitted aggregate loss."""
def distance(left,right):
row=list(range(len(right)+1))
for i,x in enumerate(left,1):
next_row=[i]
for j,y in enumerate(right,1):
next_row.append(min(next_row[-1]+1,row[j]+1,row[j-1]+(x!=y)))
row=next_row
return row[-1]
ns=[[dict(start=F(n['start']),end=F(n['end']),pitch=F(n['pitch'])) for n in notes] for notes in (a,b)]
points=sorted({n[k] for notes in ns for n in notes for k in ('start','end')})
occupied=equal=classes=F()
for start,end in zip(points,points[1:]):
pitches=[{n['pitch'] for n in notes if n['start']<=start<n['end']} for notes in ns]
if any(pitches):
occupied+=end-start
if pitches[0]==pitches[1]:equal+=end-start
if {p%12 for p in pitches[0]}=={p%12 for p in pitches[1]}:classes+=end-start
pitches=[[n['pitch'] for n in notes] for notes in ns]
rhythms=[[(n['start'],n['end']-n['start']) for n in notes] for notes in ns]
return {'pitch_sequence_edit_distance':distance(*pitches),
'timed_attack_edit_distance':distance(*rhythms),
'normalization_attacks':max(len(a),len(b)),
'same_sounding_pitch_beats':str(equal),'same_pitch_class_beats':str(classes),
'either_sounding_beats':str(occupied),
'interpretation':'Separate descriptive comparisons, not a quality score. Pitch distance ignores timing; timing distance ignores pitch. Held overlap uses the original aligned register and includes lead-ins.'}
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,baseline=None):
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)
leadin=actual['alignment']['form_start_measure']-1
backing=excerpt(score.load_json(Path(assets)/'songs'/f'{song}_{other}.json'),first-leadin,count+leadin)
backing_offset=F(actual['alignment']['encoded_offset'])
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'])-backing_offset-origin,model.fraction(e['onset'])+model.fraction(e['duration'])-backing_offset-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),*((('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='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':
# 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'][leadin:leadin+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),
'backing':'Exact independently generated other-guitar part, including the production lead-in. Fixed unchanged for all three comparison versions.',
'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,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)
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)
review['baseline_relationships']=relationships(events(baseline),events(expected))
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.
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>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>')
rows.append('<table><tr><th>Diagnostic against reference</th><th>Current</th><th>Baseline</th></tr>')
for key in ('pitch_sequence_edit_distance','timed_attack_edit_distance','same_sounding_pitch_beats','same_pitch_class_beats','either_sounding_beats'):
rows.append('<tr><td>'+key.replace('_',' ')+'</td><td>'+str(r['relationships'][key])+'</td><td>'+str(r.get('baseline_relationships',{}).get(key,'—'))+'</td></tr>')
rows.append('</table><p>Lower edit distance means fewer insertions, deletions or substitutions on that axis. Sounding agreement is measured in quarter-note beats, without octave shifting or fitted alignment. These do not measure musical quality.</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 this exported production allocation: '+str(len(r['reference_events_after_production_end']))+' events. The reference and source timing remain unchanged.</p>')
funnel=r['production_phrases'].get('selection_trace',{}).get('operation_coverage',{})
if funnel:
stages=tuple(dict.fromkeys(k for counts in funnel.values() for k in counts))
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.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>')
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('--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.')
parser.add_argument('--require-exact',action='store_true',help='Fail unless every requested reference has identical complete sounding events and rests.')
args=parser.parse_args()
if args.require_exact and not args.reference:
parser.error('--require-exact requires at least one explicit --reference')
result=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,args.baseline_dir)
for role,review in result['solos'].items():
print(f'{role}: exact={review["exact_musical_match"]}; matching attacks={review["exact_matching_attacks"]}; differing onsets={len(review["differences"])}; rests differ={review["rest_difference"]}')
return 1 if args.require_exact and any(not r['exact_musical_match'] for r in result['solos'].values()) else 0
if __name__=='__main__':raise SystemExit(main())