Chord Families

This commit is contained in:
khodowany
2026-10-06 03:18:02 -05:00
parent e71385dad1
commit 8423f3d788
14 changed files with 196 additions and 57 deletions
+13 -10
View File
@@ -68,7 +68,7 @@ class AcousticVoicingTests(unittest.TestCase):
requirements = generator.fretboard.chord_requirements(span.harmony)
if basses and uppers:
self.assertTrue(generator.acoustic_voicing.playable(generator.acoustic_voicing.Voicing(*grip)))
self.assertLessEqual(max(p.fret for p in grip) - min(p.fret for p in grip) + 1, 3)
self.assertLessEqual(max(p.fret for p in grip) - min(p.fret for p in grip) + 1, 4)
self.assertTrue(requirements.covers({p.midi % 12 for p in grip}))
self.assertTrue({p.midi % 12 for p in grip} <= set(requirements.allowed))
for note in span.notes:
@@ -188,17 +188,20 @@ class AcousticVoicingTests(unittest.TestCase):
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, 3, 1, 3), True), ((1, 4, 1, 4), False), ((1, 5, 1, 4), False), ((0, 1, 1, 1), False), ((12, 14, 14, 12), True), ((12, 15, 14, 12), False), ((13, 13, 13, 13), False), ((12, 16, 14, 12), False), ((12, 21, 12, 12), False)):
for frets, valid in (((1, 1, 1, 1), True), ((1, 3, 1, 3), True), ((1, 4, 1, 4), True), ((1, 5, 1, 4), False), ((0, 1, 1, 1), False), ((12, 14, 14, 12), True), ((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_autumn_leaves_cm7_and_f7_cannot_span_four_fret_positions(self):
def test_four_fret_cm7_and_f7_are_fallbacks_not_preferred_shapes(self):
for symbol, frets in (('Cm7', (3, 1, 3, 4)), ('F7', (3, 1, 2, 4))):
with self.subTest(symbol=symbol):
self.assertFalse(generator.acoustic_voicing.playable(voicing(frets)))
self.assertNotIn(voicing(frets), candidates(symbol))
self.place(planned_document([[(0, symbol)]]))
self.assertTrue(generator.acoustic_voicing.playable(voicing(frets)))
self.assertIn(voicing(frets), candidates(symbol))
document = planned_document([[(0, symbol)]])
selected = generator.acoustic_voicing.select(generator.acoustic.harmony_spans(document))
self.assertTrue(all(generator.fretboard.span_penalty(v) == 0 for v in selected))
self.place(document)
def test_acoustic_cost_has_no_register_span_or_shape_penalties(self):
options = (voicing((1, 1, 1, 1)), voicing((1, 3, 2, 3)), voicing((12, 12, 12, 12)), voicing((12, 14, 13, 14)))
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
@@ -218,7 +221,7 @@ class AcousticVoicingTests(unittest.TestCase):
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)))
return generator.acoustic_voicing.Cost(sum(generator.fretboard.span_penalty(v) for v in path), 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))
@@ -229,16 +232,16 @@ class AcousticVoicingTests(unittest.TestCase):
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 (('C7no1', 0), ('C7no5', 7)):
for symbol, omitted in (('C7no1', 0), ('C7no5', 7), ('Dmno1', 2), ('CMno5', 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):
for symbol in ('Dmno5', 'Dmno1', 'CMno5'):
for symbol in ['Dmno5']:
with self.subTest(symbol=symbol):
self.assertFalse(candidates(symbol))
document = planned_document([[(0, symbol)]])
before = copy.deepcopy(document)
with self.assertRaisesRegex(ValueError, f'{symbol}: no acoustic grip.*3 consecutive fret positions'):
with self.assertRaisesRegex(ValueError, f'{symbol}: no acoustic grip.*4 consecutive fret positions'):
generator.acoustic_voicing.place(document)
self.assertEqual(document, before)
def test_explicit_inversions_and_nonchord_slash_basses_are_preserved(self):
+8 -9
View File
@@ -22,16 +22,14 @@ import tests.test_voicing
class ChordFamilies(unittest.TestCase):
def test_common_jazz_qualities_in_multiple_keys(self):
suffixes = ('', 'm', 'M7', 'm7', '7', 'mM7', '6', 'm6', 'dim', 'dim7', 'm7b5', 'aug', 'aug7', 'augM7', 'sus2', 'sus4', '7sus4', '9sus4', '9', 'M9', 'm9', '11', 'M11', 'm11', '13', 'M13', 'm13', '7b9', '7#9', '7b5', '7#5', '7#11', '7b13', '7b9#5', '6add9')
for root in ('C', 'F#', 'Bb'):
for root in ('C', 'Db', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B'):
for suffix in suffixes:
chord = generator.harmony.parse_symbol(root + suffix)
for part in ('acoustic', 'electric'):
with self.subTest(chord=root + suffix, part=part):
# These transpositions have no inversion satisfying all of the
# three-consecutive-frets, bass <=12 and written-tone constraints.
# A familiar low Bb9 root grip needs fret zero; its octave needs
# bass fret 13. The fifth-bass grip 8/10/7/9 spans four positions.
if part == 'acoustic' and root == 'Bb' and suffix in ('9', 'M9', 'm9', '7#9', '6add9'):
# Even four positions cannot satisfy these written tones with
# a root/fifth bass <=12 and upper notes on strings 2/3/4.
if part == 'acoustic' and root in ('Bb', 'B') and suffix == 'm9':
with self.assertRaisesRegex(ValueError, 'no legal acoustic voicing'):
generator.comping.voicings(generator.comping.semantic_key(chord), part)
continue
@@ -65,8 +63,8 @@ class ChordFamilies(unittest.TestCase):
self.assertNotIn(tests.test_voicing.grip((7, 8, 6, 8), (5, 4, 3, 2)), tests.test_comping_voicings.options('A7', 'acoustic'))
self.assertNotIn(tests.test_voicing.grip((5, 7, 5, 6), (5, 4, 3, 2)), tests.test_comping_voicings.options('Dm', 'acoustic'))
def test_impossible_family_is_explained_without_weakening_harmony(self):
chord = generator.harmony.parse_symbol('C9')
coverage = generator.chord_families.coverage(chord, tests.test_comping_voicings.options('C9', 'acoustic'), 'acoustic')
chord = generator.harmony.parse_symbol('Bb9')
coverage = generator.chord_families.coverage(chord, tests.test_comping_voicings.options('Bb9', 'acoustic'), 'acoustic')
missing = [c for c in coverage if not c['candidates']]
self.assertTrue(missing)
self.assertTrue(all('fret/span' in c['reason'] for c in missing))
@@ -79,7 +77,8 @@ class ChordFamilies(unittest.TestCase):
self.assertTrue(choices)
def top_string(v):
return min(p.string_number for p in v if p.midi == midi)
self.assertEqual(top_string(choices[0]), min(map(top_string, choices)))
tier = generator.fretboard.span_penalty(choices[0])
self.assertEqual(top_string(choices[0]), min(top_string(v) for v in choices if generator.fretboard.span_penalty(v) == tier))
self.assertEqual(max(p.midi for p in choices[0]), midi)
self.assertTrue(all(p.string_number in (1, 2, 3, 4) for v in choices for p in v))
self.assertTrue(all(midi in {p.midi for p in v} for v in choices))
+6 -6
View File
@@ -21,9 +21,9 @@ def options(symbol, part):
return generator.comping.voicings(generator.comping.semantic_key(generator.harmony.parse_symbol(symbol)), part)
def path_cost(path, costs, part, left=(), right=(), close=False):
total = [sum(costs[i][v][j] for i, v in enumerate(path)) for j in range(4)]
total[1] += sum(generator.comping.movement(part, a, b) for a, b in zip((left, *path), path))
total[1] += generator.comping.movement(part, path[-1], path[0] if close else right)
total = [sum(costs[i][v][j] for i, v in enumerate(path)) for j in range(5)]
total[2] += sum(generator.comping.movement(part, a, b) for a, b in zip((left, *path), path))
total[2] += generator.comping.movement(part, path[-1], path[0] if close else right)
return tuple(total)
class CompingVoicings(unittest.TestCase):
@@ -69,9 +69,9 @@ class CompingVoicings(unittest.TestCase):
# Bass responsibility belongs to the acoustic voicing, as in electric melody.
self.assertEqual(options('C/E', 'electric'), options('C', 'electric'))
def test_impossible_grip_reports_source_location_without_relaxation(self):
source = tests.solo_fixtures.song(count=1, chords=('C5',))
source = tests.solo_fixtures.song(count=1, chords=('C7b9#11',))
for part in ('acoustic', 'electric'):
with self.subTest(part=part), self.assertRaisesRegex(ValueError, 'source measure 1, beat 1: C5: no legal'):
with self.subTest(part=part), self.assertRaisesRegex(ValueError, 'source measure 1, beat 1: C7b9#11: no legal'):
generator.comping.Canonical(source, part, 'test')
def test_complete_path_beats_greedy_and_matches_exhaustive_search(self):
for part in ('acoustic', 'electric'):
@@ -90,7 +90,7 @@ class CompingVoicings(unittest.TestCase):
def test_repeat_join_and_neighbor_boundaries_participate_in_selection(self):
for part in ('acoustic', 'electric'):
layers = tuple(options(s, part) for s in ('C', 'Dm7', 'G7'))
costs = tuple({v: (0, 0, 0, 0) for v in layer} for layer in layers)
costs = tuple({v: generator.comping.VoicingCost(generator.fretboard.span_penalty(v)) for v in layer} for layer in layers)
for endpoints in ({'close': True}, {'left': options('Am7', part)[0], 'right': options('FM7', part)[-1]}):
with self.subTest(part=part, endpoints=endpoints):
path, cost = generator.comping.select_grips(layers, costs, part, 'test', **endpoints)
+88 -1
View File
@@ -7,6 +7,8 @@ import unittest.mock
import generator.acoustic
import generator.acoustic_voicing
import generator.chord_families
import generator.comping
import generator.electric
import generator.fretboard
import generator.guitar
@@ -18,6 +20,8 @@ import generator.solo.placement
import tests.solo_fixtures
import tests.test_acoustic_voicing
import tests.test_musicxml
import tests.test_voicing
import tests.score_events
def position(string, fret):
return generator.fretboard.TabPosition(string, fret, dict(generator.fretboard.STANDARD_TUNING)[string] + fret)
@@ -35,6 +39,89 @@ def document(positions):
return result
class FretPolicyTests(unittest.TestCase):
def test_span_tiers_group_one_two_and_three_positions(self):
for frets, span, tier, legal in (((7,), 1, 0, True), ((7, 8), 2, 0, True), ((7, 9), 3, 0, True), ((7, 10), 4, 1, True), ((7, 11), 5, 1, False)):
grip = tuple(position(s, f) for s, f in zip((2, 1), frets))
self.assertEqual(generator.fretboard.fret_span(grip), span)
self.assertEqual(generator.fretboard.span_penalty(grip), tier)
self.assertEqual(generator.fretboard.within_fret_span(grip), legal)
self.assertEqual(generator.fretboard.fret_span(()), 0)
def test_primary_comping_grip_beats_family_and_arbitrarily_less_movement(self):
chord = generator.harmony.parse_symbol('C')
key = generator.comping.semantic_key(chord)
primary = tests.test_acoustic_voicing.voicing((7, 5, 5, 5))
fallback = tests.test_acoustic_voicing.voicing((8, 5, 5, 5), 6)
self.assertFalse(generator.chord_families.acoustic(chord, primary)['preferred'])
self.assertTrue(generator.chord_families.acoustic(chord, fallback)['preferred'])
self.assertTrue(all(v in generator.comping.voicings(key, 'acoustic') for v in (primary, fallback)))
costs = {v: generator.comping.local_cost('acoustic', key, v, 55) for v in (primary, fallback)}
# Movement/family wins cannot purchase entry into the four-fret tier.
costs[primary] = costs[primary]._replace(effort=100000)
path, cost = generator.comping.select_grips(((primary, fallback),), (costs,), 'acoustic', 'tiers', left=fallback, right=fallback)
self.assertEqual(path, (primary,))
self.assertEqual(cost.extended_grips, 0)
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost(), fallback)]
legacy = generator.acoustic_voicing.advance((primary, fallback), generator.comping.requirements(chord, 'acoustic'), states)
self.assertEqual(min(legacy, key=lambda s: s.cost).voicing, primary)
def test_primary_electric_grip_beats_highest_melody_string_and_pruning(self):
for prune in (False, True):
choices = tests.test_voicing.ranked('G7', (65,), prune=prune)
best = choices[0]
self.assertEqual(generator.fretboard.span_penalty(best), 0)
self.assertEqual(min(p.string_number for p in best if p.midi == 65), 2)
self.assertEqual(max(p.midi for p in best), 65)
self.assertTrue(any(generator.fretboard.span_penalty(v) and any(p.midi == 65 and p.string_number == 1 for p in v) for v in choices))
primary_cost = generator.guitar.voicing_cost(best, 'electric', melody=(65,)).moved(100000)
self.assertTrue(all(primary_cost < generator.guitar.voicing_cost(v, 'electric', melody=(65,)) for v in choices if generator.fretboard.span_penalty(v)))
def test_previously_missing_chords_select_reported_fallbacks(self):
for symbol in ('Bb9', 'BbM9', 'Bb7#9', 'Bb6add9'):
with self.subTest(chord=symbol):
source = tests.solo_fixtures.song(count=1, chords=(symbol,))
canonical = generator.comping.Canonical(source, 'acoustic', 'fallback')
report = canonical.report[0]
self.assertEqual(report['fret_span'], 4)
self.assertEqual(report['span_group'], 'fallback')
self.assertEqual(report['span_candidates']['primary'], 0)
self.assertGreater(report['span_candidates']['fallback'], 0)
self.assertEqual(canonical.path_cost.extended_grips, 1)
required = generator.comping.requirements(generator.harmony.parse_symbol(symbol), 'acoustic')
self.assertTrue(required.covers({p['sounding_midi'] % 12 for p in report['positions']}))
def test_future_held_notes_can_require_a_retained_four_fret_history(self):
source = document((position(4, 5), position(2, 2))) # G3, C#4
measure = source['staves'][0]['measures'][0]
for p in tests.test_musicxml.notes(source)[0]['pitches']:
p.pop('tablature')
incoming = generator.model.make_event('incoming', fractions.Fraction(1), fractions.Fraction(3), 'note', [{'letter': 'a', 'alter': 0, 'octave': 3, 'tablature': {'string': 3, 'fret': 2}}])
measure['voices'].append({'id': 'other', 'notes': [generator.model.make_event('rest', fractions.Fraction(), fractions.Fraction(1), 'rest'), incoming]})
# G/C# also have primary placements. Neither permits this held A/string 3.
self.assertFalse(generator.fretboard.span_penalty((position(4, 5), position(3, 6))))
before = tests.score_events.timeline(source)
generator.guitar.place_guitar(source)
generator.model.validate_document(source)
generator.fretboard.validate_document(source)
self.assertEqual(tests.score_events.timeline(source), before)
self.assertEqual([p['tablature'] for p in measure['voices'][0]['notes'][0]['pitches']], [{'string': 4, 'fret': 5}, {'string': 2, 'fret': 2}])
def test_tied_four_fret_grip_is_charged_once_and_keeps_its_anchor(self):
source = document((position(2, 12), position(1, 15)))
original = tests.test_musicxml.notes(source)[0]
pieces = [generator.model.make_event(f'split_{i}', fractions.Fraction(2 * i), fractions.Fraction(2), 'note', original['pitches']) for i in range(2)]
for a, b in zip(pieces[0]['pitches'], pieces[1]['pitches']):
a['tie_to'], b['tie_from'] = b['id'], a['id']
source['staves'][0]['measures'][0]['voices'][0]['notes'] = pieces
_, tones = generator.guitar.guitar_events(source)
states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())}
for i, n in enumerate(pieces):
states = generator.guitar.advance_positions([n], fractions.Fraction(2 * i), tones, states, True)
self.assertEqual(min(s.cost for s in states.values()).extended_grips, 1)
generator.guitar.place_guitar(source)
generator.model.validate_document(source)
generator.fretboard.validate_document(source)
self.assertEqual([p['tablature'] for p in pieces[0]['pitches']], [p['tablature'] for p in pieces[1]['pitches']])
# Removing the low anchor early still leaves an illegal high single tail.
pieces[0]['pitches'][0].pop('tie_to')
pieces[1]['pitches'].pop(0)
with self.assertRaisesRegex(ValueError, 'held/tied tails'):
generator.fretboard.validate_document(source)
def test_single_limits_and_alternate_string(self):
for midi, expected in ((40, False), (41, True), (72, True), (76, True), (77, False), (84, False)):
with self.subTest(midi=midi):
@@ -59,7 +146,7 @@ class FretPolicyTests(unittest.TestCase):
generator.guitar.place_guitar(source)
self.assertEqual(source, before)
def test_chord_anchor_and_span_are_hard_constraints(self):
for frets, valid in (((12, 14), True), ((13, 14), False), ((12, 15), False), ((0, 2), False)):
for frets, valid in (((12, 14), True), ((13, 14), False), ((12, 15), True), ((12, 16), False), ((0, 2), False)):
with self.subTest(frets=frets):
source = document(tuple(position(s, f) for s, f in zip((2, 1), frets)))
if valid:
+10 -5
View File
@@ -32,20 +32,20 @@ class GuitarTests(unittest.TestCase):
self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(85)))
self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(129, 2)))
self.assertTrue(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 11, 61), generator.fretboard.TabPosition(3, 12, 67), generator.fretboard.TabPosition(2, 13, 72))))
self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 11, 61), generator.fretboard.TabPosition(3, 12, 67), generator.fretboard.TabPosition(2, 14, 73))))
self.assertTrue(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 11, 61), generator.fretboard.TabPosition(3, 12, 67), generator.fretboard.TabPosition(2, 14, 73))))
self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(6, 0, 40), generator.fretboard.TabPosition(1, 20, 84))))
self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 10, 60), generator.fretboard.TabPosition(1, 20, 84))))
def test_fretted_positions_keep_span_and_proximity_scoring(self):
for frets, valid in (((1, 2, 3), True), ((1, 4), False), ((1, 5), False), ((7, 9), True), ((7, 10), False), ((7, 11), False), ((12, 1, 11), False), ((3, 3), True), ((18, 20), True)):
for frets, valid in (((1, 2, 3), True), ((1, 4), True), ((1, 5), False), ((7, 9), True), ((7, 10), True), ((7, 11), False), ((12, 1, 11), False), ((3, 3), True), ((18, 20), True)):
with self.subTest(frets=frets):
positions = tuple(generator.fretboard.TabPosition(6 - i, fret, 0) for i, fret in enumerate(frets))
self.assertEqual(generator.fretboard.within_fret_span(positions), valid)
self.assertEqual(generator.guitar.compactness(positions), max(frets) - min(frets))
self.assertEqual(generator.guitar.position_center(positions)[0], min(frets) + max(frets))
self.assertTrue(generator.fretboard.within_fret_span(()))
def test_held_notes_obey_the_shared_three_fret_limit(self):
def test_held_notes_obey_the_shared_four_fret_limit(self):
held = {'held': generator.fretboard.TabPosition(4, 7, 57)}
for fret, valid in ((9, True), (10, False), (11, False)):
for fret, valid in ((9, True), (10, True), (11, False)):
with self.subTest(fret=fret):
pending = [('attack', (generator.fretboard.TabPosition(2, fret, 59 + fret),))]
self.assertEqual(bool(list(generator.guitar.extend_positions(held, pending))), valid)
@@ -194,8 +194,13 @@ class GuitarTests(unittest.TestCase):
content = tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 3, 'half', octave=4, extra='<dot/><tie type="stop"/>')
content += '<backup><duration>4</duration></backup><forward><duration>1</duration></forward>' + tests.test_musicxml.xml_note('F', 3, 'half', octave=4, extra='<dot/><voice>2</voice>')
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
# These fixed E/F positions span five frets; a tie cannot move to a
# different E position to make the incoming F fit.
for note in tests.test_musicxml.notes(document):
for pitch in note['pitches']:
pitch['tablature'] = {'string': 2, 'fret': 5} if pitch['letter'] == 'e' else {'string': 1, 'fret': 1}
before = copy.deepcopy(document)
with self.assertRaisesRegex(ValueError, '3 consecutive fret positions.*held/tied strings'):
with self.assertRaisesRegex(ValueError, '4 consecutive fret positions.*held/tied strings'):
generator.guitar.place_guitar(document)
self.assertEqual(document, before)
def test_proximity_chooses_a_path_without_relocating_tied_notes(self):
+5 -3
View File
@@ -41,11 +41,13 @@ class GuitarPartTests(unittest.TestCase):
self.assertNotIn(0, generator.fretboard.chord_requirements(chord, respect_bass=False).required)
candidates = generator.guitar.chord_positions(generator.fretboard.chord_requirements(chord, respect_bass=False), profile='electric')
self.assertTrue(any(all(p.midi % 12 != 0 for p in c) for c in candidates))
def test_power_chord_cannot_expand_span_or_invent_upper_tones(self):
def test_power_chord_uses_four_fret_fallback_without_inventing_tones(self):
requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C5'))
self.assertEqual(set(requirements.allowed), {0, 7})
self.assertFalse(generator.acoustic_voicing.candidates(requirements))
self.assertFalse(generator.guitar.chord_positions(requirements, profile='electric'))
for candidates in (generator.acoustic_voicing.candidates(requirements), generator.guitar.chord_positions(requirements, profile='electric')):
self.assertTrue(candidates)
self.assertTrue(all(generator.fretboard.fret_span(c) == 4 for c in candidates))
self.assertTrue(all({p.midi % 12 for p in c} == {0, 7} for c in candidates))
def test_acoustic_uses_bass_and_three_upper_notes(self):
result = self.section(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
generator.model.validate_document(result)
+4 -4
View File
@@ -23,14 +23,14 @@ def ranked(symbol, melody=(), profile='electric', prune=False):
return sorted(candidates, key=lambda shape: (generator.guitar.voicing_cost(shape, profile, requirements.allowed, melody), shape))
class VoicingTests(unittest.TestCase):
def test_electric_chords_use_the_shared_three_fret_limit(self):
def test_electric_chords_use_the_shared_four_fret_limit(self):
for prune in (False, True):
candidates = ranked('CM7', prune=prune)
self.assertTrue(candidates)
self.assertTrue(all(generator.guitar.compactness(c) + 1 <= 3 for c in candidates))
self.assertTrue(all(generator.guitar.compactness(c) + 1 <= 4 for c in candidates))
candidates = ranked('CM7')
self.assertIn(grip((5, 4, 5, 3)), candidates)
self.assertNotIn(grip((2, 4, 1, 3)), candidates)
self.assertIn(grip((2, 4, 1, 3)), candidates)
self.assertNotIn(grip((5, 9, 8, 7)), candidates)
def test_requested_shapes_win_without_a_shape_dictionary(self):
for symbol, melody, expected in (('FM7', 69, (3, 5, 5, 5)), ('Gm7', 67, (3, 3, 3, 3))):
@@ -55,7 +55,7 @@ class VoicingTests(unittest.TestCase):
def test_wider_low_chord_cannot_displace_a_playable_melody_grip(self):
candidates = ranked('CM7', (67,))
self.assertEqual(candidates[0], grip((5, 4, 5, 3)))
self.assertNotIn(grip((2, 4, 1, 3)), candidates)
self.assertIn(grip((2, 4, 1, 3)), candidates)
self.assertEqual(max(p.midi for p in candidates[0]), 67)
def test_essential_extensions_are_never_relaxed_to_add_the_root(self):
for symbol in ('C7b9', 'FM7#11', 'C13', 'Cdim7'):