228 lines
15 KiB
Python
228 lines
15 KiB
Python
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 = '<clef><sign>G</sign><line>2</line><clef-octave-change>-1</clef-octave-change></clef>'
|
|
GUITAR_TRANSPOSE = '<transpose><diatonic>0</diatonic><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>'
|
|
TAB_STAFF = '<clef><sign>TAB</sign><line>5</line></clef><staff-details><staff-lines>6</staff-lines></staff-details>'
|
|
|
|
def technical(string, fret):
|
|
return f'<notations><technical><string>{string}</string><fret>{fret}</fret></technical></notations>'
|
|
|
|
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 = '<harmony><root><root-step>G</root-step></root><kind>dominant</kind></harmony>'
|
|
content = xml_note('G', 2, 'half') + xml_note('B', 2, 'half', prefix='<chord/>') + xml_note('F', 2, 'half', prefix='<chord/>', octave=5)
|
|
content += xml_note('A', 2, 'half', alter=-1, octave=3) + xml_note('G', 2, 'half', alter=-1, prefix='<chord/>') + xml_note('C', 2, 'half', prefix='<chord/>', 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 = '<harmony><root><root-step>E</root-step><root-alter>-1</root-alter></root><kind>major</kind><bass><bass-step>G</bass-step></bass></harmony>'
|
|
source = parse(xml_score(harmony + xml_note('E', alter=-1), '<key><fifths>-3</fifths><mode>major</mode></key>'))
|
|
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'<key><fifths>{fifths}</fifths><mode>{mode}</mode></key>'))
|
|
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='<tie type="start"/>') + xml_note('B', 2, 'half', alter=1, extra='<tie type="stop"/>')))
|
|
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(), '<key><fifths>0</fifths><mode>none</mode></key>'))
|
|
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) + '<attributes>' + GUITAR_TRANSPOSE + '</attributes>' + 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 = '<transpose><chromatic>0</chromatic></transpose>'
|
|
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) + '<attributes>' + zero + '</attributes>' + 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 = '<tuplet type="start"/>' if index == 0 else '<tuplet type="stop"/>' if index == 4 else ''
|
|
extra = '<time-modification><actual-notes>5</actual-notes><normal-notes>4</normal-notes></time-modification><notations>' + tuplet + '</notations>'
|
|
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 = '<staff-details><staff-lines>6</staff-lines>'
|
|
for line, (step, octave) in enumerate((('E', 2), ('A', 2), ('D', 3), ('G', 3), ('B', 3), ('E', 4)), 1):
|
|
tuning += f'<staff-tuning line="{line}"><tuning-step>{step}</tuning-step><tuning-octave>{octave}</tuning-octave></staff-tuning>'
|
|
tuning += '</staff-details>'
|
|
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, '<transpose><chromatic>0</chromatic></transpose>'):
|
|
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)) + '<backup><duration>4</duration></backup><forward><duration>1</duration></forward>'
|
|
content += xml_note('F', 3, 'half', extra='<dot/><voice>2</voice>' + 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='<tie type="start"/>' + technical(1, 0))
|
|
stop = xml_note('E', 3, 'half', extra='<dot/><tie type="stop"/>')
|
|
other = xml_note('F', 3, 'half', prefix='<chord/>', extra='<dot/>' + 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='<chord/>', extra=technical(2, 0), octave=3)
|
|
content += '<backup><duration>2</duration></backup><forward><duration>1</duration></forward>' + xml_note('C', 3, 'half', extra='<dot/><voice>2</voice>' + 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='<tie type="start"/>' + technical(1, 0))
|
|
grace = '<note><grace/><pitch><step>B</step><octave>3</octave></pitch><type>eighth</type></note>'
|
|
stop = xml_note('E', 3, 'half', extra='<dot/><tie type="stop"/>')
|
|
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 [('<staff-details><capo>1</capo></staff-details>', 'capo'), ('<staff-details><staff-tuning line="1"><tuning-step>D</tuning-step><tuning-octave>2</tuning-octave></staff-tuning></staff-details>', '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='<chord/>')
|
|
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)
|