import argparse import copy from fractions import Fraction from pathlib import Path import tempfile import unittest from unittest.mock import patch from generator.__main__ import generate_assets from generator.arrange.fretboard import find_playable_voicing, positions_for_string from generator.guitar import TabPosition, positions_for_pitch, within_fret_span from generator.model import fraction, key_from_fifths, pitch_number, rational, validate_document from generator.musicxml import load_musicxml from generator.musicxml_writer import write_musicxml from generator.practice.generate import generate_practice_scores, practice_document from generator.practice.guitar import guitar_tones, transpose_guitar_practice, validate_guitar_practice from generator.summary import validate_practice_variants from generator.transposition import TARGET_KEYS, transpose_document, transposition_interval from tests.test_musicxml import notes, parse, xml_note, xml_score OCTAVE_CLEF = 'G2-1' GUITAR_TRANSPOSE = '00-1' TAB_STAFF = 'TAB56' def technical(string, fret): return f'{string}{fret}' def all_pitches(document): return [p for staff in document['staves'] for measure in staff['measures'] for voice in measure['voices'] for note in voice['notes'] for p in note['pitches']] class GuitarPracticeTests(unittest.TestCase): def test_fret_boundaries_and_crossing_twelve(self): self.assertIn(TabPosition(6, 0, 40), positions_for_pitch(Fraction(40))) self.assertIn(TabPosition(1, 15, 79), positions_for_pitch(Fraction(79))) self.assertFalse(positions_for_pitch(Fraction(80))) self.assertFalse(positions_for_pitch(Fraction(129, 2))) self.assertEqual([p.fret for p in positions_for_string(1, 64, tuple(range(12)))], list(range(16))) self.assertTrue(within_fret_span((TabPosition(4, 11, 61), TabPosition(3, 12, 67), TabPosition(2, 14, 73)))) self.assertTrue(within_fret_span((TabPosition(6, 0, 40), TabPosition(1, 15, 79)))) self.assertFalse(within_fret_span((TabPosition(4, 10, 60), TabPosition(1, 15, 79)))) content = xml_note('G', extra=technical(1, 15), octave=5) validate_guitar_practice(parse(xml_score(content, TAB_STAFF))) with self.assertRaisesRegex(ValueError, '0-15'): validate_guitar_practice(parse(xml_score(xml_note('G', alter=1, extra=technical(1, 16), octave=5), TAB_STAFF))) def test_arranger_does_not_truncate_candidate_positions(self): def candidates(string, open_midi, pitch_classes): if string == 6: return [TabPosition(6, 15, 48)] * 8 + [TabPosition(6, 1, 48)] return {4: [TabPosition(4, 1, 52)], 3: [TabPosition(3, 1, 58)], 2: [TabPosition(2, 1, 60)]}.get(string, []) with patch('generator.arrange.fretboard.positions_for_string', side_effect=candidates): voicing = find_playable_voicing('C7') self.assertEqual(len(voicing), 4) self.assertTrue(all(p.fret == 1 for p in voicing)) def test_all_keys_preserve_exact_voicings_and_adjust_g_register(self): harmony = 'Gdominant' content = xml_note('G', 2, 'half') + xml_note('B', 2, 'half', prefix='') + xml_note('F', 2, 'half', prefix='', octave=5) content += xml_note('A', 2, 'half', alter=-1, octave=3) + xml_note('G', 2, 'half', alter=-1, prefix='') + xml_note('C', 2, 'half', prefix='', octave=5) source = practice_document(parse(xml_score(harmony + content))) original = copy.deepcopy(source) for key in TARGET_KEYS: with self.subTest(key=key): result = transpose_guitar_practice(source, key) validate_document(result) validate_guitar_practice(result) _, semitones = transposition_interval(source, key) adjustment = 12 if key == 'G' else 0 self.assertEqual(result, transpose_document(source, key, octave_shift=adjustment // 12)) shifts = {pitch_number(b) - pitch_number(a) for a, b in zip(all_pitches(source), all_pitches(result))} self.assertEqual(shifts, {semitones + adjustment}) if key == 'D': self.assertEqual(result['staves'][0]['measures'][0]['harmonies'][0]['symbol'], 'A7') self.assertEqual(source, original) def test_non_c_source_modes_and_slash_harmony(self): harmony = 'E-1majorG' source = parse(xml_score(harmony + xml_note('E', alter=-1), '-3major')) result = transpose_guitar_practice(source, 'D') self.assertEqual(pitch_number(notes(result)[0]['pitches'][0]), 62) self.assertEqual(result['staves'][0]['measures'][0]['harmonies'][0]['symbol'], 'D/F#') for mode, fifths in [('minor', -3), ('dorian', -2)]: source = parse(xml_score(xml_note(), f'{fifths}{mode}')) result = transpose_guitar_practice(source, 'D') self.assertEqual(result['metadata']['key_signature'], f'D {mode}') def test_local_keys_spelling_timing_and_ties_survive(self): source = parse(xml_score(xml_note('B', 2, 'half', alter=1, extra='') + xml_note('B', 2, 'half', alter=1, extra=''))) source['staves'][0]['measures'][0]['attributes'] = [{'onset': rational(2), 'key': key_from_fifths(-1)}] result = transpose_guitar_practice(source, 'D') validate_document(result) self.assertEqual(result, transpose_document(source, 'D')) self.assertEqual(notes(result)[0]['pitches'][0]['letter'], 'c') self.assertEqual(fraction(notes(result)[0]['pitches'][0]['alter']), 2) self.assertEqual(result['staves'][0]['measures'][0]['attributes'][0]['key'], key_from_fifths(1)) def test_keyless_reference_required_for_both_practice_types(self): source = parse(xml_score(xml_note(), '0none')) with self.assertRaisesRegex(ValueError, 'vamp:tonic'): transpose_guitar_practice(source, 'D') source['metadata']['transpose_origin'] = {'letter': 'c', 'alter': rational(0)} result = transpose_guitar_practice(source, 'D') self.assertEqual(result['metadata']['key_signature'], 'No key') self.assertEqual(result['metadata']['variant_tonic'], 'D') self.assertEqual(pitch_number(notes(result)[0]['pitches'][0]), 62) def test_octave_adjustment_does_not_change_harmony_or_key(self): source = parse(xml_score(xml_note())) low = transpose_document(source, 'D') high = transpose_document(source, 'D', octave_shift=1) self.assertEqual(low['metadata'], high['metadata']) self.assertEqual(pitch_number(notes(high)[0]['pitches'][0]) - pitch_number(notes(low)[0]['pitches'][0]), 12) with self.assertRaisesRegex(ValueError, 'integer'): transpose_document(source, 'D', octave_shift=0.5) def test_instrument_transposition_clefs_and_tab_are_applied_once(self): plain = parse(xml_score(xml_note('E', octave=3))) explicit = parse(xml_score(xml_note('E', octave=3), GUITAR_TRANSPOSE)) octave_clef = parse(xml_score(xml_note('E', octave=2), OCTAVE_CLEF)) tab = parse(xml_score(xml_note('E', octave=2, extra=technical(6, 0)), TAB_STAFF)) both = parse(xml_score(xml_note('E', octave=3), GUITAR_TRANSPOSE + OCTAVE_CLEF)) for document in (plain, explicit, octave_clef, tab, both): self.assertEqual(guitar_tones(document)[0].midi, 40) validate_guitar_practice(document) source = parse(xml_score(xml_note('E', 2, 'half', octave=3) + '' + GUITAR_TRANSPOSE + '' + xml_note('E', 2, 'half', octave=3))) self.assertEqual([tone.midi for tone in guitar_tones(source)], [40, 40]) def test_explicit_zero_transposition_overrides_implicit_guitar_octave(self): zero = '0' source = parse(xml_score(xml_note('E', octave=2), zero)) self.assertTrue(source['staves'][0]['measures'][0]['transpose']['explicit']) self.assertEqual(guitar_tones(source)[0].midi, 40) validate_guitar_practice(source) content = xml_note('E', 2, 'half', octave=3) + '' + zero + '' + xml_note('E', 2, 'half', octave=2) self.assertEqual([tone.midi for tone in guitar_tones(parse(xml_score(content, GUITAR_TRANSPOSE)))], [40, 40]) def test_real_quintuplets_and_standard_tab_tuning_survive_transposition(self): content = '' for index in range(5): tuplet = '' if index == 0 else '' if index == 4 else '' extra = '54' + tuplet + '' content += xml_note(duration=4, kind='quarter', extra=extra) source = parse(xml_score(content, divisions=5)) result = transpose_guitar_practice(source, 'Db') validate_document(result) self.assertEqual([fraction(n['duration']) for n in notes(result)], [Fraction(4, 5)] * 5) self.assertEqual(source['staves'][0]['tuplets'], result['staves'][0]['tuplets']) tuning = '6' for line, (step, octave) in enumerate((('E', 2), ('A', 2), ('D', 3), ('G', 3), ('B', 3), ('E', 4)), 1): tuning += f'{step}{octave}' tuning += '' source = parse(xml_score(xml_note('E', octave=2, extra=technical(6, 0)), TAB_STAFF + tuning)) result = transpose_guitar_practice(source, 'D') self.assertEqual(notes(result)[0]['pitches'][0]['tablature'], {'string': 6, 'fret': 2}) self.assertEqual(result['staves'][0]['measures'][0]['staff_details'], source['staves'][0]['measures'][0]['staff_details']) def test_xml_roundtrip_preserves_implicit_and_explicit_transposition(self): for attributes in ('', GUITAR_TRANSPOSE, '0'): source = parse(xml_score(xml_note(), attributes)) with tempfile.TemporaryDirectory() as temp: path = Path(temp) / 'roundtrip.xml' write_musicxml(path, source) result = load_musicxml(path) self.assertEqual(source['staves'][0]['measures'][0]['transpose'], result['staves'][0]['measures'][0]['transpose']) self.assertEqual(guitar_tones(source)[0].midi, guitar_tones(result)[0].midi) def test_tab_full_interval_is_applied_before_validating_fret(self): source = parse(xml_score(xml_note('E', octave=2, extra=technical(6, 0)), TAB_STAFF)) with self.assertRaisesRegex(ValueError, 'measure 1.*tablature'): transpose_document(source, 'G') result = transpose_guitar_practice(source, 'G') self.assertEqual(notes(result)[0]['pitches'][0]['tablature'], {'string': 6, 'fret': 7}) self.assertEqual(pitch_number(notes(result)[0]['pitches'][0]), 47) def test_held_strings_cannot_be_reassigned_when_another_voice_enters(self): content = xml_note('E', extra=technical(1, 0)) + '41' content += xml_note('F', 3, 'half', extra='2' + technical(1, 1)) source = parse(xml_score(content, OCTAVE_CLEF)) with self.assertRaisesRegex(ValueError, 'held/tied strings'): validate_guitar_practice(source) notes(source)[0]['pitches'][0].pop('tablature') validate_guitar_practice(source) def test_tied_strings_and_unequal_chord_durations(self): start = xml_note('E', 1, 'quarter', extra='' + technical(1, 0)) stop = xml_note('E', 3, 'half', extra='') other = xml_note('F', 3, 'half', prefix='', extra='' + technical(1, 1)) source = parse(xml_score(start + stop + other, OCTAVE_CLEF)) with self.assertRaisesRegex(ValueError, 'held/tied strings'): validate_guitar_practice(source) notes(source)[0]['pitches'][0].pop('tie_to') notes(source)[1]['pitches'][0].pop('tie_from') validate_guitar_practice(source) content = xml_note('E', 2, 'half', extra=technical(1, 0)) + xml_note('B', 1, 'quarter', prefix='', extra=technical(2, 0), octave=3) content += '21' + xml_note('C', 3, 'half', extra='2' + technical(2, 1)) validate_guitar_practice(parse(xml_score(content, OCTAVE_CLEF))) def test_grace_notes_at_tie_boundary_do_not_drop_the_held_string(self): start = xml_note('E', 1, 'quarter', extra='' + technical(1, 0)) grace = 'B3eighth' stop = xml_note('E', 3, 'half', extra='') validate_guitar_practice(parse(xml_score(start + grace + stop, OCTAVE_CLEF))) def test_unsupported_tuning_capo_and_microtones_fail_explicitly(self): for attributes, error in [('1', 'capo'), ('D2', 'standard six-string tuning')]: with self.assertRaisesRegex(ValueError, error): transpose_guitar_practice(parse(xml_score(xml_note(), attributes)), 'C') with self.assertRaisesRegex(ValueError, 'no whole-exercise guitar register'): transpose_guitar_practice(parse(xml_score(xml_note(alter='0.5'))), 'C') def test_no_register_and_failed_generation_preserve_previous_assets(self): content = xml_note('E', octave=2) + xml_note('E', octave=7, prefix='') with tempfile.TemporaryDirectory() as temp: root = Path(temp) (root / 'data/songs').mkdir(parents=True) source = root / 'data/practice/chord/lesson/wide.xml' source.parent.mkdir(parents=True) source.write_text(xml_score(content), encoding='utf-8') output = root / 'output' output.mkdir() (output / 'last-good').write_text('preserved') args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=Path('package/assets'), clean=True, check=False) with patch('generator.__main__.engrave_tree') as engrave: with self.assertRaisesRegex(ValueError, r'wide.xml, target key C:.*measure 1'): generate_assets(args) engrave.assert_not_called() self.assertEqual((output / 'last-good').read_text(), 'preserved') self.assertEqual(list(output.iterdir()), [output / 'last-good']) def test_all_practice_types_require_twelve_deterministic_variants(self): with tempfile.TemporaryDirectory() as temp: source, output = Path(temp) / 'source', Path(temp) / 'output' for practice_type in ('chord', 'melody'): path = source / practice_type / '001_lesson' / '001-exercise.xml' path.parent.mkdir(parents=True) path.write_text(xml_score(xml_note()), encoding='utf-8') self.assertEqual(generate_practice_scores(source, output), 24) validate_practice_variants(output) first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*.json')} generate_practice_scores(source, output) self.assertEqual(first, {p.relative_to(output): p.read_bytes() for p in output.rglob('*.json')}) next((output / 'chord').rglob('g.json')).unlink() with self.assertRaisesRegex(ValueError, 'chord practice generator drift'): validate_practice_variants(output)