Generation

This commit is contained in:
khodowany
2026-09-26 18:00:51 -05:00
parent c8581ea51a
commit 83154b1a01
10 changed files with 263 additions and 90 deletions
+149
View File
@@ -0,0 +1,149 @@
import copy
import fractions
import unittest
import generator.accompaniment
import generator.acoustic
import generator.guitar
import generator.harmony
import generator.model
import tests.test_acoustic
import tests.test_accompaniment
def planned_document(harmonies, attacks=None):
if attacks is None:
attacks = [([(0, 1), (2, 1)], [(1, 1), (3, 1)])] * len(harmonies)
document = tests.test_acoustic.acoustic_document(attacks)
previous = None
for measure, changes in zip(document['staves'][0]['measures'], harmonies):
measure['harmonies'] = []
for onset, symbol in changes:
harmony = {'kind': 'none', 'degrees': []} if symbol == 'N.C.' else generator.harmony.parse_symbol(symbol) if isinstance(symbol, str) else copy.deepcopy(symbol)
measure['harmonies'].append(harmony | {'id': f'{measure["id"]}-h{len(measure["harmonies"])}', 'onset': generator.model.rational(onset)})
for voice in measure['voices']:
for note in voice['notes']:
if not note['pitches']:
continue
note['pitches'] = []
harmony = generator.accompaniment.active_chord(measure, generator.model.fraction(note['onset']), previous)
if harmony:
note.update(_harmony=harmony, _guitar_profile='acoustic', _guitar_lane=voice['id'])
else:
note['kind'] = 'rest'
if measure['harmonies']:
previous = measure['harmonies'][-1]
generator.acoustic.prepare(document)
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']))
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)
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'}
uppers = {shape(n) for n in span.notes if lanes[n['id']] == 'upper'}
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))
required, allowed, bass = generator.guitar.chord_requirements(span.harmony, 'acoustic')
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.assertTrue(generator.guitar.within_fret_span(grip))
self.assertTrue(all(0 <= p.fret <= 20 for p in grip))
for note in span.notes:
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):
document = planned_document([[(0, 'FM7')]])
spans = self.place(document)
self.assertEqual(len(spans), 1)
self.assertEqual(len(spans[0].notes), 4)
before = tests.test_acoustic.sounding(document)
generator.acoustic.combine(document)
self.assertEqual(tests.test_acoustic.sounding(document), before)
generator.model.validate_document(document)
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)
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):
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])
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)
def test_slash_bass_inversion_and_alteration_changes_are_boundaries(self):
inversion = generator.harmony.parse_symbol('C7') | {'inversion': 2}
changes = [(0, 'C7'), (1, 'C7/E'), (2, inversion), (3, 'C7b5')]
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])
def test_non_accompaniment_passage_ends_the_span(self):
document = planned_document([[(0, 'C7')], [], []])
measure = document['staves'][0]['measures'][1]
slash = generator.model.make_event('slash', fractions.Fraction(), generator.model.fraction(measure['duration']), 'note') | {'voice': 'voice-arranged', 'notehead': 'slash'}
measure['voices'] = [{'id': 'voice-arranged', 'notes': [slash]}]
self.assertEqual(len(self.place(document)), 2)
def test_carried_harmony_can_begin_an_arrangement_excerpt(self):
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):
document = planned_document([[(0, 'N.C.')], [(0, 'C7')], [(0, 'N.C.')]])
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):
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)
self.place(document)
def test_rhythm_density_does_not_multiply_shape_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(generator.guitar.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_attacks(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):
document = planned_document([[(0, 'C7')], [(0, 'C7b9#11')]])
before = copy.deepcopy(document)
with self.assertRaisesRegex(ValueError, 'measure 2, event .*no acoustic grip.*0-20'):
generator.guitar.place_guitar(document)
self.assertEqual(document, before)
+6
View File
@@ -199,3 +199,9 @@ class GuitarTests(unittest.TestCase):
generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
self.assertEqual((output / 'last-good').read_text(), 'preserved')
harmony = '<harmony><root><root-step>C</root-step></root><kind>dominant</kind><degree><degree-value>9</degree-value><degree-alter>-1</degree-alter><degree-type>add</degree-type></degree><degree><degree-value>11</degree-value><degree-alter>1</degree-alter><degree-type>add</degree-type></degree></harmony>'
(root / 'data/practice/0001.xml').write_text(tests.test_musicxml.xml_score(harmony + tests.test_musicxml.xml_note()))
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml, part acoustic:.*measure 1.*no acoustic grip.*0-20'):
generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
self.assertEqual((output / 'last-good').read_text(), 'preserved')
+6 -3
View File
@@ -39,7 +39,7 @@ class GuitarPartTests(unittest.TestCase):
self.assertNotIn(0, generator.guitar.chord_requirements(chord, 'electric')[0])
candidates = generator.guitar.chord_positions(*generator.guitar.chord_requirements(chord, 'electric'), profile='electric')
self.assertTrue(any(all(p.midi % 12 != 0 for p in c) for c in candidates))
def test_acoustic_combines_independent_rhythms_and_alternating_bass(self):
def test_acoustic_combines_independent_rhythms_with_a_fixed_grip(self):
result = self.practice(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
measure = result['staves'][0]['measures'][0]
self.assertEqual(len(measure['voices']), 1)
@@ -47,7 +47,10 @@ class GuitarPartTests(unittest.TestCase):
bass = [(n['onset'], p) for n in notes for p in n['pitches'] if p['tablature']['string'] >= 5 and 'tie_from' not in p]
upper = {generator.model.fraction(n['onset']) for n in notes for p in n['pitches'] if p['tablature']['string'] <= 4 and 'tie_from' not in p}
self.assertNotEqual({generator.model.fraction(onset) for onset, _ in bass}, upper)
self.assertEqual([generator.model.pitch_number(p) % 12 for _, p in bass], [0, 7])
self.assertEqual([generator.model.pitch_number(p) % 12 for _, p in bass], [0, 0])
self.assertEqual(bass[0][1]['tablature'], bass[1][1]['tablature'])
upper_shapes = {tuple((p['tablature']['string'], p['tablature']['fret']) for p in n['pitches'] if p['tablature']['string'] <= 4) for n in notes}
self.assertEqual(len(upper_shapes - {()}), 1)
self.assertEqual(sum(generator.model.fraction(n['duration']) for n in notes), 4)
for note in notes:
self.assertNotIn('stem', note)
@@ -62,7 +65,7 @@ class GuitarPartTests(unittest.TestCase):
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]
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_fifth_fallback(self):
def test_explicit_bass_and_default_root(self):
for symbol, expected in (('CM/E', 4), ('C7b9', 0)):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
chord = generator.harmony.parse_symbol(symbol)