Files
product-vamp/generator/solo/phrasing.py
T
khodowany 59afd78205 Soloing
2026-10-03 10:12:12 -05:00

220 lines
11 KiB
Python

"""Sustained destinations and bounded memory for the existing theme composer."""
from __future__ import annotations
import dataclasses
import functools
from fractions import Fraction as F
import generator.model
import generator.harmony
from generator.solo import types, policy, sequences
def heard(notes, operation, anchor=None):
notes = types.sounding_notes(notes)
first = notes[0].start
events = tuple((n.start-first, n.end-n.start, n.pitch.number()) for n in notes)
ending = (anchor.pitch.number()%12, anchor.earliest%4, anchor.duration) if anchor and anchor.role == 'terminal' else ()
return types.HeardFigure(first, notes[-1].end, events, operation, ending)
def remember(history, figure, bar):
return tuple(h for h in (*history, figure) if h.end > figure.end-policy.DEFAULT.history_bars*bar)
@functools.lru_cache(maxsize=4096)
def fingerprints(events, relative=False):
result = set()
for size in (3, 4, 6, 8):
for i in range(len(events)-size+1):
group = events[i:i+size]
start, _, pitch = group[0]
result.add(tuple((a-start, d, p-pitch if relative else p) for a,d,p in group))
return frozenset(result)
def repetition(figure, history, bar):
"""Bounded overuse, not a reward for arbitrary difference or extra attacks."""
exact, relative = fingerprints(figure.events), fingerprints(figure.events, True)
overuse = cadence = 0.
for old in history:
age = max(0., float((figure.start-old.end)/bar))
recent = max(0., 1-age/policy.DEFAULT.history_bars)
a,b = fingerprints(old.events), fingerprints(old.events, True)
exact_match = len(exact & a)/max(1,min(len(exact),len(a)))
shifted_match = len(relative & b)/max(1,min(len(relative),len(b)))
overuse += recent*(.65*exact_match+.35*shifted_match)
if figure.ending and figure.ending == old.ending:
cadence += recent
pitches = tuple(p for _,_,p in figure.events)
oscillation = sum(a==c and b==d for a,b,c,d in zip(pitches,pitches[1:],pitches[2:],pitches[3:]))/max(1,len(pitches)-3)
discount = .45 if figure.operation == 'return' else 1.
return min(1., discount*overuse/2) + .3*oscillation + .3*min(1.,cadence/2)
def register_goal(incoming, part, fallback):
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
if not recent:
return fallback
center = sum(recent)/len(recent)
if part == 'electric' and (center >= 67 or incoming.register_peak >= 72 and center >= 64):
return max(48, round(center)-7)
if part == 'acoustic' and max(recent)-min(recent) < 8:
home = sum(n.pitch.number() for n in incoming.home.notes)/len(incoming.home.notes) if incoming.home else center
return min(72, round(center)+5) if center <= home+7 else max(45, round(center)-5)
return round(center)
def endpoint_cost(finish, start, end, count, harmony, bar):
length = float((finish-start)/bar)
remaining = float((end-finish)/bar)
projected = count+1+remaining/(32/policy.DEFAULT.settling_target)
low, high = policy.DEFAULT.settling_range
pacing = .2*max(0,low-projected,projected-high)+.025*abs(projected-policy.DEFAULT.settling_target)
span = harmony.at(finish-F(1,100))
# A dominant may close an open question, but a nearby resolution is preferred.
degrees=generator.harmony.degrees(generator.harmony.parse_symbol(span.chord)) if span and span.scale else {}
dominant=degrees.get(3)==0 and degrees.get(7)==-1
short_remainder=.2*max(0,4-remaining) if remaining else 0.
return pacing+.035*abs(length-5)+.16*dominant+.1*(length==8)+short_remainder
def window_options(analysis, measures, start, end, harmony, incoming, *, limit=2):
from generator.solo.themes import source_notes
bounds=[F()]
for measure in measures:bounds.append(bounds[-1]+generator.model.fraction(measure['duration']))
index=next((i for i,b in enumerate(bounds[:-1]) if b<=start<bounds[i+1]),len(measures)-1)
bar=bounds[index+1]-bounds[index]
current=harmony.at(start)
if not current or not current.scale:
ends=(min(end,current.end if current else bounds[index+1]),)
else:
barrier=min((s.start for s in harmony.spans if not s.scale and s.start>start),default=end)
ends=tuple(sorted({min(end,barrier,bounds[min(index+n,len(measures))]) for n in (4,5,6,8)}))
if barrier>=end:
# The returning pickup may shorten the last measure. Do not strand a
# one-beat "new phrase" after an otherwise completed sustained close.
ends=tuple(sorted({end if 0<end-finish<bar else finish for finish in ends}))
melody=source_notes(analysis)
values=[]
for finish in ends:
if finish<=start:continue
notes=tuple(dataclasses.replace(n,start=max(start,n.start),end=min(finish,n.end),continuation=n.start<start)
for n in melody if n.start<finish and n.end>start)
values.append(types.SourcePhrase(f'theme_window_{start}_{finish}',start,finish,notes,(),''))
return tuple(sorted(values,key=lambda s:(endpoint_cost(s.end,start,end,incoming.closures,harmony,bar),s.end))[:limit])
def endings(source, incoming, part, harmony, bar, *, limit=8):
"""Choose pitch AND sounding interval before generating the approach."""
from generator.solo.leads import available
base=incoming.last.pitch.number() if incoming.last else source.notes[0].pitch.number() if source.notes else 55
goal=register_goal(incoming,part,base)
values=[]
duration=source.end-source.start
holds=(F(2),F(5,2),F(3),F(7,2),F(4)) if duration>=4 else (min(F(2),duration/2),)
for hold in holds:
for release in (F(),F(1,2),F(1),F(2)):
arrival=source.end-release-hold
if arrival-source.start<F(3,2):continue
for pitch in available(harmony,arrival,arrival+hold,members=True):
if abs(pitch.number()-goal)>12:continue
anchor=types.Anchor(f'{source.id}:close:{arrival}:{pitch.number()}',arrival,arrival,pitch,
harmony.members(arrival,arrival+hold),(41,76),hold,'terminal',harmony.at(arrival).chord,
hold_until=arrival+hold)
cadence=sum(h.ending==(pitch.number()%12,arrival%4,hold) for h in incoming.history)
rank=destination_cost(pitch,arrival,arrival+hold,harmony,incoming,goal,True)+.16*min(2,cadence)+.035*abs(float(hold)-3)+.015*float(release)
values.append((rank,anchor))
selected=destination_options(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])),
lambda x:x[1].pitch,lambda x:x[1].earliest,harmony,limit)
return tuple(a for _,a in selected)
def appearance_cost(operation, incoming, start, end):
expected=policy.DEFAULT.return_target*float(start/max(end,F(1)))
actual=len(incoming.returns)
if operation=='return':
gap=start-(incoming.returns[-1] if incoming.returns else F())
return .65*max(0,actual+1-expected)+.5*max(0,1-float(gap)/24)
return .45*max(0,expected-actual)
def activity(source, ending):
spans=[]
if ending.earliest>source.start:
spans.append(types.ActivitySpan(source.start,ending.earliest,'active',F(5,4),F()))
spans.append(types.ActivitySpan(ending.earliest,ending.hold_until,'sustained_arrival',F(),F(1)))
if ending.hold_until<source.end:
spans.append(types.ActivitySpan(ending.hold_until,source.end,'space',F(),F()))
return tuple(spans)
def evaluate(notes, incoming, harmony, start, end, bar):
"""Final musical cost uses a complete sounding phrase, never cell totals."""
notes=types.sounding_notes(notes)
checked=harmony.evaluate(notes,until=end,complete=True)
if not checked.valid or not notes:return float('inf')
density=4*len(notes)/float(end-start)
entry=max(0,abs(notes[0].pitch.number()-incoming.last.pitch.number())-5)/12 if incoming.last else 0.
leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(notes,notes[1:]))/max(1,len(notes)-1)
history_cost=repetition(heard(notes,'develop'),incoming.history,bar)
return 2*checked.exposure+entry+leaps+.35*max(0,4-density,density-6)+.8*history_cost
@functools.lru_cache(maxsize=512)
def defining_members(symbol):
"""Written chord identity, without inventing omitted thirds or sevenths."""
chord=generator.harmony.parse_symbol(symbol)
degrees=generator.harmony.degrees(chord)
root=generator.model.NATURAL[chord['root']['letter']]+generator.model.alteration(chord['root']['alter'])
selected={d for d in (3,6,7) if d in degrees}
if chord['kind'] in ('suspended-second','suspended-fourth'):
selected.update(d for d in (2,4) if d in degrees)
if degrees.get(5,0):selected.add(5)
return frozenset(int(root+generator.harmony.degree_semitones(d,degrees[d]))%12 for d in selected)
def destination_cost(pitch, start, end, harmony, incoming=None, goal=None, terminal=False):
"""Shared bounded function/trajectory cost at retrieval and final selection."""
span=harmony.at(start)
if not span or not span.scale:return float('inf')
number=pitch.number(); pc=number%12
if pc not in harmony.members(start,end):return float('inf')
tonic=generator.model.parse_pitch_name(span.scale.root)
tonic_pc=int(generator.model.NATURAL[tonic['letter']]+generator.model.alteration(tonic['alter']))%12
function=0. if pc in defining_members(span.chord) or terminal and pc==tonic_pc else .25
register=min(.15,abs(number-goal)/80) if goal is not None else 0.
entry=min(.15,max(0,abs(number-incoming.last.pitch.number())-7)/80) if incoming and incoming.last else 0.
previous=next((h.ending[0] for h in reversed(incoming.history) if h.ending),None) if incoming else None
recurrence=.04 if previous==pc else 0.
following=next((s for s in harmony.spans if s.start>=end and s.scale and s.roles.basic),None)
lookahead=0.
if following:
future=[p for p in range(max(41,number-12),min(76,number+12)+1) if p%12 in following.roles.basic and p%12 in following.scale.pitches]
if future:lookahead=.04*min(1,min(abs(p-number) for p in future)/5)
# Contour comes from the heard theme, not a universal upward/nearest rule.
direction=0.
if incoming and incoming.home and len(incoming.home.notes)>1 and incoming.last:
motion=incoming.home.notes[-1].pitch.number()-incoming.home.notes[0].pitch.number()
direction=.025 if (number-incoming.last.pitch.number())*motion<0 else 0.
return function+register+entry+recurrence+lookahead+direction
def destination_family(pitch, time, harmony):
return next((degrees for pc,degrees in harmony.at(time).roles.degrees if pc==pitch.number()%12),())
def destination_options(values, rank, pitch, time, harmony, limit):
"""Keep chord-degree representatives before register/timing variants."""
ordered=sorted(values,key=rank)
selected=[];seen=set()
for value in ordered:
family=destination_family(pitch(value),time(value),harmony)
if family not in seen:
selected.append(value);seen.add(family)
if len(selected)==limit:return tuple(selected)
for value in ordered:
if value not in selected:selected.append(value)
if len(selected)==limit:break
return tuple(selected)