This commit is contained in:
khodowany
2026-10-04 18:48:02 -05:00
parent 68df7292ee
commit 0e68f5364b
58 changed files with 1416 additions and 7567 deletions
+34
View File
@@ -6,3 +6,37 @@ import generator.solo.sequences
@functools.cache
def library():
return generator.solo.sequences.Library(tuple(generator.solo.sequences.Sequence(f'{count}_4/{index:04}.xml', steps) for count in (2, 3, 4) for index, steps in enumerate(itertools.product((1, -1, 2, -2), repeat=count - 1))))
import copy, fractions
import generator.model, generator.parts, generator.harmony
import generator.solo.analysis, generator.solo.composer, generator.solo.types
import tests.test_musicxml, tests.scale_fixtures, tests.rhythm_fixtures
def song(meter=4, count=6, chords=('Dm7', 'G7', 'CM7')):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join(tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G'))))
first = document['staves'][0]['measures'][0]
measures = []
for index in range(count):
measure = copy.deepcopy(first)
measure.update(id=f'measure_{index + 1}', number=str(index + 1), duration=generator.model.rational(meter), meter={'groups': [{'beats': [meter], 'unit': 4}]}, time_signature=f'{meter}/4')
measure['voices'][0]['notes'] = [generator.model.make_event(f'source_{index}_{beat}', fractions.Fraction(beat), fractions.Fraction(1), 'note', [{'letter': 'cdefg'[beat % 5], 'octave': 4, 'alter': 0}]) for beat in range(meter)]
measure['harmonies'] = [{**generator.harmony.parse_symbol(chords[index % len(chords)]), 'id': f'h_{index}', 'onset': generator.model.rational(0)}] if chords else []
for harmony in measure['harmonies']:
harmony['symbol'] = generator.harmony.symbol(harmony)
measures.append(measure)
document['staves'][0]['measures'] = measures
return generator.parts.prepare_document(document)
def compose(document, part='acoustic', cutoff=None):
analysis = generator.solo.analysis.analyze(document)
measures = document['staves'][0]['measures']
end = sum(generator.model.fraction(m['duration']) for m in measures) if cutoff is None else cutoff
return generator.solo.composer.compose(analysis, measures, part, end, scales=tests.scale_fixtures.library(), sequences=library(), rhythms=tests.rhythm_fixtures.library())
def note(start,end,letter='g',alter=0,octave=4,**kwargs):
return generator.solo.types.Note(fractions.Fraction(start),fractions.Fraction(end),generator.solo.types.Pitch(letter,fractions.Fraction(alter),octave),**kwargs)
def choices(symbols,duration=4,scale=None):
from generator.scales import selection,timeline
scale=scale or tests.scale_fixtures.library().scale('major',6,'D')
return tuple(selection.Choice(timeline.ChordSpan(fractions.Fraction(i*duration),fractions.Fraction((i+1)*duration),i+1,str(i+1),fractions.Fraction(1),generator.model.key_from_fifths(-1),generator.harmony.parse_symbol(symbol)),scale,'independent fixture') for i,symbol in enumerate(symbols))
-177
View File
@@ -1,177 +0,0 @@
"""Independent musical fixtures; optional target scores are never read."""
import dataclasses
from fractions import Fraction as F
import unittest
from generator.solo import groups, scoring, types, harmonic, themes, realization, profiles
from tests.test_solo_development import note
from tests.test_solo_passages import choices
from tests.test_solo_themes import melody
import tests.solo_fixtures
import tests.rhythm_fixtures
def evaluated(notes,harmony,**kwargs):
motif=types.Motif('authored',(),F(4),(),'test only')
plan=types.PhrasePlan('test',F(),F(4),motif,'continue','thematic_recomposition',None,(41,76),F(1),F(),
(types.ActivitySpan(F(),F(4),'active',F(1),F()),),ending='connected',closure='open')
draft=types.MelodicDraft('test',F(),F(4),tuple(notes),(),(),(),(),None,'continue')
return scoring.musical(plan,draft,notes,types.ContinuationState(''),harmony,**kwargs)
class RhythmGroups(unittest.TestCase):
def test_complete_triplet_can_be_displaced_without_offsetting_binary_tail(self):
pattern=((F(1,2),F(5,6)),(F(5,6),F(7,6)),(F(7,6),F(3,2)),(F(3,2),F(5,2)))
result=groups.describe(pattern)
self.assertEqual(result[0].family,'triplet')
self.assertEqual(result[-1].family,'binary')
self.assertEqual(result[0].displacement,F(1,2))
def test_partial_triplet_does_not_drift_into_binary(self):
self.assertFalse(groups.valid(((F(),F(1,3)),(F(1,3),F(5,6)))))
self.assertFalse(groups.valid(((F(1,3),F(4,3)),)))
self.assertEqual(groups.describe(((F(1,3),F(4,3)),),imported=True)[0].family,'source')
def test_relative_rhythm_identity_does_not_count_absolute_onsets(self):
original=melody(((0,1,'c'),(1,2,'e'),(2,3,'d')))
self.assertEqual(len(set(groups.signature(original))),1)
moved=tuple(dataclasses.replace(n,start=n.start+13,end=n.end+13) for n in original)
self.assertEqual(groups.signature(original),groups.signature(moved))
def test_broadening_never_cuts_inside_tuplet(self):
pattern=((F(),F(1,3)),(F(1,3),F(2,3)),(F(2,3),F(1)))
self.assertEqual(groups.broaden(pattern,F(1,2),F(4)),())
class EvaluationContract(unittest.TestCase):
def test_sum_and_configured_weights_are_authoritative(self):
h=harmonic.Harmony(choices(('C7',)),profiles.Instrument(reference=48))
ns=(note(0,1,'g'),note(2,3,'f'),note(3,4,'e'))
base=evaluated(ns,h,travel=2000)
self.assertTrue(base.valid)
self.assertAlmostEqual(base.total,sum(base.contributions.values()))
for key,value in base.raw.items():
changed=evaluated(ns,h,travel=2000,weights={**base.weights,key:base.weights[key]+1})
self.assertAlmostEqual(changed.total-base.total,value)
self.assertGreater(base.raw['activity'],0)
self.assertGreater(base.raw['harmony'],0)
self.assertGreater(base.raw['register'],0)
def test_ties_are_partition_invariant_and_silence_cannot_win(self):
h=harmonic.Harmony(choices(('C7',)))
whole=(note(0,4,'e',event_id='a'),)
split=(note(0,2,'e',event_id='a'),note(2,4,'e',event_id='a',continuation=True))
self.assertEqual(evaluated(whole,h),evaluated(split,h))
self.assertFalse(evaluated((),h).valid)
def test_computational_end_is_not_a_required_chord_member(self):
h=harmonic.Harmony(choices(('C7',)))
ns=(note(0,1,'g'),note(1,2,'e'),note(2,3,'f'),note(3,4,'a'))
self.assertTrue(evaluated(ns,h).valid)
class PendingTreatments(unittest.TestCase):
def test_complete_route_can_cross_a_search_frontier(self):
from tests.test_independent_generation import TypedExpressionContracts
fixture=TypedExpressionContracts();fixture.setUp();notes,contract=fixture.make()
partial=fixture.h.evaluate(notes[:2],until=F(2),complete=False,expressions=(contract,))
self.assertTrue(partial.valid,partial.error)
self.assertEqual(partial.pending,(contract,))
self.assertFalse(fixture.h.evaluate(notes[:2],until=F(3),expressions=(contract,)).valid)
complete=fixture.h.evaluate(notes,until=F(4),complete=True,expressions=(contract,))
self.assertTrue(complete.valid);self.assertFalse(complete.pending)
class Reachability(unittest.TestCase):
def test_all_development_operations_are_requested(self):
notes=melody(((0,1,'c'),(1,2,'e'),(2,3,'d'),(3,4,'e')))
home=types.RealizedMotif('home','source',notes,F(4),'introduce','contour',signature=notes)
state=types.ContinuationState('',home=home,notes=notes,last=notes[-1])
ops=themes.operation_choices(home,state,F(4),F(4),False)
self.assertTrue({'answer','extend','fragment','continue','return'}.issubset(ops))
h=harmonic.Harmony(choices(('CM7','CM7')))
context=realization.SearchContext()
for op in ops:
candidates=themes.unit_candidates(themes.home_reference(home,op),F(4),F(8),state,h,
tests.solo_fixtures.library(),tests.rhythm_fixtures.library(),'4/4',context,home=home,operation=op)
self.assertTrue(candidates,op)
self.assertGreater(context.diagnostics['operations'][op]['valid'],0)
if __name__=='__main__':unittest.main()
class InputFirewall(unittest.TestCase):
def test_read_write_handles_cannot_bypass_boundary(self):
import tempfile
from pathlib import Path
from generator.input_audit import composition
with tempfile.TemporaryDirectory() as folder:
forbidden=Path(folder)/'target.xml';forbidden.write_text('not musical input')
for mode in ('r','r+'):
with self.assertRaisesRegex(ValueError,'Forbidden composition input'):
with composition(Path('data'),'electric_solo'):
with forbidden.open(mode) as stream:stream.read()
class RetrievalIntegrity(unittest.TestCase):
def test_diversity_optimization_preserves_stable_old_order(self):
from generator.solo.sequences import diverse
values=((3,'b'),(1,'a'),(1,'b'),(1,'a'),(2,'c'),(4,'a'))
def original(items,limit):
families={}
for item in sorted(items,key=lambda v:v[0]):families.setdefault(item[1],[]).append(item)
out=[];i=0
while len(out)<limit:
layer=[g[i] for g in families.values() if len(g)>i]
if not layer:break
out.extend(sorted(layer,key=lambda v:v[0])[:limit-len(out)]);i+=1
return tuple(out)
for limit in range(9):
self.assertEqual(diverse(values,lambda v:v[0],lambda v:v[1],limit),original(values,limit))
def test_seed_cutoff_preserves_different_feature_contracts(self):
from generator.solo import analysis
from tests.test_solo import song
seeds=themes.seeds(analysis.analyze(song(count=12)),F(4),'independent-test')
self.assertGreater(len({s.treatment for s in seeds}),1)
class NonzeroContributions(unittest.TestCase):
def test_rhythm_destination_repetition_are_operational(self):
from generator.solo import phrasing
h=harmonic.Harmony(choices(('C7',)),profiles.Instrument(reference=55))
ns=(note(0,F(1,2),'g'),note(1,F(3,2),'c',octave=5),note(3,4,'e'))
motif=types.Motif('fixture',(),F(4),(),'independently authored')
plan=types.PhrasePlan('fixture',F(),F(4),motif,'continue','thematic_recomposition',None,(41,76),F(1),F(),
(types.ActivitySpan(F(),F(4),'active',F(1),F()),),ending='connected',closure='open')
anchor=types.Anchor('arrival',F(3),F(3),ns[-1].pitch,h.members(F(3),F(4)),(41,76),F(1),'intermediate',h.at(F(3)).chord,hold_until=F(4))
draft=types.MelodicDraft('fixture',F(),F(4),ns,(),(),(),(anchor,),None,'continue')
old=tuple(dataclasses.replace(n,start=n.start-4,end=n.end-4) for n in ns)
incoming=types.ContinuationState('',history=(phrasing.heard(old,'continue'),))
base=scoring.musical(plan,draft,ns,incoming,h)
self.assertTrue(base.valid,base.error)
for key in ('rhythm','destination','repetition'):
self.assertGreater(base.raw[key],0,key)
changed=scoring.musical(plan,draft,ns,incoming,h,weights={**base.weights,key:0})
self.assertAlmostEqual(base.total-changed.total,base.contributions[key])
class PreparedSuspensions(unittest.TestCase):
def test_preparation_and_delayed_resolution_are_required(self):
h=harmonic.Harmony(choices(('Gm7','C7')))
held=note(F(5,2),F(9,2),'f',event_id='held')
target=note(F(9,2),6,'e',event_id='resolution')
contract=types.ExpressionContract('prepared','suspension','prepared_step_down',(held,),target,'C7',boundary=F(4))
self.assertTrue(h.evaluate((held,target),expressions=(contract,)).valid)
self.assertFalse(h.evaluate((held,target)).valid)
changed=dataclasses.replace(held,start=F(15,4))
self.assertFalse(h.evaluate((changed,target),expressions=(dataclasses.replace(contract,events=(changed,)),)).valid)
self.assertFalse(h.evaluate((held,),expressions=(contract,)).valid)
def test_suspension_rule_is_reachable_from_provisional_phrase(self):
from generator.solo import expressions
h=harmonic.Harmony(choices(('Gm7','C7')))
ns=(note(3,4,'f',event_id='held'),note(4,6,'e',event_id='resolution'))
motif=types.Motif('fixture',ns,F(8),(),'independent suspension')
plan=types.PhrasePlan('fixture',F(),F(8),motif,'continue','thematic_recomposition',None,(41,76),F(1),F(),ending='connected',closure='open')
draft=types.MelodicDraft('fixture',F(),F(8),ns,(),(),(),(),None,'continue')
phrase=types.Phrase(plan,(),0,draft=draft)
candidates=[(notes,c) for notes,c in expressions.proposals(phrase,h) if c.kind=='suspension']
self.assertTrue(candidates)
self.assertTrue(any(h.evaluate(notes,expressions=(c,)).valid for notes,c in candidates))
-20
View File
@@ -14,14 +14,11 @@ import generator.harmony
import generator.model
import generator.score
import generator.solo.analysis
import generator.solo.planning
import generator.solo.placement
import generator.solo.realization
import tests.solo_fixtures
import tests.test_acoustic_voicing
import tests.test_musicxml
import tests.test_solo
import tests.test_solo_development
def position(string, fret):
return generator.fretboard.TabPosition(string, fret, dict(generator.fretboard.STANDARD_TUNING)[string] + fret)
@@ -144,20 +141,3 @@ class FretPolicyTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, 'each sounding chord requires an anchor'):
generator.score.write_json(output, target)
self.assertFalse(output.exists())
def test_solo_candidates_and_anchors_are_reachable_before_placement(self):
choices = tests.test_solo_development.choices(end=32)
options = generator.solo.realization.options(choices, fractions.Fraction(), fractions.Fraction(4), (59, 84))
self.assertTrue(options)
self.assertLessEqual(max(p.number() for p in options), 76)
source = tests.test_solo.song(count=8, chords=('CM7',))
plans = generator.solo.planning.plans(generator.solo.analysis.analyze(source), source['staves'][0]['measures'], choices, fractions.Fraction(32), 'electric', tests.solo_fixtures.library())
self.assertTrue(plans)
for plan in plans:
self.assertLessEqual(plan.register[1], 76)
self.assertTrue(plan.anchor_options)
for anchors in plan.anchor_options:
self.assertTrue(all(generator.solo.placement.positions(a.pitch.number()) for a in anchors))
motif = generator.solo.planning.new_motif(tests.solo_fixtures.library(), fractions.Fraction(4), choices=choices, register=(70, 84), seed='high')
self.assertTrue(all(generator.solo.placement.positions(n.pitch.number()) for n in motif.notes))
high = tests.test_solo_development.note(0, 1, 'f', octave=5)
self.assertIsNone(generator.solo.placement.place((high,), fractions.Fraction(120)))
+5 -5
View File
@@ -12,10 +12,10 @@ from fractions import Fraction as F
from unittest.mock import patch
from generator import production, model, vocabulary
from generator.solo import expressions, types, harmonic, placement, notation, composer, themes
from tests.test_solo import song
from tests.test_solo_passages import choices
from tests.test_solo_development import note
from generator.solo import expressions, types, harmonic, placement, notation, composer
from tests.solo_fixtures import song
from tests.solo_fixtures import choices
from tests.solo_fixtures import note
def music(result):
@@ -86,7 +86,7 @@ class IndependentRoles(unittest.TestCase):
for p in n['pitches']:p['letter']={'c':'e','d':'f','e':'g','f':'a'}[p['letter']]
a=production.generate_role(production.SourceAnalysis.read(source),'acoustic_solo',self.root)
b=production.generate_role(production.SourceAnalysis.read(changed),'acoustic_solo',self.root)
self.assertNotEqual(a['details']['phrases'][0]['draft']['notes'],b['details']['phrases'][0]['draft']['notes'])
self.assertNotEqual(a['details']['phrases'][0]['notes'],b['details']['phrases'][0]['notes'])
def test_source_tuplet_lanes_survive_assembly(self):
serial={r:production.generate_role(self.source,r,self.root) for r in production.ROLES}
-114
View File
@@ -1,114 +0,0 @@
"""Independent harmonic relationships; no optional reference reads."""
import dataclasses
from fractions import Fraction as F
import unittest
from generator.solo import gestures, harmonic, routes, types, expressions, placement, themes, realization
from tests.test_solo_passages import choices
from tests.test_solo_development import note
import tests.solo_fixtures
import tests.scale_fixtures
import tests.rhythm_fixtures
class JointArrivals(unittest.TestCase):
def fixture(self):
h=harmonic.Harmony(choices(('Dm7','G7'),scale=tests.scale_fixtures.library().scale('major',1,'C')))
ns=tuple(note(F(i,2),F(i+1,2),'d' if i<8 else 'g',event_id=str(i)) for i in range(16))
return h,ns
def test_anticipation_is_planned_before_route_and_requires_its_contract(self):
h,ns=self.fixture()
alternatives=list(gestures.proposals(ns,h,F(),F(8)))
p=next(p for p in alternatives if p.contracts and p.contracts[0].kind=='anticipation' and p.contracts[0].target.pitch.letter=='b' and p.contracts[0].target.end-p.contracts[0].target.start>=2)
result=routes.candidates(p.notes,(),h,tests.solo_fixtures.library(),(41,76),expressions=p.contracts)
self.assertTrue(result)
for r in result:
bound=p.bind(r.notes)
self.assertTrue(h.evaluate(r.notes,expressions=bound,complete=True).valid)
self.assertFalse(h.evaluate(r.notes,complete=True).valid)
self.assertLess(bound[0].target.start,F(4));self.assertGreater(bound[0].target.end,F(4))
self.assertIsNotNone(placement.place(r.notes,F(120)))
def test_approach_and_anticipation_share_exact_future_target(self):
h,ns=self.fixture()
base=next(p for p in gestures.proposals(ns,h,F(),F(8)) if p.contracts and p.contracts[0].kind=='anticipation')
proposals=list(gestures.approach(base,h,F()))
self.assertTrue(proposals)
realized=[]
for p in proposals:
for r in routes.candidates(p.notes,(),h,tests.solo_fixtures.library(),(41,76),expressions=p.contracts):
bound=p.bind(r.notes);self.assertTrue(h.evaluate(r.notes,expressions=bound,complete=True).valid)
self.assertFalse(h.evaluate(r.notes,expressions=bound[1:],complete=True).valid)
realized.append((r,bound))
self.assertTrue(realized)
r,contracts=realized[0];target=contracts[0].target
changed=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,octave=n.pitch.octave+1)) if n.event_id==target.event_id else n for n in r.notes)
self.assertFalse(h.evaluate(changed,expressions=contracts,complete=True).valid)
def test_on_time_and_delayed_arrivals_are_real_alternatives(self):
h,ns=self.fixture();ps=list(gestures.proposals(ns,h,F(),F(8)))
offsets={p.notes[next(i for i,n in enumerate(p.notes) if n.role=='arrival')].start-4 for p in ps if p.purpose.startswith('harmonic_arrival')}
self.assertEqual(offsets,{F(-1,2),F(),F(1,2)})
def test_proposal_cache_preserves_music_and_restores_each_callers_provenance(self):
h,ns=self.fixture()
direct=gestures._select(ns,h,F(),F(8))
cached=gestures.select(ns,h,F(),F(8))
self.assertEqual(direct,cached)
changed=tuple(dataclasses.replace(n,event_id='caller:'+n.event_id,sources=('new-source',)) for n in ns)
self.assertEqual(gestures.select(changed,h,F(),F(8)),gestures._select(changed,h,F(),F(8)))
self.assertEqual(len(h.gesture_cache),1)
def test_route_cache_preserves_contract_conditioned_realization(self):
h,ns=self.fixture()
p=next(p for p in gestures.proposals(ns,h,F(),F(8)) if p.contracts and p.contracts[0].kind=='anticipation')
direct=routes._planned_candidates(p.notes,(),h,(41,76),'test',p.contracts)
cached=routes.planned_candidates(p.notes,(),h,(41,76),'test',p.contracts)
self.assertTrue(cached)
self.assertEqual(cached,direct)
self.assertEqual(cached,routes.planned_candidates(p.notes,(),h,(41,76),'test',p.contracts))
def test_color_is_a_planned_route_destination_not_a_blanket_permission(self):
h=harmonic.Harmony(choices(('CM7','CM7'),scale=tests.scale_fixtures.library().scale('major',1,'C')))
ns=(note(0,1,'c',event_id='a'),note(1,3,'e',event_id='b'),note(3,4,'g',event_id='c'))
p=next(p for p in gestures.proposals(ns,h,F(),F(4)) if p.contracts and p.contracts[0].rule=='major_ninth' and p.contracts[0].events[0].end-p.contracts[0].events[0].start>=2)
rs=routes.candidates(p.notes,(),h,tests.solo_fixtures.library(),(41,76),expressions=p.contracts)
self.assertTrue(rs)
self.assertFalse(h.evaluate(rs[0].notes).valid)
self.assertTrue(h.evaluate(rs[0].notes,expressions=p.bind(rs[0].notes),complete=True).valid)
def test_independent_transpositions_and_harmonic_rhythms(self):
for tonic,symbols in [('C',('Dm7','G7')),('F',('Gm7','C7')),('G',('Am7','D7'))]:
for length in (3,4):
h=harmonic.Harmony(choices(symbols,duration=length,scale=tests.scale_fixtures.library().scale('major',1,tonic)))
ns=tuple(note(F(i,2),F(i+1,2),'g',event_id=str(i)) for i in range(length*4))
ps=[p for p in gestures.proposals(ns,h,F(),F(length*2)) if p.contracts and p.contracts[0].kind=='anticipation']
self.assertTrue(ps,(tonic,length))
self.assertTrue(any(routes.candidates(p.notes,(),h,tests.solo_fixtures.library(),(41,76),expressions=p.contracts) for p in ps),(tonic,length))
def test_closed_hold_cannot_be_consumed_by_a_joint_action(self):
h,ns=self.fixture();target=ns[-1]
ending=types.Anchor('close',F(6),F(6),target.pitch,frozenset({7}),(41,76),F(2),'terminal','G7',hold_until=F(8))
for p in gestures.proposals(ns,h,F(),F(8),ending):
self.assertFalse(any(n.role=='arrival' and n.end>6 for n in p.notes))
def test_minor_sixth_is_bounded_stable_color_not_required_neighbor_resolution(self):
h=harmonic.Harmony(choices(('Dm','Dm')))
n=note(0,3,'b',octave=3,event_id='color')
c=types.ExpressionContract('sixth','color','minor_sixth',(n,),n,'Dm')
self.assertTrue(h.evaluate((n,),expressions=(c,),complete=True).valid)
self.assertFalse(h.evaluate((n,),complete=True).valid)
too_long=dataclasses.replace(n,end=F(5));bad=dataclasses.replace(c,events=(too_long,),target=too_long)
self.assertFalse(h.evaluate((too_long,),expressions=(bad,),complete=True).valid)
def test_whole_tone_enclosure_neighbor_must_actually_be_mapped(self):
h=harmonic.Harmony(choices(('C7',),scale=tests.scale_fixtures.library().scale('major',5,'C')))
target=note(2,4,'g',event_id='target')
upper=note(1,F(3,2),'a',event_id='upper');lower=note(F(3,2),2,'f',alter=1,event_id='lower')
c=types.ExpressionContract('enclosure','enclosure','mapped_upper_lower',(upper,lower),target,'C7')
self.assertTrue(h.evaluate((upper,lower,target),expressions=(c,),complete=True).valid)
altered=harmonic.Harmony(choices(('C7',),scale=tests.scale_fixtures.library().scale('harmonic_minor',5,'C')))
self.assertFalse(altered.evaluate((upper,lower,target),expressions=(c,),complete=True).valid)
if __name__=='__main__':unittest.main()
+130
View File
@@ -0,0 +1,130 @@
"""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()
+72
View File
@@ -0,0 +1,72 @@
import dataclasses, unittest
from fractions import Fraction as F
from generator.solo import types, harmonic, groups
from tests.solo_fixtures import note,choices
def melody(rows):return tuple(note(a,b,p,event_id=str(i)) for i,(a,b,p) in enumerate(rows))
class RhythmGroups(unittest.TestCase):
def test_complete_triplet_can_be_displaced_without_offsetting_binary_tail(self):
pattern = ((F(1, 2), F(5, 6)), (F(5, 6), F(7, 6)), (F(7, 6), F(3, 2)), (F(3, 2), F(5, 2)))
result = groups.describe(pattern)
self.assertEqual(result[0].family, 'triplet')
self.assertEqual(result[-1].family, 'binary')
self.assertEqual(result[0].displacement, F(1, 2))
def test_partial_triplet_does_not_drift_into_binary(self):
self.assertFalse(groups.valid(((F(), F(1, 3)), (F(1, 3), F(5, 6)))))
self.assertFalse(groups.valid(((F(1, 3), F(4, 3)),)))
self.assertEqual(groups.describe(((F(1, 3), F(4, 3)),), imported=True)[0].family, 'source')
def test_relative_rhythm_identity_does_not_count_absolute_onsets(self):
original = melody(((0, 1, 'c'), (1, 2, 'e'), (2, 3, 'd')))
self.assertEqual(len(set(groups.signature(original))), 1)
moved = tuple((dataclasses.replace(n, start=n.start + 13, end=n.end + 13) for n in original))
self.assertEqual(groups.signature(original), groups.signature(moved))
def test_broadening_never_cuts_inside_tuplet(self):
pattern = ((F(), F(1, 3)), (F(1, 3), F(2, 3)), (F(2, 3), F(1)))
self.assertEqual(groups.broaden(pattern, F(1, 2), F(4)), ())
class PendingTreatments(unittest.TestCase):
def test_complete_route_can_cross_a_search_frontier(self):
from tests.test_independent_generation import TypedExpressionContracts
fixture = TypedExpressionContracts()
fixture.setUp()
notes, contract = fixture.make()
partial = fixture.h.evaluate(notes[:2], until=F(2), complete=False, expressions=(contract,))
self.assertTrue(partial.valid, partial.error)
self.assertEqual(partial.pending, (contract,))
self.assertFalse(fixture.h.evaluate(notes[:2], until=F(3), expressions=(contract,)).valid)
complete = fixture.h.evaluate(notes, until=F(4), complete=True, expressions=(contract,))
self.assertTrue(complete.valid)
self.assertFalse(complete.pending)
class InputFirewall(unittest.TestCase):
def test_read_write_handles_cannot_bypass_boundary(self):
import tempfile
from pathlib import Path
from generator.input_audit import composition
with tempfile.TemporaryDirectory() as folder:
forbidden = Path(folder) / 'target.xml'
forbidden.write_text('not musical input')
for mode in ('r', 'r+'):
with self.assertRaisesRegex(ValueError, 'Forbidden composition input'):
with composition(Path('data'), 'electric_solo'):
with forbidden.open(mode) as stream:
stream.read()
class PreparedSuspensions(unittest.TestCase):
def test_preparation_and_delayed_resolution_are_required(self):
h = harmonic.Harmony(choices(('Gm7', 'C7')))
held = note(F(5, 2), F(9, 2), 'f', event_id='held')
target = note(F(9, 2), 6, 'e', event_id='resolution')
contract = types.ExpressionContract('prepared', 'suspension', 'prepared_step_down', (held,), target, 'C7', boundary=F(4))
self.assertTrue(h.evaluate((held, target), expressions=(contract,)).valid)
self.assertFalse(h.evaluate((held, target)).valid)
changed = dataclasses.replace(held, start=F(15, 4))
self.assertFalse(h.evaluate((changed, target), expressions=(dataclasses.replace(contract, events=(changed,)),)).valid)
self.assertFalse(h.evaluate((held,), expressions=(contract,)).valid)
+4 -35
View File
@@ -8,10 +8,10 @@ import generator.arrange.form
import generator.model
import generator.parts
import generator.solo.arrangement
from generator.solo import analysis, profiles, placement, themes, types, harmonic, gesture_rhythm, phrasing
from tests.test_solo import song
from tests.test_solo_development import note
from tests.test_solo_passages import choices
from generator.solo import analysis, profiles, placement, types, harmonic
from tests.solo_fixtures import song
from tests.solo_fixtures import note
from tests.solo_fixtures import choices
import tests.rhythm_fixtures
@@ -75,34 +75,3 @@ class SoloInstrumentAndIdentity(unittest.TestCase):
self.assertEqual(acoustic.melody_range,electric.melody_range)
self.assertEqual(acoustic.cost(acoustic.reference+4),0)
self.assertGreater(acoustic.cost(acoustic.reference+9),0)
def test_return_keeps_opening_instead_of_later_largest_turn(self):
notes=tuple(note(i,i+1,p,event_id=str(i)) for i,p in enumerate(('d','f','g','f','c','b')))
home=types.RealizedMotif('home','seed',notes,F(6),'introduce','contour',signature=notes[:4])
reference=themes.home_reference(home,'return')
self.assertEqual(reference.notes,notes[:4])
self.assertEqual(reference.treatment,'return')
self.assertEqual(len(themes.home_reference(home,'develop_contour').notes),3)
def test_endings_distinguish_local_cadence_and_handoff(self):
harmony=harmonic.Harmony(choices(('Em7b5','A7','Dm','Dm')))
self.assertEqual(phrasing.ending_effect(F(2),harmony),'local_settle')
self.assertEqual(phrasing.ending_effect(F(9),harmony),'cadential_arrival')
self.assertEqual(phrasing.ending_effect(F(15),harmony,True),'handoff')
anchor=types.Anchor('comma',F(1),F(1),note(0,1,'g').pitch,frozenset({7}),(41,76),F(2),'terminal','Em7b5',hold_until=F(3))
self.assertEqual(set(phrasing.next_destination(anchor,harmony)),{2,5,9})
def test_rhythm_revision_connects_without_changing_attacks_or_arrival(self):
notes=(note(0,1,'g',event_id='a'),note(F(3,2),2,'e',event_id='b'),note(F(5,2),3,'g',event_id='c'),note(3,6,'e',event_id='end'))
anchor=types.Anchor('end',F(3),F(3),notes[-1].pitch,frozenset({4}),(41,76),F(3),'terminal','C7',hold_until=F(6))
draft=types.MelodicDraft('gesture',F(),F(6),notes,(),(),(),(anchor,),None,'develop',sustained_tails=(types.SustainedTail(F(3),F(6),64),))
revised,edits=gesture_rhythm.revise(draft,harmonic.Harmony(choices(('C7','C7'))))
self.assertTrue(edits)
self.assertEqual(tuple(n.pitch for n in revised.notes),tuple(n.pitch for n in notes))
self.assertEqual(revised.notes[-1],notes[-1])
self.assertLess(gesture_rhythm.fragmentation(revised.notes),gesture_rhythm.fragmentation(notes))
protected=dataclasses.replace(draft,protected=tuple(n.event_id for n in notes))
self.assertEqual(gesture_rhythm.revise(protected,harmonic.Harmony(choices(('C7','C7'))))[0].notes,notes)
if __name__=='__main__':unittest.main()
+160 -235
View File
@@ -7,7 +7,6 @@ import pathlib
import tempfile
import unittest
import unittest.mock
import generator.__main__
import generator.arrange.generate
import generator.fretboard
@@ -24,8 +23,6 @@ import generator.solo.arrangement
import generator.solo.composer
import generator.solo.notation
import generator.solo.placement
import generator.solo.planning
import generator.solo.realization
import generator.solo.report
import generator.solo.sequences
import generator.solo.types
@@ -35,240 +32,168 @@ import tests.solo_fixtures
import tests.test_musicxml
def song(meter=4, count=6, chords=('Dm7', 'G7', 'CM7')):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join(tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G'))))
first = document['staves'][0]['measures'][0]
measures = []
for index in range(count):
measure = copy.deepcopy(first)
measure.update(id=f'measure_{index + 1}', number=str(index + 1), duration=generator.model.rational(meter), meter={'groups': [{'beats': [meter], 'unit': 4}]}, time_signature=f'{meter}/4')
measure['voices'][0]['notes'] = [generator.model.make_event(f'source_{index}_{beat}', fractions.Fraction(beat), fractions.Fraction(1), 'note', [{'letter': 'cdefg'[beat % 5], 'octave': 4, 'alter': 0}]) for beat in range(meter)]
measure['harmonies'] = [{**generator.harmony.parse_symbol(chords[index % len(chords)]), 'id': f'h_{index}', 'onset': generator.model.rational(0)}] if chords else []
for harmony in measure['harmonies']:
harmony['symbol'] = generator.harmony.symbol(harmony)
measures.append(measure)
document['staves'][0]['measures'] = measures
return generator.parts.prepare_document(document)
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join((tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G')))))
first = document['staves'][0]['measures'][0]
measures = []
for index in range(count):
measure = copy.deepcopy(first)
measure.update(id=f'measure_{index + 1}', number=str(index + 1), duration=generator.model.rational(meter), meter={'groups': [{'beats': [meter], 'unit': 4}]}, time_signature=f'{meter}/4')
measure['voices'][0]['notes'] = [generator.model.make_event(f'source_{index}_{beat}', fractions.Fraction(beat), fractions.Fraction(1), 'note', [{'letter': 'cdefg'[beat % 5], 'octave': 4, 'alter': 0}]) for beat in range(meter)]
measure['harmonies'] = [{**generator.harmony.parse_symbol(chords[index % len(chords)]), 'id': f'h_{index}', 'onset': generator.model.rational(0)}] if chords else []
for harmony in measure['harmonies']:
harmony['symbol'] = generator.harmony.symbol(harmony)
measures.append(measure)
document['staves'][0]['measures'] = measures
return generator.parts.prepare_document(document)
def compose(document, part='acoustic', cutoff=None):
analysis = generator.solo.analysis.analyze(document)
measures = document['staves'][0]['measures']
end = sum(generator.model.fraction(m['duration']) for m in measures) if cutoff is None else cutoff
return generator.solo.composer.compose(analysis, measures, part, end, scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library())
analysis = generator.solo.analysis.analyze(document)
measures = document['staves'][0]['measures']
end = sum((generator.model.fraction(m['duration']) for m in measures)) if cutoff is None else cutoff
return generator.solo.composer.compose(analysis, measures, part, end, scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library())
class SoloTests(unittest.TestCase):
def setUp(self):
tests.production_fixtures.install(self)
def test_catalog_intervals_and_negative_scale_indices(self):
library = generator.solo.sequences.load_library(pathlib.Path('data/sequences'))
self.assertEqual({k: len(v) for k, v in library.paths.items()}, {2: 14, 3: 196, 4: 2744})
self.assertEqual(len({s.steps for s in library.paths[4]}), 2744)
self.assertTrue(all(s.offsets()[-1] == 0 for s in library.connecting(3, 0)))
scale = tests.scale_fixtures.library().scale('major', 6, 'D')
pitches = [generator.solo.types.scale_pitch(scale, i) for i in (2, 3, 5, 4)]
self.assertEqual([p.label() for p in pitches], ['F4', 'G4', 'Bb4', 'A4'])
self.assertEqual(generator.solo.types.scale_pitch(scale, -1).label(), 'C4')
self.assertEqual(generator.solo.types.scale_pitch(scale, -8).label(), 'C3')
self.assertEqual(generator.solo.types.scale_pitch(scale, 7).label(), 'D5')
diminished = tests.scale_fixtures.library().scale('harmonic_minor', 7, 'C')
self.assertEqual(generator.solo.types.scale_pitch(diminished, 6).label(), 'Bbb4')
def test_invalid_sequences_fail_with_source(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
with self.assertRaisesRegex(ValueError, 'empty or missing'):
generator.solo.sequences.load_library(root)
path = root / '2_4/0001.xml'
path.parent.mkdir()
path.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
with self.assertRaisesRegex(ValueError, '0001.xml.*2/4'):
generator.solo.sequences.load_library(root)
for steps in ((0,), (8,), (), (1, 1, 1, 1)):
with self.assertRaisesRegex(ValueError, 'invalid'):
generator.solo.sequences.Library((generator.solo.sequences.Sequence('bad', steps),))
def test_ties_are_one_attack_but_repeated_notes_are_two(self):
content = tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>')
content += tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="stop"/>')
content += tests.test_musicxml.xml_note(duration=2, kind='half')
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
attacks = generator.solo.analysis.attacks(document)
self.assertEqual([(n.start, n.end) for n in attacks], [(0, 2), (2, 4)])
self.assertEqual(len(attacks[0].sources), 2)
self.assertEqual(attacks[0].pitch, attacks[1].pitch)
def test_held_pitch_checked_across_every_collection(self):
document = song(count=2, chords=('CM7', 'DmM7'))
choices = generator.scales.selection.select(generator.scales.timeline.read(document['staves'][0]), generator.scales.timeline.Solo(0, 8, 'solo'), tests.scale_fixtures.library())
self.assertIn(0, generator.solo.types.permitted(choices, 0, 4))
self.assertNotIn(0, generator.solo.types.permitted(choices, 3, 5))
self.assertIn(2, generator.solo.types.permitted(choices, 3, 5))
self.assertFalse(generator.solo.types.permitted(choices, 7, 9))
def test_targets_preserve_suspensions_alterations_and_extensions(self):
for symbol, present, absent in (('Csus', 5, 4), ('Cm7b5', 6, 7), ('CmM7', 11, 10), ('C7b9', 1, 2)):
pitches = generator.solo.planning.target_pitches(generator.harmony.parse_symbol(symbol), False)
self.assertIn(present, pitches)
self.assertNotIn(absent, pitches)
def test_compose_all_meters_without_stretching(self):
for meter in (2, 3, 4, 5):
with self.subTest(meter=meter):
source = song(meter, 3)
result = compose(source)
generator.solo.composer.validate(result)
self.assertEqual(result.end, meter * 3)
def setUp(self):
tests.production_fixtures.install(self)
def test_catalog_intervals_and_negative_scale_indices(self):
library = generator.solo.sequences.load_library(pathlib.Path('data/sequences'))
self.assertEqual({k: len(v) for k, v in library.paths.items()}, {2: 14, 3: 196, 4: 2744})
self.assertEqual(len({s.steps for s in library.paths[4]}), 2744)
self.assertTrue(all((s.offsets()[-1] == 0 for s in library.connecting(3, 0))))
scale = tests.scale_fixtures.library().scale('major', 6, 'D')
pitches = [generator.solo.types.scale_pitch(scale, i) for i in (2, 3, 5, 4)]
self.assertEqual([p.label() for p in pitches], ['F4', 'G4', 'Bb4', 'A4'])
self.assertEqual(generator.solo.types.scale_pitch(scale, -1).label(), 'C4')
self.assertEqual(generator.solo.types.scale_pitch(scale, -8).label(), 'C3')
self.assertEqual(generator.solo.types.scale_pitch(scale, 7).label(), 'D5')
diminished = tests.scale_fixtures.library().scale('harmonic_minor', 7, 'C')
self.assertEqual(generator.solo.types.scale_pitch(diminished, 6).label(), 'Bbb4')
def test_invalid_sequences_fail_with_source(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
with self.assertRaisesRegex(ValueError, 'empty or missing'):
generator.solo.sequences.load_library(root)
path = root / '2_4/0001.xml'
path.parent.mkdir()
path.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
with self.assertRaisesRegex(ValueError, '0001.xml.*2/4'):
generator.solo.sequences.load_library(root)
for steps in ((0,), (8,), (), (1, 1, 1, 1)):
with self.assertRaisesRegex(ValueError, 'invalid'):
generator.solo.sequences.Library((generator.solo.sequences.Sequence('bad', steps),))
def test_ties_are_one_attack_but_repeated_notes_are_two(self):
content = tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>')
content += tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="stop"/>')
content += tests.test_musicxml.xml_note(duration=2, kind='half')
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
attacks = generator.solo.analysis.attacks(document)
self.assertEqual([(n.start, n.end) for n in attacks], [(0, 2), (2, 4)])
self.assertEqual(len(attacks[0].sources), 2)
self.assertEqual(attacks[0].pitch, attacks[1].pitch)
def test_held_pitch_checked_across_every_collection(self):
document = song(count=2, chords=('CM7', 'DmM7'))
choices = generator.scales.selection.select(generator.scales.timeline.read(document['staves'][0]), generator.scales.timeline.Solo(0, 8, 'solo'), tests.scale_fixtures.library())
self.assertIn(0, generator.solo.types.permitted(choices, 0, 4))
self.assertNotIn(0, generator.solo.types.permitted(choices, 3, 5))
self.assertIn(2, generator.solo.types.permitted(choices, 3, 5))
self.assertFalse(generator.solo.types.permitted(choices, 7, 9))
def test_no_harmony_is_silence_and_conflict_is_an_error(self):
result = compose(song(chords=()))
self.assertFalse(result.notes())
with self.assertRaisesRegex(ValueError, 'source measure 1.*No exact match'):
compose(song(chords=('A13b9',)))
def test_microtones_are_not_rounded_into_the_catalog(self):
with self.assertRaisesRegex(ValueError, 'semitones'):
generator.solo.types.Pitch('c', fractions.Fraction(1, 2), 4)
def test_pickup_cutoff_is_reserved_before_composition(self):
result = compose(song(count=6), 'electric', fractions.Fraction(43, 2))
self.assertTrue(all((n.end <= fractions.Fraction(43, 2) for n in result.notes())))
self.assertEqual(result.phrases[-1].end, fractions.Fraction(43, 2))
def test_time_aware_placement_does_not_penalize_absolute_frets(self):
p = generator.solo.types.Pitch('c', fractions.Fraction(), 4)
before = generator.solo.types.Note(0, 1, p, position=generator.fretboard.TabPosition(2, 1, 60))
after = generator.solo.types.Note(1, 2, p, position=generator.fretboard.TabPosition(2, 6, 65))
self.assertGreater(generator.solo.placement.travel(before, after, fractions.Fraction(180)), generator.solo.placement.travel(before, after, fractions.Fraction(90)))
shifted = dataclasses.replace(before, position=generator.fretboard.TabPosition(2, 8, 67))
current = dataclasses.replace(after, position=generator.fretboard.TabPosition(2, 13, 72))
self.assertEqual(generator.solo.placement.travel(before, after, fractions.Fraction(120)), generator.solo.placement.travel(shifted, current, fractions.Fraction(120)))
def test_notation_ties_cross_bars_and_keep_string_without_new_attack(self):
document = song(count=2, chords=('CM7',))
note = generator.solo.types.Note(fractions.Fraction(3), fractions.Fraction(6), generator.solo.types.Pitch('e', fractions.Fraction(), 4), position=generator.fretboard.TabPosition(2, 5, 64))
document['staves'][0]['measures'] = generator.solo.notation.measures(document['staves'][0]['measures'], (note,), 'test')
document['staves'][0]['tablature'] = True
generator.model.validate_document(document)
events = generator.playback.compile_timeline(document)['events']
self.assertEqual(len(events), 1)
self.assertEqual(generator.model.fraction(events[0]['duration']), 3)
self.assertEqual(generator.model.fraction(events[0]['pitch']), 64)
def test_rational_motif_rhythm_has_visible_tuplets_and_exact_playback(self):
document = song(count=1, chords=('CM7',))
pitch = generator.solo.types.Pitch('e', fractions.Fraction(), 4)
notes = tuple((generator.solo.types.Note(fractions.Fraction(i, 3), fractions.Fraction(i + 1, 3), pitch, position=generator.fretboard.TabPosition(2, 5, 64)) for i in range(3)))
replacement = generator.solo.notation.measures(document['staves'][0]['measures'], notes, 'triplet')
generator.solo.notation.replace_region(document, 0, replacement)
generator.model.validate_document(document)
self.assertEqual(document['staves'][0]['tuplets'][0]['note_count'], 3)
self.assertEqual(document['staves'][0]['tuplets'][0]['space_count'], 2)
timeline = generator.playback.compile_timeline(document)
self.assertEqual([generator.model.fraction(e['duration']) for e in timeline['events']], [fractions.Fraction(1, 3)] * 3)
def test_source_tempo_and_fallback_are_quarter_note_rates(self):
source = song()
self.assertEqual(generator.solo.analysis.analyze(source).tempo, 120)
source['staves'][0]['measures'][0]['tempos'] = [{'onset': generator.model.rational(0), 'written': {'type': 'half', 'dots': 0}, 'bpm': generator.model.rational(75)}]
self.assertEqual(generator.solo.analysis.analyze(source).tempo, 150)
def test_harmony_carries_into_the_solo_until_an_explicit_no_chord(self):
source = song(count=2, chords=())
source['staves'][0]['measures'][1]['harmonies'] = [{'id': 'nc', 'onset': generator.model.rational(0), 'kind': 'none', 'symbol': 'N.C.'}]
result = generator.solo.composer.compose(generator.solo.analysis.analyze(source), source['staves'][0]['measures'], 'acoustic', 8, initial=generator.harmony.parse_symbol('CM7'), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library())
self.assertTrue(result.notes())
self.assertTrue(all(1 <= n.position.fret <= 12 for n in result.notes()))
self.assertEqual(result, compose(source))
def test_motif_recurrence_and_whole_fragment_octave_quote(self):
source = song(count=6, chords=('CM7',))
result = compose(source)
self.assertLessEqual(len({p.plan.motif.id for p in result.phrases}), 3)
self.assertTrue(all(p.draft.source_phrase for p in result.phrases))
self.assertFalse(any(d.treatment.startswith('quote') for p in result.phrases for d in p.decisions))
original = result.phrases[0].plan
explicit = dataclasses.replace(original, treatment='exact_quote', end=original.start+original.motif.duration,
target=None, anchor_options=(), gestures=(), passages=(),landmarks=(),release=fractions.Fraction(),ending='connected',closure='open',
passage_id='', passage_start=None, passage_end=None)
first = generator.solo.realization.compose(explicit, source['staves'][0]['measures'], result.choices, tests.solo_fixtures.library(), tests.rhythm_fixtures.library(), result.tempo)
quote = next(d for d in first.decisions if d.treatment.startswith('quote'))
self.assertEqual(len(quote.notes), len(first.plan.motif.notes))
shifts = {a.pitch.number() - b.pitch.number() for a, b in zip(quote.notes, first.plan.motif.notes)}
self.assertEqual(len(shifts), 1)
self.assertEqual(next(iter(shifts)) % 12, 0)
self.assertEqual([(n.start, n.end) for n in quote.notes], [(first.plan.start + n.start, first.plan.start + n.end) for n in first.plan.motif.notes])
result = dataclasses.replace(result, phrases=(first, *result.phrases[1:]))
report = generator.solo.report.describe(result, tests.scale_fixtures.library())
self.assertIn('quote octave', report)
self.assertIn('source events:', report)
def test_no_harmony_is_silence_and_conflict_is_an_error(self):
result = compose(song(chords=()))
self.assertFalse(result.notes())
with self.assertRaisesRegex(ValueError, 'source measure 1.*No exact match'):
compose(song(chords=('A13b9',)))
def test_no_harmony_inside_a_motif_is_an_explicit_fragment(self):
source = song(count=3, chords=('CM7',))
measure = source['staves'][0]['measures'][0]
measure['harmonies'].append({'id': 'nc', 'onset': generator.model.rational(1), 'kind': 'none', 'degrees': [], 'symbol': 'N.C.'})
measure['harmonies'].append({**copy.deepcopy(measure['harmonies'][0]), 'id': 'return', 'onset': generator.model.rational(2)})
result = compose(source)
self.assertTrue(result.notes())
self.assertFalse(any(n.start < 2 and n.end > 1 for n in result.notes()))
self.assertTrue(any(c.scale is None and c.span.start == 1 and c.span.end == 2 for c in result.choices))
def test_microtones_are_not_rounded_into_the_catalog(self):
with self.assertRaisesRegex(ValueError, 'semitones'):
generator.solo.types.Pitch('c', fractions.Fraction(1, 2), 4)
def test_pickup_cutoff_is_reserved_before_composition(self):
result = compose(song(count=6), 'electric', fractions.Fraction(43, 2))
self.assertTrue(all(n.end <= fractions.Fraction(43, 2) for n in result.notes()))
self.assertEqual(result.phrases[-1].plan.end, fractions.Fraction(43, 2))
def test_search_exhaustion_does_not_claim_impossibility(self):
source = song()
with unittest.mock.patch('generator.solo.themes.phrase_candidates', return_value=()), self.assertRaisesRegex(generator.solo.realization.SearchExhausted, 'not proof'):
compose(source)
def test_time_aware_placement_does_not_penalize_absolute_frets(self):
p = generator.solo.types.Pitch('c', fractions.Fraction(), 4)
before = generator.solo.types.Note(0, 1, p, position=generator.fretboard.TabPosition(2, 1, 60))
after = generator.solo.types.Note(1, 2, p, position=generator.fretboard.TabPosition(2, 6, 65))
self.assertGreater(generator.solo.placement.travel(before, after, fractions.Fraction(180)), generator.solo.placement.travel(before, after, fractions.Fraction(90)))
shifted = dataclasses.replace(before, position=generator.fretboard.TabPosition(2, 8, 67))
current = dataclasses.replace(after, position=generator.fretboard.TabPosition(2, 13, 72))
self.assertEqual(generator.solo.placement.travel(before, after, fractions.Fraction(120)), generator.solo.placement.travel(shifted, current, fractions.Fraction(120)))
def test_notation_ties_cross_bars_and_keep_string_without_new_attack(self):
document = song(count=2, chords=('CM7',))
note = generator.solo.types.Note(fractions.Fraction(3), fractions.Fraction(6), generator.solo.types.Pitch('e', fractions.Fraction(), 4), position=generator.fretboard.TabPosition(2, 5, 64))
document['staves'][0]['measures'] = generator.solo.notation.measures(document['staves'][0]['measures'], (note,), 'test')
document['staves'][0]['tablature'] = True
generator.model.validate_document(document)
events = generator.playback.compile_timeline(document)['events']
self.assertEqual(len(events), 1)
self.assertEqual(generator.model.fraction(events[0]['duration']), 3)
self.assertEqual(generator.model.fraction(events[0]['pitch']), 64)
def test_rational_motif_rhythm_has_visible_tuplets_and_exact_playback(self):
document = song(count=1, chords=('CM7',))
pitch = generator.solo.types.Pitch('e', fractions.Fraction(), 4)
notes = tuple(generator.solo.types.Note(fractions.Fraction(i, 3), fractions.Fraction(i + 1, 3), pitch, position=generator.fretboard.TabPosition(2, 5, 64)) for i in range(3))
replacement = generator.solo.notation.measures(document['staves'][0]['measures'], notes, 'triplet')
generator.solo.notation.replace_region(document, 0, replacement)
generator.model.validate_document(document)
self.assertEqual(document['staves'][0]['tuplets'][0]['note_count'], 3)
self.assertEqual(document['staves'][0]['tuplets'][0]['space_count'], 2)
timeline = generator.playback.compile_timeline(document)
self.assertEqual([generator.model.fraction(e['duration']) for e in timeline['events']], [fractions.Fraction(1, 3)] * 3)
def test_source_tempo_and_fallback_are_quarter_note_rates(self):
source = song()
self.assertEqual(generator.solo.analysis.analyze(source).tempo, 120)
source['staves'][0]['measures'][0]['tempos'] = [{'onset': generator.model.rational(0), 'written': {'type': 'half', 'dots': 0}, 'bpm': generator.model.rational(75)}]
self.assertEqual(generator.solo.analysis.analyze(source).tempo, 150)
def test_harmony_carries_into_the_solo_until_an_explicit_no_chord(self):
source = song(count=2, chords=())
source['staves'][0]['measures'][1]['harmonies'] = [{'id': 'nc', 'onset': generator.model.rational(0), 'kind': 'none', 'symbol': 'N.C.'}]
result = generator.solo.composer.compose(generator.solo.analysis.analyze(source), source['staves'][0]['measures'], 'acoustic', 8, initial=generator.harmony.parse_symbol('CM7'), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library())
self.assertTrue(result.notes())
self.assertTrue(all(n.end <= 4 for n in result.notes()))
def test_arrangement_preserves_head_and_vocal_and_backs_both_solos(self):
source = song(count=3, chords=('CM7',))
rhythms = tests.rhythm_fixtures.library()
plans = generator.arrange.generate.plan_arrangement(source, rhythms)
parts = {p: generator.arrange.generate._arrange_document(source, p, plans) for p in generator.parts.PART_TYPES}
before = copy.deepcopy(parts)
generator.solo.arrangement.apply(source, parts, rhythms=rhythms, scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library())
self.assertEqual(before['vocal'], parts['vocal'])
regions = generator.solo.arrangement.regions(source)
low, high = regions[3][0], regions[4][0] + regions[4][1]
for part in ('acoustic', 'electric'):
old, new = before[part]['staves'][0]['measures'], parts[part]['staves'][0]['measures']
self.assertEqual(old[:low], new[:low])
self.assertEqual(old[high:], new[high:])
support = 4 if part == 'acoustic' else 3
first, count, _, _ = regions[support]
self.assertEqual(old[first:first+count],new[first:first+count])
for section in (3, 4):
first, count, _, _ = regions[section]
self.assertTrue(any(n['pitches'] for m in new[first:first + count] for n in generator.score.measure_notes(m)))
generator.model.validate_document(parts[part])
def test_conflict_preserves_published_assets(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
(root / 'data/songs').mkdir(parents=True)
path = root / 'data/songs/conflict.xml'
path.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
output = root / 'assets'
output.mkdir()
(output / 'accepted').write_text('previous build')
args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()), unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()), unittest.mock.patch('generator.solo.composer.compose', side_effect=ValueError('No exact match within the permitted catalog')):
with self.assertRaisesRegex(ValueError, 'conflict.xml.*No exact match'):
generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['accepted'])
self.assertEqual((output / 'accepted').read_text(), 'previous build')
def test_unrelated_song_does_not_change_an_accepted_solo(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
source, output = root / 'songs', root / 'assets/songs'
source.mkdir()
xml = tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>' + ''.join(tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G')))
(source / 'accepted.xml').write_text(xml)
resources = {'rhythms': tests.rhythm_fixtures.library(), 'scales': tests.scale_fixtures.library(), 'sequences': tests.solo_fixtures.library()}
generator.arrange.generate.generate_performance_scores(source, output, **resources)
before = {p: p.read_bytes() for p in root.joinpath('assets').rglob('*') if p.is_file()}
(source / 'another.xml').write_text(xml.replace('Fixture', 'Another'))
generator.arrange.generate.generate_performance_scores(source, output, **resources)
self.assertEqual(before, {p: p.read_bytes() for p in before})
def test_quote_can_reselect_a_collection_without_changing_harmony(self):
document = song(count=2, chords=('DmM7',))
timeline = generator.scales.timeline.read(document['staves'][0])
region = generator.scales.timeline.Solo(0, 8, 'solo')
retained = ((0, 2, {11}),)
result = generator.scales.selection.select(timeline, region, tests.scale_fixtures.library(), retained=retained)
self.assertTrue(all(11 in c.scale.pitches for c in result))
self.assertTrue(all(set(generator.harmony.pitch_classes(c.span.chord)) <= c.scale.pitches for c in result))
self.assertEqual([c.span.chord for c in result], [s.chord for s in timeline])
def test_pitch_adaptation_crosses_collection_without_root_restart(self):
document = song(count=2, chords=('CM7', 'DmM7'))
choices = generator.scales.selection.select(generator.scales.timeline.read(document['staves'][0]), generator.scales.timeline.Solo(0, 8, 'solo'), tests.scale_fixtures.library())
pitches = (generator.solo.types.Pitch('b', fractions.Fraction(), 3), generator.solo.types.Pitch('c', fractions.Fraction(), 4))
paths = generator.solo.realization.adapt_path(pitches, ((3, 4), (4, 5)), choices, (55, 74))
self.assertTrue(paths)
for path in paths:
self.assertTrue(generator.solo.realization.valid_notes(path, choices))
self.assertLessEqual(abs(path[1].pitch.number() - path[0].pitch.number()), 3)
def test_sequence_sources_are_packaged_and_tracked(self):
self.assertIn('"data/sequences/**/*.xml"', pathlib.Path('conanfile.py').read_text())
self.assertIn("'data/sequences/**/*.xml'", pathlib.Path('.gitea/workflows/ci.yml').read_text())
self.assertIn('data/*.xml', pathlib.Path('cmake/zero.cmake').read_text())
self.assertTrue(all((n.end <= 4 for n in result.notes())))
def test_conflict_preserves_published_assets(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
(root / 'data/songs').mkdir(parents=True)
path = root / 'data/songs/conflict.xml'
path.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
output = root / 'assets'
output.mkdir()
(output / 'accepted').write_text('previous build')
args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()), unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()), unittest.mock.patch('generator.solo.composer.compose', side_effect=ValueError('No exact match within the permitted catalog')):
with self.assertRaisesRegex(ValueError, 'conflict.xml.*No exact match'):
generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['accepted'])
self.assertEqual((output / 'accepted').read_text(), 'previous build')
def test_unrelated_song_does_not_change_an_accepted_solo(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
source, output = (root / 'songs', root / 'assets/songs')
source.mkdir()
xml = tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>' + ''.join((tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G'))))
(source / 'accepted.xml').write_text(xml)
resources = {'rhythms': tests.rhythm_fixtures.library(), 'scales': tests.scale_fixtures.library(), 'sequences': tests.solo_fixtures.library()}
generator.arrange.generate.generate_performance_scores(source, output, **resources)
before = {p: p.read_bytes() for p in root.joinpath('assets').rglob('*') if p.is_file()}
(source / 'another.xml').write_text(xml.replace('Fixture', 'Another'))
generator.arrange.generate.generate_performance_scores(source, output, **resources)
self.assertEqual(before, {p: p.read_bytes() for p in before})
def test_sequence_sources_are_packaged_and_tracked(self):
self.assertIn('"data/sequences/**/*.xml"', pathlib.Path('conanfile.py').read_text())
self.assertIn("'data/sequences/**/*.xml'", pathlib.Path('.gitea/workflows/ci.yml').read_text())
self.assertIn('data/*.xml', pathlib.Path('cmake/zero.cmake').read_text())
+4 -68
View File
@@ -10,10 +10,10 @@ import unittest
from audition import reference_review, solo_compare
from generator import vocabulary, production
from generator.solo import export, gesture_rhythm, harmonic, notation, placement, routes, themes, types
from tests.test_solo import song
from tests.test_solo_development import note
from tests.test_solo_passages import choices
from generator.solo import export, harmonic, notation, placement, routes, types
from tests.solo_fixtures import song
from tests.solo_fixtures import note
from tests.solo_fixtures import choices
from tests import solo_fixtures
@@ -116,70 +116,6 @@ class ComparisonTests(unittest.TestCase):
self.assertTrue((root/'solo_xml'/'fixture-solo2.xml').is_file())
class MusicalSelectionTests(unittest.TestCase):
def test_cell_purpose_survives_loading_and_filters_eligibility(self):
cells=tuple(vocabulary.rhythm_cells(120,'arrival'))
self.assertTrue(cells)
self.assertTrue(all(c.purpose=='arrival' for c in cells))
self.assertFalse(any(c.id=='short_answer' for c in cells))
def test_connecting_rhythm_prefers_groups_to_isolated_notes(self):
joined=((F(),F(1,2)),(F(1,2),F(3,2)),(F(3,2),F(2)))
fragmented=((F(),F(1,2)),(F(1),F(3,2)),(F(2),F(5,2)))
self.assertLess(themes.rhythm_cost(joined,F(4),'connect'),themes.rhythm_cost(fragmented,F(4),'connect'))
def test_complete_signature_boundary_can_cross_barline(self):
home=SimpleNamespace(signature=(note(0,2),note(2,5)))
source=SimpleNamespace(end=F(24));ending=SimpleNamespace(earliest=F(21))
self.assertEqual(themes.unit_end(source,F(8),home,ending,F(4)),13)
def test_arpeggio_is_not_charged_for_each_nonstep_interval(self):
chord=(note(0,F(1,2),'c'),note(F(1,2),1,'e'),note(1,F(3,2),'g'))
steps=(note(0,F(1,2),'c'),note(F(1,2),1,'d'),note(1,F(3,2),'e'))
self.assertEqual(routes.path_cost(chord),routes.path_cost(steps))
h=harmonic.Harmony(choices(('CM7',)))
self.assertEqual(routes.path_family(chord,h)[0],'arpeggio')
self.assertEqual(routes.path_family(steps,h)[0],'mapped')
run=tuple(note(i,F(i+1),p) for i,p in enumerate(('c','d','e','f','g','a')))
self.assertEqual(routes.path_cost(run),0)
oscillation=tuple(note(i,F(i+1),p) for i,p in enumerate(('c','d','c','d','c')))
self.assertGreater(routes.path_cost(oscillation),0)
def test_data_driven_arpeggio_reaches_exact_destination_as_one_route(self):
h=harmonic.Harmony(choices(('CM7',)))
notes=(note(0,F(1,2),'c',event_id='first'),note(F(1,2),1,'c',event_id='middle'),note(1,F(3,2),'g',event_id='target'))
paths=routes.candidates(notes,{'target'},h,solo_fixtures.library(),(41,76))
authored=[r for r in paths if any(s.startswith('chord-path:') for s in r.sources)]
self.assertTrue(authored)
self.assertTrue(any([n.pitch.number() for n in r.notes]==[60,64,67] for r in authored))
self.assertTrue(all(r.notes[-1].pitch==notes[-1].pitch for r in paths))
self.assertTrue(all(h.evaluate(r.notes).valid for r in paths))
def test_solo_vocabulary_changes_do_not_change_accompaniment_inputs(self):
resources=vocabulary.load()
changed=copy.deepcopy(resources)
changed['melodic_vocabulary']['cells'].append({'id':'independent-test'})
for role in ('acoustic_accompaniment','electric_accompaniment','electric_melody','vocal_melody'):
self.assertEqual(production.dependencies(role,resources),production.dependencies(role,changed))
self.assertNotEqual(production.dependencies('acoustic_solo',resources),production.dependencies('acoustic_solo',changed))
def test_cross_bar_arrival_cell_remains_one_sounding_duration(self):
cell=next(c for c in vocabulary.rhythm_cells(120,'arrival') if c.id=='anticipated_sustain')
self.assertTrue(any(a<4<b for a,b in cell.pattern))
def test_shared_provisional_arrival_preserves_terminal_hold(self):
a=note(0,1,'c',event_id='a');b=note(1,2,'c',event_id='b');c=note(2,4,'e',event_id='c')
t=lambda id,start,end,operation,events:types.Transformation(id,'source','home',operation,F(start),F(end),events,(),preserves=())
draft=types.MelodicDraft('draft',F(),F(4),(a,b,c),(t('a',0,1,'introduce',('a',)),t('b',1,4,'continue',('b','c'))),(),(),(),None,'continue')
h=harmonic.Harmony(choices(('CM7',)))
revised,edits=gesture_rhythm.shared_arrival(draft,h)
self.assertEqual(len(revised.notes),2)
self.assertEqual(revised.notes[0].start,0)
self.assertEqual(revised.notes[-1],c)
self.assertEqual(edits[0].kind,'shared_arrival')
self.assertEqual(gesture_rhythm.revise(draft,h)[1][0].kind,'shared_arrival')
protected=dataclasses.replace(draft,protected=('a',))
self.assertEqual(gesture_rhythm.shared_arrival(protected,h)[0],protected)
if __name__=='__main__':unittest.main()
-169
View File
@@ -1,169 +0,0 @@
import dataclasses
import fractions
import pathlib
import unittest
import generator.harmony
import generator.model
import generator.rhythm
import generator.solo.planning
import generator.solo.analysis
import generator.solo.composer
import generator.solo.placement
import generator.solo.realization
import generator.solo.scoring
import generator.solo.types
import generator.solo.sequences
import generator.fretboard
import generator.scales.selection
import generator.scales.timeline
import tests.scale_fixtures
import tests.solo_fixtures
import tests.rhythm_fixtures
import tests.test_solo
def choices(root='C', fifths=0, end=8):
scale = tests.scale_fixtures.library().scale('major', 1, root)
span = generator.scales.timeline.ChordSpan(fractions.Fraction(), fractions.Fraction(end), 1, '1', fractions.Fraction(1), generator.model.key_from_fifths(fifths), generator.harmony.parse_symbol(root + 'M7'))
return tuple([generator.scales.selection.Choice(span, scale, 'fixture')])
def plan(intent='active'):
motif = generator.solo.planning.new_motif(tests.solo_fixtures.library(), fractions.Fraction(4))
passage = generator.solo.types.ActivitySpan(fractions.Fraction(), fractions.Fraction(8), intent, fractions.Fraction(1) if intent == 'active' else fractions.Fraction(1, 8) if intent == 'sustained_arrival' else fractions.Fraction(), fractions.Fraction(3, 4) if intent == 'active' else fractions.Fraction(1) if intent == 'sustained_arrival' else fractions.Fraction())
return generator.solo.types.PhrasePlan('fixture', passage.start, passage.end, motif, 'statement', 'thematic', None, (59, 75), passage.activity, fractions.Fraction(), (passage,))
def note(start, end, letter='g', alter=0, octave=4, **kwargs):
return generator.solo.types.Note(fractions.Fraction(start), fractions.Fraction(end), generator.solo.types.Pitch(letter, fractions.Fraction(alter), octave), **kwargs)
class ScoringTests(unittest.TestCase):
def test_silence_is_an_intent_not_a_register_advantage(self):
for root, fifths, pitch in (('C', 0, ('g', 0)), ('Db', -5, ('a', -1))):
harmony = choices(root, fifths)
passage = plan()
notes = tuple(note(i, i + 1, *pitch) for i in range(8))
self.assertFalse(generator.solo.scoring.feasible(passage, (), harmony, 8))
self.assertTrue(generator.solo.scoring.feasible(passage, notes, harmony, 8))
sounded = generator.solo.scoring.value(generator.solo.scoring.features(passage, notes, harmony))
silent = generator.solo.scoring.value(generator.solo.scoring.features(passage, (), harmony))
self.assertLess(sounded, silent)
self.assertEqual(generator.solo.scoring.features(passage, notes, harmony)['register'], 0)
def test_breath_sustain_and_activity_have_different_obligations(self):
harmony = choices()
self.assertTrue(generator.solo.scoring.feasible(plan('space'), (), harmony, 8))
self.assertFalse(generator.solo.scoring.feasible(plan('space'), (note(0, 8),), harmony, 8))
sustained = generator.solo.scoring.features(plan('sustained_arrival'), (note(0, 8),), harmony)
active = generator.solo.scoring.features(plan(), (note(0, 8),), harmony)
self.assertEqual(sustained['occupancy'], 0)
self.assertEqual(sustained['activity'], 0)
self.assertGreater(active['activity'], sustained['activity'])
self.assertTrue(generator.solo.scoring.feasible(plan(), (), harmony, 4))
def test_partition_and_ties_do_not_change_musical_evaluation(self):
whole = (note(0, 4), note(4, 8, 'a'))
split = (note(0, 2), note(2, 4, continuation=True), note(4, 6, 'a'), note(6, 8, 'a', continuation=True))
self.assertEqual(generator.solo.scoring.features(plan(), whole, choices()), generator.solo.scoring.features(plan(), split, choices()))
self.assertEqual(len(generator.solo.types.sounding_notes(split)), 2)
self.assertEqual(len(generator.solo.types.sounding_notes((note(0, 2), note(2, 4)))), 2)
def test_transposition_moves_corridor_and_notes_together(self):
original = (note(0, 2, 'c'), note(2, 4, 'e'), note(4, 8, 'g'))
moved = (note(0, 2, 'd'), note(2, 4, 'f', 1), note(4, 8, 'a'))
shifted = dataclasses.replace(plan(), register=(61, 77))
self.assertEqual(generator.solo.scoring.features(plan(), original, choices()), generator.solo.scoring.features(shifted, moved, choices('D', 2)))
def test_no_harmony_removes_activity_obligations(self):
harmony = tuple(dataclasses.replace(c, scale=None, span=dataclasses.replace(c.span, chord=None)) for c in choices())
self.assertTrue(generator.solo.scoring.feasible(plan(), (), harmony, 8))
self.assertEqual(generator.solo.scoring.value(generator.solo.scoring.features(plan(), (), harmony)), 0)
class DevelopmentTests(unittest.TestCase):
def test_connected_motion_can_cross_a_bar_and_arrive_on_a_sustain(self):
source = tests.test_solo.song(count=4, chords=('CM7',))
passage = dataclasses.replace(plan(), end=fractions.Fraction(16), treatment='contour_reference', passages=(generator.solo.types.ActivitySpan(0, 8, 'active', fractions.Fraction(2), fractions.Fraction(1)), generator.solo.types.ActivitySpan(8, 16, 'sustained_arrival', fractions.Fraction(1, 8), fractions.Fraction(1))), target=generator.solo.types.TargetEvent(fractions.Fraction(8), fractions.Fraction(8), frozenset({4}), 'CM7', 'fixture arrival'))
phrase = generator.solo.realization.compose(passage, source['staves'][0]['measures'], choices(end=16), tests.solo_fixtures.library(), generator.rhythm.load_library(pathlib.Path('data/rhythms')), fractions.Fraction(120))
notes = generator.solo.composer.phrase_notes(phrase)
self.assertGreaterEqual(sum(n.start < 8 for n in notes), 12)
# Continuity also includes a quarter-note arrival; the new policy does
# not force every successful crossing to be an eighth-note pair.
self.assertTrue(any(n.start < 4 < n.end for n in notes) or any(a.end == b.start == 4 for a, b in zip(notes, notes[1:])))
self.assertTrue(any(n.start == 8 and n.pitch.number() % 12 == 4 and n.end - n.start >= 4 for n in notes))
self.assertEqual(notes[-1].end, 16)
def test_exact_quote_cannot_silently_fragment_at_no_harmony(self):
source = tests.test_solo.song(count=2, chords=('CM7',))
passage = dataclasses.replace(plan(), treatment='exact_quote')
harmony = tuple(dataclasses.replace(c, scale=None, span=dataclasses.replace(c.span, chord=None)) for c in choices())
with self.assertRaises(generator.solo.realization.SearchExhausted):
generator.solo.realization.compose(passage, source['staves'][0]['measures'], harmony, tests.solo_fixtures.library(), tests.rhythm_fixtures.library(), fractions.Fraction(120))
def test_dense_transformed_motif_can_fragment_at_no_harmony(self):
harmony = choices()
active = dataclasses.replace(harmony[0], span=dataclasses.replace(harmony[0].span, end=fractions.Fraction(3)))
silence = dataclasses.replace(harmony[0], scale=None, span=dataclasses.replace(harmony[0].span, start=fractions.Fraction(3), chord=None))
passage = dataclasses.replace(plan(), treatment='contour_reference')
spans = tuple((fractions.Fraction(i, 2), fractions.Fraction(i + 1, 2)) for i in range(8))
cell = generator.solo.realization.Cell(fractions.Fraction(), fractions.Fraction(4), (('eighths', spans),), passage.motif)
candidates = generator.solo.realization.candidates(cell, passage, (active, silence), tests.solo_fixtures.library())
self.assertTrue(candidates)
self.assertTrue(all(len(c.notes) == 6 and all(n.end <= 3 for n in c.notes) for c in candidates))
def test_dense_rhythms_and_chained_paths_retain_authored_timing(self):
# The real library's fully sounding eighths must survive retrieval.
rhythms = generator.rhythm.load_library(pathlib.Path('data/rhythms'))
patterns = generator.solo.realization.rhythm_patterns(rhythms, '4/4', fractions.Fraction(4), fractions.Fraction(2))
self.assertTrue(any(len(spans) == 8 and all(b - a == fractions.Fraction(1, 2) for a, b in spans) for _, spans in patterns))
sequences = tests.solo_fixtures.library()
lookup = {s.id: s for values in sequences.paths.values() for s in values}
paths = sequences.retrieve(8, 5, 1)
self.assertTrue(paths)
for path in paths:
self.assertEqual(len(path.offsets()), 8)
self.assertEqual(path.steps, tuple(step for source in path.sources for step in lookup[source].steps))
self.assertGreater(len({generator.solo.sequences.family(p.steps) for p in paths}), 2)
def test_retrieval_uses_paths_beyond_the_old_global_prefix(self):
library = tests.solo_fixtures.library()
original = {s.id for s in library.connecting(4)[:16]}
chosen = library.retrieve(4, 6, 1)
self.assertTrue(any(set(p.sources) - original for p in chosen))
self.assertTrue(any(p.offsets()[-1] == 6 for p in chosen))
self.assertEqual(chosen, library.retrieve(4, 6, 1))
def test_transformation_contracts_protect_different_features(self):
motif = generator.solo.types.Motif('source', (note(0, 1, 'c'), note(1, 2, 'd'), note(2, 3, 'e'), note(3, 4, 'c')), fractions.Fraction(4), ())
adapted = (note(0, 1, 'e'), note(1, 2, 'f'), note(2, 3, 'g'), note(3, 4, 'e'))
unrelated = (note(0, 1, 'g'), note(1, 2, 'f'), note(2, 3, 'e'), note(3, 4, 'g'))
self.assertEqual(generator.solo.scoring.motif_fidelity(unrelated, motif, 'rhythmic_reference'), 0)
self.assertLess(generator.solo.scoring.motif_fidelity(adapted, motif, 'contour_reference'), generator.solo.scoring.motif_fidelity(unrelated, motif, 'contour_reference'))
changed_ending = (*adapted[:3], note(3, 4, 'b'))
self.assertEqual(generator.solo.scoring.motif_fidelity(adapted, motif, 'opening_reference'), generator.solo.scoring.motif_fidelity(changed_ending, motif, 'opening_reference'))
def test_contextual_invention_and_identity_ignore_titles(self):
source = tests.test_solo.song(count=4, chords=('CM7',))
analysis = generator.solo.analysis.analyze(source)
seed = generator.solo.analysis.identity(analysis, source['staves'][0]['measures'], 'electric')
source['metadata']['title'] = 'Unrelated title'
self.assertEqual(seed, generator.solo.analysis.identity(generator.solo.analysis.analyze(source), source['staves'][0]['measures'], 'electric'))
up = generator.solo.planning.new_motif(tests.solo_fixtures.library(), fractions.Fraction(4), choices=choices(), seed=seed)
changed = generator.solo.planning.new_motif(tests.solo_fixtures.library(), fractions.Fraction(4), choices=choices('D', 2), seed=seed, variant=1)
self.assertNotEqual(up.contour(), changed.contour())
def test_continuation_keeps_fret_position_and_one_attack(self):
previous = note(0, 4, position=generator.fretboard.TabPosition(2, 8, 67))
following = note(4, 6, continuation=True)
placed = generator.solo.placement.place((following,), fractions.Fraction(120), previous)
self.assertEqual(placed[0], 0)
self.assertEqual(placed[1][0].position, previous.position)
joined = generator.solo.types.sounding_notes((previous, *placed[1]))
self.assertEqual([(n.start, n.end) for n in joined], [(0, 6)])
def test_same_ending_does_not_merge_different_gestures(self):
passage = plan()
left = (note(0, 2, 'c'), note(2, 4, 'e'), note(4, 8, 'g'))
right = (note(0, 1, 'a'), note(1, 4, 'c'), note(4, 8, 'g'))
self.assertNotEqual(generator.solo.types.ContinuationState(passage.id, left, last=left[-1]), generator.solo.types.ContinuationState(passage.id, right, last=right[-1]))
def test_lookahead_can_choose_a_lower_ranked_statement(self):
first = dataclasses.replace(plan(), end=fractions.Fraction(4), ending='connected')
second = dataclasses.replace(plan(), id='answer', start=fractions.Fraction(4), relationship='answer')
high = generator.solo.types.Phrase(first, (generator.solo.types.Decision(0, 4, 'fixture', 'invented', (note(0, 4, 'g', octave=5),)),), 0)
low = generator.solo.types.Phrase(first, (generator.solo.types.Decision(0, 4, 'fixture', 'invented', (note(0, 4, 'c'),)),), 0.1)
answer = generator.solo.types.Phrase(second, (generator.solo.types.Decision(4, 8, 'fixture', 'answer', (note(4, 8, 'd'),)),), 0)
selected = generator.solo.composer.choose_phrases((first, second), lambda p, previous: [high, low] if p.id == first.id else [answer])
self.assertEqual(selected[0], low)
def test_committed_statement_still_affects_final_answer_selection(self):
first = dataclasses.replace(plan(), end=fractions.Fraction(4), ending='connected')
second = dataclasses.replace(plan(), id='answer', start=fractions.Fraction(4), relationship='answer')
statement = generator.solo.types.Phrase(first, (generator.solo.types.Decision(0, 4, 'fixture', 'invented', (note(0, 4, 'c'),)),), 0)
far = generator.solo.types.Phrase(second, (generator.solo.types.Decision(4, 8, 'fixture', 'answer', (note(4, 8, 'g', octave=5),)),), 0)
near = generator.solo.types.Phrase(second, (generator.solo.types.Decision(4, 8, 'fixture', 'answer', (note(4, 8, 'd'),)),), 0.1)
selected = generator.solo.composer.choose_phrases((first, second), lambda p, previous: [statement] if p.id == first.id else [far, near])
self.assertEqual(selected[-1], near)
-242
View File
@@ -1,242 +0,0 @@
"""Construction contracts, not claims about listener preference."""
import dataclasses
import fractions
import unittest
import generator.rhythm
import generator.solo.drafts
import generator.solo.harmonic
import generator.solo.realization
import generator.solo.scoring
import generator.solo.types
import tests.rhythm_fixtures
import tests.solo_fixtures
import tests.test_solo
import tests.test_solo_development
import tests.test_solo_passages
F = fractions.Fraction
note = tests.test_solo_passages.note
def illustration():
"""User-specified four-bar example; additional fixtures await listening review."""
events = ((0, 1, 'd', 0, 4), (2, 3, 'f', 0, 4), (3, F(7, 2), 'g', 0, 4), (F(7, 2), 4, 'a', 0, 4),
(4, 5, 'b', -1, 4), (5, 6, 'g', 0, 4), (6, F(13, 2), 'a', 0, 4), (F(13, 2), 7, 'g', 0, 4), (7, 8, 'e', 0, 4),
(8, 9, 'f', 0, 4), (9, F(19, 2), 'g', 0, 4), (F(19, 2), 10, 'a', 0, 4), (10, 11, 'c', 0, 5), (11, 13, 'a', 0, 4),
(13, F(27, 2), 'g', 0, 4), (F(27, 2), 14, 'f', 0, 4), (14, 16, 'd', 0, 4))
notes = tuple(dataclasses.replace(note(a, b, pitch, alter, octave), event_id=f'example_{i}') for i, (a, b, pitch, alter, octave) in enumerate(events))
choices = tests.test_solo_passages.choices(('Gm7', 'C7', 'FM7', 'BbM7'))
harmony = generator.solo.harmonic.Harmony(choices)
home = generator.solo.realization.make_identity('home', 'user_example', notes[1:5], F(2), F(5), 'statement', harmony)
groups = []
for identifier, operation, selected in (('home', 'statement', notes[1:5]), ('extension', 'extend', notes[9:13])):
start = selected[0].start
groups.append(generator.solo.types.Transformation(identifier, 'user_example' if identifier == 'home' else 'home', 'home', operation, start, selected[-1].end, tuple(n.event_id for n in selected), tuple((n.start - start, n.end - start) for n in selected), (1, 1, 1)))
anchor = generator.solo.types.Anchor('terminal', F(14), F(14), notes[-1].pitch, frozenset({2}), (55, 76), F(2), 'terminal', 'BbM7', hold_until=F(16))
draft = generator.solo.types.MelodicDraft('illustration', F(), F(16), notes, tuple(groups), (notes[-1].event_id,), (), (anchor,), home, 'statement', (F(5), F(8)))
return draft, choices
class DraftRepresentationTests(unittest.TestCase):
def test_user_phrase_is_valid_and_contains_seventeen_attacks(self):
draft, choices = illustration()
harmony = generator.solo.harmonic.Harmony(choices)
self.assertEqual(len(draft.notes), 17)
self.assertEqual(draft.rests(), ((1, 2),))
self.assertTrue(harmony.evaluate(draft.notes, complete=True).valid)
self.assertTrue(generator.solo.drafts.contract_valid(draft, draft.notes)[0])
self.assertTrue(generator.solo.harmonic.anchors_valid(draft.anchors, draft.notes, 16, complete=True, closure='closed')[0])
self.assertTrue(any(t.start < 4 < t.end for t in draft.transformations))
def test_partition_preserves_draft_relationships_and_harmonic_roles(self):
draft, choices = illustration()
whole = draft.notes
split = []
for n in whole:
points = sorted({n.start, n.end} | {F(t) for t in range(1, 16) if n.start < t < n.end})
split.extend(dataclasses.replace(n, start=a, end=b, continuation=i > 0) for i, (a, b) in enumerate(zip(points, points[1:])))
self.assertEqual(generator.solo.types.sounding_notes(split), whole)
self.assertEqual(generator.solo.drafts.relationship_features(draft, whole), generator.solo.drafts.relationship_features(draft, split))
self.assertTrue(generator.solo.drafts.contract_valid(draft, split)[0])
harmony = generator.solo.harmonic.Harmony(choices)
self.assertEqual(harmony.describe(whole), harmony.describe(split))
def test_disconnected_alternative_breaks_declared_rise_at_same_density(self):
draft, _ = illustration()
changed = list(draft.notes)
changed[2] = dataclasses.replace(changed[2], pitch=note(0, 1, 'e').pitch)
self.assertEqual(len(changed), len(draft.notes))
self.assertEqual(sum(n.end - n.start for n in changed), sum(n.end - n.start for n in draft.notes))
valid, reason = generator.solo.drafts.contract_valid(draft, changed)
self.assertFalse(valid)
self.assertIn('contour', reason)
self.assertGreater(generator.solo.drafts.relationship_features(draft, changed)['direction'], 0)
def test_catalog_keeps_cross_bar_sustain_as_one_event(self):
event = generator.rhythm.Event
bar = generator.rhythm.Bar
snippet = generator.rhythm.Snippet('ties', '4/4', (bar((event(F(), F(3), False, 'half'), event(F(3), F(1), True, 'quarter')), F(4)), bar((event(F(), F(1), True, 'quarter', continuation=True), event(F(1), F(3), True, 'half')), F(4))))
catalog = generator.solo.drafts.catalog(generator.rhythm.Library((snippet,)), '4/4')
self.assertEqual(catalog[0][1], ((3, 5), (5, 8)))
def test_extension_reopens_only_an_unprotected_provisional_tail(self):
slot = generator.solo.drafts.Slot('tail', F(2), F(4), 0, 2)
result, cursor = generator.solo.drafts.reopen((slot,), F(4))
self.assertEqual((result[-1].end, cursor), (F(7, 2), F(7, 2)))
for protected in (dataclasses.replace(slot, protected=True), dataclasses.replace(slot, forced=note(0, 1, 'e').pitch)):
self.assertEqual(generator.solo.drafts.reopen((protected,), F(4)), ((protected,), F(4)))
def test_existing_terminal_contract_is_not_retimed(self):
draft, choices = illustration()
plan = dataclasses.replace(tests.test_solo_development.plan(), end=F(16), target=generator.solo.types.TargetEvent(F(14), F(14), frozenset({2}), 'BbM7', 'explicit ending'))
self.assertEqual(generator.solo.drafts.ending_options(draft.anchors, plan, choices), (draft.anchors,))
def test_ending_alternatives_vary_timing_without_changing_destination(self):
draft, choices = illustration()
plan = dataclasses.replace(tests.test_solo_development.plan(), end=F(16))
endings = generator.solo.drafts.ending_options(draft.anchors, plan, choices)
self.assertEqual({a[-1].duration for a in endings}, {F(1, 2), F(1), F(2), F(3)})
self.assertTrue(all(a[-1].pitch == draft.anchors[-1].pitch and a[-1].earliest + a[-1].duration == a[-1].hold_until for a in endings))
self.assertEqual({16 - a[-1].hold_until for a in endings}, {F(), F(1, 2), F(1)})
def test_revision_cannot_change_terminal_hold_or_identifying_rhythm(self):
draft, _ = illustration()
for index in (2, len(draft.notes) - 1):
notes = list(draft.notes)
notes[index] = dataclasses.replace(notes[index], end=notes[index].end - F(1, 4))
self.assertFalse(generator.solo.drafts.contract_valid(draft, notes)[0])
def test_unrelated_single_note_is_not_perfect_development(self):
draft, _ = illustration()
event = draft.notes[0]
transform = generator.solo.types.Transformation('short', 'home', 'home', 'extend', event.start, event.end, (event.event_id,), ((F(), F(1)),))
candidate = dataclasses.replace(draft, notes=(event,), transformations=(transform,), structural=(), anchors=(), joins=())
self.assertEqual(generator.solo.drafts.relationship_features(candidate, candidate.notes)['development'], 1)
def test_diminution_and_broadening_keep_exact_timing_and_event_order(self):
pattern = ((F(), F(1)), (F(3, 2), F(2)), (F(2), F(5, 2)))
transformed, length = generator.solo.drafts.develop_timing(pattern, F(3), F(1, 2), F(3, 4), True)
self.assertEqual(len(transformed), len(pattern))
self.assertTrue(all(b - a >= F(1, 2) for a, b in transformed))
self.assertTrue(all(b <= c for (_, b), (c, _) in zip(transformed, transformed[1:])))
self.assertLess(length, 3)
def test_occupancy_goal_cannot_create_arbitrary_tuplet_durations(self):
pattern = ((F(), F(1, 2)), (F(1), F(3, 2)), (F(2), F(5, 2)))
for occupancy in (F(3, 4), F(9, 10)):
for unit in (F(1, 2), F(1), F(2)):
transformed, length = generator.solo.drafts.develop_timing(pattern, F(3), unit, occupancy, True)
self.assertTrue(all((a * 4).denominator == (b * 4).denominator == 1 for a, b in transformed))
self.assertEqual((length * 4).denominator, 1)
def test_pending_treatment_constrains_the_new_draft_pitch_search(self):
choices = tests.test_solo_passages.choices(('Gm7', 'C7'))
harmony = generator.solo.harmonic.Harmony(choices)
held = note(2, F(9, 2), 'f')
treatment = generator.solo.types.HarmonicTreatment('pending', 'suspension', 65, F(4), F(9, 2), 64, F(5))
incoming = generator.solo.types.ContinuationState('p', (held,), treatments=(treatment,), last=held)
plan = dataclasses.replace(tests.test_solo_development.plan(), start=F(9, 2), end=F(6))
slot = generator.solo.drafts.Slot('resolve', F(9, 2), F(5), 0, 0)
group = generator.solo.drafts.Group('g', 'continue', F(9, 2), F(6), 'prepared treatment')
rhythm = generator.solo.drafts.RhythmDraft('r', (slot,), (group,), (), plan.motif, 'continue')
paths, error = generator.solo.realization.draft_pitch_paths(rhythm, plan, choices, harmony, tests.solo_fixtures.library(), incoming, generator.solo.realization.SearchContext())
self.assertTrue(paths, error)
self.assertTrue(all(notes[0].pitch.number() == 64 for notes, _, _ in paths))
self.assertTrue(all(harmony.evaluate((held, *notes), contracts, complete=True).valid for notes, _, contracts in paths))
def test_join_search_can_rewrite_both_sides_and_preserve_outside_events(self):
pitches = (('c', 4), ('g', 4), ('e', 4), ('c', 5), ('c', 4), ('f', 4), ('g', 4), ('e', 4))
notes = tuple(dataclasses.replace(note(i, i + 1, letter, octave=octave), event_id=f'n{i}') for i, (letter, octave) in enumerate(pitches))
plan = tests.test_solo_development.plan()
harmony = generator.solo.harmonic.Harmony(tests.test_solo_development.choices())
transform = generator.solo.types.Transformation('connecting_run', '', '', 'close', F(), F(8), tuple(n.event_id for n in notes), tuple((n.start, n.end) for n in notes), preserves=())
draft = generator.solo.types.MelodicDraft('join_fixture', F(), F(8), notes, (transform,), ('n0', 'n7'), (), (), None, 'continue', (F(4),))
incoming = generator.solo.types.ContinuationState('')
original = generator.solo.realization.checked_phrase(plan, draft, notes, (), incoming, harmony, F(120))
self.assertIsNotNone(original)
revised = generator.solo.realization.improve_window(original, incoming, harmony, tests.solo_fixtures.library(), F(120), F(2), F(6), 'join', generator.solo.realization.SearchContext())
actual = generator.solo.realization.emitted_notes(revised)
self.assertLess(revised.score, original.score)
self.assertTrue(any(a.pitch != b.pitch and a.start < 4 for a, b in zip(notes, actual)))
self.assertTrue(any(a.pitch != b.pitch and a.start >= 4 for a, b in zip(notes, actual)))
self.assertEqual((actual[0].pitch, actual[-1].pitch), (notes[0].pitch, notes[-1].pitch))
self.assertTrue(harmony.evaluate(actual, complete=True).valid)
def test_passage_boundary_does_not_count_a_held_note_as_a_new_attack(self):
choices = tests.test_solo_development.choices()
harmony = generator.solo.harmonic.Harmony(choices)
placement = generator.solo.placement.place((note(0, 4, 'g'),), F(120))[1][0]
incoming = generator.solo.types.ContinuationState('old', (placement,), last=placement)
held = dataclasses.replace(note(4, 8, 'g'), continuation=True, event_id='hold')
plan = dataclasses.replace(tests.test_solo_development.plan(), start=F(4), passage_id='new', passages=(generator.solo.types.ActivitySpan(F(4), F(8), 'active', F(1), F(3, 4)),))
transform = generator.solo.types.Transformation('hold', '', '', 'close', F(4), F(8), ('hold',), ((F(), F(4)),), preserves=())
draft = generator.solo.types.MelodicDraft('hold', F(4), F(8), (held,), (transform,), (), (), (), None, 'continue')
phrase = generator.solo.realization.checked_phrase(plan, draft, (held,), (), incoming, harmony, F(120))
self.assertIsNotNone(phrase)
features = generator.solo.scoring.features(plan, (dataclasses.replace(placement, end=F(8)),), choices)
self.assertEqual(phrase.score, generator.solo.scoring.value(features))
def test_new_passage_carries_pending_but_not_completed_suspensions(self):
treatment = generator.solo.types.HarmonicTreatment('prepared', 'suspension', 65, F(4), F(9, 2), 64, F(5))
held, resolution = note(2, F(9, 2), 'f'), note(F(9, 2), 5, 'e')
plan = dataclasses.replace(tests.test_solo_development.plan(), start=F(5), passage_id='next')
incoming = generator.solo.types.ContinuationState('old', (held,), treatments=(treatment,), last=held)
self.assertEqual(generator.solo.realization.inherited_treatments(plan, incoming), (treatment,))
resolved = dataclasses.replace(incoming, notes=(held, resolution), last=resolution)
self.assertEqual(generator.solo.realization.inherited_treatments(plan, resolved), ())
self.assertEqual(generator.solo.realization.inherited_treatments(dataclasses.replace(plan, passage_id='old'), resolved), (treatment,))
class DraftCompositionTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.solo = tests.test_solo.compose(tests.test_solo.song(count=8, chords=('Gm7', 'C7', 'FM7', 'BbM7', 'Em7b5', 'A7', 'Dm7', 'Dm7')))
def test_production_candidates_contain_complete_playable_drafts(self):
harmony = generator.solo.harmonic.Harmony(self.solo.choices)
previous = None
for phrase in self.solo.phrases:
draft = phrase.draft
self.assertIsNotNone(draft)
self.assertEqual((draft.start, draft.end), (phrase.plan.start, phrase.plan.end))
prefix = (dataclasses.replace(previous, position=None),) if previous else ()
self.assertTrue(harmony.evaluate((*prefix, *draft.notes), phrase.state.treatments, until=draft.end, enclosures=draft.enclosures, expressions=draft.expressions).valid)
self.assertTrue(generator.solo.placement.place(draft.notes, self.solo.tempo, previous))
self.assertTrue(draft.transformations)
previous = phrase.state.last
def test_source_family_survives_phrase_variations(self):
self.assertEqual(len({p.state.home.id for p in self.solo.phrases}),1)
for phrase in self.solo.phrases:
self.assertTrue(all(t.home == phrase.state.home.id for t in phrase.draft.transformations))
self.assertTrue(phrase.state.home.chord_degrees)
self.assertTrue(phrase.draft.source_links)
def test_groups_cross_barlines_without_reference_timing_reset(self):
# Exercise an explicitly spanning construction; do not require a changed
# search objective to pick the same old winner merely to test timing.
plan = self.solo.phrases[0].plan
pattern = ((F(), F(1)), (F(1), F(3, 2)), (F(3, 2), F(2)), (F(2), F(3)))
constructed = generator.solo.drafts.construct(plan, plan.motif, 'statement', ('spanning', pattern, F(3)), (), 0, self.solo.choices)
self.assertIsNotNone(constructed)
self.assertTrue(any(g.start < 4 < g.end for g in constructed.groups))
self.assertTrue(any(s.start == F(4) and s.end == F(9, 2) for s in constructed.slots))
for phrase in self.solo.phrases:
by_id = {n.event_id: n for n in phrase.draft.notes}
for transform in phrase.draft.transformations:
self.assertEqual(tuple((by_id[e].start - transform.start, by_id[e].end - transform.start) for e in transform.events), transform.rhythm)
def test_new_passage_candidates_explicitly_recall_or_contrast(self):
prior = self.solo.phrases[0].state
plan = dataclasses.replace(self.solo.phrases[0].plan, id='new', passage_id='new_passage')
rhythms = generator.solo.drafts.rhythms(plan, prior, tests.test_solo.song(count=8)['staves'][0]['measures'], self.solo.choices, tests.rhythm_fixtures.library())
self.assertTrue(any(r.reference.id == prior.home.id and r.relationship in ('continue', 'answer', 'recall') for r in rhythms))
self.assertTrue(all(r.relationship != 'statement' for r in rhythms))
def test_cell_boundaries_are_not_phrase_rhythm_inputs(self):
prior = generator.solo.types.ContinuationState('')
plan = self.solo.phrases[0].plan
measures = tests.test_solo.song(count=8)['staves'][0]['measures']
first = generator.solo.drafts.rhythms(plan, prior, measures, self.solo.choices, tests.rhythm_fixtures.library())
second = generator.solo.drafts.rhythms(dataclasses.replace(plan, gestures=(generator.solo.types.Gesture('arbitrary_cell', F(), F(3), 'continue', 'contour_reference'),)), prior, measures, self.solo.choices, tests.rhythm_fixtures.library())
self.assertEqual(first, second)
def test_full_realization_revalidates_each_accepted_revision(self):
generator.solo.composer.validate(self.solo)
for phrase in self.solo.phrases:
notes = generator.solo.realization.emitted_notes(phrase)
self.assertTrue(generator.solo.drafts.contract_valid(phrase.draft, notes)[0])
self.assertTrue(all(1 <= n.position.fret <= 12 for n in notes))
for edit in phrase.edits:
self.assertLessEqual(edit.end - edit.start, 8 if edit.kind in ('replace_idea', 'complete_route') else 4)
def test_join_comparison_reuses_identical_upstream_draft_pool(self):
plan = self.solo.phrases[0].plan
measures = tests.test_solo.song(count=8)['staves'][0]['measures']
context = generator.solo.realization.SearchContext(revise=False)
arguments = (plan, measures, self.solo.choices, tests.solo_fixtures.library(), tests.rhythm_fixtures.library(), self.solo.tempo)
generator.solo.realization.alternatives(*arguments, context=context)
fingerprints = dict(context.diagnostics['draft_pool_fingerprints'])
constructed = context.diagnostics['generated_candidates']
context.revise = True
generator.solo.realization.alternatives(*arguments, context=context)
self.assertEqual(context.diagnostics['generated_candidates'], constructed)
self.assertEqual(context.diagnostics['draft_pool_fingerprints'], fingerprints)
self.assertEqual(context.diagnostics['reused_draft_pools'], 1)
-258
View File
@@ -1,258 +0,0 @@
import dataclasses
from fractions import Fraction as F
import unittest
from generator.solo import types, harmonic, enclosures, phrasing, composer, placement, notation, report
import generator.harmony
import generator.model
import tests.scale_fixtures
import tests.solo_fixtures
from tests.test_solo_passages import choices
from tests.test_solo import song
def note(start,end,letter,alter=0,octave=4,identity=''):
return types.Note(F(start),F(end),types.Pitch(letter,F(alter),octave),event_id=identity or f'{start}:{letter}')
def fixture(symbol='C7',target=None,scale=None):
target=target or note(4,8,'e',identity='target')
harmony=harmonic.Harmony(choices((symbol,symbol),scale=scale))
pair=enclosures.approach(target,harmony)
contract=types.EnclosureContract('enclosure',*pair,target,harmony.at(target.start).chord,'terminal',types.SustainedTail(target.start,target.end,target.pitch.number()))
return harmony,(*pair,target),contract
class EnclosureContracts(unittest.TestCase):
def test_existing_upper_can_continue_without_a_new_attack(self):
h,notes,c=fixture('Dm',note(4,8,'d'))
upper=dataclasses.replace(notes[0],start=F(5,2))
contract=dataclasses.replace(c,upper=upper,approach_start=F(3))
self.assertTrue(h.evaluate((upper,*notes[1:]),enclosures=(contract,)).valid)
# Joining a tie must not hide an exposed long non-chord upper neighbor.
long_upper=dataclasses.replace(upper,start=F(1))
invalid=dataclasses.replace(contract,upper=long_upper)
self.assertFalse(h.evaluate((long_upper,*notes[1:]),enclosures=(invalid,)).valid)
split=(dataclasses.replace(upper,end=F(3)),dataclasses.replace(upper,start=F(3),continuation=True,event_id='tie'),*notes[1:])
self.assertEqual(h.evaluate(split,enclosures=(contract,)),h.evaluate((upper,*notes[1:]),enclosures=(contract,)))
def test_worked_examples_and_actual_collection_membership(self):
hm=tests.scale_fixtures.library().scale('harmonic_minor',1,'D')
for symbol,target,scale,expected in (
('C7',note(4,8,'e'),None,('F4','D#4','E4')),
('FM7',note(4,8,'a',octave=3),None,('Bb3','G#3','A3')),
('A7',note(4,8,'c',1),hm,('D4','C4','C#4')),
('Dm',note(4,8,'d'),None,('E4','C#4','D4'))):
with self.subTest(symbol=symbol):
harmony,notes,contract=fixture(symbol,target,scale)
self.assertEqual(tuple(n.pitch.label() for n in notes),expected)
self.assertFalse(harmony.evaluate(notes).valid)
self.assertTrue(harmony.evaluate(notes,enclosures=(contract,),complete=True).valid)
self.assertEqual(harmony.evaluate(notes,enclosures=(contract,)).exposure,0)
self.assertEqual(sum(r['scale_exception'] for r in harmony.describe(notes,enclosures=(contract,))),1)
def test_in_map_enclosure_does_not_need_exception(self):
h,notes,c=fixture('FM7',note(4,8,'f'))
self.assertEqual([n.pitch.label() for n in notes],['G4','E4','F4'])
self.assertTrue(h.evaluate(notes).valid)
self.assertTrue(h.evaluate(notes,enclosures=(c,)).valid)
self.assertFalse(any(r['scale_exception'] for r in h.describe(notes,enclosures=(c,))))
def test_missing_changed_target_wrong_octave_and_repeated_attack_fail(self):
h,notes,c=fixture()
for actual in (notes[:-1],(*notes[:-1],dataclasses.replace(notes[-1],pitch=types.Pitch('e',F(),3))),
(*notes[:-1],dataclasses.replace(notes[-1],start=F(9,2))),
(*notes[:-1],dataclasses.replace(notes[-1],end=F(6)),dataclasses.replace(notes[-1],start=F(6),event_id='reattack'))):
self.assertFalse(h.evaluate(actual,enclosures=(c,)).valid)
self.assertFalse(h.evaluate(notes,enclosures=(c,),until=F(7),complete=True).valid)
def test_roles_alone_never_authorize_chromatic_pitches(self):
h,notes,c=fixture()
for role in ('enclosure_lower','chromatic_approach','quote'):
actual=tuple(dataclasses.replace(n,role=role) for n in notes)
self.assertFalse(h.evaluate(actual).valid)
def test_complete_sounding_target_is_partition_invariant(self):
h,notes,c=fixture()
split=(*notes[:-1],dataclasses.replace(notes[-1],end=F(6)),dataclasses.replace(notes[-1],start=F(6),continuation=True,event_id='tie'))
self.assertEqual(h.evaluate(split,enclosures=(c,)),h.evaluate(notes,enclosures=(c,)))
self.assertEqual(h.describe(split,enclosures=(c,)),h.describe(notes,enclosures=(c,)))
self.assertEqual(types.sounding_notes(split),notes)
def test_orphan_long_approach_order_and_duplicate_contracts_fail(self):
h,notes,c=fixture()
variants=(dataclasses.replace(c,upper=dataclasses.replace(c.upper,start=F(2))),
dataclasses.replace(c,lower=dataclasses.replace(c.lower,pitch=types.Pitch('f',F(1),4))),
dataclasses.replace(c,target=dataclasses.replace(c.target,event_id='missing')),
dataclasses.replace(c,chord='Dm'))
for changed in variants:
actual=(changed.upper,changed.lower,changed.target)
if changed.target.event_id=='missing':actual=notes
self.assertFalse(h.evaluate(actual,enclosures=(changed,)).valid)
self.assertFalse(h.evaluate(notes,enclosures=(c,c)).valid)
def test_boundary_crossing_uses_arrival_chord_and_each_actual_scale(self):
hm=tests.scale_fixtures.library().scale('harmonic_minor',1,'D')
cs=choices(('A7','Dm'))
cs=(dataclasses.replace(cs[0],scale=hm),cs[1])
h=harmonic.Harmony(cs);target=note(4,8,'d')
upper,lower=enclosures.approach(target,h)
c=types.EnclosureContract('boundary',upper,lower,target,'Dm','terminal',types.SustainedTail(F(4),F(8),62))
self.assertTrue(h.evaluate((upper,lower,target),enclosures=(c,)).valid)
self.assertTrue(h.evaluate((upper,lower,target)).valid) # C# still belongs to A7's map
self.assertFalse(h.evaluate((upper,lower,target),enclosures=(dataclasses.replace(c,chord='A7'),)).valid)
missing=harmonic.Harmony((dataclasses.replace(cs[0],scale=None),cs[1]))
self.assertFalse(missing.evaluate((upper,lower,target),enclosures=(c,)).valid)
def test_held_target_must_remain_chordal_after_boundary(self):
h,notes,c=fixture('Gm7',note(2,6,'f'))
h=harmonic.Harmony(choices(('Gm7','C7')))
self.assertFalse(h.evaluate(notes,enclosures=(c,)).valid)
def test_transposition_preserves_contract_validation(self):
h,notes,c=fixture()
for octave in (-1,0,1):
moved=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,octave=n.pitch.octave+octave)) for n in notes)
contract=dataclasses.replace(c,upper=moved[0],lower=moved[1],target=moved[2],protected_tail=dataclasses.replace(c.protected_tail,pitch=moved[2].pitch.number()))
self.assertTrue(h.evaluate(moved,enclosures=(contract,)).valid)
def test_spelling_and_notation_ties_tab_and_playback_survive(self):
import generator.playback
import generator.musicxml_writer
h,notes,c=fixture(target=note(3,7,'e',identity='target'))
placed=placement.place(notes,F(120))[1]
doc=song(count=2,chords=('C7',))
measures=notation.measures(doc['staves'][0]['measures'],placed,'enclosure_test')
notation.replace_region(doc,0,measures)
generator.model.validate_document(doc)
import generator.fretboard
generator.fretboard.validate_document(doc)
pitches=[p for m in measures for v in m['voices'] for n in v['notes'] for p in n['pitches']]
self.assertTrue(any(p.get('tie_from') for p in pitches))
self.assertTrue(any(p['letter']=='d' and generator.model.fraction(p['alter'])==1 for p in pitches))
self.assertTrue(all(1<=p['tablature']['fret']<=12 for p in pitches))
# The guitar's written octave must be removed exactly once at playback.
doc['staves'][0]['measures'][0]['transpose']={'diatonic':-7,'chromatic':-12,'octave_change':0}
doc['staves'][0]['measures'][1]['transpose']={'diatonic':-7,'chromatic':-12,'octave_change':0}
timeline=generator.playback.compile_timeline(doc)
self.assertEqual([generator.model.fraction(e['pitch']) for e in timeline['events']],[65,63,64])
self.assertEqual([generator.model.fraction(e['duration']) for e in timeline['events']],[F(1,2),F(1,2),F(4)])
tree=generator.musicxml_writer.musicxml_tree(doc)
self.assertTrue(any(e.text=='1' for e in tree.findall('.//pitch/alter')))
class DestinationPriorities(unittest.TestCase):
def test_actual_symbols_define_members_without_slash_or_omitted_degrees(self):
cases={'C7':{4,10},'C':{4},'Cm':{3},'C6':{4,9},'Csus4':{5},'Csus2':{2},'C7b5':{4,6,10},'C7no3':{10},'C/D':{4},'C9':{4,10}}
for symbol,expected in cases.items():
with self.subTest(symbol=symbol):self.assertEqual(phrasing.defining_members(symbol),expected)
def test_guide_tones_compete_before_nearest_register_cutoff(self):
from generator.solo.themes import pitches
h=harmonic.Harmony(choices(('C7','FM7')))
notes=pitches(h,note(2,4,'c'),60,members=True,limit=4)
self.assertEqual({p.number()%12 for p in notes},{0,4,7,10})
cost=lambda p:phrasing.destination_cost(p,F(2),F(4),h,goal=60)
self.assertLess(cost(types.Pitch('e',F(),4)),cost(types.Pitch('c',F(),4)))
self.assertLess(cost(types.Pitch('b',F(-1),3)),cost(types.Pitch('g',F(),3)))
def solo_fixture():
h=harmonic.Harmony(choices(('C7','C7')))
notes=(note(0,1,'g'),note(1,2,'e'),note(2,3,'g'),note(3,4,'f'),note(4,8,'e',identity='target'))
notes=placement.place(notes,F(120))[1]
anchor=types.Anchor('close',F(4),F(4),notes[-1].pitch,frozenset({4}),(41,76),F(4),'terminal','C7',hold_until=F(8))
motif=types.Motif('theme',notes[:3],F(3),())
plan=types.PhrasePlan('test',F(),F(8),motif,'develop','thematic_recomposition',None,(41,76),F(1),F(),ending='sustain',closure='closed')
transform=types.Transformation('develop','','theme','develop',F(),F(8),tuple(n.event_id for n in notes),tuple((n.start,n.end) for n in notes),preserves=())
draft=types.MelodicDraft('draft',F(),F(8),notes,(transform,),(),(),(anchor,),None,'develop',sustained_tails=(types.SustainedTail(F(4),F(8),64),))
decision=types.Decision(F(),F(8),'fixture','develop',notes)
state=types.ContinuationState('test',notes=notes,last=notes[-1])
phrase=types.Phrase(plan,(decision,),state=state,anchors=(anchor,),draft=draft)
return types.Solo('acoustic',h.choices,(phrase,),F(120),F(8)),h
class EnclosureRefinement(unittest.TestCase):
def test_atomic_candidates_preserve_target_timing_and_protections(self):
solo,h=solo_fixture();phrase=solo.phrases[0]
options=enclosures.candidates(phrase,h,tests.solo_fixtures.library(),solo.tempo,[])
terminal=[p for _,p in options if p.draft.enclosures[0].target_role=='terminal']
self.assertTrue(terminal)
for p in terminal:
self.assertEqual(p.draft.enclosures[0].target,phrase.draft.notes[-1])
self.assertTrue(h.evaluate(p.draft.notes,enclosures=p.draft.enclosures).valid)
actual=placement.place(p.draft.notes,solo.tempo)[1]
placed=dataclasses.replace(p,decisions=(dataclasses.replace(p.decisions[0],notes=actual),),state=dataclasses.replace(p.state,notes=actual))
composer.validate(dataclasses.replace(solo,phrases=(placed,)))
protected=dataclasses.replace(phrase,draft=dataclasses.replace(phrase.draft,protected=tuple(n.event_id for n in phrase.draft.notes)))
self.assertFalse(enclosures.candidates(protected,h,tests.solo_fixtures.library(),solo.tempo,[]))
def test_intermediate_enclosure_leaves_continuation_and_terminal_hold_intact(self):
solo,h=solo_fixture();phrase=solo.phrases[0]
options=enclosures.candidates(phrase,h,tests.solo_fixtures.library(),solo.tempo,[])
intermediate=[p for _,p in options if p.draft.enclosures[0].target_role=='intermediate']
self.assertTrue(intermediate)
for candidate in intermediate:
target=candidate.draft.enclosures[0].target
self.assertTrue(any(n.start>target.start for n in candidate.draft.notes))
self.assertEqual(candidate.draft.notes[-1],phrase.draft.notes[-1])
self.assertEqual(candidate.state.home,phrase.state.home)
def test_short_score_can_choose_direct_arrivals_without_a_quota(self):
solo,h=solo_fixture();phrase=solo.phrases[0]
protected=dataclasses.replace(phrase,draft=dataclasses.replace(phrase.draft,protected=tuple(n.event_id for n in phrase.draft.notes)))
result=enclosures.refine(dataclasses.replace(solo,phrases=(protected,)),tests.solo_fixtures.library())
self.assertEqual(result.diagnostics['enclosures_selected'],0)
self.assertEqual([(n.pitch,n.start,n.end) for n in result.notes()],[(n.pitch,n.start,n.end) for n in solo.notes()])
self.assertTrue(result.diagnostics['enclosure_rejections'])
def test_already_sounding_entrance_is_not_detached_for_ornamentation(self):
solo,h=solo_fixture();p=solo.phrases[0]
held=dataclasses.replace(p.draft.notes[0],continuation=True)
p=dataclasses.replace(p,draft=dataclasses.replace(p.draft,notes=(held,*p.draft.notes[1:])))
self.assertFalse(any(n.event_id==held.event_id for n,_ in enclosures.declared_targets(p,h)))
rejected=[]
self.assertFalse(enclosures.candidates(p,h,tests.solo_fixtures.library(),solo.tempo,rejected))
self.assertIn('sustain retained',rejected[0]['reason'])
self.assertTrue(p.draft.notes[0].continuation)
def test_no_gap_filling_and_no_unplayable_enclosure(self):
solo,h=solo_fixture();p=solo.phrases[0]
draft=dataclasses.replace(p.draft,notes=tuple(n for n in p.draft.notes if n.start!=3))
candidates=enclosures.candidates(dataclasses.replace(p,draft=draft),h,tests.solo_fixtures.library(),solo.tempo,[])
self.assertFalse(any(c.draft.enclosures[0].target.event_id=='target' for _,c in candidates))
self.assertIsNone(enclosures.approach(note(4,8,'e',octave=5),h))
def test_disabled_is_exact_strict_input_and_selection_is_deterministic(self):
solo,h=solo_fixture();library=tests.solo_fixtures.library()
disabled=enclosures.refine(solo,library,False)
self.assertEqual(disabled.notes(),solo.notes())
first=enclosures.refine(solo,library);second=enclosures.refine(solo,library)
self.assertEqual(first.notes(),second.notes())
self.assertEqual(enclosures.strict(first).notes(),solo.notes())
composer.validate(first);composer.validate(disabled)
self.assertTrue(report.structure(first)['harmonic_valid'])
class EnclosureCompositionAPI(unittest.TestCase):
def test_disable_joint_expressions_selects_a_valid_strict_alternative(self):
from generator.solo import analysis
import tests.rhythm_fixtures
doc=song(count=8,chords=('Gm7','C7','FM7','BbM7','Em7b5','A7','Dm7','Dm7'))
a=analysis.analyze(doc)
args=(a,doc['staves'][0]['measures'],'acoustic',F(32))
kwargs=dict(scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library())
decorated=composer.compose(*args,**kwargs)
strict=composer.compose(*args,**kwargs,enclosures_enabled=False)
# A jointly composed color/anticipation can change phrase selection. Its
# disabled alternative need not be the selected decorated phrase minus
# notes; both versions must retain the same form and valid sounding spans.
self.assertEqual(decorated.end,strict.end)
composer.validate(decorated)
composer.validate(strict)
self.assertTrue(harmonic.Harmony(strict.choices).evaluate(strict.notes()).valid)
self.assertTrue(all(not p.draft.enclosures for p in strict.phrases))
self.assertGreater(sum(v['proposed'] for v in decorated.diagnostics['expression_operations'].values()),0)
self.assertTrue(all(not p.draft.expressions for p in strict.phrases))
if __name__=='__main__':unittest.main()
-147
View File
@@ -1,147 +0,0 @@
"""Production source variations, strict scale permission and paired interpretation."""
import copy
import dataclasses
from fractions import Fraction as F
import pathlib
import unittest
from generator.solo import analysis, composer, drafts, harmonic, leads, placement, realization, scoring, types
import generator.fretboard
import generator.model
import tests.test_solo as fixture
import tests.test_solo_development as notes_fixture
import tests.test_solo_passages as passage_fixture
import tests.scale_fixtures
import tests.rhythm_fixtures
import tests.solo_fixtures
class SourceVariationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.document=fixture.song(count=8,chords=('CM7',))
cls.context=realization.SearchContext()
cls.solo=cls.compose(cls.document,cls.context)
@classmethod
def compose(cls,document,context=None,end=32,part='acoustic'):
return composer.compose(analysis.analyze(document),document['staves'][0]['measures'],part,F(end),
scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),context=context,mode='paraphrase')
def test_production_is_traceable_to_complete_source_phrases(self):
self.assertTrue(self.solo.phrases)
for p in self.solo.phrases:
self.assertIsNotNone(p.draft.source_phrase)
self.assertTrue(p.draft.source_links)
self.assertTrue(p.edits)
self.assertGreater(len(p.draft.source_phrase.notes),8)
self.assertTrue(drafts.contract_valid(p.draft,composer.phrase_notes(p))[0])
self.assertTrue(all(n.sources for n in p.draft.notes))
def test_same_input_and_cache_preserve_complete_music(self):
other=self.compose(self.document,self.context)
self.assertEqual(self.solo.notes(),other.notes())
self.assertGreater(self.context.diagnostics['cache_hits'],0)
def test_source_change_drives_music_with_fixed_harmony(self):
changed=copy.deepcopy(self.document)
for m in changed['staves'][0]['measures']:
for n in m['voices'][0]['notes']:
n['pitches'][0]['letter']='g' if n['pitches'][0]['letter']=='c' else n['pitches'][0]['letter']
other=self.compose(changed)
self.assertNotEqual(leads.signature(self.solo.notes()),leads.signature(other.notes()))
self.assertNotEqual(self.solo.phrases[0].initial.notes[0].pitch.number()%12,other.phrases[0].initial.notes[0].pitch.number()%12)
def test_source_ties_and_rests_are_retained_before_variation(self):
melody=(notes_fixture.note(0,5,'c'),notes_fixture.note(6,9,'e'),notes_fixture.note(9,12,'g'),notes_fixture.note(12,16,'c'))
melody=tuple(dataclasses.replace(n,sources=(f's{i}',)) for i,n in enumerate(melody))
source=analysis.source_phrases(melody,F(),F(16),F(4))
self.assertEqual(tuple(n for p in source for n in p.notes)[0].end,5)
self.assertTrue(any((F(5),F(6)) in p.rests for p in source))
def test_strict_map_rejects_chromatic_labels(self):
harmony=harmonic.Harmony(passage_fixture.choices(('C7',)))
approach=dataclasses.replace(notes_fixture.note(F(5,2),3,'f',1),event_id='approach')
target=dataclasses.replace(notes_fixture.note(3,4,'g'),event_id='target')
contract=types.HarmonicTreatment('removed','chromatic_approach',66,F(5,2),F(3),67,F(3),F(),'approach','target')
self.assertFalse(harmony.evaluate((approach,target),complete=True).valid)
self.assertFalse(harmony.evaluate((approach,target),(contract,),complete=True).valid)
def test_mapped_accidentals_and_half_steps_remain_valid(self):
scale=tests.scale_fixtures.library().scale('harmonic_minor',1,'D')
choices=passage_fixture.choices(('A7','Dm7'),duration=2,scale=scale)
harmony=harmonic.Harmony(choices)
notes=(notes_fixture.note(0,F(3,2),'e'),notes_fixture.note(F(3,2),2,'c',1),notes_fixture.note(2,4,'d'))
self.assertTrue(harmony.evaluate(notes,complete=True).valid)
def test_partition_does_not_change_sounding_evaluation(self):
phrase=self.solo.phrases[0]
whole=phrase.draft.notes;split=[]
for n in whole:
middle=(n.start+n.end)/2
split.extend((dataclasses.replace(n,end=middle),dataclasses.replace(n,start=middle,continuation=True)))
harmony=harmonic.Harmony(self.solo.choices)
self.assertEqual(harmony.evaluate(whole,complete=True),harmony.evaluate(split,complete=True))
self.assertEqual(scoring.features(phrase.plan,whole,self.solo.choices),scoring.features(phrase.plan,split,self.solo.choices))
self.assertTrue(drafts.contract_valid(phrase.draft,split)[0])
def test_low_register_and_tied_placement(self):
low=dataclasses.replace(notes_fixture.note(0,2,'a'),pitch=types.Pitch('a',F(),2))
following=dataclasses.replace(low,start=2,end=4,continuation=True)
placed=placement.place((low,following),F(120))
self.assertEqual(placed[1][0].position.string_number,6)
self.assertEqual(placed[1][0].position,placed[1][1].position)
self.assertEqual({p.string_number for number in range(41,77) for p in placement.positions(number)},set(range(1,7)))
def test_pair_metric_detects_octave_copy(self):
shifted=dataclasses.replace(self.solo,phrases=tuple(dataclasses.replace(p,decisions=tuple(dataclasses.replace(d,notes=leads.shift(d.notes,1)) for d in p.decisions)) for p in self.solo.phrases))
self.assertEqual(leads.similarity(self.solo,shifted),1)
callback=dataclasses.replace(shifted,phrases=tuple(dataclasses.replace(p,plan=dataclasses.replace(p.plan,relationship='callback')) for p in shifted.phrases))
self.assertEqual(leads.similarity(self.solo,callback),0)
def test_later_arrivals_compare_complete_routes_with_different_holds(self):
notes=tuple(dataclasses.replace(notes_fixture.note(a,b,k),event_id=str(a),sources=(str(a),))
for a,b,k in ((0,1,'g'),(1,2,'e'),(2,8,'c')))
source=types.SourcePhrase('ending',F(),F(8),notes,(),'family')
harmony=harmonic.Harmony(passage_fixture.choices(('C7','C7')))
options=leads.elaborate(notes,(),harmony,tests.solo_fixtures.library(),tests.rhythm_fixtures.library(),'4/4',source)
self.assertEqual({r.destination.end-r.destination.start for _,_,r in options},{F(1,2),F(1),F(3,2),F(2)})
for actual,_,route in options:
self.assertTrue(harmony.evaluate(actual,complete=True).valid)
self.assertEqual(actual[-1],route.destination)
self.assertEqual((actual[0].start,actual[-1].end),(0,8))
def test_repeated_source_cadence_is_not_accidental_monotony(self):
first=self.solo.phrases[0]
changed=dataclasses.replace(first,draft=dataclasses.replace(first.draft,
source_phrase=dataclasses.replace(first.draft.source_phrase,family='unrelated')))
self.assertAlmostEqual(leads.transition(first,changed)-leads.transition(first,first),.15)
def test_space_and_vocal_return_are_preserved(self):
silent=self.compose(fixture.song(count=8,chords=()))
self.assertFalse(silent.notes())
shortened=self.compose(self.document,end=F(29))
self.assertTrue(all(n.end<=29 for n in shortened.notes()))
composer.validate(shortened)
def test_batch_selection_preserves_independent_source_variations(self):
a=analysis.analyze(self.document)
pair=composer.compose_pair(a,{part:(self.document['staves'][0]['measures'],F(32),None) for part in ('acoustic','electric')},
scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),mode='paraphrase')
for solo in pair:
alone=self.compose(self.document,part=solo.part)
self.assertEqual(solo.notes(),alone.notes())
self.assertNotIn('pairs_compared',solo.diagnostics)
for solo in pair:
composer.validate(solo)
for phrase in solo.phrases:
heard={n.event_id:n for n in composer.phrase_notes(phrase)}
for route in phrase.routes:
self.assertEqual(route.notes[-1].pitch,route.destination.pitch)
for note in route.notes:
self.assertEqual((heard[note.event_id].pitch,heard[note.event_id].start,heard[note.event_id].end),(note.pitch,note.start,note.end))
def test_pickup_is_brought_inside_window_without_touching_previous_section(self):
melody=(notes_fixture.note(0,5,'g'),notes_fixture.note(5,6,'a'),notes_fixture.note(6,8,'g'))
melody=tuple(dataclasses.replace(n,sources=(f's{i}',),event_id=f's{i}') for i,n in enumerate(melody))
pickup=tuple(dataclasses.replace(notes_fixture.note(i-3,i-2,name),sources=(f'p{i}',),event_id=f'p{i}') for i,name in enumerate(('c','d','e')))
source=types.SourcePhrase('pickup',F(),F(8),melody,(),'family',pickup)
harmony=harmonic.Harmony(passage_fixture.choices(('C7','C7')))
options=[notes for notes,edits in leads.variations(melody,source,harmony) if edits[0].kind=='pickup_entrance']
self.assertTrue(options)
self.assertEqual(options[0][0].start,0)
self.assertEqual(options[0][3].start,3)
self.assertEqual(options[0][3].end,5)
self.assertTrue(harmony.evaluate(options[0],complete=True).valid)
def test_occupancy_is_not_an_objective_and_active_silence_is_invalid(self):
plan=dataclasses.replace(notes_fixture.plan(),end=F(4),release=F(),passages=(),treatment='source_variation')
choices=passage_fixture.choices(('C7',))
for end in (F(1),F(4)):
self.assertEqual(scoring.features(plan,(notes_fixture.note(0,end,'e'),),choices)['occupancy'],0)
self.assertFalse(scoring.feasible(plan,(),choices,F(4)))
if __name__=='__main__':unittest.main()
-120
View File
@@ -1,120 +0,0 @@
"""Lead invention contracts and accepted musical examples, not taste metrics."""
import dataclasses
import fractions
import pathlib
import unittest
import generator.solo.placement
import generator.solo.references as references
import generator.solo.routes as routes
import generator.solo.realization as realization
import generator.solo.types as types
import generator.solo.drafts as drafts
import generator.solo.harmonic as harmonic
import generator.solo.scoring as scoring
import generator.solo.policy as policy
import generator.solo.sequences as sequences
import tests.scale_fixtures
import tests.test_solo_development as fixtures
import tests.test_solo_passages as passages
F = fractions.Fraction
def notes(names):
return tuple(dataclasses.replace(fixtures.note(F(i, 2), F(i + 1, 2), name), event_id=f'n{i}') for i, name in enumerate(names))
class LeadReferenceTests(unittest.TestCase):
def test_authored_sounding_phrase_is_valid_without_audition_files(self):
original=tuple(dataclasses.replace(n,start=n.start*2,end=n.end*2) for n in notes(('c','e','g','e')))
harmony=harmonic.Harmony(passages.choices(('CM7',)))
self.assertTrue(harmony.evaluate(original,complete=True).valid)
self.assertTrue(generator.solo.placement.place(original,F(120)))
def test_feature_selection_finds_the_relationship_not_the_first_three(self):
original = notes(('c', 'd', 'e', 'c', 'g', 'f', 'd'))
feature, = references.identifying_features(original, 'test')
self.assertNotEqual(feature.events[:3], tuple(n.event_id for n in original[:3]))
self.assertEqual(references.feature_error(feature, original), 0)
self.assertGreater(references.feature_error(feature, original[:1]), 0)
def test_single_note_does_not_match_a_complete_tune(self):
phrase = types.Motif('lead', notes(('d', 'g', 'f', 'd')), F(2), ('Gm7',))
self.assertEqual(references.relationship(notes(('c',)), phrase), 1)
self.assertEqual(references.relationship(phrase.notes, phrase), 0)
def test_similarity_is_counted_once(self):
self.assertNotIn('fidelity', dict(policy.DEFAULT.weights))
self.assertEqual(scoring.value({'fidelity': 1, 'development': 0}), 0)
self.assertEqual(scoring.value({'development': 1}), 2)
def test_one_attack_deadband_is_not_a_new_density_target(self):
plan = dataclasses.replace(fixtures.plan(), end=F(4), release=F(), activity=F(1), passages=())
choices = passages.choices(('C7',))
four = notes(('e', 'g', 'e', 'g'))
five = notes(('e', 'g', 'e', 'g', 'e'))
self.assertEqual(scoring.features(plan, four, choices)['activity'], 0)
self.assertEqual(scoring.features(plan, five, choices)['activity'], 0)
self.assertEqual(policy.DEFAULT.lead_activity_range, (1.0, 1.5))
def test_generated_opening_is_editable_until_a_feature_is_committed(self):
original = notes(('c', 'g', 'f', 'e'))
draft = types.MelodicDraft('invented', F(), F(2), original, (), (), (), (), None, 'statement')
changed = (dataclasses.replace(original[0], pitch=fixtures.note(0, 1, 'e').pitch), *original[1:])
self.assertTrue(drafts.contract_valid(draft, changed)[0])
selected = dataclasses.replace(draft, features=references.identifying_features(original, 'selected'))
self.assertFalse(drafts.contract_valid(selected, changed)[0])
def test_ending_activity_only_broadens_the_selected_hold(self):
plan = dataclasses.replace(fixtures.plan(), end=F(16), release=F())
anchor = dataclasses.replace(passages.anchor(), earliest=F(15), latest=F(15), duration=F(1), hold_until=F(16))
chosen = drafts.ending_plan(plan, (anchor,))
arrivals = [p for p in chosen.passages if p.intent == 'sustained_arrival']
self.assertEqual([(p.start, p.end) for p in arrivals], [(15, 16)])
self.assertTrue(all(p.intent == 'active' for p in chosen.passages if p.end <= 15))
def test_zero_passage_origin_retains_the_full_activity_budget(self):
plan = dataclasses.replace(fixtures.plan(), start=F(16), end=F(32), passage_start=F(), passage_end=F(32), activity=F(9, 8), passages=(types.ActivitySpan(F(), F(32), 'active', F(9, 8), F(3, 4)),))
anchor = dataclasses.replace(passages.anchor(), earliest=F(30), latest=F(30), duration=F(2), hold_until=F(32))
chosen = drafts.ending_plan(plan, (anchor,))
self.assertEqual(sum(p.activity * (p.end - p.start) for p in chosen.passages), F(36))
class CompleteRouteTests(unittest.TestCase):
def test_complete_route_reaches_endpoint_without_nearest_note_repairs(self):
harmony = harmonic.Harmony(passages.choices(('C7',)))
library = sequences.Library((sequences.Sequence('descending', (-1, -1)), sequences.Sequence('wrong', (1, 1))))
template = notes(('g', 'c', 'e'))
result = routes.candidates(template, {'n0', 'n2'}, harmony, library, (55, 79))
# Explicit repeated-attack primitives can also complete a chord traversal.
# The authored route itself must survive intact, without nearest-note repair.
selected=next(r for r in result if r.sources==('descending',))
self.assertEqual([n.pitch.number() for n in selected.notes], [67, 65, 64])
self.assertTrue(all(r.destination==template[-1] for r in result))
self.assertTrue(all(harmony.evaluate(r.notes).valid for r in result))
def test_route_cannot_repair_a_forbidden_fixed_pitch(self):
harmony = harmonic.Harmony(passages.choices(('C7',)))
library = sequences.Library((sequences.Sequence('descending', (-1, -1)),))
self.assertEqual(routes.candidates(notes(('g', 'c', 'e')), {'n1'}, harmony, library, (55, 79)), ())
def test_collection_change_preserves_sounding_coordinate(self):
minor = tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')
aeolian = tests.scale_fixtures.library().scale('major', 6, 'D')
# C-sharp is absent in Aeolian: the first rising degree is actual D,
# rather than reinterpreting C-sharp's old harmonic-minor index.
self.assertEqual(routes.advance(aeolian, 61, 1)[0].number(), 62)
choices = list(passages.choices(('A7', 'Dm7'), duration=1, scale=minor))
choices[1] = dataclasses.replace(choices[1], scale=aeolian)
harmony = harmonic.Harmony(tuple(choices))
template = (dataclasses.replace(fixtures.note(0, F(1, 2), 'e'), event_id='a'), dataclasses.replace(fixtures.note(F(1, 2), 1, 'c', 1), event_id='b'), dataclasses.replace(fixtures.note(1, F(3, 2), 'd'), event_id='c'))
library = sequences.Library((sequences.Sequence('resolution', (-2, 1)),))
result = routes.candidates(template, {'a', 'c'}, harmony, library, (55, 79))
self.assertTrue(result)
self.assertEqual([n.pitch.number() for n in result[0].notes], [64, 61, 62])
self.assertEqual(len(result[0].spans), 2)
def test_whole_idea_replacement_can_change_an_ordinary_opening(self):
harmony = harmonic.Harmony(passages.choices(('C7', 'C7')))
plan = dataclasses.replace(fixtures.plan(), end=F(8), release=F(), passages=())
def phrase(names, identity):
actual = tuple(dataclasses.replace(n, start=2 * n.start, end=2 * n.end, event_id=identity + n.event_id) for n in notes(names))
group = types.Transformation(identity, '', '', 'statement', F(), F(8), tuple(n.event_id for n in actual), tuple((n.start, n.end) for n in actual), preserves=())
draft = types.MelodicDraft(identity, F(), F(8), actual, (group,), (), (), (), None, 'statement')
return realization.checked_phrase(plan, draft, actual, (), types.ContinuationState(''), harmony, F(120))
first = phrase(('c', 'c', 'c', 'e', 'g', 'g', 'g', 'e'), 'first')
donor = phrase(('e', 'g', 'e', 'c', 'e', 'g', 'f', 'e'), 'donor')
self.assertIsNotNone(first)
self.assertIsNotNone(donor)
revised = realization.replace_idea(first, donor, F(), F(8), types.ContinuationState(''), harmony, F(120))
self.assertIsNotNone(revised)
self.assertEqual([n.pitch for n in realization.emitted_notes(revised)], [n.pitch for n in realization.emitted_notes(donor)])
self.assertEqual(revised.edits[-1].kind, 'replace_idea')
+95 -182
View File
@@ -1,188 +1,101 @@
import dataclasses
import fractions
import unittest
import generator.harmony
import generator.model
import generator.scales.selection
import generator.scales.timeline
import generator.solo.harmonic
import generator.solo.motifs
import generator.solo.scoring
import generator.solo.types
import dataclasses, fractions, unittest
import generator.harmony, generator.model, generator.scales.selection, generator.scales.timeline
import generator.solo.harmonic, generator.solo.types
import tests.scale_fixtures
import tests.test_solo_development
F = fractions.Fraction
note = tests.test_solo_development.note
def choices(symbols, duration=4, scale=None):
scale = scale or tests.scale_fixtures.library().scale('major', 6, 'D')
return tuple(generator.scales.selection.Choice(generator.scales.timeline.ChordSpan(F(i * duration), F((i + 1) * duration), i + 1, str(i + 1), F(1), generator.model.key_from_fifths(-1), generator.harmony.parse_symbol(symbol)), scale, 'fixture') for i, symbol in enumerate(symbols))
from tests.solo_fixtures import note,choices
F=fractions.Fraction
def anchor(role='terminal', pitch='e', earliest=2, latest=2, duration=2, onset=False):
return generator.solo.types.Anchor('arrival', F(earliest), F(latest), note(0, 1, pitch).pitch, frozenset({4}), (55, 79), F(duration), role, 'C7', onset, F(4) if role == 'terminal' else None)
return generator.solo.types.Anchor('arrival', F(earliest), F(latest), note(0, 1, pitch).pitch, frozenset({4}), (55, 79), F(duration), role, 'C7', onset, F(4) if role == 'terminal' else None)
class HarmonicPassageTests(unittest.TestCase):
def test_same_collection_distinguishes_chord_function(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7', 'FM7', 'BbM7')))
self.assertEqual([s.roles.basic for s in harmony.spans], [frozenset(v) for v in ({7, 10, 2, 5}, {0, 4, 7, 10}, {5, 9, 0, 4}, {10, 2, 5, 9})])
def test_semantic_degrees_keep_alterations_omissions_suspensions_and_slash_bass(self):
for symbol in ('C7b5', 'C9', 'Csus4', 'C7no5', 'C/E', 'C/D'):
chord = generator.harmony.parse_symbol(symbol)
roles = generator.solo.harmonic.roles(chord)
self.assertFalse(roles.basic & roles.specified)
self.assertNotIn(2, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C/D')).basic)
self.assertIn(2, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C9')).specified)
self.assertIn(5, generator.solo.harmonic.roles(generator.harmony.parse_symbol('Csus4')).basic)
self.assertNotIn(7, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C7no5')).basic)
def test_ordinary_long_destination_requires_chord_member(self):
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
self.assertTrue(harmony.evaluate((note(0, 2, 'e'),)).valid)
self.assertFalse(harmony.evaluate((note(0, 2, 'f'),)).valid)
self.assertLess(harmony.evaluate((note(0, 1, 'e'),)).exposure, harmony.evaluate((note(0, 1, 'f'),)).exposure)
def test_connected_passing_motion_is_permitted(self):
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
notes = (note(0, F(1, 2), 'g'), note(F(1, 2), 1, 'f'), note(1, 2, 'e'))
self.assertEqual(harmony.connected_motion(notes, 1), 'passing')
self.assertTrue(harmony.evaluate(notes).valid)
self.assertEqual(harmony.evaluate(notes).exposure, 0)
def test_common_tone_hold_needs_no_reattack(self):
harmony = generator.solo.harmonic.Harmony(choices(('Em7b5', 'A7'), scale=tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')))
self.assertTrue(harmony.evaluate((note(2, 6, 'g'),)).valid)
def test_held_tension_detected_without_scale_change(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7')))
notes = (note(2, 6, 'f'),)
self.assertFalse(harmony.evaluate(notes).valid)
self.assertEqual([v['function'] for v in harmony.describe(notes)], ['basic', 'mapped_motion'])
def test_prepared_suspension_requires_actual_resolution(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7')))
treatment = generator.solo.types.HarmonicTreatment('suspension', 'suspension', 65, F(4), F(5), 64, F(5))
notes = (note(2, F(9, 2), 'f'), note(F(9, 2), 6, 'e'))
self.assertTrue(harmony.evaluate(notes, (treatment,), complete=True).valid)
partial = (note(2, F(9, 2), 'f'),)
result = harmony.evaluate(partial, (treatment,), until=F(9, 2))
self.assertTrue(result.valid)
self.assertEqual(result.pending, (treatment,))
self.assertFalse(harmony.evaluate(partial, (treatment,), until=6, complete=True).valid)
self.assertFalse(harmony.evaluate((note(4, F(9, 2), 'f'), note(F(9, 2), 6, 'e')), (treatment,), complete=True).valid)
self.assertFalse(harmony.evaluate((note(2, F(9, 2), 'f'), note(F(9, 2), 6, 'g')), (treatment,), complete=True).valid)
def test_symbol_specified_color_requires_a_plan(self):
harmony = generator.solo.harmonic.Harmony(choices(('C9',)))
notes = (note(0, 2, 'd'),)
color = generator.solo.types.HarmonicTreatment('ninth', 'color', 62, F(0), F(2))
self.assertFalse(harmony.evaluate(notes).valid)
self.assertTrue(harmony.evaluate(notes, (color,)).valid)
ordinary = generator.solo.harmonic.Harmony(choices(('C7',)))
self.assertFalse(ordinary.evaluate(notes, (color,)).valid)
def test_quotation_tension_preserved_and_reported(self):
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
notes = (note(0, 2, 'f', role='quote'),)
self.assertTrue(harmony.evaluate(notes).valid)
report = harmony.describe(notes)
self.assertTrue(report[0]['protected'])
self.assertEqual(report[0]['function'], 'mapped_motion')
def test_partition_invariance_for_roles_duration_activity_and_score(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7')))
whole = (note(2, 6, 'f'),)
split = (note(2, 3, 'f'), note(3, 4, 'f', continuation=True), note(4, 6, 'f', continuation=True))
self.assertEqual(harmony.evaluate(whole), harmony.evaluate(split))
self.assertEqual(harmony.describe(whole), harmony.describe(split))
plan = tests.test_solo_development.plan()
self.assertEqual(generator.solo.scoring.features(plan, whole, harmony.choices), generator.solo.scoring.features(plan, split, harmony.choices))
def test_intermediate_arrival_does_not_require_closure(self):
notes = (note(0, 1, 'g'), note(1, 2, 'e'), note(2, 3, 'f'), note(3, 4, 'g'))
middle = anchor('intermediate', earliest=1, latest=1, duration=1)
self.assertTrue(generator.solo.harmonic.anchors_valid((middle,), notes, F(4), complete=True)[0])
def test_terminal_touch_then_departure_is_not_closure(self):
terminal = dataclasses.replace(anchor(), duration=F(1, 2), hold_until=None)
notes = (note(2, F(5, 2), 'e'), note(F(5, 2), 4, 'g'))
self.assertFalse(generator.solo.harmonic.anchors_valid((terminal,), notes, F(4), complete=True, closure='closed')[0])
self.assertTrue(generator.solo.harmonic.anchors_valid((anchor(),), (note(2, 4, 'e'),), F(4), complete=True, closure='closed')[0])
def test_existing_hold_can_satisfy_anchor_only_when_allowed(self):
notes = (note(0, 4, 'e'),)
self.assertIsNotNone(generator.solo.harmonic.anchor_note(anchor(), notes))
self.assertIsNone(generator.solo.harmonic.anchor_note(anchor(onset=True), notes))
def test_single_unrelated_note_is_not_perfect_motif_evidence(self):
motif = generator.solo.types.Motif('idea', (note(0, 1, 'c'), note(1, 2, 'd'), note(2, 3, 'g')), F(3), ())
for treatment in ('contour_reference', 'rhythmic_reference', 'opening_reference', 'answer'):
self.assertGreater(generator.solo.scoring.motif_fidelity((note(0, 1, 'e'),), motif, treatment), 0)
def test_scale_index_round_trips_negative_and_octave_crossings(self):
for scale in (tests.scale_fixtures.library().scale('major', 6, 'D'), tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')):
for index in range(-21, 28):
self.assertEqual(generator.solo.types.scale_index(scale, generator.solo.types.scale_pitch(scale, index).number()), index)
scale = tests.scale_fixtures.library().scale('major', 6, 'D')
self.assertEqual(generator.solo.types.scale_index(scale, 65) - generator.solo.types.scale_index(scale, 64), 1)
class PassageStateTests(unittest.TestCase):
def test_source_phrases_carry_identity_and_completed_anchors(self):
import tests.test_solo
solo = tests.test_solo.compose(tests.test_solo.song(count=8, chords=('Gm7', 'C7', 'FM7', 'BbM7', 'Em7b5', 'A7', 'Dm7', 'Dm7')))
self.assertEqual((solo.passages[0].start, solo.passages[-1].end), (0, 32))
for phrase in solo.phrases:
self.assertTrue(phrase.draft.source_phrase)
self.assertTrue(phrase.state.home.chord_degrees)
self.assertTrue(all(generator.solo.harmonic.anchor_note(a, phrase.state.notes) for a in phrase.anchors))
self.assertTrue(generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=True, closure='closed')[0])
def test_development_references_realization_instead_of_source_template(self):
plan = tests.test_solo_development.plan()
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
gesture = generator.solo.types.Gesture('heard', F(0), F(4), 'statement', 'opening_reference')
notes = (note(0, 1, 'e'), note(1, 2, 'f'), note(2, 4, 'g'))
valid, _, realized = generator.solo.motifs.complete(gesture, plan.motif, notes, harmony)
self.assertTrue(valid)
following = dataclasses.replace(gesture, id='answer', start=F(4), end=F(8), relationship='answer')
self.assertEqual(generator.solo.motifs.reference(plan, following, (realized,)), realized)
self.assertEqual(realized.contour(), (1, 2))
self.assertNotEqual(realized.contour(), plan.motif.contour())
def test_concrete_pool_includes_continuation_from_actual_incoming_register(self):
import generator.solo.realization
import tests.solo_fixtures
plan = tests.test_solo_development.plan()
incoming = note(0, 4, 'c', octave=5)
state = generator.solo.types.ContinuationState('passage', (incoming,), last=incoming)
cell = generator.solo.realization.Cell(F(4), F(6), (('moving', ((F(0), F(1, 2)), (F(1, 2), F(1)), (F(1), F(3, 2)), (F(3, 2), F(2)))),))
pool = generator.solo.realization.candidates(cell, plan, choices(('C7', 'C7')), tests.solo_fixtures.library(), state)
self.assertTrue(pool)
self.assertTrue(any(c.notes[0].pitch.number() == incoming.pitch.number() for c in pool))
self.assertTrue(any(c.notes[0].continuation for c in pool))
def test_pending_suspension_guides_next_cells_candidate_pool(self):
import generator.solo.realization
import tests.solo_fixtures
harmony = choices(('Gm7', 'C7'))
held = note(2, F(9, 2), 'f')
contract = generator.solo.types.HarmonicTreatment('pending', 'suspension', 65, F(4), F(9, 2), 64, F(5))
state = generator.solo.types.ContinuationState('passage', (held,), treatments=(contract,), last=held)
cell = generator.solo.realization.Cell(F(9, 2), F(6), (('answer', ((F(0), F(1, 2)), (F(1, 2), F(1)), (F(1), F(3, 2)))),))
pool = generator.solo.realization.candidates(cell, tests.test_solo_development.plan(), harmony, tests.solo_fixtures.library(), state)
self.assertTrue(pool)
self.assertTrue(all(c.notes[0].pitch.number() == 64 for c in pool))
for candidate in pool:
checked = generator.solo.harmonic.Harmony(harmony).evaluate((*state.notes, *candidate.notes), candidate.treatments, until=6, complete=True)
self.assertTrue(checked.valid)
self.assertFalse(checked.pending)
def test_incompatible_anchor_cannot_retime_a_protected_quote(self):
import generator.solo.realization
import tests.test_solo
import tests.solo_fixtures
import tests.rhythm_fixtures
plan = tests.test_solo_development.plan()
demanded = generator.solo.types.Anchor('incompatible', F(1, 4), F(1, 4), plan.motif.notes[1].pitch, frozenset({plan.motif.notes[1].pitch.number() % 12}), plan.register, F(1, 2), 'opening', 'CM7', True)
plan = dataclasses.replace(plan, treatment='exact_quote', anchor_options=((demanded,),))
source = tests.test_solo.song(count=2, chords=('CM7',))
with self.assertRaises(generator.solo.realization.SearchExhausted):
generator.solo.realization.compose(plan, source['staves'][0]['measures'], tests.test_solo_development.choices(), tests.solo_fixtures.library(), tests.rhythm_fixtures.library(), F(120))
def test_phrase_lookahead_does_not_double_count_shared_passage_prefix(self):
import generator.solo.composer
first = dataclasses.replace(tests.test_solo_development.plan(), passage_id='shared', start=F(0), end=F(4))
second = dataclasses.replace(first, id='second', start=F(4), end=F(8))
left = generator.solo.types.Phrase(first, (generator.solo.types.Decision(F(0), F(4), 'fixture', 'invented', (note(0, 4, 'c'),)),), 1.0)
right = generator.solo.types.Phrase(first, (generator.solo.types.Decision(F(0), F(4), 'fixture', 'invented', (note(0, 4, 'g'),)),), 0.5)
def realize(plan, previous):
if plan.id == first.id:
return (left, right)
score = 0.6 if previous.pitch.letter == 'c' else 0.7
continuation = dataclasses.replace(previous, start=F(4), end=F(8))
return tuple([generator.solo.types.Phrase(second, (generator.solo.types.Decision(F(4), F(8), 'fixture', 'answer', (continuation,)),), score)])
chosen = generator.solo.composer.choose_phrases((first, second), realize)
self.assertEqual(chosen[0], left)
def test_same_collection_distinguishes_chord_function(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7', 'FM7', 'BbM7')))
self.assertEqual([s.roles.basic for s in harmony.spans], [frozenset(v) for v in ({7, 10, 2, 5}, {0, 4, 7, 10}, {5, 9, 0, 4}, {10, 2, 5, 9})])
def test_semantic_degrees_keep_alterations_omissions_suspensions_and_slash_bass(self):
for symbol in ('C7b5', 'C9', 'Csus4', 'C7no5', 'C/E', 'C/D'):
chord = generator.harmony.parse_symbol(symbol)
roles = generator.solo.harmonic.roles(chord)
self.assertFalse(roles.basic & roles.specified)
self.assertNotIn(2, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C/D')).basic)
self.assertIn(2, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C9')).specified)
self.assertIn(5, generator.solo.harmonic.roles(generator.harmony.parse_symbol('Csus4')).basic)
self.assertNotIn(7, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C7no5')).basic)
def test_ordinary_long_destination_requires_chord_member(self):
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
self.assertTrue(harmony.evaluate((note(0, 2, 'e'),)).valid)
self.assertFalse(harmony.evaluate((note(0, 2, 'f'),)).valid)
self.assertLess(harmony.evaluate((note(0, 1, 'e'),)).exposure, harmony.evaluate((note(0, 1, 'f'),)).exposure)
def test_connected_passing_motion_is_permitted(self):
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
notes = (note(0, F(1, 2), 'g'), note(F(1, 2), 1, 'f'), note(1, 2, 'e'))
self.assertEqual(harmony.connected_motion(notes, 1), 'passing')
self.assertTrue(harmony.evaluate(notes).valid)
self.assertEqual(harmony.evaluate(notes).exposure, 0)
def test_common_tone_hold_needs_no_reattack(self):
harmony = generator.solo.harmonic.Harmony(choices(('Em7b5', 'A7'), scale=tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')))
self.assertTrue(harmony.evaluate((note(2, 6, 'g'),)).valid)
def test_held_tension_detected_without_scale_change(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7')))
notes = (note(2, 6, 'f'),)
self.assertFalse(harmony.evaluate(notes).valid)
self.assertEqual([v['function'] for v in harmony.describe(notes)], ['basic', 'mapped_motion'])
def test_prepared_suspension_requires_actual_resolution(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7')))
treatment = generator.solo.types.HarmonicTreatment('suspension', 'suspension', 65, F(4), F(5), 64, F(5))
notes = (note(2, F(9, 2), 'f'), note(F(9, 2), 6, 'e'))
self.assertTrue(harmony.evaluate(notes, (treatment,), complete=True).valid)
partial = (note(2, F(9, 2), 'f'),)
result = harmony.evaluate(partial, (treatment,), until=F(9, 2))
self.assertTrue(result.valid)
self.assertEqual(result.pending, (treatment,))
self.assertFalse(harmony.evaluate(partial, (treatment,), until=6, complete=True).valid)
self.assertFalse(harmony.evaluate((note(4, F(9, 2), 'f'), note(F(9, 2), 6, 'e')), (treatment,), complete=True).valid)
self.assertFalse(harmony.evaluate((note(2, F(9, 2), 'f'), note(F(9, 2), 6, 'g')), (treatment,), complete=True).valid)
def test_symbol_specified_color_requires_a_plan(self):
harmony = generator.solo.harmonic.Harmony(choices(('C9',)))
notes = (note(0, 2, 'd'),)
color = generator.solo.types.HarmonicTreatment('ninth', 'color', 62, F(0), F(2))
self.assertFalse(harmony.evaluate(notes).valid)
self.assertTrue(harmony.evaluate(notes, (color,)).valid)
ordinary = generator.solo.harmonic.Harmony(choices(('C7',)))
self.assertFalse(ordinary.evaluate(notes, (color,)).valid)
def test_quotation_tension_preserved_and_reported(self):
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
notes = (note(0, 2, 'f', role='quote'),)
self.assertTrue(harmony.evaluate(notes).valid)
report = harmony.describe(notes)
self.assertTrue(report[0]['protected'])
self.assertEqual(report[0]['function'], 'mapped_motion')
def test_intermediate_arrival_does_not_require_closure(self):
notes = (note(0, 1, 'g'), note(1, 2, 'e'), note(2, 3, 'f'), note(3, 4, 'g'))
middle = anchor('intermediate', earliest=1, latest=1, duration=1)
self.assertTrue(generator.solo.harmonic.anchors_valid((middle,), notes, F(4), complete=True)[0])
def test_terminal_touch_then_departure_is_not_closure(self):
terminal = dataclasses.replace(anchor(), duration=F(1, 2), hold_until=None)
notes = (note(2, F(5, 2), 'e'), note(F(5, 2), 4, 'g'))
self.assertFalse(generator.solo.harmonic.anchors_valid((terminal,), notes, F(4), complete=True, closure='closed')[0])
self.assertTrue(generator.solo.harmonic.anchors_valid((anchor(),), (note(2, 4, 'e'),), F(4), complete=True, closure='closed')[0])
def test_existing_hold_can_satisfy_anchor_only_when_allowed(self):
notes = (note(0, 4, 'e'),)
self.assertIsNotNone(generator.solo.harmonic.anchor_note(anchor(), notes))
self.assertIsNone(generator.solo.harmonic.anchor_note(anchor(onset=True), notes))
def test_scale_index_round_trips_negative_and_octave_crossings(self):
for scale in (tests.scale_fixtures.library().scale('major', 6, 'D'), tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')):
for index in range(-21, 28):
self.assertEqual(generator.solo.types.scale_index(scale, generator.solo.types.scale_pitch(scale, index).number()), index)
scale = tests.scale_fixtures.library().scale('major', 6, 'D')
self.assertEqual(generator.solo.types.scale_index(scale, 65) - generator.solo.types.scale_index(scale, 64), 1)
-133
View File
@@ -1,133 +0,0 @@
import dataclasses
from fractions import Fraction as F
import unittest
from generator.solo import drafts, harmonic, phrasing, themes, types, leads, realization
from tests.test_solo_development import note
from tests.test_solo_passages import choices
from tests.test_solo_themes import melody
import tests.rhythm_fixtures
import tests.solo_fixtures
class SustainedPhrasing(unittest.TestCase):
def test_vocal_cutoff_does_not_strand_a_one_beat_final_phrase(self):
from generator.solo import analysis
from tests.test_solo import song
document=song(count=32,chords=('CM7',))
source=analysis.analyze(document)
harmony=harmonic.Harmony(choices(('CM7',)*32))
values=phrasing.window_options(source,document['staves'][0]['measures'],F(100),F(125),harmony,types.ContinuationState('',closures=5),limit=4)
self.assertEqual(values[0].end,125)
self.assertFalse(any(0<125-v.end<4 for v in values))
def test_production_cost_uses_complete_sounding_events_across_cells(self):
harmony=harmonic.Harmony(choices(('C7','C7')))
whole=(note(0,1,'g'),note(1,F(3,2),'f'),note(F(3,2),4,'e'))
split=(whole[0],whole[1],dataclasses.replace(whole[2],end=2),note(2,4,'e',continuation=True))
incoming=types.ContinuationState('')
from tests.test_composer_repair import evaluated
self.assertEqual(evaluated(whole,harmony),evaluated(split,harmony))
def test_route_cache_preserves_concrete_provenance_and_uncached_selection(self):
from generator.solo import routes,sequences,policy
harmony=harmonic.Harmony(choices(('C7',)))
notes=melody(((0,1,'g'),(1,2,'f'),(2,3,'e')))
library=tests.solo_fixtures.library()
raw=routes._candidates(notes,{'2'},harmony,library,(41,76))
expected=sequences.diverse(raw,
lambda r:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(r.notes,r.notes[1:])),r.id),
lambda r:sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(r.notes,r.notes[1:]))),policy.DEFAULT.join_alternatives)
cached=routes.candidates(notes,{'2'},harmony,library,(41,76))
self.assertEqual(tuple((r.id,r.notes) for r in expected),tuple((r.id,r.notes) for r in cached))
renamed=tuple(dataclasses.replace(n,event_id='other'+n.event_id,sources=('other-source',)) for n in notes)
changed=routes.candidates(renamed,{'other2'},harmony,library,(41,76))
self.assertEqual(len(harmony.route_cache),1)
self.assertTrue(all(n.sources==('other-source',) for r in changed for n in r.notes))
self.assertTrue(all(n.event_id.startswith('other') for r in changed for n in r.notes))
def test_extended_closing_unit_has_complete_timing_beyond_two_bars(self):
values=themes.rhythm_windows(tests.rhythm_fixtures.library(),'4/4',F(12),realization.SearchContext())
self.assertTrue(values)
self.assertTrue(any(any(a>=8 for a,b in pattern) for pattern,_ in values))
for pattern,_ in values:
self.assertTrue(all(0<=a<b<=12 for a,b in pattern))
self.assertTrue(all(b<=c for (a,b),(c,d) in zip(pattern,pattern[1:])))
def test_two_bar_retrieval_uses_the_richer_one_bar_vocabulary(self):
values=themes.rhythm_windows(tests.rhythm_fixtures.library(),'4/4',F(8),realization.SearchContext())
self.assertTrue(any(b-a==F(1,2) for pattern,_ in values for a,b in pattern))
def test_endpoint_ranking_distinguishes_major_seventh_from_dominant(self):
major=harmonic.Harmony(choices(('CM7',)*4))
dominant=harmonic.Harmony(choices(('C7',)*4))
self.assertLess(phrasing.endpoint_cost(F(16),F(),F(128),0,major,F(4)),
phrasing.endpoint_cost(F(16),F(),F(128),0,dominant,F(4)))
def test_protected_tail_is_sounding_time_not_an_immutable_whole_event(self):
original=(note(0,4,'e',event_id='arrival'),)
tail=types.SustainedTail(F(2),F(4),64)
draft=types.MelodicDraft('held',F(),F(4),original,(),(),(),(),None,'close',sustained_tails=(tail,))
revised=(note(0,1,'g',event_id='a'),note(1,2,'f',event_id='b'),note(2,4,'e',event_id='c'))
self.assertTrue(drafts.contract_valid(draft,revised)[0])
tied=(*revised[:-1],note(2,3,'e',event_id='c'),note(3,4,'e',event_id='c',continuation=True))
self.assertTrue(drafts.contract_valid(draft,tied)[0])
repeated=(*revised[:-1],note(2,3,'e',event_id='c'),note(3,4,'e',event_id='d'))
self.assertFalse(drafts.contract_valid(draft,repeated)[0])
def test_planned_endings_have_varied_timing_and_full_chord_permission(self):
harmony=harmonic.Harmony(choices(('Gm7','C7','FM7','BbM7')))
source=types.SourcePhrase('phrase',F(),F(16),(note(0,1,'d'),),(),'')
options=phrasing.endings(source,types.ContinuationState(''), 'acoustic',harmony,F(4))
self.assertTrue(options)
self.assertGreater(len({(a.earliest,a.duration) for a in options}),1)
for anchor in options:
self.assertGreaterEqual(anchor.duration,2)
self.assertLessEqual(anchor.duration,4)
self.assertIn(anchor.pitch.number()%12,harmony.members(anchor.earliest,anchor.hold_until))
self.assertTrue(harmony.evaluate((types.Note(anchor.earliest,anchor.hold_until,anchor.pitch),),complete=True).valid)
def test_rhythmic_development_keeps_a_heard_turn_without_copying_its_timing(self):
original=melody(((0,1,'d'),(1,2,'f'),(2,3,'e')))
home=types.RealizedMotif('home','seed',original,F(3),'introduce','contour')
ref=themes.home_reference(home,'develop_contour')
context=realization.SearchContext()
patterns=themes.patterns(ref,F(),F(8),tests.rhythm_fixtures.library(),'4/4',context,home=home,operation='develop_contour')
self.assertGreater(len({p[:3] for p,_ in patterns}),1)
actual=(note(0,F(1,2),'d',event_id='0'),note(F(1,2),2,'f',event_id='1'),note(2,3,'e',event_id='2'))
feature=themes.feature(ref,actual,treatment='develop_contour')
from generator.solo.references import feature_error
self.assertEqual(feature_error(feature,actual),0)
def test_repetition_remembers_recent_figures_but_not_tie_splits(self):
original=melody(((0,1,'d'),(1,2,'f'),(2,3,'e'),(3,4,'f')))
old=phrasing.heard(original,'introduce')
repeated=tuple(dataclasses.replace(n,start=n.start+8,end=n.end+8) for n in original)
figure=phrasing.heard(repeated,'develop_contour')
self.assertGreater(phrasing.repetition(figure,(old,),F(4)),phrasing.repetition(figure,(),F(4)))
returned=dataclasses.replace(figure,operation='return')
self.assertGreater(phrasing.repetition(returned,(old,old,old),F(4)),phrasing.repetition(returned,(old,),F(4)))
hold=(note(0,4,'e'),)
split=(note(0,2,'e'),note(2,4,'e',continuation=True))
self.assertEqual(phrasing.heard(hold,'close'),phrasing.heard(split,'close'))
self.assertEqual(phrasing.repetition(phrasing.heard(hold,'close'),(),F(4)),0)
def test_high_electric_state_proposes_a_lower_goal(self):
high=melody(((0,1,'c'),(1,2,'e'),(2,3,'d')))
high=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,octave=5)) for n in high)
state=types.ContinuationState('',last=high[-1],history=(phrasing.heard(high,'develop_contour'),),register_peak=76)
self.assertLess(phrasing.register_goal(state,'electric',74),min(n.pitch.number() for n in high))
def test_internal_short_notes_can_be_elaborated_without_consuming_arrival(self):
notes=melody(((0,1,'g'),(1,2,'f'),(2,3,'g'),(3,5,'e')))
source=types.SourcePhrase('internal',F(),F(5),notes,(),'')
harmony=harmonic.Harmony(choices(('C7','C7')))
tails=(types.SustainedTail(F(3),F(5),64),)
options=leads.elaborate(notes,(),harmony,tests.solo_fixtures.library(),tests.rhythm_fixtures.library(),'4/4',source,sustain_entry=True,tails=tails)
self.assertTrue(options)
for actual,_,_ in options:
self.assertTrue(any(n.pitch.number()==64 and n.start<=3 and n.end>=5 for n in actual))
self.assertTrue(harmony.evaluate(actual,complete=True).valid)
if __name__=='__main__':unittest.main()
-147
View File
@@ -1,147 +0,0 @@
"""Source influence and realized-theme continuity are different contracts."""
import dataclasses
from fractions import Fraction as F
import unittest
from generator.solo import analysis, composer, drafts, harmonic, leads, references, realization, similarity, themes, types
import tests.test_solo as fixture
import tests.test_solo_development as notes_fixture
import tests.test_solo_passages as harmony_fixture
import tests.scale_fixtures
import tests.solo_fixtures
import tests.rhythm_fixtures
def melody(values):
return tuple(dataclasses.replace(notes_fixture.note(a,b,p),event_id=str(i),sources=(str(i),)) for i,(a,b,p) in enumerate(values))
class ThemeContracts(unittest.TestCase):
def test_selected_rhythm_does_not_also_require_source_pitches(self):
source=melody(((0,1,'c'),(1,2,'d'),(2,3,'e')))
ref=types.SourceReference('source','phrase',F(),F(3),source,'rhythmic_seed')
actual=melody(((8,9,'g'),(9,10,'e'),(10,11,'c')))
self.assertEqual(references.feature_error(themes.feature(ref,actual),actual),0)
contour=dataclasses.replace(ref,treatment='contour_seed')
self.assertGreater(references.feature_error(themes.feature(contour,actual),actual),0)
def test_a_single_event_is_not_evidence_of_a_multinote_feature(self):
source=melody(((0,1,'c'),(1,2,'d'),(2,3,'e')))
ref=types.SourceReference('source','phrase',F(),F(3),source,'rhythmic_seed')
contract=themes.feature(ref,source)
self.assertEqual(references.feature_error(contract,source[:1]),1)
self.assertEqual(references.feature_error(themes.feature(ref,source[:1]),source[:1]),1)
def test_copy_comparison_cannot_be_diluted_by_octaves_runs_or_reattacks(self):
source=melody(((0,2,'c'),(2,4,'e'),(4,6,'g'),(6,8,'c')))
shifted=leads.shift(source,1)
self.assertTrue(similarity.near_copy(shifted,source,F(),F(8))['near_copy'])
repeated=tuple(part for n in shifted for part in (dataclasses.replace(n,end=n.start+1),dataclasses.replace(n,start=n.start+1,event_id=n.event_id+'r')))
self.assertTrue(similarity.near_copy(repeated,source,F(),F(8))['near_copy'])
self.assertEqual(similarity.compare(repeated,source)['octave_insensitive'],1)
shortened=tuple(dataclasses.replace(n,end=n.end-F(1,2)) for n in shifted)
self.assertEqual(similarity.compare(shortened,source)['octave_insensitive'],.75)
relocated=tuple(dataclasses.replace(n,start=n.start+8,end=n.end+8) for n in shifted)
self.assertTrue(similarity.near_copy(relocated,source,F(8),F(16))['near_copy'])
def test_tied_partition_does_not_change_similarity(self):
source=melody(((0,2,'c'),(2,4,'e'),(4,6,'g'),(6,8,'c')))
split=tuple(piece for n in source for piece in (dataclasses.replace(n,end=n.start+1),dataclasses.replace(n,start=n.start+1,continuation=True)))
self.assertEqual(similarity.compare(source,source),similarity.compare(split,source))
def test_downward_cross_collection_steps_preserve_the_actual_target(self):
from generator.solo import routes
scale=tests.scale_fixtures.library().scale('major',6,'D')
harmony=harmonic.Harmony(harmony_fixture.choices(('Dm7',),scale=scale))
first=dataclasses.replace(notes_fixture.note(0,1,'c'),pitch=types.Pitch('c',F(1),4))
last=dataclasses.replace(notes_fixture.note(1,2,'b'),pitch=types.Pitch('b',F(-1),3))
step,=themes.relative_steps((first,last),harmony)
self.assertEqual(step,-2)
self.assertEqual(routes.advance(scale,first.pitch.number(),step)[0].number(),last.pitch.number())
def test_two_note_route_uses_exact_scale_displacement(self):
from generator.solo import routes
harmony=harmonic.Harmony(harmony_fixture.choices(('C7',)))
notes=melody(((0,1,'g'),(1,2,'e')))
options=routes.candidates(notes,{'1'},harmony,tests.solo_fixtures.library(),(41,76))
self.assertTrue(options)
self.assertTrue(all(r.notes[-1].pitch==notes[-1].pitch for r in options))
def test_elaboration_may_continue_its_entry_instead_of_reattacking(self):
notes=melody(((0,3,'g'),(3,4,'e')))
source=types.SourcePhrase('held_entry',F(),F(4),notes,(),'')
harmony=harmonic.Harmony(harmony_fixture.choices(('C7',)))
options=leads.elaborate(notes,(),harmony,tests.solo_fixtures.library(),tests.rhythm_fixtures.library(),'4/4',source,sustain_entry=True)
self.assertTrue(options)
for actual,edit,route in options:
self.assertEqual(actual[0].start,0)
self.assertGreater(actual[0].end,edit.start)
self.assertEqual(actual[:len(route.notes)],route.notes)
self.assertTrue(harmony.evaluate(actual,complete=True).valid)
class ThemeProduction(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.document=fixture.song(count=8,chords=('CM7',))
cls.analysis=analysis.analyze(cls.document)
cls.context=realization.SearchContext()
cls.kw=dict(scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),context=cls.context)
cls.solo=composer.compose(cls.analysis,cls.document['staves'][0]['measures'],'acoustic',F(32),**cls.kw)
def test_one_heard_theme_develops_across_source_boundaries(self):
self.assertEqual(self.solo.diagnostics['construction'],'thematic_recomposition')
self.assertEqual(len({p.state.home.id for p in self.solo.phrases}),1)
for phrase in self.solo.phrases:
self.assertTrue(drafts.contract_valid(phrase.draft,composer.phrase_notes(phrase))[0])
self.assertFalse(similarity.near_copy(phrase.refined.notes,themes.source_notes(self.analysis),phrase.plan.start,phrase.plan.end)['near_copy'])
self.assertIn(self.solo.phrases[0].plan.closure,('open','closed'))
self.assertTrue(all(p.evaluation and p.score==p.evaluation.total for p in self.solo.phrases))
self.assertEqual(self.solo.phrases[-1].anchors[-1].role,'terminal')
self.assertTrue(any(t.operation in ('develop_contour','develop_rhythm','continue','return') for p in self.solo.phrases for t in p.draft.transformations))
def test_library_routes_match_the_actual_complete_theme(self):
plain=composer.compose(self.analysis,self.document['staves'][0]['measures'],'acoustic',F(32),stage='plain',**self.kw)
# Joint rhythm/expression edits can replace every original route in this
# particular fixture. Require construction to use complete routes, then
# verify all retained provenance in both the plain and edited realizations.
self.assertTrue(any(p.routes for p in plain.phrases))
for phrase in (*plain.phrases,*self.solo.phrases):
heard={n.event_id:n for n in composer.phrase_notes(phrase)}
for route in phrase.routes:
for note in route.notes:
actual=heard[note.event_id]
self.assertEqual((note.start,note.end,note.pitch),(actual.start,actual.end,actual.pitch))
self.assertEqual(route.notes[-1].pitch,route.destination.pitch)
def test_report_can_compare_a_source_hold_cut_by_new_phrase_boundaries(self):
from generator.solo import report
phrases=[]
for i,p in enumerate(self.solo.phrases):
held=notes_fixture.note(p.plan.start,p.plan.end,'c',event_id='source_hold',continuation=i>0)
phrases.append(dataclasses.replace(p,initial=dataclasses.replace(p.initial,notes=(held,))))
solo=dataclasses.replace(self.solo,phrases=tuple(phrases))
detail=report.structure(solo)
self.assertEqual(len(detail['phrases']),len(phrases))
self.assertTrue(phrases[-1].initial.notes[0].continuation)
def test_incoming_pitch_changes_concrete_continuation_pool(self):
harmony=harmonic.Harmony(self.solo.choices)
home=self.solo.phrases[0].state.home
ref=themes.home_reference(home,'answer')
context=realization.SearchContext()
outputs=[]
for octave in (3,4):
prior=dataclasses.replace(notes_fixture.note(15,16,'e'),pitch=types.Pitch('e',F(),octave))
state=types.ContinuationState('prior',last=prior,home=home)
options=themes.unit_candidates(ref,F(16),F(24),state,harmony,self.kw['sequences'],self.kw['rhythms'],'4/4',context,home=home,operation='answer')
self.assertTrue(options)
outputs.append({u.notes[0].pitch.number() for u in options})
self.assertNotEqual(*outputs)
def test_deterministic_cache_and_actual_source_input(self):
repeated=composer.compose(self.analysis,self.document['staves'][0]['measures'],'acoustic',F(32),**self.kw)
self.assertEqual(self.solo.notes(),repeated.notes())
changed=dataclasses.replace(self.analysis,melody=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,letter='g')) for n in self.analysis.melody))
other=composer.compose(changed,self.document['staves'][0]['measures'],'acoustic',F(32),**self.kw)
self.assertNotEqual(leads.signature(self.solo.notes()),leads.signature(other.notes()))
if __name__=='__main__':unittest.main()