Simplification

This commit is contained in:
khodowany
2026-10-05 16:40:22 -05:00
parent 654299decd
commit bcaf2f1403
49 changed files with 922 additions and 3327 deletions
+1 -1
View File
@@ -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.
File diff suppressed because it is too large Load Diff
+418
View File
@@ -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())
-233
View File
@@ -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)
+3 -1
View File
@@ -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
View File
@@ -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)
+29
View File
@@ -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)
-2
View File
@@ -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)
+30 -1
View File
@@ -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>')
-208
View File
@@ -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()