|
|
|
@@ -0,0 +1,154 @@
|
|
|
|
|
"""Construction and permission contracts; listening remains the taste test."""
|
|
|
|
|
import dataclasses
|
|
|
|
|
import pathlib
|
|
|
|
|
import unittest
|
|
|
|
|
from fractions import Fraction as F
|
|
|
|
|
|
|
|
|
|
from generator.solo import analysis, composer, drafts, harmonic, leads, references, realization, scoring, types
|
|
|
|
|
import tests.test_solo as solo_fixture
|
|
|
|
|
import tests.test_solo_passages as passage_fixture
|
|
|
|
|
import tests.test_solo_development as fixture
|
|
|
|
|
import tests.rhythm_fixtures
|
|
|
|
|
import tests.scale_fixtures
|
|
|
|
|
import tests.solo_fixtures
|
|
|
|
|
|
|
|
|
|
class ChromaticApproachTests(unittest.TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.harmony = harmonic.Harmony(passage_fixture.choices(('C7', 'FM7')))
|
|
|
|
|
self.approach = dataclasses.replace(fixture.note(F(7, 2), 4, 'g', -1), event_id='approach')
|
|
|
|
|
self.target = dataclasses.replace(fixture.note(4, 6, 'f'), event_id='resolution')
|
|
|
|
|
self.treatment = types.HarmonicTreatment('test', 'chromatic_approach', 66, F(7, 2), F(4), 65, F(4), F(), 'approach', 'resolution')
|
|
|
|
|
def test_approach_resolves_across_chord_boundary_without_remapping_scale(self):
|
|
|
|
|
self.assertFalse(self.harmony.evaluate((self.approach, self.target), complete=True).valid)
|
|
|
|
|
result = self.harmony.evaluate((self.approach, self.target), (self.treatment,), complete=True)
|
|
|
|
|
self.assertTrue(result.valid, result.error)
|
|
|
|
|
self.assertFalse(result.pending)
|
|
|
|
|
report = self.harmony.describe((self.approach, self.target), (self.treatment,))
|
|
|
|
|
self.assertEqual((report[0]['function'], report[0]['motion']), ('outside_collection', 'chromatic_approach'))
|
|
|
|
|
def test_pending_approach_survives_computational_boundary(self):
|
|
|
|
|
partial = self.harmony.evaluate((self.approach,), (self.treatment,), until=F(4))
|
|
|
|
|
self.assertTrue(partial.valid)
|
|
|
|
|
self.assertEqual(partial.pending, (self.treatment,))
|
|
|
|
|
self.assertFalse(self.harmony.evaluate((self.approach,), (self.treatment,), until=F(5)).valid)
|
|
|
|
|
def test_tie_partition_does_not_change_approach_or_sustain(self):
|
|
|
|
|
split = (dataclasses.replace(self.approach, end=F(15, 4)), dataclasses.replace(self.approach, start=F(15, 4), continuation=True, event_id='tie'), dataclasses.replace(self.target, end=F(5)), dataclasses.replace(self.target, start=F(5), continuation=True, event_id='target_tie'))
|
|
|
|
|
whole = (self.approach, self.target)
|
|
|
|
|
self.assertEqual(self.harmony.evaluate(whole, (self.treatment,), complete=True), self.harmony.evaluate(split, (self.treatment,), complete=True))
|
|
|
|
|
self.assertEqual(self.harmony.describe(whole, (self.treatment,)), self.harmony.describe(split, (self.treatment,)))
|
|
|
|
|
self.assertEqual(len(types.sounding_notes(split)), 2)
|
|
|
|
|
def test_labels_cannot_excuse_arbitrary_notes(self):
|
|
|
|
|
cases = [
|
|
|
|
|
(dataclasses.replace(self.approach, start=F(3)), self.target, self.treatment),
|
|
|
|
|
(self.approach, dataclasses.replace(self.target, start=F(9, 2)), self.treatment),
|
|
|
|
|
(self.approach, dataclasses.replace(self.target, pitch=fixture.note(0, 1, 'e').pitch), self.treatment),
|
|
|
|
|
(self.approach, self.target, dataclasses.replace(self.treatment, event='unrelated')),
|
|
|
|
|
(dataclasses.replace(self.approach, role='quote'), self.target, self.treatment),
|
|
|
|
|
(self.approach, self.target, dataclasses.replace(self.treatment, deadline=F(5))),
|
|
|
|
|
]
|
|
|
|
|
for approach, target, treatment in cases:
|
|
|
|
|
with self.subTest(approach=approach, target=target, treatment=treatment):
|
|
|
|
|
self.assertFalse(self.harmony.evaluate((approach, target), (treatment,), complete=True).valid)
|
|
|
|
|
def test_resolution_must_be_chord_member_and_valid_through_its_hold(self):
|
|
|
|
|
nonmember = dataclasses.replace(self.target, pitch=fixture.note(4, 6, 'g').pitch)
|
|
|
|
|
treatment = dataclasses.replace(self.treatment, resolution=67)
|
|
|
|
|
self.assertFalse(self.harmony.evaluate((self.approach, nonmember), (treatment,), complete=True).valid)
|
|
|
|
|
long_approach = dataclasses.replace(self.approach, start=F(2))
|
|
|
|
|
self.assertFalse(self.harmony.evaluate((long_approach, self.target), (dataclasses.replace(self.treatment, start=F(2)),), complete=True).valid)
|
|
|
|
|
def test_approach_spelling_avoids_unnecessary_double_accidentals(self):
|
|
|
|
|
target = fixture.note(0, 1, 'a', -1).pitch
|
|
|
|
|
self.assertEqual(leads.approach_pitch(target, 69).label(), 'A4')
|
|
|
|
|
target = fixture.note(0, 1, 'e').pitch
|
|
|
|
|
self.assertEqual(leads.approach_pitch(target, 63).label(), 'D#4')
|
|
|
|
|
def test_chromatic_spacing_carries_across_passage_boundary(self):
|
|
|
|
|
notes = (dataclasses.replace(self.approach, pitch=fixture.note(0, 1, 'g').pitch), self.target)
|
|
|
|
|
plan = dataclasses.replace(fixture.plan(), end=F(8), release=F(2), target=None, passages=())
|
|
|
|
|
group = types.Transformation('group', '', '', 'answer', F(), F(8), tuple(n.event_id for n in notes), tuple((n.start, n.end) for n in notes), preserves=())
|
|
|
|
|
draft = types.MelodicDraft('spacing', F(), F(8), notes, (group,), ('resolution',), (), (), None, 'answer')
|
|
|
|
|
empty = types.ContinuationState('previous')
|
|
|
|
|
phrase = realization.checked_phrase(plan, draft, notes, (), empty, self.harmony, F(120))
|
|
|
|
|
self.assertIsNotNone(phrase)
|
|
|
|
|
self.assertTrue(leads.chromatic(phrase, empty, self.harmony, F(120)).edits)
|
|
|
|
|
recent = dataclasses.replace(empty, treatments=(dataclasses.replace(self.treatment, start=F(-7, 2)),))
|
|
|
|
|
self.assertFalse(leads.chromatic(phrase, recent, self.harmony, F(120)).edits)
|
|
|
|
|
|
|
|
|
|
class CompleteLeadTests(unittest.TestCase):
|
|
|
|
|
@classmethod
|
|
|
|
|
def setUpClass(cls):
|
|
|
|
|
cls.references = references.load_library(pathlib.Path('data/lead_phrases'))
|
|
|
|
|
cls.source = solo_fixture.song(count=8, chords=('Gm7', 'C7', 'FM7', 'BbM7', 'Em7b5', 'A7', 'Dm7', 'Dm7'))
|
|
|
|
|
cls.context = realization.SearchContext()
|
|
|
|
|
cls.result = cls.compose(cls.context)
|
|
|
|
|
@classmethod
|
|
|
|
|
def compose(cls, context, stage='elaborated'):
|
|
|
|
|
return composer.compose(analysis.analyze(cls.source), cls.source['staves'][0]['measures'], 'acoustic', F(32), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library(), references=cls.references, context=context, stage=stage)
|
|
|
|
|
def test_complete_tune_precedes_decorations_and_contains_a_return(self):
|
|
|
|
|
for phrase in self.result.phrases:
|
|
|
|
|
self.assertTrue(phrase.draft.lead_identity)
|
|
|
|
|
self.assertTrue(phrase.draft.correspondence)
|
|
|
|
|
self.assertTrue(drafts.contract_valid(phrase.initial, phrase.initial.notes)[0])
|
|
|
|
|
self.assertTrue(drafts.contract_valid(phrase.initial, realization.emitted_notes(phrase))[0])
|
|
|
|
|
source = {n.event_id: n for n in phrase.initial.notes}
|
|
|
|
|
for a, b in phrase.draft.correspondence:
|
|
|
|
|
self.assertEqual(source[a].end - source[a].start, source[b].end - source[b].start)
|
|
|
|
|
self.assertNotIn('diminish', [t.operation for t in phrase.draft.transformations])
|
|
|
|
|
for route in phrase.routes:
|
|
|
|
|
run = peak = 0
|
|
|
|
|
end = None
|
|
|
|
|
for note in route.notes:
|
|
|
|
|
eighth = note.end-note.start == F(1,2)
|
|
|
|
|
run = run+1 if eighth and end == note.start else int(eighth)
|
|
|
|
|
peak = max(peak, run)
|
|
|
|
|
end = note.end
|
|
|
|
|
self.assertGreaterEqual(peak, 4)
|
|
|
|
|
composer.validate(self.result)
|
|
|
|
|
self.assertTrue(all(1 <= n.position.fret <= 12 for n in self.result.notes()))
|
|
|
|
|
def test_same_inputs_and_cache_preserve_music(self):
|
|
|
|
|
second = self.compose(self.context)
|
|
|
|
|
self.assertEqual(self.result.notes(), second.notes())
|
|
|
|
|
self.assertGreater(self.context.diagnostics['cache_hits'], 0)
|
|
|
|
|
def test_lead_evaluation_is_independent_of_computational_partitions(self):
|
|
|
|
|
phrase = self.result.phrases[0]
|
|
|
|
|
whole = phrase.initial.notes
|
|
|
|
|
split = []
|
|
|
|
|
for note in whole:
|
|
|
|
|
cuts = sorted({note.start,note.end} | {F(i,2) for i in range(64) if note.start < F(i,2) < note.end})
|
|
|
|
|
split.extend(dataclasses.replace(note,start=a,end=b,continuation=i>0) for i,(a,b) in enumerate(zip(cuts,cuts[1:])))
|
|
|
|
|
harmony = harmonic.Harmony(self.result.choices)
|
|
|
|
|
self.assertEqual(harmony.describe(whole),harmony.describe(split))
|
|
|
|
|
self.assertEqual(scoring.features(phrase.plan,whole,self.result.choices),scoring.features(phrase.plan,split,self.result.choices))
|
|
|
|
|
self.assertTrue(drafts.contract_valid(phrase.initial,split)[0])
|
|
|
|
|
def test_activity_and_occupancy_are_ranges(self):
|
|
|
|
|
plan = dataclasses.replace(fixture.plan(), start=F(), end=F(4), release=F(), treatment='lead', passages=())
|
|
|
|
|
choices = passage_fixture.choices(('C7',))
|
|
|
|
|
for count in (4, 5, 6):
|
|
|
|
|
notes = tuple(fixture.note(F(4*i, count), F(4*(i+1), count), 'e') for i in range(count))
|
|
|
|
|
values = scoring.features(plan, notes, choices)
|
|
|
|
|
self.assertEqual(values['activity'], 0)
|
|
|
|
|
self.assertEqual(values['occupancy'], 0)
|
|
|
|
|
self.assertFalse(scoring.feasible(plan, (), choices, F(4)))
|
|
|
|
|
def test_new_identity_is_a_joint_rhythm_and_pitch_object(self):
|
|
|
|
|
identities = leads.identities(self.references, types.ContinuationState(''))
|
|
|
|
|
self.assertEqual(len(identities), 12)
|
|
|
|
|
self.assertGreater(len({i.durations for i in identities}), 1)
|
|
|
|
|
self.assertGreater(len({i.degrees for i in identities}), 1)
|
|
|
|
|
for identity in identities:
|
|
|
|
|
self.assertEqual(len(identity.degrees), len(identity.durations))
|
|
|
|
|
self.assertEqual(sum(identity.durations), 4)
|
|
|
|
|
def test_invented_material_can_be_replaced_but_committed_hook_is_preserved(self):
|
|
|
|
|
phrase = self.result.phrases[0]
|
|
|
|
|
changed = tuple(n for n in phrase.initial.notes if n.event_id != phrase.draft.correspondence[0][1])
|
|
|
|
|
self.assertFalse(drafts.contract_valid(phrase.initial, changed)[0])
|
|
|
|
|
def test_missing_harmony_remains_space_with_lead_generation_enabled(self):
|
|
|
|
|
document = solo_fixture.song(count=4, chords=())
|
|
|
|
|
solo = composer.compose(analysis.analyze(document), document['staves'][0]['measures'], 'acoustic', F(16), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library(), references=self.references)
|
|
|
|
|
self.assertFalse(solo.notes())
|
|
|
|
|
composer.validate(solo)
|
|
|
|
|
def test_vocal_pickup_cutoff_does_not_compress_a_full_ending(self):
|
|
|
|
|
solo = composer.compose(analysis.analyze(self.source), self.source['staves'][0]['measures'], 'electric', F(29), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library(), references=self.references)
|
|
|
|
|
composer.validate(solo)
|
|
|
|
|
self.assertTrue(all(n.end <= 29 and n.end-n.start >= F(1,2) for n in solo.notes()))
|
|
|
|
|
tail = [n for n in solo.notes() if n.start >= 28]
|
|
|
|
|
self.assertEqual([(n.start,n.end) for n in tail], [(28,29)])
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
unittest.main()
|