244 lines
18 KiB
Python
244 lines
18 KiB
Python
"""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).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):
|
|
first, second = self.solo.phrases
|
|
self.assertEqual(first.state.home.id, second.state.home.id)
|
|
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)
|