@@ -115,7 +115,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. Lines break only between measures and share consistent staff/TAB widths. A measure that cannot fit at readable spacing fails with a diagnostic instead of being split. 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. Every generated guitar chord requires the root, the fifth, or both; neither is preferred over the other. Acoustic favors simple grips, allowing bass plus two upper notes when that is easier than a four-note shape. Defining chord tones remain mandatory. Acoustic and electric chord scoring balances movement and span with a lower-position preference: the highest fret adds one effort point per fret, plus two more per fret above 12. Frets 13-20 remain available when movement savings justify them. Exact melody pitches and rhythms remain unchanged, and unharmonized melody placement retains its existing proximity scoring.
|
||||
Electric melody prioritizes four-string chords, then melody on top. Every generated guitar chord requires the root, the fifth, or both; neither is preferred over the other. Acoustic favors simple grips, allowing bass plus two upper notes when that is easier than a four-note shape. Defining chord tones remain mandatory. Acoustic and electric chords share a combined effort score for shape difficulty, fret height, and movement, so a compact mid-neck grip can beat a stretched lower one. Shape difficulty adds three points per fret of span and two per additional distinct fret. Height adds one point per fret, plus two more per fret above 12. Acoustic transitions weight per-string movement by two; electric retains its doubled-center movement metric. Frets 13-20 remain available when the overall tradeoff justifies them. Exact melody pitches and rhythms remain unchanged, and unharmonized melody placement retains its existing proximity scoring.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+3
-3
@@ -142,13 +142,13 @@ Electric chord blocks use strings 1-3 or 1-4, including string 1. Rootless voici
|
||||
|
||||
`melody.py` treats the full tied duration as one note when testing whether it exceeds one quarter-note beat. Qualifying notes receive a chord at their initial attack and at each subsequent changed harmony, even if a resulting fragment is less than one beat. Changed harmonies break ties and reattack the melody; repeated identical harmonies do not. N.C. releases the added voices while preserving the melody's sustain. Added tones through unchanged harmony retain their own ties. The quarter-note cap on electric stabs does not apply to these melody chords. If no valid chord fits, the exact melody remains with a diagnostic; it is not octave-wrapped or omitted.
|
||||
|
||||
Placement uses the ordered `VoicingCost` components: electric string shortfall from four, melody covered, number of tones above the melody, physical effort, acoustic simplicity, and missing optional pitch classes. These costs accumulate over the whole passage, so movement cannot outweigh the earlier electric musical priorities. Melody height compares sounding pitch; unison doubling does not cover it. Electric accompaniment uses the same ranking without the melody-height terms. Acoustic does not receive electric fullness penalties: movement takes priority over simplicity, and a simpler three-note grip can beat an equally nearby but more awkward four-note grip.
|
||||
Placement uses the ordered `VoicingCost` components: electric string shortfall from four, melody covered, number of tones above the melody, combined physical effort, and missing optional pitch classes. These costs accumulate over the whole passage, so physical effort cannot outweigh the earlier electric musical priorities. Melody height compares sounding pitch; unison doubling does not cover it. Electric accompaniment uses the same ranking without the melody-height terms. Acoustic does not receive electric fullness penalties: a simpler three-note grip can beat a more awkward four-note grip. Shape difficulty competes directly with fret height and movement, not as a later tie-breaker.
|
||||
|
||||
`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.
|
||||
|
||||
Physical effort for generated electric chords is `movement + highest_fret + 2 * max(0, highest_fret - 12) + fret_span`. Exact, unharmonized melody placement retains its previous cost without the `highest_fret` term. Their movement metric remains the Manhattan distance between doubled fret/string position centers, with the exact small-grid distance transform used for independent attacks. Acoustic effort is `movement + highest_fret + 2 * max(0, highest_fret - 12)`, followed by a separate simplicity cost of `3 * fret_span + 2 * (distinct_frets - 1)`. Acoustic movement sums fret changes on retained strings plus one unit per added or released string; an unchanged position costs zero. Exact candidate-to-candidate dynamic programming retains all acoustic grips, including different grips with equal centers. This favors C7 at `5:10, 3:9, 2:11` after Gm7 at `5:10, 3:10, 2:11`, without a song-specific shape dictionary.
|
||||
Both generated chord profiles use the same shape-and-height cost: `highest_fret + 2 * max(0, highest_fret - 12) + 3 * fret_span + 2 * max(0, distinct_frets - 1)`. Electric adds its existing movement metric, the Manhattan distance between doubled fret/string position centers, with the exact small-grid distance transform used for independent attacks. Acoustic adds twice its per-string movement: fret changes on retained strings plus one unit per added or released string. The doubled weight balances transitions against shape difficulty so a lower isolated grip does not justify a large jump. An unchanged grip costs zero movement. Exact candidate-to-candidate dynamic programming retains all acoustic grips, including different grips with equal centers. Exact, unharmonized melody placement retains `movement + 2 * max(0, highest_fret - 12) + fret_span`.
|
||||
|
||||
Generated chord height contributes one point per fret throughout the allowed range. The shared `PREFERRED_MAX_FRET` remains 12, above which each excess fret adds two more points. Lower positions compete with movement and span costs rather than winning unconditionally; higher grips remain available when they reduce movement enough. 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 or finger-assignment model. Stable position ordering resolves equal costs.
|
||||
Generated chord height contributes one point per fret throughout the allowed range. The shared `PREFERRED_MAX_FRET` remains 12, above which each excess fret adds two more points. Compact mid-neck grips can beat stretched lower ones, while lower positions still win when shape difficulty and movement are comparable. Autumn Leaves begins with Em7b5 at `5:7, 4:8, 3:7, 2:8` and A7b9 at `5:7, 4:8, 3:6, 2:8`; the nearby C7 at `5:10, 3:9, 2:11` remains preferred after Gm7 at `5:10, 3:10, 2:11`. These are regression examples, not a song-specific shape dictionary. 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 or finger-assignment model. 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.
|
||||
|
||||
|
||||
+4
-6
@@ -31,7 +31,6 @@ class VoicingCost(typing.NamedTuple):
|
||||
covered_melody: int = 0
|
||||
covering_tones: int = 0
|
||||
effort: int = 0
|
||||
simplicity: int = 0
|
||||
missing_tones: int = 0
|
||||
def plus(self, other: VoicingCost) -> VoicingCost:
|
||||
return VoicingCost(*(a + b for a, b in zip(self, other)))
|
||||
@@ -47,11 +46,10 @@ def voicing_cost(positions: tuple[TabPosition, ...], profile: str = '', allowed:
|
||||
span = compactness(positions)
|
||||
effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET)
|
||||
if profile in ('acoustic', 'electric'):
|
||||
effort += max(frets, default=0)
|
||||
simplicity = 3 * span + 2 * max(0, len(frets) - 1) if profile == 'acoustic' else 0
|
||||
if profile != 'acoustic':
|
||||
effort += max(frets, default=0) + 3 * span + 2 * max(0, len(frets) - 1)
|
||||
else:
|
||||
effort += span
|
||||
return VoicingCost(max(0, 4 - len(positions)) if profile == 'electric' else 0, int(bool(above)), above, effort, simplicity, len(set(allowed) - classes))
|
||||
return VoicingCost(max(0, 4 - len(positions)) if profile == 'electric' else 0, int(bool(above)), above, effort, len(set(allowed) - classes))
|
||||
|
||||
def within_fret_span(positions) -> bool:
|
||||
frets = [position.fret for position in positions]
|
||||
@@ -385,7 +383,7 @@ def advance_attacks(candidates, states: dict) -> dict:
|
||||
def advance_acoustic(candidates, states: dict) -> dict:
|
||||
result = {}
|
||||
for candidate in candidates:
|
||||
cost, _, previous = min((p.cost.moved(acoustic_movement(tuple(position for _, position in p.active), candidate.shape)), i, p) for i, p in enumerate(states.values()))
|
||||
cost, _, previous = min((p.cost.moved(2 * acoustic_movement(tuple(position for _, position in p.active), candidate.shape)), i, p) for i, p in enumerate(states.values()))
|
||||
result[candidate.active] = Placement(cost.plus(candidate.cost), candidate.active, previous, candidate.choices)
|
||||
return result
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
expected = {}
|
||||
for candidate in candidates:
|
||||
expected[candidate.active] = min((previous.cost.moved(generator.guitar.acoustic_movement(tuple(p for _, p in previous.active), candidate.shape)).plus(candidate.cost), index) for index, previous in enumerate(states.values()))
|
||||
expected[candidate.active] = min((previous.cost.moved(2 * generator.guitar.acoustic_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_acoustic(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)
|
||||
|
||||
@@ -185,7 +185,7 @@ 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(generator.guitar.VoicingCost(*(randomizer.randrange(8) for _ in range(6))), tuple((str(i), p) for i, p in enumerate(positions)), None, ())
|
||||
states[index] = generator.guitar.Placement(generator.guitar.VoicingCost(*(randomizer.randrange(8) for _ in generator.guitar.VoicingCost._fields)), 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):
|
||||
|
||||
+40
-22
@@ -7,6 +7,7 @@ import generator.guitar
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.rhythm
|
||||
import tests.rhythm_fixtures
|
||||
import tests.test_musicxml
|
||||
import tests.test_parts
|
||||
@@ -42,14 +43,14 @@ class VoicingTests(unittest.TestCase):
|
||||
self.assertEqual(max(p.midi for p in best), 65)
|
||||
self.assertNotIn(1, {p.midi % 12 for p in best})
|
||||
self.assertTrue(any(1 in {p.midi % 12 for p in c} and max(p.midi for p in c) > 65 for c in candidates))
|
||||
def test_lower_position_can_balance_a_wider_span_without_covering_melody(self):
|
||||
def test_compact_chord_can_beat_lower_frets_without_covering_melody(self):
|
||||
candidates = ranked('CM7', (67,))
|
||||
self.assertEqual(candidates[0], grip((2, 4, 1, 3)))
|
||||
self.assertIn(grip((5, 4, 5, 3)), candidates)
|
||||
self.assertEqual(candidates[0], grip((5, 4, 5, 3)))
|
||||
self.assertIn(grip((2, 4, 1, 3)), candidates)
|
||||
rooted = generator.guitar.voicing_cost(grip((2, 4, 1, 3)), 'electric', (0, 4, 7, 11), (67,))
|
||||
rootless = generator.guitar.voicing_cost(grip((5, 4, 5, 3)), 'electric', (0, 4, 7, 11), (67,))
|
||||
self.assertEqual(rooted.effort, rootless.effort)
|
||||
self.assertLess(rooted, rootless)
|
||||
self.assertEqual((rooted.effort, rootless.effort), (19, 15))
|
||||
self.assertLess(rootless, rooted)
|
||||
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)
|
||||
@@ -81,30 +82,47 @@ class VoicingTests(unittest.TestCase):
|
||||
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4)).effort, 2 * max(0, fret - 12))
|
||||
crossing = grip((11, 12, 13, 14))
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 7)
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing, 'electric').effort, 21)
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing, 'acoustic').effort, 18)
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing, 'acoustic').simplicity, 15)
|
||||
def test_span_and_acoustic_simplicity_costs_remain_unchanged(self):
|
||||
for profile in ('electric', 'acoustic'):
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing, profile).effort, 33)
|
||||
def test_chord_shape_and_height_share_one_effort_cost(self):
|
||||
shape = grip((1, 3, 4, 5))
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape).effort, 4)
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape, 'electric').effort, 9)
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape, 'acoustic').effort, 5)
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape, 'acoustic').simplicity, 18)
|
||||
for profile in ('electric', 'acoustic'):
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape, profile).effort, 23)
|
||||
for profile in ('', 'electric', 'acoustic'):
|
||||
self.assertLess(generator.guitar.voicing_cost(grip((4, 4, 4, 4)), profile), generator.guitar.voicing_cost(grip((2, 4, 1, 3)), profile))
|
||||
def test_lower_acoustic_half_diminished_and_dominant_grips_win(self):
|
||||
def test_distinct_frets_add_difficulty_even_with_the_same_span(self):
|
||||
simple, varied = grip((3, 3, 5, 5)), grip((3, 4, 5, 5))
|
||||
for profile in ('electric', 'acoustic'):
|
||||
self.assertEqual(generator.guitar.voicing_cost(simple, profile).effort, 13)
|
||||
self.assertEqual(generator.guitar.voicing_cost(varied, profile).effort, 15)
|
||||
self.assertEqual(generator.guitar.voicing_cost(simple).effort, generator.guitar.voicing_cost(varied).effort)
|
||||
def test_compact_mid_neck_acoustic_pair_beats_high_and_stretched_low_pairs(self):
|
||||
high = [grip((12, 12, 12, 11), (6, 4, 3, 2)), grip((12, 11, 12, 11), (6, 4, 3, 2))]
|
||||
low = [grip((7, 8, 7, 8), (5, 4, 3, 2)), grip((7, 8, 6, 8), (5, 4, 3, 2))]
|
||||
for symbol, upper, lower in zip(('Em7b5', 'A7b9'), high, low):
|
||||
requested = [grip((7, 8, 7, 8), (5, 4, 3, 2)), grip((7, 8, 6, 8), (5, 4, 3, 2))]
|
||||
low = [grip((6, 5, 3, 3), (6, 4, 3, 2)), grip((5, 5, 3, 2), (6, 4, 3, 2))]
|
||||
for symbol, upper, middle, lower in zip(('Em7b5', 'A7b9'), high, requested, low):
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol(symbol), 'acoustic')
|
||||
candidates = generator.guitar.chord_positions(requirements, profile='acoustic')
|
||||
self.assertIn(upper, candidates)
|
||||
self.assertIn(lower, candidates)
|
||||
self.assertEqual({p.midi % 12 for p in upper}, {p.midi % 12 for p in lower})
|
||||
self.assertEqual(min(p.midi for p in upper), min(p.midi for p in lower))
|
||||
self.assertLess(generator.guitar.voicing_cost(lower, 'acoustic', requirements.allowed), generator.guitar.voicing_cost(upper, 'acoustic', requirements.allowed))
|
||||
costs = [sum(generator.guitar.voicing_cost(s, 'acoustic').effort for s in pair) + generator.guitar.acoustic_movement(*pair) for pair in (low, high)]
|
||||
self.assertEqual(costs, [17, 25])
|
||||
for shape in (upper, middle, lower):
|
||||
self.assertIn(shape, candidates)
|
||||
self.assertEqual({p.midi % 12 for p in upper}, {p.midi % 12 for p in middle})
|
||||
self.assertEqual(min(p.midi for p in upper), min(p.midi for p in middle))
|
||||
costs = [sum(generator.guitar.voicing_cost(s, 'acoustic').effort for s in pair) + 2 * generator.guitar.acoustic_movement(*pair) for pair in (requested, high, low)]
|
||||
self.assertEqual(costs, [33, 36, 41])
|
||||
def test_real_autumn_leaves_lead_in_uses_requested_compact_grips(self):
|
||||
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn-leaves.xml'))
|
||||
before = copy.deepcopy(source)
|
||||
result = generator.arrange.generate.arrange_document(source, 'acoustic', rhythms=generator.rhythm.load_library(pathlib.Path('data/rhythms')))
|
||||
generator.model.validate_document(result)
|
||||
measures = result['staves'][0]['measures'][:2]
|
||||
self.assertEqual(measures[0]['section_label'], 'Rhythm Lead-in')
|
||||
for measure, symbol, expected in zip(measures, ('Em7b5', 'A7b9'), (((5, 7), (4, 8), (3, 7), (2, 8)), ((5, 7), (4, 8), (3, 6), (2, 8)))):
|
||||
with self.subTest(chord=symbol):
|
||||
self.assertEqual(generator.harmony.symbol(measure['harmonies'][0]), symbol)
|
||||
positions = {(p['tablature']['string'], p['tablature']['fret']) for v in measure['voices'] for n in v['notes'] for p in n['pitches']}
|
||||
self.assertEqual(tuple(sorted(positions, reverse=True)), expected)
|
||||
self.assertEqual(source, before)
|
||||
def test_acoustic_simpler_three_note_shape_can_beat_four_notes(self):
|
||||
candidates = ranked('Csus', profile='acoustic')
|
||||
best = candidates[0]
|
||||
|
||||
Reference in New Issue
Block a user