Chord Families

This commit is contained in:
khodowany
2026-10-06 02:57:37 -05:00
parent df83cae38c
commit e71385dad1
16 changed files with 590 additions and 126 deletions
+159
View File
@@ -0,0 +1,159 @@
"""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()
+6 -3
View File
@@ -31,12 +31,15 @@ class LayeredComping(unittest.TestCase):
self.resources = generator.vocabulary.load()
def test_candidates_preserve_all_counts_and_articulations(self):
m = self.source['staves'][0]['measures'][0]
policy = self.resources['accompaniment']
for lane, expected in (('electric', {0, 1, 2}), ('bass', {1, 2}), ('upper', {1, 2, 3})):
policy = generator.vocabulary.accompaniment('electric' if lane == 'electric' else 'acoustic', self.resources)
candidates = generator.comping_rhythm.lane_candidates(m, m['harmonies'][0], lane, (), policy, 'test')
self.assertEqual({len(c.events) for c in candidates}, expected)
self.assertTrue(any(e.end - e.start <= fractions.Fraction(1, 2) for c in candidates for e in c.events) if lane != 'bass' else True)
self.assertTrue(any(e.end - e.start >= 2 for c in candidates for e in c.events))
if lane == 'electric':
self.assertTrue(any(e.end - e.start >= 2 for c in candidates for e in c.events))
else:
self.assertEqual({e.end - e.start for c in candidates for e in c.events}, {fractions.Fraction(1, 2), fractions.Fraction(1)})
if lane == 'bass':
self.assertTrue(all(c.events[0].start == 0 for c in candidates))
def test_ranges_downbeats_and_overlap(self):
@@ -125,7 +128,7 @@ class LayeredComping(unittest.TestCase):
n.update(kind='rest', pitches=[])
results[part + '_solo'] = empty
assembled = generator.production.assemble(source, results)
self.assertTrue(any(len(m['voices']) == 2 for m in assembled['acoustic']['staves'][0]['measures']))
self.assertTrue(all(len(m['voices']) == 1 for m in assembled['acoustic']['staves'][0]['measures']))
audit = generator.arrange.accompaniment.audit(self.source, assembled)
self.assertGreater(audit['acoustic']['reused_bars'], 0)
for doc in assembled.values():
+5 -5
View File
@@ -52,24 +52,24 @@ class GuitarPartTests(unittest.TestCase):
generator.fretboard.validate_document(result)
sounding = [n for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m) if n['pitches']]
self.assertTrue(sounding)
self.assertTrue(all(len(n['pitches']) in (1, 3) for n in sounding))
self.assertTrue(all(len(n['pitches']) in (1, 3, 4) for n in sounding))
for n in sounding:
self.assertIn(sorted(p['tablature']['string'] for p in n['pitches']), ([2, 3, 4], [5], [6]))
self.assertIn(sorted(p['tablature']['string'] for p in n['pitches']), ([2, 3, 4], [5], [6], [2, 3, 4, 5], [2, 3, 4, 6]))
def test_explicit_bass_is_preserved_for_triads_and_extended_chords(self):
for symbol, expected in (('CM/E', 4), ('C7b9/E', 4)):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
chord = generator.harmony.parse_symbol(symbol)
source['staves'][0]['measures'][0]['harmonies'] = [{**chord, 'id': 'h1', 'onset': generator.model.rational(0)}]
result = tests.arrangement_fixtures.section_document(source, 'acoustic')
notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] if v['id'] == 'bass' for n in v['notes']]
self.assertEqual({min(generator.model.pitch_number(p) for p in n['pitches']) % 12 for n in notes if n['pitches']}, {expected})
bass = [p for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m) for p in n['pitches'] if p['tablature']['string'] in (5, 6)]
self.assertEqual({generator.model.pitch_number(p) % 12 for p in bass}, {expected})
def test_long_melody_chords_and_short_notes(self):
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', 2, 'half', octave=4) + tests.test_musicxml.xml_note('G', 1, 'quarter', octave=4) + tests.test_musicxml.xml_note('C', 1, 'quarter', octave=5)
notes = tests.test_musicxml.notes(self.section(content))
self.assertIn(len(notes[0]['pitches']), (3, 4))
self.assertEqual([len(n['pitches']) for n in notes[1:]], [1, 1])
self.assertEqual(generator.model.pitch_number(notes[0]['pitches'][0]), 76)
self.assertIn(sorted(p['tablature']['string'] for p in notes[0]['pitches']), ([1, 2, 3], [1, 2, 3, 4]))
self.assertIn(sorted(p['tablature']['string'] for p in notes[0]['pitches']), ([1, 2, 3], [2, 3, 4], [1, 2, 3, 4]))
def test_tied_quarters_qualify_and_keep_all_strings(self):
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='<tie type="stop"/>') + '<note><rest/><duration>2</duration><type>half</type></note>'
result = self.section(content)
+1 -1
View File
@@ -125,7 +125,7 @@ class IndependentRoles(unittest.TestCase):
def signature(m):
return [(n['onset'], n['duration'], [(generator.model.pitch_number(p), p['tablature']) for p in n['pitches']]) for v in m['voices'] for n in v['notes']]
self.assertEqual([signature(m) for m in measures[4:8]], [signature(m) for m in measures[8:12]])
self.assertTrue(all(len(n['pitches']) in (0, 1, 3) for m in measures for v in m['voices'] for n in v['notes']))
self.assertTrue(all(len(n['pitches']) in (0, 1, 3, 4) for m in measures for v in m['voices'] for n in v['notes']))
class TypedExpressionContracts(unittest.TestCase):
def setUp(self):
+2 -2
View File
@@ -36,11 +36,11 @@ class VoicingTests(unittest.TestCase):
for symbol, melody, expected in (('FM7', 69, (3, 5, 5, 5)), ('Gm7', 67, (3, 3, 3, 3))):
with self.subTest(symbol=symbol):
self.assertEqual(ranked(symbol, (melody,))[0], grip(expected))
def test_four_strings_precede_melody_height_and_root_presence(self):
def test_melody_height_precedes_four_strings_and_optional_root(self):
candidates = ranked('C#M7', (65,))
four = next(c for c in candidates if len(c) == 4 and max(p.midi for p in c) > 65)
three = next(c for c in candidates if len(c) == 3 and max(p.midi for p in c) == 65)
self.assertLess(candidates.index(four), candidates.index(three))
self.assertLess(candidates.index(three), candidates.index(four))
candidates = ranked('FM7', (69,))
rootless = next(c for c in candidates if len(c) == 4 and 5 not in {p.midi % 12 for p in c})
rooted = next(c for c in candidates if len(c) == 3 and 5 in {p.midi % 12 for p in c})