Fix Playability
ci / checks (push) Failing after 23m26s

This commit is contained in:
khodowany
2026-10-08 19:04:06 -05:00
parent bc7baea7c4
commit 3f63b255ac
23 changed files with 633 additions and 374 deletions
+37 -38
View File
@@ -15,21 +15,20 @@ import generator.comping
import generator.fretboard
import generator.guitar
import generator.harmony
import generator.hand_position
import generator.model
import generator.score
import generator.vocabulary
import tests.solo_fixtures
import tests.test_acoustic_voicing
import tests.test_voicing
import tests.position_fixtures
def options(symbol, part):
return generator.comping.voicings(generator.comping.semantic_key(generator.harmony.parse_symbol(symbol)), part)
def path_cost(path, costs, part, left=(), right=(), close=False):
total = [sum(costs[i][v][j] for i, v in enumerate(path)) for j in range(5)]
total[2] += sum(generator.comping.movement(part, a, b) for a, b in zip((left, *path), path))
total[2] += generator.comping.movement(part, path[-1], path[0] if close else right)
return tuple(total)
def path_cost(path, costs, part, left=(), right=(), close=False, **kwargs):
return tests.position_fixtures.cost(path, costs, (2, 3, 4), acoustic=part == 'acoustic', left=left, right=right, close=close, **kwargs)
class CompingVoicings(unittest.TestCase):
def test_electric_prefers_lower_grip_but_can_avoid_a_large_shift(self):
@@ -39,23 +38,23 @@ class CompingVoicings(unittest.TestCase):
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')
path, _, hands = 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)
path, _, hands = 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.source.SourceAnalysis.read(tests.solo_fixtures.song(count=4, chords=('C7', 'FM7', 'Dm7', 'G7')))
root = pathlib.Path('data')
current = generator.composition.generate_role(source, 'electric_accompaniment', root)
with tempfile.TemporaryDirectory() as cache:
settings = {'voicing': {'register_weight': 0, 'high_position_weight': 0}}
settings = {'voicing': {'register_weight': 0}}
other = generator.composition.generate_role(source, 'electric_accompaniment', root, settings, cache)
warm = generator.composition.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):
def test_connected_progression_uses_actual_windows_and_neighboring_grips(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)))
high = tuple(tests.test_acoustic_voicing.voicing(f, 6) for f in ((12, 12, 12, 11), (12, 11, 12, 11), (10, 10, 10, 10), (10, 9, 10, 8), (8, 8, 8, 8), (8, 7, 8, 6)))
@@ -66,12 +65,11 @@ class CompingVoicings(unittest.TestCase):
self.assertTrue(all(v in options(symbol, 'acoustic') for v in layer))
key = generator.comping.semantic_key(generator.harmony.parse_symbol(symbol))
costs.append({v: generator.comping.local_cost('acoustic', key, v, 53) for v in layer})
def travel(path):
return sum(generator.comping.movement('acoustic', a, b) for a, b in zip((left, *path), (*path, right)))
self.assertEqual((travel(low), travel(high)), (43, 35))
self.assertLess(path_cost(low, costs, 'acoustic', left, right), path_cost(high, costs, 'acoustic', left, right))
path, cost = generator.comping.select_grips(layers, costs, 'acoustic', 'lower-voicings', left=left, right=right)
self.assertEqual(path, low)
# The high path travels less from these particular neighbors; a lower
# sounding register cannot purchase extra hand movement.
self.assertGreater(path_cost(low, costs, 'acoustic', left, right)[2], path_cost(high, costs, 'acoustic', left, right)[2])
path, cost, hands = generator.comping.select_grips(layers, costs, 'acoustic', 'lower-voicings', left=left, right=right)
self.assertEqual(cost, path_cost(path, costs, 'acoustic', left, right, hands=hands))
self.assertEqual(cost, min(path_cost(p, costs, 'acoustic', left, right) for p in itertools.product(*layers)))
def test_large_round_trip_can_retain_a_high_position(self):
low = tests.test_acoustic_voicing.voicing((7, 8, 7, 8))
@@ -79,26 +77,24 @@ class CompingVoicings(unittest.TestCase):
key = generator.comping.semantic_key(generator.harmony.parse_symbol('Em7b5'))
costs = {v: generator.comping.local_cost('acoustic', key, v, 53) for v in (low, high)}
self.assertLess(costs[low], costs[high])
path, _ = generator.comping.select_grips(((low, high),), (costs,), 'acoustic', 'travel', left=high, right=high)
path, _, hands = generator.comping.select_grips(((low, high),), (costs,), 'acoustic', 'travel', left=high, right=high)
self.assertEqual(path, (high,))
def test_acoustic_preferences_are_exact_and_weights_are_operational(self):
def test_register_weight_is_an_exact_subordinate_tiebreak(self):
high = tests.test_acoustic_voicing.voicing((12, 12, 12, 11), 6)
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.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'])
policy[field] += 1
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': policy}):
cost = generator.comping.local_cost('acoustic', key, high, 53)
self.assertEqual(cost.effort - baseline.effort, expected, field)
self.assertEqual(cost.extended_grips, baseline.extended_grips)
self.assertEqual(cost.family, baseline.family)
self.assertEqual(baseline.register, fractions.Fraction(39, 4))
self.assertEqual(baseline.effort, 0)
self.assertEqual(baseline.peak_position, 0)
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': {'register_weight': 2}}):
cost = generator.comping.local_cost('acoustic', key, high, 53)
self.assertEqual(cost.register, 2 * baseline.register)
self.assertEqual(cost[:-1], baseline[:-1])
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': {'register_weight': -1}}), self.assertRaises(ValueError):
generator.comping.local_cost('acoustic', key, high, 53)
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': {'high_position_weight': 1}}), self.assertRaisesRegex(ValueError, 'unknown voicing'):
generator.comping.local_cost('acoustic', key, high, 53)
def test_production_voicing_policy_changes_cache_not_rhythm_or_electric(self):
source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=6, chords=('Em7b5', 'A7b9', 'Dm7', 'G7', 'Cm7', 'F7')))
def rhythm(result):
@@ -115,12 +111,13 @@ class CompingVoicings(unittest.TestCase):
data['roles']['acoustic_accompaniment']['voicing'] = policy
role_path.write_text(json.dumps(data))
current = generator.composition.generate_role(source, 'acoustic_accompaniment', root)
settings = {'voicing': {'register_weight': 0, 'high_position_weight': 0, 'comfortable_max_fret': 9}}
settings = {'voicing': {'register_weight': 0}}
old = generator.composition.generate_role(source, 'acoustic_accompaniment', root, settings, pathlib.Path(directory) / 'cache')
warm = generator.composition.generate_role(source, 'acoustic_accompaniment', root, settings, pathlib.Path(directory) / 'cache')
self.assertTrue(warm['cache_hit'])
self.assertEqual(old['document'], warm['document'])
self.assertNotEqual(current['document'], old['document'])
# A tie weight is not required to change a winner with better movement.
self.assertEqual(current['details']['guitar_placement']['cost']['effort'], old['details']['guitar_placement']['cost']['effort'])
self.assertNotEqual(current['trace']['cache_key'], old['trace']['cache_key'])
self.assertNotEqual(legacy['trace']['cache_key'], current['trace']['cache_key'])
self.assertEqual(legacy['document'], current['document'])
@@ -133,8 +130,10 @@ class CompingVoicings(unittest.TestCase):
for row in rows:
cost = row['voicing_cost']
self.assertEqual(cost['movement'], row['movement'])
self.assertEqual(fractions.Fraction(cost['combined']), sum(fractions.Fraction(cost[k]) for k in ('movement', 'register', 'position')))
self.assertEqual(canonical.path_cost.effort, sum(fractions.Fraction(r['voicing_cost']['combined']) for r in rows))
self.assertGreaterEqual(fractions.Fraction(cost['secondary_effort']), fractions.Fraction(cost['grip_effort']))
self.assertEqual(canonical.path_cost.peak_position, max(r['hand_position'] for r in rows))
self.assertEqual(canonical.path_cost.travel, sum(r['movement'] for r in rows))
self.assertEqual(canonical.path_cost.effort, sum(fractions.Fraction(r['voicing_cost']['secondary_effort']) for r in rows))
def test_acoustic_geometry_complete_triads_and_component_fret_limits(self):
for symbol in ('C', 'Cm', 'F#', 'Bbm', 'C7', 'Cm6', 'Em7b5', 'A7b9', 'C7/E', 'C/D'):
with self.subTest(chord=symbol):
@@ -187,21 +186,21 @@ class CompingVoicings(unittest.TestCase):
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')
path, cost, hands = generator.comping.select_grips(layers, costs, part, 'test')
greedy = []
for i, layer in enumerate(layers):
greedy.append(min(layer, key=lambda v: (path_cost((*greedy, v), costs[:i + 1], part), v)))
self.assertLess(cost, path_cost(greedy, costs, part))
self.assertLessEqual(cost, path_cost(greedy, costs, part))
self.assertEqual(cost, min(path_cost(p, costs, part) for p in itertools.product(*layers)))
self.assertEqual(cost, path_cost(path, costs, part))
self.assertEqual((path, cost), generator.comping.select_grips(tuple(tuple(reversed(l)) for l in layers), costs, part, 'test'))
self.assertEqual((path, cost, hands), generator.comping.select_grips(tuple(tuple(reversed(l)) for l in layers), costs, part, 'test'))
def test_repeat_join_and_neighbor_boundaries_participate_in_selection(self):
for part in ('acoustic', 'electric'):
layers = tuple(options(s, part) for s in ('C', 'Dm7', 'G7'))
costs = tuple({v: generator.comping.VoicingCost(generator.fretboard.span_penalty(v)) for v in layer} for layer in layers)
costs = tuple({v: generator.comping.VoicingCost(generator.fretboard.span_penalty(v), peak_position=generator.hand_position.base(v)) for v in layer} for layer in layers)
for endpoints in ({'close': True}, {'left': options('Am7', part)[0], 'right': options('FM7', part)[-1]}):
with self.subTest(part=part, endpoints=endpoints):
path, cost = generator.comping.select_grips(layers, costs, part, 'test', **endpoints)
path, cost, hands = generator.comping.select_grips(layers, costs, part, 'test', **endpoints)
self.assertEqual(cost, min(path_cost(p, costs, part, **endpoints) for p in itertools.product(*layers)))
self.assertEqual(cost, path_cost(path, costs, part, **endpoints))
def test_rest_does_not_reset_the_previous_hand_position(self):
@@ -210,7 +209,7 @@ class CompingVoicings(unittest.TestCase):
for part in ('acoustic', 'electric'):
canonical = generator.comping.Canonical(source, part, 'rest')
self.assertEqual({i for i, _, _ in canonical.order}, {0, 2})
expected = generator.comping.movement(part, canonical.order[0][2], canonical.order[1][2])
expected = generator.comping.movement(part, canonical.order[0][2], canonical.order[1][2], *canonical.hand_order[:2])
self.assertEqual(next(r['movement'] for r in canonical.report if r['source_index'] == 2), expected)
def test_harmony_variants_are_independent_of_request_order(self):
source = tests.solo_fixtures.song(count=3, chords=('C7', 'FM7', 'G7'))