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
+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'})