"""Accompaniment geometry and whole-form movement, using authored small cases.""" 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.production 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.production.SourceAnalysis.read(tests.solo_fixtures.song(count=4, chords=('C7', 'FM7', 'Dm7', 'G7'))) root = pathlib.Path('data') current = generator.production.generate_role(source, 'electric_accompaniment', root) with tempfile.TemporaryDirectory() as cache: settings = {'voicing': {'register_weight': 0, 'high_position_weight': 0}} other = generator.production.generate_role(source, 'electric_accompaniment', root, settings, cache) warm = generator.production.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.production.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.production.generate_role(source, 'acoustic_accompaniment', root) electric = generator.production.generate_role(source, 'electric_accompaniment', root) data['roles']['acoustic_accompaniment']['voicing'] = policy role_path.write_text(json.dumps(data)) current = generator.production.generate_role(source, 'acoustic_accompaniment', root) settings = {'voicing': {'register_weight': 0, 'high_position_weight': 0, 'comfortable_max_fret': 9}} old = generator.production.generate_role(source, 'acoustic_accompaniment', root, settings, pathlib.Path(directory) / 'cache') warm = generator.production.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.production.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.production.SourceAnalysis.read(tests.solo_fixtures.song(count=3)) for part in ('acoustic', 'electric'): with tempfile.TemporaryDirectory() as directory: role = part + '_accompaniment' cold = generator.production.generate_role(source, role, pathlib.Path('data'), cache_dir=directory) warm = generator.production.generate_role(source, role, pathlib.Path('data'), cache_dir=directory) clean = generator.production.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()