"""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.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) # 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] @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()}') 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,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 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 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: 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) result.append(Action(n.start,n.end,(n,),'color',(c,),rule['id'])) return tuple(result) 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,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,validate)]=result;return result def add(action): if eligible is not None and not eligible(action):return 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'])) 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 self.harmony.permitted(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 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 # 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 0self.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 timevocabulary.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 0F(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.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=[] 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 '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) 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,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 () 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)