258 lines
18 KiB
Python
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()
|