This commit is contained in:
khodowany
2026-10-01 16:41:18 -05:00
parent 28fc67fba1
commit f5861fcdf6
22 changed files with 1473 additions and 122 deletions
+3 -2
View File
@@ -89,7 +89,8 @@ class DraftRepresentationTests(unittest.TestCase):
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 == 16 for a in endings))
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):
@@ -219,7 +220,7 @@ class DraftCompositionTests(unittest.TestCase):
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, 4 if edit.kind != 'elaboration' else phrase.plan.end - phrase.plan.start)
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']
+129
View File
@@ -0,0 +1,129 @@
"""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.acoustic_activity, policy.DEFAULT.electric_activity), (1.125, 1.375))
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')