import copy import fractions import unittest 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)) required, allowed, bass = 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(set(required) <= {p.midi % 12 for p in grip} <= set(allowed)) if basses: self.assertEqual(min(p.midi for p in grip) % 12, bass) self.assertTrue(generator.guitar.within_fret_span(grip)) self.assertTrue(all(0 <= 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_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) self.assertEqual([min(p.midi for n in span.notes for p in shape(n)) % 12 for span in spans], [0, 4, 7, 0]) 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(generator.guitar.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_attacks(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.*0-20'): generator.guitar.place_guitar(document) self.assertEqual(document, before)