Fixed acoustic
ci / checks (push) Successful in 48m31s

This commit is contained in:
khodowany
2026-09-30 12:14:49 -05:00
parent 7683793052
commit 10d295abfe
5 changed files with 173 additions and 26 deletions
+106 -4
View File
@@ -1,6 +1,7 @@
import copy
import fractions
import unittest
import unittest.mock
import generator.accompaniment
import generator.acoustic
@@ -85,9 +86,9 @@ class AcousticVoicingTests(unittest.TestCase):
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):
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, 7}), ('Cdim7', {0, 6}), ('Caug', {0, 8}), ('Cno5', {0}), ('C7/E', {4}), (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))
@@ -116,8 +117,9 @@ 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, 7))
self.assertEqual(basses[1:], [4, 7, 6])
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]
@@ -189,3 +191,103 @@ class AcousticVoicingTests(unittest.TestCase):
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)