Files
product-vamp/tests/test_solo_development.py
T
khodowany 75655786e9
ci / checks (push) Successful in 1h9m55s
Soloing
2026-10-01 08:35:12 -05:00

168 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.Passage(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.Passage(0, 8, 'active', fractions.Fraction(2), fractions.Fraction(1)), generator.solo.types.Passage(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)
self.assertTrue(any(a.end == b.start == 4 and a.end - a.start == b.end - b.start == fractions.Fraction(1, 2) 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.realization.state_identity((0, (), left[-1], 0, left), passage), generator.solo.realization.state_identity((0, (), right[-1], 0, right), passage))
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)