+108
-70
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user