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
+40 -55
View File
@@ -6,6 +6,8 @@ import unittest
import generator.arrange.generate
import generator.fretboard
import generator.guitar
import generator.hand_position
import itertools
import generator.harmony
import generator.model
import generator.musicxml
@@ -13,6 +15,7 @@ import generator.score
import generator.vocabulary
import tests.test_musicxml
import tests.test_parts
import tests.position_fixtures
def grip(frets, strings=(4, 3, 2, 1)):
tuning = dict(generator.fretboard.STANDARD_TUNING)
@@ -27,7 +30,7 @@ def ranked(symbol, melody=(), profile='electric', prune=False):
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}}):
with generator.vocabulary.using(resources, 'electric_melody', {'voicing': {'register_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])]
@@ -38,17 +41,15 @@ class VoicingTests(unittest.TestCase):
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}}):
with generator.vocabulary.using(resources, 'electric_melody', {'voicing': {'register_weight': 0}}):
plain = generator.guitar.voicing_cost(shape, 'electric', melody=(76,))
self.assertEqual(original.effort - plain.effort, sum(preference))
self.assertEqual(original.register - plain.register, preference)
self.assertEqual(original.effort, plain.effort)
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()))
self.assertEqual(set(ranked('C7', (72,), prune=True)), set(ranked('C7', (72,), prune=False)))
def test_electric_chords_use_the_shared_four_fret_limit(self):
for prune in (False, True):
candidates = ranked('CM7', prune=prune)
@@ -120,11 +121,12 @@ class VoicingTests(unittest.TestCase):
self.assertNotIn(grip((17, 17, 17, 19)), candidates)
self.assertFalse(generator.fretboard.has_fret_anchor(grip((13, 13, 13, 13))))
self.assertFalse(generator.fretboard.has_fret_anchor(()))
def test_exact_melody_height_scoring_remains_unchanged(self):
def test_height_is_a_passage_peak_not_an_additive_effort_penalty(self):
for fret in range(1, 21):
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4)).effort, 2 * max(0, fret - 12))
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4)).effort, 0)
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4)).peak_position, 0)
crossing = grip((12, 13, 14, 14))
self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 6)
self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 2)
self.assertEqual(generator.guitar.voicing_cost(crossing, 'electric').effort, 10)
def test_high_melody_fails_when_neither_anchored_chord_nor_single_note_fits(self):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('C', octave=6)))
@@ -134,13 +136,14 @@ class VoicingTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, 'single notes need frets 1-12'):
generator.guitar.place_guitar(document)
self.assertEqual(document, before)
def test_shape_and_movement_share_one_effort_cost(self):
def test_finger_complexity_is_subordinate_to_hand_travel(self):
shape = grip((1, 2, 3, 3))
self.assertEqual(generator.guitar.voicing_cost(shape).effort, 2)
self.assertEqual(generator.guitar.voicing_cost(shape, 'electric').effort, 10)
for profile in ('', 'electric'):
cost = generator.guitar.voicing_cost(shape, profile)
self.assertEqual(cost.moved(5).effort, cost.effort + 5)
self.assertEqual(cost.moved(5).travel, 5)
self.assertEqual(cost.moved(5).effort, cost.effort)
self.assertLess(generator.guitar.voicing_cost(grip((4, 4, 4, 4)), profile), cost)
def test_distinct_frets_add_difficulty_even_with_the_same_span(self):
simple, varied = grip((3, 3, 5, 5)), grip((3, 4, 5, 5))
@@ -150,28 +153,19 @@ class VoicingTests(unittest.TestCase):
def test_nearby_position_beats_lower_frets_within_comfort_range(self):
low, nearby, previous = (grip((f,) * 4) for f in (3, 8, 9))
for profile in ('', 'electric'):
self.assertLess(generator.guitar.voicing_cost(nearby, profile).moved(generator.guitar.movement(previous, nearby)), generator.guitar.voicing_cost(low, profile).moved(generator.guitar.movement(previous, low)))
self.assertLess(generator.guitar.voicing_cost(nearby, profile).moved(generator.guitar.movement(previous, nearby, 7, 7)), generator.guitar.voicing_cost(low, profile).moved(generator.guitar.movement(previous, low, 7, 3)))
def test_exact_melody_threshold_remains_a_soft_cost(self):
low, high = (grip((f,) * 4) for f in (3, 13))
self.assertLess(generator.guitar.voicing_cost(high), generator.guitar.voicing_cost(low).moved(generator.guitar.movement(high, low)))
def test_anchor_is_checked_before_equal_center_pruning(self):
# A permissive pitch set lets the ineligible fret-13 barre compete at center 26.
self.assertLess(generator.guitar.voicing_cost(high), generator.guitar.voicing_cost(low).moved(generator.guitar.movement(high, low, 12, 3)))
def test_anchor_is_checked_before_candidate_retention(self):
requirements = generator.fretboard.ChordRequirements(allowed=tuple(range(12)), foundation=(0,))
unpruned = generator.guitar.chord_positions(requirements, prune=False, profile='electric')
ineligible = grip((13, 13, 13, 13))
center = generator.guitar.position_center(ineligible)
eligible = [c for c in unpruned if generator.guitar.position_center(c) == center]
self.assertTrue(eligible)
self.assertTrue(all(generator.guitar.voicing_cost(ineligible, 'electric', requirements.allowed) < generator.guitar.voicing_cost(c, 'electric', requirements.allowed) for c in eligible))
best = min(eligible, key=lambda c: (generator.guitar.voicing_cost(c, 'electric', requirements.allowed), c))
self.assertIn(best, generator.guitar.chord_positions(requirements, profile='electric'))
self.assertNotIn(ineligible, unpruned)
def test_candidate_pruning_uses_full_cost_and_preserves_each_center(self):
for symbol, profile, melody in (('FM7', 'electric', (69,)), ('Gm7', 'electric', (67,))):
best = {}
for shape in ranked(symbol, melody, profile):
best.setdefault(generator.guitar.position_center(shape), shape)
self.assertEqual(set(ranked(symbol, melody, profile, prune=True)), set(best.values()))
for prune in (True, False):
candidates = generator.guitar.chord_positions(requirements, prune=prune, profile='electric')
self.assertTrue(candidates)
self.assertTrue(all(generator.fretboard.has_fret_anchor(c) for c in candidates))
def test_candidate_retention_preserves_concrete_fingerings(self):
for symbol in ('C7', 'FM7', 'Gm7'):
self.assertEqual(set(ranked(symbol, prune=True)), set(ranked(symbol, prune=False)))
def test_tie_continuations_do_not_charge_a_second_voicing_cost(self):
note = tests.test_musicxml.xml_note('E', 2, 'half', octave=5, extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', octave=5, extra='<tie type="stop"/>')
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(note))
@@ -180,32 +174,23 @@ class VoicingTests(unittest.TestCase):
first = generator.guitar.advance_positions([events[0][3]], events[0][0], tones, states, False)
second = generator.guitar.advance_positions([events[1][3]], events[1][0], tones, first, False)
self.assertEqual(sorted(p.cost for p in first.values()), sorted(p.cost for p in second.values()))
def test_empty_and_positioned_states_are_compared_by_the_same_grid_cost(self):
def test_empty_and_positioned_states_use_the_same_physical_objective(self):
empty = generator.guitar.Placement(generator.guitar.VoicingCost(effort=20), (), None, ())
placed = generator.guitar.Placement(generator.guitar.VoicingCost(), (('tone', grip((3,), (1,))[0]),), None, ())
grid = generator.guitar.nearest_positions({(): empty, 1: placed})
self.assertIs(grid[6][0][2], placed)
self.assertIs(grid[40][10][2], empty)
def test_optimized_passage_search_matches_exhaustive_candidate_ranking(self):
contents = ''.join(tests.test_musicxml.xml_note('E', 1, 'quarter', octave=5) for _ in range(4))
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(contents))
measure = document['staves'][0]['measures'][0]
for note, symbol in zip(measure['voices'][0]['notes'], ('FM7', 'Gm7', 'C7b9', 'FM7')):
note.update(pitches=[], _harmony=generator.harmony.parse_symbol(symbol), _guitar_profile='electric')
events, tones = generator.guitar.guitar_events(document)
shape = grip((3,), (1,))
placed = generator.guitar.Placement(generator.guitar.VoicingCost(), (('tone', shape[0]),), None, (), 1)
candidate = generator.guitar.AttackCandidate((('next', shape[0]),), (), shape, generator.guitar.VoicingCost())
result = generator.guitar.advance_attacks([candidate], {0: empty, 1: placed})
self.assertIs(min(result.values(), key=lambda p: p.cost).previous, placed)
def test_passage_search_matches_exhaustive_concrete_paths(self):
layers = [ranked(symbol)[:4] for symbol in ('FM7', 'Gm7', 'C7b9', 'FM7')]
costs = [{v: generator.guitar.voicing_cost(v, 'electric') for v in layer} for layer in layers]
def total(path):
return tests.position_fixtures.cost(path, costs, (6, 7, 8))
states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())}
for onset, _, _, note in events:
expected = {}
for candidate in generator.guitar.attack_candidates([note], tones, {}, {}, False):
choices = []
for index, previous in enumerate(states.values()):
distance = generator.guitar.movement(tuple(p for _, p in previous.active), candidate.shape)
choices.append((previous.cost.moved(distance).plus(candidate.cost), index))
expected[candidate.active] = min(choices)
actual = generator.guitar.advance_positions([note], onset, tones, states, False)
predecessors = {id(p): i for i, p in enumerate(states.values())}
self.assertEqual({key: (p.cost, predecessors[id(p.previous)]) for key, p in actual.items()}, expected)
states = actual
for i, layer in enumerate(layers):
candidates = [generator.guitar.AttackCandidate(tuple((str(p.string_number), p) for p in v), (), v, costs[i][v]) for v in layer]
states = generator.guitar.advance_attacks(candidates, states)
self.assertEqual(min(s.cost for s in states.values()), min(total(p) for p in itertools.product(*layers)))
def test_real_autumn_leaves_melody_has_expected_shapes_and_unchanged_notes(self):
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn_leaves.xml'))
before = copy.deepcopy(source)