Files
product-vamp/tests/test_comping_voicings.py
T
khodowany 3f63b255ac
ci / checks (push) Failing after 23m26s
Fix Playability
2026-10-08 19:04:06 -05:00

258 lines
18 KiB
Python

"""Accompaniment geometry and whole-form movement, using authored small cases."""
import generator.composition
import generator.source
import copy
import fractions
import itertools
import json
import pathlib
import shutil
import tempfile
import unittest
import generator.accompaniment
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, **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):
low, high = (tests.test_voicing.grip(f) for f in ((2, 3, 1, 3), (10, 12, 11, 12)))
key = generator.comping.semantic_key(generator.harmony.parse_symbol('C7'))
self.assertTrue(all(v in options('C7', 'electric') for v in (low, high)))
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, _, hands = generator.comping.select_grips(((low, high),), (costs,), 'electric', 'lower')
self.assertEqual(path, (low,))
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}}
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_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)))
left, right = tests.test_acoustic_voicing.voicing((8, 10, 9, 10)), low[0]
layers = tuple(zip(low, high))
costs = []
for symbol, layer in zip(symbols, layers):
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})
# 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))
high = tests.test_acoustic_voicing.voicing((12, 12, 12, 11), 6)
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, _, hands = generator.comping.select_grips(((low, high),), (costs,), 'acoustic', 'travel', left=high, right=high)
self.assertEqual(path, (high,))
def test_register_weight_is_an_exact_subordinate_tiebreak(self):
high = tests.test_acoustic_voicing.voicing((12, 12, 12, 11), 6)
resources = generator.vocabulary.load()
key = generator.comping.semantic_key(generator.harmony.parse_symbol('Em7b5'))
baseline = generator.comping.local_cost('acoustic', key, high, 53)
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):
return [[(n['onset'], n['duration'], n['written'], n['kind'], len(n['pitches']), tuple((bool(p.get('tie_from')), bool(p.get('tie_to'))) for p in n['pitches'])) for n in generator.score.measure_notes(m)] for m in result['document']['staves'][0]['measures']]
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory) / 'data'
shutil.copytree(pathlib.Path('data/composition'), root / 'composition')
role_path = root / 'composition/roles.json'
data = json.loads(role_path.read_text())
policy = data['roles']['acoustic_accompaniment'].pop('voicing')
role_path.write_text(json.dumps(data))
legacy = generator.composition.generate_role(source, 'acoustic_accompaniment', root)
electric = generator.composition.generate_role(source, 'electric_accompaniment', root)
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}}
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'])
# 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'])
self.assertEqual(rhythm(current), rhythm(old))
self.assertEqual(current['details']['accompaniment_rhythm'], old['details']['accompaniment_rhythm'])
self.assertEqual(electric['document'], generator.composition.generate_role(source, 'electric_accompaniment', root)['document'])
def test_report_balanced_cost_matches_selected_path(self):
canonical = generator.comping.Canonical(tests.solo_fixtures.song(count=4, chords=('Em7b5', 'A7b9', 'Dm7', 'G7')), 'acoustic', 'report')
rows = [r for r in canonical.report if r['tones']]
for row in rows:
cost = row['voicing_cost']
self.assertEqual(cost['movement'], row['movement'])
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):
chord = generator.harmony.parse_symbol(symbol)
required = generator.comping.requirements(chord, 'acoustic')
for grip in options(symbol, 'acoustic'):
self.assertIn(tuple(p.string_number for p in grip), ((5, 4, 3, 2), (6, 4, 3, 2)))
self.assertLessEqual(grip.bass.fret, 12)
self.assertTrue(all(1 <= p.fret <= 20 for p in grip))
self.assertTrue(generator.fretboard.has_fret_anchor(grip[1:]))
self.assertTrue(generator.fretboard.within_fret_span(grip))
self.assertEqual(grip.bass.midi, min(p.midi for p in grip))
self.assertTrue(required.covers({p.midi % 12 for p in grip}))
self.assertTrue(generator.fretboard.playable_attack((p, fractions.Fraction(1)) for p in grip))
if symbol in ('C', 'Cm', 'F#', 'Bbm'):
self.assertEqual({p.midi % 12 for p in grip}, set(required.allowed))
def test_bass_thirds_only_for_plain_major_and_minor(self):
for symbol, expected in (('C', {0, 4, 7}), ('Cm', {0, 3, 7}), ('C7', {0, 7}), ('Cm6', {0, 7}), ('Em7b5', {4, 10})):
with self.subTest(chord=symbol):
required = generator.comping.requirements(generator.harmony.parse_symbol(symbol), 'acoustic')
self.assertEqual(set(required.basses), expected)
self.assertEqual({v.bass.midi % 12 for v in options(symbol, 'acoustic')}, expected)
self.assertEqual(set(generator.comping.requirements(generator.harmony.parse_symbol('Cdim'), 'acoustic').basses), {0, 6})
def test_explicit_slash_or_inversion_overrides_default_bass(self):
inversion = generator.harmony.parse_symbol('C7')
inversion['inversion'] = 1
for chord, expected in ((generator.harmony.parse_symbol('C7/E'), 4), (generator.harmony.parse_symbol('C/D'), 2), (inversion, 4)):
self.assertEqual(generator.comping.requirements(chord, 'acoustic').basses, (expected,))
grips = generator.comping.voicings(generator.comping.semantic_key(chord), 'acoustic')
self.assertEqual({v.bass.midi % 12 for v in grips}, {expected})
def test_electric_uses_melody_voicing_families_and_chord_semantics(self):
for symbol in ('C', 'Cm', 'C7', 'CmM7', 'Csus', 'Cdim7', 'Caug7', 'C7b9', 'C13', 'C/E'):
with self.subTest(chord=symbol):
required = generator.fretboard.chord_requirements(generator.harmony.parse_symbol(symbol), respect_bass=False)
melody = generator.guitar.chord_positions(required, profile='electric', prune=False)
self.assertEqual(set(options(symbol, 'electric')), set(melody))
for grip in options(symbol, 'electric'):
self.assertIn(tuple(p.string_number for p in grip), generator.guitar.ELECTRIC_STRINGS)
self.assertTrue(required.covers({p.midi % 12 for p in grip}))
# Bass responsibility belongs to the acoustic voicing, as in electric melody.
self.assertEqual(options('C/E', 'electric'), options('C', 'electric'))
def test_impossible_grip_reports_source_location_without_relaxation(self):
source = tests.solo_fixtures.song(count=1, chords=('C7b9#11',))
for part in ('acoustic', 'electric'):
with self.subTest(part=part), self.assertRaisesRegex(ValueError, 'source measure 1, beat 1: C7b9#11: no legal'):
generator.comping.Canonical(source, part, 'test')
def test_complete_path_beats_greedy_and_matches_exhaustive_search(self):
for part in ('acoustic', 'electric'):
with self.subTest(part=part):
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, 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.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, 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), 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, 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):
source = tests.solo_fixtures.song(count=3, chords=('C', 'C', 'Dm7'))
source['staves'][0]['measures'][1]['harmonies'][0]['kind'] = 'none'
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], *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'))
measures = source['staves'][0]['measures']
carried = generator.accompaniment.carried_chords(measures)
variants = []
for index, symbol in ((1, 'Dm7'), (2, 'Am7')):
changed = copy.deepcopy(measures[index])
changed['harmonies'][0] = {**generator.harmony.parse_symbol(symbol), 'id': 'variant', 'onset': generator.model.rational(0)}
variants.append((changed, carried[index], index))
for part in ('acoustic', 'electric'):
a, b = (generator.comping.Canonical(source, part, 'variants') for _ in range(2))
forward = {args[2]: a(*args) for args in variants}
reverse = {args[2]: b(*args) for args in reversed(variants)}
self.assertEqual(forward, reverse)
self.assertEqual(a.report, b.report)
self.assertEqual(a.base, b.base)
self.assertEqual(a(measures[0], carried[0], 0), b(measures[0], carried[0], 0))
def test_register_preference_does_not_change_rhythm(self):
resources = generator.vocabulary.load()
source = tests.solo_fixtures.song(count=4)
measures = source['staves'][0]['measures']
carried = generator.accompaniment.carried_chords(measures)
def rhythm(canonical):
return [[(n['onset'], n['duration'], n['written'], n['kind']) for n in generator.score.measure_notes(canonical(m, p, i))] for i, (m, p) in enumerate(zip(measures, carried))]
for part in ('acoustic', 'electric'):
with generator.vocabulary.using(resources, part + '_accompaniment', {'center': 45}):
low = generator.comping.Canonical(source, part, 'fixed-rhythm')
with generator.vocabulary.using(resources, part + '_accompaniment', {'center': 70}):
high = generator.comping.Canonical(source, part, 'fixed-rhythm')
self.assertEqual(rhythm(low), rhythm(high))
def test_accompaniment_cache_matches_clean_generation(self):
source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=3))
for part in ('acoustic', 'electric'):
with tempfile.TemporaryDirectory() as directory:
role = part + '_accompaniment'
cold = generator.composition.generate_role(source, role, pathlib.Path('data'), cache_dir=directory)
warm = generator.composition.generate_role(source, role, pathlib.Path('data'), cache_dir=directory)
clean = generator.composition.generate_role(source, role, pathlib.Path('data'))
self.assertTrue(warm['cache_hit'])
self.assertEqual(cold['document'], warm['document'])
self.assertEqual(cold['document'], clean['document'])
if __name__ == '__main__':
unittest.main()