import copy import fractions import unittest import unittest.mock import generator.accompaniment import generator.acoustic import generator.guitar import generator.harmony import generator.model import tests.test_acoustic import tests.test_accompaniment def planned_document(harmonies, attacks=None): if attacks is None: attacks = [([(0, 1), (2, 1)], [(1, 1), (3, 1)])] * len(harmonies) document = tests.test_acoustic.acoustic_document(attacks) previous = None for measure, changes in zip(document['staves'][0]['measures'], harmonies): measure['harmonies'] = [] for onset, symbol in changes: harmony = {'kind': 'none', 'degrees': []} if symbol == 'N.C.' else generator.harmony.parse_symbol(symbol) if isinstance(symbol, str) else copy.deepcopy(symbol) measure['harmonies'].append(harmony | {'id': f'{measure["id"]}-h{len(measure["harmonies"])}', 'onset': generator.model.rational(onset)}) for voice in measure['voices']: for note in voice['notes']: if not note['pitches']: continue note['pitches'] = [] harmony = generator.accompaniment.active_chord(measure, generator.model.fraction(note['onset']), previous) if harmony: note.update(_harmony=harmony, _guitar_profile='acoustic', _guitar_lane=voice['id']) else: note['kind'] = 'rest' if measure['harmonies']: previous = measure['harmonies'][-1] generator.acoustic.prepare(document) return document def shape(note): tuning = dict(generator.guitar.STANDARD_TUNING) return tuple(sorted(generator.guitar.TabPosition(p['tablature']['string'], p['tablature']['fret'], tuning[p['tablature']['string']] + p['tablature']['fret']) for p in note['pitches'])) class AcousticVoicingTests(unittest.TestCase): def place(self, document): spans = generator.acoustic.harmony_spans(document) lanes = {n['id']: n['_guitar_lane'] for span in spans for n in span.notes} before = [tests.test_accompaniment.timing(v['notes']) for m in document['staves'][0]['measures'] for v in m['voices']] generator.guitar.place_guitar(document) self.assertEqual(before, [tests.test_accompaniment.timing(v['notes']) for m in document['staves'][0]['measures'] for v in m['voices']]) for span in spans: basses = {shape(n) for n in span.notes if lanes[n['id']] == 'bass'} uppers = {shape(n) for n in span.notes if lanes[n['id']] == 'upper'} self.assertLessEqual(len(basses), 1) self.assertLessEqual(len(uppers), 1) grip = tuple(sorted({p for n in span.notes for p in shape(n)}, reverse=True)) requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic') if basses and uppers: self.assertIn(tuple(p.string_number for p in grip), generator.guitar.ACOUSTIC_STRINGS) self.assertTrue(requirements.covers({p.midi % 12 for p in grip})) self.assertTrue({p.midi % 12 for p in grip} <= set(requirements.allowed)) if basses: self.assertIn(min(p.midi for p in grip) % 12, requirements.basses) self.assertTrue(generator.guitar.within_fret_span(grip)) self.assertTrue(all(1 <= p.fret <= 20 for p in grip)) for note in span.notes: self.assertFalse(any(k.startswith('_') for k in note)) self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in note['pitches'])) generator.model.validate_document(document) return spans def test_independent_attacks_keep_one_complete_grip(self): document = planned_document([[(0, 'FM7')]]) spans = self.place(document) self.assertEqual(len(spans), 1) self.assertEqual(len(spans[0].notes), 4) before = tests.test_acoustic.sounding(document) generator.acoustic.combine(document) self.assertEqual(tests.test_acoustic.sounding(document), before) generator.model.validate_document(document) def test_fifth_bass_can_win_without_alternating(self): document = planned_document([[(0, 'Csus')], []]) spans = self.place(document) self.assertEqual(len(spans), 1) bass = [p for n in spans[0].notes for p in shape(n) if p.string_number >= 5] self.assertTrue(bass) self.assertEqual({p.midi % 12 for p in bass}, {7}) root_shapes = generator.guitar.chord_positions(generator.guitar.chord_requirements(generator.harmony.parse_symbol('Csus/C'), 'acoustic'), profile='acoustic') selected = tuple(sorted({p for n in spans[0].notes for p in shape(n)}, reverse=True)) self.assertLessEqual(generator.guitar.voicing_cost(selected, 'acoustic'), min(generator.guitar.voicing_cost(s, 'acoustic') for s in root_shapes)) def test_bass_options_follow_all_chord_tones_and_explicit_basses(self): inversion = generator.harmony.parse_symbol('Cdim7') | {'inversion': 0} for chord, expected in (('C7', {0, 4, 7, 10}), ('Cdim7', {0, 3, 6, 9}), ('Caug', {0, 4, 8}), ('Cno5', {0, 4}), ('C7b9', {0, 1, 4, 7, 10}), ('Dmno1', {5, 9}), ('C7/E', {4}), (inversion, {0})): with self.subTest(chord=chord): span = generator.acoustic.harmony_spans(planned_document([[(0, chord)]]))[0] candidates = list(generator.guitar.acoustic_candidates(span)) self.assertEqual({min(p.midi for p in c.shape) % 12 for c in candidates}, expected) requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic') self.assertTrue(all(requirements.covers({p.midi % 12 for p in c.shape}) for c in candidates)) def test_same_grip_survives_repeated_symbols_rest_bars_and_sections(self): attacks = [([(0, 1), (2, 1)], [(0, 1), (3, 1)]), ([], []), ([(2, 1)], [(1, 1), (3, 1)])] document = planned_document([[(0, 'C7')], [], [(0, 'C7'), (2, 'C7')]], attacks) document['staves'][0]['measures'][2]['section_label'] = 'Next section' self.assertEqual(len(self.place(document)), 1) def test_changes_during_silence_end_the_span(self): for middle in ('G7', 'N.C.'): with self.subTest(middle=middle): document = planned_document([[(0, 'C7'), (1, middle), (2, 'C7')]], [([(0, 1), (2, 1)], [(0, 1), (3, 1)])]) spans = self.place(document) self.assertEqual(len(spans), 2) self.assertEqual([len(s.notes) for s in spans], [2, 2]) def test_returning_to_a_chord_is_a_new_span(self): document = planned_document([[(0, 'C7')], [(0, 'G7')], [(0, 'C7')]]) self.assertEqual(len(self.place(document)), 3) def test_slash_bass_inversion_and_alteration_changes_are_boundaries(self): inversion = generator.harmony.parse_symbol('C7') | {'inversion': 2} changes = [(0, 'C7'), (1, 'C7/E'), (2, inversion), (3, 'C7b5')] document = planned_document([changes], [([(i, 1) for i in range(4)], [(i, 1) for i in range(4)])]) spans = self.place(document) self.assertEqual(len(spans), 4) basses = [min(p.midi for n in span.notes for p in shape(n)) % 12 for span in spans] self.assertIn(basses[0], (0, 4, 7, 10)) self.assertEqual(basses[1:3], [4, 7]) self.assertIn(basses[3], (0, 4, 6, 10)) def test_non_accompaniment_passage_ends_the_span(self): document = planned_document([[(0, 'C7')], [], []]) measure = document['staves'][0]['measures'][1] slash = generator.model.make_event('slash', fractions.Fraction(), generator.model.fraction(measure['duration']), 'note') | {'voice': 'voice-arranged', 'notehead': 'slash'} measure['voices'] = [{'id': 'voice-arranged', 'notes': [slash]}] self.assertEqual(len(self.place(document)), 2) def test_carried_harmony_can_begin_an_arrangement_excerpt(self): document = planned_document([[(0, 'C7')], []]) document['staves'][0]['measures'][0]['harmonies'] = [] self.assertEqual(len(self.place(document)), 1) def test_no_chord_does_not_generate_an_attack(self): document = planned_document([[(0, 'N.C.')], [(0, 'C7')], [(0, 'N.C.')]]) self.assertEqual(len(self.place(document)), 1) for measure in (document['staves'][0]['measures'][0], document['staves'][0]['measures'][2]): self.assertTrue(all(n['kind'] == 'rest' for v in measure['voices'] for n in v['notes'])) def test_candidate_includes_unsounded_half_of_the_grip(self): for lane in ('bass', 'upper'): document = planned_document([[(0, 'C7')]], [([(0, 1)] if lane == 'bass' else [], [(1, 1)] if lane == 'upper' else [])]) span = generator.acoustic.harmony_spans(document)[0] candidates = list(generator.guitar.acoustic_candidates(span)) self.assertTrue(candidates) for candidate in candidates: self.assertIn(tuple(p.string_number for p in candidate.shape), generator.guitar.ACOUSTIC_STRINGS) self.assertEqual({p for _, p in candidate.active}, set(candidate.shape)) self.assertEqual(len(candidate.choices), 1) self.assertEqual(len(candidate.choices[0].added), 1 if lane == 'bass' else len(candidate.shape) - 1) self.place(document) def test_rhythm_density_does_not_multiply_shape_cost(self): harmonies = [[(0, symbol)] for symbol in ('FM7', 'Gm7', 'C7')] sparse = planned_document(harmonies, [([(0, 1)], [(2, 1)])] * 3) dense = planned_document(harmonies, [([(i, 1) for i in range(4)], [(i, 1) for i in range(4)])] * 3) selected = [tuple(tuple(sorted({p for n in span.notes for p in shape(n)})) for span in self.place(document)) for document in (sparse, dense)] self.assertEqual(*selected) def test_span_search_matches_exhaustive_full_grip_transitions(self): document = planned_document([[(0, s)] for s in ('FM7', 'Gm7', 'C7', 'FM7')]) states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())} for span in generator.acoustic.harmony_spans(document): candidates = list(generator.guitar.acoustic_candidates(span)) expected = {} for candidate in candidates: expected[candidate.active] = min((previous.cost.moved(2 * generator.guitar.acoustic_movement(tuple(p for _, p in previous.active), candidate.shape)).plus(candidate.cost), index) for index, previous in enumerate(states.values())) actual = generator.guitar.advance_acoustic(candidates, states) 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_impossible_grip_fails_contextually_without_partial_materialization(self): document = planned_document([[(0, 'C7')], [(0, 'C7b9#11')]]) before = copy.deepcopy(document) with self.assertRaisesRegex(ValueError, 'measure 2, event .*no acoustic grip.*1-20'): generator.guitar.place_guitar(document) self.assertEqual(document, before) def test_g_minor_to_c_seventh_preserves_positions_with_fifth_bass(self): tuning = dict(generator.guitar.STANDARD_TUNING) previous = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 10), (3, 10), (2, 11))) active = tuple((str(p.string_number), p) for p in previous) states = {active: generator.guitar.Placement(generator.guitar.VoicingCost(), active, None, ())} span = generator.acoustic.harmony_spans(planned_document([[(0, 'C7')]]))[0] candidates = list(generator.guitar.acoustic_candidates(span)) result = generator.guitar.advance_acoustic(candidates, states) best = min(result.values(), key=lambda p: (p.cost, p.active)) chosen = tuple(p for _, p in best.active) self.assertEqual([(p.string_number, p.fret) for p in chosen], [(5, 10), (3, 9), (2, 11)]) self.assertEqual(generator.guitar.acoustic_movement(previous, chosen), 1) self.assertEqual(generator.guitar.acoustic_movement(chosen, previous), 1) self.assertEqual(generator.guitar.acoustic_movement(previous, previous), 0) self.assertEqual(generator.guitar.acoustic_movement((), chosen), 0) def test_acoustic_candidates_are_not_pruned_by_grip_center(self): requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7'), 'acoustic') candidates = generator.guitar.chord_positions(requirements, profile='acoustic') self.assertEqual(candidates, generator.guitar.chord_positions(requirements, profile='acoustic', prune=False)) self.assertGreater(len(candidates), len({generator.guitar.position_center(p) for p in candidates})) def test_physical_reach_uses_fret_spacing_and_an_inclusive_reference(self): tuning = dict(generator.guitar.STANDARD_TUNING) low = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 3), (3, 2), (2, 5))) reference = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 8), (3, 7), (2, 10))) high = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 9), (3, 8), (2, 11))) self.assertEqual(generator.guitar.compactness(low), generator.guitar.compactness(reference)) self.assertGreater(generator.guitar.physical_reach(low), generator.guitar.physical_reach(reference)) self.assertGreater(generator.guitar.physical_reach(reference), generator.guitar.physical_reach(high)) self.assertEqual(generator.guitar.physical_reach(reference), generator.guitar.PREFERRED_ACOUSTIC_REACH) self.assertEqual(generator.guitar.physical_reach(()), 0) self.assertEqual(generator.guitar.physical_reach(reference[:1]), 0) span = generator.acoustic.harmony_spans(planned_document([[(0, 'Dm')]]))[0] self.assertIn(reference, [c.shape for c in generator.guitar.acoustic_candidates(span)]) def test_acoustic_effort_does_not_reward_further_compression(self): tuning = dict(generator.guitar.STANDARD_TUNING) shapes = [tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in zip((5, 4, 3, 2), frets)) for frets in ((5, 5, 7, 7), (6, 6, 7, 7))] self.assertGreater(generator.guitar.physical_reach(shapes[0]), generator.guitar.physical_reach(shapes[1])) self.assertTrue(all(generator.guitar.physical_reach(s) <= generator.guitar.PREFERRED_ACOUSTIC_REACH for s in shapes)) self.assertEqual(*(generator.guitar.voicing_cost(s, 'acoustic').effort for s in shapes)) def test_comfortable_candidates_win_even_when_previous_grip_is_wider(self): tuning = dict(generator.guitar.STANDARD_TUNING) wider = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((6, 6), (4, 5), (3, 3), (2, 3))) active = tuple((str(p.string_number), p) for p in wider) states = {active: generator.guitar.Placement(generator.guitar.VoicingCost(), active, None, ())} span = generator.acoustic.harmony_spans(planned_document([[(0, 'Em7b5')]]))[0] requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic') self.assertIn(wider, generator.guitar.chord_positions(requirements, profile='acoustic')) candidates = list(generator.guitar.acoustic_candidates(span)) self.assertNotIn(wider, [c.shape for c in candidates]) result = generator.guitar.advance_acoustic(candidates, states) chosen = min(result.values(), key=lambda p: (p.cost, p.active)) self.assertLessEqual(generator.guitar.physical_reach(tuple(p for _, p in chosen.active)), generator.guitar.PREFERRED_ACOUSTIC_REACH) def test_complete_triads_are_preferred_within_comfortable_candidates(self): for symbol in ('CM', 'Dm', 'Cdim', 'Caug', 'Dm/F'): with self.subTest(chord=symbol): span = generator.acoustic.harmony_spans(planned_document([[(0, symbol)]]))[0] candidates = list(generator.guitar.acoustic_candidates(span)) classes = set(generator.harmony.pitch_classes(span.harmony)) self.assertTrue(candidates) self.assertTrue(all(classes <= {p.midi % 12 for p in c.shape} for c in candidates)) self.assertTrue(all(generator.guitar.physical_reach(c.shape) <= generator.guitar.PREFERRED_ACOUSTIC_REACH for c in candidates)) def test_omissions_and_extended_chords_do_not_gain_complete_triad_requirement(self): for symbol, omitted in (('Dmno1', 2), ('Dmno5', 9)): with self.subTest(chord=symbol): span = generator.acoustic.harmony_spans(planned_document([[(0, symbol)]]))[0] candidates = list(generator.guitar.acoustic_candidates(span)) self.assertTrue(candidates) self.assertTrue(all(omitted not in {p.midi % 12 for p in c.shape} for c in candidates)) for symbol in ('C7', 'C7b9', 'C13'): with self.subTest(chord=symbol): span = generator.acoustic.harmony_spans(planned_document([[(0, symbol)]]))[0] candidates = list(generator.guitar.acoustic_candidates(span)) requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic') self.assertTrue(any(0 not in {p.midi % 12 for p in c.shape} for c in candidates)) self.assertTrue(all(requirements.covers({p.midi % 12 for p in c.shape}) for c in candidates)) def test_complete_triad_preference_cannot_force_a_wider_grip(self): tuning = dict(generator.guitar.STANDARD_TUNING) reduced, complete = [tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in pairs) for pairs in (((6, 1), (4, 3), (3, 2)), ((5, 5), (4, 3), (3, 2)))] requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('Dm'), 'acoustic') self.assertTrue(all(requirements.covers({p.midi % 12 for p in s}) for s in (reduced, complete))) self.assertLessEqual(generator.guitar.physical_reach(reduced), generator.guitar.PREFERRED_ACOUSTIC_REACH) self.assertGreater(generator.guitar.physical_reach(complete), generator.guitar.PREFERRED_ACOUSTIC_REACH) generator.guitar.acoustic_positions.cache_clear() self.addCleanup(generator.guitar.acoustic_positions.cache_clear) with unittest.mock.patch('generator.guitar.chord_positions', return_value=(reduced, complete)): self.assertEqual(generator.guitar.acoustic_positions(requirements, complete_triad=True), (reduced,)) def test_explicit_inversions_and_nonchord_slash_basses_are_preserved(self): for chord, bass in ((generator.harmony.parse_symbol('Dm') | {'inversion': 1}, 5), (generator.harmony.parse_symbol('C7') | {'inversion': 3}, 10), ('Dm/B', 11)): with self.subTest(chord=chord): document = planned_document([[(0, chord)]]) span = self.place(document)[0] self.assertEqual({p.midi % 12 for n in span.notes for p in shape(n) if p.string_number >= 5}, {bass}) def test_wider_fallback_is_least_reaching_and_reported_with_context(self): document = planned_document([[(0, 'C13/E')]]) span = generator.acoustic.harmony_spans(document)[0] requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic') raw = generator.guitar.chord_positions(requirements, profile='acoustic') minimum = min(generator.guitar.physical_reach(s) for s in raw) self.assertGreater(minimum, generator.guitar.PREFERRED_ACOUSTIC_REACH) with self.assertLogs('generator.guitar', level='WARNING') as logs: candidates = list(generator.guitar.acoustic_candidates(span)) self.assertTrue(candidates) self.assertEqual({generator.guitar.physical_reach(c.shape) for c in candidates}, {minimum}) self.assertTrue(all(requirements.covers({p.midi % 12 for p in c.shape}) and generator.guitar.within_fret_span(c.shape) for c in candidates)) self.assertIn(span.location, logs.output[0]) self.assertIn('C13/E requires a wider acoustic reach', logs.output[0]) with self.assertLogs('generator.guitar', level='WARNING'): self.place(document) def test_inversions_still_follow_nearby_full_grip_positions(self): tuning = dict(generator.guitar.STANDARD_TUNING) for previous, expected in ((((6, 3), (4, 2), (3, 3), (2, 3)), ((6, 3), (4, 2), (3, 3), (2, 2))), (((5, 7), (4, 8), (3, 7), (2, 8)), ((5, 7), (4, 8), (3, 6), (2, 8)))): with self.subTest(previous=previous): grip = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in previous) active = tuple((str(p.string_number), p) for p in grip) states = {active: generator.guitar.Placement(generator.guitar.VoicingCost(), active, None, ())} span = generator.acoustic.harmony_spans(planned_document([[(0, 'A7b9')]]))[0] result = generator.guitar.advance_acoustic(generator.guitar.acoustic_candidates(span), states) chosen = tuple(p for _, p in min(result.values(), key=lambda p: (p.cost, p.active)).active) self.assertEqual(tuple((p.string_number, p.fret) for p in chosen), expected) self.assertEqual(generator.guitar.acoustic_movement(grip, chosen), 1)