217 lines
14 KiB
Python
217 lines
14 KiB
Python
import copy
|
|
import pathlib
|
|
import unittest
|
|
|
|
import generator.arrange.generate
|
|
import generator.fretboard
|
|
import generator.guitar
|
|
import generator.harmony
|
|
import generator.model
|
|
import generator.musicxml
|
|
import generator.rhythm
|
|
import tests.rhythm_fixtures
|
|
import tests.test_musicxml
|
|
import tests.test_parts
|
|
|
|
def grip(frets, strings=(4, 3, 2, 1)):
|
|
tuning = dict(generator.fretboard.STANDARD_TUNING)
|
|
return tuple(generator.fretboard.TabPosition(s, f, tuning[s] + f) for s, f in zip(strings, frets))
|
|
|
|
def ranked(symbol, melody=(), profile='electric', prune=False):
|
|
definition = generator.harmony.parse_symbol(symbol)
|
|
requirements = generator.fretboard.chord_requirements(definition, respect_bass=profile != 'electric')
|
|
candidates = generator.guitar.chord_positions(requirements, prune=prune, profile=profile, melody=melody)
|
|
return sorted(candidates, key=lambda shape: (generator.guitar.voicing_cost(shape, profile, requirements.allowed, melody), shape))
|
|
|
|
class VoicingTests(unittest.TestCase):
|
|
def test_electric_chords_use_the_shared_three_fret_limit(self):
|
|
for prune in (False, True):
|
|
candidates = ranked('CM7', prune=prune)
|
|
self.assertTrue(candidates)
|
|
self.assertTrue(all(generator.guitar.compactness(c) + 1 <= 3 for c in candidates))
|
|
candidates = ranked('CM7')
|
|
self.assertIn(grip((5, 4, 5, 3)), candidates)
|
|
self.assertNotIn(grip((2, 4, 1, 3)), candidates)
|
|
self.assertNotIn(grip((5, 9, 8, 7)), candidates)
|
|
def test_requested_shapes_win_without_a_shape_dictionary(self):
|
|
for symbol, melody, expected in (('FM7', 69, (3, 5, 5, 5)), ('Gm7', 67, (3, 3, 3, 3))):
|
|
with self.subTest(symbol=symbol):
|
|
self.assertEqual(ranked(symbol, (melody,))[0], grip(expected))
|
|
def test_four_strings_precede_melody_height_and_root_presence(self):
|
|
candidates = ranked('C#M7', (65,))
|
|
four = next(c for c in candidates if len(c) == 4 and max(p.midi for p in c) > 65)
|
|
three = next(c for c in candidates if len(c) == 3 and max(p.midi for p in c) == 65)
|
|
self.assertLess(candidates.index(four), candidates.index(three))
|
|
candidates = ranked('FM7', (69,))
|
|
rootless = next(c for c in candidates if len(c) == 4 and 5 not in {p.midi % 12 for p in c})
|
|
rooted = next(c for c in candidates if len(c) == 3 and 5 in {p.midi % 12 for p in c})
|
|
self.assertLess(candidates.index(rootless), candidates.index(rooted))
|
|
def test_melody_on_top_precedes_root_among_four_string_shapes(self):
|
|
candidates = ranked('C#M7', (65,))
|
|
best = candidates[0]
|
|
self.assertEqual(len(best), 4)
|
|
self.assertEqual(max(p.midi for p in best), 65)
|
|
self.assertNotIn(1, {p.midi % 12 for p in best})
|
|
self.assertTrue(any(1 in {p.midi % 12 for p in c} and max(p.midi for p in c) > 65 for c in candidates))
|
|
def test_wider_low_chord_cannot_displace_a_playable_melody_grip(self):
|
|
candidates = ranked('CM7', (67,))
|
|
self.assertEqual(candidates[0], grip((5, 4, 5, 3)))
|
|
self.assertNotIn(grip((2, 4, 1, 3)), candidates)
|
|
self.assertEqual(max(p.midi for p in candidates[0]), 67)
|
|
def test_essential_extensions_are_never_relaxed_to_add_the_root(self):
|
|
for symbol in ('C7b9', 'FM7#11', 'C13', 'Cdim7'):
|
|
definition = generator.harmony.parse_symbol(symbol)
|
|
requirements = generator.fretboard.chord_requirements(definition, respect_bass=False)
|
|
for candidate in ranked(symbol):
|
|
classes = {p.midi % 12 for p in candidate}
|
|
self.assertTrue(requirements.covers(classes))
|
|
self.assertTrue(classes <= set(requirements.allowed))
|
|
def test_explicitly_removed_root_is_not_restored_or_penalized(self):
|
|
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_parts.xml_harmony(kind='major-seventh') + tests.test_musicxml.xml_note('E', octave=5)))
|
|
measure = document['staves'][0]['measures'][0]
|
|
chord = measure['harmonies'][0]
|
|
chord['degrees'].append({'value': 1, 'alter': 0, 'type': 'subtract'})
|
|
note = measure['voices'][0]['notes'][0]
|
|
measure['transpose']['octave_change'] = -1
|
|
note.update(_harmony=chord, _guitar_profile='electric', _harmonize=True)
|
|
_, tones = generator.guitar.guitar_events(document)
|
|
candidates = list(generator.guitar.attack_candidates([note], tones, {}, {}, False))
|
|
self.assertTrue(candidates)
|
|
self.assertTrue(all(all(p.midi % 12 != 0 for p in c.shape) and any(p.midi % 12 == 7 for p in c.shape) for c in candidates))
|
|
def test_anchored_chord_effort_is_independent_of_absolute_height(self):
|
|
for fret in range(1, 13):
|
|
with self.subTest(fret=fret):
|
|
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), 'electric').effort, 0)
|
|
shape = grip((fret, fret + 2, fret + 1, fret + 2))
|
|
self.assertTrue(generator.fretboard.has_fret_anchor(shape))
|
|
self.assertEqual(generator.guitar.voicing_cost(shape, 'electric').effort, 10)
|
|
self.assertEqual(generator.guitar.voicing_cost((), 'electric').effort, 0)
|
|
def test_electric_anchor_is_eligibility_not_a_highest_fret_cap(self):
|
|
for prune in (False, True):
|
|
candidates = ranked('CM7', prune=prune)
|
|
self.assertTrue(candidates)
|
|
self.assertTrue(all(min(p.fret for p in c) <= 12 for c in candidates))
|
|
self.assertIn(grip((14, 12, 12, 12)), candidates)
|
|
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):
|
|
for fret in range(1, 21):
|
|
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4)).effort, 2 * max(0, fret - 12))
|
|
crossing = grip((12, 13, 14, 14))
|
|
self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 6)
|
|
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)))
|
|
note = tests.test_musicxml.notes(document)[0]
|
|
note.update(_harmony=generator.harmony.parse_symbol('CM'), _guitar_profile='electric', _harmonize=True)
|
|
before = copy.deepcopy(document)
|
|
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):
|
|
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.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))
|
|
self.assertEqual(generator.guitar.voicing_cost(simple, 'electric').effort, 8)
|
|
self.assertEqual(generator.guitar.voicing_cost(varied, 'electric').effort, 10)
|
|
self.assertEqual(generator.guitar.voicing_cost(simple).effort, generator.guitar.voicing_cost(varied).effort)
|
|
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)))
|
|
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.
|
|
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()))
|
|
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))
|
|
events, tones = generator.guitar.guitar_events(document)
|
|
states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())}
|
|
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):
|
|
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)
|
|
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
|
|
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)
|
|
result = generator.arrange.generate.arrange_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
|
|
generator.model.validate_document(result)
|
|
section, checked = '', set()
|
|
for measure in result['staves'][0]['measures']:
|
|
for voice in measure['voices']:
|
|
for note in voice['notes']:
|
|
frets = [p['tablature']['fret'] for p in note['pitches']]
|
|
if frets:
|
|
self.assertLessEqual(max(frets) - min(frets) + 1, 3)
|
|
if len(frets) > 1:
|
|
self.assertLessEqual(min(frets), 12)
|
|
section = measure.get('section_label') or section
|
|
if section != 'Guitar Melody':
|
|
continue
|
|
original = next(m for m in source['staves'][0]['measures'] if m['number'] == measure['number'])
|
|
for note in measure['voices'][0]['notes']:
|
|
if not note['pitches']:
|
|
continue
|
|
onset = generator.model.fraction(note['onset'])
|
|
authored = next(n for n in original['voices'][0]['notes'] if generator.model.fraction(n['onset']) <= onset < generator.model.fraction(n['onset']) + generator.model.fraction(n['duration']))
|
|
self.assertEqual(generator.model.pitch_number(note['pitches'][0]), generator.model.pitch_number(authored['pitches'][0]) + 12)
|
|
if measure['number'] not in ('4', '12', '22', '24') or len(note['pitches']) == 1:
|
|
continue
|
|
expected = (3, 3, 3, 3) if measure['number'] == '22' else (3, 5, 5, 5)
|
|
frets = tuple(p['tablature']['fret'] for p in sorted(note['pitches'], key=lambda p: -p['tablature']['string']))
|
|
self.assertEqual(frets, expected)
|
|
self.assertEqual(generator.model.pitch_number(note['pitches'][0]), max(generator.model.pitch_number(p) for p in note['pitches']))
|
|
checked.add(measure['number'])
|
|
self.assertEqual(checked, {'4', '12', '22', '24'})
|
|
self.assertEqual(source, before)
|