254 lines
20 KiB
Python
254 lines
20 KiB
Python
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 tests.arrangement_fixtures
|
|
import tests.rhythm_fixtures
|
|
import tests.test_musicxml
|
|
|
|
class GuitarTests(unittest.TestCase):
|
|
def test_fret_boundaries_and_span(self):
|
|
self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(40)))
|
|
self.assertIn(generator.guitar.TabPosition(6, 1, 41), generator.guitar.positions_for_pitch(fractions.Fraction(41)))
|
|
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_fretted_positions_keep_span_and_proximity_scoring(self):
|
|
for frets, valid in (((1, 2, 3), True), ((1, 5), True), ((1, 6), False), ((12, 1, 11), False), ((3, 3), 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_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.guitar.STANDARD_TUNING[1:]:
|
|
positions = generator.guitar.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.guitar.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.guitar.within_fret_span(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.guitar.chord_requirements(generator.harmony.parse_symbol('Cmadd9')).required)
|
|
self.assertNotIn(7, generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7')).required)
|
|
self.assertEqual(generator.guitar.chord_requirements(generator.harmony.parse_symbol('CM')).foundation, (0, 7))
|
|
self.assertEqual(generator.guitar.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 0}).basses, (0,))
|
|
self.assertEqual(generator.guitar.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.guitar.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.guitar.chord_requirements(generator.harmony.parse_symbol('C7'), profile)
|
|
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.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.guitar.chord_requirements(generator.harmony.parse_symbol(symbol), 'electric')
|
|
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.guitar.chord_requirements(chord, profile)
|
|
self.assertEqual(requirements.foundation, (remaining,))
|
|
self.assertTrue(all(remaining in {p.midi % 12 for p in c} for c in generator.guitar.chord_positions(requirements, profile=profile)))
|
|
chord['degrees'].append({'value': 1, 'alter': 0, 'type': 'subtract'})
|
|
with self.assertRaisesRegex(ValueError, 'root or fifth'):
|
|
generator.guitar.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.guitar.chord_requirements(standard, profile), generator.guitar.chord_requirements(explicit, profile))
|
|
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), ('<transpose><chromatic>-12</chromatic><diatonic>-7</diatonic></transpose>', 48), ('<transpose><chromatic>0</chromatic></transpose>', 60), ('<clef><sign>G</sign><line>2</line><clef-octave-change>-1</clef-octave-change></clef>', 60), ('<clef><sign>F</sign><line>4</line></clef>', 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}), (7, 0, 84, {'string': 1, 'fret': 20})):
|
|
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 = '<attributes><transpose><chromatic>0</chromatic></transpose></attributes>'
|
|
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 = '<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>'
|
|
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='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 3, 'half', octave=4, extra='<dot/><tie type="stop"/>')
|
|
content += '<backup><duration>4</duration></backup><forward><duration>1</duration></forward>' + tests.test_musicxml.xml_note('F', 3, 'half', octave=4, extra='<dot/><voice>2</voice>')
|
|
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 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() + '<backup><duration>4</duration></backup><forward><duration>2</duration></forward>' + tests.test_musicxml.xml_note('C', 2, 'half', octave=7, extra='<voice>2</voice>')
|
|
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 = '<backup><duration>4</duration></backup>'.join(tests.test_musicxml.xml_note(extra=f'<voice>{i}</voice>') 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(), '<staff-details><capo>1</capo></staff-details>', '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(), '<clef><sign>TAB</sign></clef>'), (tests.test_musicxml.xml_note(extra='<notations><technical><fingering>1</fingering></technical></notations>'), '')]:
|
|
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), '<key><fifths>-3</fifths></key>'))
|
|
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_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())
|
|
self.assertEqual(first, {p.name: p.read_bytes() for p in output.rglob('*.json')})
|
|
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/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, part electric:.*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')
|
|
harmony = '<harmony><root><root-step>C</root-step></root><kind>dominant</kind><degree><degree-value>9</degree-value><degree-alter>-1</degree-alter><degree-type>add</degree-type></degree><degree><degree-value>11</degree-value><degree-alter>1</degree-alter><degree-type>add</degree-type></degree></harmony>'
|
|
(root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score(harmony + tests.test_musicxml.xml_note()))
|
|
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml, part acoustic:.*measure 1.*no acoustic grip.*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')
|