"""Source-only sparse accompaniment, frozen once at each form position.""" from __future__ import annotations import copy import functools import json import fractions import generator.accompaniment import generator.fretboard import generator.guitar import generator.harmony import generator.model import generator.parts import generator.score import generator.vocabulary import generator.solo.policy 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 = generator.harmony.degrees(chord) root = int(generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter'])) classes = {d: (root + generator.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 = generator.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 = generator.fretboard.ChordRequirements(tuple(sorted(required)), tuple(sorted(allowed)), tuple(sorted(allowed)), full.basses) options = generator.guitar.chord_positions(requirements, prune=False) size = max(2, len(required)) options = [v for v in options if len(v) == size and generator.fretboard.playable_attack((p, fractions.Fraction(1)) for p in v)] if not options: raise ValueError(generator.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 = (generator.guitar.compactness(v), generator.solo.policy.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 = generator.vocabulary.role(part + '_accompaniment') self.cache, self.previous, self.report = {}, (), [] measures = generator.score.source_staff(document)['measures'] self.first_index = generator.score.body_start_measure_index(measures) self.last_index = len(measures) - 1 carried = generator.accompaniment.carried_chords(measures) for index in range(generator.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((generator.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 = generator.model.fraction(source['duration']) boundaries = sorted({fractions.Fraction(), length, *(generator.model.fraction(h['onset']) for h in source['harmonies'])}) attacks = [] source_attacks = {generator.model.fraction(n['onset']) for n in generator.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 = generator.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 * generator.guitar.movement(self.previous, v) if self.previous else 0) + .1 * generator.guitar.compactness(v), generator.solo.policy.stable_rank(self.seed, (index, start, v)))) self.previous = selected patterns = [] for figure in generator.vocabulary.data('accompaniment')['comping']: events = tuple((start + fractions.Fraction(a), min(end, start + fractions.Fraction(a) + fractions.Fraction(d))) for a, d in figure['attacks'] if fractions.Fraction(a) < duration) # Space is available on held harmony, never an accidental empty winner. if not events and (not prior or not generator.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, generator.solo.policy.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': generator.harmony.symbol(chord), 'figure': label, 'tones': len(selected), 'bass': bass, 'fuller_voicing_reason': 'explicit written degrees/bass' if len(selected) > 3 else ''}) notes, cursor = [], fractions.Fraction() def emit(a, b, positions=(), chord=None): nonlocal cursor 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}_{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 = generator.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