Cleanup
This commit is contained in:
@@ -24,9 +24,9 @@ def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,e
|
||||
if placed is None:raise construction.SearchExhausted('selected melody has no complete legal guitar path')
|
||||
by_id={n.event_id:n for n in placed[1]}
|
||||
phrases=tuple(dataclasses.replace(p,notes=tuple(by_id[n.event_id] for n in p.notes)) for p in phrases)
|
||||
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'joint_actions','version':policy.DEFAULT.composition_version,
|
||||
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'complete_phrase_frontiers','version':policy.DEFAULT.composition_version,
|
||||
'construction_seconds':time.monotonic()-began,**builder.timings,'operation_coverage':{k:dict(v) for k,v in builder.coverage.items()},
|
||||
'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile,start)
|
||||
'frontiers':builder.frontiers,'completed_candidates':[{'rank':i+1,'evaluation':dataclasses.asdict(s.evaluation),'events':[(str(n.start),str(n.end),n.pitch.number()) for n in s.notes]} for i,s in enumerate(builder.completed)],'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile,start)
|
||||
validate(result)
|
||||
return result
|
||||
|
||||
|
||||
+181
-56
@@ -1,13 +1,16 @@
|
||||
"""Bounded search of joint musical actions; one normal production path.
|
||||
|
||||
Selection compares complete actions by cost per quarter. Actions are indivisible;
|
||||
cross-bar paths and resolution contracts never restart at search boundaries.
|
||||
Selection compares several histories at common exact-time frontiers. Actions
|
||||
are indivisible; crossing a reporting window does not restart their contracts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from collections import Counter
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
import time
|
||||
import heapq
|
||||
import itertools
|
||||
import functools
|
||||
|
||||
from generator import vocabulary
|
||||
from generator.solo import types, groups, operators, selection, sequences, scoring
|
||||
@@ -26,6 +29,14 @@ class State:
|
||||
tie: tuple = ()
|
||||
pending: tuple = ()
|
||||
alternatives: tuple = ()
|
||||
actions: tuple = ()
|
||||
notes: tuple = ()
|
||||
full_evaluation: object = None
|
||||
|
||||
@functools.cached_property
|
||||
def music_key(self):return tuple((n.start,n.end,n.pitch.number()) for n in self.notes)
|
||||
@functools.cached_property
|
||||
def path_key(self):return tuple(a.key for a in self.actions)
|
||||
|
||||
class Builder:
|
||||
def __init__(self,analysis,measures,harmony,sequences,rhythms,end,seed,enabled=True,*,start=F()):
|
||||
@@ -33,32 +44,66 @@ class Builder:
|
||||
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()
|
||||
self.observe=None # Optional read-only diagnostic; its return is ignored.
|
||||
self.successor_cache={};self.retrieval_at=None;self.retrieval_weights=None
|
||||
|
||||
def candidates(self,at,state):
|
||||
candidates=self.operators.at(at)
|
||||
candidates=self.operators.proposals(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'])
|
||||
# Retrieval never compares cost rates of differently sized actions. Each
|
||||
# endpoint has its own ranking; round-robin the endpoints before cutoff.
|
||||
context=(tuple(n.pitch.number() for n in state.recent[-3:]),state.recent[-1].end if state.recent else None)
|
||||
settled=state.recent[-1].end-state.recent[-1].start>=F(3,2) if state.recent else None
|
||||
if at!=self.retrieval_at or self.retrieval_weights is not self.selector.weights:
|
||||
self.successor_cache.clear();self.retrieval_at=at;self.retrieval_weights=self.selector.weights
|
||||
# Cache only endpoint retrieval, whose sufficient incoming state is three
|
||||
# pitches, release, rest function and pending contracts. Home-specific
|
||||
# actions change the immutable catalog identity; full evaluation below
|
||||
# still uses the actual complete home and recent history on every call.
|
||||
key=(id(candidates),context,settled,state.pending,self.rules['per_state'])
|
||||
cached=self.successor_cache.get(key)
|
||||
if cached is not None and cached[0] is candidates:
|
||||
self.timings['retrieval_cache_hits']+=1;retained=cached[1]
|
||||
else:
|
||||
self.timings['retrieval_cache_misses']+=1;buckets={}
|
||||
for order,a in enumerate(candidates):
|
||||
if not self.pending_compatible(state,a):continue
|
||||
cost=self.selector.fast_cost(a,state.recent,context=context)
|
||||
buckets.setdefault(a.end,[]).append((cost,order,a))
|
||||
ranked=[]
|
||||
for end,rows in sorted(buckets.items()):
|
||||
heapq.heapify(rows);ranked.append(rows)
|
||||
retained=[]
|
||||
while ranked and len(retained)<self.rules['per_state']:
|
||||
again=[]
|
||||
for rows in ranked:
|
||||
while rows:
|
||||
cost,_,action=heapq.heappop(rows)
|
||||
if self.operators.is_valid(action):
|
||||
retained.append((cost,action));break
|
||||
if rows:again.append(rows)
|
||||
if len(retained)==self.rules['per_state']:break
|
||||
ranked=again
|
||||
retained=tuple(retained)
|
||||
self.successor_cache[key]=(candidates,retained) # Retain object; no id reuse.
|
||||
if len(self.successor_cache)>64:self.successor_cache.pop(next(iter(self.successor_cache)))
|
||||
for _,a in retained:self.operators.coverage['retained:'+a.kind]+=1
|
||||
if self.observe:self.observe('successors',at,state,tuple(candidates),tuple(a for _,a in retained))
|
||||
return tuple((self.selector.evaluate(a,state.recent,state.home,state.last_kind),a) for _,a in retained)
|
||||
|
||||
def advance(self,state,evaluation,action):
|
||||
def pending_compatible(self,state,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
|
||||
and n.end==min(action.end,target.end) and n.pitch==target.pitch for n in action.notes):return False
|
||||
return True
|
||||
|
||||
def advance(self,state,evaluation,action):
|
||||
if not self.pending_compatible(state,action):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)
|
||||
@@ -69,55 +114,139 @@ class Builder:
|
||||
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)
|
||||
recent=(*state.recent,*action.notes)[-8:]
|
||||
notes=types.sounding_notes((*state.notes,*action.notes))
|
||||
actions=(*state.actions,action)
|
||||
# A complete opening, rather than the first four eligible notes, owns the
|
||||
# theme. Keep it separate from the small context used for retrieval.
|
||||
home=state.home
|
||||
boundaries=[x-self.analysis.body_offset for x in self.analysis.boundaries if x-self.analysis.body_offset>self.start]
|
||||
opening_end=boundaries[min(3,len(boundaries)-1)] if boundaries else self.end
|
||||
if not home and action.end>=opening_end and not pending and len(notes)>=3:
|
||||
# The same sounded opening has the same home identity regardless of
|
||||
# whether its last generating action extends beyond this source window.
|
||||
home=tuple(n for n in notes if n.start<opening_end)
|
||||
if evaluation is None:
|
||||
return State(recent=recent,home=home,last_kind=action.kind,parent=state,action=action,pending=pending,actions=actions,notes=notes)
|
||||
full=self.selector.extend(state.full_evaluation,state.notes,action,home,old_home=state.home,start=self.start,
|
||||
contracts=tuple(dict.fromkeys(c for a in actions for c in a.contracts)))
|
||||
return State(full.total,recent,home,action.kind,state,action,full,(),pending,(),actions,notes,full)
|
||||
|
||||
@staticmethod
|
||||
def identity(state):
|
||||
# Keep rhythmic histories, home identity, and obligations, not just pitch.
|
||||
return (state.music_key,
|
||||
selection.signature(state.home),state.pending)
|
||||
|
||||
def rank(self,state):
|
||||
return (state.total,state.music_key,state.path_key)
|
||||
|
||||
def diversity(self,state):
|
||||
# Protect alternative *openings* before a home theme has been selected.
|
||||
# Recent-note diversity alone lets every history inherit the same opening.
|
||||
first_bar=next((x-self.analysis.body_offset for x in self.analysis.boundaries if x-self.analysis.body_offset>max(F(),self.start)),self.end)
|
||||
opening=tuple(itertools.takewhile(lambda n:n.start<first_bar,state.notes))
|
||||
return (selection.signature(opening),opening[0].pitch.number()//12 if opening else None)
|
||||
|
||||
def retain(self,states,at):
|
||||
normalized=[]
|
||||
for s in states:
|
||||
if s.actions and (s.evaluation is None or F(s.evaluation.denominators['span'])!=at-self.start):
|
||||
e=self.frontier_score(s,at);s=dataclasses.replace(s,total=e.total,evaluation=e)
|
||||
normalized.append(s)
|
||||
states=normalized
|
||||
unique={}
|
||||
for s in states:
|
||||
key=self.identity(s)
|
||||
if key not in unique or self.rank(s)<self.rank(unique[key]):unique[key]=s
|
||||
values=sorted(unique.values(),key=self.rank)
|
||||
# Half by score, half by realized pitch/rhythm/obligation diversity. Labels
|
||||
# do not earn a bonus, nor do duplicate derivations receive extra votes.
|
||||
limit=self.rules['histories'];best=values[:max(1,limit//2)]
|
||||
rest=sequences.diverse(values[len(best):],self.rank,self.diversity,limit-len(best))
|
||||
result=tuple(sorted((*best,*rest),key=self.rank))
|
||||
self.frontiers.append({'time':str(at),'proposed':len(states),'distinct':len(unique),'retained':len(result),
|
||||
'best':str(result[0].total) if result else None})
|
||||
if self.observe:self.observe('frontier',at,None,tuple(states),result)
|
||||
return result
|
||||
|
||||
def frontier_score(self,state,until):
|
||||
# The already chosen action can continue beyond this computational cut.
|
||||
# Only its heard prefix participates in an equal-time comparison. The
|
||||
# original notes, full route and obligations remain intact in the state.
|
||||
if until==state.action.end and state.full_evaluation is not None:return state.full_evaluation
|
||||
a=state.action
|
||||
ns=tuple(dataclasses.replace(n,end=min(n.end,until)) if n.end>until else n for n in a.notes if n.start<until)
|
||||
clipped=dataclasses.replace(a,end=until,notes=ns)
|
||||
home=state.home if state.home and state.home[-1].end<=until else ()
|
||||
parent=state.parent
|
||||
return self.selector.extend(parent.full_evaluation,parent.notes,clipped,home,old_home=parent.home,start=self.start,
|
||||
contracts=tuple(dict.fromkeys(c for action in state.actions for c in action.contracts)))
|
||||
|
||||
def compose(self):
|
||||
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.decisions=[];self.frontiers=[];began=time.monotonic()
|
||||
self.successor_cache.clear()
|
||||
self.timings['retrieval_cache_hits']=self.timings['retrieval_cache_misses']=0
|
||||
states=(State(),);at=self.start
|
||||
while at<self.end:
|
||||
# Source quarter-time frontiers are computational, not phrase endings.
|
||||
# Tuplets, delayed approaches and sustained notes retain their full tails.
|
||||
until=min(self.end,F(at.numerator//at.denominator+1))
|
||||
agenda={};ready=[];queue=[];queued=set()
|
||||
for state in states:
|
||||
cursor=state.action.end if state.action else self.start
|
||||
if cursor>=until:ready.append(state)
|
||||
else:
|
||||
agenda.setdefault(cursor,[]).append(state)
|
||||
if cursor not in queued:heapq.heappush(queue,cursor);queued.add(cursor)
|
||||
while queue:
|
||||
cursor=heapq.heappop(queue)
|
||||
partial=self.retain(agenda.pop(cursor),cursor)
|
||||
for state in partial:
|
||||
for evaluation,action in self.candidates(cursor,state):
|
||||
next_state=self.advance(state,evaluation,action)
|
||||
if next_state is None:continue
|
||||
if action.end>=until:ready.append(next_state)
|
||||
else:
|
||||
agenda.setdefault(action.end,[]).append(next_state)
|
||||
if action.end not in queued:heapq.heappush(queue,action.end);queued.add(action.end)
|
||||
# This exact-time frontier will never be expanded again. Keep chosen
|
||||
# actions in immutable histories, not tens of thousands of rejected
|
||||
# concrete actions from obsolete frontiers (especially across workers).
|
||||
self.operators.cache.clear();self.operators.validity_cache.clear()
|
||||
self.selector.static.clear();self.selector.cost_static.clear()
|
||||
if not ready:raise SearchExhausted(f'No continuation at {until} within deterministic search budget')
|
||||
scored=[]
|
||||
for state in ready:
|
||||
evaluation=self.frontier_score(state,until)
|
||||
scored.append(dataclasses.replace(state,total=evaluation.total,evaluation=evaluation))
|
||||
states=self.retain(scored,until);at=until
|
||||
completed=tuple(s for s in states if not s.pending)
|
||||
if not completed:raise SearchExhausted('No complete phrase within deterministic frontier budget; not a proof of musical impossibility')
|
||||
completed=tuple(sorted(completed,key=self.rank))
|
||||
self.completed=(completed[0],*sequences.diverse(completed[1:],self.rank,self.diversity,self.rules['completed']-1))
|
||||
final=completed[0]
|
||||
self.timings['action_search_seconds']=time.monotonic()-began
|
||||
self.timings['frontiers']=len(self.frontiers)
|
||||
path=[];state=final
|
||||
while state.action:
|
||||
path.append(state);state=state.parent
|
||||
while state.action:path.append(state);state=state.parent
|
||||
path.reverse();phrases=[];pending=[]
|
||||
self.selected_actions=tuple(s.action for s in path)
|
||||
self.selected_actions=final.actions
|
||||
boundaries=[x-self.analysis.body_offset for x in self.analysis.boundaries]
|
||||
checkpoints=set(boundaries[4::4])
|
||||
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:
|
||||
'evaluation':dataclasses.asdict(state.evaluation),'prefix_total':state.total})
|
||||
# Reporting partitions never invent a cadence or split an active action.
|
||||
boundary=any(action.start<x<=action.end for x in checkpoints)
|
||||
if (boundary and not state.pending) 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
|
||||
self.winner_evaluation=final.evaluation
|
||||
return tuple(phrases)
|
||||
|
||||
def phrase(self,states,index):
|
||||
@@ -132,11 +261,7 @@ class Builder:
|
||||
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})
|
||||
evaluation=self.selector.complete(actions,states[-1].home,start=start,end=end)
|
||||
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,
|
||||
|
||||
@@ -44,6 +44,10 @@ def chromatic(target,delta):
|
||||
|
||||
|
||||
def eligible_color(rule,span,harmony):
|
||||
# An explicit no-chord span (or missing harmony) has no optional colors.
|
||||
# In particular, looking ahead for an anticipation must not try to parse
|
||||
# the display label N.C. as a written chord.
|
||||
if not span or not span.scale or not span.chord:return False
|
||||
key=(tuple(rule['qualities']),rule['degree'],rule['alter'],rule.get('function'),span)
|
||||
cache=harmony.color_eligibility
|
||||
if key not in cache:cache[key]=_eligible_color(rule,span,harmony)
|
||||
@@ -91,7 +95,7 @@ def validate(notes,contracts,harmony):
|
||||
arrival=c.boundary if c.boundary is not None else target.start
|
||||
if c.boundary is not None and not any(a.kind=='anticipation' and a.target==target and a.boundary==c.boundary for a in contracts):return {},c.id+': future target lacks anticipation contract'
|
||||
planned_color=any(a.kind=='color' and a.target==target and a.events==(target,) for a in contracts)
|
||||
if not planned_color and (target.pitch.number()%12 not in harmony.members(arrival,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,arrival,target.end)):return {},c.id+': invalid destination hold'
|
||||
if not planned_color and (target.pitch.number()%12 not in harmony.members(arrival,target.end) or target.pitch.number()%12 not in harmony.permitted(arrival,target.end)):return {},c.id+': invalid destination hold'
|
||||
gaps=tuple(map(F,rule.get('internal_gaps',['0']*(len(c.events)-1))))
|
||||
if len(gaps)!=len(c.events)-1 or any(g<0 for g in gaps):return {},c.id+': invalid internal approach gaps'
|
||||
gap=F(rule['gap']);cursor=target.start-gap-sum((F(d) for d in rule['durations']),F())-sum(gaps,F())
|
||||
@@ -101,7 +105,7 @@ def validate(notes,contracts,harmony):
|
||||
cursor=n.end+(gaps[i] if i<len(gaps) else 0)
|
||||
for index in rule.get('mapped_indices',[]):
|
||||
n=c.events[index]
|
||||
if n.pitch.number()%12 not in types.permitted(harmony.choices,n.start,n.end):return {},c.id+': declared diatonic neighbor is outside the mapped collection'
|
||||
if n.pitch.number()%12 not in harmony.permitted(n.start,n.end):return {},c.id+': declared diatonic neighbor is outside the mapped collection'
|
||||
if any(n.start<c.target.start and n.end>c.events[0].start and n.event_id not in {e.event_id for e in c.events} for n in notes):return {},c.id+': interrupted approach'
|
||||
if c.kind=='connection' and (not c.preparation or c.preparation.end!=c.events[0].start or abs(c.preparation.pitch.number()-c.events[0].pitch.number())>2):return {},c.id+': chromatic connection lacks its incoming motion'
|
||||
elif c.kind=='delayed_approach':
|
||||
@@ -110,7 +114,7 @@ def validate(notes,contracts,harmony):
|
||||
n,=c.events
|
||||
if n.end-n.start>F(rule['max_note']) or not n.end<=target.start<=n.start+F(rule['deadline']):return {},c.id+': expired delayed approach'
|
||||
if target.pitch.number()-n.pitch.number() not in rule['steps']:return {},c.id+': wrong delayed target register'
|
||||
if target.pitch.number()%12 not in harmony.members(target.start,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,target.start,target.end):return {},c.id+': invalid delayed destination'
|
||||
if target.pitch.number()%12 not in harmony.members(target.start,target.end) or target.pitch.number()%12 not in harmony.permitted(target.start,target.end):return {},c.id+': invalid delayed destination'
|
||||
elif c.kind=='color':
|
||||
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None)
|
||||
if not rule:return {},c.id+': unknown color policy'
|
||||
@@ -140,7 +144,7 @@ def validate(notes,contracts,harmony):
|
||||
if held.end-c.boundary>F(rule['max_delay']) or target.start!=held.end or target.pitch.number()-held.pitch.number() not in rule['steps']:
|
||||
return {},c.id+': missed suspension resolution'
|
||||
if target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': invalid suspension target'
|
||||
if held.pitch.number()%12 not in types.permitted(harmony.choices,held.start,held.end):return {},c.id+': suspension outside mapped collections'
|
||||
if held.pitch.number()%12 not in harmony.permitted(held.start,held.end):return {},c.id+': suspension outside mapped collections'
|
||||
elif c.kind=='anticipated_color':
|
||||
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None)
|
||||
if not rule or len(c.events)!=1 or c.boundary!=span.start:return {},c.id+': unsupported anticipated color'
|
||||
@@ -158,7 +162,7 @@ def validate(notes,contracts,harmony):
|
||||
if c.boundary is None or not n.start<c.boundary or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipation interval'
|
||||
if c.rule=='early_chord_tone' and (n!=target or not c.boundary<n.end):return {},c.id+': invalid held anticipation'
|
||||
if c.rule=='early_reattack' and (n.end!=c.boundary or target.start!=c.boundary or target.event_id==n.event_id or target.pitch.number()!=n.pitch.number()):return {},c.id+': invalid anticipated reattack'
|
||||
if target.pitch.number()%12 not in harmony.members(c.boundary,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,c.boundary,target.end):return {},c.id+': invalid future destination'
|
||||
if target.pitch.number()%12 not in harmony.members(c.boundary,target.end) or target.pitch.number()%12 not in harmony.permitted(c.boundary,target.end):return {},c.id+': invalid future destination'
|
||||
elif c.kind=='blues':
|
||||
if len(c.events)!=1:return {},c.id+': blues treatment requires one inflected event'
|
||||
rule=next((r for r in vocabulary.data('lead_ins')['blues'] if r['id']==c.rule),None)
|
||||
|
||||
@@ -57,6 +57,7 @@ class Harmony:
|
||||
self._at = {}
|
||||
self._segments = {}
|
||||
self._members = {}
|
||||
self._permitted = {}
|
||||
self.evaluations = {}
|
||||
self.color_eligibility = {}
|
||||
def at(self, time):
|
||||
@@ -74,6 +75,11 @@ class Harmony:
|
||||
spans = [s for s in self.spans if s.start < end and s.end > start]
|
||||
self._members[key] = frozenset.intersection(*(s.roles.basic for s in spans)) if spans else frozenset()
|
||||
return self._members[key]
|
||||
def permitted(self,start,end):
|
||||
key=(start,end)
|
||||
if key not in self._permitted:
|
||||
self._permitted[key]=generator.solo.types.permitted(self.choices,start,end)
|
||||
return self._permitted[key]
|
||||
def function(self, number, span):
|
||||
pitch = number % 12
|
||||
return 'basic' if pitch in span.roles.basic else 'specified_extension' if pitch in span.roles.specified else 'mapped_motion' if span.scale and pitch in span.scale.pitches else 'outside_collection'
|
||||
|
||||
+51
-21
@@ -33,8 +33,9 @@ class Operators:
|
||||
def __init__(self,harmony,analysis,end,start=F(),enabled=True,sequences=None,rhythms=None):
|
||||
self.harmony=harmony;self.analysis=analysis;self.start=start;self.end=end;self.enabled=enabled
|
||||
self.rules=vocabulary.data('solo')['operators'];self.cache={};self.pitch_cache={};self.coverage=Counter()
|
||||
self.ordinary_cache={}
|
||||
self.ordinary_cache={};self.validity_cache={}
|
||||
self.sequences=sequences
|
||||
self.licensed=frozenset(s.steps for values in sequences.paths.values() for s in values) if sequences else None
|
||||
self.patterns=[]
|
||||
if rhythms is not None:
|
||||
library=rhythm.Rhythms(rhythms,analysis.tempo)
|
||||
@@ -57,6 +58,7 @@ class Operators:
|
||||
if self.harmony.profile.permits((p:=types.scale_pitch(span.scale,i)).number()))
|
||||
return self.pitch_cache[span.scale]
|
||||
|
||||
@functools.lru_cache(maxsize=32768)
|
||||
def note(self,start,end,pitch,role):
|
||||
# Identity follows exact musical content, not an output filename or hash.
|
||||
return types.Note(start,end,pitch,role=role,event_id=f'{start}:{end}:{pitch.number()}')
|
||||
@@ -68,7 +70,7 @@ class Operators:
|
||||
return tuple(p for octave in range(1,6)
|
||||
if self.harmony.profile.permits((p:=dataclasses.replace(spelling,octave=octave)).number()))
|
||||
|
||||
def _accepted(self,action):
|
||||
def _accepted(self,action,validate=True):
|
||||
self.coverage['proposed:'+action.kind]+=1
|
||||
if action.end>self.end or action.end<=action.start:
|
||||
self.coverage['rejected_by_allocation:'+action.kind]+=1;return False
|
||||
@@ -76,40 +78,51 @@ class Operators:
|
||||
self.coverage['rejected_by_physical_domain:'+action.kind]+=1;return False
|
||||
if not groups.valid(tuple((n.start-action.start,n.end-action.start) for n in action.notes)):
|
||||
self.coverage['rejected_by_rhythm:'+action.kind]+=1;return False
|
||||
self.coverage['feasible:'+action.kind]+=1
|
||||
return self.is_valid(action) if validate else True
|
||||
|
||||
def is_valid(self,action):
|
||||
key=action.key
|
||||
if key in self.validity_cache:return self.validity_cache[key]
|
||||
if action.contracts:
|
||||
valid=self.harmony.evaluate(action.notes,expressions=action.contracts,complete=False,until=action.end).valid
|
||||
else:
|
||||
valid=True
|
||||
for n in action.notes:
|
||||
key=(n.start,n.end,n.pitch.number())
|
||||
if key not in self.ordinary_cache:self.ordinary_cache[key]=self.harmony.evaluate((n,),complete=True,until=n.end).valid
|
||||
if not self.ordinary_cache[key]:valid=False;break
|
||||
if not valid:
|
||||
self.coverage['rejected_by_rule:'+action.kind]+=1
|
||||
return False
|
||||
self.coverage['valid:'+action.kind]+=1
|
||||
return True
|
||||
note_key=(n.start,n.end,n.pitch.number())
|
||||
if note_key not in self.ordinary_cache:self.ordinary_cache[note_key]=self.harmony.evaluate((n,),complete=True,until=n.end).valid
|
||||
if not self.ordinary_cache[note_key]:valid=False;break
|
||||
self.coverage[('valid:' if valid else 'rejected_by_rule:')+action.kind]+=1
|
||||
self.validity_cache[key]=valid
|
||||
if len(self.validity_cache)>8192:self.validity_cache.pop(next(iter(self.validity_cache)))
|
||||
return valid
|
||||
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def _color(self,n,span):
|
||||
result=[]
|
||||
for rule in vocabulary.data('harmony')['colors']:
|
||||
if rule.get('resolve') or not expressions.eligible_color(rule,span,self.harmony):continue
|
||||
expected=expressions.degree_pitch(expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],n.pitch.number())
|
||||
if expected.number()!=n.pitch.number():continue
|
||||
c=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(n,),n,span.chord)
|
||||
yield Action(n.start,n.end,(n,),'color',(c,),rule['id'])
|
||||
result.append(Action(n.start,n.end,(n,),'color',(c,),rule['id']))
|
||||
return tuple(result)
|
||||
|
||||
def at(self,time,eligible=None,first=None):
|
||||
def proposals(self,time):
|
||||
return self.at(time,validate=False)
|
||||
|
||||
def at(self,time,eligible=None,first=None,*,validate=True):
|
||||
"""Unpruned legal actions. Incoming-state ranking is a separate operation."""
|
||||
if eligible is None and first is None and time in self.cache:return self.cache[time]
|
||||
if eligible is None and first is None and (time,validate) in self.cache:return self.cache[(time,validate)]
|
||||
span=self.harmony.at(time);values=[]
|
||||
if not span or not span.scale:
|
||||
end=min(self.end,span.end if span else self.end)
|
||||
result=(Action(time,end,(),'space'),)
|
||||
if eligible is not None:return tuple(a for a in result if eligible(a))
|
||||
self.cache[time]=result;return result
|
||||
self.cache[(time,validate)]=result;return result
|
||||
def add(action):
|
||||
if eligible is not None and not eligible(action):return
|
||||
if self._accepted(action):values.append(action)
|
||||
if self._accepted(action,validate=validate):values.append(action)
|
||||
def entry(a,b,pitch):
|
||||
return first is None or first==(a,b,pitch.number())
|
||||
durations=tuple(map(F,self.rules['durations']))
|
||||
@@ -122,7 +135,7 @@ class Operators:
|
||||
for pitch in self.pitches(time):
|
||||
if not entry(time,time+duration,pitch):continue
|
||||
n=self.note(time,time+duration,pitch,'motion')
|
||||
if pitch.number()%12 in types.permitted(self.harmony.choices,time,n.end):
|
||||
if pitch.number()%12 in self.harmony.permitted(time,n.end):
|
||||
add(Action(time,n.end,(n,),'tone'))
|
||||
if self.enabled:
|
||||
delayed=vocabulary.data('lead_ins').get('delayed_approach')
|
||||
@@ -139,7 +152,7 @@ class Operators:
|
||||
for step in delayed['steps']:
|
||||
n=self.note(time,time+F(delayed['max_note']),expressions.chromatic(p,-step),'delayed_approach')
|
||||
if not entry(n.start,n.end,n.pitch):continue
|
||||
if n.pitch.number()%12 in types.permitted(self.harmony.choices,n.start,n.end):continue
|
||||
if n.pitch.number()%12 in self.harmony.permitted(n.start,n.end):continue
|
||||
c=types.ExpressionContract(n.event_id+':delayed','delayed_approach',delayed['id'],(n,),target,region.chord)
|
||||
add(Action(time,n.end,(n,),'delayed_approach',(c,),delayed['id']))
|
||||
# Optional colors retain functional degree identity independently of
|
||||
@@ -254,7 +267,7 @@ class Operators:
|
||||
if first is not None and (time+pattern[0][0],time+pattern[0][1])!=first[:2]:continue
|
||||
for stride in (-2,-1,1,2):
|
||||
# Catalog licenses relative paths, but never sets family probability.
|
||||
if self.sequences and not any(s.steps==(stride,)*(len(pattern)-1) for s in self.sequences.paths.get(len(pattern),())):continue
|
||||
if self.licensed is not None and (stride,)*(len(pattern)-1) not in self.licensed:continue
|
||||
for initial in self.pitches(time):
|
||||
if not entry(time+pattern[0][0],time+pattern[0][1],initial):continue
|
||||
cursor=time;p=initial;notes=[]
|
||||
@@ -265,8 +278,24 @@ class Operators:
|
||||
if index:
|
||||
try:p=types.scale_pitch(region.scale,types.scale_index(region.scale,p.number())+stride)
|
||||
except ValueError:break
|
||||
notes.append(self.note(cursor,time+b,p,'scalar' if abs(stride)==1 else 'arpeggio'))
|
||||
if len(notes)==len(pattern):add(Action(time,time+length,tuple(notes),'scalar' if abs(stride)==1 else 'arpeggio',source=f'{name}:degree_stride:{stride}'))
|
||||
notes.append(self.note(cursor,time+b,p,'scalar' if abs(stride)==1 else 'scale_thirds'))
|
||||
if len(notes)==len(pattern):add(Action(time,time+length,tuple(notes),'scalar' if abs(stride)==1 else 'scale_thirds',source=f'{name}:degree_stride:{stride}'))
|
||||
# A real arpeggio walks successive members of the WRITTEN chord, not
|
||||
# alternate degrees of its selected seven-note collection.
|
||||
for direction in (-1,1):
|
||||
for initial in self.pitches(time):
|
||||
if initial.number()%12 not in span.roles.basic:continue
|
||||
if not entry(time+pattern[0][0],time+pattern[0][1],initial):continue
|
||||
p=initial;notes=[]
|
||||
for index,(a,b) in enumerate(pattern):
|
||||
region=self.harmony.at(time+a)
|
||||
if not region or not region.scale:break
|
||||
if index:
|
||||
choices=[q for q in self.pitches(time+a) if q.number()%12 in region.roles.basic and (q.number()-p.number())*direction>0]
|
||||
if not choices:break
|
||||
p=min(choices,key=lambda q:abs(q.number()-p.number()))
|
||||
notes.append(self.note(time+a,time+b,p,'arpeggio'))
|
||||
if len(notes)==len(pattern):add(Action(time,time+length,tuple(notes),'arpeggio',source=f'{name}:written_chord:{direction}'))
|
||||
# Equivalent musical actions need one stable derivation, not extra votes.
|
||||
unique={}
|
||||
for a in values:unique.setdefault(a.key,a)
|
||||
@@ -274,9 +303,10 @@ class Operators:
|
||||
if eligible is not None or first is not None:return result
|
||||
if len(self.cache)>=32:self.cache.pop(next(iter(self.cache)))
|
||||
if len(self.ordinary_cache)>8192:self.ordinary_cache.clear()
|
||||
self.cache[time]=result
|
||||
self.cache[(time,validate)]=result
|
||||
return result
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def related(self,time,home,kind):
|
||||
"""Construct a heard-theme return or answer as a whole group, never a prefix."""
|
||||
if len(home)<3:return ()
|
||||
|
||||
@@ -4,7 +4,7 @@ import dataclasses, hashlib
|
||||
class Policy:
|
||||
long_note: float = 2.
|
||||
stopping_note: float = 1.
|
||||
composition_version: str = 'joint-actions-1'
|
||||
composition_version: str = 'phrase-frontiers-2'
|
||||
DEFAULT=Policy()
|
||||
def stable_rank(seed, value):
|
||||
return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
|
||||
|
||||
+260
-48
@@ -1,25 +1,42 @@
|
||||
"""Interpretable shared action ranking; no song identity or absolute bar features.
|
||||
"""Canonical sounded-event evaluation, shared by inference and training.
|
||||
|
||||
Complete actions compete by exact cost per quarter, with bounded backtracking.
|
||||
The final report exposes the exact same contributions used by search.
|
||||
Role-specific learned integer coefficients can augment these general priors.
|
||||
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='action-selection-1'
|
||||
VERSION='phrase-selection-2'
|
||||
DEFAULT={'excessive_leap':12,'register_distance':1,'oscillation':6,'repeated_attack':3,
|
||||
'unprepared_space':12,'rest_after_rest':24,'source_relation':-2,'theme_relation':-4,
|
||||
'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'}
|
||||
kinds={'tone','color','color_resolution','anticipation','anticipated_color','approach','enclosure','connection','delayed_approach','space','rest','scalar','arpeggio','return','answer'}
|
||||
allowed={f'kind/{k}' for k in kinds}|{f'duration/{d}' for d in durations}
|
||||
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}
|
||||
@@ -30,9 +47,10 @@ def validate_weights(weights):
|
||||
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():
|
||||
if key not in allowed or not isinstance(value,int) or isinstance(value,bool):
|
||||
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):
|
||||
if any(c not in 'abcdefghijklmnopqrstuvwxyz_0123456789/-=&' for c in key):
|
||||
raise ValueError('Noncanonical fitted feature: '+key)
|
||||
return dict(weights)
|
||||
|
||||
@@ -43,7 +61,21 @@ def signature(notes):
|
||||
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',{}))
|
||||
@@ -52,11 +84,29 @@ class Selector:
|
||||
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)
|
||||
values['kind/'+action.kind]=ticks
|
||||
for contract in action.contracts:
|
||||
if contract.target.start>=action.end and contract.events:
|
||||
# Pending destinations must be visible to selection. Identical heard
|
||||
@@ -65,41 +115,48 @@ class Selector:
|
||||
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
|
||||
if not action.notes:
|
||||
values['space/'+('unharmonized' if action.kind=='space' else 'breath')]=ticks
|
||||
for n in action.notes:
|
||||
duration=n.end-n.start;length=int(duration*12)
|
||||
label=str(duration).replace('/','_')
|
||||
values['duration/'+label]+=length
|
||||
beat=(n.start%1)
|
||||
metric='barline' if n.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,(n.pitch.number()-self.center+6)//12))
|
||||
values[f'register/{band}']+=length
|
||||
region=self.harmony.at(n.start)
|
||||
chord=expressions.parsed_chord(region.chord)
|
||||
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))
|
||||
degree=(n.pitch.number()-root)%12
|
||||
values[f'harmony/{expressions.quality(chord)}/{degree}/{label}']+=length
|
||||
prior['register_distance']+=length*max(0,abs(n.pitch.number()-self.center)-7)
|
||||
if duration>=2 and n.pitch.number()%12 in self.harmony.members(n.start,n.end):
|
||||
prior['sustained_arrival']+=length
|
||||
values['source/rhythm_match']+=length if duration in self.source_rhythms else 0
|
||||
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)
|
||||
if not action.notes:
|
||||
if action.kind=='space':return values,prior
|
||||
ticks=int((action.end-action.start)*12)
|
||||
if previous_kind in ('rest','space'):prior['rest_after_rest']+=ticks
|
||||
elif recent and recent[-1].end-recent[-1].start>=F(3,2):values['space/after_arrival']+=ticks
|
||||
elif recent:prior['unprepared_space']+=ticks
|
||||
else:values['space/delayed_entrance']+=ticks
|
||||
return values,prior
|
||||
# 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:
|
||||
@@ -121,24 +178,72 @@ class Selector:
|
||||
if 0<abs(delta)<=2 and intervals and delta*intervals[-1]>0:prior['directed_motion']+=length
|
||||
intervals.append(delta)
|
||||
previous=n
|
||||
if action.kind=='return' and len(home)>=3:
|
||||
if signature(action.notes)==signature(home):
|
||||
recently_heard=any(signature(recent[i:i+len(home)])==signature(home)
|
||||
for i in range(max(0,len(recent)-len(home)+1)))
|
||||
if not recently_heard:prior['theme_relation']+=int((action.end-action.start)*12)
|
||||
if recent and home:
|
||||
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)
|
||||
count=min(len(home),len(joined))
|
||||
if count>=3 and signature(joined[-count:])==signature(home[:count]):
|
||||
values['theme/realized_identity']+=12
|
||||
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()}|dict(features)
|
||||
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()}
|
||||
@@ -152,3 +257,110 @@ class Selector:
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user