Comping Gen

This commit is contained in:
khodowany
2026-10-06 22:13:50 -05:00
parent 2f21ec1f16
commit 125aaa1637
13 changed files with 213 additions and 44 deletions
+57 -2
View File
@@ -43,7 +43,7 @@ class LayeredComping(unittest.TestCase):
for part, candidates in choices.items():
self.assertTrue(expected <= {dict(c.families)['pattern'] for c in candidates})
for c in candidates:
generator.comping_rhythm.validate_events(c.events, part)
generator.comping_rhythm.validate_events(c.events, part, (4, 4))
for start in (0, 4):
self.assertLessEqual(sum(start <= e.start < start + 4 for e in c.events), 3 if part == 'acoustic' else 2)
self.assertAlmostEqual(c.cost, sum(dict(c.contributions).values()))
@@ -51,7 +51,7 @@ class LayeredComping(unittest.TestCase):
e = {tuple(e.start for e in c.events) for c in choices['electric']}
self.assertFalse(a & e)
self.assertTrue(any(c.events[0].start > 0 for c in choices['acoustic']))
self.assertTrue(any(c.events[0].lane == 'upper' for c in choices['acoustic']))
self.assertTrue(all(c.events[0].lane in ('bass', 'combined') for c in choices['acoustic'] if c.events))
self.assertTrue(any(e.end - e.start > 1 for c in choices['electric'] for e in c.events))
def test_separate_short_attacks_and_only_synchronized_long_combinations(self):
F = fractions.Fraction
@@ -61,6 +61,61 @@ class LayeredComping(unittest.TestCase):
for events in ((attack('combined', F(0), F(1)),), (attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(1))), (attack('bass', F(0), F(2)),)):
with self.assertRaises(ValueError):
generator.comping_rhythm.validate_events(events, 'acoustic')
def test_bass_first_reassigns_layers_without_moving_or_adding_attacks(self):
source = tests.solo_fixtures.song(count=2, chords=('C7',))
flat = generator.comping_rhythm.flatten(tuple((m, None, i) for i, m in enumerate(source['staves'][0]['measures'])))
patterns = self.resources['accompaniment']['instruments']['acoustic']['patterns']
before = copy.deepcopy(patterns)
for pattern in patterns:
if pattern['lengths'] != ['4', '4']:
continue
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic')
self.assertEqual([(e.start, e.end - e.start) for e in events], [(fractions.Fraction(r['onset']), fractions.Fraction(r['duration'])) for r in pattern['events']])
for start in (0, 4):
local = [e for e in events if start <= e.start < start + 4]
if local:
self.assertIn(local[0].lane, ('bass', 'combined'))
self.assertEqual(before, patterns)
def test_missing_harmony_reassigns_first_survivor_before_anticipation(self):
m = copy.deepcopy(self.source['staves'][0]['measures'][0])
m['harmonies'] = [{'id': 'absent', 'kind': 'none', 'onset': generator.model.rational(0)},
{**generator.harmony.parse_symbol('C7'), 'id': 'current', 'onset': generator.model.rational(1)},
{**generator.harmony.parse_symbol('FM7'), 'id': 'next', 'onset': generator.model.rational(2)}]
pattern = {'lengths': ['4'], 'events': [
{'onset': '0', 'duration': '1', 'layer': 'bass'},
{'onset': '3/2', 'duration': '1', 'layer': 'upper', 'anticipate': True},
{'onset': '7/2', 'duration': '1/2', 'layer': 'bass'}]}
events, changes = generator.comping_rhythm.fit(pattern, m, None, 'acoustic', anticipate=True)
self.assertEqual([(e.lane, e.start, e.end, e.harmonic_time) for e in events], [('bass', fractions.Fraction(3, 2), 2, None), ('upper', fractions.Fraction(7, 2), 4, None)])
self.assertIn('layer_assignment', {kind for kind, _ in changes})
limited, _ = generator.comping_rhythm.fit(pattern, m, None, 'acoustic', anticipate=True, limit=fractions.Fraction(2))
self.assertEqual(limited, (events[0],))
def test_combined_first_attack_and_carried_hold_do_not_require_reattacks(self):
attack, F = generator.comping_rhythm.Attack, fractions.Fraction
valid = (attack('combined', F(3), F(5)), attack('bass', F(6), F(7)))
generator.comping_rhythm.validate_events(valid, 'acoustic', (4, 4))
generator.comping_rhythm.validate_events(valid[:1], 'acoustic', (4, 4))
invalid = (valid[0], attack('upper', F(6), F(7)))
with self.assertRaisesRegex(ValueError, 'first acoustic attack'):
generator.comping_rhythm.validate_events(invalid, 'acoustic', (4, 4))
def test_completed_score_rejects_upper_first_even_after_a_rest(self):
source = tests.solo_fixtures.song(count=2, chords=('C7',))
pattern = {'id': 'test.delayed', 'family': 'call_response', 'lengths': ['4', '4'], 'events': [
{'onset': '1', 'duration': '1/2', 'layer': 'upper'}, {'onset': '2', 'duration': '1', 'layer': 'bass'},
{'onset': '5', 'duration': '1', 'layer': 'combined'}]}
# The final combined event must be longer than one beat.
pattern['events'][-1]['duration'] = '2'
resources = copy.deepcopy(self.resources)
resources['accompaniment']['instruments']['acoustic']['patterns'] = [pattern]
with generator.vocabulary.using(resources, 'acoustic_accompaniment'):
canonical = generator.comping.Canonical(source, 'acoustic', 'bass-first')
measures = source['staves'][0]['measures']
source['staves'][0]['measures'] = [canonical(m, None, i) for i, m in enumerate(measures)]
generator.acoustic.validate_comping(source)
first = source['staves'][0]['measures'][0]
first['voices'] = [v for v in first['voices'] if v['id'] != 'bass']
with self.assertRaisesRegex(ValueError, 'first acoustic attack'):
generator.acoustic.validate_comping(source)
def test_harmonic_changes_clip_direct_holds_without_extra_attacks(self):
source = copy.deepcopy(self.source)
for m in source['staves'][0]['measures'][:2]:
+27 -3
View File
@@ -19,6 +19,7 @@ import generator.score
import generator.vocabulary
import tests.solo_fixtures
import tests.test_acoustic_voicing
import tests.test_voicing
def options(symbol, part):
return generator.comping.voicings(generator.comping.semantic_key(generator.harmony.parse_symbol(symbol)), part)
@@ -30,6 +31,29 @@ def path_cost(path, costs, part, left=(), right=(), close=False):
return tuple(total)
class CompingVoicings(unittest.TestCase):
def test_electric_prefers_lower_grip_but_can_avoid_a_large_shift(self):
low, high = (tests.test_voicing.grip(f) for f in ((2, 3, 1, 3), (10, 12, 11, 12)))
key = generator.comping.semantic_key(generator.harmony.parse_symbol('C7'))
self.assertTrue(all(v in options('C7', 'electric') for v in (low, high)))
costs = {v: generator.comping.local_cost('electric', key, v, 60) for v in (low, high)}
self.assertEqual(costs[low][:2], costs[high][:2])
self.assertLess(costs[low], costs[high])
path, _ = generator.comping.select_grips(((low, high),), (costs,), 'electric', 'lower')
self.assertEqual(path, (low,))
path, _ = generator.comping.select_grips(((low, high),), (costs,), 'electric', 'lower', left=high, right=high)
self.assertEqual(path, (high,))
def test_electric_voicing_policy_does_not_reseed_rhythm(self):
source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=4, chords=('C7', 'FM7', 'Dm7', 'G7')))
root = pathlib.Path('data')
current = generator.production.generate_role(source, 'electric_accompaniment', root)
with tempfile.TemporaryDirectory() as cache:
settings = {'voicing': {'register_weight': 0, 'high_position_weight': 0}}
other = generator.production.generate_role(source, 'electric_accompaniment', root, settings, cache)
warm = generator.production.generate_role(source, 'electric_accompaniment', root, settings, cache)
self.assertTrue(warm['cache_hit'])
self.assertEqual(other['document'], warm['document'])
self.assertNotEqual(current['trace']['cache_key'], other['trace']['cache_key'])
self.assertEqual(current['details']['accompaniment_rhythm'], other['details']['accompaniment_rhythm'])
def test_lower_progression_can_outweigh_extra_boundary_travel(self):
symbols = ('Em7b5', 'A7b9', 'Dm7', 'G7', 'Cm7', 'F7')
low = tuple(tests.test_acoustic_voicing.voicing(f) for f in ((7, 8, 7, 8), (7, 8, 6, 8), (5, 7, 5, 6), (5, 5, 4, 6), (3, 5, 3, 4), (3, 3, 2, 4)))
@@ -61,8 +85,8 @@ class CompingVoicings(unittest.TestCase):
low = tests.test_acoustic_voicing.voicing((7, 8, 7, 8))
resources = generator.vocabulary.load()
key = generator.comping.semantic_key(generator.harmony.parse_symbol('Em7b5'))
self.assertEqual(generator.comping.acoustic_preferences(high, 53), (fractions.Fraction(39, 4), fractions.Fraction(11, 4)))
self.assertEqual(generator.comping.acoustic_preferences(low, 53)[1], 0)
self.assertEqual(generator.comping.voicing_preferences('acoustic', high, 53), (fractions.Fraction(39, 4), fractions.Fraction(11, 4)))
self.assertEqual(generator.comping.voicing_preferences('acoustic', low, 53)[1], 0)
baseline = generator.comping.local_cost('acoustic', key, high, 53)
for field, expected in (('register_weight', fractions.Fraction(39, 4)), ('high_position_weight', fractions.Fraction(11, 4)), ('comfortable_max_fret', -1)):
policy = dict(resources['roles']['roles']['acoustic_accompaniment']['voicing'])
@@ -159,7 +183,7 @@ class CompingVoicings(unittest.TestCase):
def test_complete_path_beats_greedy_and_matches_exhaustive_search(self):
for part in ('acoustic', 'electric'):
with self.subTest(part=part):
chords = ('C', 'Dm7', 'A7b9') if part == 'acoustic' else ('C', 'C', 'Dm7')
chords = ('C', 'Dm7', 'A7b9') if part == 'acoustic' else ('C', 'FM7', 'C')
layers = tuple(options(s, part) for s in chords)
costs = tuple({v: generator.comping.local_cost(part, generator.comping.semantic_key(generator.harmony.parse_symbol(s)), v, 55) for v in layer} for s, layer in zip(chords, layers))
path, cost = generator.comping.select_grips(layers, costs, part, 'test')
+25
View File
@@ -9,6 +9,7 @@ import generator.harmony
import generator.model
import generator.musicxml
import generator.score
import generator.vocabulary
import tests.test_musicxml
import tests.test_parts
@@ -23,6 +24,30 @@ def ranked(symbol, melody=(), profile='electric', prune=False):
return sorted(candidates, key=lambda shape: (generator.guitar.voicing_cost(shape, profile, requirements.allowed, melody), shape))
class VoicingTests(unittest.TestCase):
def test_melody_preferences_cannot_purchase_a_lower_priority_string(self):
resources = generator.vocabulary.load()
with generator.vocabulary.using(resources, 'electric_melody', {'voicing': {'register_weight': 1000, 'high_position_weight': 1000}}):
for symbol, melody in (('C7', (67,)), ('FM7', (69,)), ('Cm7', (70,))):
candidates = ranked(symbol, melody)
primary = [c for c in candidates if generator.fretboard.span_penalty(c) == generator.fretboard.span_penalty(candidates[0])]
self.assertEqual(min(p.string_number for p in candidates[0] if p.midi in melody), min(p.string_number for c in primary for p in c if p.midi in melody))
self.assertTrue(set(melody) <= {p.midi for p in candidates[0]})
def test_melody_preference_cost_and_pruning_caches_include_role_settings(self):
resources = generator.vocabulary.load()
shape = grip((10, 12, 11, 12))
original = generator.guitar.voicing_cost(shape, 'electric', melody=(76,))
preference = generator.guitar.voicing_preferences(shape, generator.guitar.voicing_preference('electric_melody'))
with generator.vocabulary.using(resources, 'electric_melody', {'voicing': {'register_weight': 0, 'high_position_weight': 0}}):
plain = generator.guitar.voicing_cost(shape, 'electric', melody=(76,))
self.assertEqual(original.effort - plain.effort, sum(preference))
self.assertEqual(original[:6], plain[:6])
self.assertEqual(original, generator.guitar.voicing_cost(shape, 'electric', melody=(76,)))
for center in (45, 76, 45):
with generator.vocabulary.using(resources, 'electric_melody', {'center': center}):
best = {}
for candidate in ranked('C7', (72,)):
best.setdefault(generator.guitar.position_center(candidate), candidate)
self.assertEqual(set(ranked('C7', (72,), prune=True)), set(best.values()))
def test_electric_chords_use_the_shared_four_fret_limit(self):
for prune in (False, True):
candidates = ranked('CM7', prune=prune)