import copy import fractions import unittest import generator.accompaniment import generator.arrange.generate import generator.guitar import generator.harmony import generator.model import generator.parts import generator.playback import generator.practice.generate import generator.rhythm import tests.rhythm_fixtures import tests.test_accompaniment import tests.test_musicxml import tests.test_parts class GuitarPartTests(unittest.TestCase): def practice(self, content, part='electric', divisions=1): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=divisions)) result = generator.practice.generate.practice_document(source, part, rhythms=tests.rhythm_fixtures.library()) generator.model.validate_document(result) return result def test_profile_shapes_cover_defining_chord_tones(self): for symbol in ('CM', 'Cm', 'C7', 'CmM7', 'Csus', 'Cdim7', 'Caug7', 'C7b9', 'C13', 'C5', 'CM/E'): for profile in ('acoustic', 'electric'): with self.subTest(symbol=symbol, profile=profile): definition = generator.harmony.parse_symbol(symbol) required, allowed, bass = generator.guitar.chord_requirements(definition, profile) candidates = generator.guitar.chord_positions(required, allowed, bass, profile=profile) self.assertTrue(candidates) for candidate in candidates: strings = tuple(p.string_number for p in candidate) self.assertIn(strings, generator.guitar.ACOUSTIC_STRINGS if profile == 'acoustic' else generator.guitar.ELECTRIC_STRINGS) self.assertTrue(set(required) <= {p.midi % 12 for p in candidate} <= set(allowed)) self.assertTrue(generator.guitar.within_fret_span(candidate)) chord = generator.harmony.parse_symbol('C7') self.assertNotIn(0, generator.guitar.chord_requirements(chord, 'electric')[0]) candidates = generator.guitar.chord_positions(*generator.guitar.chord_requirements(chord, 'electric'), profile='electric') self.assertTrue(any(all(p.midi % 12 != 0 for p in c) for c in candidates)) def test_acoustic_combines_independent_rhythms_and_alternating_bass(self): result = self.practice(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic') measure = result['staves'][0]['measures'][0] self.assertEqual(len(measure['voices']), 1) notes = measure['voices'][0]['notes'] bass = [(n['onset'], p) for n in notes for p in n['pitches'] if p['tablature']['string'] >= 5 and 'tie_from' not in p] upper = {generator.model.fraction(n['onset']) for n in notes for p in n['pitches'] if p['tablature']['string'] <= 4 and 'tie_from' not in p} self.assertNotEqual({generator.model.fraction(onset) for onset, _ in bass}, upper) self.assertEqual([generator.model.pitch_number(p) % 12 for _, p in bass], [0, 7]) self.assertEqual(sum(generator.model.fraction(n['duration']) for n in notes), 4) for note in notes: self.assertNotIn('stem', note) strings = sorted(p['tablature']['string'] for p in note['pitches'] if p['tablature']['string'] <= 4) if strings: self.assertIn(strings, ([2, 3], [3, 4], [2, 3, 4])) timeline = generator.playback.compile_timeline(result)['events'] by_id = {p['id']: p for v in measure['voices'] for n in v['notes'] for p in n['pitches']} for event in timeline: start = generator.model.fraction(event['onset']) simultaneous = [e for e in timeline if generator.model.fraction(e['onset']) <= start < generator.model.fraction(e['onset']) + generator.model.fraction(e['duration'])] positions = [generator.guitar.TabPosition(by_id[e['id']]['tablature']['string'], by_id[e['id']]['tablature']['fret'], int(generator.model.fraction(e['pitch']))) for e in simultaneous] self.assertEqual(len(positions), len({p.string_number for p in positions})) self.assertTrue(generator.guitar.within_fret_span(positions)) def test_explicit_bass_and_fifth_fallback(self): for symbol, expected in (('CM/E', 4), ('C7b9', 0)): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) chord = generator.harmony.parse_symbol(symbol) source['staves'][0]['measures'][0]['harmonies'] = [{**chord, 'id': 'h1', 'onset': generator.model.rational(0)}] result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=tests.rhythm_fixtures.library()) notes = tests.test_musicxml.notes(result) self.assertEqual({generator.model.pitch_number(p) % 12 for n in notes for p in n['pitches'] if p['tablature']['string'] >= 5}, {expected}) def test_acoustic_bass_and_upper_chord_can_attack_together(self): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note())) prepared = generator.parts.prepare_document(source, practice=True) bar = tests.test_accompaniment.quarter_bar(4, {0, 2}) plan = generator.accompaniment.Plan((bar,), (bar,), ()) result = generator.practice.generate._practice_document(prepared, 'acoustic', plan) generator.model.validate_document(result) self.assertEqual(len(result['staves'][0]['measures'][0]['voices']), 1) notes = tests.test_musicxml.notes(result) self.assertEqual([generator.model.fraction(n['onset']) for n in notes if n['pitches']], [0, 2]) for onset in (0, 2): pitches = [p for n in notes if generator.model.fraction(n['onset']) == onset for p in n['pitches']] positions = [generator.guitar.TabPosition(p['tablature']['string'], p['tablature']['fret'], int(generator.model.pitch_number(p) - 12)) for p in pitches] self.assertIn(tuple(sorted((p.string_number for p in positions), reverse=True)), generator.guitar.ACOUSTIC_STRINGS) self.assertTrue(generator.guitar.within_fret_span(positions)) def test_long_melody_chords_and_short_notes(self): content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', 2, 'half', octave=5) + tests.test_musicxml.xml_note('G', 1, 'quarter', octave=5) + tests.test_musicxml.xml_note('C', 1, 'quarter', octave=6) notes = tests.test_musicxml.notes(self.practice(content)) self.assertIn(len(notes[0]['pitches']), (3, 4)) self.assertEqual([len(n['pitches']) for n in notes[1:]], [1, 1]) self.assertEqual(generator.model.pitch_number(notes[0]['pitches'][0]), 76) self.assertIn(sorted(p['tablature']['string'] for p in notes[0]['pitches']), ([1, 2, 3], [1, 2, 3, 4])) def test_tied_quarters_qualify_and_keep_all_strings(self): content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', 1, 'quarter', octave=5, extra='') + tests.test_musicxml.xml_note('E', 1, 'quarter', octave=5, extra='') + '2half' result = self.practice(content) first, second = tests.test_musicxml.notes(result)[:2] self.assertIn(len(first['pitches']), (3, 4)) self.assertEqual(len(first['pitches']), len(second['pitches'])) for before, after in zip(first['pitches'], second['pitches']): self.assertEqual(before['tie_to'], after['id']) self.assertEqual(after['tablature'], before['tablature']) self.assertEqual({generator.model.fraction(e['duration']) for e in generator.playback.compile_timeline(result)['events']}, {2}) def test_chord_change_reattacks_even_for_final_eighth(self): content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=7) + tests.test_musicxml.xml_note('G', 8, 'whole', octave=5) result = self.practice(content, divisions=2) notes = tests.test_musicxml.notes(result) self.assertEqual([generator.model.fraction(n['duration']) for n in notes], [fractions.Fraction(7, 2), fractions.Fraction(1, 2)]) for note in notes: self.assertIn(len(note['pitches']), (3, 4)) self.assertFalse(any('tie_from' in p or 'tie_to' in p for p in note['pitches'])) self.assertEqual(generator.model.pitch_number(note['pitches'][0]), 79) self.assertEqual({generator.model.fraction(e['onset']) for e in generator.playback.compile_timeline(result)['events']}, {0, fractions.Fraction(7, 2)}) def test_cross_measure_ties_continue_only_without_a_chord_change(self): for root in ('C', 'F'): with self.subTest(root=root): first = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', octave=5, extra='') second = tests.test_parts.xml_harmony(root) + tests.test_musicxml.xml_note('E', octave=5, extra='') source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([first, second])) result = generator.practice.generate.practice_document(source, 'electric', rhythms=tests.rhythm_fixtures.library()) generator.model.validate_document(result) notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes']] self.assertTrue(all(len(n['pitches']) in (3, 4) for n in notes)) self.assertEqual(bool(notes[0]['pitches'][0].get('tie_to')), root == 'C') self.assertEqual({generator.model.fraction(e['onset']) for e in generator.playback.compile_timeline(result)['events']}, {0} if root == 'C' else {0, 4}) def test_split_tuplet_keeps_exact_timing_and_span_end(self): content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=10) for index in range(3): extra = '32half' if index != 1: extra += '' content += tests.test_musicxml.xml_note('E', 4, 'half', octave=5, extra=extra) result = self.practice(content, divisions=3) notes = tests.test_musicxml.notes(result) self.assertEqual([generator.model.fraction(n['duration']) for n in notes], [fractions.Fraction(4, 3)] * 2 + [fractions.Fraction(2, 3)] * 2) self.assertEqual(result['staves'][0]['tuplets'][0]['end'], notes[-1]['id']) self.assertTrue(all(len(n['pitches']) in (3, 4) for n in notes)) self.assertFalse(notes[-1]['pitches'][0].get('tie_from')) def test_no_chord_releases_harmony_without_reattacking_melody(self): content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony(kind='none', offset=2) + tests.test_musicxml.xml_note('E', octave=5) result = self.practice(content) first, second = tests.test_musicxml.notes(result) self.assertIn(len(first['pitches']), (3, 4)) self.assertEqual(len(second['pitches']), 1) self.assertEqual(first['pitches'][0]['tie_to'], second['pitches'][0]['id']) timeline = generator.playback.compile_timeline(result)['events'] self.assertEqual(sum(generator.model.fraction(e['duration']) == 4 for e in timeline), 1) self.assertTrue(all(generator.model.fraction(e['onset']) == 0 for e in timeline)) def test_unplayable_harmonization_preserves_low_melody(self): content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('C', octave=4) with self.assertLogs('generator.guitar', level='WARNING'): result = self.practice(content) note = tests.test_musicxml.notes(result)[0] self.assertEqual(len(note['pitches']), 1) self.assertEqual(note['pitches'][0]['tablature'], {'string': 5, 'fret': 3}) def test_song_melody_only_is_raised_an_octave(self): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=4))) before = copy.deepcopy(source) for builder in (generator.practice.generate.practice_document, generator.arrange.generate.arrange_document): for part in ('electric', 'vocal'): result = builder(source, part, rhythms=tests.rhythm_fixtures.library()) notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] if n['pitches']] expected = 76 if part == 'electric' and builder is generator.arrange.generate.arrange_document else 64 self.assertEqual({generator.model.pitch_number(n['pitches'][0]) for n in notes}, {expected}) self.assertEqual(source, before) def test_electric_stabs_are_capped_and_do_not_continue(self): source = tests.test_accompaniment.melody_measures(1)[0] for continuation in (False, True): bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole', continuation=continuation),), fractions.Fraction(4)) events = generator.accompaniment.realize(source, bar, None, lane='electric') self.assertEqual(tests.test_accompaniment.timing(events), [(0, 4, 'rest')] if continuation else [(0, 1, 'note'), (1, 3, 'rest')])