Simplification
This commit is contained in:
@@ -6,4 +6,4 @@ The manually transcribed first four chorus measures retain the vocal rhythm, inc
|
||||
|
||||
St. Louis Blues was considered, but the institutional original-score download was unavailable (403). Hindustan supplies the third independent source instead; no target arrangement was used to reconstruct missing music.
|
||||
|
||||
Run `python -m audition.transfer_review --output build/transfer_review` through the repository Python runner. This runs the normal independent generators; no tuning/selection result flows back into production.
|
||||
Evaluate these inputs using the ordinary `python -m generator.compose --song ... --role ... --data-dir data --output ...` command. They are transfer checks, not sources of production vocabulary.
|
||||
|
||||
+13347
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
@@ -1,233 +0,0 @@
|
||||
"""Independently authored operator, fitting-boundary and cache regressions."""
|
||||
import copy
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
import contextlib
|
||||
import io
|
||||
from unittest.mock import patch
|
||||
|
||||
from generator import resources, vocabulary
|
||||
from generator.solo import expressions, harmonic, operators, selection, types
|
||||
from tests import solo_fixtures as fixture, rhythm_fixtures, scale_fixtures
|
||||
from tests.test_phrase_composer import builder
|
||||
|
||||
class Operators(unittest.TestCase):
|
||||
def test_supported_sixteenth_tail_can_finish_the_allocation(self):
|
||||
b=builder(('CM7',),count=1)
|
||||
actions=b.operators.at(F(15,4))
|
||||
self.assertTrue(any(a.kind=='tone' and a.end==4 for a in actions))
|
||||
# A source/role cutoff does not require a new long cadence.
|
||||
b.start=F(15,4)
|
||||
phrases=b.compose()
|
||||
self.assertEqual(phrases[-1].end,F(4))
|
||||
self.assertLessEqual(sum(len(p.notes) for p in phrases),1)
|
||||
|
||||
def test_source_relation_uses_order_and_does_not_reward_subdivision(self):
|
||||
b=builder(('CM7',),count=1)
|
||||
slow=operators.Action(F(),F(4),tuple(fixture.note(i,i+1,p,event_id=str(i)) for i,p in enumerate(('c','d','e','f'))),'scalar')
|
||||
fast=dataclasses.replace(slow,end=F(2),notes=tuple(dataclasses.replace(n,start=n.start/2,end=n.end/2) for n in slow.notes))
|
||||
self.assertEqual(b.selector.evaluate(slow).raw['prior/source_relation'],b.selector.evaluate(fast).raw['prior/source_relation'])
|
||||
changed=dataclasses.replace(b.analysis,melody=tuple(fixture.note(i,i+1,p,event_id=str(i)) for i,p in enumerate(('e','f','g','a'))))
|
||||
other=selection.Selector(b.harmony,changed,b.selector.center,{})
|
||||
self.assertNotEqual(b.selector.evaluate(slow).raw.get('prior/source_relation',0),other.evaluate(slow).raw.get('prior/source_relation',0))
|
||||
|
||||
@unittest.skipUnless(__import__('importlib.util',fromlist=['find_spec']).find_spec('z3'),'development-only pinned solver not installed')
|
||||
def test_sparse_integer_intervals_are_exact_and_role_separable(self):
|
||||
from audition.structured_learning import solve
|
||||
rows=[{'role':'acoustic_solo','delta':{'duration/1':F(2,3)},'prior':F(1,2),'case':'independent'},
|
||||
{'role':'electric_solo','delta':{'duration/1_2':F(-1,2)},'prior':F(1,4),'case':'independent'}]
|
||||
result=solve(rows)
|
||||
self.assertEqual(result['weights'],{'acoustic_solo':{'duration/1':-3},'electric_solo':{'duration/1_2':3}})
|
||||
self.assertEqual(result,solve(list(reversed(rows))))
|
||||
impossible=[*rows,{'role':'acoustic_solo','delta':{'duration/1':F(-2,3)},'prior':F(1,2),'case':'opposite'}]
|
||||
self.assertEqual(solve(impossible)['status'],'incompatible_with_current_features_and_bounds')
|
||||
|
||||
def test_full_rollout_uses_the_requested_data_and_role_context(self):
|
||||
import copy
|
||||
from generator import vocabulary
|
||||
from audition.fit_selection import fit
|
||||
resources=copy.deepcopy(vocabulary.current()['data'])
|
||||
resources['roles']['difficulty']['triplet_max_bpm']=123
|
||||
b=builder(('CM7',),count=1)
|
||||
action=operators.Action(F(),F(4),(fixture.note(0,4,'c'),),'tone')
|
||||
def proposed(at,**kwargs):
|
||||
self.assertEqual(vocabulary.current()['role'],'acoustic_solo')
|
||||
self.assertEqual(vocabulary.data('roles')['difficulty']['triplet_max_bpm'],123)
|
||||
return (action,) if at==0 else ()
|
||||
with patch.object(b.operators,'at',side_effect=proposed),contextlib.redirect_stdout(io.StringIO()):
|
||||
result=fit([('synthetic','acoustic_solo',b,(action,))],1,resources=resources)
|
||||
self.assertTrue(result['complete'])
|
||||
|
||||
def test_complete_training_rounds_accumulate_only_compatible_evidence(self):
|
||||
from audition.train_completed import training_context,previous_constraints
|
||||
data=copy.deepcopy(vocabulary.current()['data'])
|
||||
context=training_context(data)
|
||||
data['roles']['roles']['electric_solo']['selection_weights']={'duration/1':7}
|
||||
data['roles']['provenance']='Explicit development trial'
|
||||
self.assertEqual(context,training_context(data))
|
||||
cases={'example':{'role':'electric_solo','source_sha256':'source','target_sha256':'example'}}
|
||||
old={'schema':'complete-candidate-training-1','context':context,'training_dependency':cases,
|
||||
'constraints':[{'case':'example','role':'electric_solo','delta':{'duration/1':'2/3'},'prior':'1/2'}]}
|
||||
row=previous_constraints(old,cases,context)[0]
|
||||
self.assertEqual(row['delta']['duration/1'],F(2,3));self.assertEqual(row['prior'],F(1,2))
|
||||
changed=copy.deepcopy(cases);changed['example']['target_sha256']='different'
|
||||
with self.assertRaises(ValueError):previous_constraints(old,changed,context)
|
||||
data['roles']['roles']['electric_solo']['register_center']+=12
|
||||
with self.assertRaises(ValueError):previous_constraints(old,cases,training_context(data))
|
||||
bad=copy.deepcopy(old);bad['constraints'][0]['delta']={'song/example':1}
|
||||
with self.assertRaises(ValueError):previous_constraints(bad,cases,context)
|
||||
|
||||
def test_coverage_diagnostic_does_not_accept_a_shortened_target_hold(self):
|
||||
from audition.structured_learning import CoverageTrace
|
||||
from generator.solo.construction import State
|
||||
held=fixture.note(0,2,'e')
|
||||
target=operators.Action(F(),F(2),(held,),'tone')
|
||||
trace=CoverageTrace((target,))
|
||||
self.assertFalse(trace.matches((dataclasses.replace(held,end=F(1)),),F(1)))
|
||||
self.assertTrue(trace.matches((held,),F(2)))
|
||||
# A computational cut through an already committed hold is different
|
||||
# from selecting a shorter attack and promising to repair it afterward.
|
||||
s=State(action=target,notes=(held,))
|
||||
trace('frontier',F(1),None,(s,),(s,))
|
||||
self.assertFalse(trace.records)
|
||||
|
||||
@unittest.skipUnless(__import__('importlib.util',fromlist=['find_spec']).find_spec('z3'),'development-only pinned solver not installed')
|
||||
def test_shared_fitter_learns_an_independent_synthetic_decision(self):
|
||||
from audition.fit_selection import fit
|
||||
b=builder(('CM7',),count=1)
|
||||
low=operators.Action(F(),F(4),(fixture.note(0,4,'c',event_id='low'),),'tone')
|
||||
high=operators.Action(F(),F(4),(fixture.note(0,4,'e',event_id='high'),),'tone')
|
||||
b.selector.weights={}
|
||||
with patch.object(b.operators,'at',side_effect=lambda at,**kwargs: (low,high) if at==0 else ()),contextlib.redirect_stdout(io.StringIO()):
|
||||
result=fit([('synthetic','acoustic_solo',b,(high,))],10)
|
||||
self.assertTrue(result['complete'])
|
||||
b.selector.weights=result['weights']['acoustic_solo']
|
||||
self.assertLess(b.selector.evaluate(high).total,b.selector.evaluate(low).total)
|
||||
selection.validate_weights(b.selector.weights)
|
||||
|
||||
@unittest.skipUnless(__import__('importlib.util',fromlist=['find_spec']).find_spec('z3'),'development-only pinned solver not installed')
|
||||
def test_fitting_normalization_covers_pickup_plus_whole_note(self):
|
||||
from audition.fit_selection import fit
|
||||
b=builder(('CM7',),count=2);b.end=F(17,4)
|
||||
low=operators.Action(F(),b.end,(fixture.note(0,F(1,4),'c',event_id='lead'),fixture.note(F(1,4),b.end,'e',event_id='low')),'anticipation')
|
||||
high=dataclasses.replace(low,notes=(low.notes[0],fixture.note(F(1,4),b.end,'g',event_id='high')))
|
||||
b.selector.weights={}
|
||||
with patch.object(b.operators,'at',side_effect=lambda at,**kwargs: (low,high) if at==0 else ()),contextlib.redirect_stdout(io.StringIO()):
|
||||
result=fit([('synthetic','acoustic_solo',b,(high,))],10)
|
||||
self.assertTrue(result['complete'])
|
||||
selection.validate_weights(result['weights']['acoustic_solo'])
|
||||
|
||||
def test_anticipated_reattack_is_not_an_invented_tie(self):
|
||||
b=builder(('C7','FM7'),count=2)
|
||||
actions=b.operators.at(F(7,2))
|
||||
a=next(a for a in actions if a.kind=='anticipation' and a.source=='early_reattack')
|
||||
self.assertEqual(len(a.notes),2)
|
||||
self.assertEqual(a.notes[0].end,a.notes[1].start)
|
||||
self.assertEqual(a.notes[0].pitch,a.notes[1].pitch)
|
||||
self.assertFalse(a.notes[1].continuation)
|
||||
self.assertTrue(b.harmony.evaluate(a.notes,expressions=a.contracts).valid)
|
||||
self.assertFalse(b.harmony.evaluate(a.notes[:1],expressions=a.contracts).valid)
|
||||
|
||||
def test_open_motion_and_all_color_octaves_are_constructed(self):
|
||||
b=builder(('C7','FM7'),count=2)
|
||||
actions=b.operators.at(F())
|
||||
self.assertTrue(any(len(a.notes)==1 and a.notes[0].pitch.number()%12==2 and a.end==F(1,2) for a in actions))
|
||||
h=builder(('E7','Am7'),count=2)
|
||||
values=h.operators.at(F())
|
||||
self.assertTrue(any(a.kind=='color' and len(a.notes)==1 and a.notes[0].pitch.number()==66 for a in values))
|
||||
self.assertTrue(any(a.kind=='rest' for a in values))
|
||||
|
||||
def test_anticipation_color_has_actual_future_resolution(self):
|
||||
h=harmonic.Harmony(fixture.choices(('Dm7','G7','CM7')))
|
||||
held=fixture.note(F(7,2),F(11,2),'c',alter=1,event_id='held')
|
||||
target=fixture.note(F(11,2),7,'d',event_id='target')
|
||||
c=types.ExpressionContract('anticipated','anticipated_color','lydian_color',(held,),target,'G7',boundary=F(4))
|
||||
self.assertTrue(h.evaluate((held,target),expressions=(c,)).valid)
|
||||
self.assertFalse(h.evaluate((held,),expressions=(c,)).valid)
|
||||
changed=dataclasses.replace(target,pitch=dataclasses.replace(target.pitch,octave=5))
|
||||
self.assertFalse(h.evaluate((held,changed),expressions=(c,)).valid)
|
||||
self.assertFalse(h.evaluate((held,target)).valid)
|
||||
|
||||
def test_delayed_approach_is_pending_and_expires(self):
|
||||
h=harmonic.Harmony(fixture.choices(('Dm7',)))
|
||||
approach=fixture.note(0,F(1,2),'c',alter=1,event_id='approach')
|
||||
target=fixture.note(3,F(7,2),'d',event_id='target')
|
||||
c=types.ExpressionContract('delayed','delayed_approach','bounded_semitone_resolution',(approach,),target,'Dm7')
|
||||
r=h.evaluate((approach,),expressions=(c,),until=F(1,2),complete=False)
|
||||
self.assertTrue(r.valid,r.error);self.assertEqual(r.pending,(c,))
|
||||
self.assertFalse(h.evaluate((approach,),expressions=(c,),until=4,complete=True).valid)
|
||||
self.assertTrue(h.evaluate((approach,target),expressions=(c,)).valid)
|
||||
|
||||
def test_distinct_pending_targets_are_visible_to_the_selector(self):
|
||||
b=builder(('Dm7',),count=2)
|
||||
actions=[a for a in b.operators.at(F()) if a.kind=='delayed_approach']
|
||||
first=actions[0]
|
||||
second=next(a for a in actions if a.notes==first.notes and a.contracts[0].target.start!=first.contracts[0].target.start)
|
||||
left,_=b.selector.features(first);right,_=b.selector.features(second)
|
||||
self.assertNotEqual(left,right)
|
||||
selection.validate_weights(dict.fromkeys(left,1))
|
||||
|
||||
def test_missed_pending_target_is_rejected_before_expensive_validation(self):
|
||||
from generator.solo.construction import State
|
||||
b=builder(('Dm7',),count=2)
|
||||
approach=fixture.note(0,F(1,2),'c',alter=1,event_id='approach')
|
||||
target=fixture.note(3,4,'d',event_id='target')
|
||||
c=types.ExpressionContract('delayed','delayed_approach','bounded_semitone_resolution',(approach,),target,'Dm7')
|
||||
state=State(pending=(c,))
|
||||
wrong=operators.Action(F(3),F(4),(fixture.note(3,4,'e',event_id='wrong'),),'tone')
|
||||
with patch.object(b.harmony,'evaluate',side_effect=AssertionError('unnecessary full check')):
|
||||
self.assertIsNone(b.advance(state,None,wrong))
|
||||
good=dataclasses.replace(wrong,notes=(target,))
|
||||
self.assertIsNotNone(b.advance(state,None,good))
|
||||
|
||||
def test_functional_degree_does_not_depend_on_enharmonic_label(self):
|
||||
h=harmonic.Harmony(fixture.choices(('E7','Am7')))
|
||||
n=fixture.note(0,2,'g',event_id='sharp9')
|
||||
c=types.ExpressionContract('color','color','dominant_sharp_ninth',(n,),n,'E7')
|
||||
self.assertTrue(h.evaluate((n,),expressions=(c,)).valid)
|
||||
wrong=dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,alter=F(1)))
|
||||
self.assertFalse(h.evaluate((wrong,),expressions=(dataclasses.replace(c,events=(wrong,),target=wrong),)).valid)
|
||||
|
||||
def test_weight_contributions_and_target_free_features(self):
|
||||
b=builder(count=1)
|
||||
a=next(a for a in b.operators.at(F()) if a.kind=='tone')
|
||||
v=b.selector.evaluate(a)
|
||||
self.assertEqual(v.total,sum(v.contributions.values()))
|
||||
self.assertEqual(v.total,b.selector.cost(a))
|
||||
key='duration/'+str(a.end-a.start).replace('/','_');b.selector.weights={key:3}
|
||||
changed=b.selector.evaluate(a)
|
||||
self.assertEqual(changed.total,b.selector.cost(a))
|
||||
self.assertEqual(changed.total-v.total,3*v.raw[key])
|
||||
with self.assertRaises(ValueError):selection.validate_weights({'song/all_of_me':1})
|
||||
|
||||
def test_derivation_cannot_inject_an_illegal_reference_note(self):
|
||||
from audition.fit_selection import derive
|
||||
b=builder(('CM7',),count=1)
|
||||
target={'alignment':{'encoded_offset':'0'},'sounding_events':[{'start':'0','end':'4','sounding_midi':'99','pitch':{},'measure':1}]}
|
||||
path,result=derive(b,target)
|
||||
self.assertFalse(result['complete']);self.assertFalse(path)
|
||||
|
||||
class CompiledResources(unittest.TestCase):
|
||||
def test_compiled_and_xml_libraries_have_identical_contents(self):
|
||||
original=(scale_fixtures.library(),fixture.library(),rhythm_fixtures.library())
|
||||
import json
|
||||
encoded=json.loads(json.dumps(resources.encode(original),default=str))
|
||||
restored=resources.decode(encoded)
|
||||
self.assertEqual(original[0],restored[0])
|
||||
self.assertEqual(original[1].paths,restored[1].paths)
|
||||
self.assertEqual(original[2].snippets,restored[2].snippets)
|
||||
|
||||
def test_warm_cache_avoids_xml_import_and_corruption_recompiles(self):
|
||||
original=(scale_fixtures.library(),fixture.library(),rhythm_fixtures.library())
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
with patch('generator.resources.scales.load_library',return_value=original[0]) as sc,patch('generator.resources.sequences.load_library',return_value=original[1]),patch('generator.resources.rhythm.load_library',return_value=original[2]):
|
||||
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
|
||||
self.assertEqual(sc.call_count,1)
|
||||
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
|
||||
self.assertEqual(sc.call_count,1)
|
||||
next((Path(root)/'_resources').glob('*.json')).write_text('{}')
|
||||
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
|
||||
self.assertEqual(sc.call_count,2)
|
||||
@@ -124,7 +124,9 @@ class MusicModelTests(unittest.TestCase):
|
||||
self.assertEqual((output / 'last-good').read_text(), 'preserved')
|
||||
(data / 'songs' / 'bad.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()), encoding='utf-8')
|
||||
args.check = True
|
||||
with self.assertRaisesRegex(ValueError, 'summary'):
|
||||
# --check uses the validation-only repository fixture. An alternate
|
||||
# source tree fails its form contract before atomic publication.
|
||||
with self.assertRaisesRegex(ValueError, 'performance generator drift'):
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual((output / 'last-good').read_text(), 'preserved')
|
||||
def test_instrument_transposition_is_converted_once(self):
|
||||
|
||||
+142
-73
@@ -1,96 +1,165 @@
|
||||
"""General, independently authored capabilities of the active action search."""
|
||||
import dataclasses, unittest
|
||||
from fractions import Fraction as F
|
||||
"""Independent musical and selection contracts for complete phrase generation."""
|
||||
import dataclasses
|
||||
import fractions
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from generator import model, vocabulary
|
||||
from generator.scales import selection, timeline
|
||||
from generator.solo import analysis, construction, harmonic, profiles, types, placement, operators
|
||||
from generator.solo import analysis, construction, harmonic, profiles, types, placement
|
||||
from generator.solo import selection as evaluation
|
||||
from tests import solo_fixtures as fixtures, rhythm_fixtures, scale_fixtures
|
||||
|
||||
|
||||
def builder(symbols=('C7','FM7','Dm7','G7'),seed='independent',count=8):
|
||||
source=fixtures.song(count=count,chords=symbols);a=analysis.analyze(source);ms=source['staves'][0]['measures']
|
||||
end=sum(model.fraction(m['duration']) for m in ms)
|
||||
cs=selection.select(timeline.read(source['staves'][0]),timeline.Solo(F(),end,'test'),scale_fixtures.library())
|
||||
cs=selection.select(timeline.read(source['staves'][0]),timeline.Solo(0,end,'test'),scale_fixtures.library())
|
||||
return construction.Builder(a,ms,harmonic.Harmony(cs,profiles.Instrument(reference=60)),fixtures.library(),rhythm_fixtures.library(),end,seed)
|
||||
|
||||
|
||||
class PhraseConstruction(unittest.TestCase):
|
||||
def test_candidates_include_open_motion_arrivals_and_joint_expression(self):
|
||||
b=builder(count=2);values=b.operators.at(F())
|
||||
self.assertTrue(any(a.kind=='tone' and a.end==F(1,2) for a in values))
|
||||
self.assertTrue(any(len(a.notes)==1 and a.end>=2 for a in values))
|
||||
self.assertTrue(any(a.contracts for a in values))
|
||||
for a in values:
|
||||
checked=b.harmony.evaluate(a.notes,until=a.end,complete=False,expressions=a.contracts)
|
||||
self.assertTrue(checked.valid,checked.error)
|
||||
evaluation=b.selector.evaluate(a)
|
||||
self.assertEqual(evaluation.total,sum(evaluation.contributions.values()))
|
||||
self.assertTrue(all(a.start<=n.start<n.end<=a.end for n in a.notes))
|
||||
def test_completed_candidates_are_valid_and_cover_the_requested_span(self):
|
||||
b=builder(count=4)
|
||||
values=b.candidates(fractions.Fraction(),b.end,construction.State())
|
||||
self.assertGreater(len(values),1)
|
||||
for p,home in values:
|
||||
self.assertEqual((p.start,p.end),(0,b.end))
|
||||
self.assertTrue(b.harmony.evaluate(p.notes,expressions=p.expressions,complete=True).valid)
|
||||
self.assertEqual(p.evaluation.total,sum(p.evaluation.contributions.values()))
|
||||
self.assertTrue(all(p.start<=n.start<n.end<=p.end for n in p.notes))
|
||||
self.assertTrue(any(p.destination for p,_ in values))
|
||||
|
||||
def test_audible_theme_returns_and_answers_are_reachable(self):
|
||||
def test_open_boundaries_are_real_and_dont_force_final_chord_tones(self):
|
||||
b=builder(('C7',),count=8)
|
||||
values=b.candidates(fractions.Fraction(),fractions.Fraction(16),construction.State())
|
||||
self.assertTrue(any(p.ending=='open' and p.destination is None for p,_ in values))
|
||||
self.assertTrue(any(p.ending!='open' for p,_ in values))
|
||||
|
||||
def test_realized_theme_is_retained_separately_from_recent_notes(self):
|
||||
b=builder(('CM7',),count=8);b.compose()
|
||||
for s in b.completed:
|
||||
self.assertGreaterEqual(len(s.home),3)
|
||||
self.assertTrue(set(s.home).issubset(set(s.phrases[0].notes)))
|
||||
self.assertNotEqual(s.home,s.notes[-len(s.home):])
|
||||
|
||||
def test_return_and_answer_are_actual_candidate_operations(self):
|
||||
b=builder(('CM7',),count=4)
|
||||
home=(fixtures.note(0,1,'c'),fixtures.note(1,2,'e'),fixtures.note(2,3,'d'))
|
||||
state=construction.State(home=home)
|
||||
values=b.candidates(fractions.Fraction(4),fractions.Fraction(16),state)
|
||||
self.assertIn('return',{p.purpose for p,_ in values})
|
||||
self.assertIn('answer',{p.purpose for p,_ in values})
|
||||
for p,_ in values:
|
||||
if p.purpose=='return':self.assertEqual([n.end-n.start for n in p.notes[:3]],[1,1,1])
|
||||
|
||||
def test_disabled_expressions_are_strict_and_repeatable(self):
|
||||
b=builder(count=2);b.phrases.enabled=False
|
||||
a=b.compose();c=b.compose()
|
||||
self.assertEqual(a,c)
|
||||
self.assertFalse(any(p.expressions for p in a))
|
||||
self.assertTrue(all(n.pitch.number()%12 in types.permitted(b.harmony.choices,n.start,n.end) for p in a for n in p.notes))
|
||||
|
||||
def test_actual_incoming_pitch_conditions_whole_routes(self):
|
||||
b=builder(('CM7',),count=2)
|
||||
home=(fixtures.note(0,F(1,2),'c'),fixtures.note(F(1,2),1,'d'),fixtures.note(1,2,'e'))
|
||||
for kind in ('return','answer'):
|
||||
values=b.operators.related(F(4),home,kind)
|
||||
self.assertTrue(values,kind)
|
||||
self.assertTrue(all(len(a.notes)==len(home) and a.kind==kind for a in values))
|
||||
rhythm=((fractions.Fraction(),fractions.Fraction(1,2)),(fractions.Fraction(1,2),fractions.Fraction(1)))
|
||||
low=b.phrases.route(rhythm,(0,2),fixtures.note(-1,0,'c',octave=3),60,'connect')
|
||||
high=b.phrases.route(rhythm,(0,2),fixtures.note(-1,0,'c',octave=5),60,'connect')
|
||||
self.assertIsNotNone(low);self.assertIsNotNone(high)
|
||||
self.assertNotEqual(low,high)
|
||||
|
||||
def test_disabled_expressions_stay_strict(self):
|
||||
b=builder(count=1);b.operators.enabled=False
|
||||
ps=b.compose();ns=tuple(n for p in ps for n in p.notes)
|
||||
self.assertFalse(any(p.expressions for p in ps))
|
||||
self.assertTrue(all(n.pitch.number()%12 in types.permitted(b.harmony.choices,n.start,n.end) for n in ns))
|
||||
again=b.compose()
|
||||
self.assertEqual(ps,again)
|
||||
def test_arpeggio_routes_use_written_chord_members(self):
|
||||
b=builder(('C7',),count=1)
|
||||
rhythm=tuple((fractions.Fraction(i,2),fractions.Fraction(i+1,2)) for i in range(4))
|
||||
result=b.phrases.route(rhythm,(0,4,7,10),None,60,'arpeggio')
|
||||
self.assertIsNotNone(result)
|
||||
self.assertTrue(all(n.pitch.number()%12 in b.harmony.at(n.start).roles.basic for n in result[0]))
|
||||
|
||||
def test_role_domains_and_complete_tied_position(self):
|
||||
def test_scale_routes_retain_exact_degrees_from_actual_pitch(self):
|
||||
b=builder(('CM7',),count=1)
|
||||
scale=scale_fixtures.library().scale('major',1,'C')
|
||||
self.assertEqual(b.phrases.scale_step(scale,64,1).number(),65) # E to F
|
||||
self.assertEqual(b.phrases.scale_step(scale,71,1).number(),72) # B to C
|
||||
self.assertEqual(b.phrases.scale_step(scale,48,-1).number(),47)
|
||||
self.assertEqual(b.phrases.scale_step(scale,61,1).number(),62) # enter a new map
|
||||
rhythm=tuple((fractions.Fraction(i,2),fractions.Fraction(i+1,2)) for i in range(4))
|
||||
realized=b.phrases.route(rhythm,(0,1,2,3),None,64,'connect',degree_path=True)
|
||||
self.assertIsNotNone(realized)
|
||||
indices=[types.scale_index(b.harmony.at(n.start).scale,n.pitch.number()) for n in realized[0]]
|
||||
self.assertEqual([b-a for a,b in zip(indices,indices[1:])],[1,1,1])
|
||||
|
||||
def test_no_chord_is_explicit_space(self):
|
||||
s=fixtures.compose(fixtures.song(count=2,chords=()))
|
||||
self.assertFalse(s.notes());self.assertEqual(s.phrases[-1].end,8)
|
||||
|
||||
def test_source_features_retain_rhythm_and_order_without_absolute_onsets(self):
|
||||
from generator.solo.phrases import source_ideas
|
||||
b=builder(count=2);original=source_ideas(b.analysis)
|
||||
moved=dataclasses.replace(b.analysis,melody=tuple(dataclasses.replace(n,start=n.start+20,end=n.end+20) for n in b.analysis.melody))
|
||||
shifted=source_ideas(moved)
|
||||
self.assertEqual({(v.rhythm,v.intervals) for v in original},{(v.rhythm,v.intervals) for v in shifted})
|
||||
|
||||
def test_local_best_can_lose_after_the_following_phrase(self):
|
||||
b=builder(('CM7',),count=2)
|
||||
def candidate(start,end,state):
|
||||
pitches=('c','e') if start==0 else ('g',)
|
||||
result=[]
|
||||
for letter in pitches:
|
||||
n=fixtures.note(start,end,letter)
|
||||
cost=0 if letter=='c' else 1 if letter=='e' else 20 if state.notes[0].pitch.letter=='c' else 0
|
||||
e=evaluation.evaluate((n,),start,end,b.harmony)
|
||||
e=dataclasses.replace(e,total=fractions.Fraction(cost))
|
||||
result.append((types.Phrase(letter,start,end,(n,),'statement','up','open',evaluation=e),(n,)))
|
||||
return result
|
||||
with patch.object(b.phrases,'horizon',side_effect=lambda start:start+4),patch.object(b,'candidates',side_effect=candidate):
|
||||
result=b.compose()
|
||||
self.assertEqual(result[0].notes[0].pitch.letter,'e')
|
||||
|
||||
def test_both_roles_use_all_strings_and_ties_keep_position(self):
|
||||
for role in ('acoustic','electric'):
|
||||
p=profiles.for_part(role);self.assertEqual(p.strings,(1,2,3,4,5,6))
|
||||
for midi in range(41,77):self.assertTrue(placement.positions(midi,p))
|
||||
a=fixtures.note(0,2,'e',event_id='held');ns=(a,dataclasses.replace(a,start=F(2),end=F(4),continuation=True))
|
||||
positioned=placement.place(ns,F(120))[1]
|
||||
self.assertEqual(positioned[0].position,positioned[1].position)
|
||||
self.assertEqual(len(types.sounding_notes(positioned)),1)
|
||||
n=fixtures.note(0,2,'e');placed=placement.place((n,dataclasses.replace(n,start=fractions.Fraction(2),end=fractions.Fraction(4),continuation=True)),120)[1]
|
||||
self.assertEqual(placed[0].position,placed[1].position)
|
||||
|
||||
def test_complete_routes_cross_boundaries_without_a_forced_chord_tone(self):
|
||||
b=builder(('C7','DmM7'),count=2)
|
||||
values=b.operators.at(F(3))
|
||||
crossed=[a for a in values if a.start<4<a.end and a.kind=='scalar']
|
||||
self.assertTrue(crossed)
|
||||
for a in crossed:self.assertTrue(b.harmony.evaluate(a.notes).valid)
|
||||
one=builder(('C7',),count=1)
|
||||
self.assertTrue(any(a.kind=='tone' and a.notes[0].pitch.number()%12==5 and a.end==F(1,2) for a in one.operators.at(F())))
|
||||
|
||||
def test_actual_incoming_state_changes_ranking(self):
|
||||
b=builder(count=1)
|
||||
a=b.candidates(F(),construction.State(recent=(fixtures.note(-1,0,'c',octave=3),)))
|
||||
c=b.candidates(F(),construction.State(recent=(fixtures.note(-1,0,'c',octave=5),)))
|
||||
self.assertNotEqual([v[1].key for v in a],[v[1].key for v in c])
|
||||
|
||||
def test_handoff_is_not_forced_to_have_a_long_note(self):
|
||||
b=builder(count=1)
|
||||
values=b.operators.at(F(7,2))
|
||||
self.assertTrue(any(a.kind=='tone' and a.end==4 for a in values))
|
||||
self.assertTrue(any(a.kind=='rest' and a.end==4 for a in values))
|
||||
|
||||
def test_cached_actions_do_not_copy_source_ancestry(self):
|
||||
b=builder(count=1);a=b.operators.at(F());self.assertIs(a,b.operators.at(F()))
|
||||
self.assertTrue(all(not n.sources for action in a for n in action.notes))
|
||||
|
||||
class EvaluationContract(unittest.TestCase):
|
||||
def test_tie_partition_is_invariant_and_reattack_is_distinct(self):
|
||||
b=builder(('C7',),count=1);n=fixtures.note(0,4,'e',event_id='hold')
|
||||
a=operators.Action(F(),F(4),(n,),'tone')
|
||||
split=(dataclasses.replace(n,end=F(2)),dataclasses.replace(n,start=F(2),continuation=True))
|
||||
x=b.selector.evaluate(a);y=b.selector.evaluate(dataclasses.replace(a,notes=split))
|
||||
self.assertEqual(x,y)
|
||||
reattack=tuple(dataclasses.replace(n,continuation=False) for n in split)
|
||||
self.assertNotEqual(x,b.selector.evaluate(dataclasses.replace(a,notes=reattack)))
|
||||
def test_ties_and_partitions_preserve_evaluation(self):
|
||||
b=builder(('C7',),count=1);n=fixtures.note(0,4,'e')
|
||||
split=(dataclasses.replace(n,end=fractions.Fraction(2)),dataclasses.replace(n,start=fractions.Fraction(2),continuation=True))
|
||||
self.assertEqual(evaluation.evaluate((n,),0,4,b.harmony),evaluation.evaluate(split,0,4,b.harmony))
|
||||
reattacks=tuple(dataclasses.replace(n,continuation=False) for n in split)
|
||||
self.assertNotEqual(evaluation.evaluate((n,),0,4,b.harmony),evaluation.evaluate(reattacks,0,4,b.harmony))
|
||||
|
||||
def test_target_touch_and_repeated_attacks_do_not_fulfill_hold(self):
|
||||
a=fixtures.note(2,4,'e',event_id='arrival')
|
||||
anchor=types.Anchor('held',F(2),F(2),a.pitch,frozenset({4}),(41,76),F(2),'terminal','C7',hold_until=F(4))
|
||||
ns=(dataclasses.replace(a,end=F(3)),dataclasses.replace(a,start=F(3),event_id='again'))
|
||||
self.assertFalse(harmonic.anchors_valid((anchor,),ns,F(4),complete=True,closure='closed')[0])
|
||||
self.assertTrue(harmonic.anchors_valid((anchor,),(a,),F(4),complete=True,closure='closed')[0])
|
||||
def test_target_touch_does_not_fulfill_sustained_ending(self):
|
||||
n=fixtures.note(2,4,'e');anchor=types.Anchor('held',2,2,n.pitch,frozenset({4}),(41,76),2,'terminal','C7',hold_until=4)
|
||||
reattacks=(dataclasses.replace(n,end=3),dataclasses.replace(n,start=3,event_id='again'))
|
||||
self.assertFalse(harmonic.anchors_valid((anchor,),reattacks,4,complete=True,closure='closed')[0])
|
||||
self.assertTrue(harmonic.anchors_valid((anchor,),(n,),4,complete=True,closure='closed')[0])
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
def test_silence_cannot_win_through_missing_register(self):
|
||||
b=builder(('C7',),count=1)
|
||||
silent=evaluation.evaluate((),0,4,b.harmony)
|
||||
sounding=evaluation.evaluate((fixtures.note(0,4,'e'),),0,4,b.harmony)
|
||||
self.assertGreater(silent.total,sounding.total)
|
||||
|
||||
class ExpressionConstruction(unittest.TestCase):
|
||||
def test_prepared_suspension_is_constructed_and_keeps_terminal_hold(self):
|
||||
b=builder(('Gm7','C7'),count=2)
|
||||
held=fixtures.note(fractions.Fraction(5,2),4,'f',event_id='held')
|
||||
target=fixtures.note(4,6,'e',event_id='target')
|
||||
terminal=fixtures.note(6,8,'g',event_id='terminal')
|
||||
original=b.phrases.choose
|
||||
with patch.object(b.phrases,'choose',side_effect=lambda values,domain,key:target if domain=='expression_target' else original(values,domain,key)):
|
||||
ns,cs=b.phrases.expression((held,target,terminal),(),(),0,1)
|
||||
self.assertEqual(cs[0].kind,'suspension')
|
||||
self.assertEqual(ns[-1],terminal)
|
||||
self.assertTrue(b.harmony.evaluate(ns,expressions=cs,complete=True).valid)
|
||||
|
||||
def test_anticipation_is_constructed_without_consuming_target_hold(self):
|
||||
b=builder(('C7','FM7'),count=2)
|
||||
before=fixtures.note(3,4,'g',event_id='before')
|
||||
target=fixtures.note(4,6,'a',event_id='target')
|
||||
ns,cs=b.phrases.expression((before,target),(),(),0,1)
|
||||
self.assertEqual(cs[0].kind,'anticipation')
|
||||
self.assertLess(ns[-1].start,4);self.assertEqual(ns[-1].end,6)
|
||||
self.assertTrue(b.harmony.evaluate(ns,expressions=cs,complete=True).valid)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
from generator import resources
|
||||
from tests import scale_fixtures, rhythm_fixtures
|
||||
from tests import solo_fixtures as fixture
|
||||
|
||||
class ResourceCache(unittest.TestCase):
|
||||
def test_compiled_and_xml_libraries_have_identical_contents(self):
|
||||
original=(scale_fixtures.library(),fixture.library(),rhythm_fixtures.library())
|
||||
import json
|
||||
encoded=json.loads(json.dumps(resources.encode(original),default=str))
|
||||
restored=resources.decode(encoded)
|
||||
self.assertEqual(original[0],restored[0])
|
||||
self.assertEqual(original[1].paths,restored[1].paths)
|
||||
self.assertEqual(original[2].snippets,restored[2].snippets)
|
||||
|
||||
def test_warm_cache_avoids_xml_import_and_corruption_recompiles(self):
|
||||
original=(scale_fixtures.library(),fixture.library(),rhythm_fixtures.library())
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
with patch('generator.resources.scales.load_library',return_value=original[0]) as sc,patch('generator.resources.sequences.load_library',return_value=original[1]),patch('generator.resources.rhythm.load_library',return_value=original[2]):
|
||||
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
|
||||
self.assertEqual(sc.call_count,1)
|
||||
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
|
||||
self.assertEqual(sc.call_count,1)
|
||||
next((Path(root)/'_resources').glob('*.json')).write_text('{}')
|
||||
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
|
||||
self.assertEqual(sc.call_count,2)
|
||||
@@ -73,5 +73,3 @@ class SoloInstrumentAndIdentity(unittest.TestCase):
|
||||
acoustic=profiles.for_part('acoustic',source);electric=profiles.for_part('electric',source)
|
||||
self.assertLess(electric.reference-acoustic.reference,12)
|
||||
self.assertEqual(acoustic.melody_range,electric.melody_range)
|
||||
self.assertEqual(acoustic.cost(acoustic.reference+4),0)
|
||||
self.assertGreater(acoustic.cost(acoustic.reference+9),0)
|
||||
|
||||
@@ -11,7 +11,8 @@ from unittest.mock import patch
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
from audition import reference_review, solo_compare
|
||||
from tests import solo_compare
|
||||
reference_review = solo_compare
|
||||
from generator import vocabulary, production
|
||||
from generator.solo import export, harmonic, notation, placement, types
|
||||
from tests.solo_fixtures import song
|
||||
@@ -29,6 +30,20 @@ def pitched(duration, tie='', octave=5):
|
||||
|
||||
|
||||
class ComparisonTests(unittest.TestCase):
|
||||
def test_comparison_separates_pitch_timing_and_sounding_duration(self):
|
||||
events=lambda pitch,end: [dict(start='0',end=end,pitch=pitch)]
|
||||
a=events(60,'2')
|
||||
octave=solo_compare.relationships(a,events(72,'2'))
|
||||
self.assertEqual(octave['pitch_sequence_edit_distance'],1)
|
||||
self.assertEqual(octave['timed_attack_edit_distance'],0)
|
||||
self.assertEqual(octave['same_sounding_pitch_beats'],'0')
|
||||
self.assertEqual(octave['same_pitch_class_beats'],'2')
|
||||
shorter=solo_compare.relationships(a,events(60,'1'))
|
||||
self.assertEqual(shorter['pitch_sequence_edit_distance'],0)
|
||||
self.assertEqual(shorter['timed_attack_edit_distance'],1)
|
||||
self.assertEqual(shorter['same_sounding_pitch_beats'],'1')
|
||||
self.assertEqual(shorter['either_sounding_beats'],'2')
|
||||
|
||||
def test_exact_cli_is_a_real_exit_gate(self):
|
||||
args=['solo_compare','--generated-dir','unused','--output','unused','--reference','fixture/acoustic=unused','--require-exact']
|
||||
review={'exact_musical_match':False,'exact_matching_attacks':0,'differences':[{}],'rest_difference':True}
|
||||
@@ -85,6 +100,20 @@ class ComparisonTests(unittest.TestCase):
|
||||
b=self.review(root,'reference.xml',xml(pitched(24),leadin=prelude),form_start=2,pitch_convention='guitar-written')
|
||||
self.assertEqual(solo_compare.audition_bounds(a,b),(F(-4),F(4)))
|
||||
|
||||
def test_fixed_backing_includes_the_production_leadin(self):
|
||||
import array
|
||||
from generator import playback, score
|
||||
document=song(count=2,chords=('CM7',))
|
||||
document['staves'][0]['measures'][1]['section_label']='Acoustic Solo'
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
prelude='<measure number="0"><attributes><divisions>6</divisions></attributes>'+pitched(24)+'</measure>'
|
||||
actual=self.review(root,'actual.xml',xml(pitched(24),leadin=prelude),form_start=2,pitch_convention='guitar-written')
|
||||
with patch.object(score,'load_json',return_value=document),patch.object(solo_compare,'render_events',return_value=array.array('f',[0]*10)) as render,patch.object(playback,'write_pcm'):
|
||||
solo_compare.audition_pair(actual,actual,'fixture/acoustic',root,Path(root),{})
|
||||
# Backing's preceding bar starts at the same audition clock as the lead.
|
||||
self.assertEqual(render.call_args_list[0].args[0][0][0],0)
|
||||
self.assertTrue(any(start>=4 for start,end,pitch in render.call_args_list[0].args[0]))
|
||||
|
||||
def test_triplet_time_and_annotations_are_literal(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
triplet=pitched(2).replace('<type>quarter</type>','<type>eighth</type><time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>')
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
"""Independent counterfactuals: no authored target needed."""
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
from unittest.mock import patch
|
||||
import unittest
|
||||
from generator.solo import construction, operators, selection
|
||||
from tests.solo_fixtures import note
|
||||
from tests.test_phrase_composer import builder
|
||||
|
||||
|
||||
class CompleteSelection(unittest.TestCase):
|
||||
def test_fast_signature_comparison_is_exact(self):
|
||||
home=tuple(note(F(i,3),F(i+1,3),('c','e','g')[i%3]) for i in range(12))
|
||||
variants=[(),home,home[:1],tuple(dataclasses.replace(n,start=n.start+5,end=n.end+5) for n in home)]
|
||||
for i in range(len(home)):
|
||||
for field in ('start','end'):
|
||||
values=list(home);values[i]=dataclasses.replace(values[i],**{field:getattr(values[i],field)+F(1,12)})
|
||||
variants.append(tuple(values))
|
||||
values=list(home);values[i]=dataclasses.replace(values[i],pitch=dataclasses.replace(values[i].pitch,octave=5))
|
||||
variants.append(tuple(values))
|
||||
expected=selection.signature(home)
|
||||
for notes in variants:self.assertEqual(selection.matches_signature(notes,expected),selection.signature(notes)==expected)
|
||||
|
||||
def test_locally_cheaper_opening_can_lose_complete_phrase(self):
|
||||
b=builder(('CM7',),count=1)
|
||||
# C is locally cheaper. Its only continuation leaps to high C, whereas
|
||||
# the more costly E opening reaches nearby G. All notes are chord tones.
|
||||
low=operators.Action(F(),F(2),(note(0,2,'c',octave=4),),'tone')
|
||||
high=operators.Action(F(),F(2),(note(0,2,'e',octave=4),),'tone')
|
||||
bad=operators.Action(F(2),F(4),(note(2,4,'c',octave=5),),'tone')
|
||||
good=operators.Action(F(2),F(4),(note(2,4,'g',octave=4),),'tone')
|
||||
b.selector.weights={'harmony/major/4/2':1}
|
||||
self.assertLess(b.selector.cost(low),b.selector.cost(high))
|
||||
def options(at,state):
|
||||
xs=(low,high) if at==0 else (bad,) if state.notes[-1].pitch.number()==60 else (good,)
|
||||
return tuple((b.selector.evaluate(a),a) for a in xs)
|
||||
with patch.object(b,'candidates',side_effect=options):b.compose()
|
||||
self.assertEqual(b.selected_actions,(high,good))
|
||||
self.assertEqual(len(b.completed),2)
|
||||
|
||||
def test_constructor_labels_and_segmentation_do_not_change_score(self):
|
||||
b=builder(('CM7',),count=1)
|
||||
ns=(note(0,1,'c'),note(1,2,'e'),note(3,4,'g'))
|
||||
whole=operators.Action(F(),F(4),ns,'arpeggio')
|
||||
split=(operators.Action(F(),F(2),ns[:2],'scalar'),operators.Action(F(2),F(5,2),(),'rest'),
|
||||
operators.Action(F(5,2),F(3),(),'rest'),operators.Action(F(3),F(4),ns[2:],'answer'))
|
||||
self.assertEqual(b.selector.complete((whole,)),b.selector.complete(split))
|
||||
self.assertEqual(b.selector.evaluate(whole),b.selector.evaluate(dataclasses.replace(whole,kind='enclosure')))
|
||||
held=note(0,4,'e')
|
||||
tied=(operators.Action(F(),F(2),(dataclasses.replace(held,end=F(2)),),'tone'),
|
||||
operators.Action(F(2),F(4),(dataclasses.replace(held,start=F(2),continuation=True),),'tone'))
|
||||
self.assertEqual(b.selector.complete((operators.Action(F(),F(4),(held,),'tone'),)),b.selector.complete(tied))
|
||||
|
||||
def test_home_belongs_to_complete_opening_not_recent_buffer(self):
|
||||
b=builder(('CM7',),count=4);state=construction.State()
|
||||
for i in range(16):
|
||||
a=operators.Action(F(i),F(i+1),(note(i,i+1,('c','e','g','b')[i%4]),),'tone')
|
||||
state=b.advance(state,None,a)
|
||||
if i<15:self.assertFalse(state.home)
|
||||
self.assertEqual(len(state.home),16);self.assertEqual(len(state.recent),8)
|
||||
self.assertEqual(state.home[0].start,0)
|
||||
|
||||
def test_true_arpeggio_uses_written_chord_members(self):
|
||||
b=builder(('C7',),count=1)
|
||||
values=b.operators.at(F())
|
||||
arps=[a for a in values if a.kind=='arpeggio']
|
||||
thirds=[a for a in values if a.kind=='scale_thirds']
|
||||
self.assertTrue(arps);self.assertTrue(thirds)
|
||||
for a in arps:
|
||||
self.assertTrue(all(n.pitch.number()%12 in b.harmony.at(n.start).roles.basic for n in a.notes))
|
||||
self.assertTrue(any(any(n.pitch.number()%12 not in b.harmony.at(n.start).roles.basic for n in a.notes) for a in thirds))
|
||||
|
||||
def test_optional_color_lookahead_rejects_missing_and_no_chord_harmony(self):
|
||||
from generator.solo import expressions
|
||||
from generator import vocabulary
|
||||
b=builder(('CM7',),count=1)
|
||||
absent=dataclasses.replace(b.harmony.at(F()),scale=None,chord='N.C.')
|
||||
for rule in vocabulary.data('harmony')['colors']:
|
||||
self.assertFalse(expressions.eligible_color(rule,None,b.harmony))
|
||||
self.assertFalse(expressions.eligible_color(rule,absent,b.harmony))
|
||||
|
||||
def test_training_features_cannot_encode_role_song_lookup(self):
|
||||
for key in ('kind/arpeggio','song/all_of_me','target/pitch_sequence','source/hash'):
|
||||
with self.assertRaises(ValueError):selection.validate_weights({key:1})
|
||||
with self.assertRaises(ValueError):selection.validate_weights({'duration/1':1025})
|
||||
from audition.structured_learning import constraint
|
||||
row=constraint('acoustic_solo',({'duration/1':12},F(3)),({'duration/1':12},F(2)),'test')
|
||||
self.assertFalse(row['delta']);self.assertEqual(row['prior'],1)
|
||||
|
||||
def test_contextual_rules_are_bounded_transposable_and_operational(self):
|
||||
from audition.structured_learning import contextual_candidates
|
||||
b=builder(('C7',),count=1);d=builder(('D7',),count=1)
|
||||
ns=(note(0,1,'d'),note(1,2,'e'))
|
||||
moved=(note(0,1,'e'),note(1,2,'f',alter=1))
|
||||
key='joint/degree=4&motion=2&quality=dominant'
|
||||
selection.validate_weights({key:-3})
|
||||
left=contextual_candidates(b.selector,ns);right=contextual_candidates(d.selector,moved)
|
||||
self.assertEqual(left[key],12);self.assertEqual(left[key],right[key])
|
||||
self.assertNotIn(key,contextual_candidates(b.selector,(ns[0],note(1,2,'f'))))
|
||||
a=operators.Action(F(),F(2),ns,'tone');plain=b.selector.evaluate(a)
|
||||
b.selector.weights={key:-3};changed=b.selector.evaluate(a)
|
||||
self.assertEqual(changed.total-plain.total,-18)
|
||||
for forbidden in ('joint/degree=4&metric=beat&motion=2&quality=dominant',
|
||||
'joint/degree=4&song=all_of_me','joint/absolute_bar=4&quality=dominant'):
|
||||
with self.assertRaises(ValueError):selection.validate_weights({forbidden:1})
|
||||
|
||||
def test_open_route_survives_equal_time_frontier_without_extra_attack(self):
|
||||
b=builder(('CM7',),count=1)
|
||||
n=note(0,4,'e',event_id='held')
|
||||
a=operators.Action(F(),F(4),(n,),'tone')
|
||||
with patch.object(b.operators,'at',return_value=(a,)):b.compose()
|
||||
self.assertEqual(b.completed[0].notes,(n,))
|
||||
self.assertEqual(b.selected_actions,(a,))
|
||||
self.assertEqual(b.completed[0].evaluation,b.selector.complete((a,)))
|
||||
|
||||
def test_deferred_validation_preserves_eager_successor_selection(self):
|
||||
b=builder(('C7',),count=1)
|
||||
state=construction.State(recent=(note(-1,0,'g'),))
|
||||
lazy=b.candidates(F(),state)
|
||||
with patch.object(b.operators,'proposals',side_effect=lambda at:b.operators.at(at)):
|
||||
eager=b.candidates(F(),state)
|
||||
self.assertEqual(tuple(a.key for _,a in lazy),tuple(a.key for _,a in eager))
|
||||
|
||||
def test_incremental_evaluation_equals_complete_event_evaluation(self):
|
||||
b=builder(('CM7','Dm7','G7','CM7'),count=8)
|
||||
actions=[];notes=();previous=None;home=();old_home=();cursor=F()
|
||||
for i in range(18):
|
||||
gap=F(1,2) if i%4==2 else F()
|
||||
length=(F(1,2),F(1),F(3,2))[i%3]
|
||||
ns=() if i%7==6 else (note(cursor+gap,cursor+gap+length,('c','d','e','g')[i%4]),)
|
||||
a=operators.Action(cursor,cursor+gap+length,ns,'arbitrary_label')
|
||||
if i==5:home=notes
|
||||
contracts=()
|
||||
fast=b.selector.extend(previous,notes,a,home,old_home=old_home,contracts=contracts)
|
||||
actions.append(a)
|
||||
full=b.selector.complete(actions,home)
|
||||
self.assertEqual(fast,full,(i,fast.raw,full.raw))
|
||||
previous=fast;notes=(*notes,*ns);old_home=home;cursor=a.end
|
||||
|
||||
def test_cached_retrieval_retains_incoming_rhythm_and_pitch_context(self):
|
||||
b=builder(('C7',),count=1)
|
||||
incoming=(note(-3,-2,'c'),note(-2,0,'e'))
|
||||
b.selector.weights={'space/after_arrival':-2,'motion/3/1':2}
|
||||
values=b.operators.at(F())
|
||||
selected=[a for a in values if a.kind in ('rest','scalar','arpeggio')][:80]
|
||||
for a in selected:
|
||||
self.assertEqual(b.selector.fast_cost(a,incoming),b.selector.cost(a,incoming)*(a.end-a.start),a.key)
|
||||
|
||||
def test_intrinsic_retrieval_matches_reports_for_every_action_family(self):
|
||||
from collections import Counter
|
||||
b=builder(('C7','FM7'),count=2)
|
||||
incoming=(note(0,1,'c'),note(1,F(7,2),'e'))
|
||||
b.selector.weights={'duration/1':4,'harmony/dominant/4/1_2':-3,'obligation/step/-1':-2,
|
||||
'space/after_arrival':-1,'metric/eighth/1_2':-1,'source/rhythm_match':2,
|
||||
'register/1':1,'continuation/up/up':-1,'motion/2/1_2':3}
|
||||
checked=Counter()
|
||||
for a in b.operators.proposals(F(7,2)):
|
||||
if checked[a.kind]>=32:continue
|
||||
checked[a.kind]+=1
|
||||
self.assertEqual(b.selector.fast_cost(a,incoming),b.selector.cost(a,incoming)*(a.end-a.start),a.key)
|
||||
self.assertGreaterEqual(len(checked),9)
|
||||
b.selector.weights={'duration/1':-3}
|
||||
a=operators.Action(F(7,2),F(9,2),(note(F(7,2),F(9,2),'e'),),'tone')
|
||||
self.assertEqual(b.selector.fast_cost(a,incoming),b.selector.cost(a,incoming)*(a.end-a.start))
|
||||
|
||||
def test_cached_retrieval_treats_tied_fragments_as_one_sounding_event(self):
|
||||
b=builder(('CM7',),count=1)
|
||||
incoming=(note(-2,-1,'c'),note(-1,0,'d'))
|
||||
whole=operators.Action(F(),F(3),(note(0,2,'e'),note(2,3,'g')),'tone')
|
||||
tied=dataclasses.replace(whole,notes=(note(0,1,'e'),note(1,2,'e',continuation=True),whole.notes[-1]))
|
||||
expected=b.selector.cost(whole,incoming)*3
|
||||
self.assertEqual(b.selector.fast_cost(whole,incoming),expected)
|
||||
self.assertEqual(b.selector.fast_cost(tied,incoming),expected)
|
||||
|
||||
def test_successor_cache_preserves_context_and_invalidates_changed_weights(self):
|
||||
b=builder(('C7',),count=1)
|
||||
state=construction.State(recent=(note(-3,-2,'c'),note(-2,-1,'e'),note(-1,0,'g')))
|
||||
with patch.object(b.selector,'fast_cost',wraps=b.selector.fast_cost) as score:
|
||||
first=b.candidates(F(),state);calls=score.call_count
|
||||
self.assertGreater(calls,0)
|
||||
self.assertEqual(first,b.candidates(F(),state));self.assertEqual(score.call_count,calls)
|
||||
b.selector.weights={'duration/1':9}
|
||||
changed=b.candidates(F(),state);self.assertGreater(score.call_count,calls)
|
||||
b.successor_cache.clear()
|
||||
self.assertEqual(changed,b.candidates(F(),state))
|
||||
before=score.call_count
|
||||
# Same pitches and final release; a longer incoming hold changes how
|
||||
# leading space is classified, so it is a distinct retrieval context.
|
||||
held=dataclasses.replace(state,recent=(note(-4,-3,'c'),note(-3,-2,'e'),note(-2,0,'g')))
|
||||
b.candidates(F(),held);self.assertGreater(score.call_count,before)
|
||||
|
||||
def test_successor_cache_keeps_pending_destinations_distinct(self):
|
||||
from generator.solo import types
|
||||
b=builder(('C7',),count=1)
|
||||
incoming=(note(-1,0,'d',alter=1),)
|
||||
state=construction.State(recent=incoming)
|
||||
target=next(a.notes[0] for a in b.operators.at(F()) if a.kind=='tone' and a.end==1 and a.notes[0].pitch.number()==64)
|
||||
contract=types.ExpressionContract('pending','delayed_approach','bounded_semitone_resolution',incoming,target,'C7')
|
||||
with patch.object(b.selector,'fast_cost',wraps=b.selector.fast_cost) as score:
|
||||
b.candidates(F(),state);calls=score.call_count
|
||||
pending=dataclasses.replace(state,pending=(contract,))
|
||||
values=b.candidates(F(),pending)
|
||||
self.assertTrue(values);self.assertGreater(score.call_count,calls)
|
||||
self.assertTrue(all(b.pending_compatible(pending,a) for _,a in values))
|
||||
b.successor_cache.clear()
|
||||
self.assertEqual(values,b.candidates(F(),pending))
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
Reference in New Issue
Block a user