Files
product-vamp/tests/test_guitar_practice.py
T
khodowany 01c6d4e786 Transpose
2026-09-16 21:33:34 -05:00

221 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_current_catalogue_all_keys_preserve_exact_voicings(self):
source = practice_document(load_musicxml(Path('data/practice/chord/01_three_note_shell_voicings/01-cycles.xml')))
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:
output = Path(temp)
self.assertEqual(generate_practice_scores(Path('data/practice'), output), 48)
validate_practice_variants(output)
first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*.json')}
generate_practice_scores(Path('data/practice'), 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)