Files
product-vamp/tests/test_voicing.py
T
khodowany bc7baea7c4
ci / checks (push) Failing after 23m22s
Cleanup
2026-10-08 02:13:45 -05:00

244 lines
16 KiB
Python

import generator.validation
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.score
import generator.vocabulary
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_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}}):
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])]
self.assertEqual(min(p.string_number for p in candidates[0] if p.midi in melody), min(p.string_number for c in primary for p in c if p.midi in melody))
self.assertTrue(set(melody) <= {p.midi for p in candidates[0]})
def test_melody_preference_cost_and_pruning_caches_include_role_settings(self):
resources = generator.vocabulary.load()
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}}):
plain = generator.guitar.voicing_cost(shape, 'electric', melody=(76,))
self.assertEqual(original.effort - plain.effort, sum(preference))
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()))
def test_electric_chords_use_the_shared_four_fret_limit(self):
for prune in (False, True):
candidates = ranked('CM7', prune=prune)
self.assertTrue(candidates)
self.assertTrue(all(generator.guitar.compactness(c) + 1 <= 4 for c in candidates))
candidates = ranked('CM7')
self.assertIn(grip((5, 4, 5, 3)), candidates)
self.assertIn(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_melody_height_precedes_four_strings_and_optional_root(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(three), candidates.index(four))
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.assertIn(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')
generator.validation.validate_document(result)
section, checked = '', set()
source_index = generator.score.body_start_measure_index(source['staves'][0]['measures'])
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 = source['staves'][0]['measures'][source_index]
source_index += 1
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 original['number'] not in ('4', '12', '22', '24') or len(note['pitches']) == 1:
continue
expected = (3, 3, 3, 3) if original['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(original['number'])
self.assertEqual(checked, {'4', '12', '22', '24'})
self.assertEqual(source, before)