Files
product-vamp/generator/solo/operators.py
T
khodowany ef62af892b Cleanup
2026-10-04 22:29:06 -05:00

297 lines
17 KiB
Python

"""Shared, enumerable musical actions for composition and derivation checking.
An action owns its complete rhythm and path. A rest is an action; an ordinary
attack is not an implied cadence. Compound actions retain approach targets and
whole tuplet groups. This module has no training/reference-file interface.
"""
from __future__ import annotations
import dataclasses
import functools
from fractions import Fraction as F
from collections import Counter
from generator import harmony as chords, vocabulary
from generator.solo import types, expressions, groups, rhythm
@dataclasses.dataclass(frozen=True)
class Action:
start: F
end: F
notes: tuple
kind: str
contracts: tuple = ()
source: str = ''
@functools.cached_property
def key(self):
return (self.start,self.end,self.kind,tuple((n.start,n.end,n.pitch.number()) for n in self.notes),
tuple((c.rule,str(c.boundary),c.target.start,c.target.end,c.target.pitch.number()) for c in self.contracts))
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.sequences=sequences
self.patterns=[]
if rhythms is not None:
library=rhythm.Rhythms(rhythms,analysis.tempo)
# Purposeful curated groups are peers; exhaustive catalogs contribute
# representatives, not a probability proportional to XML file counts.
candidates=library.groups
for meter,values in library.catalog.items():
if any(b-a==F(meter.split('/')[0])*4/int(meter.split('/')[1]) for a,b in zip(analysis.boundaries,analysis.boundaries[1:])):
candidates+=list(values)
seen=set()
for name,pattern,length,_ in candidates:
if 2<=len(pattern)<=4 and length<=4 and (pattern,length) not in seen:
seen.add((pattern,length));self.patterns.append((name,pattern,length))
def pitches(self,time):
span=self.harmony.at(time)
if not span or not span.scale:return ()
if span.scale not in self.pitch_cache:
self.pitch_cache[span.scale]=tuple(p for i in range(-28,29)
if self.harmony.profile.permits((p:=types.scale_pitch(span.scale,i)).number()))
return self.pitch_cache[span.scale]
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()}')
def degree_pitches(self,chord,rule):
spelling=expressions.degree_pitch(chord,rule['degree'],rule['alter'],60)
# Enumerate octaves, not repeated rounding from C-octave centers. Halfway
# pitches (e.g. F#) otherwise disappear because round uses even ties.
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):
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
if any(not self.harmony.profile.permits(n.pitch.number()) for n in action.notes):
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
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
def _color(self,n,span):
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'])
def at(self,time,eligible=None,first=None):
"""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]
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
def add(action):
if eligible is not None and not eligible(action):return
if self._accepted(action):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']))
if self.analysis.tempo>vocabulary.data('roles')['difficulty']['sixteenth_max_bpm']:
durations=tuple(d for d in durations if d>=F(1,2))
for duration in durations:
if time+duration>self.end:continue
if (time*(4 if duration==F(1,4) else 2)).denominator==1:
add(Action(time,time+duration,(),'rest'))
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):
add(Action(time,n.end,(n,),'tone'))
if self.enabled:
delayed=vocabulary.data('lead_ins').get('delayed_approach')
if delayed:
for delay in map(F,delayed['arrivals']):
arrival=time+delay
for hold in map(F,delayed['holds']):
if arrival+hold>self.end:continue
region=self.harmony.at(arrival)
if not region:continue
for p in self.pitches(arrival):
if p.number()%12 not in self.harmony.members(arrival,arrival+hold):continue
target=self.note(arrival,arrival+hold,p,'arrival')
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
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
# the reference/exporter's enharmonic notation. No scale is widened.
for rule in vocabulary.data('harmony')['colors']:
if not expressions.eligible_color(rule,span,self.harmony):continue
for p in self.degree_pitches(expressions.parsed_chord(span.chord),rule):
if not self.harmony.profile.permits(p.number()):continue
for duration in durations:
if duration>F(rule['max_beats']) or time+duration>self.end:continue
if not entry(time,time+duration,p):continue
n=self.note(time,time+duration,p,'color')
if not rule.get('resolve'):
c=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(n,),n,span.chord)
add(Action(time,n.end,(n,),'color',(c,),rule['id']))
else:
for gap in (F(),F(1,2),F(1)):
for q in self.pitches(n.end+gap):
if not 0<abs(q.number()-p.number())<=2:continue
for hold in map(F,self.rules['approach_holds']):
target=self.note(n.end+gap,n.end+gap+hold,q,'arrival')
target_span=self.harmony.at(target.start)
if not target_span or target.end>self.end:continue
c=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(n,),target,target_span.chord)
add(Action(time,n.end,(n,),'color_resolution',(c,),rule['id']))
for boundary in (s.start for s in self.harmony.spans if time<s.start<=time+F(vocabulary.data('harmony')['anticipation_max'])):
region=self.harmony.at(boundary)
for hold in durations:
for p in self.pitches(boundary):
n=self.note(time,boundary,p,'anticipation')
if not entry(n.start,n.end,p):continue
target=self.note(boundary,boundary+hold,p,'arrival')
c=types.ExpressionContract(n.event_id+':anticipation','anticipation','early_reattack',(n,),target,region.chord,boundary=boundary)
add(Action(time,target.end,(n,target),'anticipation',(c,),c.rule))
for duration in durations:
if time+duration<=boundary:continue
for p in self.pitches(boundary):
if not entry(time,time+duration,p):continue
n=self.note(time,time+duration,p,'anticipation')
c=types.ExpressionContract(n.event_id+':anticipation','anticipation','early_chord_tone',(n,),n,region.chord,boundary=boundary)
add(Action(time,n.end,(n,),'anticipation',(c,),c.rule))
for rule in vocabulary.data('harmony')['colors']:
if not expressions.eligible_color(rule,region,self.harmony):continue
for p in self.degree_pitches(expressions.parsed_chord(region.chord),rule):
if not entry(time,time+duration,p):continue
n=self.note(time,time+duration,p,'anticipated_color')
if not rule.get('resolve'):
c=types.ExpressionContract(n.event_id+':anticipated_color','anticipated_color',rule['id'],(n,),n,region.chord,boundary=boundary)
add(Action(time,n.end,(n,),'anticipated_color',(c,),rule['id']))
else:
for q in self.pitches(n.end):
if not 0<abs(q.number()-p.number())<=2:continue
for hold in map(F,self.rules['approach_holds']):
target=self.note(n.end,n.end+hold,q,'arrival')
c=types.ExpressionContract(n.event_id+':anticipated_color','anticipated_color',rule['id'],(n,),target,region.chord,boundary=boundary)
add(Action(time,target.end,(n,target),'anticipated_color',(c,),rule['id']))
for rule in vocabulary.data('lead_ins')['patterns']:
if rule.get('difficulty') and self.analysis.tempo>vocabulary.data('roles')['difficulty'][rule['difficulty']+'_max_bpm']:continue
# A connection's preceding event is explicit in the action. It cannot
# obtain a fictitious preparation from a label or from a future note.
if rule['kind']=='connection':
for preparation in map(F,self.rules['connection_preparations']):
if first is not None and (first[0]!=time or first[1]!=time+preparation):continue
arrival=time+preparation+sum(map(F,rule['durations']),F())+F(rule['gap'])
for hold in map(F,self.rules['approach_holds']):
if arrival+hold>self.end:continue
for p in self.pitches(arrival):
region=self.harmony.at(arrival)
target=self.note(arrival,arrival+hold,p,'arrival')
cursor=time+preparation;notes=[]
for step,length in zip(rule['semitones'],map(F,rule['durations'])):
notes.append(self.note(cursor,cursor+length,expressions.chromatic(p,step),'connection'));cursor+=length
for prep_pitch in self.pitches(time):
if not entry(time,time+preparation,prep_pitch) or not 0<abs(prep_pitch.number()-notes[0].pitch.number())<=2:continue
prep=self.note(time,time+preparation,prep_pitch,'preparation')
c=types.ExpressionContract(prep.event_id+':'+rule['id'],'connection',rule['id'],tuple(notes),target,region.chord,preparation=prep)
add(Action(time,target.end,(prep,*notes,target),'connection',(c,),rule['id']))
continue
gaps=tuple(map(F,rule.get('internal_gaps',[])))
arrival=time+sum(map(F,rule['durations']),F())+sum(gaps,F())+F(rule['gap'])
for hold in map(F,self.rules['approach_holds']):
for p in self.pitches(arrival):
if not entry(time,time+F(rule['durations'][0]),expressions.chromatic(p,rule['semitones'][0])):continue
region=self.harmony.at(arrival)
if not region:continue
target=self.note(arrival,arrival+hold,p,'arrival')
cursor=time;notes=[]
for i,(step,length) in enumerate(zip(rule['semitones'],map(F,rule['durations']))):
notes.append(self.note(cursor,cursor+length,expressions.chromatic(p,step),rule['kind']));cursor+=length+(gaps[i] if i<len(gaps) else 0)
c=types.ExpressionContract(notes[0].event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(notes),target,region.chord)
add(Action(time,target.end,(*notes,target),rule['kind'],(c,),rule['id']))
for colored in self._color(target,region):
add(Action(time,target.end,(*notes,target),rule['kind'],(c,*colored.contracts),rule['id']))
for upcoming in self.harmony.spans:
if not arrival<upcoming.start<target.end or upcoming.start-arrival>F(vocabulary.data('harmony')['anticipation_max']):continue
anticipation=types.ExpressionContract(target.event_id+':early','anticipation','early_chord_tone',(target,),target,upcoming.chord,boundary=upcoming.start)
approach=dataclasses.replace(c,chord=upcoming.chord,boundary=upcoming.start)
add(Action(time,target.end,(*notes,target),rule['kind'],(approach,anticipation),rule['id']))
# Complete directional groups use exact scale indexing at each harmonic
# time. A changed collection retains actual pitches, never degree labels.
lengths_list=[(F(1,2),)*2,(F(1,2),)*4,(F(1),F(1,2),F(1,2)),(F(1,2),F(1),F(1,2))]
difficulty=vocabulary.data('roles')['difficulty']
if self.analysis.tempo<=difficulty['triplet_max_bpm']:lengths_list.append((F(1,3),)*3)
if self.analysis.tempo<=difficulty['sixteenth_max_bpm']:lengths_list.extend(((F(1,4),)*4,(F(1,4),)*2))
patterns=list(self.patterns)
for lengths in lengths_list:
cursor=F();pattern=[]
for d in lengths:pattern.append((cursor,cursor+d));cursor+=d
patterns.append(('complete_subdivision',tuple(pattern),cursor))
for name,pattern,length in patterns:
if time+length>self.end:continue
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
for initial in self.pitches(time):
if not entry(time+pattern[0][0],time+pattern[0][1],initial):continue
cursor=time;p=initial;notes=[]
for index,(a,b) in enumerate(pattern):
cursor=time+a
region=self.harmony.at(cursor)
if not region or not region.scale:break
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}'))
# Equivalent musical actions need one stable derivation, not extra votes.
unique={}
for a in values:unique.setdefault(a.key,a)
result=tuple(unique[k] for k in sorted(unique))
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
return result
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 ()
pattern=tuple((n.start-home[0].start,n.end-home[0].start) for n in home)
if not groups.valid(pattern):return ()
values=[]
for p in self.pitches(time):
notes=[]
for n,(a,b) in zip(home,pattern):
number=p.number()+(n.pitch.number()-home[0].pitch.number())*(1 if kind=='return' else -1)
q=next((q for q in self.pitches(time+a) if q.number()==number),None)
if q is None:break
notes.append(self.note(time+a,time+b,q,kind))
if len(notes)==len(home):
action=Action(time,time+pattern[-1][1],tuple(notes),kind,source='realized_theme')
if self._accepted(action):values.append(action)
return tuple(values)