Files
product-vamp/generator/solo/routes.py
T
khodowany 0e68f5364b Cleanup
2026-10-04 18:48:02 -05:00

100 lines
5.8 KiB
Python

"""Complete pitch paths for joint phrase proposals, with explicit structural motion."""
import dataclasses
from fractions import Fraction as F
from generator import vocabulary
from generator.solo import types, policy, sequences
class Routes:
def __init__(self,harmony,library):
self.harmony=harmony;self.library=library;self.pitch_cache={};self.cache={}
# Every short relative path is reachable by attack count and exact displacement.
self.index={}
self.steps=frozenset(s.steps[0] for s in library.paths.get(2,()))
for values in library.paths.values():
for value in values:self.index.setdefault((len(value.steps)+1,value.offsets()[-1]),[]).append(value)
def pitches(self,start,end):
key=(start,end)
if key not in self.pitch_cache:
span=self.harmony.at(start)
allowed=types.permitted(self.harmony.choices,start,end)
if end-start>=2:allowed &= self.harmony.members(start,end)| (span.roles.specified if span else frozenset())
self.pitch_cache[key]=tuple(types.scale_pitch(span.scale,i) for i in range(-28,29)
if types.scale_pitch(span.scale,i).number()%12 in allowed and self.harmony.profile.permits(types.scale_pitch(span.scale,i).number())) if span and span.scale else ()
return self.pitch_cache[key]
def realize(self,notes,fixed,purpose,direction,seed,center,incoming=None,hint=()):
"""Only completed paths leave this function; internal frontier cuts have no cadence."""
if not notes:return ()
# Identical musical work is cached without event/provenance IDs.
key=(tuple((n.start,n.end,n.pitch if i in fixed else None) for i,n in enumerate(notes)),tuple(sorted(fixed)),purpose,direction,seed,center,incoming.pitch if incoming else None,tuple((n.start,n.end,n.pitch) for n in hint))
if key in self.cache:
return tuple(tuple(dataclasses.replace(n,pitch=p) for n,p in zip(notes,path)) for path in self.cache[key])
width=vocabulary.data('solo')['search']['route_frontier'];states=[(0.,())];count=len(notes)
for i,n in enumerate(notes):
options=(n.pitch,) if i in fixed else self.pitches(n.start,n.end)
region=self.harmony.at(n.start)
pitch_order={p:policy.stable_rank(seed,('pitch',i,p)) for p in options}
following=[]
for cost,path in states:
prior=path[-1] if path else incoming.pitch if incoming else None
phase=i/max(1,count-1)
sign=1 if direction=='up' or direction=='arch' and phase<.5 or direction=='valley' and phase>=.5 else -1
target=next((notes[j].pitch for j in sorted(fixed) if j>i),None)
choices=[]
for pitch in options:
number=pitch.number();motion=number-prior.number() if prior else 0
if not self.harmony.profile.permits(number) or prior and abs(motion)>12:continue
if prior and i not in fixed:
try:
displacement=types.scale_index(region.scale,number)-types.scale_index(region.scale,prior.number())
if displacement and displacement not in self.steps:continue
except ValueError:
pass # Changed collection: retain the actual incoming pitch.
local=max(0,abs(number-center)-10)/12
if i not in fixed:
local+=max(0,abs(motion)-7)/3
if prior:
span=region
chordal=number%12 in span.roles.basic
# Arpeggios expand during the gesture; the last portion connects to its goal.
if purpose=='arpeggio' and phase<.65:local+=(0 if chordal else 1)+(.4 if abs(motion)<3 else 0)
elif purpose=='neighbor':local+=max(0,abs(motion)-2)/2
else:local+=max(0,abs(motion)-3)/3
desired=sign
if hint and 0<i<len(hint):
step=hint[i].pitch.number()-hint[i-1].pitch.number()
if purpose=='answer':step=-step
desired=(step>0)-(step<0)
if purpose in ('return','head') and ((motion>0)-(motion<0))!=desired:continue
local+=abs(abs(motion)-abs(step))/12
if purpose=='neighbor':desired=sign if i%2 else -sign
local+=.35 if motion*desired<0 else .2 if motion==0 else 0
if len(path)>=2 and path[-2]==prior==pitch:local+=1
if target and phase>.6:
remaining=max(1,next(j for j in sorted(fixed) if j>i)-i)
local+=max(0,abs(target.number()-number)-remaining*2)/3
choices.append((local,pitch))
# Preserve distinct register/motion alternatives, not merely two nearest notes.
for local,pitch in sorted(choices,key=lambda v:(v[0],pitch_order[v[1]]))[:width]:
following.append((cost+local,(*path,pitch)))
if not following:return ()
states=sequences.diverse(following,lambda v:(round(v[0],8),policy.stable_rank(seed,('path',v[1]))),
lambda v:((v[1][-1].number()-v[1][0].number()>0),v[1][-1].number()),width)
# Where applicable, add complete catalog routes as peers of the contextual route.
if 2<=count<=4 and fixed=={0,count-1}:
span=self.harmony.at(notes[0].start)
if span and span.scale and all(self.harmony.at(n.start).scale==span.scale for n in notes):
try:
origin=types.scale_index(span.scale,notes[0].pitch.number());displacement=types.scale_index(span.scale,notes[-1].pitch.number())-origin
for route in self.index.get((count,displacement),())[:32]:
path=tuple(types.scale_pitch(span.scale,origin+x) for x in route.offsets())
if all(p in self.pitches(n.start,n.end) for p,n in zip(path,notes)):states=(*states,(0.,path))
except ValueError:pass
paths=[]
for _,path in sorted(states,key=lambda v:(v[0],policy.stable_rank(seed,v[1]))):
if path not in paths:paths.append(path)
if len(paths)==2:break
self.cache[key]=tuple(paths)
return tuple(tuple(dataclasses.replace(n,pitch=p) for n,p in zip(notes,path)) for path in paths)