Solo
ci / checks (push) Failing after 23m43s

This commit is contained in:
khodowany
2026-10-02 06:33:01 -05:00
parent a0bd19b3ba
commit 5fff5a165e
13 changed files with 786 additions and 211 deletions
+108 -50
View File
@@ -10,7 +10,7 @@ import fractions
import generator.model
import generator.fretboard
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts, references
F = fractions.Fraction
@@ -160,49 +160,54 @@ def connection_patterns(start,end,entry,destination,rhythms,meter):
return result
def elaborate(notes, features, harmony, library, rhythms, meter, source, *, sustain_entry=False):
"""Edit an exposed held tail or a complete interior span, with both endpoints known."""
fixed={e for f in features for e in f.events}
def elaborate(notes, features, harmony, library, rhythms, meter, source, *, sustain_entry=False, tails=None):
"""Replace complete internal spans while retaining identity and sounding tails."""
legacy=tails is None
tails=tails or ()
fixed={e for f in features for e in f.events} if legacy else {n.event_id for n in notes if n.role=='quote'}
windows=[]
for i,(note,target) in enumerate(zip(notes,notes[1:])):
if note.event_id in fixed or note.end != target.start:
continue
if note.event_id in fixed or note.end!=target.start:continue
if note.end-note.start>=F(3,2):
start=max(note.start+F(1,2),target.start-3)
windows.append((i,start,target,False))
# A source-held ending can arrive later via a related in-scale turn.
if notes and notes[-1].event_id not in fixed and notes[-1].end-notes[-1].start>=F(5,2):
windows.append((i,i+1,start,target))
if not legacy:
for j in range(i+2,min(i+5,len(notes))):
target=notes[j]
if not 1<=target.start-note.start<=3:continue
if any(n.event_id in fixed for n in notes[i:j]):continue
if any(t.start<target.start and t.end>note.start for t in tails):continue
windows.append((i,j,note.start,target))
if legacy and notes and notes[-1].event_id not in fixed and notes[-1].end-notes[-1].start>=F(5,2):
tail=notes[-1]
for duration in (F(1,2),F(1),F(3,2),F(2)):
target=dataclasses.replace(tail,start=tail.end-duration,event_id=tail.event_id+':arrival')
start=max(tail.start+F(1,2),target.start-3)
if target.start-start>=1:
windows.append((len(notes)-1,start,target,True))
if target.start-start>=1:windows.append((len(notes)-1,len(notes)-1,start,target))
# Consequential edits only; do not exhaustively branch at each passing note.
windows=sequences.diverse(windows,lambda w:(policy.stable_rank(source.id,w),),lambda w:w[0],8)
result=[]
for i,start,target,terminal in windows:
entry=notes[i];end=target.start
for i,j,start,target in windows:
entry=notes[i]
prefix=dataclasses.replace(entry,end=start)
seed=dataclasses.replace(entry,start=start,event_id=entry.event_id+':tail',role='connection')
for pattern,rhythm_id in connection_patterns(start,end,seed,target,rhythms,meter):
for pattern,rhythm_id in connection_patterns(start,target.start,seed,target,rhythms,meter):
for route in routes.candidates(pattern,{target.event_id},harmony,library,(41,76),'develop '+source.id):
if sustain_entry:
# The route owns its complete sounding entry, including the old hold.
if sustain_entry or start==entry.start:
joined=dataclasses.replace(route.notes[0],start=entry.start,event_id=entry.event_id)
route=dataclasses.replace(route,notes=(joined,*route.notes[1:]))
actual=(*notes[:i],*route.notes,*notes[i+1 if terminal else i+2:])
actual=(*notes[:i],*route.notes,*notes[j+1:])
else:
actual=(*notes[:i],prefix,*route.notes,*notes[i+1 if terminal else i+2:])
if not harmony.evaluate(actual,complete=True).valid:
continue
if any(abs(b.pitch.number()-a.pitch.number())>7 for a,b in zip(route.notes,route.notes[1:])):
continue
actual=(*notes[:i],prefix,*route.notes,*notes[j+1:])
if any(references.feature_error(f,actual) for f in features):continue
if any(not any(n.pitch.number()==t.pitch and n.start<=t.start and n.end>=t.end for n in actual) for t in tails):continue
if not harmony.evaluate(actual,complete=True).valid:continue
if any(abs(b.pitch.number()-a.pitch.number())>7 for a,b in zip(route.notes,route.notes[1:])):continue
route=dataclasses.replace(route,sources=(*route.sources,'rhythm:'+rhythm_id))
edit=types.DraftEdit('complete_route',start,target.start,(entry.event_id,),tuple(n.event_id for n in route.notes),
source.id+'; '+rhythm_id+'; '+('later terminal arrival on ' if terminal else 'lead into ')+target.pitch.label())
edit=types.DraftEdit('complete_route',start,target.start,tuple(n.event_id for n in notes[i:j+1]),tuple(n.event_id for n in route.notes),
source.id+'; '+rhythm_id+'; lead into '+target.pitch.label())
result.append((actual,edit,route))
# Whole routes compete; bounded diversity retains different locations and rhythms.
return sequences.diverse(result,lambda v:(abs(len(v[0])*4/float(source.end-source.start)-5),
sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(v[2].notes,v[2].notes[1:])),v[2].id),
return sequences.diverse(result,lambda v:(max(0,4-len(v[0])*4/float(source.end-source.start),len(v[0])*4/float(source.end-source.start)-6),v[2].id),
lambda v:(v[1].start,v[2].destination.end-v[2].destination.start,len(v[2].notes)),8)
@@ -315,13 +320,81 @@ def transition(first,second):
return .2*leap+.15*bool(repeated_end)
def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'):
from generator.solo import realization, themes
if mode not in ('thematic','paraphrase'):raise ValueError('Unknown solo treatment: '+mode)
def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage):
from generator.solo import realization,themes,phrasing
context.resources[id(library)]=library
context.resources[id(rhythms)]=rhythms
harmony=harmonic.Harmony(choices)
sources=themes.windows(analysis,measures,end,harmony) if mode=='thematic' else clipped_sources(analysis,end)
seed=policy.stable_rank('source_variations',(analysis.melody,choices,policy.SEED_NAMESPACE))
states=[(0.,())]
failures=[]
for _ in range(2*len(measures)+1):
if all(history and history[-1].plan.end==end for _,history in states):break
following=[]
for cost,history in states:
start=history[-1].plan.end if history else F()
if start==end:
following.append((cost,history));continue
incoming=history[-1].state if history else types.ContinuationState('')
sources=phrasing.window_options(analysis,measures,start,end,harmony,incoming)
elapsed=F();meter=measures[0]['time_signature']
for measure in measures:
elapsed+=generator.model.fraction(measure['duration'])
if elapsed>start:
meter=measure['time_signature'];break
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
for source in sources:
key=('phrasing',source,incoming,part,end,stage,seed,id(library),id(rhythms),policy.DEFAULT)
if key in context.results:
pool=context.results[key];context.diagnostics['cache_hits']+=1
else:
pool=()
if not harmony.at(start) or not harmony.at(start).scale:
pool=themes.silence(source,incoming,part,harmony,analysis.tempo,seed)
else:
for batch in range(policy.DEFAULT.replan_attempts):
pool=themes.phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,analysis.tempo,context,seed,
stage=stage,batch=batch,final=source.end==end,solo_end=end)
if pool:break
context.diagnostics['theme_replans']=context.diagnostics.get('theme_replans',0)+1
context.results[key]=pool
context.diagnostics.setdefault('source_candidate_pools',{}).setdefault(source.id,[]).extend(
{'id':p.draft.id,'score':p.score,'attacks':len(p.draft.notes),'theme':p.state.home.id if p.state.home else None,
'ending':str(p.anchors[-1].duration) if p.anchors else None,'returns':len(p.state.returns)} for p in pool)
for candidate in pool:
span=float((source.end-source.start)/end)
total=cost+span*(candidate.score+phrasing.endpoint_cost(source.end,start,end,incoming.closures,harmony,bar))
total+=transition(history[-1] if history else None,candidate)
following.append((total,(*history,candidate)))
if not pool:failures.append(f'{source.id}: planned sustained destination, feature or incoming continuation')
if not following:
raise realization.SearchExhausted(f'{part}: {"; ".join(failures[-4:])} failed within the bounded search; not proof of incompatibility')
def rank(value):
cost,history=value;state=history[-1].state;elapsed=history[-1].plan.end
musical=cost/max(.001,float(elapsed/end))
if elapsed==end:
target=policy.DEFAULT.settling_target*float(end)/(32*4)
musical+=.1*max(0,abs(state.closures-target)-1)
musical+=.15*abs(len(state.returns)-policy.DEFAULT.return_target*float(end)/(32*4))
return musical,policy.stable_rank(seed,tuple(p.draft.id for p in history))
states=sequences.diverse(following,rank,
lambda v:(v[1][-1].state.home.id if v[1][-1].state.home else '',v[1][-1].plan.end,len(v[1][-1].state.returns)),policy.DEFAULT.chorus_frontier)
finished=[(cost,history) for cost,history in states if history[-1].plan.end==end]
if not finished:raise realization.SearchExhausted(f'{part}: phrase-boundary search budget exhausted; not proof of incompatibility')
return tuple((rank((cost,phrases))[0],types.Solo(part,choices,phrases,analysis.tempo,end,
{**context.diagnostics,'construction':'thematic_recomposition','policy':policy.describe()['fingerprint']},
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases))) for cost,phrases in finished)
def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'):
from generator.solo import realization
if mode not in ('thematic','paraphrase'):raise ValueError('Unknown solo treatment: '+mode)
if mode=='thematic':
return thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage)
context.resources[id(library)]=library
context.resources[id(rhythms)]=rhythms
harmony=harmonic.Harmony(choices)
sources=clipped_sources(analysis,end)
seed=policy.stable_rank('source_variations', (analysis.melody,choices,policy.SEED_NAMESPACE))
states=[(0.,())]
for source in sources:
@@ -329,30 +402,15 @@ def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stag
following=[]
for cost,history in states:
incoming=history[-1].state if history else types.ContinuationState('')
key=(mode,source,incoming if mode=='thematic' else None,policy.stable_rank("harmony",choices),part,analysis.tempo,seed,stage,id(library),id(rhythms),policy.DEFAULT)
key=(mode,source,None,policy.stable_rank("harmony",choices),part,analysis.tempo,seed,stage,id(library),id(rhythms),policy.DEFAULT)
if key in context.results:
pool=context.results[key];context.diagnostics['cache_hits']+=1
else:
if mode=='thematic' and any(c.scale and c.span.start<source.end and c.span.end>source.start for c in choices):
pool=()
for batch in range(2):
pool=themes.phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,analysis.tempo,context,seed,stage=stage,batch=batch,final=source.end==end)
if pool:break
context.diagnostics['theme_replans']=context.diagnostics.get('theme_replans',0)+1
elif mode=='thematic':
pool=themes.silence(source,incoming,part,harmony,analysis.tempo,seed)
else:
pool=candidates(source,harmony,library,rhythms,meter,part,analysis.tempo,seed,stage=stage)
pool=candidates(source,harmony,library,rhythms,meter,part,analysis.tempo,seed,stage=stage)
context.results[key]=pool;context.diagnostics['generated_candidates']+=len(pool)
context.diagnostics.setdefault('source_candidate_pools', {}).setdefault(source.id,[]).extend(
{'id':p.draft.id,'score':p.score,'octave':p.draft.octave_transfer,'attacks':len(p.draft.notes),'edits':[e.kind for e in p.edits],'source_family':source.family,'theme':p.state.home.id if p.state.home else None} for p in pool)
for candidate in pool:
# The thematic constructor has already placed all decisions from the
# actual incoming state; do not collapse its motif groups into one bar.
if mode=='thematic':
total=cost+candidate.score*float(source.end-source.start)/float(end)+transition(history[-1] if history else None,candidate)
following.append((total,(*history,candidate)))
continue
last=incoming.last
placements=placement.alternatives(tuple(n for d in candidate.decisions for n in d.notes),analysis.tempo,last,limit=2)
for travel,notes in placements:
@@ -364,8 +422,8 @@ def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stag
if not following:
raise realization.SearchExhausted(f'{part} {source.id}: {mode} feature, harmonic destination or continuation failed within the bounded search; not proof of incompatibility')
states=sequences.diverse(following,lambda v:(v[0],policy.stable_rank(seed,tuple((p.draft.id,p.state.last.position if p.state.last else None) for p in v[1]))),
lambda v:(v[1][-1].state.home.id if mode=='thematic' and v[1][-1].state.home else tuple(p.draft.octave_transfer for p in v[1])),policy.DEFAULT.chorus_frontier)
return tuple((cost,types.Solo(part,choices,phrases,analysis.tempo,end,{**context.diagnostics,'construction':'thematic_recomposition' if mode=='thematic' else 'source_phrase_variations','policy':policy.describe()['fingerprint']},
lambda v:(tuple(p.draft.octave_transfer for p in v[1])),policy.DEFAULT.chorus_frontier)
return tuple((cost,types.Solo(part,choices,phrases,analysis.tempo,end,{**context.diagnostics,'construction':'source_phrase_variations','policy':policy.describe()['fingerprint']},
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases))) for cost,phrases in states)