Files
product-vamp/tests/test_acoustic_voicing.py
T
2026-09-26 18:00:51 -05:00

150 lines
8.6 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))
required, allowed, bass = 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(set(required) <= {p.midi % 12 for p in grip} <= set(allowed))
if basses:
self.assertEqual(min(p.midi for p in grip) % 12, bass)
self.assertTrue(generator.guitar.within_fret_span(grip))
self.assertTrue(all(0 <= 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_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)
self.assertEqual([min(p.midi for n in span.notes for p in shape(n)) % 12 for span in spans], [0, 4, 7, 0])
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.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_attacks(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.*0-20'):
generator.guitar.place_guitar(document)
self.assertEqual(document, before)