Voice Leading
ci / checks (push) Successful in 48m12s

This commit is contained in:
khodowany
2026-09-30 15:13:00 -05:00
parent 10d295abfe
commit fb92ed78ee
17 changed files with 511 additions and 484 deletions
+163 -180
View File
@@ -1,13 +1,18 @@
import copy
import fractions
import itertools
import pathlib
import unittest
import unittest.mock
import generator.accompaniment
import generator.acoustic
import generator.guitar
import generator.acoustic_voicing
import generator.arrange.generate
import generator.fretboard
import generator.harmony
import generator.model
import generator.musicxml
import generator.rhythm
import tests.test_acoustic
import tests.test_accompaniment
@@ -37,15 +42,23 @@ def planned_document(harmonies, attacks=None):
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']))
tuning = dict(generator.fretboard.STANDARD_TUNING)
return tuple(sorted(generator.fretboard.TabPosition(p['tablature']['string'], p['tablature']['fret'], tuning[p['tablature']['string']] + p['tablature']['fret']) for p in note['pitches']))
def voicing(frets, bass_string=5):
tuning = dict(generator.fretboard.STANDARD_TUNING)
return generator.acoustic_voicing.Voicing(*(generator.fretboard.TabPosition(s, f, tuning[s] + f) for s, f in zip((bass_string, 4, 3, 2), frets)))
def candidates(chord):
definition = generator.harmony.parse_symbol(chord) if isinstance(chord, str) else chord
return generator.acoustic_voicing.candidates(generator.fretboard.chord_requirements(definition), generator.harmony.is_triad(definition))
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)
generator.acoustic_voicing.place(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'}
@@ -53,21 +66,22 @@ class AcousticVoicingTests(unittest.TestCase):
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')
requirements = generator.fretboard.chord_requirements(span.harmony)
if basses and uppers:
self.assertIn(tuple(p.string_number for p in grip), generator.guitar.ACOUSTIC_STRINGS)
self.assertTrue(generator.acoustic_voicing.playable(generator.acoustic_voicing.Voicing(*grip)))
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:
strings = sorted(p['tablature']['string'] for p in note['pitches'])
if lanes[note['id']] == 'upper':
self.assertEqual(strings, [2, 3, 4])
else:
self.assertIn(strings, ([5], [6]))
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):
def test_independent_attacks_keep_one_complete_voicing(self):
document = planned_document([[(0, 'FM7')]])
spans = self.place(document)
self.assertEqual(len(spans), 1)
@@ -76,37 +90,47 @@ class AcousticVoicingTests(unittest.TestCase):
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_all_chord_tones_and_explicit_basses(self):
inversion = generator.harmony.parse_symbol('Cdim7') | {'inversion': 0}
for chord, expected in (('C7', {0, 4, 7, 10}), ('Cdim7', {0, 3, 6, 9}), ('Caug', {0, 4, 8}), ('Cno5', {0, 4}), ('C7b9', {0, 1, 4, 7, 10}), ('Dmno1', {5, 9}), ('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):
def test_four_strings_allow_all_defined_bass_inversions(self):
for symbol in ('CM', 'Dm', 'Caug', 'Cdim', 'C7', 'Cdim7', 'Cno5', 'C7b9', 'Dmno1', 'C6', 'C13', 'Csus', 'Csus2'):
with self.subTest(chord=symbol):
chord = generator.harmony.parse_symbol(symbol)
requirements = generator.fretboard.chord_requirements(chord)
options = candidates(chord)
self.assertTrue(options)
self.assertFalse(requirements.basses)
self.assertTrue(all(v.bass.midi % 12 in requirements.allowed for v in options))
self.assertTrue(all(requirements.covers({p.midi % 12 for p in v}) for v in options))
self.assertTrue(all(generator.acoustic_voicing.playable(v) for v in options))
self.assertEqual({v.bass.midi % 12 for v in candidates('C7')}, {0, 4, 7, 10})
self.assertIn(1, {v.bass.midi % 12 for v in candidates('C7b9')})
self.assertIn(5, {v.bass.midi % 12 for v in candidates('Dm')})
def test_same_voicing_survives_repeated_symbols_and_rest_bars(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):
def test_iterations_are_independent_even_with_identical_boundary_chords(self):
first, second = [[(0, s)] for s in ('C7', 'G7')], [[(0, s)] for s in ('G7', 'FM7')]
document = planned_document(first + second)
document['staves'][0]['measures'][2]['section_label'] = 'Next iteration'
groups = [list(group) for _, group in itertools.groupby(generator.acoustic.harmony_spans(document), key=lambda s: s.iteration)]
self.assertEqual([len(g) for g in groups], [2, 2])
expected = []
for harmonies in (first, second):
separate = planned_document(harmonies)
self.place(separate)
expected.extend(separate['staves'][0]['measures'])
self.place(document)
def positions(measures):
return [[shape(n) for v in m['voices'] for n in v['notes']] for m in measures]
self.assertEqual(positions(document['staves'][0]['measures']), positions(expected))
def test_changes_during_silence_end_the_harmony_span_not_the_journey(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])
self.assertEqual(len({s.iteration for s in spans}), 1)
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)
@@ -117,9 +141,7 @@ class AcousticVoicingTests(unittest.TestCase):
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, 4, 7, 10))
self.assertEqual(basses[1:3], [4, 7])
self.assertIn(basses[3], (0, 4, 6, 10))
def test_non_accompaniment_passage_ends_the_span(self):
document = planned_document([[(0, 'C7')], [], []])
measure = document['staves'][0]['measures'][1]
@@ -130,164 +152,125 @@ class AcousticVoicingTests(unittest.TestCase):
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):
def test_no_chord_and_empty_iterations_do_not_generate_notes(self):
document = planned_document([[(0, 'N.C.')], [(0, 'C7')], [(0, 'N.C.')]])
for index, measure in enumerate(document['staves'][0]['measures']):
measure['section_label'] = f'Iteration {index}'
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):
self.assertEqual(generator.acoustic_voicing.select([]), [])
def test_unsounded_voices_still_constrain_the_full_voicing(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)
selected = generator.acoustic_voicing.select([span])[0]
self.assertTrue(generator.acoustic_voicing.playable(selected))
self.place(document)
def test_rhythm_density_does_not_multiply_shape_cost(self):
self.assertEqual(shape(span.notes[0]), tuple(sorted((selected.bass,) if lane == 'bass' else selected[1:])))
def test_rhythm_density_does_not_multiply_movement_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(2 * 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):
def test_bass_switch_counts_both_string_and_fret_travel(self):
previous = voicing((7, 8, 7, 8))
for current, expected in ((voicing((7, 8, 7, 8), 6), 1), (voicing((8, 8, 7, 8), 6), 2), (voicing((9, 8, 7, 8), 6), 3), (voicing((6, 8, 7, 8)), 1)):
self.assertEqual(generator.acoustic_voicing.movement(previous, current), expected)
self.assertEqual(generator.acoustic_voicing.movement(current, previous), expected)
self.assertEqual(generator.acoustic_voicing.movement(previous, previous), 0)
def test_each_upper_string_is_tracked_not_just_the_grip_center(self):
before, after = voicing((5, 7, 5, 7)), voicing((7, 5, 7, 5))
self.assertEqual(generator.acoustic_voicing.movement(before, after), 8)
for index in (1, 2, 3):
frets = [5, 7, 5, 7]
frets[index] += 1
self.assertEqual(generator.acoustic_voicing.movement(before, voicing(frets)), 1)
def test_fret_and_register_limits_are_hard_constraints(self):
for frets, valid in (((1, 1, 1, 1), True), ((1, 4, 1, 4), True), ((1, 5, 1, 4), False), ((0, 1, 1, 1), False), ((12, 15, 14, 12), True), ((13, 13, 13, 13), False), ((12, 16, 14, 12), False), ((12, 21, 12, 12), False)):
with self.subTest(frets=frets):
self.assertEqual(generator.acoustic_voicing.playable(voicing(frets)), valid)
for symbol in ('CM', 'C7b9', 'C13', 'Cdim7'):
self.assertTrue(all(generator.acoustic_voicing.playable(v) for v in candidates(symbol)))
def test_acoustic_cost_has_no_register_span_or_shape_penalties(self):
options = (voicing((1, 1, 1, 1)), voicing((1, 4, 3, 4)), voicing((12, 12, 12, 12)), voicing((12, 15, 14, 15)))
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
result = generator.acoustic_voicing.advance(options, generator.fretboard.ChordRequirements(), states)
self.assertEqual([p.cost for p in result], [generator.acoustic_voicing.Cost()] * len(options))
def test_optional_tones_break_equal_movement_costs_only(self):
requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C7'))
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
result = generator.acoustic_voicing.advance(candidates('C7'), requirements, states)
self.assertTrue(all(p.cost.travel == 0 for p in result))
self.assertEqual({p.cost.missing_tones for p in result}, {0, 1})
best = min(result, key=lambda p: (p.cost, p.voicing))
self.assertEqual({p.midi % 12 for p in best.voicing}, {0, 4, 7, 10})
def test_dynamic_program_matches_exhaustive_paths_and_is_deterministic(self):
harmonies = ['FM7', 'Gm7', 'C7']
spans = generator.acoustic.harmony_spans(planned_document([[(0, s)] for s in harmonies]))
options = [candidates(s) for s in harmonies]
requirements = [generator.fretboard.chord_requirements(span.harmony) for span in spans]
def cost(path):
return generator.acoustic_voicing.Cost(sum(generator.acoustic_voicing.movement(a, b) for a, b in zip(path, path[1:])), sum(len(set(r.allowed) - {p.midi % 12 for p in v}) for r, v in zip(requirements, path)))
selected = generator.acoustic_voicing.select(spans)
self.assertEqual(cost(selected), min(cost(path) for path in itertools.product(*options)))
self.assertEqual(selected, generator.acoustic_voicing.select(spans))
def test_complete_triads_and_doubling_use_only_authored_chord_tones(self):
for symbol in ('CM', 'Dm', 'Cdim', 'Caug', 'Dm/F', 'Dm7no7'):
with self.subTest(chord=symbol):
classes = set(generator.harmony.pitch_classes(generator.harmony.parse_symbol(symbol)))
options = candidates(symbol)
self.assertTrue(options)
self.assertTrue(all(len(v) == 4 and {p.midi % 12 for p in v} == classes for v in options))
for symbol, omitted in (('Dmno1', 2), ('C7no5', 7)):
self.assertTrue(candidates(symbol))
self.assertTrue(all(omitted not in {p.midi % 12 for p in v} for v in candidates(symbol)))
def test_unplayable_omission_is_not_restored_to_fill_upper_strings(self):
self.assertFalse(candidates('Dmno5'))
document = planned_document([[(0, 'Dmno5')]])
before = copy.deepcopy(document)
with self.assertRaisesRegex(ValueError, 'Dmno5: no acoustic grip.*three-fret span'):
generator.acoustic_voicing.place(document)
self.assertEqual(document, before)
def test_explicit_inversions_and_nonchord_slash_basses_are_preserved(self):
for chord, bass in ((generator.harmony.parse_symbol('Dm') | {'inversion': 1}, 5), (generator.harmony.parse_symbol('C7') | {'inversion': 3}, 10), ('C7/E', 4), ('C7b9/Db', 1), ('Dm/B', 11)):
with self.subTest(chord=chord):
span = self.place(planned_document([[(0, chord)]]))[0]
self.assertEqual({p.midi % 12 for n in span.notes for p in shape(n) if p.string_number >= 5}, {bass})
def test_impossible_later_iteration_does_not_partially_materialize(self):
document = planned_document([[(0, 'C7')], [(0, 'C7b9#11')]])
document['staves'][0]['measures'][1]['section_label'] = 'Later iteration'
before = copy.deepcopy(document)
with self.assertRaisesRegex(ValueError, 'measure 2, event .*no acoustic grip.*1-20'):
generator.guitar.place_guitar(document)
generator.acoustic_voicing.place(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}))
def test_physical_reach_uses_fret_spacing_and_an_inclusive_reference(self):
tuning = dict(generator.guitar.STANDARD_TUNING)
low = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 3), (3, 2), (2, 5)))
reference = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 8), (3, 7), (2, 10)))
high = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 9), (3, 8), (2, 11)))
self.assertEqual(generator.guitar.compactness(low), generator.guitar.compactness(reference))
self.assertGreater(generator.guitar.physical_reach(low), generator.guitar.physical_reach(reference))
self.assertGreater(generator.guitar.physical_reach(reference), generator.guitar.physical_reach(high))
self.assertEqual(generator.guitar.physical_reach(reference), generator.guitar.PREFERRED_ACOUSTIC_REACH)
self.assertEqual(generator.guitar.physical_reach(()), 0)
self.assertEqual(generator.guitar.physical_reach(reference[:1]), 0)
span = generator.acoustic.harmony_spans(planned_document([[(0, 'Dm')]]))[0]
self.assertIn(reference, [c.shape for c in generator.guitar.acoustic_candidates(span)])
def test_acoustic_effort_does_not_reward_further_compression(self):
tuning = dict(generator.guitar.STANDARD_TUNING)
shapes = [tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in zip((5, 4, 3, 2), frets)) for frets in ((5, 5, 7, 7), (6, 6, 7, 7))]
self.assertGreater(generator.guitar.physical_reach(shapes[0]), generator.guitar.physical_reach(shapes[1]))
self.assertTrue(all(generator.guitar.physical_reach(s) <= generator.guitar.PREFERRED_ACOUSTIC_REACH for s in shapes))
self.assertEqual(*(generator.guitar.voicing_cost(s, 'acoustic').effort for s in shapes))
def test_comfortable_candidates_win_even_when_previous_grip_is_wider(self):
tuning = dict(generator.guitar.STANDARD_TUNING)
wider = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((6, 6), (4, 5), (3, 3), (2, 3)))
active = tuple((str(p.string_number), p) for p in wider)
states = {active: generator.guitar.Placement(generator.guitar.VoicingCost(), active, None, ())}
span = generator.acoustic.harmony_spans(planned_document([[(0, 'Em7b5')]]))[0]
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
self.assertIn(wider, generator.guitar.chord_positions(requirements, profile='acoustic'))
candidates = list(generator.guitar.acoustic_candidates(span))
self.assertNotIn(wider, [c.shape for c in candidates])
result = generator.guitar.advance_acoustic(candidates, states)
chosen = min(result.values(), key=lambda p: (p.cost, p.active))
self.assertLessEqual(generator.guitar.physical_reach(tuple(p for _, p in chosen.active)), generator.guitar.PREFERRED_ACOUSTIC_REACH)
def test_complete_triads_are_preferred_within_comfortable_candidates(self):
for symbol in ('CM', 'Dm', 'Cdim', 'Caug', 'Dm/F'):
with self.subTest(chord=symbol):
span = generator.acoustic.harmony_spans(planned_document([[(0, symbol)]]))[0]
candidates = list(generator.guitar.acoustic_candidates(span))
classes = set(generator.harmony.pitch_classes(span.harmony))
self.assertTrue(candidates)
self.assertTrue(all(classes <= {p.midi % 12 for p in c.shape} for c in candidates))
self.assertTrue(all(generator.guitar.physical_reach(c.shape) <= generator.guitar.PREFERRED_ACOUSTIC_REACH for c in candidates))
def test_omissions_and_extended_chords_do_not_gain_complete_triad_requirement(self):
for symbol, omitted in (('Dmno1', 2), ('Dmno5', 9)):
with self.subTest(chord=symbol):
span = generator.acoustic.harmony_spans(planned_document([[(0, symbol)]]))[0]
candidates = list(generator.guitar.acoustic_candidates(span))
self.assertTrue(candidates)
self.assertTrue(all(omitted not in {p.midi % 12 for p in c.shape} for c in candidates))
for symbol in ('C7', 'C7b9', 'C13'):
with self.subTest(chord=symbol):
span = generator.acoustic.harmony_spans(planned_document([[(0, symbol)]]))[0]
candidates = list(generator.guitar.acoustic_candidates(span))
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
self.assertTrue(any(0 not in {p.midi % 12 for p in c.shape} for c in candidates))
self.assertTrue(all(requirements.covers({p.midi % 12 for p in c.shape}) for c in candidates))
def test_complete_triad_preference_cannot_force_a_wider_grip(self):
tuning = dict(generator.guitar.STANDARD_TUNING)
reduced, complete = [tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in pairs) for pairs in (((6, 1), (4, 3), (3, 2)), ((5, 5), (4, 3), (3, 2)))]
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('Dm'), 'acoustic')
self.assertTrue(all(requirements.covers({p.midi % 12 for p in s}) for s in (reduced, complete)))
self.assertLessEqual(generator.guitar.physical_reach(reduced), generator.guitar.PREFERRED_ACOUSTIC_REACH)
self.assertGreater(generator.guitar.physical_reach(complete), generator.guitar.PREFERRED_ACOUSTIC_REACH)
generator.guitar.acoustic_positions.cache_clear()
self.addCleanup(generator.guitar.acoustic_positions.cache_clear)
with unittest.mock.patch('generator.guitar.chord_positions', return_value=(reduced, complete)):
self.assertEqual(generator.guitar.acoustic_positions(requirements, complete_triad=True), (reduced,))
def test_explicit_inversions_and_nonchord_slash_basses_are_preserved(self):
for chord, bass in ((generator.harmony.parse_symbol('Dm') | {'inversion': 1}, 5), (generator.harmony.parse_symbol('C7') | {'inversion': 3}, 10), ('Dm/B', 11)):
with self.subTest(chord=chord):
document = planned_document([[(0, chord)]])
span = self.place(document)[0]
self.assertEqual({p.midi % 12 for n in span.notes for p in shape(n) if p.string_number >= 5}, {bass})
def test_wider_fallback_is_least_reaching_and_reported_with_context(self):
document = planned_document([[(0, 'C13/E')]])
span = generator.acoustic.harmony_spans(document)[0]
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
raw = generator.guitar.chord_positions(requirements, profile='acoustic')
minimum = min(generator.guitar.physical_reach(s) for s in raw)
self.assertGreater(minimum, generator.guitar.PREFERRED_ACOUSTIC_REACH)
with self.assertLogs('generator.guitar', level='WARNING') as logs:
candidates = list(generator.guitar.acoustic_candidates(span))
self.assertTrue(candidates)
self.assertEqual({generator.guitar.physical_reach(c.shape) for c in candidates}, {minimum})
self.assertTrue(all(requirements.covers({p.midi % 12 for p in c.shape}) and generator.guitar.within_fret_span(c.shape) for c in candidates))
self.assertIn(span.location, logs.output[0])
self.assertIn('C13/E requires a wider acoustic reach', logs.output[0])
with self.assertLogs('generator.guitar', level='WARNING'):
self.place(document)
def test_inversions_still_follow_nearby_full_grip_positions(self):
tuning = dict(generator.guitar.STANDARD_TUNING)
for previous, expected in ((((6, 3), (4, 2), (3, 3), (2, 3)), ((6, 3), (4, 2), (3, 3), (2, 2))), (((5, 7), (4, 8), (3, 7), (2, 8)), ((5, 7), (4, 8), (3, 6), (2, 8)))):
with self.subTest(previous=previous):
grip = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in previous)
active = tuple((str(p.string_number), p) for p in grip)
states = {active: generator.guitar.Placement(generator.guitar.VoicingCost(), active, None, ())}
span = generator.acoustic.harmony_spans(planned_document([[(0, 'A7b9')]]))[0]
result = generator.guitar.advance_acoustic(generator.guitar.acoustic_candidates(span), states)
chosen = tuple(p for _, p in min(result.values(), key=lambda p: (p.cost, p.active)).active)
self.assertEqual(tuple((p.string_number, p.fret) for p in chosen), expected)
self.assertEqual(generator.guitar.acoustic_movement(grip, chosen), 1)
def test_invalid_inversion_and_tuning_fail_with_context(self):
invalid = generator.harmony.parse_symbol('C7') | {'inversion': 4}
document = planned_document([[(0, invalid)]])
with self.assertRaisesRegex(ValueError, 'measure 1.*inversion'):
generator.acoustic_voicing.place(document)
document = planned_document([[(0, 'C7')]])
document['staves'][0]['measures'][0]['staff_details']['capo'] = 1
with self.assertRaisesRegex(ValueError, 'capo'):
generator.acoustic_voicing.place(document)
def test_real_song_iterations_use_complete_upper_voices(self):
rhythms = generator.rhythm.load_library(pathlib.Path('data/rhythms'))
for path in sorted(pathlib.Path('data/songs').glob('*.xml')):
source = generator.musicxml.load_musicxml(path)
before = copy.deepcopy(source)
result = generator.arrange.generate.arrange_document(source, 'acoustic', rhythms=rhythms)
generator.model.validate_document(result)
self.assertEqual(source, before)
for measure in result['staves'][0]['measures']:
for voice in measure['voices']:
for note in voice['notes']:
upper = sorted(p['tablature']['string'] for p in note['pitches'] if p['tablature']['string'] <= 4)
self.assertIn(upper, ([], [2, 3, 4]))
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in note['pitches']))
if path.stem == 'autumn-leaves':
for index, expected in ((0, voicing((3, 2, 3, 3), 6)), (1, voicing((3, 2, 3, 2), 6)), (4, voicing((3, 2, 3, 3), 6)), (5, voicing((3, 2, 3, 2), 6))):
measure = result['staves'][0]['measures'][index]
actual = {p for v in measure['voices'] for n in v['notes'] for p in shape(n)}
self.assertEqual(actual, set(expected))
self.assertEqual(result, generator.arrange.generate.arrange_document(source, 'acoustic', rhythms=rhythms))