292 lines
17 KiB
Python
292 lines
17 KiB
Python
"""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
|
|
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(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_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_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)
|
|
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_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_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))
|
|
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)
|
|
|
|
|
|
class EvaluationContract(unittest.TestCase):
|
|
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_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])
|
|
|
|
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)
|
|
|
|
|
|
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()))
|