"""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)) self.assertEqual(len(result), 1) self.assertEqual([n.pitch.number() for n in result[0].notes], [67, 65, 64]) self.assertEqual(result[0].sources, ('descending',)) self.assertEqual(result[0].destination, template[-1]) self.assertTrue(harmony.evaluate(result[0].notes).valid) 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')