Files
product-vamp/tests/test_phrase_composer.py
T
2026-10-05 16:40:22 -05:00

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)