This commit is contained in:
khodowany
2026-10-04 18:48:02 -05:00
parent 68df7292ee
commit 0e68f5364b
58 changed files with 1416 additions and 7567 deletions
+95 -267
View File
@@ -1,271 +1,99 @@
"""Instantiate complete authored routes; a failed path is never repaired note by note."""
from __future__ import annotations
"""Complete pitch paths for joint phrase proposals, with explicit structural motion."""
import dataclasses
import fractions
from fractions import Fraction as F
from generator import vocabulary
from generator.solo import types, policy, sequences
import generator.fretboard
import generator.solo.placement
import generator.solo.policy
import generator.solo.sequences
import generator.solo.types as types
import generator.vocabulary
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 path_cost(notes):
"""Penalize unexplained extremes, not every third or arpeggio interval."""
intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(notes,notes[1:])]
excess=sum(max(0,abs(v)-7) for v in intervals)/max(1,len(intervals))
oscillation=sum(a!=0 and a==-b and a==c and b==d for a,b,c,d in zip(intervals,intervals[1:],intervals[2:],intervals[3:]))
return excess+.1*oscillation
def path_family(notes,harmony):
intervals=tuple(b.pitch.number()-a.pitch.number() for a,b in zip(notes,notes[1:]))
chordal=all(n.pitch.number()%12 in harmony.members(n.start,n.end) for n in notes)
return ('arpeggio' if chordal and any(abs(v)>=3 for v in intervals) else 'mapped',
generator.solo.sequences.family(intervals))
def advance(scale, previous, step):
try:
degree = types.scale_index(scale, previous)
return tuple([types.scale_pitch(scale, degree + step)])
except ValueError:
# At a changed collection count actual intervening scale tones from the
# incoming sound. No old degree number is transferred to the new scale.
if not step:
return ()
nearby = sorted((types.scale_pitch(scale, i) for i in range(-28, 35)), key=lambda p: p.number())
outgoing = [p for p in nearby if (p.number() - previous) * step > 0]
if step < 0:
outgoing.reverse()
return tuple(outgoing[abs(step) - 1:abs(step)])
def candidates(notes, fixed, harmony, library, register, relationship='connect theme', *, purpose='connect', expressions=()):
if expressions:
return planned_candidates(notes,fixed,harmony,register,relationship,expressions)
# Cache concrete routes only with their endpoints, timing, scale context and
# vocabulary. The per-Harmony cache cannot leak across collection changes.
if not hasattr(harmony,'route_cache'):
harmony.route_cache={}
harmony.route_libraries={}
harmony.concrete_routes={}
harmony.route_libraries[id(library)]=library
concrete_key=(notes,frozenset(fixed),id(library),register,harmony.profile,purpose)
if concrete_key in harmony.concrete_routes:
return tuple(dataclasses.replace(r,relationship=relationship) for r in harmony.concrete_routes[concrete_key])
# Event/source IDs and non-fixed placeholder pitches do not affect route
# feasibility. Cache the pitch work across different thematic histories,
# then restore this caller's provenance before ranking complete routes.
fixed_indices=frozenset(i for i,n in enumerate(notes) if n.event_id in fixed)
canonical=tuple(dataclasses.replace(n,sources=(),event_id=str(i),role='connection',position=None,
pitch=n.pitch if i==0 or i==len(notes)-1 or i in fixed_indices else types.Pitch('c',fractions.Fraction(),0)) for i,n in enumerate(notes))
key=(canonical,fixed_indices,id(library),register,harmony.profile,purpose)
if key not in harmony.route_cache:
harmony.route_cache[key]=_candidates(canonical,{str(i) for i in fixed_indices},harmony,library,register,purpose)
result=[]
for route in harmony.route_cache[key]:
actual=tuple(n if i==0 else dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==r.pitch.number() else r.pitch,position=None)
for i,(n,r) in enumerate(zip(notes,route.notes)))
identity=generator.solo.policy.stable_rank('complete-route',(actual,route.sources))[:16]
result.append(dataclasses.replace(route,id=identity,notes=actual,destination=notes[-1],relationship=relationship))
selected=generator.solo.sequences.diverse(result,
lambda r:(path_cost(r.notes),r.id),
lambda r:path_family(r.notes,harmony),
generator.solo.policy.DEFAULT.join_alternatives)
if len(harmony.concrete_routes)>=8192:harmony.concrete_routes.pop(next(iter(harmony.concrete_routes)))
if len(harmony.route_cache)>=8192:harmony.route_cache.pop(next(iter(harmony.route_cache)))
harmony.concrete_routes[concrete_key]=selected
return selected
def planned_candidates(notes,fixed,harmony,register,relationship,contracts):
# Cache complete contract-conditioned pitch work, not a next-note suggestion.
# IDs/provenance are restored below; timing, pitch, role, obligations and
# profile remain in the key. The cache is owned by this harmonic timeline.
ids={n.event_id:str(i) for i,n in enumerate(notes)}
canonical=tuple(dataclasses.replace(n,sources=(),event_id=str(i)) for i,n in enumerate(notes))
def note(n):return canonical[int(ids[n.event_id])]
obligations=tuple(dataclasses.replace(c,id=str(i),events=tuple(map(note,c.events)),
target=note(c.target),preparation=note(c.preparation) if c.preparation else None) for i,c in enumerate(contracts))
locked=frozenset(ids[i] for i in fixed)
key=(canonical,locked,register,harmony.profile,obligations)
if not hasattr(harmony,'planned_route_cache'):harmony.planned_route_cache={}
if key not in harmony.planned_route_cache:
harmony.planned_route_cache[key]=_planned_candidates(canonical,locked,harmony,register,'',obligations)
if len(harmony.planned_route_cache)>8192:harmony.planned_route_cache.pop(next(iter(harmony.planned_route_cache)))
result=[]
for route in harmony.planned_route_cache[key]:
actual=tuple(dataclasses.replace(original,pitch=realized.pitch,position=realized.position) for original,realized in zip(notes,route.notes))
result.append(dataclasses.replace(route,notes=actual,destination=actual[-1],relationship=relationship))
return tuple(result)
def _planned_candidates(notes,fixed,harmony,register,relationship,contracts):
"""Solve the whole route around explicit, preselected harmonic obligations.
Fixed expressive pitches have narrow permissions; all other attacks use the
map. Complete contracts are checked after realization, never inferred merely
from a label. No incomplete route is handed back to the next cell.
"""
from generator.solo import gestures
proposal=gestures.Proposal(notes,contracts,relationship)
locked={n.event_id for c in contracts for n in (*c.events,c.target)}|set(fixed)|{notes[0].event_id,notes[-1].event_id}
special={n.event_id for c in contracts for n in c.events}
states=[(0.,(notes[0],))]
for i,n in enumerate(notes[1:],1):
span=harmony.at(n.start)
if not span or not span.scale:return ()
allowed=types.permitted(harmony.choices,n.start,n.end)
if n.end-n.start>=2 and n.event_id not in special:allowed &= harmony.members(n.start,n.end)
target=next((a for a in notes[i:] if a.event_id in locked),notes[-1])
remaining=max(1,sum(a.start<target.start for a in notes[i:]))
following=[]
for cost,path in states:
prior=path[-1]
if n.event_id in locked:options=(n.pitch,)
else:
options=tuple(p for step in (-3,-2,-1,1,2,3) for p in advance(span.scale,prior.pitch.number(),step))
if prior.pitch.number()%12 in allowed:options=(*options,prior.pitch)
for pitch in options:
number=pitch.number()
if not harmony.profile.permits(number) or not register[0]<=number<=register[1]:continue
if n.event_id not in special and number%12 not in allowed:continue
motion=number-prior.pitch.number()
if abs(motion)>12:continue
# Continuation aims at the known future destination. Selected thirds
# and fourths remain feasible; repeated oscillation is not progress.
distance=abs(target.pitch.number()-number)
remaining_cost=max(0,distance-5*remaining)/12
repeating=len(path)>1 and path[-2].pitch==prior.pitch==pitch
value=cost+max(0,abs(motion)-7)/12+.2*repeating
following.append((value,(*path,dataclasses.replace(n,pitch=pitch,position=None)),remaining_cost))
chosen=generator.solo.sequences.diverse(following,lambda v:(v[0]+v[2],tuple(n.pitch.number() for n in v[1])),
lambda v:(generator.solo.sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(v[1],v[1][1:]))),v[1][-1].pitch.number()),8)
states=[(cost,path) for cost,path,_ in chosen]
if not states:return ()
result=[]
for _,actual in states:
bound=proposal.bind(actual)
if not harmony.evaluate(actual,until=actual[-1].end,complete=True,expressions=bound).valid:continue
identity=generator.solo.policy.stable_rank('planned-route',tuple((n.start,n.end,n.pitch) for n in actual))[:16]
result.append(types.RouteCandidate(identity,actual,actual[-1],(),relationship,tuple('gesture:'+c.rule for c in bound)))
return tuple(result)
def _candidates(notes, fixed, harmony, library, register, purpose='connect'):
"""Every returned candidate includes its entry, complete rhythm and destination.
Callers validate the whole surrounding sounding context and guitar path.
This function applies exact scale paths, fixed events and range constraints.
"""
if not 2 <= len(notes) <= 16:
return ()
first, destination = notes[0], notes[-1]
span = harmony.at(first.start)
if not span or not span.scale:
return ()
contexts=tuple((harmony.at(n.start).scale if harmony.at(n.start) else None,
types.permitted(harmony.choices,n.start,n.end) & (harmony.members(n.start,n.end) if n.end-n.start>=2 else frozenset(range(12))),
n.pitch.number() if n.event_id in fixed or i==len(notes)-1 else None) for i,n in enumerate(notes))
if not hasattr(harmony,'pitch_routes'):harmony.pitch_routes={}
key=(first.pitch,contexts,id(library),register,harmony.profile)
if key not in harmony.pitch_routes:
harmony.pitch_routes[key]=pitch_paths(first.pitch,contexts,library,register,harmony.profile)
if len(harmony.pitch_routes)>8192:harmony.pitch_routes.pop(next(iter(harmony.pitch_routes)))
route_spans=tuple((max(first.start,c.span.start),min(destination.end,c.span.end),c.scale.label())
for c in harmony.choices if c.scale and c.span.start<destination.end and c.span.end>first.start)
result=[]
paths=list(harmony.pitch_routes[key])
# These are traversal operators, not stored phrases. An arpeggio prefix
# adapts to the chord at each note and a complete scale route then connects
# it to this caller's actual destination. No endpoint is attacked twice.
for prefix,rule in chord_prefixes(notes,fixed,harmony,register,purpose):
i=len(prefix)-1
if i==len(notes)-1:
if prefix[-1].pitch.number()==destination.pitch.number():
paths.append((tuple(n.pitch for n in prefix),('chord-path:'+rule,)))
continue
suffix_key=(prefix[-1].pitch,contexts[i:],id(library),register,harmony.profile)
if suffix_key not in harmony.pitch_routes:
while len(harmony.pitch_routes)>=8192:
harmony.pitch_routes.pop(next(iter(harmony.pitch_routes)))
harmony.pitch_routes[suffix_key]=pitch_paths(prefix[-1].pitch,contexts[i:],library,register,harmony.profile)
for tail,sources in harmony.pitch_routes[suffix_key][:2]:
paths.append((tuple(n.pitch for n in prefix[:-1])+tail,('chord-path:'+rule,*sources)))
for pitches,sources in paths:
realized=(first,*(dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==p.number() else p,position=None)
for n,p in zip(notes[1:],pitches[1:])))
identity=generator.solo.policy.stable_rank('complete-route',(realized,sources))[:16]
result.append(types.RouteCandidate(identity,realized,destination,route_spans,'connect theme',sources))
return tuple(result)
def chord_prefixes(notes,fixed,harmony,register,purpose):
from generator.solo import phrasing
if len(notes)<2:return ()
values=[]
first=notes[0];destination=notes[-1]
for rule in generator.vocabulary.data('melodic_vocabulary')['chord_paths']:
if purpose not in rule['purpose']:continue
if rule['require_chord_entry'] and first.pitch.number()%12 not in harmony.members(first.start,first.end):continue
displacement=destination.pitch.number()-first.pitch.number()
direction=1 if rule['direction']=='up' else -1 if rule['direction']=='down' else (1 if displacement>=0 else -1)
for count in rule['prefix_attacks']:
if count>len(notes):continue
prefix=[first]
for i,n in enumerate(notes[1:count]):
span=harmony.at(n.start)
if not span or not span.scale:break
eligible=harmony.members(n.start,n.end)&types.permitted(harmony.choices,n.start,n.end)
if rule['members']=='defining':eligible=eligible&phrasing.defining_members(span.chord)
step=rule['member_steps'][i%len(rule['member_steps'])]
old=prefix[-1].pitch.number()
pitches=sorted((types.scale_pitch(span.scale,j) for j in range(-21,29)),key=lambda p:p.number(),reverse=direction*step<0)
options=[p for p in pitches if p.number()%12 in eligible and 0<(p.number()-old)*direction*step
and abs(p.number()-old)<=rule['max_interval'] and register[0]<=p.number()<=register[1] and harmony.profile.permits(p.number())]
if len(options)<abs(step):break
selected=options[abs(step)-1]
if (n.event_id in fixed or i+1==len(notes)-1) and selected.number()!=n.pitch.number():break
prefix.append(dataclasses.replace(n,pitch=selected))
if len(prefix)==count:values.append((tuple(prefix),rule['id']))
return tuple(values)
def pitch_paths(first, contexts, library, register, profile=None):
"""Time-independent pitch work; context includes every overlapped collection.
Sounding durations and chord roles are still validated on each concrete
realization. Equal collection contexts can reuse paths across rhythmic edits.
"""
from generator.solo.profiles import UNRESTRICTED
profile = profile or UNRESTRICTED
if not profile.permits(first.number()): return ()
destination=contexts[-1][2]
try:
displacement=types.scale_index(contexts[0][0],destination)-types.scale_index(contexts[0][0],first.number())
displacements=(displacement,)
except ValueError:
displacements=tuple(range(-7,8))
direction=(destination>first.number())-(destination<first.number())
paths=dict.fromkeys(path for d in displacements for path in library.retrieve(len(contexts),d,direction))
low,high=generator.fretboard.single_register((register[0]-7,register[1]+5))
low, high = max(low, profile.register[0]), min(high, profile.register[1])
result=[]
# Reattacks are an explicit primitive, separate from interval catalog paths
# (whose zero steps are deliberately excluded). Tied holds remain one event.
if all(first.number()%12 in allowed and (fixed is None or fixed==first.number())
for _,allowed,fixed in contexts):
result.append(((first,)*len(contexts),('primitive:repeated_attack',)))
for path in paths:
realized=[first]
for (scale,allowed,fixed),step in zip(contexts[1:],path.steps):
if not scale:break
options=advance(scale,realized[-1].number(),step)
if not options:break
pitch=options[0];number=pitch.number()
if not low<=number<=high or number%12 not in allowed:break
if fixed is not None and number!=fixed:break
if not generator.solo.placement.positions(number,profile):break
realized.append(pitch)
if len(realized)==len(contexts):result.append((tuple(realized),path.sources))
return tuple(result)
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)