131 lines
7.6 KiB
Python
131 lines
7.6 KiB
Python
"""General, independently authored capabilities; no optional reference inputs."""
|
|
import copy,dataclasses,unittest
|
|
from fractions import Fraction as F
|
|
from unittest.mock import patch
|
|
from generator import vocabulary,model
|
|
from generator.scales import selection,timeline
|
|
from generator.solo import analysis,construction,harmonic,profiles,types,scoring,composer,routes,placement
|
|
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())
|
|
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_complete_candidates_have_varied_functions_and_joint_expression(self):
|
|
b=builder();values=[b.propose(F(),(),i) for i in range(64)];values=[p for p in values if p]
|
|
self.assertTrue(values)
|
|
self.assertIn('open',{p.ending for p in values})
|
|
self.assertTrue(any(p.destination and p.destination.duration>=2 for p in values))
|
|
self.assertTrue(any(p.expressions for p in values))
|
|
for p in values:
|
|
self.assertTrue(b.harmony.evaluate(p.notes,until=p.end,complete=True,expressions=p.expressions).valid)
|
|
self.assertEqual(p.score,p.evaluation.total)
|
|
self.assertTrue(all(p.start<=n.start<n.end<=p.end for n in p.notes))
|
|
|
|
def test_all_operations_are_eligible_and_construct_complete_music(self):
|
|
b=builder(count=12);first=b.candidates(F(),())[0]
|
|
history=(first,dataclasses.replace(first,id='second'))
|
|
self.assertEqual(set(b.actions(history)),set(vocabulary.data('solo')['actions'])-{'head'})
|
|
history=(*history,dataclasses.replace(first,id='third'))
|
|
self.assertEqual(set(b.actions(history)),set(vocabulary.data('solo')['actions']))
|
|
# Request each operation through the same active constructor in eligible contexts.
|
|
for action in b.actions(history):
|
|
with self.subTest(action=action),patch.object(b,'actions',return_value=[action]):
|
|
candidates=[b.propose(F(16),history,i) for i in range(32)]
|
|
self.assertTrue(any(p and p.purpose==action for p in candidates))
|
|
|
|
def test_head_is_optional_not_required_rhythm(self):
|
|
b=builder();b.analysis=dataclasses.replace(b.analysis,melody=())
|
|
values=b.candidates(F(),())
|
|
self.assertTrue(values)
|
|
self.assertTrue(all(p.purpose!='head' for p in values))
|
|
|
|
def test_recomposed_opening_is_not_source_hold(self):
|
|
source=fixtures.song(count=4,chords=('CM7',))
|
|
n=source['staves'][0]['measures'][0]['voices'][0]['notes'][0]
|
|
source['staves'][0]['measures'][0]['voices'][0]['notes']=[{**n,'duration':model.rational(4),'pitches':[{**n['pitches'][0],'duration':model.rational(4)}]}]
|
|
result=fixtures.compose(source)
|
|
self.assertTrue(result.notes());self.assertLess(result.notes()[0].end-result.notes()[0].start,4)
|
|
|
|
def test_disabled_expressions_are_strict_and_reproducible(self):
|
|
b=builder();b.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))
|
|
self.assertEqual(ps,b.compose())
|
|
|
|
def test_role_domains_and_complete_tied_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)
|
|
|
|
def test_routes_keep_actual_pitches_across_collection_change(self):
|
|
b=builder(('C7','DmM7'),count=2)
|
|
ns=(fixtures.note(3,F(7,2),'g'),fixtures.note(F(7,2),4,'a'),fixtures.note(4,F(9,2),'c'),fixtures.note(F(9,2),5,'d'))
|
|
values=b.routes.realize(ns,{0,3},'scalar','up','crossing',64)
|
|
self.assertTrue(values)
|
|
for route in values:
|
|
self.assertEqual(route[0].pitch,ns[0].pitch);self.assertEqual(route[-1].pitch,ns[-1].pitch)
|
|
self.assertTrue(all(n.pitch.number()%12 in types.permitted(b.harmony.choices,n.start,n.end) for n in route))
|
|
|
|
def test_no_internal_chordal_endpoint_requirement(self):
|
|
b=builder(('C7',),count=2)
|
|
ns=(fixtures.note(0,F(1,2),'e'),fixtures.note(F(1,2),1,'f'))
|
|
paths=b.routes.realize(ns,{0,1},'scalar','up','open',64)
|
|
self.assertTrue(paths)
|
|
self.assertTrue(b.harmony.evaluate(paths[0]).valid)
|
|
self.assertNotIn(paths[0][-1].pitch.number()%12,b.harmony.at(F(1,2)).roles.basic)
|
|
|
|
def test_meaningful_incoming_state_changes_continuations(self):
|
|
b=builder();history=(b.candidates(F(),())[0],)
|
|
a=b.candidates(history[-1].end,history)
|
|
old=history[0];last=old.notes[-1]
|
|
changed=dataclasses.replace(old,id='changed',notes=(*old.notes[:-1],dataclasses.replace(last,pitch=dataclasses.replace(last.pitch,octave=last.pitch.octave+1))))
|
|
c=b.candidates(changed.end,(changed,))
|
|
self.assertNotEqual(tuple(p.notes for p in a),tuple(p.notes for p in c))
|
|
|
|
def test_no_short_accidental_handoff_tail(self):
|
|
b=builder(count=4);ps=b.compose()
|
|
self.assertEqual(ps[-1].end,b.end)
|
|
if ps[-1].destination:self.assertGreaterEqual(ps[-1].destination.duration,2)
|
|
|
|
def test_cache_restores_exact_music_without_source_ancestry_growth(self):
|
|
b=builder(count=4);cold=b.candidates(F(),());warm=b.candidates(F(),())
|
|
self.assertEqual(cold,warm)
|
|
self.assertTrue(all(not n.sources for p in cold for n in p.notes))
|
|
|
|
class EvaluationContract(unittest.TestCase):
|
|
def phrase(self,ns,**kwargs):return types.Phrase('test',F(),F(8),tuple(ns),'scalar','up','open',**kwargs)
|
|
def test_configured_terms_are_authoritative_and_nonzero(self):
|
|
ns=(fixtures.note(0,1,'c',octave=3),fixtures.note(1,2,'e',octave=4),fixtures.note(2,4,'c',octave=4))
|
|
p=self.phrase(ns);incoming=fixtures.note(-1,0,'c',octave=5)
|
|
base=scoring.evaluate(p,incoming,(p,),40)
|
|
self.assertEqual(base.total,sum(base.contributions.values()))
|
|
for name,value in base.raw.items():
|
|
self.assertGreater(value,0,name)
|
|
altered=scoring.evaluate(p,incoming,(p,),40,weights={**base.weights,name:base.weights[name]+1})
|
|
self.assertAlmostEqual(altered.total-base.total,value)
|
|
def test_silence_is_explicit_and_tie_partition_is_invariant(self):
|
|
a=fixtures.note(0,4,'e',event_id='a')
|
|
split=(dataclasses.replace(a,end=F(2)),dataclasses.replace(a,start=F(2),continuation=True))
|
|
self.assertEqual(scoring.evaluate(self.phrase((a,)),None,(),60),scoring.evaluate(self.phrase(split),None,(),60))
|
|
self.assertFalse(scoring.evaluate(self.phrase(()),None,(),60).valid)
|
|
self.assertTrue(scoring.evaluate(dataclasses.replace(self.phrase(()),purpose='space'),None,(),60).valid)
|
|
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])
|
|
|
|
if __name__=='__main__':unittest.main()
|