227 lines
15 KiB
Python
227 lines
15 KiB
Python
import copy
|
|
import pathlib
|
|
import unittest
|
|
|
|
import generator.arrange.generate
|
|
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.guitar.STANDARD_TUNING)
|
|
return tuple(generator.guitar.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.guitar.chord_requirements(definition, profile)
|
|
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_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_compact_chord_can_beat_lower_frets_without_covering_melody(self):
|
|
candidates = ranked('CM7', (67,))
|
|
self.assertEqual(candidates[0], grip((5, 4, 5, 3)))
|
|
self.assertIn(grip((2, 4, 1, 3)), candidates)
|
|
rooted = generator.guitar.voicing_cost(grip((2, 4, 1, 3)), 'electric', (0, 4, 7, 11), (67,))
|
|
rootless = generator.guitar.voicing_cost(grip((5, 4, 5, 3)), 'electric', (0, 4, 7, 11), (67,))
|
|
self.assertEqual((rooted.effort, rootless.effort), (19, 15))
|
|
self.assertLess(rootless, rooted)
|
|
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.guitar.chord_requirements(definition, 'electric')
|
|
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_chord_height_cost_increases_throughout_the_fretboard(self):
|
|
for profile in ('electric', 'acoustic'):
|
|
for fret in range(1, 21):
|
|
with self.subTest(profile=profile, fret=fret):
|
|
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), profile).effort, fret + 2 * max(0, fret - 12))
|
|
self.assertEqual(generator.guitar.voicing_cost((), profile).effort, 0)
|
|
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((11, 12, 13, 14))
|
|
self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 7)
|
|
self.assertEqual(generator.guitar.voicing_cost(crossing, 'electric').effort, 33)
|
|
self.assertEqual(generator.guitar.voicing_cost(crossing, 'acoustic').effort, 24)
|
|
def test_chord_shape_and_height_share_one_effort_cost(self):
|
|
shape = grip((1, 3, 4, 5))
|
|
self.assertEqual(generator.guitar.voicing_cost(shape).effort, 4)
|
|
self.assertEqual(generator.guitar.voicing_cost(shape, 'electric').effort, 23)
|
|
self.assertEqual(generator.guitar.voicing_cost(shape, 'acoustic').effort, 11)
|
|
for profile in ('', 'electric', 'acoustic'):
|
|
self.assertLess(generator.guitar.voicing_cost(grip((4, 4, 4, 4)), profile), generator.guitar.voicing_cost(grip((2, 4, 1, 3)), profile))
|
|
def test_distinct_frets_add_difficulty_even_with_the_same_span(self):
|
|
simple, varied = grip((3, 3, 5, 5)), grip((3, 4, 5, 5))
|
|
for profile, expected in (('electric', 13), ('acoustic', 7)):
|
|
self.assertEqual(generator.guitar.voicing_cost(simple, profile).effort, expected)
|
|
self.assertEqual(generator.guitar.voicing_cost(varied, profile).effort, expected + 2)
|
|
self.assertEqual(generator.guitar.voicing_cost(simple).effort, generator.guitar.voicing_cost(varied).effort)
|
|
def test_acoustic_reach_excludes_stretched_low_pair_before_scoring(self):
|
|
high = [grip((12, 12, 12, 11), (6, 4, 3, 2)), grip((12, 11, 12, 11), (6, 4, 3, 2))]
|
|
requested = [grip((7, 8, 7, 8), (5, 4, 3, 2)), grip((7, 8, 6, 8), (5, 4, 3, 2))]
|
|
low = [grip((6, 5, 3, 3), (6, 4, 3, 2)), grip((5, 5, 3, 2), (6, 4, 3, 2))]
|
|
for symbol, upper, middle, lower in zip(('Em7b5', 'A7b9'), high, requested, low):
|
|
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol(symbol), 'acoustic')
|
|
candidates = generator.guitar.chord_positions(requirements, profile='acoustic')
|
|
for shape in (upper, middle, lower):
|
|
self.assertIn(shape, candidates)
|
|
self.assertEqual({p.midi % 12 for p in upper}, {p.midi % 12 for p in middle})
|
|
self.assertEqual(min(p.midi for p in upper), min(p.midi for p in middle))
|
|
preferred = generator.guitar.acoustic_positions(requirements)
|
|
self.assertIn(middle, preferred)
|
|
self.assertIn(upper, preferred)
|
|
self.assertNotIn(lower, preferred)
|
|
costs = [sum(generator.guitar.voicing_cost(s, 'acoustic').effort for s in pair) + 2 * generator.guitar.acoustic_movement(*pair) for pair in (requested, high, low)]
|
|
self.assertEqual(costs, [24, 30, 23])
|
|
def test_real_autumn_leaves_cadences_use_comfortable_inversions_and_complete_triads(self):
|
|
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn-leaves.xml'))
|
|
before = copy.deepcopy(source)
|
|
result = generator.arrange.generate.arrange_document(source, 'acoustic', rhythms=generator.rhythm.load_library(pathlib.Path('data/rhythms')))
|
|
generator.model.validate_document(result)
|
|
measures = result['staves'][0]['measures']
|
|
self.assertEqual(measures[0]['section_label'], 'Rhythm Lead-in')
|
|
for index, symbol, expected in ((0, 'Em7b5', ((6, 3), (4, 2), (3, 3), (2, 3))), (1, 'A7b9', ((6, 3), (4, 2), (3, 3), (2, 2))), (4, 'Em7b5', ((6, 3), (4, 2), (3, 3), (2, 3))), (5, 'A7b9', ((6, 3), (4, 2), (3, 3), (2, 2)))):
|
|
measure = measures[index]
|
|
with self.subTest(measure=measure['number'], chord=symbol):
|
|
self.assertEqual(generator.harmony.symbol(measure['harmonies'][0]), symbol)
|
|
positions = {(p['tablature']['string'], p['tablature']['fret']) for v in measure['voices'] for n in v['notes'] for p in n['pitches']}
|
|
self.assertEqual(tuple(sorted(positions, reverse=True)), expected)
|
|
checked = set()
|
|
for measure in measures:
|
|
if measure['number'] not in ('26', '27', '30', '31', '32', '33'):
|
|
continue
|
|
pitches = [p for v in measure['voices'] for n in v['notes'] for p in n['pitches']]
|
|
if not pitches:
|
|
continue
|
|
positions = tuple(generator.guitar.TabPosition(p['tablature']['string'], p['tablature']['fret'], int(generator.model.pitch_number(p)) - 12) for p in pitches)
|
|
self.assertLessEqual(generator.guitar.physical_reach(positions), generator.guitar.PREFERRED_ACOUSTIC_REACH, measure['id'])
|
|
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in pitches))
|
|
if measure['number'] in ('32', '33'):
|
|
self.assertEqual({p.midi % 12 for p in positions}, {2, 5, 9})
|
|
checked.add(measure['number'])
|
|
self.assertEqual(checked, {'26', '27', '30', '31', '32', '33'})
|
|
self.assertEqual(result, generator.arrange.generate.arrange_document(source, 'acoustic', rhythms=generator.rhythm.load_library(pathlib.Path('data/rhythms'))))
|
|
self.assertEqual(source, before)
|
|
def test_acoustic_simpler_three_note_shape_can_beat_four_notes(self):
|
|
candidates = ranked('Csus', profile='acoustic')
|
|
best = candidates[0]
|
|
self.assertEqual(len(best), 3)
|
|
self.assertTrue(any(len(c) == 4 for c in candidates))
|
|
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('Csus'), 'acoustic')
|
|
self.assertTrue(requirements.covers({p.midi % 12 for p in best}))
|
|
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', 'acoustic'):
|
|
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_above_threshold_is_a_soft_cost_not_a_range_priority(self):
|
|
low, high = (grip((f,) * 4) for f in (3, 13))
|
|
for profile in ('', 'electric', 'acoustic'):
|
|
self.assertLess(generator.guitar.voicing_cost(high, profile), generator.guitar.voicing_cost(low, profile).moved(generator.guitar.movement(high, low)))
|
|
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']:
|
|
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)
|