138 lines
8.4 KiB
Python
138 lines
8.4 KiB
Python
import copy
|
|
import fractions
|
|
import pathlib
|
|
import unittest
|
|
import unittest.mock
|
|
|
|
import generator.arrange.form
|
|
import generator.arrange.generate
|
|
import generator.electric
|
|
import generator.guitar
|
|
import generator.harmony
|
|
import generator.model
|
|
import generator.musicxml
|
|
import tests.rhythm_fixtures
|
|
|
|
def harmony(symbol, onset=0):
|
|
return generator.harmony.parse_symbol(symbol) | {'id': f'chord-{onset}', 'onset': generator.model.rational(onset)}
|
|
|
|
def note(identifier, onset, chord, frets, *, melody=False, duration=1):
|
|
tuning = dict(generator.guitar.STANDARD_TUNING)
|
|
positions = tuple(generator.guitar.TabPosition(string, fret, tuning[string] + fret) for string, fret in zip((4, 3, 2, 1), frets))
|
|
pitches = generator.guitar.chord_pitches(chord, positions)
|
|
result = generator.model.make_event(identifier, fractions.Fraction(onset), fractions.Fraction(duration), 'note', pitches)
|
|
result.update(_harmony=chord, _guitar_profile='electric')
|
|
if melody:
|
|
result['_harmonize'] = True
|
|
return result
|
|
|
|
def measure(notes, harmonies=()):
|
|
return {'number': '1', 'duration': generator.model.rational(4), 'harmonies': list(harmonies), 'voices': [{'id': 'voice', 'notes': notes}]}
|
|
|
|
def shape(event):
|
|
return tuple(sorted((p['tablature']['string'], p['tablature']['fret']) for p in event['pitches']))
|
|
|
|
class ElectricVoicingTests(unittest.TestCase):
|
|
def test_mixed_pickup_bar_links_each_role_to_its_original_passage(self):
|
|
chord = harmony('C7')
|
|
pickup = note('pickup', 3, chord, (2, 3, 1, 3), melody=True)
|
|
outgoing = note('outgoing', 0, chord, (5, 3, 5, 3))
|
|
target = note('target', 0, chord, (10, 9, 11, 8))
|
|
mixed = measure([outgoing, pickup])
|
|
source = [measure([], [chord]), measure([], [harmony('G7')]), measure([], [chord])]
|
|
origins = [generator.electric.MeasureOrigin(fractions.Fraction(8), 'accompaniment', mixed, end=fractions.Fraction(3)), generator.electric.MeasureOrigin(fractions.Fraction(-3), 'melody', mixed, start=fractions.Fraction(3)), generator.electric.MeasureOrigin(fractions.Fraction(0), 'accompaniment', measure([target]))]
|
|
links = generator.electric.link_voicings(source, origins)
|
|
self.assertEqual(len(links), 1)
|
|
self.assertIs(links[0].target, target)
|
|
self.assertEqual(links[0].references, (pickup,))
|
|
def test_corresponding_melody_shapes_change_within_one_harmony(self):
|
|
chord = harmony('C7')
|
|
first = note('melody-1', 1, chord, (2, 3, 1, 3), melody=True, duration=2)
|
|
second = note('melody-2', 2, chord, (10, 9, 11, 8), melody=True, duration=2)
|
|
earlier = [note(f'accompaniment-{i}', i, chord, (5, 3, 5, 3)) for i in (0, 2)]
|
|
later = [note(f'accompaniment-next-{i}', i, chord, (5, 3, 5, 3)) for i in (0, 3)]
|
|
source = [measure([], [chord]), measure([], [copy.deepcopy(chord)])]
|
|
origins = [generator.electric.MeasureOrigin(fractions.Fraction(0), 'melody', measure([first])), generator.electric.MeasureOrigin(fractions.Fraction(4), 'melody', measure([second])), generator.electric.MeasureOrigin(fractions.Fraction(0), 'accompaniment', measure(earlier)), generator.electric.MeasureOrigin(fractions.Fraction(4), 'accompaniment', measure(later))]
|
|
links = generator.electric.link_voicings(source, origins)
|
|
self.assertEqual(len(links), 4)
|
|
before = copy.deepcopy((first, second))
|
|
generator.electric.reuse_voicings(links)
|
|
self.assertEqual((first, second), before)
|
|
self.assertEqual([shape(n) for n in earlier + later], [shape(first)] * 3 + [shape(second)])
|
|
self.assertTrue(all(p['duration'] == n['duration'] and p['written'] == n['written'] for n in earlier + later for p in n['pitches']))
|
|
def test_links_follow_actual_placement_without_copying_melody_ties(self):
|
|
chord = harmony('FM7')
|
|
reference = note('melody', 0, chord, (3, 5, 5, 5), melody=True, duration=2)
|
|
target = note('stab', 1, chord, (3, 2, 1, 0))
|
|
origins = [generator.electric.MeasureOrigin(fractions.Fraction(), role, measure([n])) for role, n in (('melody', reference), ('accompaniment', target))]
|
|
links = generator.electric.link_voicings([measure([], [chord])], origins)
|
|
for pitch in reference['pitches']:
|
|
pitch['tie_to'] = 'next-' + pitch['id']
|
|
before = copy.deepcopy(reference)
|
|
generator.electric.reuse_voicings(links)
|
|
self.assertEqual(reference, before)
|
|
self.assertEqual(shape(reference), shape(target))
|
|
self.assertTrue(all('tie_to' not in p and 'tie_from' not in p for p in target['pitches']))
|
|
self.assertEqual({p['id'] for p in target['pitches']}, {f'stab-s{i}' for i in range(1, 5)})
|
|
target['pitches'][0]['tablature']['fret'] = 20
|
|
self.assertEqual(reference, before)
|
|
def test_unharmonized_passage_does_not_borrow_from_another_occurrence(self):
|
|
chord = harmony('C7')
|
|
reference = note('melody', 0, chord, (2, 3, 1, 3), melody=True)
|
|
target = note('stab', 0, chord, (5, 3, 5, 3))
|
|
origins = [generator.electric.MeasureOrigin(fractions.Fraction(), 'melody', measure([reference])), generator.electric.MeasureOrigin(fractions.Fraction(8), 'accompaniment', measure([target]))]
|
|
for middle in (harmony('G7'), {'kind': 'none', 'degrees': [], 'onset': generator.model.rational(0)}):
|
|
source = [measure([], [chord]), measure([], [middle]), measure([], [chord])]
|
|
self.assertEqual(generator.electric.link_voicings(source, origins), [])
|
|
def test_mid_measure_changes_and_omitted_turnaround_do_not_copy_wrong_chord(self):
|
|
first, second = harmony('C7'), harmony('FM7', 2)
|
|
source = [measure([], [first, second])]
|
|
reference = note('melody', 2, second, (3, 5, 5, 5), melody=True)
|
|
target = note('stab', 3, first, (2, 3, 1, 3))
|
|
origins = [generator.electric.MeasureOrigin(fractions.Fraction(), role, measure([n])) for role, n in (('melody', reference), ('accompaniment', target))]
|
|
self.assertEqual(generator.electric.link_voicings(source, origins), [])
|
|
target['_harmony'] = second
|
|
links = generator.electric.link_voicings(source, origins)
|
|
self.assertEqual(len(links), 1)
|
|
generator.electric.reuse_voicings(links)
|
|
self.assertEqual(shape(reference), shape(target))
|
|
def test_failed_melody_harmonization_keeps_independent_accompaniment(self):
|
|
chord = harmony('FM7')
|
|
reference = note('melody', 0, chord, (3, 5, 5, 5), melody=True)
|
|
target = note('stab', 1, chord, (3, 2, 1, 0))
|
|
origins = [generator.electric.MeasureOrigin(fractions.Fraction(), role, measure([n])) for role, n in (('melody', reference), ('accompaniment', target))]
|
|
links = generator.electric.link_voicings([measure([], [chord])], origins)
|
|
reference['pitches'] = reference['pitches'][:1]
|
|
before = copy.deepcopy(target)
|
|
generator.electric.reuse_voicings(links)
|
|
self.assertEqual(target, before)
|
|
def test_real_arrangement_reuses_requested_grips_without_changing_melody(self):
|
|
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn-leaves.xml'))
|
|
before = copy.deepcopy(source)
|
|
rhythms = tests.rhythm_fixtures.library()
|
|
with unittest.mock.patch('generator.electric.reuse_voicings'):
|
|
independent = generator.arrange.generate.arrange_document(source, 'electric', rhythms=rhythms)
|
|
result = generator.arrange.generate.arrange_document(source, 'electric', rhythms=rhythms)
|
|
generator.model.validate_document(result)
|
|
self.assertEqual(source, before)
|
|
start, checked = 0, set()
|
|
for section in generator.arrange.form.full_arrangement_sections(source['staves'][0]['measures'], 'electric'):
|
|
measures = result['staves'][0]['measures'][start:start + section.measure_count]
|
|
original = independent['staves'][0]['measures'][start:start + section.measure_count]
|
|
start += section.measure_count
|
|
if section.role == 'melody':
|
|
self.assertEqual(measures, original)
|
|
if section.role != 'accompaniment':
|
|
continue
|
|
for m, old in zip(measures, original):
|
|
for voice, old_voice in zip(m['voices'], old['voices']):
|
|
self.assertEqual([{k: v for k, v in n.items() if k != 'pitches'} for n in voice['notes']], [{k: v for k, v in n.items() if k != 'pitches'} for n in old_voice['notes']])
|
|
for n in voice['notes']:
|
|
if not n['pitches'] or m['number'] not in ('4', '12', '22', '24'):
|
|
continue
|
|
expected = (3, 3, 3, 3) if m['number'] == '22' else (3, 5, 5, 5)
|
|
self.assertEqual(tuple(f for _, f in sorted(shape(n), reverse=True)), expected)
|
|
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in n['pitches']))
|
|
checked.add(m['number'])
|
|
self.assertEqual(checked, {'4', '12', '22', '24'})
|