import tests.production_fixtures import argparse import copy import fractions import pathlib import random import tempfile import unittest import unittest.mock import generator.__main__ import generator.acoustic_voicing import generator.arrange.generate import generator.fretboard import generator.guitar import generator.harmony import generator.model import generator.parts import generator.pitch import generator.playback import tests.arrangement_fixtures import tests.rhythm_fixtures import tests.scale_fixtures import tests.solo_fixtures import tests.test_musicxml class GuitarTests(unittest.TestCase): def setUp(self): tests.production_fixtures.install(self) def test_fret_boundaries_and_span(self): self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(40))) self.assertIn(generator.fretboard.TabPosition(6, 1, 41), generator.fretboard.positions_for_pitch(fractions.Fraction(41))) self.assertIn(generator.fretboard.TabPosition(1, 20, 84), generator.fretboard.positions_for_pitch(fractions.Fraction(84))) self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(85))) self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(129, 2))) self.assertTrue(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 11, 61), generator.fretboard.TabPosition(3, 12, 67), generator.fretboard.TabPosition(2, 13, 72)))) self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 11, 61), generator.fretboard.TabPosition(3, 12, 67), generator.fretboard.TabPosition(2, 14, 73)))) self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(6, 0, 40), generator.fretboard.TabPosition(1, 20, 84)))) self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 10, 60), generator.fretboard.TabPosition(1, 20, 84)))) def test_fretted_positions_keep_span_and_proximity_scoring(self): for frets, valid in (((1, 2, 3), True), ((1, 4), False), ((1, 5), False), ((7, 9), True), ((7, 10), False), ((7, 11), False), ((12, 1, 11), False), ((3, 3), True), ((18, 20), True)): with self.subTest(frets=frets): positions = tuple(generator.fretboard.TabPosition(6 - i, fret, 0) for i, fret in enumerate(frets)) self.assertEqual(generator.fretboard.within_fret_span(positions), valid) self.assertEqual(generator.guitar.compactness(positions), max(frets) - min(frets)) self.assertEqual(generator.guitar.position_center(positions)[0], min(frets) + max(frets)) self.assertTrue(generator.fretboard.within_fret_span(())) def test_held_notes_obey_the_shared_three_fret_limit(self): held = {'held': generator.fretboard.TabPosition(4, 7, 57)} for fret, valid in ((9, True), (10, False), (11, False)): with self.subTest(fret=fret): pending = [('attack', (generator.fretboard.TabPosition(2, fret, 59 + fret),))] self.assertEqual(bool(list(generator.guitar.extend_positions(held, pending))), valid) def test_low_open_e_fails_without_octave_fitting_or_mutation(self): document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=2))) before = copy.deepcopy(document) with self.assertRaisesRegex(ValueError, 'measure_1, event .*pitch 40.*1-20'): generator.guitar.place_guitar(document) self.assertEqual(document, before) def test_open_string_pitch_uses_a_fretted_alternative(self): for _, midi in generator.fretboard.STANDARD_TUNING[1:]: positions = generator.fretboard.positions_for_pitch(fractions.Fraction(midi)) self.assertTrue(positions) self.assertTrue(all(p.fret >= 1 and p.midi == midi for p in positions)) document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=4))) generator.guitar.place_guitar(document) tone = tests.test_musicxml.notes(document)[0]['pitches'][0] self.assertGreater(tone['tablature']['fret'], 0) self.assertEqual(generator.model.pitch_number(tone), 64) def test_explicit_open_tab_is_rejected_without_substitution(self): document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=4))) tests.test_musicxml.notes(document)[0]['pitches'][0]['tablature'] = {'string': 1, 'fret': 0} before = copy.deepcopy(document) with self.assertRaisesRegex(ValueError, 'event .*1-20'): generator.guitar.place_guitar(document) self.assertEqual(document, before) document['staves'][0]['tablature'] = True with self.assertRaisesRegex(ValueError, 'invalid generated tablature'): generator.model.validate_document(document) def test_theory_controls_omissions_and_bass(self): for symbol in ('CM', 'G7b9', 'FM7#11/A', 'CmM7', 'Cdim7', 'C13', 'Csus', 'Cmadd9'): with self.subTest(symbol=symbol): requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol(symbol)) candidates = generator.guitar.chord_positions(requirements) self.assertTrue(candidates) for candidate in candidates: covered = {p.midi % 12 for p in candidate} self.assertTrue(requirements.covers(covered)) self.assertTrue(covered <= set(requirements.allowed)) self.assertEqual(len(candidate), len({p.string_number for p in candidate})) self.assertTrue(generator.fretboard.within_fret_span(candidate)) self.assertTrue(generator.fretboard.has_fret_anchor(candidate)) self.assertTrue(all(1 <= p.fret <= 20 for p in candidate)) if requirements.basses: self.assertIn(min(p.midi for p in candidate) % 12, requirements.basses) self.assertIn(2, generator.fretboard.chord_requirements(generator.harmony.parse_symbol('Cmadd9')).required) self.assertNotIn(7, generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C7')).required) self.assertEqual(generator.fretboard.chord_requirements(generator.harmony.parse_symbol('CM')).foundation, (0, 7)) self.assertEqual(generator.fretboard.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 0}).basses, (0,)) self.assertEqual(generator.fretboard.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 1}).basses, (4,)) self.assertTrue(any(p.fret > 12 for c in generator.guitar.chord_positions(generator.fretboard.chord_requirements(generator.harmony.parse_symbol('CM'))) for p in c)) def test_root_or_fifth_is_required_for_every_generated_profile(self): for profile in ('', 'acoustic', 'electric'): requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C7'), respect_bass=profile != 'electric') for classes in ({0, 4, 10}, {4, 7, 10}, {0, 4, 7, 10}): self.assertTrue(requirements.covers(classes)) self.assertFalse(requirements.covers({4, 10})) candidates = generator.acoustic_voicing.candidates(requirements) if profile == 'acoustic' else generator.guitar.chord_positions(requirements, prune=False, profile=profile) self.assertTrue(candidates) self.assertTrue(all({p.midi % 12 for p in shape} & {0, 7} for shape in candidates)) self.assertEqual({frozenset({p.midi % 12 for p in shape} & {0, 7}) for shape in candidates}, {frozenset({0}), frozenset({7}), frozenset({0, 7})}) def test_altered_and_removed_fifths_keep_their_semantics(self): for symbol, fifth in (('Cdim7', 6), ('Caug7', 8)): requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol(symbol), respect_bass=False) self.assertEqual(requirements.foundation, (0, fifth)) self.assertIn(fifth, requirements.required) self.assertNotIn(7, requirements.allowed) for omitted, remaining in ((1, 7), (5, 0)): chord = generator.harmony.parse_symbol('C7') chord['degrees'].append({'value': omitted, 'alter': 0, 'type': 'subtract'}) for profile in ('acoustic', 'electric'): requirements = generator.fretboard.chord_requirements(chord, respect_bass=profile != 'electric') self.assertEqual(requirements.foundation, (remaining,)) candidates = generator.acoustic_voicing.candidates(requirements) if profile == 'acoustic' else generator.guitar.chord_positions(requirements, profile=profile) self.assertTrue(candidates) self.assertTrue(all(remaining in {p.midi % 12 for p in c} for c in candidates)) chord['degrees'].append({'value': 1, 'alter': 0, 'type': 'subtract'}) with self.assertRaisesRegex(ValueError, 'root or fifth'): generator.fretboard.chord_requirements(chord) def test_explicit_degree_chord_uses_the_same_root_or_fifth_rule(self): standard = generator.harmony.parse_symbol('C7') explicit = {**standard, 'kind': 'other', 'degrees': [{'value': d, 'alter': a, 'type': 'add'} for d, a in generator.harmony.degrees(standard).items()]} for profile in ('', 'acoustic', 'electric'): self.assertEqual(generator.fretboard.chord_requirements(standard, respect_bass=profile != 'electric'), generator.fretboard.chord_requirements(explicit, respect_bass=profile != 'electric')) def test_melody_placement_preserves_notes_timing_and_sound(self): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', 2, 'half', octave=4) + tests.test_musicxml.xml_note('F', 2, 'half', octave=4))) before = copy.deepcopy(source) electric = tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library()) vocal = tests.arrangement_fixtures.section_document(source, 'vocal', rhythms=tests.rhythm_fixtures.library()) generator.model.validate_document(electric) self.assertEqual(source, before) self.assertEqual([(e['onset'], e['duration'], generator.model.fraction(e['pitch']) - 12) for e in generator.playback.compile_timeline(electric)['events']], [(e['onset'], e['duration'], generator.model.fraction(e['pitch'])) for e in generator.playback.compile_timeline(vocal)['events']]) self.assertEqual([(e['pitch'], e['onset'], e['duration']) for e in generator.playback.compile_timeline(electric)['events']], [(e['pitch'], e['onset'], e['duration']) for e in generator.playback.compile_timeline(source)['events']]) for original, generated in zip(tests.test_musicxml.notes(source), tests.test_musicxml.notes(electric)): self.assertEqual(generator.model.pitch_number(generated['pitches'][0]), generator.model.pitch_number(original['pitches'][0]) + 12) self.assertIn('tablature', generated['pitches'][0]) def test_instrument_transposition_and_octave_clef_applied_once(self): for attributes, expected in (('', 48), ('-12-7', 48), ('0', 60), ('G2-1', 60), ('F4', 48)): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes)) for part in ('electric', 'vocal'): document = tests.arrangement_fixtures.section_document(source, part, rhythms=tests.rhythm_fixtures.library()) self.assertEqual(generator.model.fraction(generator.playback.compile_timeline(document)['events'][0]['pitch']), expected + (12 if part == 'electric' else 0)) def test_written_middle_c_and_c_sharp_six_have_matching_tab_and_audio(self): for octave, alter, expected, tab in ((4, 0, 48, {'string': 5, 'fret': 3}), (6, 1, 73, {'string': 1, 'fret': 9})): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=octave - 1, alter=alter))) document = tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library()) tone = tests.test_musicxml.notes(document)[0]['pitches'][0] self.assertEqual(generator.model.pitch_number(tone), 12 * (octave + 1) + alter) self.assertEqual(tone['tablature'], tab) self.assertEqual(generator.model.fraction(generator.playback.compile_timeline(document)['events'][0]['pitch']), expected) generator.model.validate_document(document) def test_guitar_normalization_is_idempotent_and_respects_timed_transposition(self): change = '0' source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + change + tests.test_musicxml.xml_note(duration=2, kind='half'))) before = copy.deepcopy(source) concert = generator.pitch.concert_document(source, octave_transposing=True) self.assertEqual(generator.pitch.concert_document(concert, octave_transposing=True), concert) self.assertEqual(source, before) document = tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library()) self.assertEqual([generator.model.fraction(e['pitch']) for e in generator.playback.compile_timeline(document)['events']], [60, 72]) def test_all_parts_share_written_octave_convention(self): chord = 'Cmajor' source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord + tests.test_musicxml.xml_note())) for generate in (tests.arrangement_fixtures.section_document, generator.arrange.generate.arrange_document): for part in generator.parts.PART_TYPES: with self.subTest(generator=generate.__name__, part=part): document = generate(source, part, rhythms=tests.rhythm_fixtures.library()) generator.model.validate_document(document) written = {p['id']: generator.model.pitch_number(p) for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']} events = generator.playback.compile_timeline(document)['events'] self.assertTrue(events) self.assertTrue(all(generator.model.fraction(e['pitch']) == written[e['id']] - 12 for e in events)) if part == 'vocal': self.assertEqual(set(written.values()), {60}) self.assertTrue(all(m['transpose']['octave_change'] == -1 for m in document['staves'][0]['measures'])) def test_ties_and_overlapping_voices_keep_strings(self): content = tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='') + tests.test_musicxml.xml_note('E', 3, 'half', octave=4, extra='') content += '41' + tests.test_musicxml.xml_note('G', 3, 'half', octave=4, extra='2') document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)) generator.guitar.place_guitar(document) first, continuation, other = [p['tablature'] for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']] self.assertEqual(first, continuation) self.assertNotEqual(continuation['string'], other['string']) self.assertEqual(len(generator.playback.compile_timeline(document)['events']), 2) def test_overlapping_tones_cannot_expand_the_span_or_relocate_a_tie(self): content = tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='') + tests.test_musicxml.xml_note('E', 3, 'half', octave=4, extra='') content += '41' + tests.test_musicxml.xml_note('F', 3, 'half', octave=4, extra='2') document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)) before = copy.deepcopy(document) with self.assertRaisesRegex(ValueError, '3 consecutive fret positions.*held/tied strings'): generator.guitar.place_guitar(document) self.assertEqual(document, before) def test_proximity_chooses_a_path_without_relocating_tied_notes(self): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D', 2, 'half', octave=5) + tests.test_musicxml.xml_note('E', 2, 'half', octave=5))) generator.guitar.place_guitar(source) tabs = [n['pitches'][0]['tablature'] for n in tests.test_musicxml.notes(source)] self.assertEqual(tabs, [{'string': 1, 'fret': 10}, {'string': 1, 'fret': 12}]) def test_grid_search_matches_exhaustive_costs_and_tie_breaks(self): randomizer = random.Random(17) states = {} for index in range(40): positions = tuple(generator.fretboard.TabPosition(s, randomizer.randrange(21), 0) for s in randomizer.sample(range(1, 7), 3)) states[index] = generator.guitar.Placement(generator.guitar.VoicingCost(*(randomizer.randrange(8) for _ in generator.guitar.VoicingCost._fields)), tuple((str(i), p) for i, p in enumerate(positions)), None, ()) grid = generator.guitar.nearest_positions(states) for x, row in enumerate(grid): for y, actual in enumerate(row): candidates = [] for index, state in enumerate(states.values()): a, b = generator.guitar.position_center(tuple(p for _, p in state.active)) candidates.append((state.cost.moved(abs(x - a) + abs(y - b)), index)) self.assertEqual(actual[:2], min(candidates)) def test_generated_chord_retains_strings_when_a_melody_enters(self): content = tests.test_musicxml.xml_note() + '42' + tests.test_musicxml.xml_note('E', 2, 'half', octave=6, extra='2') document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)) measure = document['staves'][0]['measures'][0] measure['transpose']['octave_change'] = -1 chord, melody = [n for v in measure['voices'] for n in v['notes']] chord['pitches'] = [] chord['_harmony'] = generator.harmony.parse_symbol('CM') generator.guitar.place_guitar(document) generator.model.validate_document(document) self.assertEqual(melody['pitches'][0]['tablature'], {'string': 1, 'fret': 12}) self.assertNotIn(1, [p['tablature']['string'] for p in chord['pitches']]) self.assertEqual(len(generator.playback.compile_timeline(document)['events']), 3) def test_more_than_six_simultaneous_tones_fail_without_omission(self): content = '4'.join(tests.test_musicxml.xml_note(extra=f'{i}') for i in range(7)) document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)) before = copy.deepcopy(document) with self.assertRaisesRegex(ValueError, 'no guitar placement'): generator.guitar.place_guitar(document) self.assertEqual(before, document) def test_source_contract_and_unplayable_music_fail(self): cases = [(tests.test_musicxml.xml_note(octave=7, alter=1), '', '1-20'), (tests.test_musicxml.xml_note(alter='0.5'), '', '1-20'), (tests.test_musicxml.xml_note(), '1', 'capo')] for content, attributes, error in cases: with self.subTest(error=error), self.assertRaisesRegex(ValueError, error): tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, attributes)), 'electric', rhythms=tests.rhythm_fixtures.library()) for content, attributes in [(tests.test_musicxml.xml_note(), 'TAB'), (tests.test_musicxml.xml_note(extra='1'), '')]: with self.assertRaisesRegex(ValueError, 'authored TAB'): tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, attributes)) def test_three_parts_are_deterministic_without_transposition(self): with tempfile.TemporaryDirectory() as temporary: source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output' source.mkdir() (source / '0000.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', alter=-1), '-3')) self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library(), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library()), 3) first = {p.name: p.read_bytes() for p in output.rglob('*.json')} generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library(), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library()) self.assertEqual(first, {p.name: p.read_bytes() for p in output.rglob('*.json')}) def test_unplayable_generation_preserves_previous_assets(self): self.enterContext(unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library())) self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library())) with tempfile.TemporaryDirectory() as temporary: root = pathlib.Path(temporary) (root / 'data/songs').mkdir(parents=True) (root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=7, alter=1))) output = root / 'output' output.mkdir() (output / 'last-good').write_text('preserved') args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False) with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml/electric_melody:.*1-20'): generator.__main__.generate_assets(args) self.assertEqual([p.name for p in output.iterdir()], ['last-good']) self.assertEqual((output / 'last-good').read_text(), 'preserved') with unittest.mock.patch('generator.comping.voicings', side_effect=ValueError('no legal sparse voicing')), unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()): (root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score('Cdominant'+tests.test_musicxml.xml_note())) with self.assertRaisesRegex(ValueError, 'no legal sparse voicing'): generator.__main__.generate_assets(args) self.assertEqual((output / 'last-good').read_text(), 'preserved')