130 lines
8.3 KiB
Python
130 lines
8.3 KiB
Python
"""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_accepted_pair_preserves_sounding_pitch_exact_time_and_harmony(self):
|
|
reference, = references.load_library(pathlib.Path('data/lead_phrases'))
|
|
self.assertEqual((len(reference.notes), len(reference.decorated), reference.duration), (31, 36, 32))
|
|
self.assertEqual(reference.notes[0].pitch.number(), 62)
|
|
self.assertEqual(reference.notes[1].end - reference.notes[1].start, F(3, 2))
|
|
choices = list(passages.choices(tuple(chord for _, chord in reference.harmony)))
|
|
minor = tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')
|
|
choices[5] = dataclasses.replace(choices[5], scale=minor)
|
|
harmony = harmonic.Harmony(tuple(choices))
|
|
for variant in (reference.notes, reference.decorated):
|
|
self.assertTrue(harmony.evaluate(variant, complete=True).valid)
|
|
placed = generator.solo.placement.place(variant, F(120))
|
|
self.assertTrue(placed)
|
|
self.assertTrue(all(1 <= n.position.fret <= 12 for n in placed[1]))
|
|
self.assertTrue(all(m.context and m.duration in (8, 16) for m in reference.windows()))
|
|
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')
|