This commit is contained in:
khodowany
2026-10-04 15:51:18 -05:00
parent 405066eb43
commit f234dd6d51
54 changed files with 2104 additions and 330 deletions
+150 -167
View File
@@ -11,7 +11,7 @@ from fractions import Fraction as F
import generator.model
import generator.harmony
import generator.vocabulary
from generator.solo import types, policy, sequences, routes, features as references, similarity, phrasing, profiles
from generator.solo import types, policy, sequences, routes, features as references, similarity, phrasing, profiles, groups as rhythm_groups
def source_notes(analysis):
@@ -71,13 +71,16 @@ def seeds(analysis, bar, seed):
identity=policy.stable_rank(seed,(group,treatment))[:12]
groups.append(types.SourceReference(identity,parent,group[0].start,group[-1].end,local,treatment))
def rank(ref):
rhythm={(n.start,n.end-n.start) for n in ref.notes}
rhythm=set(rhythm_groups.signature(ref.notes))
intervals={b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:])}
preferred=generator.vocabulary.role().get('preferred_features',[])
preference=preferred.index(ref.treatment) if ref.treatment in preferred else 0
turn=any(a*b<0 for a,b in zip(ref.notes and tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:])),tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:]))[1:]))
return (-min(4,len(intervals))-.3*turn+.15*preference,-min(3,len(rhythm)),ref.id)
return sequences.diverse(groups,rank,lambda r:(r.treatment,int(max(0,r.start)//(4*bar))),policy.DEFAULT.theme_seed_limit)
# Preserve different contracts first. Combining treatment with form position
# created many categories of the preferred treatment and could crowd every
# rhythm/contour seed out before a single pitch was constructed.
return sequences.diverse(groups,rank,lambda r:r.treatment,policy.DEFAULT.theme_seed_limit)
def feature(reference, actual, *, treatment=None):
@@ -87,7 +90,7 @@ def feature(reference, actual, *, treatment=None):
'repeat_with_harmonic_adaptation':('rhythm','contour'),'answer':('contour',),
'extend':('contour',),'fragment':('rhythm',),'callback':('rhythm','pitches'),
'return':('rhythm','intervals'), 'develop_rhythm':('rhythm',),
'develop_contour':('intervals',), 'continue':('contour',)}[treatment]
'develop_contour':('intervals',), 'continue':(), 'connect':(), 'extension':('contour',)}[treatment]
original=reference.notes
authored=next((r for r in generator.vocabulary.data('grammar')['transformations'] if r['id']==treatment),None)
if authored:required=tuple(authored['preserve'])
@@ -103,7 +106,7 @@ def rhythmic_purpose(operation, terminal=False):
if terminal:return 'arrival'
if operation in ('introduce','callback','return','repeat_with_harmonic_adaptation'):return 'entrance'
if operation in ('answer','fragment'):return 'answer'
return 'connect' if operation=='continue' else 'continue'
return 'connect' if operation in ('continue','connect') else 'continue'
def rhythm_cost(pattern,length,purpose):
@@ -145,12 +148,12 @@ def rhythm_windows(library,meter,length,context,purpose='continue'):
for offset in sorted({F(),*(a for a,_ in pattern)}):
if offset+length>total or any(a<offset<b or a<offset+length<b for a,b in pattern):continue
selected=tuple((a-offset,b-offset) for a,b in pattern if offset<=a and b<=offset+length)
if selected and len(selected)<=16:patterns.setdefault(selected,identifier)
if selected and len(selected)<=16 and rhythm_groups.valid(selected):patterns.setdefault(selected,identifier)
# Authored cells can start late and continue over computational barlines.
for cell in generator.vocabulary.rhythm_cells(tempo,purpose):
identifier,pattern,total=cell.id,cell.pattern,cell.duration
if total>length:continue
for offset in (F(),F(1,2),max(F(),length-total)):
for offset in ((F(),) if cell.entry=='pickup' else (F(),F(1,2),max(F(),length-total))):
if offset+total>length:continue
head=tuple((a+offset,b+offset) for a,b in pattern)
# Keep representative compatible continuations; never let dictionary
@@ -159,8 +162,10 @@ def rhythm_windows(library,meter,length,context,purpose='continue'):
lambda p:(len(p),p[-1][1]-p[-1][0]),2) or ((),)
for tail in tails:
if any(a<offset+total<b for a,b in tail):continue
combined=(*head,*((a,b) for a,b in tail if a>=offset+total))
if 2<=len(combined)<=16:patterns.setdefault(combined,'authored:'+identifier)
continuation=tuple((a,b) for a,b in tail if a>=offset+total)
if cell.exit=='continue' and offset+total<length and (not continuation or continuation[0][0]!=offset+total):continue
combined=(*head,*continuation)
if 2<=len(combined)<=16 and rhythm_groups.valid(combined):patterns.setdefault(combined,'authored:'+identifier)
context.pools[key]=sequences.diverse(tuple(patterns.items()),
lambda x:(rhythm_cost(x[0],length,purpose)+(0 if x[1].startswith('authored:') else .05),policy.stable_rank('rhythm-window',x[0])),
lambda x:(x[1].startswith('authored:'),len(x[0]),x[0][0][0],any(a[1]!=b[0] for a,b in zip(x[0],x[0][1:]))),8)
@@ -193,7 +198,7 @@ def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operatio
if b-a==1:
values[(*original[:i],(a,a+F(1,2)),(a+F(1,2),b),*original[i+1:])]=label+'; diminution'
for i,((a,b),(c,d)) in enumerate(zip(original,original[1:])):
if b!=c:continue
if b!=c or any((v*2).denominator!=1 for v in (a,b,c,d)):continue
for split in (a+F(1,2),a+F(3,2),d-F(1,2)):
if a<split<d:
values[(*original[:i],(a,split),(split,d),*original[i+2:])]=label+'; rhythmic alteration'
@@ -201,10 +206,12 @@ def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operatio
arrivals={}
arrival=ending.earliest-start
for original,label in values.items():
head=tuple((a,min(b,arrival)) for a,b in original if a<arrival)
if len(head)>=len(ref.notes):
arrivals[(*head,(arrival,ending.hold_until-start))]=label+'; planned sustained close'
try: revised=rhythm_groups.broaden(original,arrival,ending.hold_until-start)
except ValueError: revised=()
if revised and len(revised)>=len(ref.notes):
arrivals[revised]=label+'; planned sounding destination'
values=arrivals
values={p:label for p,label in values.items() if rhythm_groups.valid(p) or fixed_rhythm and p==prefix}
return sequences.diverse(tuple(values.items()),lambda x:(rhythm_cost(x[0],length,purpose),policy.stable_rank(ref.id,x[0])),
lambda x:(len(x[0]),tuple(b-a for a,b in x[0][:3]),x[0][0][0]),policy.DEFAULT.theme_rhythm_alternatives)
@@ -233,61 +240,6 @@ def relative_steps(notes,harmony):
return tuple(result)
def instantiate(templates,first,steps,harmony):
result=[dataclasses.replace(templates[0],pitch=first)]
for note,step in zip(templates[1:],steps):
span=harmony.at(note.start)
if not span or not span.scale:return ()
options=routes.advance(span.scale,result[-1].pitch.number(),step)
if not options:return ()
p=options[0]
if not harmony.profile.permits(p.number()) or p.number()%12 not in types.permitted(harmony.choices,note.start,note.end):return ()
result.append(dataclasses.replace(note,pitch=p))
return tuple(result) if len(result)==len(templates) else ()
def prefix_paths(templates,ref,first,harmony,library):
if not hasattr(harmony,'theme_prefixes'):harmony.theme_prefixes={}
key=(templates[:len(ref.notes)],ref,first,id(library))
if key not in harmony.theme_prefixes:
harmony.theme_prefixes[key]=_prefix_paths(templates,ref,first,harmony,library)
return harmony.theme_prefixes[key]
def _prefix_paths(templates,ref,first,harmony,library):
count=len(ref.notes); original=ref.notes
if count==1:return ((dataclasses.replace(templates[0],pitch=first),),)
if ref.treatment in ('opening_fragment','characteristic_interval','callback'):
delta=first.number()-original[0].pitch.number()
if ref.treatment in ('opening_fragment','callback') and delta%12:return ()
result=[]
from generator.solo.leads import available
for template,n in zip(templates,original):
target=n.pitch.number()+delta
p=next((p for p in available(harmony,template.start,template.end,members=template.end-template.start>=2) if p.number()==target),None)
if p is None:return ()
result.append(dataclasses.replace(template,pitch=p))
return (tuple(result),)
paths=[]
if ref.treatment == 'return':
from generator.solo.leads import available
delta=first.number()-original[0].pitch.number()
direct=[]
for template,n in zip(templates,original):
p=next((p for p in available(harmony,template.start,template.end,members=template.end-template.start>=2) if p.number()==n.pitch.number()+delta),None)
if p is None: break
direct.append(dataclasses.replace(template,pitch=p))
if len(direct)==count: paths.append(tuple(direct))
end_template=templates[count-1]
source_motion=original[-1].pitch.number()-original[0].pitch.number()
for target in pitches(harmony,end_template,first.number()+source_motion,limit=4):
template=tuple(dataclasses.replace(n,pitch=first if i==0 else target) for i,n in enumerate(templates[:count]))
paths.extend(r.notes for r in routes.candidates(template,{template[-1].event_id},harmony,library,harmony.profile.register,'seed '+ref.id,purpose='entrance'))
valid=[p for p in paths if not references.feature_error(feature(ref,p),p)]
return sequences.diverse(valid,lambda ns:(routes.path_cost(ns),policy.stable_rank(ref.id,ns)),
lambda ns:routes.path_family(ns,harmony),4)
@dataclasses.dataclass(frozen=True)
class Unit:
notes: tuple[types.Note,...]
@@ -303,81 +255,86 @@ class Unit:
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None):
# History is evaluated by the caller after retrieving these concrete options.
# Every state component used to construct this pool is in its cache key.
if not hasattr(harmony,'fingerprint'):harmony.fingerprint=policy.stable_rank('harmony',(harmony.choices,harmony.profile))
key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,incoming.history,home,operation,ending,goal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT)
"""Joint whole-gesture pitch/rhythm actions, conditioned on the actual entry.
A complete route is proposed before thematic evidence is checked. No motif
prefix is spliced to an independently cheapest suffix. Ordinary search ends
carry no chord-tone obligation; long-note and destination contracts still do.
"""
from generator.solo import leads
key=('joint-gesture-1',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,
home,operation,ending,goal,batch,id(harmony),id(library),id(rhythms))
if key in context.results:
context.diagnostics['cache_hits']+=1;return context.results[key]
result=[]; count=len(ref.notes)
trace=context.diagnostics.setdefault('operations',{}).setdefault(operation,
dict(eligible=0,proposed=0,valid=0,retained=0,jobs=0,pruned_jobs=0,rejected_by_rule=0))
trace['eligible']+=1
require_feature=operation not in ('continue','connect')
count=len(ref.notes) if require_feature else 0
jobs=[]
for pattern,label in patterns(ref,start,finish,rhythms,meter,context,home=home,operation=operation,terminal=terminal,ending=ending):
if len(pattern)<count or len(pattern)>16:continue
templates=tuple(types.Note(start+a,start+b,ref.notes[0].pitch,tuple(s for n in ref.notes for s in n.sources),
'theme',event_id=f'{ref.id}:{start}:{i}') for i,(a,b) in enumerate(pattern))
base=incoming.last.pitch.number() if incoming.last else harmony.profile.reference if harmony.profile.reference is not None else ref.notes[0].pitch.number()
firsts=pitches(harmony,templates[0],base,limit=6 if batch else 3)
targets=(ending.pitch,) if ending else pitches(harmony,templates[-1],goal if goal is not None else base,members=True,limit=policy.DEFAULT.theme_destinations,incoming=incoming)
if len(pattern)<max(2,count) or len(pattern)>16:continue
provenance=tuple(dict.fromkeys(s for n in ref.notes for s in n.sources))
templates=tuple(types.Note(start+a,start+b,ref.notes[0].pitch,provenance,
'theme' if require_feature else 'connection',event_id=f'{ref.id}:{operation}:{start}:{i}') for i,(a,b) in enumerate(pattern))
base=incoming.last.pitch.number() if incoming.last else harmony.profile.reference or ref.notes[0].pitch.number()
firsts=pitches(harmony,templates[0],base,limit=6)
if incoming.last is None:
offsets=generator.vocabulary.role().get('register_alternatives',[-7,0,7])
firsts=tuple(dict.fromkeys(p for offset in offsets for p in pitches(harmony,templates[0],base+offset,limit=2)))
targets=(ending.pitch,) if ending else pitches(harmony,templates[-1],goal if goal is not None else base,
members=False,limit=policy.DEFAULT.theme_destinations,incoming=incoming)
for first in firsts:
for target in targets:
# Rank joint jobs, retaining rhythm and destination coverage before
# expensive path instantiation. The budget is spent on complete routes.
rank=harmony.profile.cost(first.number())+max(0,abs(first.number()-base)-5)/12+phrasing.destination_cost(target,templates[-1].start,templates[-1].end,harmony,incoming,goal if goal is not None else base,terminal)
motion=ref.notes[-1].pitch.number()-ref.notes[0].pitch.number()
if operation=='answer':motion=-motion
# Rank purposeful direction separately from entry continuity. No reward
# for minimizing every interval or for repeatedly visiting the midpoint.
rank=max(0,abs(first.number()-base)-7)/12
rank+=.1*min(1,abs((target.number()-first.number())-motion)/12)
jobs.append((rank,templates,label,first,target))
jobs.sort(key=lambda j:(j[0],policy.stable_rank(ref.id,(j[1],j[3],j[4]))))
selected=[];rhythm_seen=set();target_seen=set();entry_seen=set();degrees_seen=set()
for job in jobs:
family=phrasing.destination_family(job[4],job[1][-1].start,harmony)
if family not in degrees_seen:
selected.append(job);degrees_seen.add(family)
for job in jobs:
rhythm=tuple((n.start,n.end) for n in job[1]);target=job[4].number();entry=job[3].number()
if rhythm not in rhythm_seen or target not in target_seen or entry not in entry_seen:
if job not in selected:selected.append(job)
rhythm_seen.add(rhythm);target_seen.add(target);entry_seen.add(entry)
job_limit=policy.DEFAULT.theme_route_jobs*(batch+1)
for job in jobs:
if len(selected)>=job_limit:break
if job not in selected:selected.append(job)
for _,templates,label,first,target in selected[:job_limit]:
proposed=[]
if home and operation in ('repeat_with_harmonic_adaptation','return') and len(templates)==len(home.notes):
heard=instantiate(templates,first,home.degree_steps,harmony)
if heard and heard[-1].pitch==target:proposed.append((heard,()))
for prefix in prefix_paths(templates,ref,first,harmony,library)[:2+batch]:
if len(prefix)==len(templates):
if prefix[-1].pitch==target:proposed.append((prefix,()))
continue
tail=(prefix[-1],*templates[count:])
tail=(*tail[:-1],dataclasses.replace(tail[-1],pitch=target))
for route in routes.candidates(tail,{tail[-1].event_id},harmony,library,harmony.profile.register,operation+' '+ref.id,purpose=rhythmic_purpose(operation,terminal))[:3+batch]:
proposed.append(((*prefix[:-1],*route.notes),(route,)))
seen=set()
for actual,connections in proposed:
identity=tuple(n.pitch.number() for n in actual)
if identity in seen:continue
seen.add(identity)
core=actual[:count]; contract=feature(ref,core,treatment=operation if operation!='introduce' else None)
if references.feature_error(contract,actual):continue
last=actual[-1]
if last.pitch.number()%12 not in harmony.members(last.start,last.end):continue
checked=harmony.evaluate(actual,until=finish,complete=True)
if not checked.valid:continue
entry=max(0,abs(first.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(actual,actual[1:]))/max(1,len(actual)-1)
direction=min(1,abs(last.pitch.number()-goal)/12) if goal is not None else 0.
# Activity belongs to the complete phrase; contracts are pass/fail.
score=harmony.profile.cost(profiles.weighted_center(actual))+2*checked.exposure+entry+leaps+phrasing.destination_cost(last.pitch,last.start,last.end,harmony,incoming,goal,terminal)
anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),harmony.profile.register,
last.end-last.start,'intermediate',harmony.at(last.start).chord,hold_until=last.end)
score+=.25*rhythm_cost(tuple((n.start-start,n.end-start) for n in actual),finish-start,rhythmic_purpose(operation,terminal))
result.append(Unit(actual,contract,ref,operation,anchor,score,label,connections,start,finish))
selected=sequences.diverse(jobs,lambda j:(j[0],policy.stable_rank(ref.id,(j[1],j[3],j[4]))),
lambda j:(tuple(n.end-n.start for n in j[1]),j[3].number()//12,
(j[4].number()>j[3].number())-(j[4].number()<j[3].number())),policy.DEFAULT.theme_route_jobs*(batch+1))
trace['jobs']+=len(jobs);trace['pruned_jobs']+=len(jobs)-len(selected)
result=[]
for _,templates,label,first,target in selected:
timed=(dataclasses.replace(templates[0],pitch=first),*templates[1:-1],dataclasses.replace(templates[-1],pitch=target))
proposed=list(routes.candidates(timed,{timed[-1].event_id},harmony,library,harmony.profile.register,
operation+' '+ref.id,purpose=rhythmic_purpose(operation,terminal)))
# A substantial literal return is a musical action, not merely the hope
# that a general interval search will accidentally reproduce the theme.
if require_feature and len(templates)==len(ref.notes):
delta=first.number()-ref.notes[0].pitch.number()
actual=[]
for template,n in zip(templates,ref.notes):
options=leads.available(harmony,template.start,template.end,members=template.end-template.start>=2)
p=next((p for p in options if p.number()==n.pitch.number()+delta),None)
if p is None:break
actual.append(dataclasses.replace(template,pitch=p))
if len(actual)==len(templates) and actual[-1].pitch==target:
proposed.append(types.RouteCandidate('theme:'+ref.id,tuple(actual),actual[-1],(),operation,(ref.id,)))
for route in proposed:
trace['proposed']+=1
actual=route.notes
contract=feature(ref,actual[:count],treatment=operation if operation!='introduce' else None) if count else None
if contract and references.feature_error(contract,actual):
trace['rejected_by_rule']+=1;continue
checked=harmony.evaluate(actual,until=finish,complete=True)
if not checked.valid:trace['rejected_by_rule']+=1;continue
trace['valid']+=1
entry=max(0,abs(first.number()-incoming.last.pitch.number())-7)/12 if incoming.last else 0.
score=2*checked.exposure+entry+.1*routes.path_cost(actual)
score+=.25*rhythm_cost(tuple((n.start-start,n.end-start) for n in actual),finish-start,rhythmic_purpose(operation,terminal))
result.append(Unit(actual,contract,ref,operation,ending,score,label,(route,),start,finish))
unique={tuple((n.start,n.end,n.pitch) for n in u.notes):u for u in result}
selected=sequences.diverse(tuple(unique.values()),lambda u:(u.score,policy.stable_rank(ref.id,u.notes)),
lambda u:(u.notes[0].pitch.number()//12,u.notes[-1].pitch.number(),tuple(n.end-n.start for n in u.notes[:count])),policy.DEFAULT.theme_unit_alternatives)
context.diagnostics['generated_candidates']+=len(selected)
context.results[key]=selected
return selected
chosen=sequences.diverse(tuple(unique.values()),lambda u:(u.score,policy.stable_rank(ref.id,u.notes)),
lambda u:(routes.path_family(u.notes,harmony),u.notes[0].pitch.number()//12,
tuple(n.end-n.start for n in u.notes)),policy.DEFAULT.theme_unit_alternatives)
trace['retained']+=len(chosen)
context.diagnostics['generated_candidates']+=len(chosen)
context.results[key]=chosen
return chosen
def home_reference(home,operation):
@@ -400,6 +357,18 @@ def make_home(unit,harmony,start=None,end=None):
return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony),signature=home.notes[:len(unit.reference.notes)])
def operation_choices(home,incoming,cursor,phrase_start,closing):
available=generator.vocabulary.data('grammar')['operations']
selected=[]
for rule in available:
op=rule['id']
if rule.get('entry_only') and cursor!=phrase_start:continue
if rule.get('after_statement') and not home:continue
if rule.get('needs_recent') and not incoming.notes:continue
selected.append(op)
return tuple(selected)
def unit_end(source,cursor,home,ending,bar):
"""Bound search by time without cutting the heard signature at a barline."""
length=2*bar
@@ -409,7 +378,7 @@ def unit_end(source,cursor,home,ending,bar):
length+=home.signature[-1].end
length=min(2*bar,max(bar,length))
finish=min(source.end,cursor+length)
if source.end-finish<bar+2 or ending.earliest<finish:
if source.end-finish<bar+2 or ending and ending.earliest<finish:
finish=source.end
return finish
@@ -424,7 +393,8 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
seed_key=('source-seeds',analysis,bar,seed)
if seed_key not in context.pools:context.pools[seed_key]=seeds(analysis,bar,seed)
roots=context.pools[seed_key]
ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=8*(batch+1),final=final)
ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=6*(batch+1),final=final)
if not final:ending_options=(None,*ending_options)
states=[(0.,(),incoming.home,incoming,ending) for ending in ending_options]
while states and any(not units or units[-1].end<source.end for _,units,_,_,_ in states):
next_states=[]
@@ -438,15 +408,12 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
if introducing:
requests=[(r,'introduce') for r in roots]
else:
operations=['continue'] if finish==source.end and cursor>source.start else ['develop_contour','develop_rhythm']
if cursor==source.start:operations.append('return')
operations=operation_choices(home,state,cursor,source.start,finish==source.end)
requests=[(home_reference(home,op),op) for op in operations]
# A uniform duration alone is not identifying musical evidence.
requests=[(r,op) for r,op in requests if op!='develop_rhythm' or len({n.end-n.start for n in r.notes})>1 or any(a.end!=b.start for a,b in zip(r.notes,r.notes[1:]))]
if cursor>=8*bar and cursor==source.start and incoming.callbacks<2:
requests.extend((dataclasses.replace(r,treatment='callback'),'callback') for r in roots[:1] if r.end-r.start<=bar)
for ref,operation in requests:
if introducing and source.end-source.start<2*bar and ending.earliest-cursor<ref.notes[-1].end:
if introducing and ending and source.end-source.start<2*bar and ending.earliest-cursor<ref.notes[-1].end:
# A missing-harmony boundary can leave less than one full statement.
# Declare a smaller contour seed; never pretend it proves the full motif.
count=min(3,int(2*(ending.earliest-cursor)),len(ref.notes))
@@ -465,9 +432,10 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
if len(reduced)<2:continue
ref=dataclasses.replace(ref,notes=reduced,treatment='contour_seed')
operation='continue'
terminal=finish==source.end
terminal=bool(ending and finish==source.end)
base=state.last.pitch.number() if state.last else ref.notes[0].pitch.number()
goal=ending.pitch.number() if terminal else round(base+(ending.pitch.number()-base)*float((finish-cursor)/(source.end-cursor)))
destination=ending.pitch.number() if ending else phrasing.register_goal(state,part,base,harmony.profile)
goal=destination if finish==source.end else round(base+(destination-base)*float((finish-cursor)/(source.end-cursor)))
options=unit_candidates(ref,cursor,finish,state,harmony,library,rhythms,meter,context,home=home,operation=operation,
terminal=terminal,batch=batch,ending=ending if terminal else None,goal=goal)
for unit in options:
@@ -487,22 +455,19 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
elapsed=units[-1].end-source.start
return cost/max(.001,float(elapsed)),policy.stable_rank(seed,tuple(u.notes for u in units))
states=sequences.diverse(next_states,rank,
lambda v:(v[2].id,v[4].pitch.number(),v[4].duration,len(v[1][-1].notes),v[1][-1].operation),policy.DEFAULT.theme_continuations)
lambda v:(v[2].id,(v[4].pitch.number(),v[4].duration) if v[4] else 'open',len(v[1][-1].notes),v[1][-1].operation),policy.DEFAULT.theme_continuations)
if not states:return ()
result=[]
for cost,units,home,state,ending in states:
plain=tuple(n for u in units for n in u.notes)
if similarity.near_copy(plain,head,source.start,source.end)['near_copy']:
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
features=tuple(u.feature for u in units)
features=tuple(u.feature for u in units if u.feature)
# Cell tails are not compulsory melodic arrivals. Only the selected close
# has a sounding contract; intermediate turns remain editable.
anchors=(ending,)
tails=(types.SustainedTail(ending.earliest,ending.hold_until,ending.pitch.number()),)
anchors=(ending,) if ending else ()
tails=(types.SustainedTail(ending.earliest,ending.hold_until,ending.pitch.number()),) if ending else ()
options=[(plain,(),())]
if stage=='elaborated':
for actual,edit,route in leads.elaborate(plain,features,harmony,library,rhythms,meter,source,sustain_entry=True,tails=tails):
options.append((actual,(edit,),(route,)))
for notes,edits,selected_routes in options:
identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,leads.signature(notes))[:12]
transforms=tuple(types.Transformation(identifier+f':{i}',u.reference.id,home.id,u.operation,u.notes[0].start,u.notes[-1].end,
@@ -512,9 +477,11 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
features=features,reference=source_ref.id,reference_motif=home,source_phrase=source,source_reference=source_ref,
source_links=tuple((n.event_id,n.sources) for n in notes),sustained_tails=tails)
motif=types.Motif(home.id,home.notes,home.duration,(), 'realized solo theme from '+source_ref.id)
landmark=types.Landmark(ending.earliest,ending.latest,ending.pitch.number(),ending.pitch.number(),0,'sustained destination')
landmarks=(types.Landmark(ending.earliest,ending.latest,ending.pitch.number(),ending.pitch.number(),0,'sounding destination'),) if ending else ()
closed=bool(ending and ending.role=='terminal')
plan=types.PhrasePlan(identifier,source.start,source.end,motif,draft.relationship,'thematic_recomposition',None,harmony.profile.register,F(5,4),
source.end-ending.hold_until,phrasing.activity(source,ending),(landmark,), 'sustain','closed',seed,
source.end-ending.hold_until if ending else F(),phrasing.activity(source,ending),landmarks,
'sustain' if ending else 'connected','closed' if closed else 'open',seed,
source.id,source.start,source.end,(anchors,))
before_rhythm=draft
from generator.solo import gesture_rhythm
@@ -523,20 +490,12 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
notes=draft.notes;edits=(*edits,*rhythm_edits);transforms=draft.transformations
phrase=realization.checked_phrase(plan,draft,notes,incoming.treatments,incoming,harmony,tempo,edits)
if phrase is None:continue
score=phrasing.evaluate(notes,incoming,harmony,source.start,source.end,bar)+.1*gesture_rhythm.fragmentation(notes)
score+=sum(phrasing.destination_cost(u.anchor.pitch,u.anchor.earliest,u.anchor.hold_until,harmony,incoming,phrasing.register_goal(incoming,part,u.notes[0].pitch.number(),harmony.profile),u.anchor.role=='terminal') for u in units)/len(units)
appearance_state=incoming
for unit in units:
score+=phrasing.appearance_cost(unit.operation,appearance_state,unit.notes[0].start,solo_end)/len(units)
if unit.operation=='return':
appearance_state=dataclasses.replace(appearance_state,returns=(*appearance_state.returns,unit.notes[0].start))
score+=.05*phrase.state.travel/max(1,len(notes))/2000
history=incoming.history
for transform in transforms:
group=tuple(n for n in notes if n.event_id in transform.events)
if group:history=phrasing.remember(history,phrasing.heard(group,transform.operation,ending if group[-1].end==ending.hold_until else None,bar),bar)
current=dataclasses.replace(phrase.state,next_destination=phrasing.next_destination(ending,harmony),home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units),
history=history,returns=state.returns,closures=incoming.closures+1,register_peak=max(state.register_peak,*(n.pitch.number() for n in notes)))
if group:history=phrasing.remember(history,phrasing.heard(group,transform.operation,ending if ending and group[-1].end==ending.hold_until else None,bar),bar)
current=dataclasses.replace(phrase.state,next_destination=phrasing.next_destination(ending,harmony) if ending else incoming.next_destination,home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units),
history=history,returns=state.returns,closures=incoming.closures+int(closed),register_peak=max(state.register_peak,*(n.pitch.number() for n in notes)))
if not incoming.home:
introduced=tuple(n for n in notes if n.start<units[0].notes[-1].end)
heard=dataclasses.replace(home,notes=tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start,position=None) for n in introduced),
@@ -545,6 +504,30 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
source_control=leads.shift(source.notes,next((o for o in (0,1,-1,2,-2) if all(41<=n.pitch.number()+12*o<=76 for n in source.notes)),0))
original=dataclasses.replace(draft,id=identifier+':source',notes=source_control,features=(),anchors=(),structural=(),sustained_tails=())
refined=dataclasses.replace(draft,id=identifier+':plain',notes=plain)
result.append(dataclasses.replace(phrase,score=score,state=current,initial=original,refined=refined,before_rhythm=before_rhythm,routes=(*tuple(r for u in units for r in u.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in notes) for n in r.notes)),*selected_routes)))
return sequences.diverse(result,lambda p:(p.score,policy.stable_rank(seed,p.draft.id)),
lambda p:(p.state.home.id,p.state.last.pitch.number(),p.anchors[-1].duration,len(p.state.returns)),policy.DEFAULT.phrase_alternatives)
result.append(dataclasses.replace(phrase,state=current,initial=original,refined=refined,before_rhythm=before_rhythm,routes=(*tuple(r for u in units for r in u.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in notes) for n in r.notes)),*selected_routes)))
from generator.solo import expressions,gesture_rhythm
if stage=='elaborated':
revisions=[]
for base in result:
for changed,edit,route in gesture_rhythm.join_variants(base.draft,harmony,library):
revised=realization.checked_phrase(base.plan,changed,changed.notes,incoming.treatments,incoming,harmony,tempo,(*base.edits,edit))
if revised is None:continue
state=dataclasses.replace(base.state,notes=revised.state.notes,last=revised.state.last,
motifs=revised.state.motifs,travel=revised.state.travel)
history=incoming.history
for t in changed.transformations:
heard=tuple(n for n in changed.notes if n.event_id in t.events)
if heard:history=phrasing.remember(history,phrasing.heard(heard,t.operation,bar=bar),bar)
state=dataclasses.replace(state,history=history)
retained=tuple(r for r in base.routes if not any(n.start<edit.end and n.end>edit.start for n in r.notes))
revisions.append(dataclasses.replace(revised,state=state,initial=base.initial,refined=base.refined,
before_rhythm=base.draft,routes=(*retained,route)))
context.diagnostics['join_revision_candidates']=context.diagnostics.get('join_revision_candidates',0)+len(revisions)
result.extend(revisions)
if stage=='elaborated' and getattr(context,'expressions_enabled',True):
result=expressions.joint_candidates(result,incoming,harmony,tempo,context,bar=bar)
selected=sequences.diverse(result,lambda p:(p.score,policy.stable_rank(seed,p.draft.id)),
lambda p:(p.state.home.id,p.plan.closure,p.state.last.pitch.number(),tuple(e.kind for e in p.draft.expressions)),policy.DEFAULT.phrase_alternatives)
context.diagnostics.setdefault('evaluations',[]).extend(
{'id':p.draft.id,'retained':p in selected,'evaluation':dataclasses.asdict(p.evaluation)} for p in sorted(result,key=lambda p:p.score)[:12] if p.evaluation)
return selected