import generator.validation import copy import pathlib import unittest import generator.arrange.generate import generator.fretboard import generator.guitar import generator.hand_position import itertools import generator.harmony import generator.model import generator.musicxml import generator.score import generator.vocabulary import tests.test_musicxml import tests.test_parts import tests.position_fixtures def grip(frets, strings=(4, 3, 2, 1)): tuning = dict(generator.fretboard.STANDARD_TUNING) return tuple(generator.fretboard.TabPosition(s, f, tuning[s] + f) for s, f in zip(strings, frets)) def ranked(symbol, melody=(), profile='electric', prune=False): definition = generator.harmony.parse_symbol(symbol) requirements = generator.fretboard.chord_requirements(definition, respect_bass=profile != 'electric') candidates = generator.guitar.chord_positions(requirements, prune=prune, profile=profile, melody=melody) return sorted(candidates, key=lambda shape: (generator.guitar.voicing_cost(shape, profile, requirements.allowed, melody), shape)) class VoicingTests(unittest.TestCase): def test_melody_preferences_cannot_purchase_a_lower_priority_string(self): resources = generator.vocabulary.load() with generator.vocabulary.using(resources, 'electric_melody', {'voicing': {'register_weight': 1000}}): for symbol, melody in (('C7', (67,)), ('FM7', (69,)), ('Cm7', (70,))): candidates = ranked(symbol, melody) primary = [c for c in candidates if generator.fretboard.span_penalty(c) == generator.fretboard.span_penalty(candidates[0])] self.assertEqual(min(p.string_number for p in candidates[0] if p.midi in melody), min(p.string_number for c in primary for p in c if p.midi in melody)) self.assertTrue(set(melody) <= {p.midi for p in candidates[0]}) def test_melody_preference_cost_and_pruning_caches_include_role_settings(self): resources = generator.vocabulary.load() shape = grip((10, 12, 11, 12)) original = generator.guitar.voicing_cost(shape, 'electric', melody=(76,)) preference = generator.guitar.voicing_preferences(shape, generator.guitar.voicing_preference('electric_melody')) with generator.vocabulary.using(resources, 'electric_melody', {'voicing': {'register_weight': 0}}): plain = generator.guitar.voicing_cost(shape, 'electric', melody=(76,)) self.assertEqual(original.register - plain.register, preference) self.assertEqual(original.effort, plain.effort) self.assertEqual(original[:6], plain[:6]) self.assertEqual(original, generator.guitar.voicing_cost(shape, 'electric', melody=(76,))) for center in (45, 76, 45): with generator.vocabulary.using(resources, 'electric_melody', {'center': center}): self.assertEqual(set(ranked('C7', (72,), prune=True)), set(ranked('C7', (72,), prune=False))) 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 <= 4 for c in candidates)) candidates = ranked('CM7') self.assertIn(grip((5, 4, 5, 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))): with self.subTest(symbol=symbol): self.assertEqual(ranked(symbol, (melody,))[0], grip(expected)) 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(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}) self.assertLess(candidates.index(rootless), candidates.index(rooted)) def test_melody_on_top_precedes_root_among_four_string_shapes(self): candidates = ranked('C#M7', (65,)) best = candidates[0] self.assertEqual(len(best), 4) self.assertEqual(max(p.midi for p in best), 65) self.assertNotIn(1, {p.midi % 12 for p in best}) self.assertTrue(any(1 in {p.midi % 12 for p in c} and max(p.midi for p in c) > 65 for c in candidates)) 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.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'): definition = generator.harmony.parse_symbol(symbol) requirements = generator.fretboard.chord_requirements(definition, respect_bass=False) for candidate in ranked(symbol): classes = {p.midi % 12 for p in candidate} self.assertTrue(requirements.covers(classes)) self.assertTrue(classes <= set(requirements.allowed)) def test_explicitly_removed_root_is_not_restored_or_penalized(self): document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_parts.xml_harmony(kind='major-seventh') + tests.test_musicxml.xml_note('E', octave=5))) measure = document['staves'][0]['measures'][0] chord = measure['harmonies'][0] chord['degrees'].append({'value': 1, 'alter': 0, 'type': 'subtract'}) note = measure['voices'][0]['notes'][0] measure['transpose']['octave_change'] = -1 note.update(_harmony=chord, _guitar_profile='electric', _harmonize=True) _, tones = generator.guitar.guitar_events(document) candidates = list(generator.guitar.attack_candidates([note], tones, {}, {}, False)) self.assertTrue(candidates) self.assertTrue(all(all(p.midi % 12 != 0 for p in c.shape) and any(p.midi % 12 == 7 for p in c.shape) for c in candidates)) def test_anchored_chord_effort_is_independent_of_absolute_height(self): for fret in range(1, 13): with self.subTest(fret=fret): self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), 'electric').effort, 0) shape = grip((fret, fret + 2, fret + 1, fret + 2)) self.assertTrue(generator.fretboard.has_fret_anchor(shape)) self.assertEqual(generator.guitar.voicing_cost(shape, 'electric').effort, 10) self.assertEqual(generator.guitar.voicing_cost((), 'electric').effort, 0) def test_electric_anchor_is_eligibility_not_a_highest_fret_cap(self): for prune in (False, True): candidates = ranked('CM7', prune=prune) self.assertTrue(candidates) self.assertTrue(all(min(p.fret for p in c) <= 12 for c in candidates)) self.assertIn(grip((14, 12, 12, 12)), candidates) self.assertNotIn(grip((17, 17, 17, 19)), candidates) self.assertFalse(generator.fretboard.has_fret_anchor(grip((13, 13, 13, 13)))) self.assertFalse(generator.fretboard.has_fret_anchor(())) def test_height_is_a_passage_peak_not_an_additive_effort_penalty(self): for fret in range(1, 21): self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4)).effort, 0) self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4)).peak_position, 0) crossing = grip((12, 13, 14, 14)) self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 2) self.assertEqual(generator.guitar.voicing_cost(crossing, 'electric').effort, 10) def test_high_melody_fails_when_neither_anchored_chord_nor_single_note_fits(self): document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('C', octave=6))) note = tests.test_musicxml.notes(document)[0] note.update(_harmony=generator.harmony.parse_symbol('CM'), _guitar_profile='electric', _harmonize=True) before = copy.deepcopy(document) with self.assertRaisesRegex(ValueError, 'single notes need frets 1-12'): generator.guitar.place_guitar(document) self.assertEqual(document, before) def test_finger_complexity_is_subordinate_to_hand_travel(self): shape = grip((1, 2, 3, 3)) self.assertEqual(generator.guitar.voicing_cost(shape).effort, 2) self.assertEqual(generator.guitar.voicing_cost(shape, 'electric').effort, 10) for profile in ('', 'electric'): cost = generator.guitar.voicing_cost(shape, profile) self.assertEqual(cost.moved(5).travel, 5) self.assertEqual(cost.moved(5).effort, cost.effort) self.assertLess(generator.guitar.voicing_cost(grip((4, 4, 4, 4)), profile), cost) def test_distinct_frets_add_difficulty_even_with_the_same_span(self): simple, varied = grip((3, 3, 5, 5)), grip((3, 4, 5, 5)) self.assertEqual(generator.guitar.voicing_cost(simple, 'electric').effort, 8) self.assertEqual(generator.guitar.voicing_cost(varied, 'electric').effort, 10) self.assertEqual(generator.guitar.voicing_cost(simple).effort, generator.guitar.voicing_cost(varied).effort) def test_nearby_position_beats_lower_frets_within_comfort_range(self): low, nearby, previous = (grip((f,) * 4) for f in (3, 8, 9)) for profile in ('', 'electric'): self.assertLess(generator.guitar.voicing_cost(nearby, profile).moved(generator.guitar.movement(previous, nearby, 7, 7)), generator.guitar.voicing_cost(low, profile).moved(generator.guitar.movement(previous, low, 7, 3))) def test_exact_melody_threshold_remains_a_soft_cost(self): low, high = (grip((f,) * 4) for f in (3, 13)) self.assertLess(generator.guitar.voicing_cost(high), generator.guitar.voicing_cost(low).moved(generator.guitar.movement(high, low, 12, 3))) def test_anchor_is_checked_before_candidate_retention(self): requirements = generator.fretboard.ChordRequirements(allowed=tuple(range(12)), foundation=(0,)) for prune in (True, False): candidates = generator.guitar.chord_positions(requirements, prune=prune, profile='electric') self.assertTrue(candidates) self.assertTrue(all(generator.fretboard.has_fret_anchor(c) for c in candidates)) def test_candidate_retention_preserves_concrete_fingerings(self): for symbol in ('C7', 'FM7', 'Gm7'): self.assertEqual(set(ranked(symbol, prune=True)), set(ranked(symbol, prune=False))) def test_tie_continuations_do_not_charge_a_second_voicing_cost(self): note = tests.test_musicxml.xml_note('E', 2, 'half', octave=5, extra='') + tests.test_musicxml.xml_note('E', 2, 'half', octave=5, extra='') document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(note)) events, tones = generator.guitar.guitar_events(document) states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())} first = generator.guitar.advance_positions([events[0][3]], events[0][0], tones, states, False) second = generator.guitar.advance_positions([events[1][3]], events[1][0], tones, first, False) self.assertEqual(sorted(p.cost for p in first.values()), sorted(p.cost for p in second.values())) def test_empty_and_positioned_states_use_the_same_physical_objective(self): empty = generator.guitar.Placement(generator.guitar.VoicingCost(effort=20), (), None, ()) shape = grip((3,), (1,)) placed = generator.guitar.Placement(generator.guitar.VoicingCost(), (('tone', shape[0]),), None, (), 1) candidate = generator.guitar.AttackCandidate((('next', shape[0]),), (), shape, generator.guitar.VoicingCost()) result = generator.guitar.advance_attacks([candidate], {0: empty, 1: placed}) self.assertIs(min(result.values(), key=lambda p: p.cost).previous, placed) def test_passage_search_matches_exhaustive_concrete_paths(self): layers = [ranked(symbol)[:4] for symbol in ('FM7', 'Gm7', 'C7b9', 'FM7')] costs = [{v: generator.guitar.voicing_cost(v, 'electric') for v in layer} for layer in layers] def total(path): return tests.position_fixtures.cost(path, costs, (6, 7, 8)) states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())} for i, layer in enumerate(layers): candidates = [generator.guitar.AttackCandidate(tuple((str(p.string_number), p) for p in v), (), v, costs[i][v]) for v in layer] states = generator.guitar.advance_attacks(candidates, states) self.assertEqual(min(s.cost for s in states.values()), min(total(p) for p in itertools.product(*layers))) def test_real_autumn_leaves_melody_has_expected_shapes_and_unchanged_notes(self): source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn_leaves.xml')) before = copy.deepcopy(source) result = generator.arrange.generate.arrange_document(source, 'electric') generator.validation.validate_document(result) section, checked = '', set() source_index = generator.score.body_start_measure_index(source['staves'][0]['measures']) for measure in result['staves'][0]['measures']: for voice in measure['voices']: for note in voice['notes']: frets = [p['tablature']['fret'] for p in note['pitches']] if frets: self.assertLessEqual(max(frets) - min(frets) + 1, 3) if len(frets) > 1: self.assertLessEqual(min(frets), 12) section = measure.get('section_label') or section if section != 'Guitar Melody': continue original = source['staves'][0]['measures'][source_index] source_index += 1 for note in measure['voices'][0]['notes']: if not note['pitches']: continue onset = generator.model.fraction(note['onset']) authored = next(n for n in original['voices'][0]['notes'] if generator.model.fraction(n['onset']) <= onset < generator.model.fraction(n['onset']) + generator.model.fraction(n['duration'])) self.assertEqual(generator.model.pitch_number(note['pitches'][0]), generator.model.pitch_number(authored['pitches'][0]) + 12) if original['number'] not in ('4', '12', '22', '24') or len(note['pitches']) == 1: continue expected = (3, 3, 3, 3) if original['number'] == '22' else (3, 5, 5, 5) frets = tuple(p['tablature']['fret'] for p in sorted(note['pitches'], key=lambda p: -p['tablature']['string'])) self.assertEqual(frets, expected) self.assertEqual(generator.model.pitch_number(note['pitches'][0]), max(generator.model.pitch_number(p) for p in note['pitches'])) checked.add(original['number']) self.assertEqual(checked, {'4', '12', '22', '24'}) self.assertEqual(source, before)