Simplification

This commit is contained in:
khodowany
2026-10-05 16:40:22 -05:00
parent 654299decd
commit bcaf2f1403
49 changed files with 922 additions and 3327 deletions
+418
View File
@@ -0,0 +1,418 @@
"""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())