This commit is contained in:
khodowany
2026-10-05 08:51:03 -05:00
parent ef62af892b
commit 654299decd
25 changed files with 1695 additions and 351 deletions
+51 -21
View File
@@ -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 ()