Files
product-vamp/tests/test_chord_families.py
T
2026-10-06 02:57:37 -05:00

160 lines
9.7 KiB
Python

"""General chord families and one-voice acoustic rhythm, not song overrides."""
import copy
import fractions
import unittest
import generator.acoustic
import generator.accompaniment
import generator.chord_families
import generator.comping
import generator.comping_rhythm
import generator.fretboard
import generator.guitar
import generator.harmony
import generator.model
import generator.production
import generator.vocabulary
import tests.solo_fixtures
import tests.test_acoustic
import tests.test_comping_voicings
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 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'):
with self.assertRaisesRegex(ValueError, 'no legal acoustic voicing'):
generator.comping.voicings(generator.comping.semantic_key(chord), part)
continue
options = generator.comping.voicings(generator.comping.semantic_key(chord), part)
self.assertTrue(options)
required = generator.comping.requirements(chord, part)
for grip in options:
self.assertTrue(required.covers({p.midi % 12 for p in grip}))
self.assertTrue({p.midi % 12 for p in grip} <= set(required.allowed))
self.assertTrue(generator.fretboard.playable_attack((p, fractions.Fraction(1)) for p in grip))
coverage = generator.chord_families.coverage(chord, options, part)
self.assertTrue(coverage)
if part == 'acoustic':
self.assertEqual(len(coverage), 4)
self.assertTrue(all(c['candidates'] or c['reason'] for c in coverage))
else:
self.assertEqual(sum(coverage.values()), len(options))
def test_root_and_fifth_families_on_both_bass_strings(self):
for symbol in ('C', 'Cm', 'CM7', 'Cm7', 'C7', 'CmM7', 'C6', 'Cm6', 'Cdim7', 'Cm7b5', 'C7sus4', 'C7b5'):
chord = generator.harmony.parse_symbol(symbol)
coverage = generator.chord_families.coverage(chord, tests.test_comping_voicings.options(symbol, 'acoustic'), 'acoustic')
self.assertTrue(all(c['candidates'] for c in coverage), symbol)
def test_user_examples_are_transposable_family_members(self):
examples = (('Em7b5', (7, 8, 7, 8)), ('A7b9', (7, 8, 6, 8)), ('Dm7', (5, 7, 5, 6)), ('G7', (5, 5, 4, 6)), ('Cm7', (3, 5, 3, 4)), ('F7', (3, 3, 2, 4)))
for symbol, frets in examples:
chord = generator.harmony.parse_symbol(symbol)
grip = tests.test_voicing.grip(frets, (5, 4, 3, 2))
self.assertIn(grip, tests.test_comping_voicings.options(symbol, 'acoustic'))
family = generator.chord_families.acoustic(chord, grip)
self.assertTrue(family['preferred'], symbol)
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')
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))
for grip in tests.test_comping_voicings.options('C7/E', 'acoustic'):
self.assertEqual(grip.bass.midi % 12, 4)
def test_electric_families_and_highest_possible_melody_string(self):
for symbol, midi in (('C7', 64), ('FM7', 69), ('C#M7', 65), ('Cm7', 67), ('C7b9', 70)):
with self.subTest(chord=symbol, melody=midi):
choices = tests.test_voicing.ranked(symbol, (midi,))
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)))
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))
low = tests.test_voicing.ranked('C7', (64,))[0]
self.assertEqual(tuple(p.string_number for p in low), (4, 3, 2))
def test_acoustic_policy_change_does_not_reseed_electric_roles(self):
resources = generator.vocabulary.load()
changed = copy.deepcopy(resources)
changed['accompaniment']['instruments']['acoustic']['families']['quarters'] = ['1/2']
for role in ('electric_accompaniment', 'electric_melody', 'electric_solo', 'vocal_melody'):
self.assertEqual(generator.production.dependencies(role, resources), generator.production.dependencies(role, changed))
self.assertNotEqual(generator.production.dependencies('acoustic_accompaniment', resources), generator.production.dependencies('acoustic_accompaniment', changed))
class CombinedAcoustic(unittest.TestCase):
def document(self, bars):
document = tests.test_acoustic.acoustic_document(bars)
# C bass with F/A above it keeps the overlapping fixture within two frets.
for m in document['staves'][0]['measures']:
for v in m['voices']:
if v['id'] == 'bass':
for n in v['notes']:
for p in n['pitches']:
p['letter'], p['tablature']['fret'] = 'c', 3
generator.fretboard.validate_document(document)
return document
def convert(self, document):
before = tests.test_acoustic.sounding(document)
generator.acoustic.combine(document)
generator.model.validate_document(document)
generator.fretboard.validate_document(document)
self.assertEqual(tests.test_acoustic.sounding(document), before)
self.assertTrue(all(len(m['voices']) == 1 for m in document['staves'][0]['measures']))
once = copy.deepcopy(document)
generator.acoustic.combine(document)
self.assertEqual(document, once)
return [n for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes']]
def test_staggered_quarter_and_eighth_share_a_voice_without_shortening(self):
doc = self.document([([(0, 1)], [(fractions.Fraction(1, 2), fractions.Fraction(1, 2))])])
notes = self.convert(doc)
self.assertEqual([len(n['pitches']) for n in notes[:2]], [1, 3])
a, b = notes[:2]
self.assertEqual(a['pitches'][0]['tie_to'], b['pitches'][0]['id'])
self.assertEqual(b['pitches'][0]['tie_from'], a['pitches'][0]['id'])
self.assertTrue(all(n['kind'] == 'rest' for n in notes[2:]))
self.assertTrue(all('stem' not in n and all('stem' not in p for p in n['pitches']) for n in notes))
def test_repeated_bass_remains_an_attack_under_a_held_upper_chord(self):
doc = self.document([([(0, fractions.Fraction(1, 2)), (fractions.Fraction(1, 2), fractions.Fraction(1, 2))], [(0, 1)])])
notes = self.convert(doc)
bass = [p for n in notes for p in n['pitches'] if p['tablature']['string'] == 5]
self.assertEqual(len(bass), 2)
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in bass))
upper = [p for n in notes for p in n['pitches'] if p['tablature']['string'] == 4]
self.assertEqual(upper[0]['tie_to'], upper[1]['id'])
def test_existing_cross_bar_tie_survives_combination(self):
doc = self.document([([(3, 1)], [(3, 1)]), ([(0, 1)], [(fractions.Fraction(1, 2), fractions.Fraction(1, 2))])])
bass = [p for m in doc['staves'][0]['measures'] for v in m['voices'] if v['id'] == 'bass' for n in v['notes'] for p in n['pitches']]
bass[0]['tie_to'], bass[1]['tie_from'] = bass[1]['id'], bass[0]['id']
self.convert(doc)
def test_active_acoustic_rhythm_has_short_values_and_eighth_pairs(self):
source = tests.solo_fixtures.song(count=2, chords=('C7', 'FM7'))
m = source['staves'][0]['measures'][0]
choices = generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'quarters-eighths')
self.assertTrue(all(e.end - e.start in (fractions.Fraction(1, 2), 1) for c in choices for e in c.events))
policy = generator.vocabulary.accompaniment('acoustic')
upper = generator.comping_rhythm.lane_candidates(m, None, 'upper', (), policy, 'pairs')
self.assertTrue(any(b.start - a.start == fractions.Fraction(1, 2) for c in upper for a, b in zip(c.events, c.events[1:])))
canonical = generator.comping.Canonical(source, 'acoustic', 'short')
self.assertFalse(any(l['tied_continuations'] for row in canonical.rhythm_report for l in row['layers'].values()))
def test_unknown_harmony_subdivision_is_not_silently_quantized(self):
source = tests.solo_fixtures.song(count=1, chords=('C7',))
m = source['staves'][0]['measures'][0]
m['harmonies'].append({**generator.harmony.parse_symbol('FM7'), 'id': 'change', 'onset': generator.model.rational(fractions.Fraction(1, 3))})
with self.assertRaisesRegex(ValueError, 'no bass rhythm'):
generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'exact')
if __name__ == '__main__':
unittest.main()