import argparse import copy import fractions import pathlib import random import tempfile import unittest import unittest.mock import generator.__main__ import generator.arrange.generate import generator.guitar import generator.harmony import generator.model import generator.parts import generator.pitch import generator.playback import generator.practice.generate import generator.summary import tests.rhythm_fixtures import tests.test_musicxml class GuitarTests(unittest.TestCase): def test_fret_boundaries_and_span(self): self.assertIn(generator.guitar.TabPosition(6, 0, 40), generator.guitar.positions_for_pitch(fractions.Fraction(40))) self.assertIn(generator.guitar.TabPosition(1, 20, 84), generator.guitar.positions_for_pitch(fractions.Fraction(84))) self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(85))) self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(129, 2))) self.assertTrue(generator.guitar.within_fret_span((generator.guitar.TabPosition(4, 11, 61), generator.guitar.TabPosition(3, 12, 67), generator.guitar.TabPosition(2, 14, 73)))) self.assertFalse(generator.guitar.within_fret_span((generator.guitar.TabPosition(6, 0, 40), generator.guitar.TabPosition(1, 20, 84)))) self.assertFalse(generator.guitar.within_fret_span((generator.guitar.TabPosition(4, 10, 60), generator.guitar.TabPosition(1, 20, 84)))) def test_open_strings_count_in_span_and_proximity(self): for frets, valid in (((0, 2, 3), True), ((0, 4), True), ((0, 5), False), ((12, 0, 11), False), ((0, 0), True)): with self.subTest(frets=frets): positions = tuple(generator.guitar.TabPosition(6 - i, fret, 0) for i, fret in enumerate(frets)) self.assertEqual(generator.guitar.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.guitar.within_fret_span(())) def test_held_open_string_cannot_mix_with_high_frets(self): held = tests.test_musicxml.xml_note('E', octave=2) high = tests.test_musicxml.xml_note('C', 2, 'half', octave=6, extra='2') document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(held + '42' + high)) with self.assertRaisesRegex(ValueError, 'no guitar placement'): generator.guitar.place_guitar(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): required, allowed, bass = generator.guitar.chord_requirements(generator.harmony.parse_symbol(symbol)) candidates = generator.guitar.chord_positions(required, allowed, bass) self.assertTrue(candidates) for candidate in candidates: covered = {p.midi % 12 for p in candidate} self.assertTrue(set(required) <= covered <= set(allowed)) self.assertEqual(len(candidate), len({p.string_number for p in candidate})) self.assertTrue(generator.guitar.within_fret_span(candidate)) if bass is not None: self.assertEqual(min(p.midi for p in candidate) % 12, bass) self.assertIn(2, generator.guitar.chord_requirements(generator.harmony.parse_symbol('Cmadd9'))[0]) self.assertNotIn(7, generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7'))[0]) self.assertIn(7, generator.guitar.chord_requirements(generator.harmony.parse_symbol('CM'))[0]) self.assertEqual(generator.guitar.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 0})[2], 0) self.assertEqual(generator.guitar.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 1})[2], 4) self.assertTrue(any(p.fret > 12 for c in generator.guitar.chord_positions(*generator.guitar.chord_requirements(generator.harmony.parse_symbol('CM'))) for p in c)) 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 = generator.practice.generate.practice_document(source, 'electric', rhythms=tests.rhythm_fixtures.library()) vocal = generator.practice.generate.practice_document(source, 'vocal', rhythms=tests.rhythm_fixtures.library()) generator.model.validate_document(electric) self.assertEqual(source, before) self.assertEqual(generator.playback.compile_timeline(electric)['events'], generator.playback.compile_timeline(vocal)['events']) self.assertEqual([(e['pitch'], e['onset'], e['duration']) for e in generator.playback.compile_timeline(electric)['events']], [(generator.model.rational(generator.model.fraction(e['pitch']) - 12), 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])) 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 = generator.practice.generate.practice_document(source, part, rhythms=tests.rhythm_fixtures.library()) self.assertEqual(generator.model.fraction(generator.playback.compile_timeline(document)['events'][0]['pitch']), expected) 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}), (7, 0, 84, {'string': 1, 'fret': 20})): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=octave, alter=alter))) document = generator.practice.generate.practice_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 = generator.practice.generate.practice_document(source, 'electric', rhythms=tests.rhythm_fixtures.library()) self.assertEqual([generator.model.fraction(e['pitch']) for e in generator.playback.compile_timeline(document)['events']], [48, 60]) def test_all_parts_share_written_octave_convention_in_songs_and_practice(self): chord = 'Cmajor' source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord + tests.test_musicxml.xml_note())) for generate in (generator.practice.generate.practice_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('F', 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_proximity_chooses_a_path_without_relocating_tied_notes(self): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('B', 2, 'half', octave=5) + tests.test_musicxml.xml_note('C', 2, 'half', octave=6))) generator.guitar.place_guitar(source) tabs = [n['pitches'][0]['tablature'] for n in tests.test_musicxml.notes(source)] self.assertEqual(tabs, [{'string': 1, 'fret': 19}, {'string': 1, 'fret': 20}]) def test_grid_search_matches_exhaustive_costs_and_tie_breaks(self): randomizer = random.Random(17) states = {} for index in range(40): positions = tuple(generator.guitar.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 range(6))), 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('C', 2, 'half', octave=7, 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': 20}) self.assertNotIn(1, [p['tablature']['string'] for p in chord['pitches']]) self.assertEqual(len(generator.playback.compile_timeline(document)['events']), 4) 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), '', '0-20'), (tests.test_musicxml.xml_note(alter='0.5'), '', '0-20'), (tests.test_musicxml.xml_note() + tests.test_musicxml.xml_note('E', prefix=''), '', 'monophonic'), (tests.test_musicxml.xml_note() + '4' + tests.test_musicxml.xml_note('E', extra='2'), '', 'one monophonic voice'), (tests.test_musicxml.xml_note(), '1', 'capo')] for content, attributes, error in cases: with self.subTest(error=error), self.assertRaisesRegex(ValueError, error): generator.practice.generate.practice_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.practice.generate.generate_practice_scores(source, output, rhythms=tests.rhythm_fixtures.library()), 3) generator.summary.validate_practice_parts(output) first = {p.name: p.read_bytes() for p in output.rglob('*.json')} generator.practice.generate.generate_practice_scores(source, output, rhythms=tests.rhythm_fixtures.library()) self.assertEqual(first, {p.name: p.read_bytes() for p in output.rglob('*.json')}) (output / '0000/vocal.json').unlink() with self.assertRaisesRegex(ValueError, 'practice generator drift'): generator.summary.validate_practice_parts(output) def test_unplayable_generation_preserves_previous_assets(self): with tempfile.TemporaryDirectory() as temporary: root = pathlib.Path(temporary) (root / 'data/songs').mkdir(parents=True) (root / 'data/practice').mkdir() (root / 'data/practice/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, part electric:.*0-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')