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
+16 -16
View File
@@ -10655,8 +10655,8 @@
"letter": "c",
"octave": 5,
"tablature": {
"fret": 1,
"string": 2
"fret": 5,
"string": 3
},
"written": {
"dots": 0,
@@ -10966,8 +10966,8 @@
"letter": "d",
"octave": 5,
"tablature": {
"fret": 7,
"string": 3
"fret": 3,
"string": 2
},
"written": {
"dots": 0,
@@ -11007,8 +11007,8 @@
"letter": "e",
"octave": 5,
"tablature": {
"fret": 9,
"string": 3
"fret": 5,
"string": 2
},
"written": {
"dots": 0,
@@ -11048,8 +11048,8 @@
"letter": "f",
"octave": 5,
"tablature": {
"fret": 10,
"string": 3
"fret": 6,
"string": 2
},
"written": {
"dots": 0,
@@ -13379,8 +13379,8 @@
"letter": "d",
"octave": 4,
"tablature": {
"fret": 10,
"string": 6
"fret": 5,
"string": 5
},
"written": {
"dots": 0,
@@ -13439,8 +13439,8 @@
"letter": "c",
"octave": 4,
"tablature": {
"fret": 8,
"string": 6
"fret": 3,
"string": 5
},
"written": {
"dots": 0,
@@ -13624,8 +13624,8 @@
"letter": "d",
"octave": 4,
"tablature": {
"fret": 10,
"string": 6
"fret": 5,
"string": 5
},
"written": {
"dots": 0,
@@ -13684,8 +13684,8 @@
"letter": "c",
"octave": 4,
"tablature": {
"fret": 8,
"string": 6
"fret": 3,
"string": 5
},
"written": {
"dots": 0,
+37
View File
@@ -0,0 +1,37 @@
"""Independent exhaustive oracle for small grip/hand placement examples."""
import itertools
def regions(shape):
return tuple(h for h in range(1, 13) if all(h <= p.fret <= h + 3 for p in shape)) if shape else tuple([0])
def movement(a, b, ha, hb, acoustic=False):
if not a or not b:
return 0, 0
if len(a) == len(b) == 1:
pairs, unmatched = [(a[0], b[0])], 0
else:
def voices(shape):
return {('bass' if acoustic and p.string_number in (5, 6) else p.string_number): p for p in shape}
x, y = voices(a), voices(b)
pairs = [(x[s], y[s]) for s in x.keys() & y.keys()]
unmatched = len(x.keys() ^ y.keys())
return abs(ha - hb), unmatched + sum(abs((p.fret - ha) - (q.fret - hb)) + abs(p.string_number - q.string_number) for p, q in pairs)
def cost(path, costs, indices, *, acoustic=False, left=(), right=(), close=False, hands=None, left_hand=None, right_hand=None):
travel, peak, effort = indices
base = [sum(costs[i][v][j] for i, v in enumerate(path)) for j in range(len(costs[0][path[0]]))]
results = []
for positions in [hands] if hands is not None else itertools.product(*(regions(v) for v in path)):
for lh in [left_hand] if left_hand is not None else regions(left):
for rh in [positions[0]] if close else [right_hand] if right_hand is not None else regions(right):
points = [(left, lh), *zip(path, positions), (path[0] if close else right, rh)]
total = base.copy()
total[peak] = max(h for _, h in points)
for (a, ha), (b, hb) in zip(points, points[1:]):
shift, ergonomics = movement(a, b, ha, hb, acoustic)
total[travel] += shift
total[effort] += ergonomics
results.append(tuple(total))
return min(results)
+19 -17
View File
@@ -1,3 +1,4 @@
import generator.hand_position
import generator.validation
import copy
import fractions
@@ -13,6 +14,7 @@ import generator.harmony
import generator.model
import tests.test_acoustic
import tests.test_accompaniment
import tests.position_fixtures
def planned_document(harmonies, attacks=None):
if attacks is None:
@@ -173,19 +175,15 @@ class AcousticVoicingTests(unittest.TestCase):
dense = planned_document(harmonies, [([(i, 1) for i in range(4)], [(i, 1) for i in range(4)])] * 3)
selected = [tuple(tuple(sorted({p for n in span.notes for p in shape(n)})) for span in self.place(document)) for document in (sparse, dense)]
self.assertEqual(*selected)
def test_bass_switch_counts_both_string_and_fret_travel(self):
def test_bass_switch_and_finger_changes_do_not_move_the_hand(self):
previous = voicing((7, 8, 7, 8))
for current, expected in ((voicing((7, 8, 7, 8), 6), 1), (voicing((8, 8, 7, 8), 6), 2), (voicing((9, 8, 7, 8), 6), 3), (voicing((6, 8, 7, 8)), 1)):
self.assertEqual(generator.acoustic_voicing.movement(previous, current), expected)
self.assertEqual(generator.acoustic_voicing.movement(current, previous), expected)
self.assertEqual(generator.acoustic_voicing.movement(previous, previous), 0)
def test_each_upper_string_is_tracked_not_just_the_grip_center(self):
for current in (voicing((7, 8, 7, 8), 6), voicing((8, 8, 7, 8), 6), voicing((9, 8, 7, 8), 6), voicing((6, 8, 7, 8))):
self.assertEqual(generator.acoustic_voicing.movement(previous, current, 6, 6), 0)
self.assertEqual(generator.acoustic_voicing.movement(previous, previous, 5, 7), 2)
def test_each_upper_string_is_tracked_as_secondary_finger_effort(self):
before, after = voicing((5, 7, 5, 7)), voicing((7, 5, 7, 5))
self.assertEqual(generator.acoustic_voicing.movement(before, after), 8)
for index in (1, 2, 3):
frets = [5, 7, 5, 7]
frets[index] += 1
self.assertEqual(generator.acoustic_voicing.movement(before, voicing(frets)), 1)
self.assertEqual(generator.acoustic_voicing.movement(before, after, 5, 5), 0)
self.assertEqual(generator.hand_position.motion(before, after, 5, 5, True).fingers, 8)
def test_fret_and_register_limits_are_hard_constraints(self):
for frets, valid in (((1, 1, 1, 1), True), ((1, 3, 1, 3), True), ((1, 4, 1, 4), True), ((1, 5, 1, 4), False), ((0, 1, 1, 1), False), ((12, 14, 14, 12), True), ((12, 15, 14, 12), True), ((13, 13, 13, 13), False), ((12, 16, 14, 12), False), ((12, 21, 12, 12), False)):
with self.subTest(frets=frets):
@@ -205,7 +203,8 @@ class AcousticVoicingTests(unittest.TestCase):
options = (voicing((1, 1, 1, 1)), voicing((1, 3, 2, 3)), voicing((12, 12, 12, 12)), voicing((12, 14, 13, 14)))
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
result = generator.acoustic_voicing.advance(options, generator.fretboard.ChordRequirements(), states)
self.assertEqual([p.cost for p in result], [generator.acoustic_voicing.Cost()] * len(options))
self.assertTrue(all(p.cost.travel == 0 for p in result))
self.assertEqual({(p.voicing, p.hand, p.cost.peak_position) for p in result}, {(v, h, h) for v in options for h in tests.position_fixtures.regions(v)})
def test_optional_tones_break_equal_movement_costs_only(self):
requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C7'))
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
@@ -217,13 +216,16 @@ class AcousticVoicingTests(unittest.TestCase):
def test_dynamic_program_matches_exhaustive_paths_and_is_deterministic(self):
harmonies = ['FM7', 'Gm7', 'C7']
spans = generator.acoustic.harmony_spans(planned_document([[(0, s)] for s in harmonies]))
options = [candidates(s) for s in harmonies]
options = [candidates(s)[:4] for s in harmonies]
requirements = [generator.fretboard.chord_requirements(span.harmony) for span in spans]
costs = [{v: generator.acoustic_voicing.Cost(extended_grips=generator.fretboard.span_penalty(v), missing_tones=len(set(r.allowed) - {p.midi % 12 for p in v})) for v in layer} for r, layer in zip(requirements, options)]
def cost(path):
return generator.acoustic_voicing.Cost(sum(generator.fretboard.span_penalty(v) for v in path), sum(generator.acoustic_voicing.movement(a, b) for a, b in zip(path, path[1:])), sum(len(set(r.allowed) - {p.midi % 12 for p in v}) for r, v in zip(requirements, path)))
selected = generator.acoustic_voicing.select(spans)
self.assertEqual(cost(selected), min(cost(path) for path in itertools.product(*options)))
self.assertEqual(selected, generator.acoustic_voicing.select(spans))
return tests.position_fixtures.cost(path, costs, (1, 2, 3), acoustic=True)
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
for layer, r in zip(options, requirements):
states = generator.acoustic_voicing.advance(layer, r, states)
self.assertEqual(min(s.cost for s in states), min(cost(path) for path in itertools.product(*options)))
self.assertEqual(generator.acoustic_voicing.select(spans), generator.acoustic_voicing.select(spans))
def test_complete_triads_and_doubling_use_only_authored_chord_tones(self):
for symbol in ('CM', 'Dm', 'Cdim', 'Caug', 'Dm/F', 'Dm7no7'):
with self.subTest(chord=symbol):
+4 -2
View File
@@ -78,11 +78,13 @@ class ChordFamilies(unittest.TestCase):
return min(p.string_number for p in v if p.midi == midi)
tier = generator.fretboard.span_penalty(choices[0])
self.assertEqual(top_string(choices[0]), min(top_string(v) for v in choices if generator.fretboard.span_penalty(v) == tier))
self.assertEqual(max(p.midi for p in choices[0]), midi)
self.assertTrue(all(tuple(p.string_number for p in v) in ((3, 2, 1), (4, 3, 2, 1)) for v in choices))
self.assertTrue(all(p.string_number in (1, 2, 3, 4) for v in choices for p in v))
self.assertTrue(all(midi in {p.midi for p in v} for v in choices))
low = tests.test_voicing.ranked('C7', (64,))[0]
self.assertEqual(tuple(p.string_number for p in low), (4, 3, 2))
self.assertIn(1, {p.string_number for p in low})
self.assertIn(64, {p.midi for p in low})
self.assertGreater(max(p.midi for p in low), 64)
def test_acoustic_policy_change_does_not_reseed_electric_roles(self):
resources = generator.vocabulary.load()
changed = copy.deepcopy(resources)
+2
View File
@@ -155,7 +155,9 @@ class CompingMeters(unittest.TestCase):
measures = source['staves'][0]['measures']
carried = generator.accompaniment.carried_chords(measures)
full = canonical(measures[3], carried[3], 3)
full_position = canonical.last_position
partial = canonical(measures[3], carried[3], 3, bounds=(3, 10))
self.assertEqual(canonical.last_position, full_position)
# Rest engraving can split differently at a sliced boundary. Sounded
# onsets, releases, voicings and tie relationships must remain equal.
self.assertEqual([n for n in tests.test_comping_cycles.signature(full) if n[-1]], [n for n in tests.test_comping_cycles.signature(partial) if n[-1]])
+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'))
+3 -2
View File
@@ -56,7 +56,7 @@ class FretPolicyTests(unittest.TestCase):
costs = {v: generator.comping.local_cost('acoustic', key, v, 55) for v in (primary, fallback)}
# Movement/family wins cannot purchase entry into the four-fret tier.
costs[primary] = costs[primary]._replace(effort=100000)
path, cost = generator.comping.select_grips(((primary, fallback),), (costs,), 'acoustic', 'tiers', left=fallback, right=fallback)
path, cost, hands = generator.comping.select_grips(((primary, fallback),), (costs,), 'acoustic', 'tiers', left=fallback, right=fallback)
self.assertEqual(path, (primary,))
self.assertEqual(cost.extended_grips, 0)
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost(), fallback)]
@@ -68,7 +68,8 @@ class FretPolicyTests(unittest.TestCase):
best = choices[0]
self.assertEqual(generator.fretboard.span_penalty(best), 0)
self.assertEqual(min(p.string_number for p in best if p.midi == 65), 2)
self.assertEqual(max(p.midi for p in best), 65)
self.assertIn(65, {p.midi for p in best})
self.assertIn(1, {p.string_number for p in best})
self.assertTrue(any(generator.fretboard.span_penalty(v) and any(p.midi == 65 and p.string_number == 1 for p in v) for v in choices))
primary_cost = generator.guitar.voicing_cost(best, 'electric', melody=(65,)).moved(100000)
self.assertTrue(all(primary_cost < generator.guitar.voicing_cost(v, 'electric', melody=(65,)) for v in choices if generator.fretboard.span_penalty(v)))
+10 -15
View File
@@ -15,6 +15,7 @@ import generator.acoustic_voicing
import generator.arrange.generate
import generator.fretboard
import generator.guitar
import generator.hand_position
import generator.harmony
import generator.model
import generator.parts
@@ -43,7 +44,7 @@ class GuitarTests(unittest.TestCase):
positions = tuple(generator.fretboard.TabPosition(6 - i, fret, 0) for i, fret in enumerate(frets))
self.assertEqual(generator.fretboard.within_fret_span(positions), valid)
self.assertEqual(generator.guitar.compactness(positions), max(frets) - min(frets))
self.assertEqual(generator.guitar.position_center(positions)[0], min(frets) + max(frets))
self.assertEqual(generator.hand_position.base(positions), min(frets))
self.assertTrue(generator.fretboard.within_fret_span(()))
def test_held_notes_obey_the_shared_four_fret_limit(self):
held = {'held': generator.fretboard.TabPosition(4, 7, 57)}
@@ -210,20 +211,14 @@ class GuitarTests(unittest.TestCase):
generator.guitar.place_guitar(source)
tabs = [n['pitches'][0]['tablature'] for n in tests.test_musicxml.notes(source)]
self.assertEqual(tabs, [{'string': 1, 'fret': 10}, {'string': 1, 'fret': 12}])
def test_grid_search_matches_exhaustive_costs_and_tie_breaks(self):
randomizer = random.Random(17)
states = {}
for index in range(40):
positions = tuple(generator.fretboard.TabPosition(s, randomizer.randrange(21), 0) for s in randomizer.sample(range(1, 7), 3))
states[index] = generator.guitar.Placement(generator.guitar.VoicingCost(*(randomizer.randrange(8) for _ in generator.guitar.VoicingCost._fields)), tuple((str(i), p) for i, p in enumerate(positions)), None, ())
grid = generator.guitar.nearest_positions(states)
for x, row in enumerate(grid):
for y, actual in enumerate(row):
candidates = []
for index, state in enumerate(states.values()):
a, b = generator.guitar.position_center(tuple(p for _, p in state.active))
candidates.append((state.cost.moved(abs(x - a) + abs(y - b)), index))
self.assertEqual(actual[:2], min(candidates))
def test_equal_old_centers_can_have_different_finger_adjustments(self):
def grip(frets):
return tuple(generator.fretboard.TabPosition(s, f, 0) for s, f in zip((4, 3, 2, 1), frets))
first, second = grip((3, 5, 3, 5)), grip((5, 3, 5, 3))
self.assertEqual(generator.hand_position.base(first), generator.hand_position.base(second))
self.assertEqual(generator.guitar.movement(first, first, 3, 3), 0)
self.assertEqual(generator.guitar.movement(first, second, 3, 3), 0)
self.assertEqual(generator.hand_position.motion(first, second, 3, 3).fingers, 8)
def test_generated_chord_retains_strings_when_a_melody_enters(self):
content = tests.test_musicxml.xml_note() + '<backup><duration>4</duration></backup><forward><duration>2</duration></forward>' + tests.test_musicxml.xml_note('E', 2, 'half', octave=6, extra='<voice>2</voice>')
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
+85
View File
@@ -0,0 +1,85 @@
"""Whole-path position objectives and ties, with independently checked examples."""
import dataclasses
import fractions
import itertools
import unittest
import unittest.mock
import generator.comping
import generator.fretboard
import generator.guitar
import generator.hand_position
import generator.solo.placement
import tests.solo_fixtures
import tests.test_acoustic_voicing
import tests.test_comping_voicings
def position(string, fret):
return generator.fretboard.TabPosition(string, fret, dict(generator.fretboard.STANDARD_TUNING)[string] + fret)
class HandPositionTests(unittest.TestCase):
def test_single_notes_can_share_a_four_fret_hand_region(self):
self.assertEqual(set.intersection(*(set(generator.hand_position.regions((position(1, f),))) for f in range(7, 11))), {7})
self.assertEqual(generator.hand_position.regions((position(1, 1),)), (1,))
self.assertIn(12, generator.hand_position.regions((position(1, 12),)))
self.assertTrue(all(p.fret <= 12 for n in range(41, 89) for p in generator.solo.placement.positions(n)))
def test_chords_share_the_same_window_as_single_notes(self):
self.assertEqual(generator.hand_position.regions((position(5, 7), position(4, 8), position(3, 7), position(2, 8))), (5, 6, 7))
self.assertEqual(generator.hand_position.regions((position(5, 7), position(4, 10))), (7,))
a, b = (position(5, 7),), (position(3, 9),)
motion = generator.hand_position.motion(a, b, 7, 7)
self.assertEqual(motion.shift, 0)
self.assertGreater(motion.ergonomics, 0)
def test_future_peak_can_equalize_histories_at_the_same_endpoint(self):
low, high, join, final = ((position(1, f),) for f in (1, 4, 2, 8))
layers = ((low, high), (join,), (final,))
costs = ({low: generator.comping.VoicingCost(register=10), high: generator.comping.VoicingCost()},
{join: generator.comping.VoicingCost()}, {final: generator.comping.VoicingCost()})
# Isolate peak propagation: a higher earlier peak must remain available
# when a common later maximum exposes its better subordinate tie-break.
with unittest.mock.patch('generator.hand_position.motion', return_value=generator.hand_position.Motion()):
path, cost, hands = generator.comping.select_grips(layers, costs, 'electric', 'peak')
self.assertEqual(path, (high, join, final))
self.assertEqual((cost.travel, cost.peak_position, cost.register), (0, 5, 0))
def test_complete_path_avoids_a_locally_lower_position_trap(self):
low, high, middle, end = ((position(1, f),) for f in (1, 4, 5, 8))
layers = ((low, high), (middle,), (end,))
costs = tuple({v: generator.comping.VoicingCost(register=int(v == high)) for v in layer} for layer in layers)
first, _, _ = generator.comping.select_grips(layers[:1], costs[:1], 'electric', 'lookahead')
path, cost, hands = generator.comping.select_grips(layers, costs, 'electric', 'lookahead')
self.assertEqual(first, (low,))
self.assertEqual(path, (high, middle, end))
self.assertEqual(cost.travel, 1)
self.assertEqual(cost, tests.test_comping_voicings.path_cost(path, costs, 'electric', hands=hands))
def test_lower_travel_beats_easier_finger_changes(self):
notes = tuple(tests.solo_fixtures.note(i, i + 1, pitch, octave=4) for i, pitch in enumerate(('e', 'g', 'f', 'e')))
result = generator.solo.placement.paths(notes, fractions.Fraction(120))[0]
self.assertEqual(result.travel, 0)
self.assertEqual(len(set(result.hands)), 1)
self.assertGreater(result.effort, 0)
def test_solo_matches_exhaustive_pitch_and_hand_paths(self):
notes = (tests.solo_fixtures.note(0, 1, 'e', octave=4), tests.solo_fixtures.note(1, 2, 'g', octave=4), tests.solo_fixtures.note(2, 3, 'f', octave=4))
layers = [tuple((p, h) for p in generator.solo.placement.positions(n.pitch.number()) for h in range(max(1, p.fret - 3), p.fret + 1)) for n in notes]
expected = []
for path in itertools.product(*layers):
effort = sum(200 * (abs(h - k) + abs((p.fret - h) - (q.fret - k)) + abs(p.string_number - q.string_number)) for (p, h), (q, k) in zip(path, path[1:]))
expected.append((sum(abs(h - k) for (_, h), (_, k) in zip(path, path[1:])), max(h for _, h in path), effort))
result = generator.solo.placement.paths(notes, fractions.Fraction(120))[0]
self.assertEqual((result.travel, result.peak, result.effort), min(expected))
self.assertEqual([(n.pitch, n.start, n.end) for n in result.notes], [(n.pitch, n.start, n.end) for n in notes])
report = generator.solo.placement.describe(result.notes, fractions.Fraction(120))
self.assertEqual(fractions.Fraction(report['secondary_effort']), sum(fractions.Fraction(r['secondary_effort']) for r in report['positions']))
def test_tie_split_never_creates_a_shift_or_shorter_travel_time(self):
merged = (tests.solo_fixtures.note(0, 3, 'e', octave=4), tests.solo_fixtures.note(3, 4, 'g', octave=4))
split = (dataclasses.replace(merged[0], end=fractions.Fraction(1)), dataclasses.replace(merged[0], start=fractions.Fraction(1), continuation=True), merged[1])
a, b = (generator.solo.placement.paths(notes, fractions.Fraction(120))[0] for notes in (merged, split))
self.assertEqual((a.travel, a.peak, a.effort), (b.travel, b.peak, b.effort))
self.assertEqual([n.position for n in a.notes], [b.notes[0].position, b.notes[2].position])
self.assertEqual((b.notes[0].position, b.hands[0]), (b.notes[1].position, b.hands[1]))
report = generator.solo.placement.describe(b.notes, fractions.Fraction(120))
self.assertEqual(report['positions'][1]['secondary_effort'], '0')
def test_guitar_cost_composes_peak_with_maximum(self):
first = generator.guitar.VoicingCost(effort=3, peak_position=4, register=fractions.Fraction(1, 2))
second = generator.guitar.VoicingCost(effort=2, peak_position=7, register=fractions.Fraction(1, 3))
result = first.plus(second)
self.assertEqual((result.effort, result.peak_position, result.register), (5, 7, fractions.Fraction(5, 6)))
+13
View File
@@ -4,6 +4,7 @@ import copy
import dataclasses
import io
import json
import math
import pathlib
import tempfile
import unittest
@@ -63,6 +64,18 @@ class PrintableParts(unittest.TestCase):
broken = dataclasses.replace(page, primitives=tuple(p for p in page.primitives if p.role != 'tie'))
with self.assertRaisesRegex(ValueError, 'tie was omitted'):
generator.engraving.audit.audit_layout(source, dataclasses.replace(layout, pages=(broken,)), self.fonts, systems_per_page=None)
def test_tight_page_uses_the_actual_footer_outline(self):
source = tests.solo_fixtures.song(count=32, chords=('C7',))
original = generator.printing.layout_part(source, self.fonts)
style = generator.printing.STYLE
# Two systems just fit the old nominal 20-unit footer reservation. The
# page-number outline needs more space than that arithmetic assumes.
height = math.ceil(original.pages[0].systems[1].box.bottom + style.margin_y + 20 + style.page_clearance)
with unittest.mock.patch('generator.printing.STYLE', dataclasses.replace(style, height=height)):
layout = generator.printing.layout_part(source, self.fonts)
for page in layout.pages:
footer = next(p for p in page.primitives if p.role == 'page_number')
self.assertGreaterEqual(footer.box.top - page.systems[-1].box.bottom, style.page_clearance - .01)
def test_guitar_tab_pickups_and_final_three_bar_hold_are_included(self):
source = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter'), implicit=True)
source = source.replace('</measure></part>', '</measure><measure number="2"><harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note() + '</measure></part>')
+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)