import generator.validation import copy import fractions import pathlib import unittest import unittest.mock import generator.arrange.generate import generator.electric import generator.fretboard import generator.harmony import generator.model import generator.musicxml def harmony(symbol, onset=0): return generator.harmony.parse_symbol(symbol) | {'id': f'chord-{onset}', 'onset': generator.model.rational(onset)} def note(identifier, onset, chord, frets, *, melody=False, duration=1): tuning = dict(generator.fretboard.STANDARD_TUNING) positions = tuple(generator.fretboard.TabPosition(string, fret, tuning[string] + fret) for string, fret in zip((4, 3, 2, 1), frets)) pitches = generator.fretboard.chord_pitches(chord, positions) result = generator.model.make_event(identifier, fractions.Fraction(onset), fractions.Fraction(duration), 'note', pitches) result.update(_harmony=chord, _guitar_profile='electric') if melody: result['_harmonize'] = True return result def measure(notes, harmonies=()): return {'number': '1', 'duration': generator.model.rational(4), 'harmonies': list(harmonies), 'voices': [{'id': 'voice', 'notes': notes}]} def shape(event): return tuple(sorted((p['tablature']['string'], p['tablature']['fret']) for p in event['pitches'])) class ElectricVoicingTests(unittest.TestCase): def test_mixed_pickup_bar_links_each_role_to_its_original_passage(self): chord = harmony('C7') pickup = note('pickup', 3, chord, (2, 3, 1, 3), melody=True) outgoing = note('outgoing', 0, chord, (5, 3, 5, 3)) target = note('target', 0, chord, (10, 9, 11, 8)) mixed = measure([outgoing, pickup]) source = [measure([], [chord]), measure([], [harmony('G7')]), measure([], [chord])] origins = [generator.electric.MeasureOrigin(fractions.Fraction(8), 'accompaniment', mixed, end=fractions.Fraction(3)), generator.electric.MeasureOrigin(fractions.Fraction(-3), 'melody', mixed, start=fractions.Fraction(3)), generator.electric.MeasureOrigin(fractions.Fraction(0), 'accompaniment', measure([target]))] links = generator.electric.link_voicings(source, origins) self.assertEqual(len(links), 1) self.assertIs(links[0].target, target) self.assertEqual(links[0].references, (pickup,)) def test_corresponding_melody_shapes_change_within_one_harmony(self): chord = harmony('C7') first = note('melody-1', 1, chord, (2, 3, 1, 3), melody=True, duration=2) second = note('melody-2', 2, chord, (10, 9, 11, 8), melody=True, duration=2) earlier = [note(f'accompaniment-{i}', i, chord, (5, 3, 5, 3)) for i in (0, 2)] later = [note(f'accompaniment-next-{i}', i, chord, (5, 3, 5, 3)) for i in (0, 3)] source = [measure([], [chord]), measure([], [copy.deepcopy(chord)])] origins = [generator.electric.MeasureOrigin(fractions.Fraction(0), 'melody', measure([first])), generator.electric.MeasureOrigin(fractions.Fraction(4), 'melody', measure([second])), generator.electric.MeasureOrigin(fractions.Fraction(0), 'accompaniment', measure(earlier)), generator.electric.MeasureOrigin(fractions.Fraction(4), 'accompaniment', measure(later))] links = generator.electric.link_voicings(source, origins) self.assertEqual(len(links), 4) before = copy.deepcopy((first, second)) generator.electric.reuse_voicings(links) self.assertEqual((first, second), before) self.assertEqual([shape(n) for n in earlier + later], [shape(first)] * 3 + [shape(second)]) self.assertTrue(all(p['duration'] == n['duration'] and p['written'] == n['written'] for n in earlier + later for p in n['pitches'])) def test_links_follow_actual_placement_without_copying_melody_ties(self): chord = harmony('FM7') reference = note('melody', 0, chord, (3, 5, 5, 5), melody=True, duration=2) target = note('stab', 1, chord, (3, 2, 1, 0)) origins = [generator.electric.MeasureOrigin(fractions.Fraction(), role, measure([n])) for role, n in (('melody', reference), ('accompaniment', target))] links = generator.electric.link_voicings([measure([], [chord])], origins) for pitch in reference['pitches']: pitch['tie_to'] = 'next-' + pitch['id'] before = copy.deepcopy(reference) generator.electric.reuse_voicings(links) self.assertEqual(reference, before) self.assertEqual(shape(reference), shape(target)) self.assertTrue(all('tie_to' not in p and 'tie_from' not in p for p in target['pitches'])) self.assertEqual({p['id'] for p in target['pitches']}, {f'stab_s{i}' for i in range(1, 5)}) target['pitches'][0]['tablature']['fret'] = 20 self.assertEqual(reference, before) def test_unharmonized_passage_does_not_borrow_from_another_occurrence(self): chord = harmony('C7') reference = note('melody', 0, chord, (2, 3, 1, 3), melody=True) target = note('stab', 0, chord, (5, 3, 5, 3)) origins = [generator.electric.MeasureOrigin(fractions.Fraction(), 'melody', measure([reference])), generator.electric.MeasureOrigin(fractions.Fraction(8), 'accompaniment', measure([target]))] for middle in (harmony('G7'), {'kind': 'none', 'degrees': [], 'onset': generator.model.rational(0)}): source = [measure([], [chord]), measure([], [middle]), measure([], [chord])] self.assertEqual(generator.electric.link_voicings(source, origins), []) def test_mid_measure_changes_and_omitted_turnaround_do_not_copy_wrong_chord(self): first, second = harmony('C7'), harmony('FM7', 2) source = [measure([], [first, second])] reference = note('melody', 2, second, (3, 5, 5, 5), melody=True) target = note('stab', 3, first, (2, 3, 1, 3)) origins = [generator.electric.MeasureOrigin(fractions.Fraction(), role, measure([n])) for role, n in (('melody', reference), ('accompaniment', target))] self.assertEqual(generator.electric.link_voicings(source, origins), []) target['_harmony'] = second links = generator.electric.link_voicings(source, origins) self.assertEqual(len(links), 1) generator.electric.reuse_voicings(links) self.assertEqual(shape(reference), shape(target)) def test_failed_melody_harmonization_keeps_independent_accompaniment(self): chord = harmony('FM7') reference = note('melody', 0, chord, (3, 5, 5, 5), melody=True) target = note('stab', 1, chord, (3, 2, 1, 0)) origins = [generator.electric.MeasureOrigin(fractions.Fraction(), role, measure([n])) for role, n in (('melody', reference), ('accompaniment', target))] links = generator.electric.link_voicings([measure([], [chord])], origins) reference['pitches'] = reference['pitches'][:1] before = copy.deepcopy(target) generator.electric.reuse_voicings(links) self.assertEqual(target, before) def test_arrangement_never_reuses_generated_melody_grips(self): source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn_leaves.xml')) before = copy.deepcopy(source) with unittest.mock.patch('generator.electric.reuse_voicings', side_effect=AssertionError('cross-part grip dependency')) as reuse: result = generator.arrange.generate.arrange_document(source, 'electric') generator.validation.validate_document(result) reuse.assert_not_called() self.assertEqual(source, before)