Generation
ci / checks (push) Successful in 1h10m52s

This commit is contained in:
khodowany
2026-09-26 17:26:22 -05:00
parent 11222a9f8c
commit c8581ea51a
11 changed files with 343 additions and 186 deletions
+34 -73
View File
@@ -8,6 +8,7 @@ import xml.etree.ElementTree
import generator.accompaniment
import generator.model
import generator.parts
import generator.playback
import generator.practice.generate
import generator.rhythm
@@ -301,85 +302,45 @@ class AccompanimentRealizationTests(unittest.TestCase):
self.assertEqual(plan.acoustic_bass, (bar,))
self.assertEqual(plan.electric, (bar,))
class TiedAccompanimentTests(unittest.TestCase):
def source(self, root='C', kind='major', offset=0):
class DetachedAccompanimentTests(unittest.TestCase):
def source(self):
first = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(extra='<tie type="start"/>')
second = tests.test_parts.xml_harmony(root, kind, offset) + tests.test_musicxml.xml_note(extra='<tie type="stop"/>')
second = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(extra='<tie type="stop"/>')
return tests.test_musicxml.parse(xml_bars([first, second]))
def assert_tied_chord(self, first, continuation):
self.assertTrue(first['pitches'])
self.assertEqual(len(first['pitches']), len(continuation['pitches']))
for previous, current in zip(first['pitches'], continuation['pitches']):
self.assertEqual(current['tie_from'], previous['id'])
self.assertEqual(previous['tie_to'], current['id'])
self.assertEqual(generator.model.pitch_number(current), generator.model.pitch_number(previous))
self.assertEqual(current['tablature'], previous['tablature'])
for note in (first, continuation):
self.assertNotIn('_rhythm_continuation', note)
for pitch in note['pitches']:
self.assertEqual(pitch['duration'], note['duration'])
self.assertEqual(pitch['written'], note['written'])
def test_whole_note_tie_keeps_second_bar_audible_without_a_new_attack(self):
self.check_tied_rhythm(4)
def test_half_note_continuation_preserves_tone_duration_strings_and_following_rest(self):
self.check_tied_rhythm(2)
def check_tied_rhythm(self, continuation_duration):
def test_rhythm_continuations_are_silent_in_all_accompaniment_lanes(self):
source = self.source()
first, second = source['staves'][0]['measures']
bars = tied_library(2).for_meter('4/4')[0].bars
for lane in ('bass', 'upper', 'electric'):
events = generator.accompaniment.realize(second, bars[1], first['harmonies'][0], lane=lane)
self.assertTrue(all(n['kind'] == 'rest' and '_rhythm_continuation' not in n for n in events))
def test_continuation_only_bar_does_not_count_as_audible_accompaniment(self):
measures = self.source()['staves'][0]['measures']
with self.assertRaisesRegex(ValueError, 'no rhythm motif'):
generator.accompaniment.plan_section(measures, tied_library(4))
def test_planner_chooses_attacks_instead_of_continuation_only_bars(self):
source = self.source()
library = generator.rhythm.Library(tied_library(4).for_meter('4/4') + tests.rhythm_fixtures.library().for_meter('4/4'))
plan = generator.accompaniment.plan_section(source['staves'][0]['measures'], library)
for bar in (*plan.acoustic_bass, *plan.acoustic_upper):
self.assertTrue(bar.attacks())
def test_acoustic_stops_at_barline_without_inventing_continuation_attacks(self):
source = self.source()
before = copy.deepcopy(source)
library = tied_library(continuation_duration)
bars = library.for_meter('4/4')[0].bars
self.assertEqual(bars[1].attacks(), ())
plan = generator.accompaniment.plan_section(source['staves'][0]['measures'], library)
self.assertEqual(plan.acoustic_upper, bars)
self.assertEqual(plan.acoustic_bass, bars)
self.assertEqual(plan.electric, bars)
result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=library)
first, second = tests.test_musicxml.notes(result), tests.test_musicxml.notes(result, 1)
self.assertEqual(timing(first), [(0, 4, 'note')])
expected = [(0, continuation_duration, 'note')]
if continuation_duration == 2:
expected.append((2, 2, 'rest'))
self.assertEqual(second[1]['pitches'], [])
self.assertEqual(timing(second), expected)
self.assert_tied_chord(first[0], second[0])
first_tones = [p for v in result['staves'][0]['measures'][0]['voices'] for n in v['notes'] for p in n['pitches']]
self.assertEqual({generator.model.pitch_number(p) % 12 for p in first_tones}, {0, 4, 7})
self.assertEqual(len(result['staves'][0]['measures'][0]['voices']), 1)
bars = tied_library(2).for_meter('4/4')[0].bars
plan = generator.accompaniment.Plan(bars, bars, ())
prepared = generator.parts.prepare_document(source, practice=True)
result = generator.practice.generate._practice_document(prepared, 'acoustic', plan)
generator.model.validate_document(result)
self.assertEqual(timing(tests.test_musicxml.notes(result)), [(0, 4, 'note')])
self.assertEqual(timing(tests.test_musicxml.notes(result, 1)), [(0, 4, 'rest')])
timeline = generator.playback.compile_timeline(result)
self.assertEqual(generator.model.fraction(timeline['duration']), 8)
self.assertEqual(len(timeline['events']), len(first_tones))
self.assertTrue(all(generator.model.fraction(e['onset']) == 0 and generator.model.fraction(e['duration']) == 4 + continuation_duration for e in timeline['events']))
self.assertEqual(source, before)
self.assertEqual(library.for_meter('4/4')[0].bars, bars)
def test_changed_harmony_breaks_rhythm_ties_without_inventing_attacks(self):
for root, kind in (('F', 'major'), ('C', 'minor'), ('C', 'none')):
with self.subTest(root=root, kind=kind):
source = self.source(root, kind)
before = copy.deepcopy(source)
result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=tied_library(2))
first, second = tests.test_musicxml.notes(result), tests.test_musicxml.notes(result, 1)
self.assertEqual(timing(first), [(0, 4, 'note')])
self.assertEqual(timing(second), [(0, 4, 'rest')])
self.assertEqual({generator.model.pitch_number(p) % 12 for v in result['staves'][0]['measures'][0]['voices'] for n in v['notes'] for p in n['pitches']}, {0, 4, 7})
self.assertEqual(second[0]['pitches'], [])
for note in first + second:
self.assertTrue(timeline['events'])
self.assertTrue(all(generator.model.fraction(e['onset']) == 0 and generator.model.fraction(e['duration']) == 4 for e in timeline['events']))
for measure in result['staves'][0]['measures']:
for voice in measure['voices']:
for note in voice['notes']:
for pitch in note['pitches']:
self.assertNotIn('tie_from', pitch)
self.assertNotIn('tie_to', pitch)
self.assertEqual(pitch['duration'], note['duration'])
generator.model.validate_document(result)
events = generator.playback.compile_timeline(result)['events']
self.assertEqual({(generator.model.fraction(e['onset']), generator.model.fraction(e['duration'])) for e in events}, {(0, 4)})
self.assertEqual(source, before)
def test_harmony_change_inside_continuation_cuts_tied_sound_and_preserves_rest(self):
source = self.source('F', offset=1)
result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=tied_library(2))
first, second = tests.test_musicxml.notes(result), tests.test_musicxml.notes(result, 1)
self.assertEqual(timing(first), [(0, 4, 'note')])
self.assertEqual(timing(second), [(0, 1, 'note'), (1, 3, 'rest')])
self.assert_tied_chord(first[0], second[0])
generator.model.validate_document(result)
events = generator.playback.compile_timeline(result)['events']
self.assertEqual(len(events), sum(len(n['pitches']) for v in result['staves'][0]['measures'][0]['voices'] for n in v['notes']))
self.assertTrue(all(generator.model.fraction(e['onset']) == 0 and generator.model.fraction(e['duration']) == 5 for e in events))
self.assertEqual(source, before)
+25 -28
View File
@@ -46,6 +46,11 @@ def acoustic_document(bars):
class AcousticTests(unittest.TestCase):
def combine(self, document):
generator.acoustic.prepare(document)
generator.model.validate_document(document)
prepared = copy.deepcopy(document)
generator.acoustic.prepare(document)
self.assertEqual(document, prepared)
before = sounding(document)
generator.acoustic.combine(document)
generator.model.validate_document(document)
@@ -57,6 +62,8 @@ class AcousticTests(unittest.TestCase):
self.assertNotIn('beams', note)
for pitch in note['pitches']:
self.assertNotIn('stem', pitch)
self.assertNotIn('tie_from', pitch)
self.assertNotIn('tie_to', pitch)
once = copy.deepcopy(document)
generator.acoustic.combine(document)
self.assertEqual(document, once)
@@ -65,37 +72,25 @@ class AcousticTests(unittest.TestCase):
notes = self.combine(acoustic_document([([(0, 2), (3, 1)], [(0, 2), (3, 1)])]))[0]
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 2, 'note'), (2, 1, 'rest'), (3, 1, 'note')])
self.assertEqual([len(n['pitches']) for n in notes], [3, 0, 3])
def test_sustained_bass_ties_through_upper_attacks_and_releases(self):
def test_bass_releases_at_next_upper_attack_without_reattacking(self):
notes = self.combine(acoustic_document([([(0, 4)], [(1, 1), (3, 1)])]))[0]
self.assertTrue(all(n['kind'] == 'note' for n in notes))
self.assertEqual([len(n['pitches']) for n in notes], [1, 3, 1, 3])
for first, second in zip(notes, notes[1:]):
self.assertEqual(first['pitches'][0]['tie_to'], second['pitches'][0]['id'])
self.assertEqual(first['pitches'][0]['tablature'], second['pitches'][0]['tablature'])
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 1, 'note'), (1, 1, 'note'), (2, 1, 'rest'), (3, 1, 'note')])
self.assertEqual([len(n['pitches']) for n in notes], [1, 2, 0, 2])
def test_repeated_pitch_is_an_attack_not_a_tie(self):
notes = self.combine(acoustic_document([([(0, 2), (2, 2)], [(0, 4)])]))[0]
self.assertEqual(len(notes), 2)
self.assertNotIn('tie_to', notes[0]['pitches'][0])
self.assertNotIn('tie_from', notes[1]['pitches'][0])
for first, second in zip(notes[0]['pitches'][1:], notes[1]['pitches'][1:]):
self.assertEqual(first['tie_to'], second['id'])
def test_existing_tie_boundary_does_not_add_a_redundant_event(self):
document = acoustic_document([([(0, 1), (1, 1)], [(2, 2)])])
first, second = document['staves'][0]['measures'][0]['voices'][0]['notes'][:2]
first['pitches'][0]['tie_to'] = second['pitches'][0]['id']
second['pitches'][0]['tie_from'] = first['pitches'][0]['id']
notes = self.combine(document)[0]
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 2, 'note'), (2, 2, 'note')])
def test_cross_measure_ties_preserve_both_rhythms(self):
self.assertEqual([len(n['pitches']) for n in notes], [3, 1])
def test_simultaneous_tones_release_at_shortest_planned_duration(self):
notes = self.combine(acoustic_document([([(0, 4)], [(0, 1)])]))[0]
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 1, 'note'), (1, 3, 'rest')])
self.assertEqual(len(notes[0]['pitches']), 3)
def test_barline_releases_notes_before_next_measures_attacks(self):
document = acoustic_document([([(0, 4)], [(2, 2)]), ([(0, 2)], [(0, 1), (2, 1)])])
first, second = document['staves'][0]['measures']
for lane in range(2):
old = next(n for n in first['voices'][lane]['notes'] if n['pitches'])
new = second['voices'][lane]['notes'][0]
for a, b in zip(old['pitches'], new['pitches']):
a['tie_to'], b['tie_from'] = b['id'], a['id']
notes = self.combine(document)
self.assertTrue(all('tie_from' in p for p in notes[1][0]['pitches']))
self.assertEqual(tests.test_accompaniment.timing(notes[0]), [(0, 2, 'note'), (2, 2, 'note')])
self.assertEqual(tests.test_accompaniment.timing(notes[1]), [(0, 1, 'note'), (1, 1, 'rest'), (2, 1, 'note'), (3, 1, 'rest')])
self.assertEqual(notes[1][-1]['kind'], 'rest')
def test_silence_collapses_to_a_single_measure_rest(self):
notes = self.combine(acoustic_document([([], [])]))[0]
@@ -109,21 +104,23 @@ class AcousticTests(unittest.TestCase):
voice['notes'] = voice['notes'][:1]
notes = self.combine(document)[0]
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 1, 'note')])
def test_five_eighths_uses_conventional_tied_notes_not_a_tuplet(self):
def test_five_eighths_shortens_to_one_note_without_ties_or_reattacks(self):
notes = self.combine(acoustic_document([([(0, fractions.Fraction(5, 2))], [])]))[0]
self.assertEqual([n['written'] for n in notes], [{'type': 'half', 'dots': 0}, {'type': 'eighth', 'dots': 0}, {'type': 'quarter', 'dots': 1}])
self.assertEqual(notes[0]['pitches'][0]['tie_to'], notes[1]['pitches'][0]['id'])
self.assertEqual([n['written'] for n in notes], [{'type': 'half', 'dots': 0}, {'type': 'half', 'dots': 0}])
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 2, 'note'), (2, 2, 'rest')])
def test_unrelated_voices_and_solo_slashes_are_unchanged(self):
document = acoustic_document([([(0, 4)], [(0, 4)])])
for part in ('electric', 'vocal', None):
document['staves'][0]['arrangement_part_type'] = part
before = copy.deepcopy(document)
generator.acoustic.prepare(document)
generator.acoustic.combine(document)
self.assertEqual(document, before)
document['staves'][0]['arrangement_part_type'] = 'acoustic'
measure = document['staves'][0]['measures'][0]
measure['voices'] = [{'id': 'voice-arranged', 'notes': [generator.model.make_event('slash', fractions.Fraction(), fractions.Fraction(4), 'note') | {'voice': 'voice-arranged', 'notehead': 'slash'}]}]
before = copy.deepcopy(document)
generator.acoustic.prepare(document)
generator.acoustic.combine(document)
self.assertEqual(document, before)
def test_song_and_practice_integration_preserve_playback(self):
@@ -137,7 +134,7 @@ class AcousticTests(unittest.TestCase):
generator.model.validate_document(combined)
self.assertTrue(all(len(m['voices']) == 1 for m in combined['staves'][0]['measures']))
def test_engraved_chord_has_one_stem_and_no_competing_rests(self):
document = acoustic_document([([(0, 4)], [(1, 1), (3, 1)])])
document = acoustic_document([([(0, 4)], [(1, 2), (3, 1)])])
self.combine(document)
fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
layout = generator.engraving.layout.layout_score(document, generator.engraving.types.Style(), fonts)
@@ -145,4 +142,4 @@ class AcousticTests(unittest.TestCase):
self.assertFalse([p for p in primitives if p.role in ('rest', 'tab-rest')])
for note in document['staves'][0]['measures'][0]['voices'][0]['notes']:
self.assertEqual(len([p for p in primitives if p.role == 'stem' and note['id'] in p.sources]), 1)
self.assertTrue([p for p in primitives if p.role == 'tie'])
self.assertFalse([p for p in primitives if p.role in ('tie', 'tab-tie')])
+2 -2
View File
@@ -132,14 +132,14 @@ class GuitarTests(unittest.TestCase):
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))
states[index] = generator.guitar.Placement(tuple(randomizer.randrange(8) for _ in range(3)), tuple((str(i), p) for i, p in enumerate(positions)), None, ())
states[index] = generator.guitar.Placement(generator.guitar.VoicingCost(*(randomizer.randrange(8) for _ in range(6))), tuple((str(i), p) for i, p in enumerate(positions)), None, ())
grid = generator.guitar.nearest_positions(states)
for x, row in enumerate(grid):
for y, actual in enumerate(row):
candidates = []
for index, state in enumerate(states.values()):
a, b = generator.guitar.position_center(tuple(p for _, p in state.active))
candidates.append(((state.cost[0] + abs(x - a) + abs(y - b), *state.cost[1:]), index))
candidates.append((state.cost.moved(abs(x - a) + abs(y - b)), index))
self.assertEqual(actual[:2], min(candidates))
def test_generated_chord_retains_strings_when_a_melody_enters(self):
content = tests.test_musicxml.xml_note() + '<backup><duration>4</duration></backup><forward><duration>2</duration></forward>' + tests.test_musicxml.xml_note('C', 2, 'half', octave=7, extra='<voice>2</voice>')
+155
View File
@@ -0,0 +1,155 @@
import copy
import pathlib
import unittest
import generator.arrange.generate
import generator.guitar
import generator.harmony
import generator.model
import generator.musicxml
import tests.rhythm_fixtures
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))
def ranked(symbol, melody=(), profile='electric', prune=False):
definition = generator.harmony.parse_symbol(symbol)
required, allowed, bass = generator.guitar.chord_requirements(definition, profile)
root = int((generator.model.NATURAL[definition['root']['letter']] + generator.model.alteration(definition['root']['alter'])) % 12)
candidates = generator.guitar.chord_positions(required, allowed, bass, prune=prune, profile=profile, melody=melody, root=root)
return sorted(candidates, key=lambda shape: (generator.guitar.voicing_cost(shape, profile, allowed, root, melody), shape))
class VoicingTests(unittest.TestCase):
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_four_strings_precede_melody_height_and_root_presence(self):
candidates = ranked('CM7', (64,))
four = next(c for c in candidates if len(c) == 4 and max(p.midi for p in c) > 64)
three = next(c for c in candidates if len(c) == 3 and max(p.midi for p in c) == 64)
self.assertLess(candidates.index(four), candidates.index(three))
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('C7b9', (64,))
best = candidates[0]
self.assertEqual(len(best), 4)
self.assertEqual(max(p.midi for p in best), 64)
self.assertNotIn(0, {p.midi % 12 for p in best})
self.assertTrue(any(0 in {p.midi % 12 for p in c} and max(p.midi for p in c) > 64 for c in candidates))
def test_root_precedes_physical_cost_without_covering_the_melody(self):
self.assertEqual(ranked('CM7', (67,))[0], grip((2, 4, 1, 3)))
rooted = generator.guitar.voicing_cost(grip((2, 4, 1, 3)), 'electric', (0, 4, 7, 11), 0, (67,))
rootless = generator.guitar.voicing_cost(grip((2, 0, 0, 3)), 'electric', (0, 4, 7, 11), 0, (67,))
self.assertGreater(rooted.effort, rootless.effort)
self.assertLess(rooted, rootless)
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)
required, allowed, _ = generator.guitar.chord_requirements(definition, 'electric')
for candidate in ranked(symbol):
classes = {p.midi % 12 for p in candidate}
self.assertTrue(set(required) <= classes <= set(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(c.cost.missing_root == 0 and all(p.midi % 12 != 0 for p in c.shape) for c in candidates))
def test_lower_frets_and_small_spans_have_explicit_costs(self):
for profile in ('', 'electric', 'acoustic'):
low = generator.guitar.voicing_cost(grip((3, 3, 3, 3)), profile)
high = generator.guitar.voicing_cost(grip((15, 15, 15, 15)), profile)
self.assertLess(low, high)
self.assertEqual(high.effort - low.effort, 24)
shape = grip((0, 2, 3, 4))
self.assertEqual(generator.guitar.voicing_cost(shape).effort, 12)
self.assertEqual(generator.guitar.voicing_cost(shape, 'acoustic').effort, 26)
def test_acoustic_simpler_three_note_shape_can_beat_four_notes(self):
candidates = ranked('C7', profile='acoustic')
best = candidates[0]
self.assertEqual(len(best), 3)
self.assertTrue(any(len(c) == 4 for c in candidates))
required, _, _ = generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7'), 'acoustic')
self.assertTrue(set(required) <= {p.midi % 12 for p in best})
def test_nearby_position_can_balance_lower_fret_preference(self):
low, nearby, previous = (grip((f,), (1,)) for f in (3, 4, 5))
self.assertEqual(generator.guitar.voicing_cost(low).moved(generator.guitar.movement(previous, low)), generator.guitar.voicing_cost(nearby).moved(generator.guitar.movement(previous, nearby)))
def test_candidate_pruning_uses_full_cost_and_preserves_each_center(self):
for symbol, profile, melody in (('FM7', 'electric', (69,)), ('C7', 'acoustic', ()), ('Gm7', 'electric', (67,))):
best = {}
for shape in ranked(symbol, melody, profile):
best.setdefault(generator.guitar.position_center(shape), shape)
self.assertEqual(set(ranked(symbol, melody, profile, prune=True)), set(best.values()))
def test_tie_continuations_do_not_charge_a_second_voicing_cost(self):
note = tests.test_musicxml.xml_note('E', 2, 'half', octave=5, extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', octave=5, extra='<tie type="stop"/>')
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_are_compared_by_the_same_grid_cost(self):
empty = generator.guitar.Placement(generator.guitar.VoicingCost(effort=20), (), None, ())
placed = generator.guitar.Placement(generator.guitar.VoicingCost(), (('tone', grip((3,), (1,))[0]),), None, ())
grid = generator.guitar.nearest_positions({(): empty, 1: placed})
self.assertIs(grid[6][0][2], placed)
self.assertIs(grid[40][10][2], empty)
def test_optimized_passage_search_matches_exhaustive_candidate_ranking(self):
contents = ''.join(tests.test_musicxml.xml_note('E', 1, 'quarter', octave=5) for _ in range(4))
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(contents))
measure = document['staves'][0]['measures'][0]
for note, symbol in zip(measure['voices'][0]['notes'], ('FM7', 'Gm7', 'C7b9', 'FM7')):
note.update(pitches=[], _harmony=generator.harmony.parse_symbol(symbol), _guitar_profile='electric')
events, tones = generator.guitar.guitar_events(document)
states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())}
for onset, _, _, note in events:
expected = {}
for candidate in generator.guitar.attack_candidates([note], tones, {}, {}, False):
choices = []
for index, previous in enumerate(states.values()):
distance = generator.guitar.movement(tuple(p for _, p in previous.active), candidate.shape)
choices.append((previous.cost.moved(distance).plus(candidate.cost), index))
expected[candidate.active] = min(choices)
actual = generator.guitar.advance_positions([note], onset, tones, states, False)
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_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', rhythms=tests.rhythm_fixtures.library())
generator.model.validate_document(result)
section, checked = '', set()
for measure in result['staves'][0]['measures']:
section = measure.get('section_label') or section
if section != 'Guitar Melody':
continue
original = next(m for m in source['staves'][0]['measures'] if m['number'] == measure['number'])
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 measure['number'] not in ('4', '12', '22', '24') or len(note['pitches']) == 1:
continue
expected = (3, 3, 3, 3) if measure['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(measure['number'])
self.assertEqual(checked, {'4', '12', '22', '24'})
self.assertEqual(source, before)