Simplification

This commit is contained in:
khodowany
2026-10-05 17:24:16 -05:00
parent bcaf2f1403
commit db7966a430
12 changed files with 639 additions and 123 deletions
+26
View File
@@ -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>')
+126
View File
@@ -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()))