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
+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)