"""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 00: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 00: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