192 lines
12 KiB
Python
192 lines
12 KiB
Python
import copy
|
|
import fractions
|
|
import unittest
|
|
|
|
import generator.accompaniment
|
|
import generator.acoustic
|
|
import generator.guitar
|
|
import generator.harmony
|
|
import generator.model
|
|
import tests.test_acoustic
|
|
import tests.test_accompaniment
|
|
|
|
def planned_document(harmonies, attacks=None):
|
|
if attacks is None:
|
|
attacks = [([(0, 1), (2, 1)], [(1, 1), (3, 1)])] * len(harmonies)
|
|
document = tests.test_acoustic.acoustic_document(attacks)
|
|
previous = None
|
|
for measure, changes in zip(document['staves'][0]['measures'], harmonies):
|
|
measure['harmonies'] = []
|
|
for onset, symbol in changes:
|
|
harmony = {'kind': 'none', 'degrees': []} if symbol == 'N.C.' else generator.harmony.parse_symbol(symbol) if isinstance(symbol, str) else copy.deepcopy(symbol)
|
|
measure['harmonies'].append(harmony | {'id': f'{measure["id"]}-h{len(measure["harmonies"])}', 'onset': generator.model.rational(onset)})
|
|
for voice in measure['voices']:
|
|
for note in voice['notes']:
|
|
if not note['pitches']:
|
|
continue
|
|
note['pitches'] = []
|
|
harmony = generator.accompaniment.active_chord(measure, generator.model.fraction(note['onset']), previous)
|
|
if harmony:
|
|
note.update(_harmony=harmony, _guitar_profile='acoustic', _guitar_lane=voice['id'])
|
|
else:
|
|
note['kind'] = 'rest'
|
|
if measure['harmonies']:
|
|
previous = measure['harmonies'][-1]
|
|
generator.acoustic.prepare(document)
|
|
return document
|
|
|
|
def shape(note):
|
|
tuning = dict(generator.guitar.STANDARD_TUNING)
|
|
return tuple(sorted(generator.guitar.TabPosition(p['tablature']['string'], p['tablature']['fret'], tuning[p['tablature']['string']] + p['tablature']['fret']) for p in note['pitches']))
|
|
|
|
class AcousticVoicingTests(unittest.TestCase):
|
|
def place(self, document):
|
|
spans = generator.acoustic.harmony_spans(document)
|
|
lanes = {n['id']: n['_guitar_lane'] for span in spans for n in span.notes}
|
|
before = [tests.test_accompaniment.timing(v['notes']) for m in document['staves'][0]['measures'] for v in m['voices']]
|
|
generator.guitar.place_guitar(document)
|
|
self.assertEqual(before, [tests.test_accompaniment.timing(v['notes']) for m in document['staves'][0]['measures'] for v in m['voices']])
|
|
for span in spans:
|
|
basses = {shape(n) for n in span.notes if lanes[n['id']] == 'bass'}
|
|
uppers = {shape(n) for n in span.notes if lanes[n['id']] == 'upper'}
|
|
self.assertLessEqual(len(basses), 1)
|
|
self.assertLessEqual(len(uppers), 1)
|
|
grip = tuple(sorted({p for n in span.notes for p in shape(n)}, reverse=True))
|
|
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
|
|
if basses and uppers:
|
|
self.assertIn(tuple(p.string_number for p in grip), generator.guitar.ACOUSTIC_STRINGS)
|
|
self.assertTrue(requirements.covers({p.midi % 12 for p in grip}))
|
|
self.assertTrue({p.midi % 12 for p in grip} <= set(requirements.allowed))
|
|
if basses:
|
|
self.assertIn(min(p.midi for p in grip) % 12, requirements.basses)
|
|
self.assertTrue(generator.guitar.within_fret_span(grip))
|
|
self.assertTrue(all(1 <= p.fret <= 20 for p in grip))
|
|
for note in span.notes:
|
|
self.assertFalse(any(k.startswith('_') for k in note))
|
|
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in note['pitches']))
|
|
generator.model.validate_document(document)
|
|
return spans
|
|
def test_independent_attacks_keep_one_complete_grip(self):
|
|
document = planned_document([[(0, 'FM7')]])
|
|
spans = self.place(document)
|
|
self.assertEqual(len(spans), 1)
|
|
self.assertEqual(len(spans[0].notes), 4)
|
|
before = tests.test_acoustic.sounding(document)
|
|
generator.acoustic.combine(document)
|
|
self.assertEqual(tests.test_acoustic.sounding(document), before)
|
|
generator.model.validate_document(document)
|
|
def test_fifth_bass_can_win_without_alternating(self):
|
|
document = planned_document([[(0, 'Csus')], []])
|
|
spans = self.place(document)
|
|
self.assertEqual(len(spans), 1)
|
|
bass = [p for n in spans[0].notes for p in shape(n) if p.string_number >= 5]
|
|
self.assertTrue(bass)
|
|
self.assertEqual({p.midi % 12 for p in bass}, {7})
|
|
root_shapes = generator.guitar.chord_positions(generator.guitar.chord_requirements(generator.harmony.parse_symbol('Csus/C'), 'acoustic'), profile='acoustic')
|
|
selected = tuple(sorted({p for n in spans[0].notes for p in shape(n)}, reverse=True))
|
|
self.assertLessEqual(generator.guitar.voicing_cost(selected, 'acoustic'), min(generator.guitar.voicing_cost(s, 'acoustic') for s in root_shapes))
|
|
def test_bass_options_follow_defined_fifth_and_explicit_basses(self):
|
|
inversion = generator.harmony.parse_symbol('Cdim7') | {'inversion': 0}
|
|
for chord, expected in (('C7', {0, 7}), ('Cdim7', {0, 6}), ('Caug', {0, 8}), ('Cno5', {0}), ('C7/E', {4}), (inversion, {0})):
|
|
with self.subTest(chord=chord):
|
|
span = generator.acoustic.harmony_spans(planned_document([[(0, chord)]]))[0]
|
|
candidates = list(generator.guitar.acoustic_candidates(span))
|
|
self.assertEqual({min(p.midi for p in c.shape) % 12 for c in candidates}, expected)
|
|
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
|
|
self.assertTrue(all(requirements.covers({p.midi % 12 for p in c.shape}) for c in candidates))
|
|
def test_same_grip_survives_repeated_symbols_rest_bars_and_sections(self):
|
|
attacks = [([(0, 1), (2, 1)], [(0, 1), (3, 1)]), ([], []), ([(2, 1)], [(1, 1), (3, 1)])]
|
|
document = planned_document([[(0, 'C7')], [], [(0, 'C7'), (2, 'C7')]], attacks)
|
|
document['staves'][0]['measures'][2]['section_label'] = 'Next section'
|
|
self.assertEqual(len(self.place(document)), 1)
|
|
def test_changes_during_silence_end_the_span(self):
|
|
for middle in ('G7', 'N.C.'):
|
|
with self.subTest(middle=middle):
|
|
document = planned_document([[(0, 'C7'), (1, middle), (2, 'C7')]], [([(0, 1), (2, 1)], [(0, 1), (3, 1)])])
|
|
spans = self.place(document)
|
|
self.assertEqual(len(spans), 2)
|
|
self.assertEqual([len(s.notes) for s in spans], [2, 2])
|
|
def test_returning_to_a_chord_is_a_new_span(self):
|
|
document = planned_document([[(0, 'C7')], [(0, 'G7')], [(0, 'C7')]])
|
|
self.assertEqual(len(self.place(document)), 3)
|
|
def test_slash_bass_inversion_and_alteration_changes_are_boundaries(self):
|
|
inversion = generator.harmony.parse_symbol('C7') | {'inversion': 2}
|
|
changes = [(0, 'C7'), (1, 'C7/E'), (2, inversion), (3, 'C7b5')]
|
|
document = planned_document([changes], [([(i, 1) for i in range(4)], [(i, 1) for i in range(4)])])
|
|
spans = self.place(document)
|
|
self.assertEqual(len(spans), 4)
|
|
basses = [min(p.midi for n in span.notes for p in shape(n)) % 12 for span in spans]
|
|
self.assertIn(basses[0], (0, 7))
|
|
self.assertEqual(basses[1:], [4, 7, 6])
|
|
def test_non_accompaniment_passage_ends_the_span(self):
|
|
document = planned_document([[(0, 'C7')], [], []])
|
|
measure = document['staves'][0]['measures'][1]
|
|
slash = generator.model.make_event('slash', fractions.Fraction(), generator.model.fraction(measure['duration']), 'note') | {'voice': 'voice-arranged', 'notehead': 'slash'}
|
|
measure['voices'] = [{'id': 'voice-arranged', 'notes': [slash]}]
|
|
self.assertEqual(len(self.place(document)), 2)
|
|
def test_carried_harmony_can_begin_an_arrangement_excerpt(self):
|
|
document = planned_document([[(0, 'C7')], []])
|
|
document['staves'][0]['measures'][0]['harmonies'] = []
|
|
self.assertEqual(len(self.place(document)), 1)
|
|
def test_no_chord_does_not_generate_an_attack(self):
|
|
document = planned_document([[(0, 'N.C.')], [(0, 'C7')], [(0, 'N.C.')]])
|
|
self.assertEqual(len(self.place(document)), 1)
|
|
for measure in (document['staves'][0]['measures'][0], document['staves'][0]['measures'][2]):
|
|
self.assertTrue(all(n['kind'] == 'rest' for v in measure['voices'] for n in v['notes']))
|
|
def test_candidate_includes_unsounded_half_of_the_grip(self):
|
|
for lane in ('bass', 'upper'):
|
|
document = planned_document([[(0, 'C7')]], [([(0, 1)] if lane == 'bass' else [], [(1, 1)] if lane == 'upper' else [])])
|
|
span = generator.acoustic.harmony_spans(document)[0]
|
|
candidates = list(generator.guitar.acoustic_candidates(span))
|
|
self.assertTrue(candidates)
|
|
for candidate in candidates:
|
|
self.assertIn(tuple(p.string_number for p in candidate.shape), generator.guitar.ACOUSTIC_STRINGS)
|
|
self.assertEqual({p for _, p in candidate.active}, set(candidate.shape))
|
|
self.assertEqual(len(candidate.choices), 1)
|
|
self.assertEqual(len(candidate.choices[0].added), 1 if lane == 'bass' else len(candidate.shape) - 1)
|
|
self.place(document)
|
|
def test_rhythm_density_does_not_multiply_shape_cost(self):
|
|
harmonies = [[(0, symbol)] for symbol in ('FM7', 'Gm7', 'C7')]
|
|
sparse = planned_document(harmonies, [([(0, 1)], [(2, 1)])] * 3)
|
|
dense = planned_document(harmonies, [([(i, 1) for i in range(4)], [(i, 1) for i in range(4)])] * 3)
|
|
selected = [tuple(tuple(sorted({p for n in span.notes for p in shape(n)})) for span in self.place(document)) for document in (sparse, dense)]
|
|
self.assertEqual(*selected)
|
|
def test_span_search_matches_exhaustive_full_grip_transitions(self):
|
|
document = planned_document([[(0, s)] for s in ('FM7', 'Gm7', 'C7', 'FM7')])
|
|
states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())}
|
|
for span in generator.acoustic.harmony_spans(document):
|
|
candidates = list(generator.guitar.acoustic_candidates(span))
|
|
expected = {}
|
|
for candidate in candidates:
|
|
expected[candidate.active] = min((previous.cost.moved(generator.guitar.acoustic_movement(tuple(p for _, p in previous.active), candidate.shape)).plus(candidate.cost), index) for index, previous in enumerate(states.values()))
|
|
actual = generator.guitar.advance_acoustic(candidates, states)
|
|
predecessors = {id(p): i for i, p in enumerate(states.values())}
|
|
self.assertEqual({key: (p.cost, predecessors[id(p.previous)]) for key, p in actual.items()}, expected)
|
|
states = actual
|
|
def test_impossible_grip_fails_contextually_without_partial_materialization(self):
|
|
document = planned_document([[(0, 'C7')], [(0, 'C7b9#11')]])
|
|
before = copy.deepcopy(document)
|
|
with self.assertRaisesRegex(ValueError, 'measure 2, event .*no acoustic grip.*1-20'):
|
|
generator.guitar.place_guitar(document)
|
|
self.assertEqual(document, before)
|
|
def test_g_minor_to_c_seventh_preserves_positions_with_fifth_bass(self):
|
|
tuning = dict(generator.guitar.STANDARD_TUNING)
|
|
previous = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 10), (3, 10), (2, 11)))
|
|
active = tuple((str(p.string_number), p) for p in previous)
|
|
states = {active: generator.guitar.Placement(generator.guitar.VoicingCost(), active, None, ())}
|
|
span = generator.acoustic.harmony_spans(planned_document([[(0, 'C7')]]))[0]
|
|
candidates = list(generator.guitar.acoustic_candidates(span))
|
|
result = generator.guitar.advance_acoustic(candidates, states)
|
|
best = min(result.values(), key=lambda p: (p.cost, p.active))
|
|
chosen = tuple(p for _, p in best.active)
|
|
self.assertEqual([(p.string_number, p.fret) for p in chosen], [(5, 10), (3, 9), (2, 11)])
|
|
self.assertEqual(generator.guitar.acoustic_movement(previous, chosen), 1)
|
|
self.assertEqual(generator.guitar.acoustic_movement(chosen, previous), 1)
|
|
self.assertEqual(generator.guitar.acoustic_movement(previous, previous), 0)
|
|
self.assertEqual(generator.guitar.acoustic_movement((), chosen), 0)
|
|
def test_acoustic_candidates_are_not_pruned_by_grip_center(self):
|
|
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7'), 'acoustic')
|
|
candidates = generator.guitar.chord_positions(requirements, profile='acoustic')
|
|
self.assertEqual(candidates, generator.guitar.chord_positions(requirements, profile='acoustic', prune=False))
|
|
self.assertGreater(len(candidates), len({generator.guitar.position_center(p) for p in candidates}))
|