Files
product-vamp/audition/production_review.py
T
2026-10-04 09:05:25 -05:00

146 lines
8.8 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.
"""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']:
for n in phrase['lead_stages'].get(stage,{}).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'];notes_report.append(f" {n['start']}–{n['end']} {tone.label()} {n['role']} string {pos[0]} fret {pos[1]}\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()