158 lines
9.4 KiB
Python
158 lines
9.4 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', '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):
|
|
# 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
|
|
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('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))
|
|
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)
|
|
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))
|
|
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']['patterns'][0]['events'][0]['duration'] = '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))
|
|
canonical = generator.comping.Canonical(source, 'acoustic', 'short')
|
|
for row in canonical.rhythm_report:
|
|
for event in row['events']:
|
|
self.assertGreater(fractions.Fraction(event['duration']), 0)
|
|
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 acoustic pattern'):
|
|
generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'exact')
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|