Files
product-vamp/generator/comping_rhythm.py
T
khodowany df83cae38c
ci / checks (push) Successful in 49m53s
Accompaniment
2026-10-06 01:59:55 -05:00

198 lines
9.5 KiB
Python

"""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]