+6
-2
@@ -117,13 +117,17 @@ class SoloTests(unittest.TestCase):
|
||||
result = compose(source)
|
||||
self.assertLessEqual(len({p.plan.motif.id for p in result.phrases}), 3)
|
||||
self.assertEqual(result.phrases[0].plan.motif.id, result.phrases[1].plan.motif.id)
|
||||
first = result.phrases[0]
|
||||
self.assertFalse(any(d.treatment.startswith('quote') for p in result.phrases for d in p.decisions))
|
||||
original = result.phrases[0].plan
|
||||
explicit = dataclasses.replace(original, treatment='exact_quote', target=None)
|
||||
first = generator.solo.realization.compose(explicit, source['staves'][0]['measures'], result.choices, tests.solo_fixtures.library(), tests.rhythm_fixtures.library(), result.tempo)
|
||||
quote = next(d for d in first.decisions if d.treatment.startswith('quote'))
|
||||
self.assertEqual(len(quote.notes), len(first.plan.motif.notes))
|
||||
shifts = {a.pitch.number() - b.pitch.number() for a, b in zip(quote.notes, first.plan.motif.notes)}
|
||||
self.assertEqual(len(shifts), 1)
|
||||
self.assertEqual(next(iter(shifts)) % 12, 0)
|
||||
self.assertEqual([(n.start, n.end) for n in quote.notes], [(first.plan.start + n.start, first.plan.start + n.end) for n in first.plan.motif.notes])
|
||||
result = dataclasses.replace(result, phrases=(first, *result.phrases[1:]))
|
||||
report = generator.solo.report.describe(result, tests.scale_fixtures.library())
|
||||
self.assertIn('quote octave', report)
|
||||
self.assertIn('source events:', report)
|
||||
@@ -140,7 +144,7 @@ class SoloTests(unittest.TestCase):
|
||||
result = compose(source)
|
||||
self.assertTrue(result.notes())
|
||||
self.assertFalse(any(n.start < 2 and n.end > 1 for n in result.notes()))
|
||||
self.assertTrue(any(d.treatment == 'fragment' for p in result.phrases for d in p.decisions))
|
||||
self.assertTrue(any(c.scale is None and c.span.start == 1 and c.span.end == 2 for c in result.choices))
|
||||
def test_microtones_are_not_rounded_into_the_catalog(self):
|
||||
with self.assertRaisesRegex(ValueError, 'semitones'):
|
||||
generator.solo.types.Pitch('c', fractions.Fraction(1, 2), 4)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user