124 lines
6.3 KiB
Python
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
|