170 lines
13 KiB
Python
170 lines
13 KiB
Python
import dataclasses
|
|
import fractions
|
|
import pathlib
|
|
import unittest
|
|
|
|
import generator.harmony
|
|
import generator.model
|
|
import generator.rhythm
|
|
import generator.solo.planning
|
|
import generator.solo.analysis
|
|
import generator.solo.composer
|
|
import generator.solo.placement
|
|
import generator.solo.realization
|
|
import generator.solo.scoring
|
|
import generator.solo.types
|
|
import generator.solo.sequences
|
|
import generator.fretboard
|
|
import generator.scales.selection
|
|
import generator.scales.timeline
|
|
import tests.scale_fixtures
|
|
import tests.solo_fixtures
|
|
import tests.rhythm_fixtures
|
|
import tests.test_solo
|
|
|
|
def choices(root='C', fifths=0, end=8):
|
|
scale = tests.scale_fixtures.library().scale('major', 1, root)
|
|
span = generator.scales.timeline.ChordSpan(fractions.Fraction(), fractions.Fraction(end), 1, '1', fractions.Fraction(1), generator.model.key_from_fifths(fifths), generator.harmony.parse_symbol(root + 'M7'))
|
|
return tuple([generator.scales.selection.Choice(span, scale, 'fixture')])
|
|
|
|
def plan(intent='active'):
|
|
motif = generator.solo.planning.new_motif(tests.solo_fixtures.library(), fractions.Fraction(4))
|
|
passage = generator.solo.types.ActivitySpan(fractions.Fraction(), fractions.Fraction(8), intent, fractions.Fraction(1) if intent == 'active' else fractions.Fraction(1, 8) if intent == 'sustained_arrival' else fractions.Fraction(), fractions.Fraction(3, 4) if intent == 'active' else fractions.Fraction(1) if intent == 'sustained_arrival' else fractions.Fraction())
|
|
return generator.solo.types.PhrasePlan('fixture', passage.start, passage.end, motif, 'statement', 'thematic', None, (59, 75), passage.activity, fractions.Fraction(), (passage,))
|
|
|
|
def note(start, end, letter='g', alter=0, octave=4, **kwargs):
|
|
return generator.solo.types.Note(fractions.Fraction(start), fractions.Fraction(end), generator.solo.types.Pitch(letter, fractions.Fraction(alter), octave), **kwargs)
|
|
|
|
class ScoringTests(unittest.TestCase):
|
|
def test_silence_is_an_intent_not_a_register_advantage(self):
|
|
for root, fifths, pitch in (('C', 0, ('g', 0)), ('Db', -5, ('a', -1))):
|
|
harmony = choices(root, fifths)
|
|
passage = plan()
|
|
notes = tuple(note(i, i + 1, *pitch) for i in range(8))
|
|
self.assertFalse(generator.solo.scoring.feasible(passage, (), harmony, 8))
|
|
self.assertTrue(generator.solo.scoring.feasible(passage, notes, harmony, 8))
|
|
sounded = generator.solo.scoring.value(generator.solo.scoring.features(passage, notes, harmony))
|
|
silent = generator.solo.scoring.value(generator.solo.scoring.features(passage, (), harmony))
|
|
self.assertLess(sounded, silent)
|
|
self.assertEqual(generator.solo.scoring.features(passage, notes, harmony)['register'], 0)
|
|
def test_breath_sustain_and_activity_have_different_obligations(self):
|
|
harmony = choices()
|
|
self.assertTrue(generator.solo.scoring.feasible(plan('space'), (), harmony, 8))
|
|
self.assertFalse(generator.solo.scoring.feasible(plan('space'), (note(0, 8),), harmony, 8))
|
|
sustained = generator.solo.scoring.features(plan('sustained_arrival'), (note(0, 8),), harmony)
|
|
active = generator.solo.scoring.features(plan(), (note(0, 8),), harmony)
|
|
self.assertEqual(sustained['occupancy'], 0)
|
|
self.assertEqual(sustained['activity'], 0)
|
|
self.assertGreater(active['activity'], sustained['activity'])
|
|
self.assertTrue(generator.solo.scoring.feasible(plan(), (), harmony, 4))
|
|
def test_partition_and_ties_do_not_change_musical_evaluation(self):
|
|
whole = (note(0, 4), note(4, 8, 'a'))
|
|
split = (note(0, 2), note(2, 4, continuation=True), note(4, 6, 'a'), note(6, 8, 'a', continuation=True))
|
|
self.assertEqual(generator.solo.scoring.features(plan(), whole, choices()), generator.solo.scoring.features(plan(), split, choices()))
|
|
self.assertEqual(len(generator.solo.types.sounding_notes(split)), 2)
|
|
self.assertEqual(len(generator.solo.types.sounding_notes((note(0, 2), note(2, 4)))), 2)
|
|
def test_transposition_moves_corridor_and_notes_together(self):
|
|
original = (note(0, 2, 'c'), note(2, 4, 'e'), note(4, 8, 'g'))
|
|
moved = (note(0, 2, 'd'), note(2, 4, 'f', 1), note(4, 8, 'a'))
|
|
shifted = dataclasses.replace(plan(), register=(61, 77))
|
|
self.assertEqual(generator.solo.scoring.features(plan(), original, choices()), generator.solo.scoring.features(shifted, moved, choices('D', 2)))
|
|
def test_no_harmony_removes_activity_obligations(self):
|
|
harmony = tuple(dataclasses.replace(c, scale=None, span=dataclasses.replace(c.span, chord=None)) for c in choices())
|
|
self.assertTrue(generator.solo.scoring.feasible(plan(), (), harmony, 8))
|
|
self.assertEqual(generator.solo.scoring.value(generator.solo.scoring.features(plan(), (), harmony)), 0)
|
|
|
|
class DevelopmentTests(unittest.TestCase):
|
|
def test_connected_motion_can_cross_a_bar_and_arrive_on_a_sustain(self):
|
|
source = tests.test_solo.song(count=4, chords=('CM7',))
|
|
passage = dataclasses.replace(plan(), end=fractions.Fraction(16), treatment='contour_reference', passages=(generator.solo.types.ActivitySpan(0, 8, 'active', fractions.Fraction(2), fractions.Fraction(1)), generator.solo.types.ActivitySpan(8, 16, 'sustained_arrival', fractions.Fraction(1, 8), fractions.Fraction(1))), target=generator.solo.types.TargetEvent(fractions.Fraction(8), fractions.Fraction(8), frozenset({4}), 'CM7', 'fixture arrival'))
|
|
phrase = generator.solo.realization.compose(passage, source['staves'][0]['measures'], choices(end=16), tests.solo_fixtures.library(), generator.rhythm.load_library(pathlib.Path('data/rhythms')), fractions.Fraction(120))
|
|
notes = generator.solo.composer.phrase_notes(phrase)
|
|
self.assertGreaterEqual(sum(n.start < 8 for n in notes), 12)
|
|
# Continuity also includes a quarter-note arrival; the new policy does
|
|
# not force every successful crossing to be an eighth-note pair.
|
|
self.assertTrue(any(n.start < 4 < n.end for n in notes) or any(a.end == b.start == 4 for a, b in zip(notes, notes[1:])))
|
|
self.assertTrue(any(n.start == 8 and n.pitch.number() % 12 == 4 and n.end - n.start >= 4 for n in notes))
|
|
self.assertEqual(notes[-1].end, 16)
|
|
def test_exact_quote_cannot_silently_fragment_at_no_harmony(self):
|
|
source = tests.test_solo.song(count=2, chords=('CM7',))
|
|
passage = dataclasses.replace(plan(), treatment='exact_quote')
|
|
harmony = tuple(dataclasses.replace(c, scale=None, span=dataclasses.replace(c.span, chord=None)) for c in choices())
|
|
with self.assertRaises(generator.solo.realization.SearchExhausted):
|
|
generator.solo.realization.compose(passage, source['staves'][0]['measures'], harmony, tests.solo_fixtures.library(), tests.rhythm_fixtures.library(), fractions.Fraction(120))
|
|
def test_dense_transformed_motif_can_fragment_at_no_harmony(self):
|
|
harmony = choices()
|
|
active = dataclasses.replace(harmony[0], span=dataclasses.replace(harmony[0].span, end=fractions.Fraction(3)))
|
|
silence = dataclasses.replace(harmony[0], scale=None, span=dataclasses.replace(harmony[0].span, start=fractions.Fraction(3), chord=None))
|
|
passage = dataclasses.replace(plan(), treatment='contour_reference')
|
|
spans = tuple((fractions.Fraction(i, 2), fractions.Fraction(i + 1, 2)) for i in range(8))
|
|
cell = generator.solo.realization.Cell(fractions.Fraction(), fractions.Fraction(4), (('eighths', spans),), passage.motif)
|
|
candidates = generator.solo.realization.candidates(cell, passage, (active, silence), tests.solo_fixtures.library())
|
|
self.assertTrue(candidates)
|
|
self.assertTrue(all(len(c.notes) == 6 and all(n.end <= 3 for n in c.notes) for c in candidates))
|
|
def test_dense_rhythms_and_chained_paths_retain_authored_timing(self):
|
|
# The real library's fully sounding eighths must survive retrieval.
|
|
rhythms = generator.rhythm.load_library(pathlib.Path('data/rhythms'))
|
|
patterns = generator.solo.realization.rhythm_patterns(rhythms, '4/4', fractions.Fraction(4), fractions.Fraction(2))
|
|
self.assertTrue(any(len(spans) == 8 and all(b - a == fractions.Fraction(1, 2) for a, b in spans) for _, spans in patterns))
|
|
sequences = tests.solo_fixtures.library()
|
|
lookup = {s.id: s for values in sequences.paths.values() for s in values}
|
|
paths = sequences.retrieve(8, 5, 1)
|
|
self.assertTrue(paths)
|
|
for path in paths:
|
|
self.assertEqual(len(path.offsets()), 8)
|
|
self.assertEqual(path.steps, tuple(step for source in path.sources for step in lookup[source].steps))
|
|
self.assertGreater(len({generator.solo.sequences.family(p.steps) for p in paths}), 2)
|
|
def test_retrieval_uses_paths_beyond_the_old_global_prefix(self):
|
|
library = tests.solo_fixtures.library()
|
|
original = {s.id for s in library.connecting(4)[:16]}
|
|
chosen = library.retrieve(4, 6, 1)
|
|
self.assertTrue(any(set(p.sources) - original for p in chosen))
|
|
self.assertTrue(any(p.offsets()[-1] == 6 for p in chosen))
|
|
self.assertEqual(chosen, library.retrieve(4, 6, 1))
|
|
def test_transformation_contracts_protect_different_features(self):
|
|
motif = generator.solo.types.Motif('source', (note(0, 1, 'c'), note(1, 2, 'd'), note(2, 3, 'e'), note(3, 4, 'c')), fractions.Fraction(4), ())
|
|
adapted = (note(0, 1, 'e'), note(1, 2, 'f'), note(2, 3, 'g'), note(3, 4, 'e'))
|
|
unrelated = (note(0, 1, 'g'), note(1, 2, 'f'), note(2, 3, 'e'), note(3, 4, 'g'))
|
|
self.assertEqual(generator.solo.scoring.motif_fidelity(unrelated, motif, 'rhythmic_reference'), 0)
|
|
self.assertLess(generator.solo.scoring.motif_fidelity(adapted, motif, 'contour_reference'), generator.solo.scoring.motif_fidelity(unrelated, motif, 'contour_reference'))
|
|
changed_ending = (*adapted[:3], note(3, 4, 'b'))
|
|
self.assertEqual(generator.solo.scoring.motif_fidelity(adapted, motif, 'opening_reference'), generator.solo.scoring.motif_fidelity(changed_ending, motif, 'opening_reference'))
|
|
def test_contextual_invention_and_identity_ignore_titles(self):
|
|
source = tests.test_solo.song(count=4, chords=('CM7',))
|
|
analysis = generator.solo.analysis.analyze(source)
|
|
seed = generator.solo.analysis.identity(analysis, source['staves'][0]['measures'], 'electric')
|
|
source['metadata']['title'] = 'Unrelated title'
|
|
self.assertEqual(seed, generator.solo.analysis.identity(generator.solo.analysis.analyze(source), source['staves'][0]['measures'], 'electric'))
|
|
up = generator.solo.planning.new_motif(tests.solo_fixtures.library(), fractions.Fraction(4), choices=choices(), seed=seed)
|
|
changed = generator.solo.planning.new_motif(tests.solo_fixtures.library(), fractions.Fraction(4), choices=choices('D', 2), seed=seed, variant=1)
|
|
self.assertNotEqual(up.contour(), changed.contour())
|
|
def test_continuation_keeps_fret_position_and_one_attack(self):
|
|
previous = note(0, 4, position=generator.fretboard.TabPosition(2, 8, 67))
|
|
following = note(4, 6, continuation=True)
|
|
placed = generator.solo.placement.place((following,), fractions.Fraction(120), previous)
|
|
self.assertEqual(placed[0], 0)
|
|
self.assertEqual(placed[1][0].position, previous.position)
|
|
joined = generator.solo.types.sounding_notes((previous, *placed[1]))
|
|
self.assertEqual([(n.start, n.end) for n in joined], [(0, 6)])
|
|
def test_same_ending_does_not_merge_different_gestures(self):
|
|
passage = plan()
|
|
left = (note(0, 2, 'c'), note(2, 4, 'e'), note(4, 8, 'g'))
|
|
right = (note(0, 1, 'a'), note(1, 4, 'c'), note(4, 8, 'g'))
|
|
self.assertNotEqual(generator.solo.types.ContinuationState(passage.id, left, last=left[-1]), generator.solo.types.ContinuationState(passage.id, right, last=right[-1]))
|
|
def test_lookahead_can_choose_a_lower_ranked_statement(self):
|
|
first = dataclasses.replace(plan(), end=fractions.Fraction(4), ending='connected')
|
|
second = dataclasses.replace(plan(), id='answer', start=fractions.Fraction(4), relationship='answer')
|
|
high = generator.solo.types.Phrase(first, (generator.solo.types.Decision(0, 4, 'fixture', 'invented', (note(0, 4, 'g', octave=5),)),), 0)
|
|
low = generator.solo.types.Phrase(first, (generator.solo.types.Decision(0, 4, 'fixture', 'invented', (note(0, 4, 'c'),)),), 0.1)
|
|
answer = generator.solo.types.Phrase(second, (generator.solo.types.Decision(4, 8, 'fixture', 'answer', (note(4, 8, 'd'),)),), 0)
|
|
selected = generator.solo.composer.choose_phrases((first, second), lambda p, previous: [high, low] if p.id == first.id else [answer])
|
|
self.assertEqual(selected[0], low)
|
|
def test_committed_statement_still_affects_final_answer_selection(self):
|
|
first = dataclasses.replace(plan(), end=fractions.Fraction(4), ending='connected')
|
|
second = dataclasses.replace(plan(), id='answer', start=fractions.Fraction(4), relationship='answer')
|
|
statement = generator.solo.types.Phrase(first, (generator.solo.types.Decision(0, 4, 'fixture', 'invented', (note(0, 4, 'c'),)),), 0)
|
|
far = generator.solo.types.Phrase(second, (generator.solo.types.Decision(4, 8, 'fixture', 'answer', (note(4, 8, 'g', octave=5),)),), 0)
|
|
near = generator.solo.types.Phrase(second, (generator.solo.types.Decision(4, 8, 'fixture', 'answer', (note(4, 8, 'd'),)),), 0.1)
|
|
selected = generator.solo.composer.choose_phrases((first, second), lambda p, previous: [statement] if p.id == first.id else [far, near])
|
|
self.assertEqual(selected[-1], near)
|