+163
-180
@@ -1,13 +1,18 @@
|
||||
import copy
|
||||
import fractions
|
||||
import itertools
|
||||
import pathlib
|
||||
import unittest
|
||||
import unittest.mock
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.acoustic
|
||||
import generator.guitar
|
||||
import generator.acoustic_voicing
|
||||
import generator.arrange.generate
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.rhythm
|
||||
import tests.test_acoustic
|
||||
import tests.test_accompaniment
|
||||
|
||||
@@ -37,15 +42,23 @@ def planned_document(harmonies, attacks=None):
|
||||
return document
|
||||
|
||||
def shape(note):
|
||||
tuning = dict(generator.guitar.STANDARD_TUNING)
|
||||
return tuple(sorted(generator.guitar.TabPosition(p['tablature']['string'], p['tablature']['fret'], tuning[p['tablature']['string']] + p['tablature']['fret']) for p in note['pitches']))
|
||||
tuning = dict(generator.fretboard.STANDARD_TUNING)
|
||||
return tuple(sorted(generator.fretboard.TabPosition(p['tablature']['string'], p['tablature']['fret'], tuning[p['tablature']['string']] + p['tablature']['fret']) for p in note['pitches']))
|
||||
|
||||
def voicing(frets, bass_string=5):
|
||||
tuning = dict(generator.fretboard.STANDARD_TUNING)
|
||||
return generator.acoustic_voicing.Voicing(*(generator.fretboard.TabPosition(s, f, tuning[s] + f) for s, f in zip((bass_string, 4, 3, 2), frets)))
|
||||
|
||||
def candidates(chord):
|
||||
definition = generator.harmony.parse_symbol(chord) if isinstance(chord, str) else chord
|
||||
return generator.acoustic_voicing.candidates(generator.fretboard.chord_requirements(definition), generator.harmony.is_triad(definition))
|
||||
|
||||
class AcousticVoicingTests(unittest.TestCase):
|
||||
def place(self, document):
|
||||
spans = generator.acoustic.harmony_spans(document)
|
||||
lanes = {n['id']: n['_guitar_lane'] for span in spans for n in span.notes}
|
||||
before = [tests.test_accompaniment.timing(v['notes']) for m in document['staves'][0]['measures'] for v in m['voices']]
|
||||
generator.guitar.place_guitar(document)
|
||||
generator.acoustic_voicing.place(document)
|
||||
self.assertEqual(before, [tests.test_accompaniment.timing(v['notes']) for m in document['staves'][0]['measures'] for v in m['voices']])
|
||||
for span in spans:
|
||||
basses = {shape(n) for n in span.notes if lanes[n['id']] == 'bass'}
|
||||
@@ -53,21 +66,22 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
self.assertLessEqual(len(basses), 1)
|
||||
self.assertLessEqual(len(uppers), 1)
|
||||
grip = tuple(sorted({p for n in span.notes for p in shape(n)}, reverse=True))
|
||||
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
|
||||
requirements = generator.fretboard.chord_requirements(span.harmony)
|
||||
if basses and uppers:
|
||||
self.assertIn(tuple(p.string_number for p in grip), generator.guitar.ACOUSTIC_STRINGS)
|
||||
self.assertTrue(generator.acoustic_voicing.playable(generator.acoustic_voicing.Voicing(*grip)))
|
||||
self.assertTrue(requirements.covers({p.midi % 12 for p in grip}))
|
||||
self.assertTrue({p.midi % 12 for p in grip} <= set(requirements.allowed))
|
||||
if basses:
|
||||
self.assertIn(min(p.midi for p in grip) % 12, requirements.basses)
|
||||
self.assertTrue(generator.guitar.within_fret_span(grip))
|
||||
self.assertTrue(all(1 <= p.fret <= 20 for p in grip))
|
||||
for note in span.notes:
|
||||
strings = sorted(p['tablature']['string'] for p in note['pitches'])
|
||||
if lanes[note['id']] == 'upper':
|
||||
self.assertEqual(strings, [2, 3, 4])
|
||||
else:
|
||||
self.assertIn(strings, ([5], [6]))
|
||||
self.assertFalse(any(k.startswith('_') for k in note))
|
||||
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in note['pitches']))
|
||||
generator.model.validate_document(document)
|
||||
return spans
|
||||
def test_independent_attacks_keep_one_complete_grip(self):
|
||||
def test_independent_attacks_keep_one_complete_voicing(self):
|
||||
document = planned_document([[(0, 'FM7')]])
|
||||
spans = self.place(document)
|
||||
self.assertEqual(len(spans), 1)
|
||||
@@ -76,37 +90,47 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
generator.acoustic.combine(document)
|
||||
self.assertEqual(tests.test_acoustic.sounding(document), before)
|
||||
generator.model.validate_document(document)
|
||||
def test_fifth_bass_can_win_without_alternating(self):
|
||||
document = planned_document([[(0, 'Csus')], []])
|
||||
spans = self.place(document)
|
||||
self.assertEqual(len(spans), 1)
|
||||
bass = [p for n in spans[0].notes for p in shape(n) if p.string_number >= 5]
|
||||
self.assertTrue(bass)
|
||||
self.assertEqual({p.midi % 12 for p in bass}, {7})
|
||||
root_shapes = generator.guitar.chord_positions(generator.guitar.chord_requirements(generator.harmony.parse_symbol('Csus/C'), 'acoustic'), profile='acoustic')
|
||||
selected = tuple(sorted({p for n in spans[0].notes for p in shape(n)}, reverse=True))
|
||||
self.assertLessEqual(generator.guitar.voicing_cost(selected, 'acoustic'), min(generator.guitar.voicing_cost(s, 'acoustic') for s in root_shapes))
|
||||
def test_bass_options_follow_all_chord_tones_and_explicit_basses(self):
|
||||
inversion = generator.harmony.parse_symbol('Cdim7') | {'inversion': 0}
|
||||
for chord, expected in (('C7', {0, 4, 7, 10}), ('Cdim7', {0, 3, 6, 9}), ('Caug', {0, 4, 8}), ('Cno5', {0, 4}), ('C7b9', {0, 1, 4, 7, 10}), ('Dmno1', {5, 9}), ('C7/E', {4}), (inversion, {0})):
|
||||
with self.subTest(chord=chord):
|
||||
span = generator.acoustic.harmony_spans(planned_document([[(0, chord)]]))[0]
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
self.assertEqual({min(p.midi for p in c.shape) % 12 for c in candidates}, expected)
|
||||
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
|
||||
self.assertTrue(all(requirements.covers({p.midi % 12 for p in c.shape}) for c in candidates))
|
||||
def test_same_grip_survives_repeated_symbols_rest_bars_and_sections(self):
|
||||
def test_four_strings_allow_all_defined_bass_inversions(self):
|
||||
for symbol in ('CM', 'Dm', 'Caug', 'Cdim', 'C7', 'Cdim7', 'Cno5', 'C7b9', 'Dmno1', 'C6', 'C13', 'Csus', 'Csus2'):
|
||||
with self.subTest(chord=symbol):
|
||||
chord = generator.harmony.parse_symbol(symbol)
|
||||
requirements = generator.fretboard.chord_requirements(chord)
|
||||
options = candidates(chord)
|
||||
self.assertTrue(options)
|
||||
self.assertFalse(requirements.basses)
|
||||
self.assertTrue(all(v.bass.midi % 12 in requirements.allowed for v in options))
|
||||
self.assertTrue(all(requirements.covers({p.midi % 12 for p in v}) for v in options))
|
||||
self.assertTrue(all(generator.acoustic_voicing.playable(v) for v in options))
|
||||
self.assertEqual({v.bass.midi % 12 for v in candidates('C7')}, {0, 4, 7, 10})
|
||||
self.assertIn(1, {v.bass.midi % 12 for v in candidates('C7b9')})
|
||||
self.assertIn(5, {v.bass.midi % 12 for v in candidates('Dm')})
|
||||
def test_same_voicing_survives_repeated_symbols_and_rest_bars(self):
|
||||
attacks = [([(0, 1), (2, 1)], [(0, 1), (3, 1)]), ([], []), ([(2, 1)], [(1, 1), (3, 1)])]
|
||||
document = planned_document([[(0, 'C7')], [], [(0, 'C7'), (2, 'C7')]], attacks)
|
||||
document['staves'][0]['measures'][2]['section_label'] = 'Next section'
|
||||
self.assertEqual(len(self.place(document)), 1)
|
||||
def test_changes_during_silence_end_the_span(self):
|
||||
def test_iterations_are_independent_even_with_identical_boundary_chords(self):
|
||||
first, second = [[(0, s)] for s in ('C7', 'G7')], [[(0, s)] for s in ('G7', 'FM7')]
|
||||
document = planned_document(first + second)
|
||||
document['staves'][0]['measures'][2]['section_label'] = 'Next iteration'
|
||||
groups = [list(group) for _, group in itertools.groupby(generator.acoustic.harmony_spans(document), key=lambda s: s.iteration)]
|
||||
self.assertEqual([len(g) for g in groups], [2, 2])
|
||||
expected = []
|
||||
for harmonies in (first, second):
|
||||
separate = planned_document(harmonies)
|
||||
self.place(separate)
|
||||
expected.extend(separate['staves'][0]['measures'])
|
||||
self.place(document)
|
||||
def positions(measures):
|
||||
return [[shape(n) for v in m['voices'] for n in v['notes']] for m in measures]
|
||||
self.assertEqual(positions(document['staves'][0]['measures']), positions(expected))
|
||||
def test_changes_during_silence_end_the_harmony_span_not_the_journey(self):
|
||||
for middle in ('G7', 'N.C.'):
|
||||
with self.subTest(middle=middle):
|
||||
document = planned_document([[(0, 'C7'), (1, middle), (2, 'C7')]], [([(0, 1), (2, 1)], [(0, 1), (3, 1)])])
|
||||
spans = self.place(document)
|
||||
self.assertEqual(len(spans), 2)
|
||||
self.assertEqual([len(s.notes) for s in spans], [2, 2])
|
||||
self.assertEqual(len({s.iteration for s in spans}), 1)
|
||||
def test_returning_to_a_chord_is_a_new_span(self):
|
||||
document = planned_document([[(0, 'C7')], [(0, 'G7')], [(0, 'C7')]])
|
||||
self.assertEqual(len(self.place(document)), 3)
|
||||
@@ -117,9 +141,7 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
spans = self.place(document)
|
||||
self.assertEqual(len(spans), 4)
|
||||
basses = [min(p.midi for n in span.notes for p in shape(n)) % 12 for span in spans]
|
||||
self.assertIn(basses[0], (0, 4, 7, 10))
|
||||
self.assertEqual(basses[1:3], [4, 7])
|
||||
self.assertIn(basses[3], (0, 4, 6, 10))
|
||||
def test_non_accompaniment_passage_ends_the_span(self):
|
||||
document = planned_document([[(0, 'C7')], [], []])
|
||||
measure = document['staves'][0]['measures'][1]
|
||||
@@ -130,164 +152,125 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
document = planned_document([[(0, 'C7')], []])
|
||||
document['staves'][0]['measures'][0]['harmonies'] = []
|
||||
self.assertEqual(len(self.place(document)), 1)
|
||||
def test_no_chord_does_not_generate_an_attack(self):
|
||||
def test_no_chord_and_empty_iterations_do_not_generate_notes(self):
|
||||
document = planned_document([[(0, 'N.C.')], [(0, 'C7')], [(0, 'N.C.')]])
|
||||
for index, measure in enumerate(document['staves'][0]['measures']):
|
||||
measure['section_label'] = f'Iteration {index}'
|
||||
self.assertEqual(len(self.place(document)), 1)
|
||||
for measure in (document['staves'][0]['measures'][0], document['staves'][0]['measures'][2]):
|
||||
self.assertTrue(all(n['kind'] == 'rest' for v in measure['voices'] for n in v['notes']))
|
||||
def test_candidate_includes_unsounded_half_of_the_grip(self):
|
||||
self.assertEqual(generator.acoustic_voicing.select([]), [])
|
||||
def test_unsounded_voices_still_constrain_the_full_voicing(self):
|
||||
for lane in ('bass', 'upper'):
|
||||
document = planned_document([[(0, 'C7')]], [([(0, 1)] if lane == 'bass' else [], [(1, 1)] if lane == 'upper' else [])])
|
||||
span = generator.acoustic.harmony_spans(document)[0]
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
self.assertTrue(candidates)
|
||||
for candidate in candidates:
|
||||
self.assertIn(tuple(p.string_number for p in candidate.shape), generator.guitar.ACOUSTIC_STRINGS)
|
||||
self.assertEqual({p for _, p in candidate.active}, set(candidate.shape))
|
||||
self.assertEqual(len(candidate.choices), 1)
|
||||
self.assertEqual(len(candidate.choices[0].added), 1 if lane == 'bass' else len(candidate.shape) - 1)
|
||||
selected = generator.acoustic_voicing.select([span])[0]
|
||||
self.assertTrue(generator.acoustic_voicing.playable(selected))
|
||||
self.place(document)
|
||||
def test_rhythm_density_does_not_multiply_shape_cost(self):
|
||||
self.assertEqual(shape(span.notes[0]), tuple(sorted((selected.bass,) if lane == 'bass' else selected[1:])))
|
||||
def test_rhythm_density_does_not_multiply_movement_cost(self):
|
||||
harmonies = [[(0, symbol)] for symbol in ('FM7', 'Gm7', 'C7')]
|
||||
sparse = planned_document(harmonies, [([(0, 1)], [(2, 1)])] * 3)
|
||||
dense = planned_document(harmonies, [([(i, 1) for i in range(4)], [(i, 1) for i in range(4)])] * 3)
|
||||
selected = [tuple(tuple(sorted({p for n in span.notes for p in shape(n)})) for span in self.place(document)) for document in (sparse, dense)]
|
||||
self.assertEqual(*selected)
|
||||
def test_span_search_matches_exhaustive_full_grip_transitions(self):
|
||||
document = planned_document([[(0, s)] for s in ('FM7', 'Gm7', 'C7', 'FM7')])
|
||||
states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())}
|
||||
for span in generator.acoustic.harmony_spans(document):
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
expected = {}
|
||||
for candidate in candidates:
|
||||
expected[candidate.active] = min((previous.cost.moved(2 * generator.guitar.acoustic_movement(tuple(p for _, p in previous.active), candidate.shape)).plus(candidate.cost), index) for index, previous in enumerate(states.values()))
|
||||
actual = generator.guitar.advance_acoustic(candidates, states)
|
||||
predecessors = {id(p): i for i, p in enumerate(states.values())}
|
||||
self.assertEqual({key: (p.cost, predecessors[id(p.previous)]) for key, p in actual.items()}, expected)
|
||||
states = actual
|
||||
def test_impossible_grip_fails_contextually_without_partial_materialization(self):
|
||||
def test_bass_switch_counts_both_string_and_fret_travel(self):
|
||||
previous = voicing((7, 8, 7, 8))
|
||||
for current, expected in ((voicing((7, 8, 7, 8), 6), 1), (voicing((8, 8, 7, 8), 6), 2), (voicing((9, 8, 7, 8), 6), 3), (voicing((6, 8, 7, 8)), 1)):
|
||||
self.assertEqual(generator.acoustic_voicing.movement(previous, current), expected)
|
||||
self.assertEqual(generator.acoustic_voicing.movement(current, previous), expected)
|
||||
self.assertEqual(generator.acoustic_voicing.movement(previous, previous), 0)
|
||||
def test_each_upper_string_is_tracked_not_just_the_grip_center(self):
|
||||
before, after = voicing((5, 7, 5, 7)), voicing((7, 5, 7, 5))
|
||||
self.assertEqual(generator.acoustic_voicing.movement(before, after), 8)
|
||||
for index in (1, 2, 3):
|
||||
frets = [5, 7, 5, 7]
|
||||
frets[index] += 1
|
||||
self.assertEqual(generator.acoustic_voicing.movement(before, voicing(frets)), 1)
|
||||
def test_fret_and_register_limits_are_hard_constraints(self):
|
||||
for frets, valid in (((1, 1, 1, 1), True), ((1, 4, 1, 4), True), ((1, 5, 1, 4), False), ((0, 1, 1, 1), False), ((12, 15, 14, 12), True), ((13, 13, 13, 13), False), ((12, 16, 14, 12), False), ((12, 21, 12, 12), False)):
|
||||
with self.subTest(frets=frets):
|
||||
self.assertEqual(generator.acoustic_voicing.playable(voicing(frets)), valid)
|
||||
for symbol in ('CM', 'C7b9', 'C13', 'Cdim7'):
|
||||
self.assertTrue(all(generator.acoustic_voicing.playable(v) for v in candidates(symbol)))
|
||||
def test_acoustic_cost_has_no_register_span_or_shape_penalties(self):
|
||||
options = (voicing((1, 1, 1, 1)), voicing((1, 4, 3, 4)), voicing((12, 12, 12, 12)), voicing((12, 15, 14, 15)))
|
||||
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
|
||||
result = generator.acoustic_voicing.advance(options, generator.fretboard.ChordRequirements(), states)
|
||||
self.assertEqual([p.cost for p in result], [generator.acoustic_voicing.Cost()] * len(options))
|
||||
def test_optional_tones_break_equal_movement_costs_only(self):
|
||||
requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C7'))
|
||||
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
|
||||
result = generator.acoustic_voicing.advance(candidates('C7'), requirements, states)
|
||||
self.assertTrue(all(p.cost.travel == 0 for p in result))
|
||||
self.assertEqual({p.cost.missing_tones for p in result}, {0, 1})
|
||||
best = min(result, key=lambda p: (p.cost, p.voicing))
|
||||
self.assertEqual({p.midi % 12 for p in best.voicing}, {0, 4, 7, 10})
|
||||
def test_dynamic_program_matches_exhaustive_paths_and_is_deterministic(self):
|
||||
harmonies = ['FM7', 'Gm7', 'C7']
|
||||
spans = generator.acoustic.harmony_spans(planned_document([[(0, s)] for s in harmonies]))
|
||||
options = [candidates(s) for s in harmonies]
|
||||
requirements = [generator.fretboard.chord_requirements(span.harmony) for span in spans]
|
||||
def cost(path):
|
||||
return generator.acoustic_voicing.Cost(sum(generator.acoustic_voicing.movement(a, b) for a, b in zip(path, path[1:])), sum(len(set(r.allowed) - {p.midi % 12 for p in v}) for r, v in zip(requirements, path)))
|
||||
selected = generator.acoustic_voicing.select(spans)
|
||||
self.assertEqual(cost(selected), min(cost(path) for path in itertools.product(*options)))
|
||||
self.assertEqual(selected, generator.acoustic_voicing.select(spans))
|
||||
def test_complete_triads_and_doubling_use_only_authored_chord_tones(self):
|
||||
for symbol in ('CM', 'Dm', 'Cdim', 'Caug', 'Dm/F', 'Dm7no7'):
|
||||
with self.subTest(chord=symbol):
|
||||
classes = set(generator.harmony.pitch_classes(generator.harmony.parse_symbol(symbol)))
|
||||
options = candidates(symbol)
|
||||
self.assertTrue(options)
|
||||
self.assertTrue(all(len(v) == 4 and {p.midi % 12 for p in v} == classes for v in options))
|
||||
for symbol, omitted in (('Dmno1', 2), ('C7no5', 7)):
|
||||
self.assertTrue(candidates(symbol))
|
||||
self.assertTrue(all(omitted not in {p.midi % 12 for p in v} for v in candidates(symbol)))
|
||||
def test_unplayable_omission_is_not_restored_to_fill_upper_strings(self):
|
||||
self.assertFalse(candidates('Dmno5'))
|
||||
document = planned_document([[(0, 'Dmno5')]])
|
||||
before = copy.deepcopy(document)
|
||||
with self.assertRaisesRegex(ValueError, 'Dmno5: no acoustic grip.*three-fret span'):
|
||||
generator.acoustic_voicing.place(document)
|
||||
self.assertEqual(document, before)
|
||||
def test_explicit_inversions_and_nonchord_slash_basses_are_preserved(self):
|
||||
for chord, bass in ((generator.harmony.parse_symbol('Dm') | {'inversion': 1}, 5), (generator.harmony.parse_symbol('C7') | {'inversion': 3}, 10), ('C7/E', 4), ('C7b9/Db', 1), ('Dm/B', 11)):
|
||||
with self.subTest(chord=chord):
|
||||
span = self.place(planned_document([[(0, chord)]]))[0]
|
||||
self.assertEqual({p.midi % 12 for n in span.notes for p in shape(n) if p.string_number >= 5}, {bass})
|
||||
def test_impossible_later_iteration_does_not_partially_materialize(self):
|
||||
document = planned_document([[(0, 'C7')], [(0, 'C7b9#11')]])
|
||||
document['staves'][0]['measures'][1]['section_label'] = 'Later iteration'
|
||||
before = copy.deepcopy(document)
|
||||
with self.assertRaisesRegex(ValueError, 'measure 2, event .*no acoustic grip.*1-20'):
|
||||
generator.guitar.place_guitar(document)
|
||||
generator.acoustic_voicing.place(document)
|
||||
self.assertEqual(document, before)
|
||||
def test_g_minor_to_c_seventh_preserves_positions_with_fifth_bass(self):
|
||||
tuning = dict(generator.guitar.STANDARD_TUNING)
|
||||
previous = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 10), (3, 10), (2, 11)))
|
||||
active = tuple((str(p.string_number), p) for p in previous)
|
||||
states = {active: generator.guitar.Placement(generator.guitar.VoicingCost(), active, None, ())}
|
||||
span = generator.acoustic.harmony_spans(planned_document([[(0, 'C7')]]))[0]
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
result = generator.guitar.advance_acoustic(candidates, states)
|
||||
best = min(result.values(), key=lambda p: (p.cost, p.active))
|
||||
chosen = tuple(p for _, p in best.active)
|
||||
self.assertEqual([(p.string_number, p.fret) for p in chosen], [(5, 10), (3, 9), (2, 11)])
|
||||
self.assertEqual(generator.guitar.acoustic_movement(previous, chosen), 1)
|
||||
self.assertEqual(generator.guitar.acoustic_movement(chosen, previous), 1)
|
||||
self.assertEqual(generator.guitar.acoustic_movement(previous, previous), 0)
|
||||
self.assertEqual(generator.guitar.acoustic_movement((), chosen), 0)
|
||||
def test_acoustic_candidates_are_not_pruned_by_grip_center(self):
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7'), 'acoustic')
|
||||
candidates = generator.guitar.chord_positions(requirements, profile='acoustic')
|
||||
self.assertEqual(candidates, generator.guitar.chord_positions(requirements, profile='acoustic', prune=False))
|
||||
self.assertGreater(len(candidates), len({generator.guitar.position_center(p) for p in candidates}))
|
||||
def test_physical_reach_uses_fret_spacing_and_an_inclusive_reference(self):
|
||||
tuning = dict(generator.guitar.STANDARD_TUNING)
|
||||
low = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 3), (3, 2), (2, 5)))
|
||||
reference = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 8), (3, 7), (2, 10)))
|
||||
high = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 9), (3, 8), (2, 11)))
|
||||
self.assertEqual(generator.guitar.compactness(low), generator.guitar.compactness(reference))
|
||||
self.assertGreater(generator.guitar.physical_reach(low), generator.guitar.physical_reach(reference))
|
||||
self.assertGreater(generator.guitar.physical_reach(reference), generator.guitar.physical_reach(high))
|
||||
self.assertEqual(generator.guitar.physical_reach(reference), generator.guitar.PREFERRED_ACOUSTIC_REACH)
|
||||
self.assertEqual(generator.guitar.physical_reach(()), 0)
|
||||
self.assertEqual(generator.guitar.physical_reach(reference[:1]), 0)
|
||||
span = generator.acoustic.harmony_spans(planned_document([[(0, 'Dm')]]))[0]
|
||||
self.assertIn(reference, [c.shape for c in generator.guitar.acoustic_candidates(span)])
|
||||
def test_acoustic_effort_does_not_reward_further_compression(self):
|
||||
tuning = dict(generator.guitar.STANDARD_TUNING)
|
||||
shapes = [tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in zip((5, 4, 3, 2), frets)) for frets in ((5, 5, 7, 7), (6, 6, 7, 7))]
|
||||
self.assertGreater(generator.guitar.physical_reach(shapes[0]), generator.guitar.physical_reach(shapes[1]))
|
||||
self.assertTrue(all(generator.guitar.physical_reach(s) <= generator.guitar.PREFERRED_ACOUSTIC_REACH for s in shapes))
|
||||
self.assertEqual(*(generator.guitar.voicing_cost(s, 'acoustic').effort for s in shapes))
|
||||
def test_comfortable_candidates_win_even_when_previous_grip_is_wider(self):
|
||||
tuning = dict(generator.guitar.STANDARD_TUNING)
|
||||
wider = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((6, 6), (4, 5), (3, 3), (2, 3)))
|
||||
active = tuple((str(p.string_number), p) for p in wider)
|
||||
states = {active: generator.guitar.Placement(generator.guitar.VoicingCost(), active, None, ())}
|
||||
span = generator.acoustic.harmony_spans(planned_document([[(0, 'Em7b5')]]))[0]
|
||||
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
|
||||
self.assertIn(wider, generator.guitar.chord_positions(requirements, profile='acoustic'))
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
self.assertNotIn(wider, [c.shape for c in candidates])
|
||||
result = generator.guitar.advance_acoustic(candidates, states)
|
||||
chosen = min(result.values(), key=lambda p: (p.cost, p.active))
|
||||
self.assertLessEqual(generator.guitar.physical_reach(tuple(p for _, p in chosen.active)), generator.guitar.PREFERRED_ACOUSTIC_REACH)
|
||||
def test_complete_triads_are_preferred_within_comfortable_candidates(self):
|
||||
for symbol in ('CM', 'Dm', 'Cdim', 'Caug', 'Dm/F'):
|
||||
with self.subTest(chord=symbol):
|
||||
span = generator.acoustic.harmony_spans(planned_document([[(0, symbol)]]))[0]
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
classes = set(generator.harmony.pitch_classes(span.harmony))
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all(classes <= {p.midi % 12 for p in c.shape} for c in candidates))
|
||||
self.assertTrue(all(generator.guitar.physical_reach(c.shape) <= generator.guitar.PREFERRED_ACOUSTIC_REACH for c in candidates))
|
||||
def test_omissions_and_extended_chords_do_not_gain_complete_triad_requirement(self):
|
||||
for symbol, omitted in (('Dmno1', 2), ('Dmno5', 9)):
|
||||
with self.subTest(chord=symbol):
|
||||
span = generator.acoustic.harmony_spans(planned_document([[(0, symbol)]]))[0]
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all(omitted not in {p.midi % 12 for p in c.shape} for c in candidates))
|
||||
for symbol in ('C7', 'C7b9', 'C13'):
|
||||
with self.subTest(chord=symbol):
|
||||
span = generator.acoustic.harmony_spans(planned_document([[(0, symbol)]]))[0]
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
|
||||
self.assertTrue(any(0 not in {p.midi % 12 for p in c.shape} for c in candidates))
|
||||
self.assertTrue(all(requirements.covers({p.midi % 12 for p in c.shape}) for c in candidates))
|
||||
def test_complete_triad_preference_cannot_force_a_wider_grip(self):
|
||||
tuning = dict(generator.guitar.STANDARD_TUNING)
|
||||
reduced, complete = [tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in pairs) for pairs in (((6, 1), (4, 3), (3, 2)), ((5, 5), (4, 3), (3, 2)))]
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('Dm'), 'acoustic')
|
||||
self.assertTrue(all(requirements.covers({p.midi % 12 for p in s}) for s in (reduced, complete)))
|
||||
self.assertLessEqual(generator.guitar.physical_reach(reduced), generator.guitar.PREFERRED_ACOUSTIC_REACH)
|
||||
self.assertGreater(generator.guitar.physical_reach(complete), generator.guitar.PREFERRED_ACOUSTIC_REACH)
|
||||
generator.guitar.acoustic_positions.cache_clear()
|
||||
self.addCleanup(generator.guitar.acoustic_positions.cache_clear)
|
||||
with unittest.mock.patch('generator.guitar.chord_positions', return_value=(reduced, complete)):
|
||||
self.assertEqual(generator.guitar.acoustic_positions(requirements, complete_triad=True), (reduced,))
|
||||
def test_explicit_inversions_and_nonchord_slash_basses_are_preserved(self):
|
||||
for chord, bass in ((generator.harmony.parse_symbol('Dm') | {'inversion': 1}, 5), (generator.harmony.parse_symbol('C7') | {'inversion': 3}, 10), ('Dm/B', 11)):
|
||||
with self.subTest(chord=chord):
|
||||
document = planned_document([[(0, chord)]])
|
||||
span = self.place(document)[0]
|
||||
self.assertEqual({p.midi % 12 for n in span.notes for p in shape(n) if p.string_number >= 5}, {bass})
|
||||
def test_wider_fallback_is_least_reaching_and_reported_with_context(self):
|
||||
document = planned_document([[(0, 'C13/E')]])
|
||||
span = generator.acoustic.harmony_spans(document)[0]
|
||||
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
|
||||
raw = generator.guitar.chord_positions(requirements, profile='acoustic')
|
||||
minimum = min(generator.guitar.physical_reach(s) for s in raw)
|
||||
self.assertGreater(minimum, generator.guitar.PREFERRED_ACOUSTIC_REACH)
|
||||
with self.assertLogs('generator.guitar', level='WARNING') as logs:
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
self.assertTrue(candidates)
|
||||
self.assertEqual({generator.guitar.physical_reach(c.shape) for c in candidates}, {minimum})
|
||||
self.assertTrue(all(requirements.covers({p.midi % 12 for p in c.shape}) and generator.guitar.within_fret_span(c.shape) for c in candidates))
|
||||
self.assertIn(span.location, logs.output[0])
|
||||
self.assertIn('C13/E requires a wider acoustic reach', logs.output[0])
|
||||
with self.assertLogs('generator.guitar', level='WARNING'):
|
||||
self.place(document)
|
||||
def test_inversions_still_follow_nearby_full_grip_positions(self):
|
||||
tuning = dict(generator.guitar.STANDARD_TUNING)
|
||||
for previous, expected in ((((6, 3), (4, 2), (3, 3), (2, 3)), ((6, 3), (4, 2), (3, 3), (2, 2))), (((5, 7), (4, 8), (3, 7), (2, 8)), ((5, 7), (4, 8), (3, 6), (2, 8)))):
|
||||
with self.subTest(previous=previous):
|
||||
grip = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in previous)
|
||||
active = tuple((str(p.string_number), p) for p in grip)
|
||||
states = {active: generator.guitar.Placement(generator.guitar.VoicingCost(), active, None, ())}
|
||||
span = generator.acoustic.harmony_spans(planned_document([[(0, 'A7b9')]]))[0]
|
||||
result = generator.guitar.advance_acoustic(generator.guitar.acoustic_candidates(span), states)
|
||||
chosen = tuple(p for _, p in min(result.values(), key=lambda p: (p.cost, p.active)).active)
|
||||
self.assertEqual(tuple((p.string_number, p.fret) for p in chosen), expected)
|
||||
self.assertEqual(generator.guitar.acoustic_movement(grip, chosen), 1)
|
||||
def test_invalid_inversion_and_tuning_fail_with_context(self):
|
||||
invalid = generator.harmony.parse_symbol('C7') | {'inversion': 4}
|
||||
document = planned_document([[(0, invalid)]])
|
||||
with self.assertRaisesRegex(ValueError, 'measure 1.*inversion'):
|
||||
generator.acoustic_voicing.place(document)
|
||||
document = planned_document([[(0, 'C7')]])
|
||||
document['staves'][0]['measures'][0]['staff_details']['capo'] = 1
|
||||
with self.assertRaisesRegex(ValueError, 'capo'):
|
||||
generator.acoustic_voicing.place(document)
|
||||
def test_real_song_iterations_use_complete_upper_voices(self):
|
||||
rhythms = generator.rhythm.load_library(pathlib.Path('data/rhythms'))
|
||||
for path in sorted(pathlib.Path('data/songs').glob('*.xml')):
|
||||
source = generator.musicxml.load_musicxml(path)
|
||||
before = copy.deepcopy(source)
|
||||
result = generator.arrange.generate.arrange_document(source, 'acoustic', rhythms=rhythms)
|
||||
generator.model.validate_document(result)
|
||||
self.assertEqual(source, before)
|
||||
for measure in result['staves'][0]['measures']:
|
||||
for voice in measure['voices']:
|
||||
for note in voice['notes']:
|
||||
upper = sorted(p['tablature']['string'] for p in note['pitches'] if p['tablature']['string'] <= 4)
|
||||
self.assertIn(upper, ([], [2, 3, 4]))
|
||||
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in note['pitches']))
|
||||
if path.stem == 'autumn-leaves':
|
||||
for index, expected in ((0, voicing((3, 2, 3, 3), 6)), (1, voicing((3, 2, 3, 2), 6)), (4, voicing((3, 2, 3, 3), 6)), (5, voicing((3, 2, 3, 2), 6))):
|
||||
measure = result['staves'][0]['measures'][index]
|
||||
actual = {p for v in measure['voices'] for n in v['notes'] for p in shape(n)}
|
||||
self.assertEqual(actual, set(expected))
|
||||
self.assertEqual(result, generator.arrange.generate.arrange_document(source, 'acoustic', rhythms=rhythms))
|
||||
|
||||
@@ -7,7 +7,7 @@ import unittest.mock
|
||||
import generator.arrange.form
|
||||
import generator.arrange.generate
|
||||
import generator.electric
|
||||
import generator.guitar
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
@@ -17,9 +17,9 @@ def harmony(symbol, onset=0):
|
||||
return generator.harmony.parse_symbol(symbol) | {'id': f'chord-{onset}', 'onset': generator.model.rational(onset)}
|
||||
|
||||
def note(identifier, onset, chord, frets, *, melody=False, duration=1):
|
||||
tuning = dict(generator.guitar.STANDARD_TUNING)
|
||||
positions = tuple(generator.guitar.TabPosition(string, fret, tuning[string] + fret) for string, fret in zip((4, 3, 2, 1), frets))
|
||||
pitches = generator.guitar.chord_pitches(chord, positions)
|
||||
tuning = dict(generator.fretboard.STANDARD_TUNING)
|
||||
positions = tuple(generator.fretboard.TabPosition(string, fret, tuning[string] + fret) for string, fret in zip((4, 3, 2, 1), frets))
|
||||
pitches = generator.fretboard.chord_pitches(chord, positions)
|
||||
result = generator.model.make_event(identifier, fractions.Fraction(onset), fractions.Fraction(duration), 'note', pitches)
|
||||
result.update(_harmony=chord, _guitar_profile='electric')
|
||||
if melody:
|
||||
|
||||
+40
-30
@@ -8,7 +8,9 @@ import unittest
|
||||
import unittest.mock
|
||||
|
||||
import generator.__main__
|
||||
import generator.acoustic_voicing
|
||||
import generator.arrange.generate
|
||||
import generator.fretboard
|
||||
import generator.guitar
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
@@ -22,22 +24,28 @@ import tests.test_musicxml
|
||||
|
||||
class GuitarTests(unittest.TestCase):
|
||||
def test_fret_boundaries_and_span(self):
|
||||
self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(40)))
|
||||
self.assertIn(generator.guitar.TabPosition(6, 1, 41), generator.guitar.positions_for_pitch(fractions.Fraction(41)))
|
||||
self.assertIn(generator.guitar.TabPosition(1, 20, 84), generator.guitar.positions_for_pitch(fractions.Fraction(84)))
|
||||
self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(85)))
|
||||
self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(129, 2)))
|
||||
self.assertTrue(generator.guitar.within_fret_span((generator.guitar.TabPosition(4, 11, 61), generator.guitar.TabPosition(3, 12, 67), generator.guitar.TabPosition(2, 14, 73))))
|
||||
self.assertFalse(generator.guitar.within_fret_span((generator.guitar.TabPosition(6, 0, 40), generator.guitar.TabPosition(1, 20, 84))))
|
||||
self.assertFalse(generator.guitar.within_fret_span((generator.guitar.TabPosition(4, 10, 60), generator.guitar.TabPosition(1, 20, 84))))
|
||||
self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(40)))
|
||||
self.assertIn(generator.fretboard.TabPosition(6, 1, 41), generator.fretboard.positions_for_pitch(fractions.Fraction(41)))
|
||||
self.assertIn(generator.fretboard.TabPosition(1, 20, 84), generator.fretboard.positions_for_pitch(fractions.Fraction(84)))
|
||||
self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(85)))
|
||||
self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(129, 2)))
|
||||
self.assertTrue(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 11, 61), generator.fretboard.TabPosition(3, 12, 67), generator.fretboard.TabPosition(2, 14, 73))))
|
||||
self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(6, 0, 40), generator.fretboard.TabPosition(1, 20, 84))))
|
||||
self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 10, 60), generator.fretboard.TabPosition(1, 20, 84))))
|
||||
def test_fretted_positions_keep_span_and_proximity_scoring(self):
|
||||
for frets, valid in (((1, 2, 3), True), ((1, 5), True), ((1, 6), False), ((12, 1, 11), False), ((3, 3), True)):
|
||||
for frets, valid in (((1, 2, 3), True), ((1, 4), True), ((1, 5), False), ((7, 10), True), ((7, 11), False), ((12, 1, 11), False), ((3, 3), True)):
|
||||
with self.subTest(frets=frets):
|
||||
positions = tuple(generator.guitar.TabPosition(6 - i, fret, 0) for i, fret in enumerate(frets))
|
||||
self.assertEqual(generator.guitar.within_fret_span(positions), valid)
|
||||
positions = tuple(generator.fretboard.TabPosition(6 - i, fret, 0) for i, fret in enumerate(frets))
|
||||
self.assertEqual(generator.fretboard.within_fret_span(positions), valid)
|
||||
self.assertEqual(generator.guitar.compactness(positions), max(frets) - min(frets))
|
||||
self.assertEqual(generator.guitar.position_center(positions)[0], min(frets) + max(frets))
|
||||
self.assertTrue(generator.guitar.within_fret_span(()))
|
||||
self.assertTrue(generator.fretboard.within_fret_span(()))
|
||||
def test_held_notes_obey_the_shared_three_fret_limit(self):
|
||||
held = {'held': generator.fretboard.TabPosition(4, 7, 57)}
|
||||
for fret, valid in ((10, True), (11, False)):
|
||||
with self.subTest(fret=fret):
|
||||
pending = [('attack', (generator.fretboard.TabPosition(2, fret, 59 + fret),))]
|
||||
self.assertEqual(bool(list(generator.guitar.extend_positions(held, pending))), valid)
|
||||
def test_low_open_e_fails_without_octave_fitting_or_mutation(self):
|
||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=2)))
|
||||
before = copy.deepcopy(document)
|
||||
@@ -45,8 +53,8 @@ class GuitarTests(unittest.TestCase):
|
||||
generator.guitar.place_guitar(document)
|
||||
self.assertEqual(document, before)
|
||||
def test_open_string_pitch_uses_a_fretted_alternative(self):
|
||||
for _, midi in generator.guitar.STANDARD_TUNING[1:]:
|
||||
positions = generator.guitar.positions_for_pitch(fractions.Fraction(midi))
|
||||
for _, midi in generator.fretboard.STANDARD_TUNING[1:]:
|
||||
positions = generator.fretboard.positions_for_pitch(fractions.Fraction(midi))
|
||||
self.assertTrue(positions)
|
||||
self.assertTrue(all(p.fret >= 1 and p.midi == midi for p in positions))
|
||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=4)))
|
||||
@@ -67,7 +75,7 @@ class GuitarTests(unittest.TestCase):
|
||||
def test_theory_controls_omissions_and_bass(self):
|
||||
for symbol in ('CM', 'G7b9', 'FM7#11/A', 'CmM7', 'Cdim7', 'C13', 'Csus', 'Cmadd9'):
|
||||
with self.subTest(symbol=symbol):
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol(symbol))
|
||||
requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol(symbol))
|
||||
candidates = generator.guitar.chord_positions(requirements)
|
||||
self.assertTrue(candidates)
|
||||
for candidate in candidates:
|
||||
@@ -75,29 +83,29 @@ class GuitarTests(unittest.TestCase):
|
||||
self.assertTrue(requirements.covers(covered))
|
||||
self.assertTrue(covered <= set(requirements.allowed))
|
||||
self.assertEqual(len(candidate), len({p.string_number for p in candidate}))
|
||||
self.assertTrue(generator.guitar.within_fret_span(candidate))
|
||||
self.assertTrue(generator.fretboard.within_fret_span(candidate))
|
||||
self.assertTrue(all(1 <= p.fret <= 20 for p in candidate))
|
||||
if requirements.basses:
|
||||
self.assertIn(min(p.midi for p in candidate) % 12, requirements.basses)
|
||||
self.assertIn(2, generator.guitar.chord_requirements(generator.harmony.parse_symbol('Cmadd9')).required)
|
||||
self.assertNotIn(7, generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7')).required)
|
||||
self.assertEqual(generator.guitar.chord_requirements(generator.harmony.parse_symbol('CM')).foundation, (0, 7))
|
||||
self.assertEqual(generator.guitar.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 0}).basses, (0,))
|
||||
self.assertEqual(generator.guitar.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 1}).basses, (4,))
|
||||
self.assertTrue(any(p.fret > 12 for c in generator.guitar.chord_positions(generator.guitar.chord_requirements(generator.harmony.parse_symbol('CM'))) for p in c))
|
||||
self.assertIn(2, generator.fretboard.chord_requirements(generator.harmony.parse_symbol('Cmadd9')).required)
|
||||
self.assertNotIn(7, generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C7')).required)
|
||||
self.assertEqual(generator.fretboard.chord_requirements(generator.harmony.parse_symbol('CM')).foundation, (0, 7))
|
||||
self.assertEqual(generator.fretboard.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 0}).basses, (0,))
|
||||
self.assertEqual(generator.fretboard.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 1}).basses, (4,))
|
||||
self.assertTrue(any(p.fret > 12 for c in generator.guitar.chord_positions(generator.fretboard.chord_requirements(generator.harmony.parse_symbol('CM'))) for p in c))
|
||||
def test_root_or_fifth_is_required_for_every_generated_profile(self):
|
||||
for profile in ('', 'acoustic', 'electric'):
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7'), profile)
|
||||
requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C7'), respect_bass=profile != 'electric')
|
||||
for classes in ({0, 4, 10}, {4, 7, 10}, {0, 4, 7, 10}):
|
||||
self.assertTrue(requirements.covers(classes))
|
||||
self.assertFalse(requirements.covers({4, 10}))
|
||||
candidates = generator.guitar.chord_positions(requirements, prune=False, profile=profile)
|
||||
candidates = generator.acoustic_voicing.candidates(requirements) if profile == 'acoustic' else generator.guitar.chord_positions(requirements, prune=False, profile=profile)
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all({p.midi % 12 for p in shape} & {0, 7} for shape in candidates))
|
||||
self.assertEqual({frozenset({p.midi % 12 for p in shape} & {0, 7}) for shape in candidates}, {frozenset({0}), frozenset({7}), frozenset({0, 7})})
|
||||
def test_altered_and_removed_fifths_keep_their_semantics(self):
|
||||
for symbol, fifth in (('Cdim7', 6), ('Caug7', 8)):
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol(symbol), 'electric')
|
||||
requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol(symbol), respect_bass=False)
|
||||
self.assertEqual(requirements.foundation, (0, fifth))
|
||||
self.assertIn(fifth, requirements.required)
|
||||
self.assertNotIn(7, requirements.allowed)
|
||||
@@ -105,17 +113,19 @@ class GuitarTests(unittest.TestCase):
|
||||
chord = generator.harmony.parse_symbol('C7')
|
||||
chord['degrees'].append({'value': omitted, 'alter': 0, 'type': 'subtract'})
|
||||
for profile in ('acoustic', 'electric'):
|
||||
requirements = generator.guitar.chord_requirements(chord, profile)
|
||||
requirements = generator.fretboard.chord_requirements(chord, respect_bass=profile != 'electric')
|
||||
self.assertEqual(requirements.foundation, (remaining,))
|
||||
self.assertTrue(all(remaining in {p.midi % 12 for p in c} for c in generator.guitar.chord_positions(requirements, profile=profile)))
|
||||
candidates = generator.acoustic_voicing.candidates(requirements) if profile == 'acoustic' else generator.guitar.chord_positions(requirements, profile=profile)
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all(remaining in {p.midi % 12 for p in c} for c in candidates))
|
||||
chord['degrees'].append({'value': 1, 'alter': 0, 'type': 'subtract'})
|
||||
with self.assertRaisesRegex(ValueError, 'root or fifth'):
|
||||
generator.guitar.chord_requirements(chord)
|
||||
generator.fretboard.chord_requirements(chord)
|
||||
def test_explicit_degree_chord_uses_the_same_root_or_fifth_rule(self):
|
||||
standard = generator.harmony.parse_symbol('C7')
|
||||
explicit = {**standard, 'kind': 'other', 'degrees': [{'value': d, 'alter': a, 'type': 'add'} for d, a in generator.harmony.degrees(standard).items()]}
|
||||
for profile in ('', 'acoustic', 'electric'):
|
||||
self.assertEqual(generator.guitar.chord_requirements(standard, profile), generator.guitar.chord_requirements(explicit, profile))
|
||||
self.assertEqual(generator.fretboard.chord_requirements(standard, respect_bass=profile != 'electric'), generator.fretboard.chord_requirements(explicit, respect_bass=profile != 'electric'))
|
||||
def test_melody_placement_preserves_notes_timing_and_sound(self):
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', 2, 'half', octave=4) + tests.test_musicxml.xml_note('F', 2, 'half', octave=4)))
|
||||
before = copy.deepcopy(source)
|
||||
@@ -185,7 +195,7 @@ class GuitarTests(unittest.TestCase):
|
||||
randomizer = random.Random(17)
|
||||
states = {}
|
||||
for index in range(40):
|
||||
positions = tuple(generator.guitar.TabPosition(s, randomizer.randrange(21), 0) for s in randomizer.sample(range(1, 7), 3))
|
||||
positions = tuple(generator.fretboard.TabPosition(s, randomizer.randrange(21), 0) for s in randomizer.sample(range(1, 7), 3))
|
||||
states[index] = generator.guitar.Placement(generator.guitar.VoicingCost(*(randomizer.randrange(8) for _ in generator.guitar.VoicingCost._fields)), tuple((str(i), p) for i, p in enumerate(positions)), None, ())
|
||||
grid = generator.guitar.nearest_positions(states)
|
||||
for x, row in enumerate(grid):
|
||||
|
||||
+16
-14
@@ -3,7 +3,9 @@ import fractions
|
||||
import unittest
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.acoustic_voicing
|
||||
import generator.arrange.generate
|
||||
import generator.fretboard
|
||||
import generator.guitar
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
@@ -27,19 +29,19 @@ class GuitarPartTests(unittest.TestCase):
|
||||
for profile in ('acoustic', 'electric'):
|
||||
with self.subTest(symbol=symbol, profile=profile):
|
||||
definition = generator.harmony.parse_symbol(symbol)
|
||||
requirements = generator.guitar.chord_requirements(definition, profile)
|
||||
candidates = generator.guitar.chord_positions(requirements, profile=profile)
|
||||
requirements = generator.fretboard.chord_requirements(definition, respect_bass=profile != 'electric')
|
||||
candidates = generator.acoustic_voicing.candidates(requirements) if profile == 'acoustic' else generator.guitar.chord_positions(requirements, profile=profile)
|
||||
self.assertTrue(candidates)
|
||||
for candidate in candidates:
|
||||
strings = tuple(p.string_number for p in candidate)
|
||||
self.assertIn(strings, generator.guitar.ACOUSTIC_STRINGS if profile == 'acoustic' else generator.guitar.ELECTRIC_STRINGS)
|
||||
self.assertIn(strings, ((5, 4, 3, 2), (6, 4, 3, 2)) if profile == 'acoustic' else generator.guitar.ELECTRIC_STRINGS)
|
||||
self.assertTrue(requirements.covers({p.midi % 12 for p in candidate}))
|
||||
self.assertTrue({p.midi % 12 for p in candidate} <= set(requirements.allowed))
|
||||
self.assertTrue(generator.guitar.within_fret_span(candidate))
|
||||
self.assertTrue(generator.fretboard.within_fret_span(candidate))
|
||||
self.assertTrue(all(1 <= p.fret <= 20 for p in candidate))
|
||||
chord = generator.harmony.parse_symbol('C7')
|
||||
self.assertNotIn(0, generator.guitar.chord_requirements(chord, 'electric').required)
|
||||
candidates = generator.guitar.chord_positions(generator.guitar.chord_requirements(chord, 'electric'), profile='electric')
|
||||
self.assertNotIn(0, generator.fretboard.chord_requirements(chord, respect_bass=False).required)
|
||||
candidates = generator.guitar.chord_positions(generator.fretboard.chord_requirements(chord, respect_bass=False), profile='electric')
|
||||
self.assertTrue(any(all(p.midi % 12 != 0 for p in c) for c in candidates))
|
||||
def test_acoustic_combines_independent_rhythms_with_a_fixed_grip(self):
|
||||
result = self.section(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
|
||||
@@ -59,17 +61,17 @@ class GuitarPartTests(unittest.TestCase):
|
||||
self.assertNotIn('stem', note)
|
||||
strings = sorted(p['tablature']['string'] for p in note['pitches'] if p['tablature']['string'] <= 4)
|
||||
if strings:
|
||||
self.assertIn(strings, ([2, 3], [3, 4], [2, 3, 4]))
|
||||
self.assertEqual(strings, [2, 3, 4])
|
||||
timeline = generator.playback.compile_timeline(result)['events']
|
||||
by_id = {p['id']: p for v in measure['voices'] for n in v['notes'] for p in n['pitches']}
|
||||
for event in timeline:
|
||||
start = generator.model.fraction(event['onset'])
|
||||
simultaneous = [e for e in timeline if generator.model.fraction(e['onset']) <= start < generator.model.fraction(e['onset']) + generator.model.fraction(e['duration'])]
|
||||
positions = [generator.guitar.TabPosition(by_id[e['id']]['tablature']['string'], by_id[e['id']]['tablature']['fret'], int(generator.model.fraction(e['pitch']))) for e in simultaneous]
|
||||
positions = [generator.fretboard.TabPosition(by_id[e['id']]['tablature']['string'], by_id[e['id']]['tablature']['fret'], int(generator.model.fraction(e['pitch']))) for e in simultaneous]
|
||||
self.assertEqual(len(positions), len({p.string_number for p in positions}))
|
||||
self.assertTrue(generator.guitar.within_fret_span(positions))
|
||||
def test_explicit_bass_and_default_root(self):
|
||||
for symbol, expected in (('CM/E', 4), ('C7b9', 0)):
|
||||
self.assertTrue(generator.fretboard.within_fret_span(positions))
|
||||
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)}]
|
||||
@@ -88,9 +90,9 @@ class GuitarPartTests(unittest.TestCase):
|
||||
self.assertEqual([generator.model.fraction(n['onset']) for n in notes if n['pitches']], [0, 2])
|
||||
for onset in (0, 2):
|
||||
pitches = [p for n in notes if generator.model.fraction(n['onset']) == onset for p in n['pitches']]
|
||||
positions = [generator.guitar.TabPosition(p['tablature']['string'], p['tablature']['fret'], int(generator.model.pitch_number(p) - 12)) for p in pitches]
|
||||
self.assertIn(tuple(sorted((p.string_number for p in positions), reverse=True)), generator.guitar.ACOUSTIC_STRINGS)
|
||||
self.assertTrue(generator.guitar.within_fret_span(positions))
|
||||
positions = [generator.fretboard.TabPosition(p['tablature']['string'], p['tablature']['fret'], int(generator.model.pitch_number(p) - 12)) for p in pitches]
|
||||
self.assertIn(tuple(sorted((p.string_number for p in positions), reverse=True)), ((5, 4, 3, 2), (6, 4, 3, 2)))
|
||||
self.assertTrue(generator.fretboard.within_fret_span(positions))
|
||||
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))
|
||||
|
||||
@@ -18,6 +18,15 @@ def chord_xml(text, kind='other', root='C', extra=''):
|
||||
return f'<harmony><root><root-step>{root}</root-step></root><kind text="{text}">{kind}</kind>{extra}</harmony>'
|
||||
|
||||
class HarmonyTests(unittest.TestCase):
|
||||
def test_triad_classification_uses_effective_degrees(self):
|
||||
for symbol in ('CM', 'Dm', 'Cdim', 'Caug', 'Dm/F', 'Dm/B', 'Dm7no7', 'C7no7'):
|
||||
with self.subTest(symbol=symbol):
|
||||
self.assertTrue(generator.harmony.is_triad(generator.harmony.parse_symbol(symbol)))
|
||||
for symbol in ('C7', 'CM7', 'Cm7', 'Cdim7', 'C6', 'C13', 'Csus', 'Csus2', 'C7no5', 'Dmno1', 'Dmno5', 'Cadd9'):
|
||||
with self.subTest(symbol=symbol):
|
||||
self.assertFalse(generator.harmony.is_triad(generator.harmony.parse_symbol(symbol)))
|
||||
custom = {'kind': 'other', 'degrees': [{'value': d, 'alter': a, 'type': 'add'} for d, a in ((1, 0), (3, -1), (5, 0))]}
|
||||
self.assertTrue(generator.harmony.is_triad(custom))
|
||||
def test_quality_spellings_are_canonical(self):
|
||||
cases = {'C': 'CM', 'Cmaj': 'CM', 'Cmin': 'Cm', 'C-': 'Cm', 'C+': 'Caug', 'Co': 'Cdim', 'Csus4': 'Csus', 'Csus2': 'Csus2',
|
||||
'Cmaj7': 'CM7', 'C-7': 'Cm7', 'C+7': 'Caug7', 'C+maj7': 'CaugM7', 'Co7': 'Cdim7', 'C-7b5': 'Cm7b5',
|
||||
|
||||
@@ -9,6 +9,7 @@ import generator.__main__
|
||||
import generator.accompaniment
|
||||
import generator.arrange.generate
|
||||
import generator.guitar
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
@@ -40,7 +41,7 @@ class MusicModelTests(unittest.TestCase):
|
||||
self.assertEqual(set(generator.harmony.pitch_classes(generator.harmony.parse_symbol(label))), expected, label)
|
||||
for label, essentials in [('G7b9', {11, 5, 8}), ('C13', {4, 10, 9})]:
|
||||
definition = generator.harmony.parse_symbol(label)
|
||||
pitches = generator.guitar.chord_pitches(definition, generator.guitar.chord_positions(generator.guitar.chord_requirements(definition))[0])
|
||||
pitches = generator.fretboard.chord_pitches(definition, generator.guitar.chord_positions(generator.fretboard.chord_requirements(definition))[0])
|
||||
self.assertTrue(essentials.issubset({generator.model.pitch_number(p) % 12 for p in pitches}), label)
|
||||
with self.assertRaises(ValueError):
|
||||
generator.harmony.parse_symbol('Cunrecognized')
|
||||
|
||||
+27
-70
@@ -3,6 +3,7 @@ import pathlib
|
||||
import unittest
|
||||
|
||||
import generator.arrange.generate
|
||||
import generator.fretboard
|
||||
import generator.guitar
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
@@ -13,16 +14,24 @@ import tests.test_musicxml
|
||||
import tests.test_parts
|
||||
|
||||
def grip(frets, strings=(4, 3, 2, 1)):
|
||||
tuning = dict(generator.guitar.STANDARD_TUNING)
|
||||
return tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in zip(strings, frets))
|
||||
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.guitar.chord_requirements(definition, profile)
|
||||
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_electric_chords_use_the_shared_three_fret_limit(self):
|
||||
for prune in (False, True):
|
||||
candidates = ranked('CM7', prune=prune)
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all(generator.guitar.compactness(c) <= 3 for c in candidates))
|
||||
candidates = ranked('CM7')
|
||||
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):
|
||||
@@ -54,7 +63,7 @@ class VoicingTests(unittest.TestCase):
|
||||
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.guitar.chord_requirements(definition, 'electric')
|
||||
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))
|
||||
@@ -72,91 +81,34 @@ class VoicingTests(unittest.TestCase):
|
||||
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_chord_height_cost_increases_throughout_the_fretboard(self):
|
||||
for profile in ('electric', 'acoustic'):
|
||||
for fret in range(1, 21):
|
||||
with self.subTest(profile=profile, fret=fret):
|
||||
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), profile).effort, fret + 2 * max(0, fret - 12))
|
||||
self.assertEqual(generator.guitar.voicing_cost((), profile).effort, 0)
|
||||
for fret in range(1, 21):
|
||||
with self.subTest(fret=fret):
|
||||
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), 'electric').effort, fret + 2 * max(0, fret - 12))
|
||||
self.assertEqual(generator.guitar.voicing_cost((), 'electric').effort, 0)
|
||||
def test_exact_melody_height_scoring_remains_unchanged(self):
|
||||
for fret in range(1, 21):
|
||||
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4)).effort, 2 * max(0, fret - 12))
|
||||
crossing = grip((11, 12, 13, 14))
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 7)
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing, 'electric').effort, 33)
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing, 'acoustic').effort, 24)
|
||||
def test_chord_shape_and_height_share_one_effort_cost(self):
|
||||
shape = grip((1, 3, 4, 5))
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape).effort, 4)
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape, 'electric').effort, 23)
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape, 'acoustic').effort, 11)
|
||||
for profile in ('', 'electric', 'acoustic'):
|
||||
for profile in ('', 'electric'):
|
||||
self.assertLess(generator.guitar.voicing_cost(grip((4, 4, 4, 4)), profile), generator.guitar.voicing_cost(grip((2, 4, 1, 3)), profile))
|
||||
def test_distinct_frets_add_difficulty_even_with_the_same_span(self):
|
||||
simple, varied = grip((3, 3, 5, 5)), grip((3, 4, 5, 5))
|
||||
for profile, expected in (('electric', 13), ('acoustic', 7)):
|
||||
self.assertEqual(generator.guitar.voicing_cost(simple, profile).effort, expected)
|
||||
self.assertEqual(generator.guitar.voicing_cost(varied, profile).effort, expected + 2)
|
||||
self.assertEqual(generator.guitar.voicing_cost(simple, 'electric').effort, 13)
|
||||
self.assertEqual(generator.guitar.voicing_cost(varied, 'electric').effort, 15)
|
||||
self.assertEqual(generator.guitar.voicing_cost(simple).effort, generator.guitar.voicing_cost(varied).effort)
|
||||
def test_acoustic_reach_excludes_stretched_low_pair_before_scoring(self):
|
||||
high = [grip((12, 12, 12, 11), (6, 4, 3, 2)), grip((12, 11, 12, 11), (6, 4, 3, 2))]
|
||||
requested = [grip((7, 8, 7, 8), (5, 4, 3, 2)), grip((7, 8, 6, 8), (5, 4, 3, 2))]
|
||||
low = [grip((6, 5, 3, 3), (6, 4, 3, 2)), grip((5, 5, 3, 2), (6, 4, 3, 2))]
|
||||
for symbol, upper, middle, lower in zip(('Em7b5', 'A7b9'), high, requested, low):
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol(symbol), 'acoustic')
|
||||
candidates = generator.guitar.chord_positions(requirements, profile='acoustic')
|
||||
for shape in (upper, middle, lower):
|
||||
self.assertIn(shape, candidates)
|
||||
self.assertEqual({p.midi % 12 for p in upper}, {p.midi % 12 for p in middle})
|
||||
self.assertEqual(min(p.midi for p in upper), min(p.midi for p in middle))
|
||||
preferred = generator.guitar.acoustic_positions(requirements)
|
||||
self.assertIn(middle, preferred)
|
||||
self.assertIn(upper, preferred)
|
||||
self.assertNotIn(lower, preferred)
|
||||
costs = [sum(generator.guitar.voicing_cost(s, 'acoustic').effort for s in pair) + 2 * generator.guitar.acoustic_movement(*pair) for pair in (requested, high, low)]
|
||||
self.assertEqual(costs, [24, 30, 23])
|
||||
def test_real_autumn_leaves_cadences_use_comfortable_inversions_and_complete_triads(self):
|
||||
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn-leaves.xml'))
|
||||
before = copy.deepcopy(source)
|
||||
result = generator.arrange.generate.arrange_document(source, 'acoustic', rhythms=generator.rhythm.load_library(pathlib.Path('data/rhythms')))
|
||||
generator.model.validate_document(result)
|
||||
measures = result['staves'][0]['measures']
|
||||
self.assertEqual(measures[0]['section_label'], 'Rhythm Lead-in')
|
||||
for index, symbol, expected in ((0, 'Em7b5', ((6, 3), (4, 2), (3, 3), (2, 3))), (1, 'A7b9', ((6, 3), (4, 2), (3, 3), (2, 2))), (4, 'Em7b5', ((6, 3), (4, 2), (3, 3), (2, 3))), (5, 'A7b9', ((6, 3), (4, 2), (3, 3), (2, 2)))):
|
||||
measure = measures[index]
|
||||
with self.subTest(measure=measure['number'], chord=symbol):
|
||||
self.assertEqual(generator.harmony.symbol(measure['harmonies'][0]), symbol)
|
||||
positions = {(p['tablature']['string'], p['tablature']['fret']) for v in measure['voices'] for n in v['notes'] for p in n['pitches']}
|
||||
self.assertEqual(tuple(sorted(positions, reverse=True)), expected)
|
||||
checked = set()
|
||||
for measure in measures:
|
||||
if measure['number'] not in ('26', '27', '30', '31', '32', '33'):
|
||||
continue
|
||||
pitches = [p for v in measure['voices'] for n in v['notes'] for p in n['pitches']]
|
||||
if not pitches:
|
||||
continue
|
||||
positions = tuple(generator.guitar.TabPosition(p['tablature']['string'], p['tablature']['fret'], int(generator.model.pitch_number(p)) - 12) for p in pitches)
|
||||
self.assertLessEqual(generator.guitar.physical_reach(positions), generator.guitar.PREFERRED_ACOUSTIC_REACH, measure['id'])
|
||||
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in pitches))
|
||||
if measure['number'] in ('32', '33'):
|
||||
self.assertEqual({p.midi % 12 for p in positions}, {2, 5, 9})
|
||||
checked.add(measure['number'])
|
||||
self.assertEqual(checked, {'26', '27', '30', '31', '32', '33'})
|
||||
self.assertEqual(result, generator.arrange.generate.arrange_document(source, 'acoustic', rhythms=generator.rhythm.load_library(pathlib.Path('data/rhythms'))))
|
||||
self.assertEqual(source, before)
|
||||
def test_acoustic_simpler_three_note_shape_can_beat_four_notes(self):
|
||||
candidates = ranked('Csus', profile='acoustic')
|
||||
best = candidates[0]
|
||||
self.assertEqual(len(best), 3)
|
||||
self.assertTrue(any(len(c) == 4 for c in candidates))
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('Csus'), 'acoustic')
|
||||
self.assertTrue(requirements.covers({p.midi % 12 for p in best}))
|
||||
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', 'acoustic'):
|
||||
for profile in ('', 'electric'):
|
||||
self.assertLess(generator.guitar.voicing_cost(nearby, profile).moved(generator.guitar.movement(previous, nearby)), generator.guitar.voicing_cost(low, profile).moved(generator.guitar.movement(previous, low)))
|
||||
def test_above_threshold_is_a_soft_cost_not_a_range_priority(self):
|
||||
low, high = (grip((f,) * 4) for f in (3, 13))
|
||||
for profile in ('', 'electric', 'acoustic'):
|
||||
for profile in ('', 'electric'):
|
||||
self.assertLess(generator.guitar.voicing_cost(high, profile), generator.guitar.voicing_cost(low, profile).moved(generator.guitar.movement(high, low)))
|
||||
def test_candidate_pruning_uses_full_cost_and_preserves_each_center(self):
|
||||
for symbol, profile, melody in (('FM7', 'electric', (69,)), ('Gm7', 'electric', (67,))):
|
||||
@@ -205,6 +157,11 @@ class VoicingTests(unittest.TestCase):
|
||||
generator.model.validate_document(result)
|
||||
section, checked = '', set()
|
||||
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), 3)
|
||||
section = measure.get('section_label') or section
|
||||
if section != 'Guitar Melody':
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user