Files
product-vamp/generator/solo/selection.py
T
khodowany 654299decd Cleanup
2026-10-05 08:51:03 -05:00

367 lines
20 KiB
Python

"""Canonical sounded-event evaluation, shared by inference and training.
Constructor names are not musical evidence. Complete equal-span candidates
are compared using this record; local retrieval is explicitly only a heuristic.
"""
from collections import Counter
from fractions import Fraction as F
import dataclasses
import functools
from types import MappingProxyType
from generator import vocabulary, harmony as chords, model
from generator.solo import expressions, scoring, types
VERSION='phrase-selection-2'
DEFAULT={'excessive_leap':12,'register_distance':1,'oscillation':6,'repeated_attack':3,
'unprepared_space':12,'source_relation':-2,'theme_relation':-4,
'sustained_arrival':-1,'directed_motion':-1}
CONDITIONS={
'quality':frozenset(('major','minor','dominant','half_diminished','other')),
'degree':frozenset(map(str,range(12))),
'motion':frozenset(map(str,range(-12,13))),
'metric':frozenset(('barline','beat','eighth','subdivision')),
'duration':frozenset(str(F(i,4)).replace('/','_') for i in range(1,17))|{'1_3','2_3'},
'source_pair':frozenset(('yes','no')),
}
def rule_conditions(key):
if not key.startswith('joint/'):return None
pairs=tuple(p.split('=',1) for p in key[6:].split('&'))
if not 2<=len(pairs)<=3 or any(len(p)!=2 for p in pairs):raise ValueError('Expected two or three contextual conditions')
if len({p[0] for p in pairs})!=len(pairs) or list(pairs)!=sorted(pairs):raise ValueError('Noncanonical contextual rule')
if any(k not in CONDITIONS or v not in CONDITIONS[k] for k,v in pairs):raise ValueError('Forbidden contextual rule condition')
return dict(pairs)
def validate_weights(weights):
durations={str(F(i,4)).replace('/','_') for i in range(1,17)}|{'1_3','2_3'}
allowed={f'duration/{d}' for d in durations}
allowed|={f'harmony/{q}/{p}/{d}' for q in ('major','minor','dominant','half_diminished','other') for p in range(12) for d in durations}
allowed|={f'motion/{p}/{d}' for p in range(-12,13) for d in durations}
allowed|={f'metric/{m}/{d}' for m in ('barline','beat','eighth','subdivision') for d in durations}
allowed|={f'register/{i}' for i in range(-4,5)}
allowed|={f'obligation/step/{i}' for i in range(-12,13)}
allowed|={f'obligation/delay/{str(F(i,4)).replace("/","_")}' for i in range(1,21)}
allowed|={f'obligation/hold/{d}' for d in durations}
allowed|={f'continuation/{a}/{b}' for a in ('up','down','repeat') for b in ('up','down','repeat')}
allowed|={'source/rhythm_match','theme/realized_identity',*(f'space/{s}' for s in ('unharmonized','breath','after_arrival','delayed_entrance'))}
for key,value in weights.items():
joint=rule_conditions(key)
if (key not in allowed and joint is None) or not isinstance(value,int) or isinstance(value,bool) or abs(value)>1024:
raise ValueError('Unsupported fitted feature (only shared musical categories): '+key)
if any(c not in 'abcdefghijklmnopqrstuvwxyz_0123456789/-=&' for c in key):
raise ValueError('Noncanonical fitted feature: '+key)
return dict(weights)
def signature(notes):
if not notes:return ()
first=notes[0]
return tuple((n.start-first.start,n.end-n.start,n.pitch.number()-first.pitch.number()) for n in notes)
def matches_signature(notes,expected):
"""Exact signature equality, stopping at the first differing feature."""
if len(notes)!=len(expected):return False
if not notes:return True
first=notes[0];pitch=first.pitch.number()
return all(n.pitch.number()-pitch==relative and n.end-n.start==duration and n.start-first.start==onset
for n,(onset,duration,relative) in zip(notes,expected))
class Selector:
@property
def weights(self):return self._weights
@weights.setter
def weights(self,value):self._weights=MappingProxyType(validate_weights(value))
def __init__(self,harmony,analysis,center,weights=None):
self.harmony=harmony;self.analysis=analysis;self.center=center
self.weights=validate_weights(weights if weights is not None else vocabulary.role().get('selection_weights',{}))
self.source_intervals=Counter(b.pitch.number()-a.pitch.number() for a,b in zip(analysis.melody,analysis.melody[1:]))
self.source_pairs=Counter((b.pitch.number()-a.pitch.number(),c.pitch.number()-b.pitch.number())
for a,b,c in zip(analysis.melody,analysis.melody[1:],analysis.melody[2:]))
self.source_rhythms=Counter(n.end-n.start for n in analysis.melody)
self.static={}
self.home_signatures={}
self.cost_static={};self.cost_weights=None
def contexts(self,notes,recent=()):
history=list(recent[-2:])
for n in notes:
region=self.harmony.at(n.start)
if region and region.chord:
chord=expressions.parsed_chord(region.chord)
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))
delta=n.pitch.number()-history[-1].pitch.number() if history else None
previous=history[-1].pitch.number()-history[-2].pitch.number() if len(history)>1 else None
metric='barline' if n.start+self.analysis.body_offset in self.analysis.boundaries else 'beat' if n.start%1==0 else 'eighth' if n.start%1==F(1,2) else 'subdivision'
context={'quality':expressions.quality(chord),'degree':str((n.pitch.number()-root)%12),
'metric':metric,'duration':str(n.end-n.start).replace('/','_'),
'source_pair':'yes' if (previous,delta) in self.source_pairs else 'no'}
if delta is not None:context['motion']=str(max(-12,min(12,delta)))
yield context,int((n.end-n.start)*12)
history.append(n)
def base(self,action):
if action.key in self.static:return self.static[action.key]
values=Counter();prior=Counter();ticks=int((action.end-action.start)*12)
for contract in action.contracts:
if contract.target.start>=action.end and contract.events:
# Pending destinations must be visible to selection. Identical heard
# attacks with different future obligations are different actions.
last=contract.events[-1];target=contract.target
values[f'obligation/step/{max(-12,min(12,target.pitch.number()-last.pitch.number()))}']+=ticks
values['obligation/delay/'+str(target.start-last.start).replace('/','_')]+=ticks
values['obligation/hold/'+str(target.end-target.start).replace('/','_')]+=ticks
for n in action.notes:
f,p=self.note_base(n.start,n.end,n.pitch.number())
values.update(f);prior.update(p)
self.static[action.key]=(values,prior)
if len(self.static)>8192:self.static.pop(next(iter(self.static)))
return values,prior
@functools.lru_cache(maxsize=32768)
def note_base(self,start,end,number):
values=Counter();prior=Counter()
duration=end-start;length=int(duration*12)
label=str(duration).replace('/','_')
values['duration/'+label]+=length
beat=(start%1)
metric='barline' if start+self.analysis.body_offset in self.analysis.boundaries else 'beat' if beat==0 else 'eighth' if beat==F(1,2) else 'subdivision'
values['metric/'+metric+'/'+label]+=length
band=max(-4,min(4,(number-self.center+6)//12))
values[f'register/{band}']+=length
region=self.harmony.at(start)
if not region or not region.chord:return values,prior
chord=expressions.parsed_chord(region.chord)
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))
degree=(number-root)%12
values[f'harmony/{expressions.quality(chord)}/{degree}/{label}']+=length
prior['register_distance']+=length*max(0,abs(number-self.center)-7)
if duration>=2 and number%12 in self.harmony.members(start,end):
prior['sustained_arrival']+=length
values['source/rhythm_match']+=length if duration in self.source_rhythms else 0
return values,prior
def features(self,action,recent=(),home=(),previous_kind=''):
if any(n.continuation for n in action.notes):action=dataclasses.replace(action,notes=types.sounding_notes(action.notes))
static,base=self.base(action);values=Counter(static);prior=Counter(base)
# Silence is measured as intervals in the sounded stream, independent of
# the number of rest actions used to encode it. Missing harmony is distinct.
cursor=action.start;before=recent[-1] if recent else None
for n in (*action.notes,None):
until=n.start if n else action.end
if until>cursor:
f,p=self.space_features(cursor,until,None if before is None else before.end-before.start>=F(3,2))
values.update(f);prior.update(p)
if n:cursor=n.end;before=n
previous=recent[-1] if recent else None
intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(recent,recent[1:])]
for n in action.notes:
length=int((n.end-n.start)*12)
if previous:
delta=n.pitch.number()-previous.pitch.number()
token=max(-12,min(12,delta));label=str(n.end-n.start).replace('/','_')
values[f'motion/{token}/{label}']+=length
if intervals:
# Direction changes are contextual, not a blanket leap prohibition.
sign=lambda x: 'up' if x>0 else 'down' if x<0 else 'repeat'
values[f'continuation/{sign(intervals[-1])}/{sign(delta)}']+=length
if len(intervals)>=2 and intervals[-2]==-intervals[-1]==delta and delta:prior['oscillation']+=length
prior['excessive_leap']+=length*max(0,abs(delta)-7)
prior['repeated_attack']+=length if delta==0 and n.start==previous.end else 0
# Ordered relationships, not the set of available intervals. Weight
# by sounding duration so subdivisions cannot multiply a reward.
if intervals and (intervals[-1],delta) in self.source_pairs:prior['source_relation']+=length
if 0<abs(delta)<=2 and intervals and delta*intervals[-1]>0:prior['directed_motion']+=length
intervals.append(delta)
previous=n
if len(home)>=3:
key=id(home)
if key not in self.home_signatures:
self.home_signatures[key]=(home,signature(home))
if len(self.home_signatures)>32:self.home_signatures.pop(next(iter(self.home_signatures)))
expected=self.home_signatures[key][1]
joined=(*recent,*action.notes)
for i in range(max(0,len(recent)-len(home)+1),len(joined)-len(home)+1):
part=joined[i:i+len(home)]
if part[0].start>home[-1].end and matches_signature(part,expected):
values['theme/realized_identity']+=12
prior['theme_relation']+=12
joint=[(key,rule_conditions(key)) for key in self.weights if key.startswith('joint/')]
if joint:
for context,ticks in self.contexts(action.notes,recent):
for key,conditions in joint:
if all(context.get(k)==v for k,v in conditions.items()):values[key]+=ticks
return values,prior
def complete(self,actions,home=(),*,start=None,end=None):
"""The sole authoritative score. Ties/rests/partitions are canonicalized."""
from generator.solo.operators import Action
actions=tuple(actions)
first=start if start is not None else actions[0].start
last=end if end is not None else actions[-1].end
notes=types.sounding_notes(tuple(n for a in actions for n in a.notes))
contracts=tuple(dict.fromkeys(c for a in actions for c in a.contracts))
return self.evaluate(Action(first,last,notes,'sounded_phrase',contracts),home=home)
def extend(self,previous,old_notes,action,home=(),*,old_home=(),start=F(),contracts=()):
"""Exact sufficient-statistic update, checked against complete() in tests.
Reevaluate the seam and the tail containing any possible new home match.
Previously completed events are not rescored on every search expansion.
"""
from generator.solo.operators import Action
if previous is None or home!=old_home or any(n.continuation for n in action.notes):
notes=types.sounding_notes((*old_notes,*action.notes))
return self.evaluate(Action(start,action.end,notes,'sounded_phrase',contracts),home=home)
span=F(previous.denominators['span'])
raw=Counter({k:v*span for k,v in previous.raw.items() if not k.startswith('obligation/')})
index=max(0,len(old_notes)-max(3,len(home)+2))
tail=old_notes[index:];context=old_notes[max(0,index-3):index]
begin=tail[0].start if index else start
before=Action(begin,action.start,tail,'sounded_phrase')
after=Action(begin,action.end,(*tail,*action.notes),'sounded_phrase')
for sign,part in ((-1,before),(1,after)):
f,p=self.features(part,context,home)
for k,v in f.items():
if not k.startswith('obligation/'):raw[k]+=sign*v
for k,v in p.items():raw['prior/'+k]+=sign*v
obligations,_=self.base(Action(start,action.end,(),'sounded_phrase',contracts))
raw.update({k:v for k,v in obligations.items() if k.startswith('obligation/')})
weights={'prior/'+k:v for k,v in DEFAULT.items()}|self.weights
duration=action.end-start
raw={k:F(v)/duration for k,v in raw.items() if v}
contributions={k:v*weights.get(k,0) for k,v in raw.items()}
return scoring.Evaluation(VERSION,True,raw,weights,contributions,sum(contributions.values(),F()),
{'span':str(duration),'ticks_per_quarter':12,'attacks':len(old_notes)+len(action.notes)})
def evaluate(self,action,recent=(),home=(),previous_kind=''):
features,prior=self.features(action,recent,home,previous_kind)
contributions={'prior/'+k:v*DEFAULT[k] for k,v in prior.items() if v}
contributions.update({k:v*self.weights.get(k,0) for k,v in features.items() if v})
weights={'prior/'+k:v for k,v in DEFAULT.items()}|self.weights
raw={'prior/'+k:v for k,v in prior.items() if v}|{k:v for k,v in features.items() if v}
span=action.end-action.start
contributions={k:F(v)/span for k,v in contributions.items()}
raw={k:F(v)/span for k,v in raw.items()}
total=sum(contributions.values(),F())
return scoring.Evaluation(VERSION,True,raw,weights,contributions,total,
{'span':str(action.end-action.start),'ticks_per_quarter':12,'attacks':len(types.sounding_notes(action.notes))})
def cost(self,action,recent=(),home=(),previous_kind=''):
"""Identical selection total without serializing every rejected breakdown."""
features,prior=self.features(action,recent,home,previous_kind)
numerator=sum(v*DEFAULT[k] for k,v in prior.items())
numerator+=sum(v*self.weights.get(k,0) for k,v in features.items())
return F(numerator)/(action.end-action.start)
@functools.lru_cache(maxsize=32768)
def transition_cost(self,delta,preceding,earlier,length,label,contiguous):
token=max(-12,min(12,delta))
sign=lambda x:'up' if x>0 else 'down' if x<0 else 'repeat'
cost=length*self.weights.get(f'motion/{token}/{label}',0)
cost+=length*DEFAULT['excessive_leap']*max(0,abs(delta)-7)
if delta==0 and contiguous:cost+=length*DEFAULT['repeated_attack']
if preceding is not None:
cost+=length*self.weights.get(f'continuation/{sign(preceding)}/{sign(delta)}',0)
if (preceding,delta) in self.source_pairs:cost+=length*DEFAULT['source_relation']
if 0<abs(delta)<=2 and delta*preceding>0:cost+=length*DEFAULT['directed_motion']
if earlier is not None and earlier==-preceding==delta and delta:cost+=length*DEFAULT['oscillation']
return cost
@functools.lru_cache(maxsize=32768)
def seam_cost(self,prefix,context,contiguous):
def total(initial):
pitches=list(initial);value=0
for index,(number,length,label,touch) in enumerate(prefix):
if pitches:
value+=self.transition_cost(number-pitches[-1],pitches[-1]-pitches[-2] if len(pitches)>1 else None,
pitches[-2]-pitches[-3] if len(pitches)>2 else None,length,label,contiguous if index==0 and initial else touch)
pitches.append(number)
return value
return total(context)-total(())
@functools.lru_cache(maxsize=32768)
def unary_cost(self,start,end,number):
features,prior=self.note_base(start,end,number)
return sum(v*DEFAULT[k] for k,v in prior.items())+sum(v*self.weights.get(k,0) for k,v in features.items())
def intrinsic_cost(self,action):
"""Exact ordinary retrieval cost without constructing discarded reports.
Unary, transition, silence and pending-target contributions are identical
to features(action). Contextual learned conjunctions use the full path.
"""
notes=types.sounding_notes(action.notes) if any(n.continuation for n in action.notes) else action.notes
cost=0;history=[];cursor=action.start;settled=None;previous=None
for n in notes:
if n.start>cursor:cost+=self.gap_cost(cursor,n.start,settled)
number=n.pitch.number();length=int((n.end-n.start)*12);label=str(n.end-n.start).replace('/','_')
cost+=self.unary_cost(n.start,n.end,number)
if history:
cost+=self.transition_cost(number-history[-1],history[-1]-history[-2] if len(history)>1 else None,
history[-2]-history[-3] if len(history)>2 else None,length,label,n.start==previous.end)
history.append(number);previous=n;cursor=n.end;settled=n.end-n.start>=F(3,2)
if action.end>cursor:cost+=self.gap_cost(cursor,action.end,settled)
ticks=int((action.end-action.start)*12)
for contract in action.contracts:
if contract.target.start>=action.end and contract.events:
last=contract.events[-1];target=contract.target
cost+=ticks*self.weights.get(f'obligation/step/{max(-12,min(12,target.pitch.number()-last.pitch.number()))}',0)
cost+=ticks*self.weights.get('obligation/delay/'+str(target.start-last.start).replace('/','_'),0)
cost+=ticks*self.weights.get('obligation/hold/'+str(target.end-target.start).replace('/','_'),0)
return cost
def fast_cost(self,action,recent=(),context=None):
"""Unnormalized endpoint-bucket retrieval, with cached internal routes.
Only the first three transitions depend on incoming history. Final ranking
always uses complete(); this heuristic does not compare unequal spans.
"""
# Learned conjunctions include incoming context; do not use a shortcut
# that forgets it. This small fallback is explicit and profileable.
if any(n.continuation for n in action.notes) or any(k.startswith('joint/') for k in self.weights):
f,p=self.features(action,recent)
return sum(v*DEFAULT[k] for k,v in p.items())+sum(v*self.weights.get(k,0) for k,v in f.items())
if self.cost_weights is not self.weights:
self.cost_static.clear();self.transition_cost.cache_clear();self.seam_cost.cache_clear();self.gap_cost.cache_clear();self.unary_cost.cache_clear();self.cost_weights=self.weights
key=id(action)
if key not in self.cost_static:
value=self.intrinsic_cost(action)
prefix=tuple((n.pitch.number(),int((n.end-n.start)*12),str(n.end-n.start).replace('/','_'),
i>0 and action.notes[i-1].end==n.start) for i,n in enumerate(action.notes[:3]))
self.cost_static[key]=(action,value,prefix)
if len(self.cost_static)>32768:self.cost_static.pop(next(iter(self.cost_static)))
_,value,prefix=self.cost_static[key]
if context is None:context=(tuple(n.pitch.number() for n in recent[-3:]),recent[-1].end if recent else None)
if context[0] and prefix:value+=self.seam_cost(prefix,context[0],context[1]==action.notes[0].start)
gap_end=action.notes[0].start if action.notes else action.end
if recent and gap_end>action.start:
# Incoming sustained arrivals change the role of a leading rest, just
# as incoming pitches change the first intervals. Do not cache it as a
# fresh silent entrance in every action.
value+=self.gap_cost(action.start,gap_end,recent[-1].end-recent[-1].start>=F(3,2))-self.gap_cost(action.start,gap_end,None)
return value
@functools.lru_cache(maxsize=8192)
def space_features(self,start,end,settled):
values=Counter();prior=Counter();delayed=0
boundaries=sorted({start,end,*[s.start for s in self.harmony.spans if start<s.start<end],
*[s.end for s in self.harmony.spans if start<s.end<end]})
for left,right in zip(boundaries,boundaries[1:]):
ticks=int((right-left)*12);region=self.harmony.at(left)
label='unharmonized' if not region or not region.scale else 'delayed_entrance' if settled is None else 'after_arrival' if settled else 'breath'
values['space/'+label]+=ticks
if label=='breath':prior['unprepared_space']+=ticks
if label=='delayed_entrance':delayed+=ticks
prior['unprepared_space']+=max(0,delayed-12)
return values,prior
@functools.lru_cache(maxsize=8192)
def gap_cost(self,start,end,settled):
f,p=self.space_features(start,end,settled)
return sum(v*DEFAULT[k] for k,v in p.items())+sum(v*self.weights.get(k,0) for k,v in f.items())