Cleanup
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
@@ -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)))
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user