166 lines
9.1 KiB
Python
166 lines
9.1 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)
|