This commit is contained in:
khodowany
2026-10-04 17:14:13 -05:00
parent f234dd6d51
commit 68df7292ee
33 changed files with 990 additions and 1872 deletions
-719
View File
@@ -1,719 +0,0 @@
{
"description": "Original hand-composed reference, not output from the user engine or a quotation of Autumn Leaves.",
"tempo_bpm": 120,
"meter": "4/4",
"pitch_convention": "sounding",
"time_units": "exact rational quarter-note beats",
"velocity_policy": "lead 80, backing 48; constant within each track; no accents or dynamics",
"scale_regions": [
{
"start": "0",
"end": "16",
"scale": "D Aeolian"
},
{
"start": "16",
"end": "24",
"scale": "D Harmonic Minor"
},
{
"start": "24",
"end": "32",
"scale": "D Aeolian"
}
],
"harmony": [
{
"bar": 1,
"chord": "Gm7",
"required_basic_pitch_classes": [
7,
10,
2,
5
]
},
{
"bar": 2,
"chord": "C7",
"required_basic_pitch_classes": [
0,
4,
7,
10
]
},
{
"bar": 3,
"chord": "Fmaj7",
"required_basic_pitch_classes": [
5,
9,
0,
4
]
},
{
"bar": 4,
"chord": "Bbmaj7",
"required_basic_pitch_classes": [
10,
2,
5,
9
]
},
{
"bar": 5,
"chord": "Em7b5",
"required_basic_pitch_classes": [
4,
7,
10,
2
]
},
{
"bar": 6,
"chord": "A7",
"required_basic_pitch_classes": [
9,
1,
4,
7
]
},
{
"bar": 7,
"chord": "Dm7",
"required_basic_pitch_classes": [
2,
5,
9,
0
]
},
{
"bar": 8,
"chord": "Dm7",
"required_basic_pitch_classes": [
2,
5,
9,
0
]
}
],
"variants": {
"lead_theme": {
"attacks": 31,
"events": [
{
"bar": 1,
"onset_beats": "0",
"duration_beats": "1",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 1,
"onset_beats": "1",
"duration_beats": "3/2",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 1,
"onset_beats": "5/2",
"duration_beats": "1/2",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 1,
"onset_beats": "3",
"duration_beats": "1",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 2,
"onset_beats": "4",
"duration_beats": "2",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 2,
"onset_beats": "6",
"duration_beats": "1/2",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 2,
"onset_beats": "13/2",
"duration_beats": "1/2",
"pitch": "A4",
"midi_pitch": 69
},
{
"bar": 2,
"onset_beats": "7",
"duration_beats": "1",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 3,
"onset_beats": "8",
"duration_beats": "1",
"pitch": "C4",
"midi_pitch": 60
},
{
"bar": 3,
"onset_beats": "9",
"duration_beats": "3/2",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 3,
"onset_beats": "21/2",
"duration_beats": "1/2",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 3,
"onset_beats": "11",
"duration_beats": "1",
"pitch": "C4",
"midi_pitch": 60
},
{
"bar": 4,
"onset_beats": "12",
"duration_beats": "2",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 4,
"onset_beats": "14",
"duration_beats": "1",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 4,
"onset_beats": "15",
"duration_beats": "1",
"pitch": "A4",
"midi_pitch": 69
},
{
"bar": 5,
"onset_beats": "16",
"duration_beats": "3/2",
"pitch": "Bb4",
"midi_pitch": 70
},
{
"bar": 5,
"onset_beats": "35/2",
"duration_beats": "1/2",
"pitch": "A4",
"midi_pitch": 69
},
{
"bar": 5,
"onset_beats": "18",
"duration_beats": "1",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 5,
"onset_beats": "19",
"duration_beats": "1",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 6,
"onset_beats": "20",
"duration_beats": "1",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 6,
"onset_beats": "21",
"duration_beats": "1/2",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 6,
"onset_beats": "43/2",
"duration_beats": "1/2",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 6,
"onset_beats": "22",
"duration_beats": "1",
"pitch": "C#4",
"midi_pitch": 61
},
{
"bar": 6,
"onset_beats": "23",
"duration_beats": "1",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 7,
"onset_beats": "24",
"duration_beats": "1",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 7,
"onset_beats": "25",
"duration_beats": "3/2",
"pitch": "A4",
"midi_pitch": 69
},
{
"bar": 7,
"onset_beats": "53/2",
"duration_beats": "1/2",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 7,
"onset_beats": "27",
"duration_beats": "1",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 8,
"onset_beats": "28",
"duration_beats": "1/2",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 8,
"onset_beats": "57/2",
"duration_beats": "1/2",
"pitch": "C4",
"midi_pitch": 60
},
{
"bar": 8,
"onset_beats": "29",
"duration_beats": "2",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 8,
"onset_beats": "31",
"duration_beats": "1",
"pitch": null,
"midi_pitch": null
}
]
},
"lead_with_runs": {
"attacks": 36,
"events": [
{
"bar": 1,
"onset_beats": "0",
"duration_beats": "1",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 1,
"onset_beats": "1",
"duration_beats": "3/2",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 1,
"onset_beats": "5/2",
"duration_beats": "1/2",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 1,
"onset_beats": "3",
"duration_beats": "1",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 2,
"onset_beats": "4",
"duration_beats": "1",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 2,
"onset_beats": "5",
"duration_beats": "1/2",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 2,
"onset_beats": "11/2",
"duration_beats": "1/2",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 2,
"onset_beats": "6",
"duration_beats": "1/2",
"pitch": "A4",
"midi_pitch": 69
},
{
"bar": 2,
"onset_beats": "13/2",
"duration_beats": "1/2",
"pitch": "Bb4",
"midi_pitch": 70
},
{
"bar": 2,
"onset_beats": "7",
"duration_beats": "1/2",
"pitch": "A4",
"midi_pitch": 69
},
{
"bar": 2,
"onset_beats": "15/2",
"duration_beats": "1/2",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 3,
"onset_beats": "8",
"duration_beats": "1",
"pitch": "C4",
"midi_pitch": 60
},
{
"bar": 3,
"onset_beats": "9",
"duration_beats": "3/2",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 3,
"onset_beats": "21/2",
"duration_beats": "1/2",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 3,
"onset_beats": "11",
"duration_beats": "1",
"pitch": "C4",
"midi_pitch": 60
},
{
"bar": 4,
"onset_beats": "12",
"duration_beats": "2",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 4,
"onset_beats": "14",
"duration_beats": "1",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 4,
"onset_beats": "15",
"duration_beats": "1",
"pitch": "A4",
"midi_pitch": 69
},
{
"bar": 5,
"onset_beats": "16",
"duration_beats": "3/2",
"pitch": "Bb4",
"midi_pitch": 70
},
{
"bar": 5,
"onset_beats": "35/2",
"duration_beats": "1/2",
"pitch": "A4",
"midi_pitch": 69
},
{
"bar": 5,
"onset_beats": "18",
"duration_beats": "1",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 5,
"onset_beats": "19",
"duration_beats": "1",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 6,
"onset_beats": "20",
"duration_beats": "1",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 6,
"onset_beats": "21",
"duration_beats": "1/2",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 6,
"onset_beats": "43/2",
"duration_beats": "1/2",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 6,
"onset_beats": "22",
"duration_beats": "1/2",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 6,
"onset_beats": "45/2",
"duration_beats": "1/2",
"pitch": "C#4",
"midi_pitch": 61
},
{
"bar": 6,
"onset_beats": "23",
"duration_beats": "1/2",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 6,
"onset_beats": "47/2",
"duration_beats": "1/2",
"pitch": "C#4",
"midi_pitch": 61
},
{
"bar": 7,
"onset_beats": "24",
"duration_beats": "1",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 7,
"onset_beats": "25",
"duration_beats": "3/2",
"pitch": "A4",
"midi_pitch": 69
},
{
"bar": 7,
"onset_beats": "53/2",
"duration_beats": "1/2",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 7,
"onset_beats": "27",
"duration_beats": "1",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 8,
"onset_beats": "28",
"duration_beats": "1/2",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 8,
"onset_beats": "57/2",
"duration_beats": "1/2",
"pitch": "C4",
"midi_pitch": 60
},
{
"bar": 8,
"onset_beats": "29",
"duration_beats": "2",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 8,
"onset_beats": "31",
"duration_beats": "1",
"pitch": null,
"midi_pitch": null
}
]
}
},
"id": "original_lead_theme",
"acceptance": "User accepted plain theme and runs as references",
"tonic": "D",
"mode": "minor",
"run_windows": [
{
"start": "4",
"end": "8"
},
{
"start": "20",
"end": "24"
}
],
"features": [
{
"id": "leap_return",
"start": "0",
"end": "4",
"relationship": "timed leap and return"
},
{
"id": "restatement",
"start": "8",
"end": "12",
"relationship": "related rhythm and leap with harmonic adaptation"
}
],
"development": [
{
"role": "statement",
"start": "0",
"end": "4",
"degrees": [
0,
3,
2,
0
]
},
{
"role": "answer",
"start": "4",
"end": "8",
"degrees": [
0,
2,
3,
2
]
},
{
"role": "return",
"start": "8",
"end": "12",
"degrees": [
0,
3,
2,
0
]
},
{
"role": "extension",
"start": "12",
"end": "16",
"degrees": [
0,
2,
4
]
},
{
"role": "departure",
"start": "16",
"end": "20",
"degrees": [
0,
-1,
-2,
-4
]
},
{
"role": "continuation",
"start": "20",
"end": "24",
"degrees": [
0,
-1,
-2,
-4,
-2
]
},
{
"role": "recall",
"start": "24",
"end": "28",
"degrees": [
0,
4,
3,
2
]
},
{
"role": "ending",
"start": "28",
"end": "32",
"degrees": [
0,
-2,
-1
]
}
]
}
-147
View File
@@ -1,147 +0,0 @@
"""Export auditions from completed assets; this command never composes music."""
from __future__ import annotations
import argparse
import array
import copy
import dataclasses
import html
import json
from pathlib import Path
from fractions import Fraction as F
from generator import model, musicxml_writer, playback, score
from generator.solo import notation, placement, types, similarity
from generator.solo.audition import excerpt, mix
def region(document, part):
measures=document['staves'][0]['measures']
first=next(i for i,m in enumerate(measures) if m.get('section_label')==part.capitalize()+' Solo')
finish=next((i for i in range(first+1,len(measures)) if measures[i].get('section_label')),len(measures))
return first,finish-first
def staged(document, details, stage, tempo):
notes=[]
for phrase in details['phrases']:
selected=phrase['lead_stages'].get(stage) or phrase['draft']
for n in selected.get('notes',[]):
p=n['pitch']
notes.append(types.Note(F(n['start']),F(n['end']),types.Pitch(p['letter'],F(p['alter']),p['octave']),
tuple(n['sources']),n['role'],continuation=n['continuation'],event_id=n['event_id']))
notes=types.sounding_notes(tuple(notes))
placed=placement.place(notes,tempo)
if notes and placed is None:raise ValueError(stage+': no legal audition placement')
result=copy.deepcopy(document)
notation.replace_region(result,0,notation.measures(result['staves'][0]['measures'],placed[1] if placed else (),stage))
model.validate_document(result)
return result
def overlap(lead, backing):
a=playback.compile_timeline(lead)['events'];b=playback.compile_timeline(backing)['events']
total=covered=above=0.
for n in a:
start=model.fraction(n['onset']);end=start+model.fraction(n['duration']);pitch=model.fraction(n['pitch'])
cuts={start,end}
for other in b:
s=model.fraction(other['onset']);e=s+model.fraction(other['duration'])
if s<end and e>start:cuts.update((max(s,start),min(e,end)))
points=sorted(cuts)
for x,y in zip(points,points[1:]):
simultaneous=[o for o in b if model.fraction(o['onset'])<=x<model.fraction(o['onset'])+model.fraction(o['duration'])]
total+=float(y-x)
if simultaneous:covered+=float(y-x)
if any(model.fraction(o['pitch'])>pitch for o in simultaneous):above+=float(y-x)
return {'sounding_beats':total,'backing_overlap_fraction':covered/max(.001,total),'backing_above_lead_fraction':above/max(.001,total),
'interpretation':'score overlap only; not a perceptual masking measurement; no feedback to composition'}
def export(assets,baseline,output):
output.mkdir(parents=True,exist_ok=True)
manifest={'composition_inputs':False,'gain':.65,'backing_gain':.55,'playback':'unchanged neutral constant-level synthesis',
'listening_status':'not auditioned by the agent; score validation and rendered signal checks only','solos':{}}
tone_cache={};clips=[];notes_report=[]
for roles_path in sorted((assets/'analysis').glob('*.roles')):
song=roles_path.stem;roles=json.loads(roles_path.read_text())
for part in ('acoustic','electric'):
print('Audition: '+song+'/'+part,flush=True)
other='electric' if part=='acoustic' else 'acoustic'
assembled=score.load_json(assets/'songs'/f'{song}_{part}.json')
backing=score.load_json(assets/'songs'/f'{song}_{other}.json')
first,count=region(assembled,part)
lead=excerpt(assembled,first,count);backing=excerpt(backing,first,count)
detail=roles[part+'_solo']['details']
tempo=next((model.fraction(t['bpm'])*model.written_length(t['written']) for m in assembled['staves'][0]['measures'] for t in m.get('tempos',[]) if 'bpm' in t),F(120))
versions={'final':lead, 'plain':staged(lead,detail,'refined',tempo), 'strict':staged(lead,detail,'before_enclosure',tempo), 'source':staged(lead,detail,'initial',tempo)}
baseline_backing=None
old=baseline/'songs'/f'{song}_{part}.json' if baseline else None
if old and old.is_file():
original=score.load_json(old);a,b=region(original,part)
versions['baseline']=excerpt(original,a,b)
baseline_backing=excerpt(score.load_json(baseline/'songs'/f'{song}_{other}.json'),a,b)
key=song+'_'+part
manifest['solos'][key]={'tempo':str(tempo),'source_form_measures':count,'details':detail,
'input_trace':roles[part+'_solo']['trace'],'ensemble':overlap(lead,backing)}
notes_report.append(key+' — solo-relative rational quarter-note time; sounding pitches\n')
for phrase in detail['phrases']:
notes_report.append(f"{phrase['id']}: {phrase['start']}–{phrase['end']}; {phrase['ending']}; completed={phrase['ending_completed']}\n")
for n in phrase['draft']['notes']:
p=n['pitch'];tone=types.Pitch(p['letter'],F(p['alter']),p['octave'])
pos=n['position'];tab=f' string {pos[0]} fret {pos[1]}' if pos else ' (draft pitch; final TAB in XML)'
notes_report.append(f" {n['start']}–{n['end']} {tone.label()} {n['role']}{tab}\n")
back_audio=mix([backing],tempo,output,tone_cache)
for name,document in versions.items():
label=key+'_'+name
score.write_json(output/(label+'.json'),document)
musicxml_writer.write_musicxml(output/(label+'.xml'),document)
solo_audio=mix([document],tempo,output,tone_cache)
combined=array.array('f',(v+.55*(back_audio[i] if i<len(back_audio) else 0) for i,v in enumerate(solo_audio)))
variants={'solo':solo_audio,'fixed_backing':combined}
if name=='final':variants['final_arrangement']=combined
if name=='baseline' and baseline_backing:
oldback=mix([baseline_backing],tempo,output,tone_cache)
variants['original_backing']=array.array('f',(v+.55*(oldback[i] if i<len(oldback) else 0) for i,v in enumerate(solo_audio)))
for context,values in variants.items():
peak=max(map(abs,values),default=0)*manifest['gain']
if peak>=1:raise ValueError('Audition gain would clip; choose a lower shared gain')
path=label+'_'+context+'.wav'
playback.write_pcm(output/path,(v*manifest['gain'] for v in values))
clips.append({'file':path,'song':song,'part':part,'version':name,'context':context,'peak':peak})
if context=='fixed_backing':
# Exact slices retain the same backing and gain. Counts come from
# the completed source form, not a presumed 32-bar chorus.
measures=document['staves'][0]['measures'];durations=[model.fraction(m['duration']) for m in measures]
for excerpt_name,start,end in (('opening',F(),sum(durations[:8],F())),('ending',sum(durations[:-4],F()),sum(durations,F()))):
factor=F(60*playback.SAMPLE_RATE)/tempo
selection=values[round(start*factor):round(end*factor)]
clip=label+'_'+excerpt_name+'.wav'
playback.write_pcm(output/clip,(v*manifest['gain'] for v in selection))
clips.append({'file':clip,'song':song,'part':part,'version':name,'context':excerpt_name,'peak':peak})
manifest['pair_comparisons']={}
for song in sorted({c['song'] for c in clips}):
def events(part):
detail=manifest['solos'][song+'_'+part]['details']
return tuple((float(F(n['start'])),float(F(n['end'])),types.Pitch(n['pitch']['letter'],F(n['pitch']['alter']),n['pitch']['octave']).number())
for p in detail['phrases'] for n in p['draft']['notes'])
manifest['pair_comparisons'][song]=similarity.compare_events(events('electric'),events('acoustic'))
manifest['clips']=clips
(output/'report.json').write_text(json.dumps(manifest,indent=2))
(output/'notes.txt').write_text(''.join(notes_report))
rows=['<!doctype html><meta charset="utf-8"><title>Independent generation auditions</title><style>body{max-width:1100px;margin:2rem auto;font:16px system-ui}audio{width:420px}td{padding:.5rem}details{margin:1rem}</style>',
'<h1>Independent generation auditions</h1><p>Baseline and new music, same neutral rendering and fixed comparison backing. All files share gain 0.65; backing gain 0.55. Final arrangements use the new independent canonical backing. No subjective listening verdict has been made.</p><p><a href="report.json">Full roles, contracts and diagnostics</a> · <a href="notes.txt">Sounding note report</a></p>']
for key,detail in manifest['solos'].items():
rows.append('<h2>'+html.escape(key)+'</h2><table>')
for c in clips:
if c['song']+'_'+c['part']!=key:continue
label=c['version']+' / '+c['context']
rows.append('<tr><td>'+html.escape(label)+'</td><td><audio controls preload="none" src="'+c['file']+'"></audio></td><td><a href="'+c['file']+'">WAV</a></td></tr>')
rows.append('</table>')
(output/'index.html').write_text('\n'.join(rows))
def main():
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--assets',type=Path,required=True);p.add_argument('--baseline',type=Path);p.add_argument('--output',type=Path,required=True)
args=p.parse_args();export(args.assets,args.baseline,args.output)
if __name__=='__main__':main()
+5
View File
@@ -59,6 +59,11 @@ def read(path, role, form_start=None, pitch_convention='encoded'):
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'})
-41
View File
@@ -1,41 +0,0 @@
"""Summarize finished production decisions; no composition or reference reading."""
from __future__ import annotations
import argparse,collections,json
from fractions import Fraction as F
from pathlib import Path
from generator import vocabulary
def main():
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--assets',type=Path,required=True);p.add_argument('--output',type=Path,required=True);args=p.parse_args();args.output.mkdir(parents=True,exist_ok=True)
evidence={};text=['# Finished production evidence','', 'Times below are exact quarter-note beats relative to the solo. Counts describe the selected music; they are not targets or listening verdicts.','']
for path in sorted((args.assets/'analysis').glob('*.roles')):
roles=json.loads(path.read_text())
for role,v in roles.items():
key=path.stem+'/'+role
evidence[key]={'document_hash':vocabulary.fingerprint(v['document']),'trace':v['trace']}
if not role.endswith('_solo'):continue
d=v['details'];ps=d['phrases'];ns=[n for p in ps for n in p['draft']['notes']]
operations=collections.Counter(t['operation'] for p in ps for t in p['draft']['transformations'])
span=max(F(p['end']) for p in ps)-min(F(p['start']) for p in ps)
sounding=sum((F(n['end'])-F(n['start']) for n in ns),F())
summary={'attacks':sum(not n['continuation'] for n in ns),'quarter_beats':str(span),'occupancy':float(sounding/span),'fretboard':d['fretboard'],'operations_selected':dict(operations),'operation_funnel':d['selection_trace'].get('operations',{}),'expressions_selected':dict(collections.Counter(e['kind'] for e in d['expressions'])),'expression_funnel':d['selection_trace'].get('expression_operations',{}),'evaluation_total':d['selection_trace']['selection_total'],'unresolved':d['unresolved_treatments'],'boundaries':[]}
evidence[key].update(summary)
text += ['## '+key,'',f"{summary['attacks']} attacks over {span} quarter beats; sounding occupancy {summary['occupancy']:.1%}; sounding MIDI range {d['fretboard']['range_midi']}. Harmonic valid: {d['harmonic_valid']}; unresolved obligations: {len(d['unresolved_treatments'])}.",'', '| Span | Behavior | Destination / actual hold | Home identity |','|---|---|---|---|']
for phrase in ps:
anchors=[{k:a[k] for k in ('role','effect','pitch','actual_start','actual_end','completed')} for a in phrase['anchors']]
summary['boundaries'].append({'start':phrase['start'],'end':phrase['end'],'closure':phrase['closure'],'anchors':anchors,'home':phrase['home']})
destination='; '.join(a['effect']+' '+a['pitch']+' '+str(a['actual_start'])+'–'+str(a['actual_end']) for a in anchors) or 'no forced arrival'
text.append(f"| {phrase['start']}–{phrase['end']} | {phrase['closure']} / {phrase['ending']} | {destination} | `{phrase['home']}` |")
text += ['', 'Selected actions: '+', '.join(f'{k}: {v}' for k,v in sorted(operations.items()))+'.','', 'Typed treatments: '+str(summary['expressions_selected'])+'.','', '### Representative evaluated phrase','']
phrase=next(p for p in ps if p['draft']['notes']);evaluation=phrase['evaluation'];assert abs(phrase['score']-sum(evaluation['contributions'].values()))<1e-10
text += [f"Selected `{phrase['id']}`; score {phrase['score']:.9f}. Identity is a hard feature contract.",'','| Contribution | Raw | Weight | Weighted |','|---|---:|---:|---:|']
for k,value in evaluation['raw'].items():text.append(f"| {k} | {value:.9f} | {evaluation['weights'][k]} | {evaluation['contributions'][k]:.9f} |")
pool=d['selection_trace'].get('source_candidate_pools',{}).get(phrase['passage'],[])
alternatives=sorted((c for c in pool if c['id']!=phrase['draft']['id']),key=lambda c:(c['score'],c['id']))
text += ['', 'Other retained alternatives for this source window (possibly different incoming histories):']
for c in alternatives[:3]:text.append(f"- `{c['id']}`: {c['score']:.9f}, {c['attacks']} attacks; selected ending {c['ending']}.")
text += ['', 'These are local alternatives, not a proof of a global optimum. The chorus frontier compares complete histories using duration-weighted phrase scores. The JSON retains every reported contribution, operation cutoff counts and source input trace.','']
evidence[key]['winner_evaluation']=evaluation;evidence[key]['local_alternatives']=alternatives[:3]
(args.output/'evidence.json').write_text(json.dumps(evidence,indent=2))
(args.output/'phrase_traces.md').write_text('\n'.join(text)+'\n')
if __name__=='__main__':main()
+28 -3
View File
@@ -95,8 +95,23 @@ def compare(generated,reference):
'reference_playability':playability(reference),'reference_original':reference}
def gesture_coverage(raw):
"""Group timing-only retained-unit labels with their direct construction.
The source label can include rhythm edits such as '; diminution'. These
describe direct candidates, not additional harmonic proposal families.
Raw generation evidence remains untouched in the manifest.
"""
result={}
for label,counts in raw.items():
purpose=label if label.startswith(('direct','harmonic_arrival:','sustained_color')) else 'direct'
target=result.setdefault(purpose,dict.fromkeys(('proposed','retained_jobs','valid_routes','retained_units'),0))
for name,value in counts.items():target[name]+=value
return result
def render_events(notes, duration, tempo, root, cache):
"""The same neutral note envelopes as production_review; no expression edits."""
"""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)
@@ -187,12 +202,22 @@ def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None
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>']
'<p><a href="comparison.json">Exact timing, notes, harmony, annotations, placement, and phrase reports</a> · <a href="analysis/theory.md">Complete musical relationship review</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>',
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>']
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',{}))
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>')
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('</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>')
+181
View File
@@ -0,0 +1,181 @@
"""Explain complete optional solos. No composer calls or production-data writes."""
from __future__ import annotations
import argparse
import collections
import json
from fractions import Fraction as F
from pathlib import Path
from audition import reference_review, solo_compare
from generator import harmony, model
def chord(h):
if not h or not h['root']:return None
root=h['root']+('#'*int(F(h['alter'])) if F(h['alter'])>0 else 'b'*-int(F(h['alter'])))
if h['kind']=='other':
# The explicit displayed quality is evidence; never substitute a target
# chord for a missing source chord. Preserve any parse error in the report.
return harmony.parse_symbol(root+(h['display'] or ''))
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']]}
def members(h):
c=chord(h)
if not c:return {}
root=model.NATURAL[c['root']['letter']]+model.alteration(c['root']['alter'])
return {int(root+harmony.degree_semitones(d,a))%12:str(d) for d,a in harmony.degrees(c).items()}
def explain(review, source=None):
"""Observed relationships and hypotheses, not retrospective permissions."""
notes=solo_compare.events(review);offset=F(review['alignment']['encoded_offset'] or 0)
hs=[{**h,'time':str(F(h['time'])-offset)} for h in review['harmonies']]
output=[]
for i,n in enumerate(notes):
start,end=F(n['start']),F(n['end']);p=int(F(n['pitch']))
active=next((h for h in reversed(hs) if F(h['time'])<=start),None)
future=next((h for h in hs if F(h['time'])>start),None)
relation=[];uncertainty=[]
try:
cm=members(active);fm=members(future)
except (ValueError,KeyError) as e:cm=fm={};uncertainty.append(str(e))
if p%12 in cm:relation.append('chord member '+cm[p%12])
else:relation.append('non-basic pitch: needs collection/color/motion analysis')
if future and start<F(future['time'])<end and p%12 in fm:
relation.append('anticipated future chord member '+fm[p%12])
if i and i+1<len(notes):
a,b=notes[i-1],notes[i+1];x=p-int(F(a['pitch']));y=int(F(b['pitch']))-p
if F(a['end'])==start and end==F(b['start']) and abs(x)<=2 and abs(y)<=2:
relation.append('neighbor motion' if x==-y else 'directional passing motion' if x*y>0 else 'turn')
for target in notes[i+1:i+4]:
t=F(target['start']);q=int(F(target['pitch']))
if t-start>F(3,2):break
if abs(q-p)==1 and end<=t and t-end<=F(1,2):
relation.append('semitone approach to '+target['pitch']+' at '+target['start']);break
if end-start>=2:relation.append('sustained event; '+('breath follows' if i+1<len(notes) and F(notes[i+1]['start'])>end else 'continuation follows'))
label=[a['text'] for a in review['annotations'] if start<=F(a['time'])-offset<end]
output.append({**n,'relationships':relation,'annotation':label,'uncertainty':uncertainty})
def signature(ns):
origin=F(ns[0]['start']);base=int(F(ns[0]['pitch']))
return tuple((str(F(n['start'])-origin),str(F(n['end'])-F(n['start'])),int(F(n['pitch']))-base) for n in ns)
repeats=collections.defaultdict(list)
for i in range(len(notes)-3):repeats[signature(notes[i:i+4])].append(notes[i]['start'])
source_matches=[]
if source:
original=solo_compare.events(source)
fingerprints={signature(original[i:i+4]):original[i]['start'] for i in range(len(original)-3)}
source_matches=[{'solo_start':n['start'],'source_start':fingerprints[signature(notes[i:i+4])]} for i,n in enumerate(notes[:-3]) if signature(notes[i:i+4]) in fingerprints]
return {'events':output,'relative_four_event_returns':[{'signature':k,'starts':v} for k,v in repeats.items() if len(v)>1],
'source_four_event_relationships':source_matches,'source_relationship_limit':'Exact relative four-event evidence only; absence does not disprove thematic influence.',
'interpretation':'Descriptive hypotheses; no label waives production validation. No output from this command is loaded by production.'}
def rule_review(review, choices, offset):
"""Read-only recognition under production policies; never run composition.
Recognition deliberately need not cover every reference note. Failed or
ambiguous relationships remain failures, rather than changing a policy.
"""
import dataclasses
from generator import vocabulary
from generator.solo import types, harmonic, expressions
choices=tuple(dataclasses.replace(c,span=dataclasses.replace(c.span,start=c.span.start-offset,end=c.span.end-offset)) for c in choices)
h=harmonic.Harmony(choices);ns=[]
for i,n in enumerate(solo_compare.events(review)):
p=n['spelling'];ns.append(types.Note(F(n['start']),F(n['end']),types.Pitch(p['step'].lower(),F(p['alter']),p['octave']-1),event_id=str(i)))
contracts=[];rejections=collections.defaultdict(list)
def accept(c):
if expressions.validate(tuple(ns),(*contracts,c),h)[1]:return False
contracts.append(c);return True
for n in ns:
for boundary in (s.start for s in h.spans if n.start<s.start<n.end):
accept(types.ExpressionContract('recognition:'+n.event_id,'anticipation','early_chord_tone',(n,),n,h.at(boundary).chord,boundary=boundary))
for i,n in enumerate(ns):
span=h.at(n.start)
if not span or not span.scale:continue
if any(n in c.events for c in contracts):continue
if n.pitch.number()%12 in span.roles.basic:continue
for rule in vocabulary.data('harmony')['colors']:
target=ns[i+1] if rule.get('resolve') and i+1<len(ns) else n
c=types.ExpressionContract('recognition:'+n.event_id,'color',rule['id'],(n,),target,h.at(target.start).chord if h.at(target.start) else '')
if accept(c):break
written=harmony.parse_symbol(span.chord)
proposed=expressions.degree_pitch(written,rule['degree'],rule['alter'],n.pitch.number())
if expressions.quality(written) in rule['qualities'] and proposed.number()==n.pitch.number():
error=expressions.validate(tuple(ns),(*contracts,c),h)[1]
rejections[n.event_id].append({'rule':rule['id'],'expected_spelling':proposed.label(),'rejection':error})
for target in ns:
anticipated=next((c for c in contracts if c.kind=='anticipation' and c.target==target),None)
span=h.at(anticipated.boundary if anticipated else target.start)
if not span or not span.scale:continue
for rule in vocabulary.data('lead_ins')['patterns']:
start=target.start-F(rule['gap'])-sum(map(F,rule['durations']),F())
events=tuple(n for n in ns if start<=n.start<target.start)
if not events:continue
prior=next((n for n in reversed(ns) if n.end<=start),None)
c=types.ExpressionContract('recognition:'+target.event_id+':'+rule['id'],rule['kind'],rule['id'],events,target,span.chord,
boundary=anticipated.boundary if anticipated else None,preparation=prior if rule['kind']=='connection' else None)
if accept(c):break
permissions,error=expressions.validate(tuple(ns),tuple(contracts),h)
if error:raise ValueError(error)
events=[]
for n in ns:
spans=h.segments(n);mapped=bool(spans) and spans[0][1]==n.start and spans[-1][2]==n.end and all(s.scale and n.pitch.number()%12 in s.scale.pitches for s,a,b in spans)
basic=bool(spans) and all(n.pitch.number()%12 in s.roles.basic for s,a,b in spans)
permission=permissions.get(n.event_id)
status='recognized_treatment' if permission else 'ordinary_valid' if mapped and (basic or n.end-n.start<2) else 'unexplained_or_unsupported'
events.append({'start':str(n.start),'end':str(n.end),'pitch':n.pitch.label(),'mapped_throughout':mapped,
'chord_member_throughout':basic,'status':status,'rule':permission.rule if permission else None,
'candidate_rejections':rejections.get(n.event_id,[]) if status=='unexplained_or_unsupported' else [],
'harmonies':[s.chord for s,a,b in spans],'collections':[s.scale.label() if s.scale else None for s,a,b in spans]})
return {'events':events,'rules':dict(collections.Counter(c.rule for c in contracts)),
'statuses':dict(collections.Counter(n['status'] for n in events)),
'limit':'Conservative recognition, not optimal parsing. Unrecognized events may be valid under a relationship not recognized here. Production harmony comes from the original source, never the target.'}
def main():
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--reference',action='append',required=True,metavar='ROLE=XML')
p.add_argument('--source',action='append',required=True,metavar='SONG=XML')
p.add_argument('--generated-dir',type=Path,required=True)
p.add_argument('--output',type=Path,required=True)
args=p.parse_args();args.output.mkdir(parents=True,exist_ok=True)
sources={k:reference_review.read(v,'original') for k,v in (s.split('=',1) for s in args.source)}
from generator import musicxml
from generator.scales import timeline, selection, library
scales=library.load_library(Path('data/scales'));source_maps={}
for name,path in (s.split('=',1) for s in args.source):
document=musicxml.load_musicxml(path);staff=document['staves'][0]
end=sum((model.fraction(m['duration']) for m in staff['measures']),F())
source_maps[name]=selection.select(timeline.read(staff),timeline.Solo(F(),end,'analysis only'),scales)
manifest=json.loads((args.generated_dir/'manifest.solo').read_text())
results={};lines=['# Musical decisions in the four supplied solos','','Sounding pitch is encoded guitar pitch minus one octave. Times are exact quarter beats from the first written harmony; preceding material remains separate. All measures and all sounding events are retained.','',
'The relationships below are observations/hypotheses, not harmonic permission or a claim of listening. Source comparisons inspect relative pitch AND rhythm; annotations remain verbatim.','']
for role,path in (s.split('=',1) for s in args.reference):
ref=reference_review.read(path,role,pitch_convention='guitar-written')
item=manifest['solos'][role];actual=reference_review.read(args.generated_dir/item['file'],role,item['form_start_measure'])
analysis=explain(ref,sources[role.split('/')[0]]);comparison=solo_compare.compare(actual,ref)
source=sources[role.split('/')[0]]
recognized=rule_review(ref,source_maps[role.split('/')[0]],F(source['alignment']['encoded_offset'] or 0))
results[role]={'reference':ref,'analysis':analysis,'rule_review':recognized,'generated_analysis':explain(actual),
'exact_musical_match':comparison['exact_musical_match'],'exact_matching_attacks':comparison['exact_matching_attacks'],
'different_onset_positions':len(comparison['differences']),'reference_playability':comparison['reference_playability']}
lines.extend(['## '+role,'',f"Exact output match: **{comparison['exact_musical_match']}**; equal pitch/onset/release attacks: {comparison['exact_matching_attacks']}. This is not a perceptual similarity score.",'',
'| Original measure | Aligned onset | Sounding pitch / duration | Observed relationship | Original annotation |','|---|---|---|---|---|'])
for n in analysis['events']:
raw=n['spelling'];pitch=raw['step']+('#'*int(F(raw['alter'])) if F(raw['alter'])>0 else 'b'*-int(F(raw['alter'])))+str(raw['octave']-1)
lines.append(f"| {n['measure']} | {n['start']} | {pitch} / {F(n['end'])-F(n['start'])} | {'; '.join(n['relationships'])} | {'; '.join(n['annotation']).replace(chr(10),' / ')} |")
lines.extend(['',f"Relative four-event returns: {analysis['relative_four_event_returns']}",'',f"Exact relative source relationships: {analysis['source_four_event_relationships']}",'',
'General rule recognition against ORIGINAL source harmony: '+str(recognized['statuses']),
'Recognized policies: '+str(recognized['rules']),'',recognized['limit'],'',
'| Onset | Pitch | Collection-valid throughout | Recognition | Policy / source harmony |','|---|---|---|---|---|'])
for n in recognized['events']:
failures='; '.join(r['rule']+': '+r['rejection'].split(': ',1)[-1]+' (expected '+r['expected_spelling']+')' for r in n['candidate_rejections'])
lines.append(f"| {n['start']} | {n['pitch']} | {n['mapped_throughout']} | {n['status']} | {n['rule'] or ''} / {', '.join(n['harmonies'])} {failures} |")
(args.output/'theory.json').write_text(json.dumps(results,indent=2))
(args.output/'theory.md').write_text('\n'.join(lines)+'\n')
print('Reviewed every sounding event in',len(results),'complete reference solos; no composer invoked.')
if __name__=='__main__':main()