"""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 startt),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 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=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 candidate_clips(candidates,role,tempo,output,cache): """Render the already completed source-only alternatives, never reselect.""" from generator import playback clips=[] for index,candidate in enumerate(candidates,1): origin,end=F(candidate['start']),F(candidate['end']) audio=render_events([(F(a)-origin,F(b)-origin,F(p)) for a,b,p in candidate['events']],end-origin,tempo,output,cache) peak=.65*max(map(abs,audio),default=0) if peak>=1:raise ValueError('Candidate excerpt would clip at comparison gain') name='audio/'+role.replace('/','_')+f'_candidate_{index}.wav' playback.write_pcm(output/name,(.65*v for v in audio)) selected=candidate['selected_in_final_solo'] clips.append({'file':name,'version':f'Completed opening {index}'+(' — selected in final solo' if selected else ' — alternative'), 'context':'source-only candidate, solo','peak':peak}) return clips 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',{}) candidates=review['production_phrases'].get('selection_trace',{}).get('opening_candidates',[]) if assets and candidates: review['audition']['clips']+=candidate_clips(candidates,role,F(review['audition']['tempo']),output,tone_cache) # 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=['Solo comparison', '', '

Completed solos and reference scores

Differences are diagnostic. These files do not supply generation inputs or candidate scores. No listening verdict is claimed.

', '

Exact timing, notes, harmony, annotations, placement, and phrase reports

'] for role,r in result['solos'].items(): actual_path=generated_dir/manifest['solos'][role]['file'] reference_path=Path(reference_paths[role]) rows += ['

'+html.escape(role)+'

',f'

Exact event match: {"yes" if r["exact_musical_match"] else "no"}. {r["exact_matching_attacks"]} exactly matching attacks; {len(r["differences"])} differing onset positions. This is not a musical-quality score.

', '

Generated MusicXML · Untouched reference MusicXML

'] if baseline_manifest: baseline_path=Path(baseline_dir)/baseline_manifest['solos'][role]['file'] rows.append('

Previous generated MusicXML

') rows.append('') for key in ('pitch_sequence_edit_distance','timed_attack_edit_distance','same_sounding_pitch_beats','same_pitch_class_beats','either_sounding_beats'): rows.append('') rows.append('
Diagnostic against referenceCurrentBaseline
'+key.replace('_',' ')+''+str(r['relationships'][key])+''+str(r.get('baseline_relationships',{}).get(key,'—'))+'

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.

') selected=collections.Counter(c['kind'] for c in r['production_phrases'].get('expressions',[])) rows.append('

Selected, validated production treatments: '+html.escape(str(dict(selected)))+'. Ordinary mapped material remains separate.

') if r.get('reference_events_after_production_end'): rows.append('

Reference material extends beyond this exported production allocation: '+str(len(r['reference_events_after_production_end']))+' events. The reference and source timing remain unchanged.

') 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('
Construction coverage (not similarity scoring)'+''.join('' for k in stages)+'') for purpose,counts in sorted(funnel.items()): rows.append(''+''.join('' for k in stages)+'') rows.append('
Purpose'+k.replace('_',' ')+'
'+html.escape(purpose)+''+str(counts.get(k,0))+'
') candidates=r['production_phrases'].get('selection_trace',{}).get('opening_candidates',[]) if candidates: rows.append('
Completed source-only opening candidates

All candidates were composed without the references. Selection accounts for later phrases; the final choice need not have the cheapest opening alone.

') for index,c in enumerate(candidates,1): rows.append(''+''.join('' for v in (index,c['selected_in_final_solo'],c['purpose'],c['evaluation']['contributions']))+'') rows.append('
CandidateFinal choicePurposeScore contributions
'+html.escape(str(v))+'
') for clip in r.get('audition',{}).get('clips',[]): rows.append('

'+html.escape(clip['version']+' / '+clip['context'])+'

') rows.append('
Exact note differences') 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(''+''.join('' for v in (d['time'],describe(d['generated']),describe(d['reference'])))+'') rows.append('
Beat relative to formGeneratedReference
'+html.escape(v)+'
') (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())