@@ -33,6 +33,7 @@ def audit(source, documents):
|
||||
if part == 'vocal':
|
||||
continue
|
||||
sections = generator.arrange.form.full_arrangement_sections(authored, part)
|
||||
entry_positions = {s.source_measure_index for s in sections if s.role == 'accompaniment'}
|
||||
measures = document['staves'][0]['measures']
|
||||
carried = generator.accompaniment.carried_chords(measures)
|
||||
seen, exceptions, offset, reused = {}, [], 0, 0
|
||||
@@ -45,10 +46,14 @@ def audit(source, documents):
|
||||
if j == section.measure_count - 1 and following and following.role == 'melody' and generator.arrange.pickup.enters(following, authored):
|
||||
exceptions.append({'measure': m['number'], 'reason': 'protected melody pickup'})
|
||||
continue
|
||||
if j == section.measure_count - 1 and following and following.role == 'slash' and generator.score.body_start_measure_index(authored):
|
||||
exceptions.append({'measure': m['number'], 'reason': 'source-defined solo pickup allocation'})
|
||||
continue
|
||||
position = section.source_measure_index + j
|
||||
key = (section.source_measure_index + j, repr(harmonic_key(m, carried[offset + j])))
|
||||
signature = tuple((generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
|
||||
tuple((generator.model.pitch_number(p), p['tablature']['string'], p['tablature']['fret']) for p in n['pitches']))
|
||||
for n in generator.score.measure_notes(m))
|
||||
signature = tuple((v['id'], generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
|
||||
tuple((generator.model.pitch_number(p), generator.model.fraction(p['duration']), p['tablature']['string'], p['tablature']['fret'], bool(p.get('tie_from')) if position not in entry_positions or generator.model.fraction(n['onset']) else False) for p in n['pitches']))
|
||||
for v in m['voices'] for n in v['notes'] if n['pitches'])
|
||||
if key in seen:
|
||||
if signature != seen[key]:
|
||||
raise ValueError(f'{part}, arrangement measure {m["number"]}: canonical accompaniment changed')
|
||||
|
||||
+232
-86
@@ -1,11 +1,15 @@
|
||||
"""Source-only sparse accompaniment, frozen once at each form position."""
|
||||
"""Source-only accompaniment: joint layered rhythms and connected grips."""
|
||||
from __future__ import annotations
|
||||
import copy
|
||||
import dataclasses
|
||||
import functools
|
||||
import json
|
||||
import fractions
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.comping_rhythm
|
||||
import generator.acoustic_voicing
|
||||
import generator.arrange.form
|
||||
import generator.fretboard
|
||||
import generator.guitar
|
||||
import generator.harmony
|
||||
@@ -18,114 +22,256 @@ 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)
|
||||
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']))
|
||||
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)]
|
||||
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)
|
||||
|
||||
@functools.lru_cache(maxsize=2048)
|
||||
def voicings(key, part):
|
||||
chord = json.loads(key)
|
||||
required = requirements(chord, part)
|
||||
if part == 'electric':
|
||||
options = generator.guitar.chord_positions(required, prune=False, profile='electric')
|
||||
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:
|
||||
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()))
|
||||
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 three 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 local_cost(part, key, grip, center):
|
||||
required = requirements(json.loads(key), part)
|
||||
register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center)
|
||||
if part == 'electric':
|
||||
value = generator.guitar.voicing_cost(grip, 'electric', required.allowed)
|
||||
return (value.string_shortfall, value.effort, value.missing_tones, register)
|
||||
# As in acoustic harmony-span placement: travel first, then completeness.
|
||||
return (0, 0, len(set(required.allowed) - {p.midi % 12 for p in grip}), register)
|
||||
|
||||
def select_grips(layers, costs, part, seed, *, left=(), right=(), close=False):
|
||||
"""Exact small-state dynamic programming; never prune by hand center.
|
||||
|
||||
Electric effort includes its melody grip cost and hand-center travel.
|
||||
Acoustic effort follows all four voices, including a bass-string change.
|
||||
Fixing the first grip permits an exact repeat-join cost without a beam.
|
||||
"""
|
||||
if not layers:
|
||||
return (), (0, 0, 0, 0)
|
||||
ranks = [{v: generator.solo.policy.stable_rank(seed, (i, v)) for v in layer} for i, layer in enumerate(layers)]
|
||||
winners = []
|
||||
for first in layers[0] if close else [None]:
|
||||
states = {left: ((0, 0, 0, 0), (), ())}
|
||||
for i, layer in enumerate(layers):
|
||||
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 = tuple(a + b for a, b in zip(cost, local))
|
||||
total = (total[0], total[1] + movement(part, previous, grip), *total[2:])
|
||||
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[0], cost[1] + travel, *cost[2:]), ties, path))
|
||||
cost, _, path = min(winners)
|
||||
return path, cost
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class HarmonyChoice:
|
||||
index: int
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
chord: dict
|
||||
events: tuple
|
||||
figure: dict
|
||||
options: tuple
|
||||
@property
|
||||
def key(self):
|
||||
return (self.index, self.start, self.end, semantic_key(self.chord), self.events)
|
||||
|
||||
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 = {}, (), []
|
||||
self.cache, self._reports, self._rhythm_reports = {}, {}, {}
|
||||
measures = generator.score.source_staff(document)['measures']
|
||||
self.first_index = generator.score.body_start_measure_index(measures)
|
||||
self.last_index = len(measures) - 1
|
||||
self.melody = generator.comping_rhythm.melody_contexts(document)
|
||||
first_index = generator.score.body_start_measure_index(measures)
|
||||
sections = generator.arrange.form.full_arrangement_sections(measures, part)
|
||||
self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(measures)
|
||||
and b.source_measure_index == first_index for a, b in zip(sections, sections[1:]))
|
||||
carried = generator.accompaniment.carried_chords(measures)
|
||||
for index in range(generator.score.body_start_measure_index(measures), len(measures)):
|
||||
inputs = []
|
||||
for index in range(first_index, 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)
|
||||
for chord in source['harmonies']:
|
||||
chord.pop('chord_optional', None)
|
||||
inputs.append((source, carried[index], index))
|
||||
layers = tuple(generator.comping_rhythm.candidates(m, prior, part, self.melody[i], seed + '/' + str(i)) for m, prior, i in inputs)
|
||||
rhythms = generator.comping_rhythm.select(layers, seed)
|
||||
self.rhythms = {i: r for (_, _, i), r in zip(inputs, rhythms)}
|
||||
prepared = [(m, prior, i, self.prepare(m, prior, i, r)) for (m, prior, i), r in zip(inputs, rhythms)]
|
||||
active = tuple(s for _, _, _, spans in prepared for s in spans if s.events)
|
||||
path, self.path_cost = self.select(active, close=self.closed)
|
||||
self.base = dict(zip((s.key for s in active), path))
|
||||
self.order = tuple((s.index, s.start, v) for s, v in zip(active, path))
|
||||
previous = path[-1] if self.closed and path else ()
|
||||
for source, prior, index, spans in prepared:
|
||||
selected = tuple(self.base[s.key] for s in spans if s.events)
|
||||
self.cache[self.key(source, prior, index)] = self.render(source, prior, index, spans, selected, previous, False)
|
||||
if selected:
|
||||
previous = selected[-1]
|
||||
self.connect_holds(inputs)
|
||||
@property
|
||||
def report(self):
|
||||
return [row for key in sorted(self._reports, key=lambda k: (k[0], repr(k))) for row in self._reports[key]]
|
||||
@property
|
||||
def rhythm_report(self):
|
||||
return [self._rhythm_reports[key] for key in sorted(self._rhythm_reports, key=lambda k: (k[0], repr(k)))]
|
||||
@staticmethod
|
||||
def key(source, prior, index):
|
||||
return (index, tuple((a, b, semantic_key(c) if c else '') for a, b, c in generator.comping_rhythm.spans(source, prior)),
|
||||
source['duration'].__repr__())
|
||||
def prepare(self, source, prior, index, rhythm):
|
||||
spans = []
|
||||
for start, end, chord in generator.comping_rhythm.spans(source, 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()
|
||||
events = tuple(e for e in rhythm.events if start <= e.start < end)
|
||||
try:
|
||||
options = voicings(semantic_key(chord), self.part)
|
||||
except ValueError as error:
|
||||
raise ValueError(f'source measure {source["number"]}, beat {start + 1}: {error}') from error
|
||||
spans.append(HarmonyChoice(index, start, end, chord, events, dict(rhythm.families), options))
|
||||
self._rhythm_reports[self.key(source, prior, index)] = {
|
||||
'source_index': index, 'families': dict(rhythm.families), 'source_melody': [(str(a), str(b)) for a, b in self.melody[index]],
|
||||
'contributions': dict(rhythm.contributions), 'selection_cost': rhythm.cost, 'exceptions': rhythm.exceptions,
|
||||
'layers': {lane: {'attacks': sum(e.lane == lane for e in rhythm.events),
|
||||
'events': [{'onset': str(e.start), 'duration': str(e.end - e.start)} for e in rhythm.events if e.lane == lane], 'tied_continuations': 0} for lane, _ in rhythm.families},
|
||||
'overlap_beats': str(sum((max(0, min(a.end, b.end) - max(a.start, b.start)) for a in rhythm.events for b in rhythm.events if a.lane == 'bass' and b.lane == 'upper'), fractions.Fraction()))}
|
||||
return tuple(spans)
|
||||
def select(self, spans, *, left=(), right=(), close=False, fixed=None):
|
||||
layers = tuple((fixed[s.key],) if fixed and s.key in fixed else s.options for s in spans)
|
||||
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55)) 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)
|
||||
def __call__(self, source, prior, index):
|
||||
key = self.key(source, prior, index)
|
||||
if key not in self.cache:
|
||||
# Effective pickup/turnaround harmony may differ from the main form.
|
||||
# Solve that measure between fixed canonical neighbors. Request order
|
||||
# and previously generated arrangement sections cannot change it.
|
||||
choices = generator.comping_rhythm.candidates(source, prior, self.part, self.melody[index], self.seed + '/' + str(index))
|
||||
rhythm = generator.comping_rhythm.select((choices,), self.seed, self.rhythms.get(index - 1), self.rhythms.get(index + 1))[0]
|
||||
spans = self.prepare(source, prior, index, rhythm)
|
||||
before = [v for i, _, v in self.order if i < index]
|
||||
after = [v for i, _, v in self.order if i > index]
|
||||
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, fixed=self.base)
|
||||
self.cache[key] = self.render(source, prior, index, spans, selected, left, True)
|
||||
return copy.deepcopy(self.cache[key])
|
||||
def render(self, source, prior, index, spans, selected, previous, variant):
|
||||
length = generator.model.fraction(source['duration'])
|
||||
attacks, rows = [], []
|
||||
choices = iter(selected)
|
||||
for span in spans:
|
||||
grip = next(choices) if span.events else ()
|
||||
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': index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord),
|
||||
'figure': span.figure, 'tones': len(grip), 'bass': bool(bass), 'variant': variant,
|
||||
'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)})
|
||||
for event in span.events:
|
||||
positions = (grip[0],) if event.lane == 'bass' else grip[1:] if event.lane == 'upper' else grip
|
||||
attacks.append((event.lane, event.start, event.end, positions, span.chord))
|
||||
if grip:
|
||||
previous = grip
|
||||
self._reports[self.key(source, prior, index)] = rows
|
||||
voices = []
|
||||
notes, cursor, lane = [], 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:
|
||||
n = generator.model.make_event(f'comp_{self.part}_{index}_{lane}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written)
|
||||
n['voice'] = lane
|
||||
if self.part == 'acoustic':
|
||||
n['stem'] = 'down' if lane == 'bass' else 'up'
|
||||
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
|
||||
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)
|
||||
for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']:
|
||||
notes, cursor = [], fractions.Fraction()
|
||||
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)
|
||||
if cursor < length:
|
||||
emit(cursor, length)
|
||||
voices.append({'id': lane, 'notes': notes})
|
||||
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)
|
||||
result['voices'] = voices
|
||||
return result
|
||||
def connect_holds(self, inputs):
|
||||
"""Only explicit sustained neighbors can share an attack across a barline.
|
||||
|
||||
These are canonical internal joins, never links across role boundaries.
|
||||
The direct reattack remains when a grip or required attack count differs.
|
||||
"""
|
||||
for left, right in zip(inputs, inputs[1:]):
|
||||
before = self.cache[self.key(*left)]
|
||||
after = self.cache[self.key(*right)]
|
||||
previous_chord = generator.accompaniment.active_chord(left[0], generator.model.fraction(left[0]['duration']), left[1])
|
||||
next_chord = generator.accompaniment.active_chord(right[0], fractions.Fraction(), right[1])
|
||||
if not generator.accompaniment.same_chord(previous_chord, next_chord):
|
||||
continue
|
||||
for a, b in zip(before['voices'], after['voices']):
|
||||
if a['id'] == 'bass':
|
||||
continue
|
||||
old, new = a['notes'][-1], b['notes'][0]
|
||||
minimum = 1 if a['id'] == 'upper' else 0
|
||||
attacks = sum(bool(n['pitches']) and not any('tie_from' in p for p in n['pitches']) for n in b['notes'])
|
||||
if not old['pitches'] or not new['pitches'] or attacks <= minimum or min(generator.model.fraction(n['duration']) for n in (old, new)) < 1:
|
||||
continue
|
||||
if [p['tablature'] for p in old['pitches']] != [p['tablature'] for p in new['pitches']]:
|
||||
continue
|
||||
for p, q in zip(old['pitches'], new['pitches']):
|
||||
p['tie_to'], q['tie_from'] = q['id'], p['id']
|
||||
row = self._rhythm_reports[self.key(*right)]['layers'][a['id']]
|
||||
row['attacks'] -= 1
|
||||
row['tied_continuations'] += 1
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Bounded, source-only measure rhythms; acoustic layers are selected jointly."""
|
||||
import dataclasses
|
||||
import fractions
|
||||
import itertools
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.model
|
||||
import generator.solo.analysis
|
||||
import generator.solo.policy
|
||||
import generator.vocabulary
|
||||
|
||||
@dataclasses.dataclass(frozen=True, order=True)
|
||||
class Attack:
|
||||
lane: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Rhythm:
|
||||
events: tuple
|
||||
families: tuple
|
||||
contributions: tuple
|
||||
exceptions: tuple = ()
|
||||
@property
|
||||
def cost(self):
|
||||
return sum(value for _, value in self.contributions)
|
||||
@property
|
||||
def signature(self):
|
||||
return tuple((e.lane, str(e.start), str(e.end - e.start)) for e in self.events)
|
||||
|
||||
def melody_contexts(document):
|
||||
melody = generator.solo.analysis.attacks(document)
|
||||
result, cursor = [], fractions.Fraction()
|
||||
for m in document['staves'][0]['measures']:
|
||||
length = generator.model.fraction(m['duration'])
|
||||
result.append(tuple((n.start - cursor, n.end - cursor) for n in melody if n.start < cursor + length and n.end > cursor))
|
||||
cursor += length
|
||||
return tuple(result)
|
||||
|
||||
def spans(measure, prior):
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
boundaries = sorted({fractions.Fraction(), length, *(generator.model.fraction(h['onset']) for h in measure['harmonies'])})
|
||||
result = []
|
||||
for a, b in zip(boundaries, boundaries[1:]):
|
||||
chord = generator.accompaniment.active_chord(measure, a, prior)
|
||||
if result and generator.accompaniment.same_chord(result[-1][2], chord):
|
||||
result[-1] = (result[-1][0], b, chord)
|
||||
else:
|
||||
result.append((a, b, chord))
|
||||
return tuple(result)
|
||||
|
||||
def lane_score(events, lane, melody, harmony, length):
|
||||
"""Named source-context preferences, independent of the deterministic seed."""
|
||||
attacks = [a for a, b in melody if a >= 0]
|
||||
occupied = sum((max(0, min(length, b) - max(0, a)) for a, b in melody), fractions.Fraction())
|
||||
space = max(0, length - occupied)
|
||||
busy = len(attacks) >= length
|
||||
if lane == 'bass':
|
||||
target = min(2, sum(c is not None and b - a >= 1 for a, b, c in harmony))
|
||||
elif lane == 'upper':
|
||||
target = 2.5 if space >= length / 2 else 1.5 if busy else 2
|
||||
else:
|
||||
target = .5 if busy else 1.5 if space >= length / 2 else 1
|
||||
# A half-attack tolerance makes neighboring densities genuine alternatives.
|
||||
# The objective does not always fill up to a layer's permitted maximum.
|
||||
activity = .35 * max(0, abs(len(events) - target) - .5)
|
||||
support, articulation = 0., 0.
|
||||
for e in events:
|
||||
# Supporting an entrance is different from doubling every moving note.
|
||||
entrance = any(a == e.start and (i == 0 and a >= 0 or i > 0 and a - melody[i - 1][1] >= fractions.Fraction(1, 2)) for i, (a, _) in enumerate(melody))
|
||||
moving = any(a <= e.start < b and b - a <= 1 for a, b in melody)
|
||||
gap = not any(a <= e.start < b for a, b in melody)
|
||||
if lane != 'bass':
|
||||
support += -.2 if entrance else .25 if moving and e.start in attacks else -.15 if gap else 0
|
||||
articulation += .12 if busy and e.end - e.start > 1 else .08 if not busy and e.end - e.start < 1 else 0
|
||||
elif e.start and e.start.denominator != 1:
|
||||
support += .15
|
||||
divisor = max(1, len(events))
|
||||
return (('activity', activity), ('melody_support', support / divisor), ('duration_contrast', articulation / divisor))
|
||||
|
||||
def representatives(candidates, limit, seed):
|
||||
"""Keep counts, placements and duration families before the final cutoff."""
|
||||
unique = {}
|
||||
for candidate in candidates:
|
||||
key = candidate.signature
|
||||
rank = (candidate.cost, generator.solo.policy.stable_rank(seed, key))
|
||||
if key not in unique or rank < unique[key][0]:
|
||||
unique[key] = rank, candidate
|
||||
ordered = [v for _, v in sorted(unique.values(), key=lambda item: item[0])]
|
||||
groups = {}
|
||||
shapes = set()
|
||||
for c in ordered:
|
||||
counts = tuple((lane, sum(e.lane == lane for e in c.events)) for lane, _ in c.families)
|
||||
shape = (counts, c.families, any(e.start.denominator != 1 for e in c.events))
|
||||
# Round-robin attack counts first. Otherwise many cosmetic variants of
|
||||
# the cheapest density can exclude every one- or three-attack idea.
|
||||
if shape not in shapes:
|
||||
groups.setdefault(counts, []).append(c)
|
||||
shapes.add(shape)
|
||||
result = []
|
||||
while groups and len(result) < limit:
|
||||
for key in list(groups):
|
||||
result.append(groups[key].pop(0))
|
||||
if not groups[key]:
|
||||
del groups[key]
|
||||
if len(result) == limit:
|
||||
break
|
||||
return tuple(result)
|
||||
|
||||
def lane_candidates(measure, prior, lane, melody, policy, seed):
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
harmony = spans(measure, prior)
|
||||
active = [(a, b, c) for a, b, c in harmony if c]
|
||||
if not active:
|
||||
return Rhythm((), ((lane, 'space'),), (('activity', 0.),)),
|
||||
low, high = policy['layers'][lane]['attacks']
|
||||
# Genuinely different harmony gets a gesture, not its own density budget.
|
||||
required = [(a, b) for a, b, c in active if b - a >= (2 if lane == 'bass' else 1)]
|
||||
if lane == 'bass' and active[0][0] == 0 and not any(a == 0 for a, _ in required):
|
||||
required.insert(0, active[0][:2])
|
||||
if lane == 'electric' and len(active) == 1 and generator.accompaniment.same_chord(active[0][2], prior):
|
||||
required = []
|
||||
# With very fast harmonic rhythm, establish the edges and longest spans.
|
||||
# Short intervening chords may be silent; no incompatible sustain is kept.
|
||||
if len(required) > high + 1:
|
||||
edges = [required[0], required[-1]]
|
||||
required = sorted(edges + sorted(required[1:-1], key=lambda ab: (-(ab[1] - ab[0]), ab[0]))[:high - 1])
|
||||
extra = int(len(required) > high)
|
||||
grid = fractions.Fraction(policy['grid'])
|
||||
positions = {i * grid for i in range(int(length / grid) + 1) if i * grid < length}
|
||||
positions.update(a for a, _, _ in active)
|
||||
positions = sorted(t for t in positions if any(a <= t < b for a, b, _ in active))
|
||||
result = []
|
||||
for count in range(low, high + extra + 1):
|
||||
for onsets in itertools.combinations(positions, count):
|
||||
if lane == 'bass' and active[0][0] == 0 and (not onsets or onsets[0] != 0):
|
||||
continue
|
||||
if any(not any(a <= t < b for t in onsets) for a, b in required):
|
||||
continue
|
||||
if any(b - a < 1 for a, b in zip(onsets, onsets[1:])):
|
||||
continue
|
||||
for family in policy['layers'][lane]['families']:
|
||||
durations = policy['families'][family]
|
||||
events = []
|
||||
for i, a in enumerate(onsets):
|
||||
end = next(b for start, b, c in active if start <= a < b)
|
||||
following = onsets[i + 1] if i + 1 < len(onsets) else length
|
||||
duration = fractions.Fraction(durations[i % len(durations)])
|
||||
events.append(Attack(lane, a, min(a + duration, following, end)))
|
||||
exceptions = ((lane, 'extra attack for harmonic changes'),) if count > high else ()
|
||||
contributions = (*lane_score(events, lane, melody, harmony, length), ('extra_attacks', .5 * bool(exceptions)))
|
||||
result.append(Rhythm(tuple(events), ((lane, family if count else 'space'),), contributions, exceptions))
|
||||
if not result:
|
||||
# A short partial bar can have fewer gestures, not invented subdivisions.
|
||||
if length < 1:
|
||||
a, b, _ = active[0]
|
||||
return Rhythm((Attack(lane, a, b),), ((lane, 'partial'),), (('activity', 0.),)),
|
||||
raise ValueError(f'measure {measure["number"]}: no {lane} rhythm within the bounded attack/spacing policy')
|
||||
return representatives(result, policy['lane_candidates'], seed + '/' + lane)
|
||||
|
||||
def crowding(events):
|
||||
times = sorted({e.start for e in events})
|
||||
cost = .22 * sum(y - x < 1 for x, y in zip(times, times[1:]))
|
||||
# Three successive half-beat changes are a busy figure even though each
|
||||
# layer is sparse. Coincident attacks count as one hand action.
|
||||
return cost + .5 * sum(z - x <= 1 for x, y, z in zip(times, times[1:], times[2:]))
|
||||
|
||||
def candidates(measure, prior, part, melody, seed):
|
||||
policy = generator.vocabulary.data('accompaniment')
|
||||
if part == 'electric':
|
||||
return lane_candidates(measure, prior, 'electric', melody, policy, seed)
|
||||
bass = lane_candidates(measure, prior, 'bass', melody, policy, seed)
|
||||
upper = lane_candidates(measure, prior, 'upper', melody, policy, seed)
|
||||
result = []
|
||||
for a, b in itertools.product(bass, upper):
|
||||
costs = tuple((lane + '_' + k, v) for lane, c in (('bass', a), ('upper', b)) for k, v in c.contributions)
|
||||
result.append(Rhythm(tuple(sorted((*a.events, *b.events))), (*a.families, *b.families), (*costs, ('combined_crowding', crowding((*a.events, *b.events)))), (*a.exceptions, *b.exceptions)))
|
||||
return representatives(result, policy['measure_candidates'], seed)
|
||||
|
||||
def transition(previous, current):
|
||||
if previous is None:
|
||||
return 0.
|
||||
# Recurrence is allowed; penalize repeating an entire bar more than sharing
|
||||
# a duration family. This stays separate from the source-context objective.
|
||||
same_counts = all(sum(e.lane == lane for e in previous.events) == sum(e.lane == lane for e in current.events) for lane, _ in current.families)
|
||||
return .45 * (previous.signature == current.signature) + .12 * (previous.families == current.families) + .08 * same_counts
|
||||
|
||||
def select(layers, seed, left=None, right=None):
|
||||
"""One state per concrete bar rhythm; compare complete equal-span paths."""
|
||||
states = [(0., (), (), left)]
|
||||
for i, layer in enumerate(layers):
|
||||
following = []
|
||||
for c in layer:
|
||||
options = [(cost + c.cost + transition(previous, c), (*ties, generator.solo.policy.stable_rank(seed, (i, c.signature))), (*path, c), c) for cost, ties, path, previous in states]
|
||||
following.append(min(options, key=lambda item: item[:2]))
|
||||
states = following
|
||||
return min(states, key=lambda item: (item[0] + (transition(item[3], right) if right else 0), item[1]))[2]
|
||||
@@ -66,6 +66,7 @@ def validate_document(document: dict) -> None:
|
||||
if not staff.get('tablature'):
|
||||
continue
|
||||
for measure in staff['measures']:
|
||||
sounding = []
|
||||
for voice in measure['voices']:
|
||||
for note in voice['notes']:
|
||||
tones = []
|
||||
@@ -73,8 +74,15 @@ def validate_document(document: dict) -> None:
|
||||
tab = pitch['tablature']
|
||||
position = TabPosition(tab['string'], tab['fret'], dict(STANDARD_TUNING)[tab['string']] + tab['fret'])
|
||||
tones.append((position, generator.model.fraction(pitch['duration'])))
|
||||
start = generator.model.fraction(note['onset'])
|
||||
sounding.append((start, start + generator.model.fraction(pitch['duration']), position))
|
||||
if not playable_attack(tones):
|
||||
raise ValueError(f'{measure["id"]}, event {note["id"]}: single notes require frets {MIN_FRET}-{MAX_SINGLE_FRET}; each sounding chord requires an anchor in {MIN_FRET}-{MAX_ANCHOR_FRET} and at most {MAX_FRET_SPAN} consecutive fret positions, including held/tied tails')
|
||||
if len(measure['voices']) > 1:
|
||||
for time in sorted({t for a, b, _ in sounding for t in (a, b)}):
|
||||
held = [(p, b - time) for a, b, p in sounding if a <= time < b]
|
||||
if not playable_attack(held):
|
||||
raise ValueError(f'{measure["id"]}, beat {time + 1}: overlapping guitar voices conflict in string, fret span or sounding anchor')
|
||||
|
||||
def validate_tuning(state: dict, location: str) -> None:
|
||||
details = state['staff_details']
|
||||
|
||||
+27
-6
@@ -25,6 +25,7 @@ import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.score
|
||||
import generator.vocabulary
|
||||
import generator.arrange.accompaniment
|
||||
import generator.arrange.form
|
||||
import generator.arrange.generate
|
||||
import generator.arrange.pickup
|
||||
@@ -62,6 +63,12 @@ class SourceAnalysis:
|
||||
return cls(json.dumps(prepared, sort_keys=True), key)
|
||||
def document(self):
|
||||
return json.loads(self.document_json)
|
||||
def accompaniment_key(self):
|
||||
document = self.document()
|
||||
document['metadata'].pop('tempo', None)
|
||||
for measure in generator.score.source_staff(document)['measures']:
|
||||
measure['tempos'] = []
|
||||
return SourceAnalysis.read(document).musical_key
|
||||
|
||||
def windows(document, part):
|
||||
source = generator.score.source_staff(document)['measures']
|
||||
@@ -102,7 +109,9 @@ def _code_key(role):
|
||||
paths += list((root / 'solo').glob('*.py'))
|
||||
paths += [root / 'harmonic_time.py', root / 'musicxml.py', root / 'training_io.py']
|
||||
elif role.endswith('_accompaniment'):
|
||||
paths += [root / n for n in ('comping.py', 'accompaniment.py', 'guitar.py')]
|
||||
paths += [root / n for n in ('comping.py', 'comping_rhythm.py', 'accompaniment.py', 'guitar.py')]
|
||||
if role == 'acoustic_accompaniment':
|
||||
paths += [root / 'acoustic_voicing.py', root / 'acoustic.py']
|
||||
elif role == 'electric_melody':
|
||||
paths += [root / 'guitar.py', root / 'melody.py']
|
||||
return generator.vocabulary.fingerprint([(p.relative_to(root).as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted(set(paths))])
|
||||
@@ -199,7 +208,8 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
part = role.split('_')[0]
|
||||
trace = {'source': source.musical_key, 'role': role, 'production_resources': inputs['catalogs'],
|
||||
'vocabulary': generator.vocabulary.fingerprint(inputs['vocabulary']), 'settings': settings, 'algorithm': algorithm, 'code': inputs['code'], 'cache_key': key}
|
||||
seed = generator.vocabulary.fingerprint({'source': source.musical_key, 'role': role, 'settings': settings, 'data': inputs['vocabulary'], 'version': algorithm})
|
||||
choice_source = source.accompaniment_key() if role.endswith('_accompaniment') else source.musical_key
|
||||
seed = generator.vocabulary.fingerprint({'source': choice_source, 'role': role, 'settings': settings, 'data': inputs['vocabulary'], 'version': algorithm})
|
||||
details = {}
|
||||
solo_report = ''
|
||||
composition_timings = {}
|
||||
@@ -237,7 +247,7 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
elif role.endswith('_accompaniment'):
|
||||
canonical = generator.comping.Canonical(document, part, seed)
|
||||
result = generator.arrange.generate._arrange_document(document, part, {}, owned={'accompaniment'}, accompaniment=canonical)
|
||||
details = {'canonical_comping': canonical.report}
|
||||
details = {'canonical_comping': canonical.report, 'accompaniment_rhythm': canonical.rhythm_report, 'rhythm_source': choice_source}
|
||||
else:
|
||||
result = generator.arrange.generate._arrange_document(document, part, {}, owned={'vocal' if part == 'vocal' else 'melody'})
|
||||
allowed = tuple((a, b) for a, b, r in windows(document, part) if r == role)
|
||||
@@ -282,16 +292,24 @@ def assemble(source, results):
|
||||
staff[field].extend(copy.deepcopy(other['staves'][0].get(field, [])))
|
||||
for i, m in enumerate(staff['measures']):
|
||||
# Tuplet rests belong to the source/role rhythm and must survive union.
|
||||
sounding = [copy.deepcopy(n) for d in selected for n in generator.score.measure_notes(d['staves'][0]['measures'][i]) if n['pitches'] or n.get('tuplet_ids')]
|
||||
m['voices'] = [{'id': 'voice_arranged', 'notes': _fill(sounding, generator.model.fraction(m['duration']), m['id'])}]
|
||||
lanes = {}
|
||||
for d in selected:
|
||||
for voice in d['staves'][0]['measures'][i]['voices']:
|
||||
lane = voice['id'] if part == 'acoustic' and voice['id'] in ('upper', 'bass') else 'voice_arranged'
|
||||
sounding = [copy.deepcopy(n) for n in voice['notes'] if n['pitches'] or n.get('tuplet_ids')]
|
||||
if sounding:
|
||||
lanes.setdefault(lane, []).extend(sounding)
|
||||
lanes = lanes or {'voice_arranged': []}
|
||||
m['voices'] = [{'id': lane, 'notes': _fill(notes, generator.model.fraction(m['duration']), m['id'] + '_' + lane, lane)} for lane, notes in lanes.items()]
|
||||
# Attribute changes belong to source melody pickups and are assembly metadata.
|
||||
for d in selected:
|
||||
other = d['staves'][0]['measures'][i]
|
||||
if other.get('attributes'):
|
||||
m['attributes'] = copy.deepcopy(other['attributes'])
|
||||
staff['tuplets'] = generator.arrange.pickup.retain_links(staff['measures'], staff['tuplets'])
|
||||
owners = {n['id']: v['id'] for m in staff['measures'] for v in m['voices'] for n in v['notes']}
|
||||
for span in staff['tuplets']:
|
||||
span['voice'] = 'voice_arranged'
|
||||
span['voice'] = owners[span['start']]
|
||||
generator.model.validate_document(document)
|
||||
generator.fretboard.validate_document(document)
|
||||
documents[part] = document
|
||||
@@ -336,6 +354,7 @@ def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=Fa
|
||||
raise ValueError(f'{path.name}/{role}: {error}') from error
|
||||
for path, (original, source) in sources.items():
|
||||
assembled = assemble(source, results[path])
|
||||
reuse = generator.arrange.accompaniment.audit(original, assembled)
|
||||
for part, document in assembled.items():
|
||||
document['metadata'] = copy.deepcopy(original['metadata'])
|
||||
document['metadata']['song_id'] = generator.score.slugify(path.stem)
|
||||
@@ -345,5 +364,7 @@ def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=Fa
|
||||
(analysis_dir / (generator.score.slugify(path.stem) + '.txt')).write_text('\n'.join(results[path][r]['report'] for r in ROLES))
|
||||
# Not a score file: diagnostics are deliberately outside the songs schema.
|
||||
stable = {role: {k: v for k, v in value.items() if k not in ('seconds', 'composition_timings', 'cache_hit', 'observed_reads')} for role, value in results[path].items()}
|
||||
for part, audit in reuse.items():
|
||||
stable[part + '_accompaniment']['details']['reuse_audit'] = audit
|
||||
(analysis_dir / (generator.score.slugify(path.stem) + '.roles')).write_text(json.dumps(stable, sort_keys=True, indent=2))
|
||||
return len(sources) * 3
|
||||
|
||||
+13
-1
@@ -23,8 +23,20 @@ def _load(contents):
|
||||
result = {}
|
||||
for name, text in contents:
|
||||
value = json.loads(text)
|
||||
if value.get('version') != 1 or not value.get('provenance') or not value.get('license'):
|
||||
if value.get('version') != (2 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'):
|
||||
raise ValueError(f'{name}: unsupported or unprovenanced production vocabulary')
|
||||
if name == 'accompaniment':
|
||||
if fractions.Fraction(value['grid']) != fractions.Fraction(1, 2):
|
||||
raise ValueError('accompaniment requires an eighth-note placement grid')
|
||||
for lane in ('electric', 'bass', 'upper'):
|
||||
layer = value['layers'][lane]
|
||||
if not 0 <= layer['attacks'][0] <= layer['attacks'][1] <= 3 or not layer['families']:
|
||||
raise ValueError('invalid accompaniment attack range or duration families')
|
||||
for family in layer['families']:
|
||||
if not value['families'][family] or any(fractions.Fraction(d) <= 0 for d in value['families'][family]):
|
||||
raise ValueError('accompaniment durations must be positive')
|
||||
if not 1 <= value['lane_candidates'] <= 64 or not 1 <= value['measure_candidates'] <= 64:
|
||||
raise ValueError('accompaniment candidate budgets must be 1–64')
|
||||
entries = value.get('cells', value.get('patterns', value.get('colors', value.get('transformations', []))))
|
||||
if len({v['id'] for v in entries}) != len(entries):
|
||||
raise ValueError(f'{name}: duplicate vocabulary ID')
|
||||
|
||||
Reference in New Issue
Block a user