Fix Playability
ci / checks (push) Failing after 23m26s

This commit is contained in:
khodowany
2026-10-08 19:04:06 -05:00
parent bc7baea7c4
commit 3f63b255ac
23 changed files with 633 additions and 374 deletions
+108 -70
View File
@@ -16,6 +16,7 @@ import generator.comping_rhythm
import generator.fretboard
import generator.guitar
import generator.harmony
import generator.hand_position
import generator.model
import generator.parts
import generator.score
@@ -61,78 +62,88 @@ def _voicings(key, part, composition):
raise ValueError(f'{generator.harmony.symbol(chord)}: no legal {part} voicing ({layout}); required degrees/bass, no open strings, fret anchor 1–12 and at most {generator.fretboard.MAX_FRET_SPAN} consecutive frets remain binding')
return tuple(sorted(options))
def movement(part, previous, current):
if not previous or not current:
return 0
if part == 'acoustic':
return generator.acoustic_voicing.movement(previous, current)
return generator.guitar.movement(previous, current)
def movement(part, previous, current, old_hand, hand):
return generator.hand_position.motion(previous, current, old_hand, hand, part == 'acoustic').shift
class VoicingCost(typing.NamedTuple):
extended_grips: int = 0
family: int = 0
travel: int = 0
peak_position: int = 0
effort: fractions.Fraction = fractions.Fraction()
missing_tones: int = 0
register: fractions.Fraction = fractions.Fraction()
def plus(self, other):
return VoicingCost(*(a + b for a, b in zip(self, other)))
def moved(self, distance):
return self._replace(effort=self.effort + distance)
def voicing_preferences(part, grip, center, *, composition=None):
composition = composition or generator.vocabulary.current()
return generator.guitar.voicing_preferences(grip, generator.guitar.voicing_preference(part + '_accompaniment', center, composition=composition))
return VoicingCost(*(max(a, b) if field == 'peak_position' else a + b for field, a, b in zip(self._fields, self, other)))
def moved(self, distance, ergonomics=0):
return self._replace(travel=self.travel + distance, effort=self.effort + ergonomics)
def local_cost(part, key, grip, center, *, composition=None):
composition = composition or generator.vocabulary.current()
required = requirements(json.loads(key), part)
register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center)
preference = sum(voicing_preferences(part, grip, center, composition=composition))
register = generator.guitar.voicing_preferences(grip, generator.guitar.voicing_preference(part + '_accompaniment', center, composition=composition))
if part == 'electric':
value = generator.guitar.voicing_cost(grip, 'electric', required.allowed, composition=composition)
return VoicingCost(value.extended_grips, 3 * value.string_shortfall + value.family_penalty, value.effort + preference, value.missing_tones, register)
# Familiar degree orders on either bass string, then balanced whole-form
# movement, sounding register and hand position. Neither is an absolute ban.
return VoicingCost(extended_grips=value.extended_grips, family=3 * value.string_shortfall + value.family_penalty, effort=value.effort, missing_tones=value.missing_tones, register=register)
# Keep the familiar families, then compare physical effort before position.
# Sounding register only breaks otherwise equal placement decisions.
family = generator.chord_families.acoustic(json.loads(key), grip, composition=composition)
return VoicingCost(generator.fretboard.span_penalty(grip), int(not family['preferred']), preference, len(set(required.allowed) - {p.midi % 12 for p in grip}), register)
return VoicingCost(extended_grips=generator.fretboard.span_penalty(grip), family=int(not family['preferred']), missing_tones=len(set(required.allowed) - {p.midi % 12 for p in grip}), register=register)
def select_grips(layers, costs, part, seed, *, left=(), right=(), close=False):
"""Exact small-state dynamic programming; never prune by hand center.
def select_grips(layers, costs, part, seed, *, left=(), right=(), left_hand=None, right_hand=None, close=False, fixed_hands=None):
"""Return complete grips, cost and hand windows, including repeat travel.
Both roles balance register/position with travel. Electric includes its
base grip effort; acoustic follows all four voices and bass-string changes.
Compact span and family remain higher tiers.
Fixing the first grip permits an exact repeat-join cost without a beam.
A concrete grip may occupy several windows. Preserve distinct earlier peaks
at a common grip/hand, and fix both starting values for a cyclic path.
"""
if not layers:
return (), VoicingCost()
# Every accompaniment transition is feasible (travel is a cost). A wider
# option cannot beat a primary grip at the same span, regardless of travel.
# This exact dominance reduction avoids growing the cubic repeat search.
return (), VoicingCost(), ()
def hands(grip, fixed=None):
options = generator.hand_position.regions(grip)
if fixed is not None:
return tuple(h for h in options if h == fixed)
return options
retained = []
for i, layer in enumerate(layers):
tier = min(costs[i][v].extended_grips for v in layer)
retained.append(tuple(v for v in layer if costs[i][v].extended_grips == tier))
layers = tuple(retained)
ranks = [{v: generator.solo.policy.stable_rank(seed, (i, v)) for v in layer} for i, layer in enumerate(layers)]
fixed = fixed_hands[i] if fixed_hands is not None else None
options = tuple((v, h) for v in layer if costs[i][v].extended_grips == tier for h in hands(v, fixed))
if not options:
raise ValueError(f'accompaniment span {i}: no grip fits the committed hand position')
retained.append(options)
ranks = [{v: generator.solo.policy.stable_rank(seed, (i, v)) for v, _ in layer} for i, layer in enumerate(retained)]
winners = []
for first in layers[0] if close else [None]:
states = {left: (VoicingCost(), (), ())}
for i, layer in enumerate(layers):
for first in retained[0] if close else [None]:
states = {(left, h, h): (VoicingCost(peak_position=h), (), (), ()) for h in hands(left, left_hand)}
for i, layer in enumerate(retained):
following = {}
for grip in [first] if i == 0 and first is not None else layer:
local = costs[i][grip]
choices = []
for previous, (cost, ties, path) in states.items():
total = cost.plus(local).moved(movement(part, previous, grip))
choices.append((total, (*ties, ranks[i][grip]), (*path, grip)))
following[grip] = min(choices)
states = following
for grip, (cost, ties, path) in states.items():
travel = movement(part, grip, first if close else right)
winners.append((cost.moved(travel), ties, path))
cost, _, path = min(winners)
return path, cost
for grip, hand in [first] if i == 0 and first is not None else layer:
local = costs[i][grip]._replace(peak_position=hand)
for (previous, old_hand, _), (cost, ties, path, positions) in states.items():
motion = generator.hand_position.motion(previous, grip, old_hand, hand, part == 'acoustic')
total = cost.plus(local).moved(motion.shift, motion.ergonomics)
candidate = (total, (*ties, (ranks[i][grip], hand)), (*path, grip), (*positions, hand))
key = (grip, hand, total.peak_position)
if key not in following or candidate < following[key]:
following[key] = candidate
# Future travel is identical at a common concrete grip and hand.
best = {}
for (grip, hand, _), value in following.items():
primary = value[0][:3]
best[grip, hand] = min(best.get((grip, hand), primary), primary)
states = {key: value for key, value in following.items() if value[0][:3] == best[key[:2]]}
endpoints = [first] if close else [(right, h) for h in hands(right, right_hand)]
for (grip, hand, _), (cost, ties, path, positions) in states.items():
for endpoint, end_hand in endpoints:
motion = generator.hand_position.motion(grip, endpoint, hand, end_hand, part == 'acoustic')
total = cost.plus(VoicingCost(peak_position=end_hand)).moved(motion.shift, motion.ergonomics)
winners.append((total, ties, path, positions))
if not winners:
raise ValueError('accompaniment boundary grip does not fit the committed hand position')
cost, _, path, positions = min(winners)
return path, cost, positions
@dataclasses.dataclass(frozen=True)
class HarmonyChoice:
@@ -143,6 +154,7 @@ class HarmonyChoice:
events: tuple
figure: dict
options: tuple
hand: int | None = None
@property
def key(self):
return (self.index, self.start, self.end, semantic_key(self.chord), self.events)
@@ -193,6 +205,8 @@ class Canonical:
self.part, self.seed = part, seed
self.policy = self.composition.role(part + '_accompaniment')
self.cache, self._reports, self._rhythm_reports = {}, {}, {}
self.cache_positions = {}
self.last_position = ((), 0)
self.measures = generator.score.source_staff(document)['measures']
# A held accompaniment chord may continue into the bar containing the
# source melody pickup. Keep its voice identity through that splice.
@@ -230,15 +244,16 @@ class Canonical:
prepared = [self.prepare(group, rhythm) for group, rhythm in zip(self.groups, selected)]
active = tuple(span for spans in prepared for span in spans if span.events)
self.canonical_spans = {group[0][2]: spans for group, spans in zip(self.groups, prepared)}
path, self.path_cost = self.select(active, close=self.closed)
path, self.path_cost, self.hand_order = self.select(active, close=self.closed)
self.base = dict(zip((s.key for s in active), path))
self.hands = dict(zip((s.key for s in active), self.hand_order))
self.order = tuple((s.index, s.start, v) for s, v in zip(active, path))
self.rhythms = {}
previous = path[-1] if self.closed and path else ()
previous = (path[-1], self.hand_order[-1]) if self.closed and path else ((), 0)
for group, rhythm, spans in zip(self.groups, selected, prepared):
grips = tuple(self.base[s.key] for s in spans if s.events)
grips = tuple((self.base[s.key], self.hands[s.key]) for s in spans if s.events)
key = (self.key(group), rhythm.signature) if part == 'acoustic' else self.key(group)
self.cache[key] = self.render(group, rhythm, spans, grips, previous, False)
self.cache[key], self.cache_positions[key] = self.render(group, rhythm, spans, grips, previous, False)
cursor = fractions.Fraction()
for m, _, i in group:
length = generator.model.fraction(m['duration'])
@@ -250,6 +265,17 @@ class Canonical:
def report(self):
return [row for key in sorted(self._reports, key=repr) for row in self._reports[key]]
@property
def placement_report(self):
previous = self.hand_order[-1] if self.closed and self.hand_order else None
shifts = []
for hand in self.hand_order:
shifts.append(abs(hand - previous) if previous is not None else 0)
previous = hand
return {'version': generator.hand_position.VERSION, 'priority': 'hand travel, maximum hand position, ergonomics, coverage/register, stable tie',
**generator.hand_position.summary(shifts), 'peak_position': self.path_cost.peak_position,
'cost': {k: str(v) for k, v in self.path_cost._asdict().items()},
'positions': [{'source_index': i, 'onset': str(t), 'hand_position': h} for (i, t, _), h in zip(self.order, self.hand_order)]}
@property
def rhythm_report(self):
return [row for key in sorted(self._rhythm_reports, key=repr) for row in self._rhythm_reports[key]]
@staticmethod
@@ -314,10 +340,10 @@ class Canonical:
raise ValueError(f'source measure {inputs[index - inputs[0][2]][0]["number"]}, beat {start - offset + 1}: {error}') from error
spans.append(HarmonyChoice(index, start, end, chord, events, dict(rhythm.families), options))
return tuple(spans)
def select(self, spans, *, left=(), right=(), close=False):
def select(self, spans, *, left=(), right=(), left_hand=None, right_hand=None, close=False):
layers = tuple(s.options for s in spans)
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55), composition=self.composition) for v in layer} for s, layer in zip(spans, layers))
return select_grips(layers, costs, self.part, self.seed, left=left, right=right, close=close)
return select_grips(layers, costs, self.part, self.seed, left=left, right=right, left_hand=left_hand, right_hand=right_hand, close=close, fixed_hands=tuple(s.hand for s in spans))
def __call__(self, source, prior, index, *, sources=None, bounds=None, stop=None):
group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),))
original_group = group
@@ -371,23 +397,27 @@ class Canonical:
for span in spans:
original = next((s for s in self.canonical_spans.get(original_group[0][2], ()) if s.key in self.base
and s.start <= span.start + offset < s.end and semantic_key(s.chord) == semantic_key(span.chord)), None)
fixed.append(dataclasses.replace(span, options=(self.base[original.key],)) if original else span)
fixed.append(dataclasses.replace(span, options=(self.base[original.key],), hand=self.hands[original.key]) if original else span)
spans = tuple(fixed)
before = [v for i, _, v in self.order if i < start]
after = [v for i, _, v in self.order if i > end]
left = before[-1] if before else self.order[-1][2] if self.closed and self.order else ()
right = after[0] if after else self.order[0][2] if self.closed and self.order else ()
selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right)
self.cache[key] = self.render(group, rhythm, spans, selected, left, True, limit)
before = [(v, h) for (i, _, v), h in zip(self.order, self.hand_order) if i < start]
after = [(v, h) for (i, _, v), h in zip(self.order, self.hand_order) if i > end]
left = before[-1] if before else (self.order[-1][2], self.hand_order[-1]) if self.closed and self.order else ((), 0)
right = after[0] if after else (self.order[0][2], self.hand_order[0]) if self.closed and self.order else ((), 0)
selected, _, hands = self.select(tuple(s for s in spans if s.events), left=left[0], right=right[0], left_hand=left[1], right_hand=right[1])
self.cache[key], self.cache_positions[key] = self.render(group, rhythm, spans, tuple(zip(selected, hands)), left, True, limit)
placement = self.cache_positions[key][index - group[0][2]]
if placement is not None:
self.last_position = placement
return copy.deepcopy(self.cache[key][index - group[0][2]])
def render(self, inputs, rhythm, spans, selected, previous, variant, limit=None):
flat = generator.comping_rhythm.flatten(inputs)
length = generator.model.fraction(flat['duration'])
index = inputs[0][2]
attacks, rows = [], []
attacks, rows, placed_events = [], [], []
choices = iter(selected)
for span in spans:
grip = next(choices) if span.events else ()
grip, hand = next(choices) if span.events else ((), 0)
motion = generator.hand_position.motion(previous[0], grip, previous[1], hand, self.part == 'acoustic')
bass = grip[0] if grip and self.part == 'acoustic' else None
degrees = generator.harmony.degrees(span.chord)
root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter']))
@@ -401,22 +431,30 @@ class Canonical:
'positions': [{'string': p.string_number, 'fret': p.fret, 'sounding_midi': p.midi} for p in grip],
'bass_degree': next((d for d, a in degrees.items() if bass and (root + generator.harmony.degree_semitones(d, a)) % 12 == bass.midi % 12), None),
'bass_string': bass.string_number if bass else None,
'movement': movement(self.part, previous, grip), 'voicing_candidates': len(span.options)})
'movement': motion.shift, 'voicing_candidates': len(span.options)})
if grip:
register, position = voicing_preferences(self.part, grip, self.policy.get('center', 55), composition=self.composition)
travel = movement(self.part, previous, grip)
effort = generator.guitar.voicing_cost(grip, 'electric', composition=self.composition).effort if self.part == 'electric' else 0
rows[-1]['voicing_cost'] = {'movement': travel, 'grip_effort': str(effort), 'register': str(register), 'position': str(position), 'combined': str(travel + effort + register + position)}
local = local_cost(self.part, semantic_key(span.chord), grip, self.policy.get('center', 55), composition=self.composition)
rows[-1]['hand_position'] = hand
rows[-1]['voicing_cost'] = {'movement': motion.shift, 'hand_shift': motion.shift, 'finger_adjustment': motion.fingers,
'string_changes': motion.strings, 'grip_effort': str(local.effort), 'register_tiebreak': str(local.register),
'secondary_effort': str(motion.ergonomics + local.effort)}
for event in span.events:
placed_events.append((event.start, event.end, (grip, hand)))
lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane]
for lane in lanes:
positions = (grip[0],) if lane == 'bass' else grip[1:] if lane == 'upper' else grip
attacks.append((lane, event.start, event.end, positions, span.chord))
if grip:
previous = grip
previous = (grip, hand)
key = self.key(inputs, limit)
if self.part == 'acoustic':
key = (key, rhythm.signature)
measure_hands, cursor = [], fractions.Fraction()
for m, _, _ in inputs:
end = cursor + generator.model.fraction(m['duration'])
played = sorted((a, b, p) for a, b, p in placed_events if a < end and b > cursor)
measure_hands.append(played[-1][2] if played else None)
cursor = end
self._reports[key] = rows
voices = []
for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']:
@@ -456,4 +494,4 @@ class Canonical:
'continuation': e.start < cursor, 'harmonic_target': str(e.harmonic_time - cursor) if e.harmonic_time is not None else None} for e in rhythm.events if e.start < cursor + duration and e.end > cursor]})
cursor += duration
self._rhythm_reports[key] = reports
return result
return result, tuple(measure_hands)