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

259 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.model
import generator.score
import generator.vocabulary
import tests.solo_fixtures
import tests.test_acoustic_voicing
import tests.test_voicing
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):
total = [sum(costs[i][v][j] for i, v in enumerate(path)) for j in range(5)]
total[2] += sum(generator.comping.movement(part, a, b) for a, b in zip((left, *path), path))
total[2] += generator.comping.movement(part, path[-1], path[0] if close else right)
return tuple(total)
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, _ = generator.comping.select_grips(((low, high),), (costs,), 'electric', 'lower')
self.assertEqual(path, (low,))
path, _ = 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, 'high_position_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_lower_progression_can_outweigh_extra_boundary_travel(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})
def travel(path):
return sum(generator.comping.movement('acoustic', a, b) for a, b in zip((left, *path), (*path, right)))
self.assertEqual((travel(low), travel(high)), (43, 35))
self.assertLess(path_cost(low, costs, 'acoustic', left, right), path_cost(high, costs, 'acoustic', left, right))
path, cost = generator.comping.select_grips(layers, costs, 'acoustic', 'lower-voicings', left=left, right=right)
self.assertEqual(path, low)
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, _ = generator.comping.select_grips(((low, high),), (costs,), 'acoustic', 'travel', left=high, right=high)
self.assertEqual(path, (high,))
def test_acoustic_preferences_are_exact_and_weights_are_operational(self):
high = tests.test_acoustic_voicing.voicing((12, 12, 12, 11), 6)
low = tests.test_acoustic_voicing.voicing((7, 8, 7, 8))
resources = generator.vocabulary.load()
key = generator.comping.semantic_key(generator.harmony.parse_symbol('Em7b5'))
self.assertEqual(generator.comping.voicing_preferences('acoustic', high, 53), (fractions.Fraction(39, 4), fractions.Fraction(11, 4)))
self.assertEqual(generator.comping.voicing_preferences('acoustic', low, 53)[1], 0)
baseline = generator.comping.local_cost('acoustic', key, high, 53)
for field, expected in (('register_weight', fractions.Fraction(39, 4)), ('high_position_weight', fractions.Fraction(11, 4)), ('comfortable_max_fret', -1)):
policy = dict(resources['roles']['roles']['acoustic_accompaniment']['voicing'])
policy[field] += 1
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': policy}):
cost = generator.comping.local_cost('acoustic', key, high, 53)
self.assertEqual(cost.effort - baseline.effort, expected, field)
self.assertEqual(cost.extended_grips, baseline.extended_grips)
self.assertEqual(cost.family, baseline.family)
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': {'register_weight': -1}}), self.assertRaises(ValueError):
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, 'high_position_weight': 0, 'comfortable_max_fret': 9}}
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'])
self.assertNotEqual(current['document'], old['document'])
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.assertEqual(fractions.Fraction(cost['combined']), sum(fractions.Fraction(cost[k]) for k in ('movement', 'register', 'position')))
self.assertEqual(canonical.path_cost.effort, sum(fractions.Fraction(r['voicing_cost']['combined']) 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 = 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.assertLess(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), 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)) 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 = 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])
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()