Fix Generation

This commit is contained in:
khodowany
2026-09-26 22:59:14 -05:00
parent 83154b1a01
commit 3beeb87549
9 changed files with 239 additions and 9 deletions
+9 -1
View File
@@ -179,7 +179,15 @@ class AccompanimentPlanningTests(unittest.TestCase):
measures = tests.test_musicxml.parse(xml_bars([content] * 4))['staves'][0]['measures']
plan = generator.accompaniment.plan_section(measures, library)
self.assertEqual(plan.acoustic_upper, (sounding,) * 4)
self.assertEqual(plan.electric, (silent,) * 4)
self.assertEqual(plan.electric, (sounding,) * 4)
def test_electric_density_targets_two_stabs_per_four_quarter_beats(self):
library = tests.rhythm_fixtures.library()
for beats in (2, 3, 4, 5):
with self.subTest(beats=beats):
plan = generator.accompaniment.plan_section(melody_measures(4, beats=beats), library)
for bar in plan.electric[:3]:
self.assertIn(len(bar.attacks()), {beats // 2, (beats + 1) // 2})
self.assertLessEqual(len(plan.electric[3].attacks()), (beats + 1) // 2 + 1)
def test_section_repeats_four_bar_motif_with_at_most_one_beat_variation(self):
for beats in (2, 3, 4, 5):
measures = melody_measures(12, beats=beats, dense=True)
+25 -2
View File
@@ -53,11 +53,15 @@ class AcousticVoicingTests(unittest.TestCase):
self.assertLessEqual(len(uppers), 1)
grip = tuple(sorted({p for n in span.notes for p in shape(n)}, reverse=True))
required, allowed, bass = generator.guitar.chord_requirements(span.harmony, 'acoustic')
bass_classes = {bass}
degrees = generator.harmony.degrees(span.harmony)
if 'bass' not in span.harmony and 'inversion' not in span.harmony and 5 in degrees:
bass_classes.add((bass + generator.harmony.degree_semitones(5, degrees[5])) % 12)
if basses and uppers:
self.assertIn(tuple(p.string_number for p in grip), generator.guitar.ACOUSTIC_STRINGS)
self.assertTrue(set(required) <= {p.midi % 12 for p in grip} <= set(allowed))
if basses:
self.assertEqual(min(p.midi for p in grip) % 12, bass)
self.assertIn(min(p.midi for p in grip) % 12, bass_classes)
self.assertTrue(generator.guitar.within_fret_span(grip))
self.assertTrue(all(0 <= p.fret <= 20 for p in grip))
for note in span.notes:
@@ -74,6 +78,25 @@ class AcousticVoicingTests(unittest.TestCase):
generator.acoustic.combine(document)
self.assertEqual(tests.test_acoustic.sounding(document), before)
generator.model.validate_document(document)
def test_simpler_fifth_bass_can_win_without_alternating(self):
document = planned_document([[(0, 'Cdim7')], []])
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}, {6})
root_shapes = generator.guitar.chord_positions(*generator.guitar.chord_requirements(spans[0].harmony, 'acoustic'), profile='acoustic')
selected = tuple(sorted({p for n in spans[0].notes for p in shape(n)}, reverse=True))
self.assertLess(generator.guitar.voicing_cost(selected, 'acoustic'), min(generator.guitar.voicing_cost(s, 'acoustic') for s in root_shapes))
def test_bass_options_follow_defined_fifth_and_explicit_basses(self):
inversion = generator.harmony.parse_symbol('Cdim7') | {'inversion': 0}
for chord, expected in (('C7', {0, 7}), ('Cdim7', {0, 6}), ('Caug', {0, 8}), ('Cno5', {0}), ('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)
required, allowed, _ = generator.guitar.chord_requirements(span.harmony, 'acoustic')
self.assertTrue(all(set(required) <= {p.midi % 12 for p in c.shape} <= set(allowed) for c in candidates))
def test_same_grip_survives_repeated_symbols_rest_bars_and_sections(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)
@@ -95,7 +118,7 @@ class AcousticVoicingTests(unittest.TestCase):
document = planned_document([changes], [([(i, 1) for i in range(4)], [(i, 1) for i in range(4)])])
spans = self.place(document)
self.assertEqual(len(spans), 4)
self.assertEqual([min(p.midi for n in span.notes for p in shape(n)) % 12 for span in spans], [0, 4, 7, 0])
self.assertEqual([min(p.midi for n in span.notes for p in shape(n)) % 12 for span in spans], [0, 4, 7, 6])
def test_non_accompaniment_passage_ends_the_span(self):
document = planned_document([[(0, 'C7')], [], []])
measure = document['staves'][0]['measures'][1]
+125
View File
@@ -0,0 +1,125 @@
import copy
import fractions
import pathlib
import unittest
import unittest.mock
import generator.arrange.form
import generator.arrange.generate
import generator.electric
import generator.guitar
import generator.harmony
import generator.model
import generator.musicxml
import tests.rhythm_fixtures
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)
result = generator.model.make_event(identifier, fractions.Fraction(onset), fractions.Fraction(duration), 'note', pitches)
result.update(_harmony=chord, _guitar_profile='electric')
if melody:
result['_harmonize'] = True
return result
def measure(notes, harmonies=()):
return {'number': '1', 'duration': generator.model.rational(4), 'harmonies': list(harmonies), 'voices': [{'id': 'voice', 'notes': notes}]}
def shape(event):
return tuple(sorted((p['tablature']['string'], p['tablature']['fret']) for p in event['pitches']))
class ElectricVoicingTests(unittest.TestCase):
def test_corresponding_melody_shapes_change_within_one_harmony(self):
chord = harmony('C7')
first = note('melody-1', 1, chord, (2, 3, 1, 3), melody=True, duration=2)
second = note('melody-2', 2, chord, (10, 9, 11, 8), melody=True, duration=2)
earlier = [note(f'accompaniment-{i}', i, chord, (5, 3, 5, 3)) for i in (0, 2)]
later = [note(f'accompaniment-next-{i}', i, chord, (5, 3, 5, 3)) for i in (0, 3)]
source = [measure([], [chord]), measure([], [copy.deepcopy(chord)])]
origins = [generator.electric.MeasureOrigin(fractions.Fraction(0), 'melody', measure([first])), generator.electric.MeasureOrigin(fractions.Fraction(4), 'melody', measure([second])), generator.electric.MeasureOrigin(fractions.Fraction(0), 'accompaniment', measure(earlier)), generator.electric.MeasureOrigin(fractions.Fraction(4), 'accompaniment', measure(later))]
links = generator.electric.link_voicings(source, origins)
self.assertEqual(len(links), 4)
before = copy.deepcopy((first, second))
generator.electric.reuse_voicings(links)
self.assertEqual((first, second), before)
self.assertEqual([shape(n) for n in earlier + later], [shape(first)] * 3 + [shape(second)])
self.assertTrue(all(p['duration'] == n['duration'] and p['written'] == n['written'] for n in earlier + later for p in n['pitches']))
def test_links_follow_actual_placement_without_copying_melody_ties(self):
chord = harmony('FM7')
reference = note('melody', 0, chord, (3, 5, 5, 5), melody=True, duration=2)
target = note('stab', 1, chord, (3, 2, 1, 0))
origins = [generator.electric.MeasureOrigin(fractions.Fraction(), role, measure([n])) for role, n in (('melody', reference), ('accompaniment', target))]
links = generator.electric.link_voicings([measure([], [chord])], origins)
for pitch in reference['pitches']:
pitch['tie_to'] = 'next-' + pitch['id']
before = copy.deepcopy(reference)
generator.electric.reuse_voicings(links)
self.assertEqual(reference, before)
self.assertEqual(shape(reference), shape(target))
self.assertTrue(all('tie_to' not in p and 'tie_from' not in p for p in target['pitches']))
self.assertEqual({p['id'] for p in target['pitches']}, {f'stab-s{i}' for i in range(1, 5)})
target['pitches'][0]['tablature']['fret'] = 20
self.assertEqual(reference, before)
def test_unharmonized_passage_does_not_borrow_from_another_occurrence(self):
chord = harmony('C7')
reference = note('melody', 0, chord, (2, 3, 1, 3), melody=True)
target = note('stab', 0, chord, (5, 3, 5, 3))
origins = [generator.electric.MeasureOrigin(fractions.Fraction(), 'melody', measure([reference])), generator.electric.MeasureOrigin(fractions.Fraction(8), 'accompaniment', measure([target]))]
for middle in (harmony('G7'), {'kind': 'none', 'degrees': [], 'onset': generator.model.rational(0)}):
source = [measure([], [chord]), measure([], [middle]), measure([], [chord])]
self.assertEqual(generator.electric.link_voicings(source, origins), [])
def test_mid_measure_changes_and_omitted_turnaround_do_not_copy_wrong_chord(self):
first, second = harmony('C7'), harmony('FM7', 2)
source = [measure([], [first, second])]
reference = note('melody', 2, second, (3, 5, 5, 5), melody=True)
target = note('stab', 3, first, (2, 3, 1, 3))
origins = [generator.electric.MeasureOrigin(fractions.Fraction(), role, measure([n])) for role, n in (('melody', reference), ('accompaniment', target))]
self.assertEqual(generator.electric.link_voicings(source, origins), [])
target['_harmony'] = second
links = generator.electric.link_voicings(source, origins)
self.assertEqual(len(links), 1)
generator.electric.reuse_voicings(links)
self.assertEqual(shape(reference), shape(target))
def test_failed_melody_harmonization_keeps_independent_accompaniment(self):
chord = harmony('FM7')
reference = note('melody', 0, chord, (3, 5, 5, 5), melody=True)
target = note('stab', 1, chord, (3, 2, 1, 0))
origins = [generator.electric.MeasureOrigin(fractions.Fraction(), role, measure([n])) for role, n in (('melody', reference), ('accompaniment', target))]
links = generator.electric.link_voicings([measure([], [chord])], origins)
reference['pitches'] = reference['pitches'][:1]
before = copy.deepcopy(target)
generator.electric.reuse_voicings(links)
self.assertEqual(target, before)
def test_real_arrangement_reuses_requested_grips_without_changing_melody(self):
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn-leaves.xml'))
before = copy.deepcopy(source)
rhythms = tests.rhythm_fixtures.library()
with unittest.mock.patch('generator.electric.reuse_voicings'):
independent = generator.arrange.generate.arrange_document(source, 'electric', rhythms=rhythms)
result = generator.arrange.generate.arrange_document(source, 'electric', rhythms=rhythms)
generator.model.validate_document(result)
self.assertEqual(source, before)
start, checked = 0, set()
for section in generator.arrange.form.full_arrangement_sections(source['staves'][0]['measures'], 'electric'):
measures = result['staves'][0]['measures'][start:start + section.measure_count]
original = independent['staves'][0]['measures'][start:start + section.measure_count]
start += section.measure_count
if section.role == 'melody':
self.assertEqual(measures, original)
if section.role != 'accompaniment':
continue
for m, old in zip(measures, original):
for voice, old_voice in zip(m['voices'], old['voices']):
self.assertEqual([{k: v for k, v in n.items() if k != 'pitches'} for n in voice['notes']], [{k: v for k, v in n.items() if k != 'pitches'} for n in old_voice['notes']])
for n in voice['notes']:
if not n['pitches'] or m['number'] not in ('4', '12', '22', '24'):
continue
expected = (3, 3, 3, 3) if m['number'] == '22' else (3, 5, 5, 5)
self.assertEqual(tuple(f for _, f in sorted(shape(n), reverse=True)), expected)
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in n['pitches']))
checked.add(m['number'])
self.assertEqual(checked, {'4', '12', '22', '24'})