Files
product-vamp/generator/comping.py
T
2026-10-04 09:05:25 -05:00

124 lines
6.3 KiB
Python

"""Source-only sparse accompaniment, frozen once at each form position."""
from __future__ import annotations
import copy
import functools
import json
from fractions import Fraction as F
from generator import accompaniment, fretboard, guitar, harmony, model, parts, score, vocabulary
from generator.solo.policy import stable_rank
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)
@functools.lru_cache(maxsize=2048)
def voicings(key, bass):
chord = json.loads(key)
degrees = harmony.degrees(chord)
root = int(model.NATURAL[chord['root']['letter']] + model.alteration(chord['root']['alter']))
classes = {d: (root + harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
required = {p for d, p in classes.items() if d in (2, 3, 4, 6, 7) or d == 5 and degrees[d] or d > 7}
full = fretboard.chord_requirements(chord)
required.update(full.basses)
if bass and 1 in classes:
required.add(classes[1])
if len(required) < 2:
required.update(classes[d] for d in (1, 5) if d in classes)
allowed = set(full.allowed)
requirements = fretboard.ChordRequirements(tuple(sorted(required)), tuple(sorted(allowed)), tuple(sorted(allowed)), full.basses)
options = guitar.chord_positions(requirements, prune=False)
size = max(2, len(required))
options = [v for v in options if len(v) == size and fretboard.playable_attack((p, F(1)) for p in v)]
if not options:
raise ValueError(harmony.symbol(chord) + ': no legal sparse voicing preserving essential written degrees')
# Retain distinct hand regions and inversions, independent of enumeration.
best = {}
for v in options:
family = (min(p.fret for p in v), min(p.midi for p in v) % 12)
rank = (guitar.compactness(v), stable_rank('comp-voicing', v))
if family not in best or rank < best[family][0]:
best[family] = rank, v
return tuple(v for _, v in sorted(best.values()))
class Canonical:
def __init__(self, document, part, seed):
self.part, self.seed = part, seed
self.policy = vocabulary.role(part + '_accompaniment')
self.cache, self.previous, self.report = {}, (), []
measures = score.source_staff(document)['measures']
self.first_index = score.body_start_measure_index(measures)
self.last_index = len(measures)-1
carried = accompaniment.carried_chords(measures)
for index in range(score.body_start_measure_index(measures), len(measures)):
source = copy.deepcopy(measures[index])
for h in source['harmonies']:
h.pop('chord_optional', None)
self(source, carried[index], index)
def __call__(self, source, prior, index):
key = (index, tuple((model.fraction(h['onset']), semantic_key(h)) for h in source['harmonies']), semantic_key(prior) if prior else '', source['duration'].__repr__())
if key in self.cache:
return copy.deepcopy(self.cache[key])
length = model.fraction(source['duration'])
boundaries = sorted({F(), length, *(model.fraction(h['onset']) for h in source['harmonies'])})
attacks = []
source_attacks = {model.fraction(n['onset']) for n in score.measure_notes(source) if n['pitches'] and not all('tie_from' in p for p in n['pitches'])}
for start, end in zip(boundaries, boundaries[1:]):
chord = accompaniment.active_chord(source, start, prior)
if not chord:
continue
duration = end - start
# Essential bass is a source-derived form responsibility, independent
# of which other guitar happens to be playing or what it generated.
bass = self.policy.get('bass') == 'primary' or bool(chord.get('bass')) or index in (self.first_index,self.last_index) or start > 0
options = voicings(semantic_key(chord), bass)
center = self.policy.get('center', 55)
selected = min(options, key=lambda v: (
.08 * abs(sum(p.midi for p in v) / len(v) - center)
+ (.15 * guitar.movement(self.previous, v) if self.previous else 0)
+ .1 * guitar.compactness(v), stable_rank(self.seed, (index, start, v))))
self.previous = selected
patterns = []
for figure in vocabulary.data('rhythms')['comping']:
events = tuple((start+F(a), min(end, start+F(a)+F(d))) for a, d in figure['attacks'] if F(a) < duration)
# Space is available on held harmony, never an accidental empty winner.
if not events and (not prior or not accompaniment.same_chord(chord, prior) or start):
continue
conflicts = sum(a in source_attacks for a, _ in events)
target = 1 if duration <= 3 else 1.5
cost = .2*abs(len(events)-target) + .08*conflicts
if figure['id'] == 'sustain' and duration < 3:
cost += .2
patterns.append((cost, stable_rank(self.seed, (index, start, figure['id'])), events, figure['id']))
# Source-specific variation within a broad acceptability band, not a loop.
low = min(p[0] for p in patterns)
_, _, events, label = min((p for p in patterns if p[0] <= low+.25), key=lambda p:p[1])
for a, b in events:
attacks.append((a, b, selected, chord))
self.report.append({'source_index': index, 'onset': str(start), 'chord': harmony.symbol(chord), 'figure': label, 'tones': len(selected), 'bass': bass,
'fuller_voicing_reason': 'explicit written degrees/bass' if len(selected)>3 else ''})
notes, cursor = [], F()
def emit(a, b, positions=(), chord=None):
nonlocal cursor
previous = None
for duration, written in accompaniment.written_durations(b-a):
pitches = fretboard.chord_pitches(chord, positions) if positions else []
n = model.make_event(f'comp_{self.part}_{index}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written)
if previous:
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
cursor = b
for start, end, positions, chord in attacks:
if cursor < start: emit(cursor, start)
emit(start, end, positions, chord)
if cursor < length: emit(cursor, length)
result = parts.build_measure(source, 'canonical', 'rest', self.part, prior)
result['voices'] = [{'id': 'voice_arranged', 'notes': notes}]
for note in notes: note['voice'] = 'voice_arranged'
self.cache[key] = copy.deepcopy(result)
return result