Simplification
This commit is contained in:
@@ -327,6 +327,23 @@ def audition_pair(actual,expected,role,assets,output,cache,baseline=None):
|
||||
'listening_status':'Rendered and signal-checked, not subjectively auditioned.'}
|
||||
|
||||
|
||||
def candidate_clips(candidates,role,tempo,output,cache):
|
||||
"""Render the already completed source-only alternatives, never reselect."""
|
||||
from generator import playback
|
||||
clips=[]
|
||||
for index,candidate in enumerate(candidates,1):
|
||||
origin,end=F(candidate['start']),F(candidate['end'])
|
||||
audio=render_events([(F(a)-origin,F(b)-origin,F(p)) for a,b,p in candidate['events']],end-origin,tempo,output,cache)
|
||||
peak=.65*max(map(abs,audio),default=0)
|
||||
if peak>=1:raise ValueError('Candidate excerpt would clip at comparison gain')
|
||||
name='audio/'+role.replace('/','_')+f'_candidate_{index}.wav'
|
||||
playback.write_pcm(output/name,(.65*v for v in audio))
|
||||
selected=candidate['selected_in_final_solo']
|
||||
clips.append({'file':name,'version':f'Completed opening {index}'+(' — selected in final solo' if selected else ' — alternative'),
|
||||
'context':'source-only candidate, solo','peak':peak})
|
||||
return clips
|
||||
|
||||
|
||||
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())
|
||||
@@ -348,6 +365,9 @@ def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None
|
||||
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',{})
|
||||
candidates=review['production_phrases'].get('selection_trace',{}).get('opening_candidates',[])
|
||||
if assets and candidates:
|
||||
review['audition']['clips']+=candidate_clips(candidates,role,F(review['audition']['tempo']),output,tone_cache)
|
||||
# Preserve the real role cutoff; reference material beyond it is not trimmed.
|
||||
phrases=review['production_phrases'].get('phrases',[])
|
||||
if phrases:
|
||||
@@ -384,6 +404,12 @@ def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None
|
||||
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>')
|
||||
candidates=r['production_phrases'].get('selection_trace',{}).get('opening_candidates',[])
|
||||
if candidates:
|
||||
rows.append('<details open><summary>Completed source-only opening candidates</summary><p>All candidates were composed without the references. Selection accounts for later phrases; the final choice need not have the cheapest opening alone.</p><table><tr><th>Candidate</th><th>Final choice</th><th>Purpose</th><th>Score contributions</th></tr>')
|
||||
for index,c in enumerate(candidates,1):
|
||||
rows.append('<tr>'+''.join('<td>'+html.escape(str(v))+'</td>' for v in (index,c['selected_in_final_solo'],c['purpose'],c['evaluation']['contributions']))+'</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>')
|
||||
|
||||
@@ -163,3 +163,129 @@ class ExpressionConstruction(unittest.TestCase):
|
||||
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)
|
||||
|
||||
|
||||
class DirectedPhraseContracts(unittest.TestCase):
|
||||
def test_different_destinations_change_the_complete_incoming_route(self):
|
||||
b=builder(('CM7',),count=2)
|
||||
rhythm=tuple((fractions.Fraction(i,2),fractions.Fraction(i+1,2)) for i in range(4))
|
||||
incoming=fixtures.note(-1,0,'g')
|
||||
low=fixtures.note(2,4,'c')
|
||||
high=fixtures.note(2,4,'c',octave=5)
|
||||
args=(rhythm,(0,1,2,3),incoming,60,'connect')
|
||||
a=b.phrases.route(*args,degree_path=True,target=low)
|
||||
z=b.phrases.route(*args,degree_path=True,target=high)
|
||||
self.assertIsNotNone(a);self.assertIsNotNone(z)
|
||||
self.assertNotEqual([n.pitch for n in a[0]],[n.pitch for n in z[0]])
|
||||
self.assertLess(abs(a[0][-1].pitch.number()-low.pitch.number()),abs(z[0][-1].pitch.number()-low.pitch.number()))
|
||||
|
||||
def test_anticipated_destination_is_planned_before_any_body_exists(self):
|
||||
b=builder(('G6','B7'),count=2)
|
||||
choices=b.phrases.arrival_options(fractions.Fraction(7,2),fractions.Fraction(6),fractions.Fraction(4),64,'settle')
|
||||
target=next(a for a in choices if a.note.pitch.number()==63)
|
||||
self.assertEqual(target.note.end,6)
|
||||
self.assertTrue(b.harmony.evaluate((target.note,),expressions=target.contracts).valid)
|
||||
self.assertFalse(b.harmony.evaluate((target.note,)).valid)
|
||||
with patch.object(b.phrases,'choose',return_value='enclosure'):
|
||||
notes,contracts=b.phrases.approach(target,fractions.Fraction(),1)
|
||||
self.assertTrue(notes)
|
||||
self.assertTrue(b.harmony.evaluate((*notes,target.note),expressions=(*target.contracts,*contracts)).valid)
|
||||
self.assertEqual(contracts[0].boundary,4)
|
||||
self.assertFalse(b.harmony.evaluate(notes,expressions=(*target.contracts,*contracts)).valid)
|
||||
|
||||
def test_development_can_rephrase_source_hold_while_return_preserves_it(self):
|
||||
from generator.solo.phrases import idea
|
||||
b=builder(('CM7',),count=2)
|
||||
original=(fixtures.note(0,1,'c'),fixtures.note(1,2,'e'),fixtures.note(2,6,'d'))
|
||||
motif=idea(original,'synthetic')
|
||||
developments=b.phrases.developments(motif,'develop')
|
||||
self.assertTrue(any(max(y-x for x,y in d.rhythm)<4 for d in developments))
|
||||
self.assertTrue(all(d.intervals==motif.intervals for d in developments))
|
||||
self.assertEqual(b.phrases.developments(motif,'return'),(motif,))
|
||||
extensions=b.phrases.developments(motif,'extend')
|
||||
reopened=next(v for v in extensions if v.source.endswith(':extend'))
|
||||
self.assertLess(reopened.rhythm[2][1]-reopened.rhythm[2][0],4)
|
||||
self.assertGreater(len(reopened.rhythm),len(motif.rhythm))
|
||||
self.assertEqual(reopened.intervals[:3],motif.intervals)
|
||||
|
||||
def test_harmonic_pool_memoization_preserves_complete_candidates(self):
|
||||
from generator.solo.phrases import Phrases
|
||||
b=builder(count=2)
|
||||
cached=b.candidates(fractions.Fraction(),b.end,construction.State())
|
||||
with patch.object(Phrases,'event_options',Phrases.event_options.__wrapped__):
|
||||
clean=builder(count=2).candidates(fractions.Fraction(),b.end,construction.State())
|
||||
self.assertEqual(cached,clean)
|
||||
|
||||
def test_colors_include_both_legal_octaves_at_a_midpoint(self):
|
||||
b=builder(('C7','FM7'),count=2)
|
||||
options=b.phrases.options(fractions.Fraction(),fractions.Fraction(1),50)
|
||||
flat_six={n.pitch.number() for n,cs in options if any(c.rule=='dominant_flat_thirteenth' for c in cs)}
|
||||
self.assertEqual(flat_six,{44,56})
|
||||
|
||||
def test_ordered_rhythmic_relationships_survive_retrieval(self):
|
||||
from generator.solo.groups import descriptor
|
||||
first=((fractions.Fraction(),fractions.Fraction(1,2)),(fractions.Fraction(1,2),fractions.Fraction(3,2)),(fractions.Fraction(3,2),fractions.Fraction(2)))
|
||||
second=((fractions.Fraction(),fractions.Fraction(1)),(fractions.Fraction(1),fractions.Fraction(3,2)),(fractions.Fraction(3,2),fractions.Fraction(2)))
|
||||
self.assertNotEqual(descriptor(first,2),descriptor(second,2))
|
||||
self.assertNotEqual(descriptor(first,2),descriptor(first,3)) # trailing breath
|
||||
|
||||
def test_complete_candidate_contains_planned_local_and_final_arrivals(self):
|
||||
b=builder(count=4)
|
||||
candidates=b.candidates(fractions.Fraction(),b.end,construction.State())
|
||||
self.assertTrue(any(len(p.arrivals)>1 for p,_ in candidates))
|
||||
for p,_ in candidates:
|
||||
self.assertTrue(harmonic.anchors_valid(p.arrivals,p.notes,p.end,complete=True,closure='closed')[0])
|
||||
self.assertEqual(p.evaluation.total,sum(p.evaluation.contributions.values()))
|
||||
|
||||
def test_development_and_answer_require_real_identity_evidence(self):
|
||||
home=(fixtures.note(0,1,'c'),fixtures.note(1,2,'e'),fixtures.note(2,3,'d'))
|
||||
developed=(fixtures.note(4,fractions.Fraction(9,2),'d'),fixtures.note(fractions.Fraction(9,2),5,'f',alter=1),fixtures.note(5,6,'e'))
|
||||
unrelated=(fixtures.note(4,5,'d'),fixtures.note(5,6,'c'),fixtures.note(6,7,'g'))
|
||||
for purpose in ('develop','answer','extend'):
|
||||
self.assertEqual(evaluation.theme_distance(home,developed,purpose),0)
|
||||
self.assertGreater(evaluation.theme_distance(home,unrelated,purpose),0)
|
||||
self.assertGreater(evaluation.theme_distance(home,developed[:1],purpose),0)
|
||||
self.assertGreater(evaluation.theme_distance(home,developed,'return'),0)
|
||||
|
||||
def test_arrival_preparation_ranks_realized_motion_not_action_label(self):
|
||||
b=builder(('CM7',),count=1)
|
||||
target=fixtures.note(2,4,'e',event_id='destination')
|
||||
anchor=types.Anchor('end',2,2,target.pitch,frozenset({4}),(41,76),2,'terminal','CM7',hold_until=4)
|
||||
flowing=(fixtures.note(0,1,'c'),fixtures.note(1,2,'d'),target)
|
||||
jumping=(fixtures.note(0,1,'c'),fixtures.note(1,2,'d',octave=3),target)
|
||||
good=evaluation.evaluate(flowing,0,4,b.harmony,destinations=(anchor,))
|
||||
poor=evaluation.evaluate(jumping,0,4,b.harmony,destinations=(anchor,))
|
||||
self.assertLess(good.raw['arrival'],poor.raw['arrival'])
|
||||
self.assertLess(good.total,poor.total)
|
||||
self.assertEqual(good,evaluation.evaluate(flowing,0,4,b.harmony,destinations=(anchor,),purpose='arpeggio'))
|
||||
|
||||
def test_history_cost_distinguishes_attacks_from_one_sustain(self):
|
||||
b=builder(('CM7',),count=4)
|
||||
notes=tuple(fixtures.note(i,i+1,s) for i,s in enumerate(('c','e','d','g')))
|
||||
prior=types.Phrase('old',0,4,notes,'statement','up','open')
|
||||
current=tuple(dataclasses.replace(n,start=n.start+4,end=n.end+4) for n in notes)
|
||||
fresh=evaluation.evaluate(current,4,8,b.harmony)
|
||||
repeated=evaluation.evaluate(current,4,8,b.harmony,history=(prior,prior,prior))
|
||||
self.assertGreater(repeated.raw['repetition'],fresh.raw['repetition'])
|
||||
held=fixtures.note(4,8,'e')
|
||||
single=evaluation.evaluate((held,),4,8,b.harmony,history=(prior,prior,prior))
|
||||
self.assertEqual(single.raw['repetition'],0)
|
||||
|
||||
def test_held_destination_is_not_inactive_motion_and_is_binding(self):
|
||||
b=builder(('CM7',),count=1)
|
||||
target=fixtures.note(0,4,'e',event_id='held')
|
||||
anchor=types.Anchor('end',0,0,target.pitch,frozenset({4}),(41,76),4,'terminal','CM7',hold_until=4)
|
||||
planned=evaluation.evaluate((target,),0,4,b.harmony,destinations=(anchor,))
|
||||
accidental=evaluation.evaluate((target,),0,4,b.harmony)
|
||||
self.assertEqual(planned.raw['activity'],0)
|
||||
self.assertGreater(accidental.raw['activity'],planned.raw['activity'])
|
||||
self.assertFalse(evaluation.evaluate((dataclasses.replace(target,end=1),),0,4,b.harmony,destinations=(anchor,)).valid)
|
||||
|
||||
def test_configured_weights_change_the_authoritative_contribution(self):
|
||||
b=builder(('CM7',),count=1)
|
||||
ns=(fixtures.note(0,1,'c'),fixtures.note(1,2,'c'),fixtures.note(2,4,'e'))
|
||||
before=evaluation.evaluate(ns,0,4,b.harmony)
|
||||
with patch.dict(evaluation.WEIGHTS,{'repetition':evaluation.WEIGHTS['repetition']+1}):
|
||||
after=evaluation.evaluate(ns,0,4,b.harmony)
|
||||
self.assertEqual(after.total-before.total,before.raw['repetition'])
|
||||
self.assertEqual(after.total,sum(after.contributions.values()))
|
||||
|
||||
Reference in New Issue
Block a user