Files
product-vamp/generator/comping.py
T
khodowany 3f63b255ac
ci / checks (push) Failing after 23m26s
Fix Playability
2026-10-08 19:04:06 -05:00

498 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Source-only accompaniment: joint layered rhythms and connected grips."""
from __future__ import annotations
import copy
import dataclasses
import fractions
import functools
import json
import typing
import generator.accompaniment
import generator.acoustic
import generator.acoustic_voicing
import generator.arrange.form
import generator.chord_families
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
import generator.solo.policy
import generator.vocabulary
def semantic_key(chord):
return json.dumps({k: chord[k] for k in ('root', 'kind', 'degrees', 'bass', 'inversion') if k in chord}, sort_keys=True)
def requirements(chord, part):
result = generator.fretboard.chord_requirements(chord, respect_bass=part != 'electric')
if part == 'electric' or result.basses:
return result
degrees = generator.harmony.degrees(chord)
plain = degrees in ({1: 0, 3: 0, 5: 0}, {1: 0, 3: -1, 5: 0})
root = int(generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter']))
basses = tuple(sorted({(root + generator.harmony.degree_semitones(d, degrees[d])) % 12 for d in ((1, 3, 5) if plain else (1, 5)) if d in degrees}))
if not basses:
raise ValueError('acoustic accompaniment requires a written bass, root or fifth')
return dataclasses.replace(result, basses=basses)
def voicings(key, part, *, composition=None):
return _voicings(key, part, composition or generator.vocabulary.current())
@functools.lru_cache(maxsize=2048)
def _voicings(key, part, composition):
chord = json.loads(key)
required = requirements(chord, part)
if part == 'electric':
options = generator.guitar.chord_positions(required, prune=False, profile='electric', composition=composition)
elif part == 'acoustic':
degrees = generator.harmony.degrees(chord)
plain = degrees in ({1: 0, 3: 0, 5: 0}, {1: 0, 3: -1, 5: 0})
options = tuple(v for v in generator.acoustic_voicing.candidates(required, plain)
if v.bass.fret <= generator.fretboard.MAX_SINGLE_FRET
and generator.fretboard.has_fret_anchor(v[1:])
and (not plain or set(required.allowed) <= {p.midi % 12 for p in v}))
else:
raise ValueError('Unknown accompaniment instrument: ' + part)
options = tuple(v for v in options if generator.fretboard.playable_attack((p, fractions.Fraction(1)) for p in v))
if not options:
layout = 'bass on 5/6 plus upper strings 2/3/4' if part == 'acoustic' else 'melody chord families on strings 1–4'
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, 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(*(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 = 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(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(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=(), left_hand=None, right_hand=None, close=False, fixed_hands=None):
"""Return complete grips, cost and hand windows, including repeat travel.
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(), ()
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)
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 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, 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:
index: int
start: fractions.Fraction
end: fractions.Fraction
chord: dict
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)
def split_measures(flat, inputs, part):
"""Project a selected sounding figure into notation; barlines create ties only."""
output, fragments, original = [], {}, {}
cursor = fractions.Fraction()
for m, prior, index in inputs:
length = generator.model.fraction(m['duration'])
result = generator.parts.build_measure(m, f'comp_{part}_{index}', 'rest', part, prior)
voices = []
for voice in flat['voices']:
notes = []
for n in voice['notes']:
start = generator.model.fraction(n['onset'])
end = start + generator.model.fraction(n['duration'])
a, b = max(cursor, start), min(cursor + length, end)
if a >= b:
continue
for duration, written in generator.accompaniment.written_durations(b - a):
pitches = [{k: v for k, v in p.items() if k not in ('tie_from', 'tie_to')} for p in n['pitches']]
event = generator.model.make_event(f'comp_{part}_{index}_{voice["id"]}_{len(notes)}', a - cursor, duration, 'note' if pitches else 'rest', pitches, written)
event['voice'] = voice['id']
for old, new in zip(n['pitches'], event['pitches']):
fragments.setdefault(old['id'], []).append(new)
original[old['id']] = old
notes.append(event)
a += duration
voices.append({'id': voice['id'], 'notes': notes})
result['voices'] = voices
for chord in result['harmonies']:
chord.pop('chord_optional', None)
output.append(result)
cursor += length
for pieces in fragments.values():
for a, b in zip(pieces, pieces[1:]):
a['tie_to'], b['tie_from'] = b['id'], a['id']
for identifier, old in original.items():
if old.get('tie_to') in fragments:
a, b = fragments[identifier][-1], fragments[old['tie_to']][0]
a['tie_to'], b['tie_from'] = b['id'], a['id']
return tuple(output)
class Canonical:
def __init__(self, document, part, seed, *, composition=None, plan=None):
self.composition = composition or generator.vocabulary.current()
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.
self.voice_id = self.measures[0]['voices'][0]['id']
self.melody = generator.comping_rhythm.melody_contexts(document)
self.first = generator.score.body_start_measure_index(self.measures)
plan = plan or generator.arrange.form.FormPlan.read(document, part)
sections = plan.sections
self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(self.measures)
and b.source_measure_index == self.first for a, b in zip(sections, sections[1:]))
carried = generator.accompaniment.carried_chords(self.measures)
self.groups = []
index = self.first
while index < len(self.measures):
size = 1
if index + 1 < len(self.measures):
lengths = [generator.model.fraction(m['duration']) for m in self.measures[index:index + 2]]
if self.measures[index]['time_signature'] == self.measures[index + 1]['time_signature'] and any(tuple(map(fractions.Fraction, p['lengths'])) == tuple(lengths) for p in self.composition.accompaniment(part)['patterns']):
size = 2
self.groups.append(tuple((self.measures[i], carried[i], i) for i in range(index, index + size)))
index += size
self.acoustic_cache = {}
self.pair_breaks = {self.first, len(self.measures)}
self.pair_stops = []
for section in sections:
if section.role == 'accompaniment':
self.pair_breaks.update((section.source_measure_index, section.source_measure_index + section.measure_count))
self.pair_stops = [stop for section, stop in zip(sections, plan.stops) if section.role == 'accompaniment' and stop is not None]
breaks = [(i, fractions.Fraction()) for i in self.pair_breaks] + self.pair_stops
choices, locations, self.groove_report = generator.comping_rhythm.choose_cycles(self.groups, part, self.melody, seed, breaks=breaks, composition=self.composition)
self.patterns = {g[0][2]: (p, a) for g, (p, a, _) in zip(self.groups, choices)}
self.locations = {g[0][2]: location for g, location in zip(self.groups, locations)}
selected = [r for _, _, r in choices]
self.block_rhythms = {group[0][2]: rhythm for group, rhythm in zip(self.groups, selected)}
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.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], 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], 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.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'])
self.rhythms[i] = dataclasses.replace(rhythm, events=tuple(dataclasses.replace(e, start=max(e.start, cursor) - cursor, end=min(e.end, cursor + length) - cursor, harmonic_time=e.harmonic_time - cursor if e.harmonic_time is not None else None) for e in rhythm.events if e.start < cursor + length and e.end > cursor))
cursor += length
if grips:
previous = grips[-1]
@property
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
def key(inputs, limit=None):
return (tuple((i, str(generator.model.fraction(m['duration'])), tuple((str(a), str(b), semantic_key(c) if c else '') for a, b, c in generator.comping_rhythm.spans(m, p))) for m, p, i in inputs), str(limit))
def candidates(self, inputs, limit=None):
flat = generator.comping_rhythm.flatten(inputs)
melody, cursor, lengths = [], fractions.Fraction(), []
for m, _, i in inputs:
melody.extend((a + cursor, b + cursor) for a, b in self.melody[i])
lengths.append(generator.model.fraction(m['duration']))
cursor += lengths[-1]
return generator.comping_rhythm.candidates(flat, None, self.part, tuple(melody), self.seed + '/' + str(inputs[0][2]), lengths=tuple(lengths), limit=limit, composition=self.composition)
def acoustic_realizations(self, sources, bounds=None, stop=None):
"""Assign the canonical attack stream once, before splitting it into blocks.
A variant uses the same patterns with the source's actual harmony and
participation boundaries. No selected notes from another role enter here.
"""
carried = generator.accompaniment.carried_chords(sources)
full = tuple((m, carried[i], i) for i, m in enumerate(sources))
key = (self.key(full), bounds, stop)
if key in self.acoustic_cache:
return self.acoustic_cache[key]
starts = [fractions.Fraction()]
for m in sources:
starts.append(starts[-1] + generator.model.fraction(m['duration']))
breaks = [(i, fractions.Fraction()) for i in self.pair_breaks | set(bounds or ())] + self.pair_stops
if stop:
breaks.append(stop)
groups, raw = [], []
for original in self.groups:
group = tuple(full[i] for _, _, i in original)
index = group[0][2]
pattern, anticipate = self.patterns[index]
limit = starts[stop[0]] + stop[1] - starts[index] if stop and any(i == stop[0] for _, _, i in group) else None
rhythm = generator.comping_rhythm.realize(pattern, group, 'acoustic', self.melody, anticipate=anticipate, limit=limit, boundary=True, assign=False)
if rhythm is None:
raise ValueError(f'source measure {sources[index]["number"]}: selected acoustic rhythm cannot fit written harmony')
raw.append(rhythm)
groups.append(group)
context = generator.comping_rhythm.acoustic_context(groups, self.melody, breaks)
realized = generator.comping_rhythm.realize_acoustic_form(raw, context)
result = {g[0][2]: r for g, r in zip(groups, realized)}
self.acoustic_cache[key] = result
return result
def prepare(self, inputs, rhythm):
flat = generator.comping_rhythm.flatten(inputs)
spans = []
offsets, cursor = [], fractions.Fraction()
for m, _, i in inputs:
offsets.append((cursor, i))
cursor += generator.model.fraction(m['duration'])
for start, end, chord in generator.comping_rhythm.spans(flat, None):
if not chord:
continue
offset, index = next((a, i) for a, i in reversed(offsets) if a <= start)
events = tuple(e for e in rhythm.events if start <= (e.harmonic_time if e.harmonic_time is not None else e.start) < end)
try:
options = voicings(semantic_key(chord), self.part, composition=self.composition)
except ValueError as error:
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=(), 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, 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
sources = sources or self.measures
if self.part == 'acoustic' and source != sources[index]:
sources = [source if i == index else m for i, m in enumerate(sources)]
carried = generator.accompaniment.carried_chords(sources)
low, high = bounds or (self.first, len(sources))
group = tuple((source if i == index else sources[i], prior if i == index else carried[i], i) for _, _, i in group if low <= i < high)
limit = None
if stop and any(i == stop[0] for _, _, i in group):
limit = sum((generator.model.fraction(m['duration']) for m, _, i in group if i < stop[0]), fractions.Fraction()) + stop[1]
key = self.key(group, limit)
complete = None
if self.part == 'acoustic' and original_group[0][2] in self.patterns:
complete = self.acoustic_realizations(sources, (low, high), stop)[original_group[0][2]]
key = (key, complete.signature)
if key not in self.cache:
start, end = group[0][2], group[-1][2]
offset = sum((generator.model.fraction(m['duration']) for m, _, i in original_group if i < start), fractions.Fraction())
if original_group[0][2] in self.patterns:
pattern, anticipate = self.patterns[original_group[0][2]]
if self.part == 'acoustic':
end_time = offset + (limit if limit is not None else sum(generator.model.fraction(m['duration']) for m, _, _ in group))
rhythm = dataclasses.replace(complete, events=tuple(dataclasses.replace(e, start=max(e.start, offset) - offset, end=min(e.end, end_time) - offset, harmonic_time=e.harmonic_time - offset if e.harmonic_time is not None and e.harmonic_time >= offset else None)
for e in complete.events if e.start < end_time and e.end > offset))
elif offset:
# Fit the incoming context before slicing. A raw template sustain
# may already have ended at a chord change; projecting it first
# would resurrect that note as an unintended new downbeat attack.
context = tuple((source if i == index else sources[i], prior if i == index else carried[i], i)
for _, _, i in original_group if i <= end)
incoming = generator.comping_rhythm.realize(pattern, context, self.part, self.melody, anticipate=anticipate, limit=offset + limit if limit is not None else None, boundary=True)
if incoming is None:
raise ValueError(f'source measure {source["number"]}: selected groove cannot fit incoming harmony')
pattern = {**pattern, 'events': [{'onset': str(e.start), 'duration': str(e.end - e.start),
'layer': e.lane, 'anticipate': e.harmonic_time is not None and e.lane in ('upper', 'electric')} for e in incoming.events]}
if self.part != 'acoustic':
pattern = generator.comping_rhythm.project(pattern, offset, tuple(generator.model.fraction(m['duration']) for m, _, _ in group))
rhythm = generator.comping_rhythm.realize(pattern, group, self.part, self.melody, anticipate=anticipate, limit=limit, boundary=True)
if rhythm is None:
raise ValueError(f'source measure {source["number"]}: selected groove cannot fit protected boundary')
else:
# A source pickup outside the repeating body uses the existing partial
# vocabulary. It never selects or changes the body groove.
rhythm = min(self.candidates(group, limit), key=lambda r: (r.cost, generator.solo.policy.stable_rank(self.seed, r.signature)))
spans = self.prepare(group, rhythm)
# Keep every unchanged harmonic interval's canonical grip, even when a
# neighbor loses an optional turnaround or a section starts mid-pattern.
fixed = []
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],), hand=self.hands[original.key]) if original else span)
spans = tuple(fixed)
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, placed_events = [], [], []
choices = iter(selected)
for span in spans:
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']))
rows.append({'source_index': span.index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord),
'figure': span.figure, 'pattern_id': rhythm.pattern_id, 'tones': len(grip), 'bass': bool(bass), 'variant': variant,
'family': (generator.chord_families.acoustic(span.chord, grip, composition=self.composition) if self.part == 'acoustic' else generator.chord_families.electric(grip)) if grip else None,
'family_coverage': generator.chord_families.coverage(span.chord, span.options, self.part, composition=self.composition),
'fret_span': generator.fretboard.fret_span(grip),
'span_group': ('fallback' if generator.fretboard.span_penalty(grip) else 'primary') if grip else None,
'span_candidates': generator.chord_families.span_coverage(span.options),
'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': motion.shift, 'voicing_candidates': len(span.options)})
if grip:
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, 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']:
voice_id = lane if self.part == 'acoustic' else self.voice_id
notes, cursor = [], fractions.Fraction()
def emit(a, b, positions=(), chord=None):
previous = None
for duration, written in generator.accompaniment.written_durations(b - a):
pitches = generator.fretboard.chord_pitches(chord, positions) if positions else []
n = generator.model.make_event(f'comp_{self.part}_{index}_{lane}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written)
n['voice'] = voice_id
if previous and pitches:
for old, new in zip(previous['pitches'], n['pitches']):
old['tie_to'], new['tie_from'] = new['id'], old['id']
notes.append(n)
previous = n
a += duration
for _, start, end, positions, chord in sorted((e for e in attacks if e[0] == lane), key=lambda e: e[1]):
if cursor < start:
emit(cursor, start)
emit(start, end, positions, chord)
cursor = end
if cursor < length:
emit(cursor, length)
voices.append({'id': voice_id, 'notes': notes})
result = split_measures({'voices': voices}, inputs, self.part)
if self.part == 'acoustic':
generator.acoustic.validate_assignment(result, rhythm.events)
reports, cursor = [], fractions.Fraction()
for m, _, i in inputs:
duration = generator.model.fraction(m['duration'])
reports.append({'source_index': i, 'pattern_id': rhythm.pattern_id, 'pattern_start': index,
**next((self.locations[g[0][2]] for g in self.groups if any(j == i for _, _, j in g)), {}),
'families': dict(rhythm.families), 'contributions': dict(rhythm.contributions), 'selection_cost': rhythm.cost,
'exceptions': rhythm.exceptions, 'source_melody': [(str(a), str(b)) for a, b in self.melody[i]],
'events': [{'layer': e.lane, 'onset': str(max(e.start, cursor) - cursor), 'duration': str(min(e.end, cursor + duration) - max(e.start, cursor)),
'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, tuple(measure_hands)