Fix Generation
ci / checks (push) Successful in 1h10m16s

This commit is contained in:
khodowany
2026-09-26 23:28:13 -05:00
parent 3beeb87549
commit a416a80dd9
6 changed files with 36 additions and 19 deletions
+1 -1
View File
@@ -113,7 +113,7 @@ Guitar parts share a theory-based voicing and proximity search over standard tun
All parts use two music systems per page, with a single system only when the score ends on an odd system. Tall notation is scaled uniformly to fit the two rows; guitar notation and TAB stay together and aligned. TAB displays frets and ties without duplicate rests; standard notation retains the complete rhythm. The generated `.pages/*.png` files are the same page images displayed by the product.
Electric melody prioritizes four-string chords, then melody on top, then root inclusion. Lower frets and compact shapes are preferred before optional chord tones; rootless voicings remain fallbacks, not equally desirable defaults. Electric accompaniment shares the fullness/root priorities. Acoustic instead favors simple grips, allowing bass plus two upper notes when that is easier than a four-note shape. Defining chord tones remain mandatory. All guitar parts prefer lower positions continuously rather than using a fret threshold; exact melody notes and necessary high positions remain available.
Electric melody prioritizes four-string chords, then melody on top, then root inclusion. Compact shapes, nearby positions, and fret comfort are preferred before optional chord tones; rootless voicings remain fallbacks, not equally desirable defaults. Electric accompaniment shares the fullness/root priorities. Acoustic instead favors simple grips, allowing bass plus two upper notes when that is easier than a four-note shape. Defining chord tones remain mandatory. All guitar placement uses a fret comfort threshold: frets 0-12 inclusive have no height penalty, and each fret above 12 adds two effort points based on the grip's highest fret. Frets 13-20 remain available; the soft penalty does not override musical priorities or force a less convenient move to a lower position. Exact melody pitches and rhythms remain unchanged.
Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The seven-section performance arrangements remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final arrangement ending.
+1 -1
View File
@@ -148,7 +148,7 @@ Placement uses the ordered `VoicingCost` components: electric string shortfall f
`electric.py` links song accompaniment attacks to harmonized melody events using authored source positions, not displayed measure numbers or a song-wide chord dictionary. References stay inside the corresponding source harmony span and must match its structured chord. Within a span, an attack uses the latest available melody grip at or before its source position, or the first later grip if it precedes all melody chords. This allows different shapes for the same chord in different passages. After guitar placement and melody tie resolution, linked stabs copy the actual melody pitches and TAB positions while retaining their own IDs, timing, and written durations; melody ties and markings are not copied. A melody that remains a single note provides no grip. Passages without a matching playable three- or four-string melody chord, including altered endings without a corresponding reference, retain independently generated electric shapes. No reference metadata reaches runtime JSON, and practice electric remains the melody part without an added accompaniment section.
Physical effort is `movement + 2 * highest_fret + fret_span` for electric and exact melody placement, and `movement + 2 * highest_fret + 3 * fret_span + 2 * (distinct_frets - 1)` for acoustic. Movement is the Manhattan distance between doubled fret/string position centers. Open strings count in every fret measurement. Acoustic shape costs are charged once per harmony span, independent of rhythm density, and transitions compare complete grips rather than whichever lane last sounded. Pure melody tie continuations add no fresh shape or movement cost. Lower positions receive a continuous preference with no comfort threshold or automatic octave fitting. Candidate pruning and the exact small-grid distance transform share this ranking; held notes keep their occupied strings and all compatible candidate states. Stable position ordering resolves equal costs.
Physical effort is `movement + 2 * max(0, highest_fret - 12) + fret_span` for electric and exact melody placement, and `movement + 2 * max(0, highest_fret - 12) + 3 * fret_span + 2 * (distinct_frets - 1)` for acoustic. The shared `PREFERRED_MAX_FRET` is 12: height alone adds no cost within frets 0-12 inclusive, then adds two points per excess fret through the hard maximum of 20. This is a soft contribution to effort, not a range priority overriding musical correctness, grip simplicity, or movement. Movement is the Manhattan distance between doubled fret/string position centers. Open strings count in every fret measurement. Acoustic shape costs are charged once per harmony span, independent of rhythm density, and transitions compare complete grips rather than whichever lane last sounded. Pure melody tie continuations add no fresh shape or movement cost. There is no automatic octave fitting. Candidate pruning and the exact small-grid distance transform share this ranking; held notes keep their occupied strings and all compatible candidate states. Stable position ordering resolves equal costs.
There is no finger assignment, barre logic, or predefined chord-shape dictionary. These are proximity and fretboard-feasibility constraints, not a guarantee of comfortable fingering. Nonstandard tuning, capos, microtonal frets, and unplayable passages fail with source/part/event context; there is no slash fallback for a failed chord. Existing assets remain untouched after failure.
+2 -1
View File
@@ -15,6 +15,7 @@ import generator.pitch
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
MIN_FRET = 0
MAX_FRET = 20
PREFERRED_MAX_FRET = 12
MAX_FRET_SPAN = 4
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
ACOUSTIC_STRINGS = ((6, 4, 3), (6, 4, 3, 2), (5, 4, 3), (5, 3, 2), (5, 4, 3, 2))
@@ -44,7 +45,7 @@ def voicing_cost(positions: tuple[TabPosition, ...], profile: str = '', allowed:
classes = {p.midi % 12 for p in positions}
above = sum(p.midi > max(melody) for p in positions) if melody else 0
span = compactness(positions)
effort = 2 * max(frets, default=0) + span
effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET) + span
if profile == 'acoustic':
effort += 2 * span + 2 * max(0, len(frets) - 1)
return VoicingCost(max(0, 4 - len(positions)) if profile == 'electric' else 0,
+2 -2
View File
@@ -79,12 +79,12 @@ class AcousticVoicingTests(unittest.TestCase):
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')], []])
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}, {6})
self.assertEqual({p.midi % 12 for p in bass}, {7})
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))
+1 -1
View File
@@ -47,7 +47,7 @@ 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, 0])
self.assertEqual([generator.model.pitch_number(p) % 12 for _, p in bass], [7, 7])
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)
+29 -13
View File
@@ -46,7 +46,7 @@ class VoicingTests(unittest.TestCase):
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,))
rootless = generator.guitar.voicing_cost(grip((5, 4, 5, 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):
@@ -68,25 +68,41 @@ class VoicingTests(unittest.TestCase):
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):
def test_frets_zero_through_twelve_have_no_height_cost(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)
for fret in range(13):
with self.subTest(profile=profile, fret=fret):
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), profile).effort, 0)
self.assertEqual(generator.guitar.voicing_cost((), profile).effort, 0)
def test_height_cost_only_counts_frets_above_twelve(self):
for profile in ('', 'electric', 'acoustic'):
for fret in range(13, 21):
with self.subTest(profile=profile, fret=fret):
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), profile).effort, 2 * (fret - 12))
crossing = grip((11, 12, 13, 14))
self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 7)
self.assertEqual(generator.guitar.voicing_cost(crossing, 'acoustic').effort, 19)
def test_span_and_acoustic_simplicity_costs_remain_unchanged(self):
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)
self.assertEqual(generator.guitar.voicing_cost(shape).effort, 4)
self.assertEqual(generator.guitar.voicing_cost(shape, 'acoustic').effort, 18)
for profile in ('', 'electric', 'acoustic'):
self.assertLess(generator.guitar.voicing_cost(grip((8, 8, 8, 8)), profile), generator.guitar.voicing_cost(grip((2, 4, 1, 3)), profile))
def test_acoustic_simpler_three_note_shape_can_beat_four_notes(self):
candidates = ranked('C7', profile='acoustic')
candidates = ranked('C6', 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')
required, _, _ = generator.guitar.chord_requirements(generator.harmony.parse_symbol('C6'), '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_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'):
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'):
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,)), ('C7', 'acoustic', ()), ('Gm7', 'electric', (67,))):
best = {}