146 lines
8.8 KiB
Python
146 lines
8.8 KiB
Python
"""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()
|