This commit is contained in:
khodowany
2026-10-04 22:29:06 -05:00
parent 0e68f5364b
commit ef62af892b
40 changed files with 1754 additions and 789 deletions
+130 -217
View File
@@ -1,230 +1,143 @@
"""Construct complete phrases directly from harmonic/rhythmic purposes.
"""Bounded search of joint musical actions; one normal production path.
The head is optional material, never a compulsory prefix or rhythmic template.
No reference score, completed part, or previous published output enters here.
Selection compares complete actions by cost per quarter. Actions are indivisible;
cross-bar paths and resolution contracts never restart at search boundaries.
"""
import dataclasses
import time
from __future__ import annotations
from collections import Counter
import dataclasses
from fractions import Fraction as F
from generator import vocabulary, harmony as chords, model
from generator.solo import types, expressions, groups, routes, rhythm, scoring, placement, policy
import time
from generator import vocabulary
from generator.solo import types, groups, operators, selection, sequences, scoring
class SearchExhausted(ValueError):pass
def choose(values,seed,domain):
return min(values,key=lambda v:policy.stable_rank(seed,(domain,v)))
@dataclasses.dataclass(frozen=True)
class State:
total: F = F()
recent: tuple = ()
home: tuple = ()
last_kind: str = ''
parent: object = None
action: object = None
evaluation: object = None
tie: tuple = ()
pending: tuple = ()
alternatives: tuple = ()
class Builder:
def __init__(self,analysis,measures,harmony,sequences,rhythms,end,seed,enabled=True):
self.analysis=analysis;self.harmony=harmony;self.end=end;self.seed=seed;self.enabled=enabled
self.rules=vocabulary.data('solo');self.budgets=self.rules['search'];self.routes=routes.Routes(harmony,sequences)
self.rhythms=rhythm.Rhythms(rhythms,analysis.tempo);self.windows=[];cursor=F()
for m in measures:
length=model.fraction(m['duration']);self.windows.append((cursor,cursor+length,m['time_signature']));cursor+=length
self.coverage={a:Counter() for a in self.rules['actions']};self.failures=Counter();self.cache={};self.decisions=[];self.timings=Counter()
def __init__(self,analysis,measures,harmony,sequences,rhythms,end,seed,enabled=True,*,start=F()):
self.analysis=analysis;self.harmony=harmony;self.start=start;self.end=end
self.operators=operators.Operators(harmony,analysis,end,start,enabled,sequences,rhythms)
self.selector=selection.Selector(harmony,analysis,harmony.profile.reference or 60)
self.rules=vocabulary.data('solo')['operators'];self.decisions=[];self.failures=Counter();self.coverage={};self.timings=Counter()
def at(self,time):return next((m for m in self.windows if m[0]<=time<m[1]),self.windows[-1])
def candidates(self,at,state):
candidates=self.operators.at(at)
if state.home and state.last_kind!='return':
candidates+=self.operators.related(at,state.home,'return')+self.operators.related(at,state.home,'answer')
evaluated=[]
for a in candidates:
if state.pending and self.advance(state,None,a) is None:continue
cost=self.selector.cost(a,state.recent,state.home,state.last_kind)
evaluated.append((cost,a))
# Rank complete actions by cost per quarter before bounded repair.
# Accumulated prefix totals are diagnostic, not a global search objective.
retained=sequences.diverse(evaluated,
lambda v:(v[0],v[1].key),
lambda v:(v[1].kind,v[1].end-v[1].start),self.rules['per_state'])
for _,a in retained:self.operators.coverage['retained:'+a.kind]+=1
return tuple((self.selector.evaluate(a,state.recent,state.home,state.last_kind),a) for _,a in retained)
def actions(self,history):
result=['arpeggio','scalar','neighbor']
if history:
result+=['develop','answer']
if len(history)>1 and history[-1].purpose!='return':result.append('return')
if self.analysis.melody and len(history)>2:result.append('head')
preferred=vocabulary.role().get('preferred_actions',[])
return sorted(result,key=lambda a:(a not in preferred,preferred.index(a) if a in preferred else 0,a))
def propose(self,start,history,variant,batch=0):
incoming=next((p.notes[-1] for p in reversed(history) if p.notes),None)
home=next((p for p in history if len(p.notes)>=4),None)
seed=policy.stable_rank(self.seed,('phrase',start,tuple(p.id for p in history[-2:]),variant,batch))
purpose=self.actions(history)[variant%len(self.actions(history))]
self.coverage[purpose]['proposed']+=1
_,_,meter=self.at(start);bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
span=self.harmony.at(start)
if not span or not span.scale:return None
limit=min(self.end,start+self.rules['max_phrase_bars']*bar)
# Source harmony boundaries and available time supply opportunities, not mandatory closes.
opportunities=sorted({min(self.end,start+2*bar),*(s.start for s in self.harmony.spans if start+2<=s.start<limit),*(s.end for s in self.harmony.spans if start+2<=s.end<limit),self.end if self.end<=limit else limit})
boundary=choose(opportunities,seed,'destination time')
ending=choose(('open','settle','complete'),seed,'ending')
if boundary==self.end:ending='handoff'
elif history and history[-1].ending=='open':ending=choose(('settle','complete'),seed,'complete continuation')
offset=choose(tuple(map(F,self.rules['arrival_offsets'])) if self.enabled else (F(),F(1,2)),seed,'arrival')
arrival=boundary+offset
if ending=='open':arrival=min(boundary,self.end)-F(1,2)
if ending=='handoff':arrival=self.end-choose((F(2),F(5,2),F(3)),seed,'handoff hold')
arrival=max(start+(F(1) if ending=='handoff' else F(3,2)),min(arrival,self.end-F(1,2)))
if arrival<=start:return None
anticipated=arrival<boundary and ending!='open' and any(s.start==boundary for s in self.harmony.spans) and boundary-arrival<=F(vocabulary.data('harmony')['anticipation_max'])
harmonic_time=boundary if anticipated else arrival
target_span=self.harmony.at(harmonic_time)
if not target_span or not target_span.scale:return None
hold=F(1,2) if ending=='open' else choose(tuple(map(F,self.rules['holds'])),seed,'hold')
if ending in ('complete','handoff'):hold=max(F(2),hold)
release=min(self.end,arrival+hold)
if anticipated and release<=boundary:return None
if ending=='handoff':release=self.end
breath=F() if ending=='open' else choose(tuple(map(F,self.rules['breaths'])),seed,'breath')
finish=min(self.end,release+breath)
if 0<self.end-finish<3:return None
if finish==self.end and ending!='open':
if release-arrival<min(F(2),self.end-start-1):return None
ending='handoff'
center=self.harmony.profile.reference or 60
# A coherent excursion and return; no per-note octave folding or string quota.
shift=choose(tuple(vocabulary.role().get('register_alternatives',(-7,0,7))),seed,'register')
if incoming and incoming.pitch.number()>center+7:shift=min(0,shift)
if incoming and incoming.pitch.number()<center-7:shift=max(0,shift)
goal=center+shift
permitted=types.permitted(self.harmony.choices,harmonic_time,release)
members=self.harmony.members(harmonic_time,release)
targets=[types.scale_pitch(target_span.scale,i) for i in range(-21,22)]
targets=[p for p in targets if p.number()%12 in permitted and (ending=='open' or p.number()%12 in members) and self.harmony.profile.permits(p.number()) and abs(p.number()-goal)<=7]
if not targets:return None
# Choose among actual chord degrees and registers, without always picking the nearest fifth/root.
target_pitch=choose(targets,seed,'destination pitch')
target=types.Note(arrival,release,target_pitch,role='arrival' if ending!='open' else 'motion',event_id=seed[:16]+':target')
contracts=[]
if anticipated:contracts.append(types.ExpressionContract(seed[:16]+':anticipation','anticipation','early_chord_tone',(target,),target,target_span.chord,boundary=boundary))
lead=();movement_end=arrival
# Shape-changing expression is planned before either rhythm or route is realized.
treatment=choose(('direct','approach','color','blues','suspension'),seed,'expression') if self.enabled and ending!='open' else 'direct'
if treatment=='approach':
rule=choose(vocabulary.data('lead_ins')['patterns'],seed,'approach rule')
movement_end=arrival-F(rule['gap'])-sum(map(F,rule['durations']),F());cursor=movement_end;events=[]
for i,(step,duration) in enumerate(zip(rule['semitones'],rule['durations'])):
events.append(types.Note(cursor,cursor+F(duration),expressions.chromatic(target_pitch,step),role=rule['kind'],event_id=seed[:16]+':approach:'+str(i)));cursor+=F(duration)
lead=tuple(events)
contracts.append(types.ExpressionContract(seed[:16]+':approach',rule['kind'],rule['id'],lead,target,target_span.chord,boundary=boundary if anticipated else None))
elif treatment=='color' and not anticipated:
region=self.harmony.at(arrival-1)
eligible=[r for r in vocabulary.data('harmony')['colors'] if region and expressions.eligible_color(r,region,self.harmony)]
if eligible:
rule=choose(eligible,seed,'color rule');p=expressions.degree_pitch(chords.parse_symbol(region.chord),rule['degree'],rule['alter'],target_pitch.number())
if not rule.get('resolve') and choose((True,False),seed,'sustained color'):
target=dataclasses.replace(target,pitch=p,role='color');target_pitch=p
contracts.append(types.ExpressionContract(seed[:16]+':color','color',rule['id'],(target,),target,target_span.chord))
else:
color=types.Note(arrival-1,arrival,p,role='color',event_id=seed[:16]+':color')
if rule.get('resolve') and not 0<abs(target_pitch.number()-p.number())<=2:return None
lead=(color,);movement_end=color.start
contracts.append(types.ExpressionContract(seed[:16]+':color','color',rule['id'],lead,target if rule.get('resolve') else color,target_span.chord if rule.get('resolve') else region.chord))
elif treatment=='blues' and not anticipated:
eligible=[r for r in vocabulary.data('lead_ins')['blues'] if expressions.degree_pitch(chords.parse_symbol(target_span.chord),r['target_degree'],0,target_pitch.number())==target_pitch]
if eligible:
rule=choose(eligible,seed,'blues rule');p=expressions.degree_pitch(chords.parse_symbol(target_span.chord),rule['degree'],rule['alter'],target_pitch.number())
lead=(types.Note(arrival-F(1,2),arrival,p,role='blues',event_id=seed[:16]+':blues'),);movement_end=lead[0].start
contracts.append(types.ExpressionContract(seed[:16]+':blues','blues',rule['id'],lead,target,target_span.chord))
elif treatment=='suspension' and offset>0 and arrival==boundary+offset:
previous=self.harmony.at(boundary-F(1,2));rule=vocabulary.data('harmony')['suspensions'][0]
if previous and previous.scale:
candidates=[p for p in self.routes.pitches(boundary-F(1,2),arrival) if p.number()%12 in previous.roles.basic and target_pitch.number()-p.number() in rule['steps']]
if candidates:
p=choose(candidates,seed,'suspension pitch');lead=(types.Note(boundary-F(1,2),arrival,p,role='suspension',event_id=seed[:16]+':suspension'),);movement_end=lead[0].start
contracts.append(types.ExpressionContract(seed[:16]+':suspension','suspension',rule['id'],lead,target,target_span.chord,boundary=boundary))
entrance=F() if history and history[-1].ending=='open' else choose(tuple(map(F,self.rules['entrances'])),seed,'entrance')
entry=start+entrance
if movement_end-entry<F(1):return None
hint=home.notes[:4] if home else ()
if purpose=='head':
candidates=[self.analysis.melody[i:i+4] for i in range(max(0,len(self.analysis.melody)-3))]
candidates=[ns for ns in candidates if ns[-1].end-ns[0].start<=movement_end-entry]
if not candidates:return None
hint=choose(candidates,seed,'head reference')
rhythm_purpose=purpose if purpose in ('arpeggio','scalar','neighbor','develop','answer') else 'develop'
rhythm_began=time.monotonic()
timing,rhythm_groups,sources=self.rhythms.realize(entry,movement_end,rhythm_purpose,seed,meter)
self.timings['rhythm_seconds']+=time.monotonic()-rhythm_began
if not timing:return None
if purpose in ('return','head'):
if len(hint)<3:return None
duration=hint[-1].end-hint[0].start
if entry+duration>movement_end:return None
pattern=tuple((entry+n.start-hint[0].start,entry+n.end-hint[0].start) for n in hint)
tail,tail_groups,tail_sources=self.rhythms.realize(entry+duration,movement_end,'develop',seed,meter)
timing=(*pattern,*tail)
if not groups.valid(tuple((a-entry,b-entry) for a,b in timing)):return None
rhythm_groups=(*groups.describe(tuple((a-entry,b-entry) for a,b in pattern),origin=entry,source='heard_theme' if purpose=='return' else 'head'),*tail_groups)
sources=(purpose,*tail_sources)
self.coverage[purpose]['rhythm_valid']+=1
first_span=self.harmony.at(entry)
starting=[p for p in self.routes.pitches(*timing[0]) if p.number()%12 in first_span.roles.basic and abs(p.number()-(incoming.pitch.number() if incoming else goal))<=7]
if not starting:return None
first=choose(starting,seed,'entry pitch')
notes=tuple(types.Note(a,b,first,role=purpose,event_id=seed[:16]+':'+str(i)) for i,(a,b) in enumerate(timing))
all_notes=(*notes,*lead,target)
fixed={0,*range(len(notes),len(all_notes))}
direction=choose(self.rules['directions'],seed,'contour')
route_began=time.monotonic()
paths=self.routes.realize(all_notes,fixed,purpose,direction,seed,goal,incoming,hint)
self.timings['route_seconds']+=time.monotonic()-route_began
self.coverage[purpose]['complete_routes']+=len(paths)
for actual in paths:
bound=[]
for c in contracts:
prior=next((n for n in reversed(actual) if n.end<=c.events[0].start),None)
bound.append(dataclasses.replace(c,preparation=prior if c.kind in ('connection','blues') else c.preparation))
validation_began=time.monotonic()
checked=self.harmony.evaluate(actual,until=finish,complete=True,expressions=tuple(bound))
self.timings['harmonic_seconds']+=time.monotonic()-validation_began
if not checked.valid:self.failures[checked.error.split(':')[-1]]+=1;continue
anchor=None if ending=='open' else types.Anchor(seed[:16]+':destination',arrival,arrival,target_pitch,members|frozenset({target_pitch.number()%12}),self.harmony.profile.register,release-arrival,'terminal' if ending in ('complete','handoff') else 'intermediate',target_span.chord,False,release,ending)
phrase=types.Phrase(seed[:16],start,finish,actual,purpose,direction,ending,anchor,tuple(bound),rhythm_groups,home.id if home else seed[:16],sources=sources)
evaluation_began=time.monotonic()
evaluation=scoring.evaluate(phrase,incoming,history,center)
self.timings['evaluation_seconds']+=time.monotonic()-evaluation_began
self.coverage[purpose]['valid']+=1
return dataclasses.replace(phrase,score=evaluation.total,evaluation=evaluation)
return None
def candidates(self,start,history):
key=(start,len(history),next(((p.id,p.notes) for p in history if len(p.notes)>=4),None),tuple((p.id,p.purpose,p.ending,p.notes,p.expressions) for p in history[-4:]))
if key in self.cache:return self.cache[key]
span=self.harmony.at(start)
if not span or not span.scale:
finish=min(self.end,span.end if span else self.end)
result=(types.Phrase('space:'+str(start),start,finish,(),'space','none','open'),)
self.cache[key]=result;return result
for action in self.actions(history):self.coverage[action]['eligible_contexts']+=1
proposals=[]
for batch in range(self.budgets['replans']+1):
for variant in range(self.budgets['proposals']):
p=self.propose(start,history,variant,batch)
if p:proposals.append(p)
if proposals:break
if not proposals:raise SearchExhausted(f'at beat {start}: no complete phrase within deterministic budget; rejections {self.failures.most_common(3)}; not proof of harmonic incompatibility')
from generator.solo.sequences import diverse
finals=diverse(proposals,lambda p:(p.score,policy.stable_rank(self.seed,p.id)),lambda p:(p.purpose,p.ending,p.direction),self.budgets['finalists'])
placed=[];incoming=next((p.notes[-1] for p in reversed(history) if p.notes),None)
for p in finals:
self.coverage[p.purpose]['finalists']+=1
placement_began=time.monotonic()
options=placement.alternatives(p.notes,self.analysis.tempo,incoming,self.budgets['fingerings'],self.harmony.profile)
self.timings['placement_seconds']+=time.monotonic()-placement_began
if options:
# Physical economy selects a fingering of this same melody, not the tune itself.
placed.append(dataclasses.replace(p,notes=options[0][1]));self.coverage[p.purpose]['placed']+=1
self.cache[key]=tuple(placed)
return self.cache[key]
def advance(self,state,evaluation,action):
for contract in state.pending:
target=contract.target
if action.start<=target.start<action.end:
# Reject a missed committed attack/hold before revalidating all the
# harmonic relationships. Partial target evidence remains possible.
if not any(n.event_id==target.event_id and n.start==target.start
and n.end==min(action.end,target.end) and n.pitch==target.pitch for n in action.notes):return None
obligations=(*state.pending,*action.contracts);pending=()
if not state.pending:
pending=tuple(c for c in action.contracts if c.target.end>action.end)
elif obligations:
heard={n.event_id:n for c in state.pending for n in c.events if n.start<action.start}
heard.update({n.event_id:n for n in action.notes})
check=self.harmony.evaluate(tuple(sorted(heard.values(),key=lambda n:n.start)),
expressions=obligations,until=action.end,complete=action.end==self.end)
if not check.valid:return None
pending=check.pending
recent=(*state.recent,*action.notes)[-8:];home=state.home
if not home and len(recent)>=4:
proposed=recent[:4]
if proposed[-1].end-proposed[0].start<=8:home=proposed
return State(state.total+(evaluation.total if evaluation else 0),recent,home,action.kind,state,action,evaluation,(),pending)
def compose(self):
history=[];start=F()
while start<self.end:
options=self.candidates(start,tuple(history));ranked=[]
for p in options[:self.budgets['frontier']]:
future=()
if p.end<self.end:
try:future=self.candidates(p.end,(*history,p))
except SearchExhausted:continue
if not future:continue
next_score=min((f.score for f in future),default=p.score)
total=(p.score+next_score)/2
ranked.append((total,policy.stable_rank(self.seed,p.id),p))
if p.purpose in self.coverage:self.coverage[p.purpose]['lookahead_valid']+=1
if not ranked:raise SearchExhausted(f'at beat {start}: no legal following phrase within lookahead budget')
_,_,chosen=min(ranked,key=lambda v:v[:2]);history.append(chosen);start=chosen.end
if chosen.purpose in self.coverage:self.coverage[chosen.purpose]['selected']+=1
self.decisions.append({'start':str(chosen.start),'selected':chosen.id,'candidates':[{'id':p.id,'purpose':p.purpose,'end':str(p.end),'evaluation':dataclasses.asdict(p.evaluation) if p.evaluation else {},'lookahead_total':next((v[0] for v in ranked if v[2].id==p.id),None)} for p in options]})
return tuple(history)
self.decisions=[];began=time.monotonic();backtracks=0
# Compare full actions by cost per exact quarter, with a bounded repair
# frontier. This is a local policy, not a claim of globally optimal melody.
stack=[(self.start,State(),None)];rankings={}
while stack:
at,state,iterator=stack[-1]
if at==self.end:final=state;break
if iterator is None:
options=sorted(self.candidates(at,state),key=lambda v:(v[0].total,v[1].key))
rankings[id(state)]=tuple({'kind':a.kind,'start':str(a.start),'end':str(a.end),
'notes':[(str(n.start),str(n.end),n.pitch.number()) for n in a.notes],
'evaluation':dataclasses.asdict(e)} for e,a in options[:4])
iterator=iter(options);stack[-1]=(at,state,iterator)
for evaluation,action in iterator:
next_state=self.advance(state,evaluation,action)
if next_state is not None:
next_state=dataclasses.replace(next_state,alternatives=rankings[id(state)])
stack.append((action.end,next_state,None));break
else:
stack.pop();backtracks+=1
if backtracks>self.rules['backtracks']:raise SearchExhausted(f'at {at}: action repair budget exhausted')
else:raise SearchExhausted('no complete action path')
self.timings['backtracks']=backtracks
self.timings['action_search_seconds']=time.monotonic()-began
path=[];state=final
while state.action:
path.append(state);state=state.parent
path.reverse();phrases=[];pending=[]
self.selected_actions=tuple(s.action for s in path)
for state in path:
action=state.action;pending.append(state)
self.operators.coverage['selected:'+action.kind]+=1
self.decisions.append({'start':str(action.start),'end':str(action.end),'selected':action.kind,
'evaluation':dataclasses.asdict(state.evaluation),'prefix_total':state.total,'alternatives':state.alternatives})
# Reporting groups the selected thought; it does not insert events.
held=bool(action.notes and action.notes[-1].end-action.notes[-1].start>=2)
breath=not action.notes and action.end-action.start>=1
if held or breath or action.end==self.end:
phrases.append(self.phrase(pending,len(phrases)));pending=[]
for name,count in self.operators.coverage.items():
stage,kind=name.split(':',1);self.coverage.setdefault(kind,{})[stage]=count
self.timings['selected_cost']=final.total
return tuple(phrases)
def phrase(self,states,index):
actions=tuple(s.action for s in states);ns=tuple(n for a in actions for n in a.notes)
start,end=actions[0].start,actions[-1].end
last=ns[-1] if ns else None
held=last and last.end-last.start>=2
ending='handoff' if end==self.end else 'settle' if held else 'open'
anchor=None
if held:
region=self.harmony.at(last.start)
anchor=types.Anchor(f'gesture_{index}:arrival',last.start,last.start,last.pitch,
frozenset({last.pitch.number()%12}),self.harmony.profile.register,last.end-last.start,
'terminal' if ending=='handoff' else 'intermediate',region.chord,False,last.end,ending)
raw=Counter();contributions=Counter();weights={}
for s in states:
raw.update(s.evaluation.raw);contributions.update(s.evaluation.contributions);weights.update(s.evaluation.weights)
evaluation=scoring.Evaluation(selection.VERSION,True,dict(raw),weights,dict(contributions),sum(s.evaluation.total for s in states),
{'span':str(end-start),'attacks':len(ns),'ticks_per_quarter':12})
direction='up' if len(ns)>1 and ns[-1].pitch.number()>ns[0].pitch.number() else 'down'
rhythm=tuple(g for a in actions for g in groups.describe(tuple((n.start-a.start,n.end-a.start) for n in a.notes),origin=a.start,source=a.kind))
return types.Phrase(f'gesture_{index}',start,end,ns,actions[0].kind,direction,ending,anchor,
tuple(c for a in actions for c in a.contracts),rhythm,'home',evaluation.total,evaluation,tuple(a.source or a.kind for a in actions))