+40
-55
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user