297 lines
17 KiB
Python
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)
|