"""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()