172 lines
9.6 KiB
Python
172 lines
9.6 KiB
Python
"""Independently authored operator, fitting-boundary and cache regressions."""
|
|
import copy
|
|
import dataclasses
|
|
from fractions import Fraction as F
|
|
from pathlib import Path
|
|
import tempfile
|
|
import unittest
|
|
import contextlib
|
|
import io
|
|
from unittest.mock import patch
|
|
|
|
from generator import resources, vocabulary
|
|
from generator.solo import expressions, harmonic, operators, selection, types
|
|
from tests import solo_fixtures as fixture, rhythm_fixtures, scale_fixtures
|
|
from tests.test_phrase_composer import builder
|
|
|
|
class Operators(unittest.TestCase):
|
|
def test_supported_sixteenth_tail_can_finish_the_allocation(self):
|
|
b=builder(('CM7',),count=1)
|
|
actions=b.operators.at(F(15,4))
|
|
self.assertTrue(any(a.kind=='tone' and a.end==4 for a in actions))
|
|
# A source/role cutoff does not require a new long cadence.
|
|
b.start=F(15,4)
|
|
phrases=b.compose()
|
|
self.assertEqual(phrases[-1].end,F(4))
|
|
self.assertLessEqual(sum(len(p.notes) for p in phrases),1)
|
|
|
|
def test_source_relation_uses_order_and_does_not_reward_subdivision(self):
|
|
b=builder(('CM7',),count=1)
|
|
slow=operators.Action(F(),F(4),tuple(fixture.note(i,i+1,p,event_id=str(i)) for i,p in enumerate(('c','d','e','f'))),'scalar')
|
|
fast=dataclasses.replace(slow,end=F(2),notes=tuple(dataclasses.replace(n,start=n.start/2,end=n.end/2) for n in slow.notes))
|
|
self.assertEqual(b.selector.evaluate(slow).raw['prior/source_relation'],b.selector.evaluate(fast).raw['prior/source_relation'])
|
|
changed=dataclasses.replace(b.analysis,melody=tuple(fixture.note(i,i+1,p,event_id=str(i)) for i,p in enumerate(('e','f','g','a'))))
|
|
other=selection.Selector(b.harmony,changed,b.selector.center,{})
|
|
self.assertNotEqual(b.selector.evaluate(slow).raw.get('prior/source_relation',0),other.evaluate(slow).raw.get('prior/source_relation',0))
|
|
|
|
def test_shared_fitter_learns_an_independent_synthetic_decision(self):
|
|
from audition.fit_selection import fit
|
|
b=builder(('CM7',),count=1)
|
|
low=operators.Action(F(),F(4),(fixture.note(0,4,'c',event_id='low'),),'tone')
|
|
high=operators.Action(F(),F(4),(fixture.note(0,4,'e',event_id='high'),),'tone')
|
|
b.selector.weights={}
|
|
with patch.object(b.operators,'at',return_value=(low,high)),contextlib.redirect_stdout(io.StringIO()):
|
|
result=fit([('synthetic','acoustic_solo',b,(high,))],10)
|
|
self.assertTrue(result['complete'])
|
|
b.selector.weights=result['weights']['acoustic_solo']
|
|
self.assertLess(b.selector.evaluate(high).total,b.selector.evaluate(low).total)
|
|
selection.validate_weights(b.selector.weights)
|
|
|
|
def test_fitting_normalization_covers_pickup_plus_whole_note(self):
|
|
from audition.fit_selection import fit
|
|
b=builder(('CM7',),count=2);b.end=F(17,4)
|
|
low=operators.Action(F(),b.end,(fixture.note(0,F(1,4),'c',event_id='lead'),fixture.note(F(1,4),b.end,'e',event_id='low')),'anticipation')
|
|
high=dataclasses.replace(low,notes=(low.notes[0],fixture.note(F(1,4),b.end,'g',event_id='high')))
|
|
b.selector.weights={}
|
|
with patch.object(b.operators,'at',return_value=(low,high)),contextlib.redirect_stdout(io.StringIO()):
|
|
result=fit([('synthetic','acoustic_solo',b,(high,))],10)
|
|
self.assertTrue(result['complete'])
|
|
selection.validate_weights(result['weights']['acoustic_solo'])
|
|
|
|
def test_anticipated_reattack_is_not_an_invented_tie(self):
|
|
b=builder(('C7','FM7'),count=2)
|
|
actions=b.operators.at(F(7,2))
|
|
a=next(a for a in actions if a.kind=='anticipation' and a.source=='early_reattack')
|
|
self.assertEqual(len(a.notes),2)
|
|
self.assertEqual(a.notes[0].end,a.notes[1].start)
|
|
self.assertEqual(a.notes[0].pitch,a.notes[1].pitch)
|
|
self.assertFalse(a.notes[1].continuation)
|
|
self.assertTrue(b.harmony.evaluate(a.notes,expressions=a.contracts).valid)
|
|
self.assertFalse(b.harmony.evaluate(a.notes[:1],expressions=a.contracts).valid)
|
|
|
|
def test_open_motion_and_all_color_octaves_are_constructed(self):
|
|
b=builder(('C7','FM7'),count=2)
|
|
actions=b.operators.at(F())
|
|
self.assertTrue(any(len(a.notes)==1 and a.notes[0].pitch.number()%12==2 and a.end==F(1,2) for a in actions))
|
|
h=builder(('E7','Am7'),count=2)
|
|
values=h.operators.at(F())
|
|
self.assertTrue(any(a.kind=='color' and len(a.notes)==1 and a.notes[0].pitch.number()==66 for a in values))
|
|
self.assertTrue(any(a.kind=='rest' for a in values))
|
|
|
|
def test_anticipation_color_has_actual_future_resolution(self):
|
|
h=harmonic.Harmony(fixture.choices(('Dm7','G7','CM7')))
|
|
held=fixture.note(F(7,2),F(11,2),'c',alter=1,event_id='held')
|
|
target=fixture.note(F(11,2),7,'d',event_id='target')
|
|
c=types.ExpressionContract('anticipated','anticipated_color','lydian_color',(held,),target,'G7',boundary=F(4))
|
|
self.assertTrue(h.evaluate((held,target),expressions=(c,)).valid)
|
|
self.assertFalse(h.evaluate((held,),expressions=(c,)).valid)
|
|
changed=dataclasses.replace(target,pitch=dataclasses.replace(target.pitch,octave=5))
|
|
self.assertFalse(h.evaluate((held,changed),expressions=(c,)).valid)
|
|
self.assertFalse(h.evaluate((held,target)).valid)
|
|
|
|
def test_delayed_approach_is_pending_and_expires(self):
|
|
h=harmonic.Harmony(fixture.choices(('Dm7',)))
|
|
approach=fixture.note(0,F(1,2),'c',alter=1,event_id='approach')
|
|
target=fixture.note(3,F(7,2),'d',event_id='target')
|
|
c=types.ExpressionContract('delayed','delayed_approach','bounded_semitone_resolution',(approach,),target,'Dm7')
|
|
r=h.evaluate((approach,),expressions=(c,),until=F(1,2),complete=False)
|
|
self.assertTrue(r.valid,r.error);self.assertEqual(r.pending,(c,))
|
|
self.assertFalse(h.evaluate((approach,),expressions=(c,),until=4,complete=True).valid)
|
|
self.assertTrue(h.evaluate((approach,target),expressions=(c,)).valid)
|
|
|
|
def test_distinct_pending_targets_are_visible_to_the_selector(self):
|
|
b=builder(('Dm7',),count=2)
|
|
actions=[a for a in b.operators.at(F()) if a.kind=='delayed_approach']
|
|
first=actions[0]
|
|
second=next(a for a in actions if a.notes==first.notes and a.contracts[0].target.start!=first.contracts[0].target.start)
|
|
left,_=b.selector.features(first);right,_=b.selector.features(second)
|
|
self.assertNotEqual(left,right)
|
|
selection.validate_weights(dict.fromkeys(left,1))
|
|
|
|
def test_missed_pending_target_is_rejected_before_expensive_validation(self):
|
|
from generator.solo.construction import State
|
|
b=builder(('Dm7',),count=2)
|
|
approach=fixture.note(0,F(1,2),'c',alter=1,event_id='approach')
|
|
target=fixture.note(3,4,'d',event_id='target')
|
|
c=types.ExpressionContract('delayed','delayed_approach','bounded_semitone_resolution',(approach,),target,'Dm7')
|
|
state=State(pending=(c,))
|
|
wrong=operators.Action(F(3),F(4),(fixture.note(3,4,'e',event_id='wrong'),),'tone')
|
|
with patch.object(b.harmony,'evaluate',side_effect=AssertionError('unnecessary full check')):
|
|
self.assertIsNone(b.advance(state,None,wrong))
|
|
good=dataclasses.replace(wrong,notes=(target,))
|
|
self.assertIsNotNone(b.advance(state,None,good))
|
|
|
|
def test_functional_degree_does_not_depend_on_enharmonic_label(self):
|
|
h=harmonic.Harmony(fixture.choices(('E7','Am7')))
|
|
n=fixture.note(0,2,'g',event_id='sharp9')
|
|
c=types.ExpressionContract('color','color','dominant_sharp_ninth',(n,),n,'E7')
|
|
self.assertTrue(h.evaluate((n,),expressions=(c,)).valid)
|
|
wrong=dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,alter=F(1)))
|
|
self.assertFalse(h.evaluate((wrong,),expressions=(dataclasses.replace(c,events=(wrong,),target=wrong),)).valid)
|
|
|
|
def test_weight_contributions_and_target_free_features(self):
|
|
b=builder(count=1)
|
|
a=next(a for a in b.operators.at(F()) if a.kind=='tone')
|
|
v=b.selector.evaluate(a)
|
|
self.assertEqual(v.total,sum(v.contributions.values()))
|
|
self.assertEqual(v.total,b.selector.cost(a))
|
|
key='kind/tone';b.selector.weights={key:3}
|
|
changed=b.selector.evaluate(a)
|
|
self.assertEqual(changed.total,b.selector.cost(a))
|
|
self.assertEqual(changed.total-v.total,3*v.raw[key])
|
|
with self.assertRaises(ValueError):selection.validate_weights({'song/all_of_me':1})
|
|
|
|
def test_derivation_cannot_inject_an_illegal_reference_note(self):
|
|
from audition.fit_selection import derive
|
|
b=builder(('CM7',),count=1)
|
|
target={'alignment':{'encoded_offset':'0'},'sounding_events':[{'start':'0','end':'4','sounding_midi':'99','pitch':{},'measure':1}]}
|
|
path,result=derive(b,target)
|
|
self.assertFalse(result['complete']);self.assertFalse(path)
|
|
|
|
class CompiledResources(unittest.TestCase):
|
|
def test_compiled_and_xml_libraries_have_identical_contents(self):
|
|
original=(scale_fixtures.library(),fixture.library(),rhythm_fixtures.library())
|
|
import json
|
|
encoded=json.loads(json.dumps(resources.encode(original),default=str))
|
|
restored=resources.decode(encoded)
|
|
self.assertEqual(original[0],restored[0])
|
|
self.assertEqual(original[1].paths,restored[1].paths)
|
|
self.assertEqual(original[2].snippets,restored[2].snippets)
|
|
|
|
def test_warm_cache_avoids_xml_import_and_corruption_recompiles(self):
|
|
original=(scale_fixtures.library(),fixture.library(),rhythm_fixtures.library())
|
|
with tempfile.TemporaryDirectory() as root:
|
|
with patch('generator.resources.scales.load_library',return_value=original[0]) as sc,patch('generator.resources.sequences.load_library',return_value=original[1]),patch('generator.resources.rhythm.load_library',return_value=original[2]):
|
|
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
|
|
self.assertEqual(sc.call_count,1)
|
|
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
|
|
self.assertEqual(sc.call_count,1)
|
|
next((Path(root)/'_resources').glob('*.json')).write_text('{}')
|
|
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
|
|
self.assertEqual(sc.call_count,2)
|