Simplification

This commit is contained in:
khodowany
2026-10-05 16:40:22 -05:00
parent 654299decd
commit bcaf2f1403
49 changed files with 922 additions and 3327 deletions
+3 -2
View File
@@ -24,9 +24,10 @@ 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':'complete_phrase_frontiers','version':policy.DEFAULT.composition_version,
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'source_melodic_phrases','version':policy.DEFAULT.composition_version,
'construction_seconds':time.monotonic()-began,**builder.timings,'operation_coverage':{k:dict(v) for k,v in builder.coverage.items()},
'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)
'whole_stream_diagnostic':dataclasses.asdict(builder.winner_evaluation),
'frontiers':builder.frontiers,'completed_candidates':[{'rank':i+1,'evaluation':{'selection_total': str(s.total / (end-start)), 'phrases': [dataclasses.asdict(p.evaluation) for p in s.phrases]},'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
+66 -251
View File
@@ -1,268 +1,83 @@
"""Bounded search of joint musical actions; one normal production path.
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
"""Bounded comparison of completed source-related phrases."""
import collections
import dataclasses
from fractions import Fraction as F
import fractions
import time
import heapq
import itertools
import functools
import generator.solo.phrases
import generator.solo.selection
import generator.solo.sequences
import generator.solo.policy
import generator.vocabulary
from generator import vocabulary
from generator.solo import types, groups, operators, selection, sequences, scoring
class SearchExhausted(ValueError):pass
class SearchExhausted(ValueError):
pass
@dataclasses.dataclass(frozen=True)
class State:
total: F = F()
recent: tuple = ()
phrases: tuple = ()
home: tuple = ()
last_kind: str = ''
parent: object = None
action: object = None
evaluation: object = None
tie: tuple = ()
pending: tuple = ()
alternatives: tuple = ()
actions: tuple = ()
notes: tuple = ()
full_evaluation: object = None
total: fractions.Fraction = fractions.Fraction()
@property
def notes(self):
return tuple(n for p in self.phrases for n in p.notes)
@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()):
self.analysis=analysis;self.harmony=harmony;self.start=start;self.end=end
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 __init__(self,analysis,measures,harmony,sequences,rhythms,end,seed,enabled=True,*,start=fractions.Fraction()):
self.analysis,self.harmony = analysis,harmony
self.start,self.end,self.seed = start,end,seed
self.phrases = generator.solo.phrases.Phrases(analysis,harmony,sequences,rhythms,start,end,seed,enabled)
self.rules = generator.vocabulary.data('solo')['search']
self.timings = {}; self.frontiers = []; self.coverage = collections.defaultdict(collections.Counter); self.decisions = []
self.failures = self.phrases.rejections
def candidates(self,at,state):
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')
# 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 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 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)
elif obligations:
heard={n.event_id:n for c in state.pending for n in c.events if n.start<action.start}
heard.update({n.event_id:n for n in action.notes})
check=self.harmony.evaluate(tuple(sorted(heard.values(),key=lambda n:n.start)),
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:]
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 candidates(self,start,end,state):
incoming = state.notes[-1] if state.notes else None
values = {}
for variant in range(self.rules['alternatives']):
value = self.phrases.make(start,end,incoming,state.home,state.phrases,variant)
if value is not None:
phrase,introduced = value
self.coverage[phrase.purpose]['valid'] += 1
key = tuple((n.start,n.end,n.pitch.number()) for n in phrase.notes)
values.setdefault(key,value)
for phrase,_ in values.values():
self.coverage[phrase.purpose]['distinct'] += 1
return tuple(values.values())
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)))
return state.total,generator.solo.policy.stable_rank(self.seed,tuple((n.start,n.end,n.pitch.number()) for n in state.notes))
def compose(self):
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()
began = time.monotonic(); self.frontiers = []; self.decisions = []
states = (State(),); start = self.start
while start<self.end:
end = self.phrases.horizon(start)
successors = []
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
path.reverse();phrases=[];pending=[]
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})
# 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):
actions=tuple(s.action for s in states);ns=tuple(n for a in actions for n in a.notes)
start,end=actions[0].start,actions[-1].end
last=ns[-1] if ns else None
held=last and last.end-last.start>=2
ending='handoff' if end==self.end else 'settle' if held else 'open'
anchor=None
if held:
region=self.harmony.at(last.start)
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)
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,
tuple(c for a in actions for c in a.contracts),rhythm,'home',evaluation.total,evaluation,tuple(a.source or a.kind for a in actions))
for phrase,introduced in self.candidates(start,end,state):
total = state.total+phrase.evaluation.total*(end-start)
successors.append(State((*state.phrases,phrase),state.home or (introduced if start>=0 and len(introduced)>=3 else ()),total))
if not successors:
raise SearchExhausted(f'No valid complete phrase at {start}–{end}; failures: {dict(self.failures)}')
successors.sort(key=self.rank)
states = tuple(generator.solo.sequences.diverse(successors,self.rank,
lambda s:(s.phrases[-1].purpose,s.phrases[-1].direction,s.phrases[-1].ending),self.rules['histories']))
for state in states:
self.coverage[state.phrases[-1].purpose]['retained_histories'] += 1
self.frontiers.append({'start':str(start),'end':str(end),'completed':len(successors),'retained':len(states),'best':str(min(s.total for s in states))})
start = end
self.completed = tuple(sorted(states,key=self.rank))
winner = self.completed[0]
self.timings['phrase_search_seconds'] = time.monotonic()-began
self.winner_evaluation = generator.solo.selection.evaluate(winner.notes,self.start,self.end,self.harmony,
home=winner.home,contracts=tuple(c for p in winner.phrases for c in p.expressions),center=self.phrases.center)
for k,v in self.phrases.proposals.items():
self.coverage[k]['proposed'] = v
for p in winner.phrases:
self.coverage[p.purpose]['selected'] += 1
self.decisions = [{'start':str(p.start),'end':str(p.end),'purpose':p.purpose,'ending':p.ending,'evaluation':dataclasses.asdict(p.evaluation)} for p in winner.phrases]
return winner.phrases
-326
View File
@@ -1,326 +0,0 @@
"""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 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.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)
+383
View File
@@ -0,0 +1,383 @@
"""Source-related melodic sentences realized as whole pitch/rhythm routes.
Original song analysis and general vocabulary are the only musical inputs.
"""
import collections
import dataclasses
import fractions
import functools
import generator.model
import generator.solo.expressions
import generator.solo.groups
import generator.solo.policy
import generator.solo.rhythm
import generator.solo.selection
import generator.solo.types
import generator.vocabulary
@dataclasses.dataclass(frozen=True)
class Idea:
rhythm: tuple
intervals: tuple
source: str
@property
def length(self):
return self.rhythm[-1][1]
def idea(notes, source):
first = notes[0]
return Idea(tuple((n.start-first.start,n.end-first.start) for n in notes),
tuple(n.pitch.number()-first.pitch.number() for n in notes),source)
def source_ideas(analysis):
found = {}
for i in range(len(analysis.melody)):
for count in (3,4,5,6):
notes = analysis.melody[i:i+count]
if len(notes)!=count or not 2<=notes[-1].end-notes[0].start<=6:
continue
value = idea(notes,'head:'+str(notes[0].start))
if not generator.solo.groups.valid(value.rhythm):
continue
key = (value.rhythm,value.intervals)
old,number = found.get(key,(value,0)); found[key] = (old,number+1)
def rank(row):
value,count = row
intervals = [b-a for a,b in zip(value.intervals,value.intervals[1:])]
turns = sum(a*b<0 for a,b in zip(intervals,intervals[1:]))
durations = len({b-a for a,b in value.rhythm})
return (-min(count,3),-durations,-min(turns,2),-min(7,max(value.intervals)-min(value.intervals)),value.source)
return tuple(v for v,_ in sorted(found.values(),key=rank)[:12])
class Phrases:
def __init__(self,analysis,harmony,sequences,rhythms,start,end,seed,enabled):
self.analysis,self.harmony = analysis,harmony
self.start,self.end,self.seed,self.enabled = start,end,seed,enabled
self.sequences = sequences
self.ideas = source_ideas(analysis)
library = generator.solo.rhythm.Rhythms(rhythms,analysis.tempo)
self.patterns = tuple(library.groups)
self.arrivals = tuple(sorted({(cell.pattern[-1][1]-cell.pattern[-1][0],cell.duration-cell.pattern[-1][1])
for cell in generator.vocabulary.rhythm_cells(analysis.tempo,'arrival') if cell.pattern and cell.pattern[-1][1]-cell.pattern[-1][0]>=1}))
# Catalog families contribute one peer per rhythmic function, not one
# vote per file. In particular the large 5/4 family cannot dominate 4/4.
representatives = {}
lengths = {b-a for a,b in zip(analysis.boundaries,analysis.boundaries[1:])}
for values in library.catalog.values():
for name,pattern,length,purposes in values:
if length not in lengths or not 2<=len(pattern)<=6:
continue
descriptor = (any(a[1]<b[0] for a,b in zip(pattern,pattern[1:])),any(a%1 for a,b in pattern),len({b-a for a,b in pattern})>1)
representatives.setdefault(descriptor,(name,pattern,length,purposes))
self.patterns += tuple(representatives.values())
self.center = harmony.profile.reference or 60
self.rejections = collections.Counter()
self.proposals = collections.Counter()
def choose(self,values,domain,key):
return min(values,key=lambda x:generator.solo.policy.stable_rank(self.seed+domain,(key,x)))
def horizon(self,start):
region = self.harmony.at(start)
if not region or not region.scale:
return min(self.end,region.end if region else self.end)
gap = next((s.start for s in self.harmony.spans if start<s.start<self.end and not s.scale),self.end)
boundaries = [t-self.analysis.body_offset for t in self.analysis.boundaries if start<t-self.analysis.body_offset<=self.end]
if not boundaries:
return self.end
if start<0:
return min(self.end,fractions.Fraction())
candidates = boundaries[1:8] or boundaries
def cost(end):
previous = self.harmony.at(end-fractions.Fraction(1,8))
before = self.harmony.at(previous.start-fractions.Fraction(1,8)) if previous else None
cadence = False
if previous and before and previous.scale and before.scale:
a = generator.solo.expressions.parsed_chord(before.chord)
b = generator.solo.expressions.parsed_chord(previous.chord)
root = lambda c:int(generator.model.NATURAL[c['root']['letter']]+generator.model.alteration(c['root']['alter']))%12
cadence = generator.solo.expressions.quality(a)=='dominant' and (root(a)-root(b))%12==7
breath = any(n.end==end+self.analysis.body_offset and (i+1==len(self.analysis.melody) or self.analysis.melody[i+1].start>n.end) for i,n in enumerate(self.analysis.melody))
return (abs(boundaries.index(end)+1-4)-2*int(cadence)-int(breath),end)
return min(gap,min(candidates,key=cost))
@functools.lru_cache(maxsize=8192)
def pitches(self,start,end):
span = self.harmony.at(start)
if not span or not span.scale:
return ()
permitted = self.harmony.permitted(start,end)
return tuple(p for index in range(-28,29) if self.harmony.profile.permits((p:=generator.solo.types.scale_pitch(span.scale,index)).number()) and p.number()%12 in permitted)
@staticmethod
def note(start,end,pitch,role):
return generator.solo.types.Note(start,end,pitch,role=role,event_id=f'{start}:{end}:{pitch.number()}')
@functools.lru_cache(maxsize=16384)
def options(self,start,end,near,structural=False):
result = []
span = self.harmony.at(start)
if not span or not span.scale:
return ()
for p in self.pitches(start,end):
if abs(p.number()-near)>12:
continue
note = self.note(start,end,p,'arrival' if structural else 'motion')
if self.harmony.evaluate((note,),complete=True).valid:
if not structural or p.number()%12 in self.harmony.members(start,end):
result.append((note,()))
if self.enabled and end-start>=1:
for rule in generator.vocabulary.data('harmony')['colors']:
if rule.get('resolve') or not generator.solo.expressions.eligible_color(rule,span,self.harmony):
continue
pitch = generator.solo.expressions.degree_pitch(generator.solo.expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],p.number())
if pitch.number()!=p.number():
continue
color = generator.solo.types.ExpressionContract(note.event_id+':'+rule['id'],'color',rule['id'],(note,),note,span.chord)
if self.harmony.evaluate((note,),expressions=(color,),complete=True).valid:
result.append((dataclasses.replace(note,role='color'),(color,)))
return tuple(result)
def scale_step(self,scale,number,step):
"""Continue from sounding pitch, never transfer a degree between maps."""
try:
index = generator.solo.types.scale_index(scale,number)
except ValueError:
# The preceding attack may belong to the previous collection. Its
# pitch stays untouched; enter the new one in the requested direction.
if not step:
return None
entries = [generator.solo.types.scale_pitch(scale,i) for i in range(-28,29)]
entries = [p for p in entries if (p.number()-number)*step>0]
if not entries:
return None
nearest = min(entries,key=lambda p:abs(p.number()-number))
index = generator.solo.types.scale_index(scale,nearest.number())
step -= 1 if step>0 else -1
return generator.solo.types.scale_pitch(scale,index+step)
def route(self,rhythm,contour,incoming,center,mode='develop',*,degree_path=False):
"""DP compares completed paths; no single route step is committed alone."""
if not rhythm:
return None
initial = incoming.pitch.number() if incoming else center
if incoming and mode in ('connect','arpeggio'):
direction = 1 if contour[-1]>=contour[0] else -1
center = initial+direction*2
options = self.options(*rhythm[0],center)
if mode=='arpeggio':
options = tuple(v for v in options if v[0].pitch.number()%12 in self.harmony.at(rhythm[0][0]).roles.basic)
starts = sorted(options,key=lambda v:(abs(v[0].pitch.number()-center)+max(0,abs(v[0].pitch.number()-initial)-5),v[0].pitch.number()))[:3]
states = [(0,(n,),c,n.pitch.number()) for n,c in starts]
for index,(a,b) in enumerate(rhythm[1:],1):
next_states = []
for cost,notes,contracts,origin in states:
wanted = origin+contour[min(index,len(contour)-1)]
expected = contour[min(index,len(contour)-1)]-contour[min(index-1,len(contour)-1)]
if degree_path:
span = self.harmony.at(a)
target = self.scale_step(span.scale,notes[-1].pitch.number(),expected) if span and span.scale else None
if target is None:
continue
wanted = target.number()
expected = wanted-notes[-1].pitch.number()
for note,support in self.options(a,b,wanted):
if degree_path and note.pitch.number()!=wanted:
continue
delta = note.pitch.number()-notes[-1].pitch.number()
if mode=='arpeggio' and (note.pitch.number()%12 not in self.harmony.at(a).roles.basic or delta==0):
continue
error = abs(note.pitch.number()-wanted)+3*int(delta==0 and expected!=0)+max(0,abs(delta)-7)*2
if b-a>=1 and note.pitch.number()%12 not in self.harmony.at(a).roles.basic and not support:
error += 2
next_states.append((cost+error,(*notes,note),(*contracts,*support),origin))
if not next_states:
return None
states = sorted(next_states,key=lambda s:(s[0],tuple(n.pitch.number() for n in s[1])))[:8]
for _,notes,contracts,_ in sorted(states,key=lambda s:(s[0],tuple(n.pitch.number() for n in s[1]))):
if self.harmony.evaluate(notes,expressions=contracts,complete=True).valid:
return notes,contracts
return None
def contour(self,count,direction,mode,motif,time):
if mode in ('statement','return','develop','answer') and motif:
values = motif.intervals
sign = -1 if mode=='answer' else 1
# Explicit reduction/extension; never cycle a route to fit its rhythm.
return tuple(sign*values[i] if i<len(values) else sign*values[-1]+direction*2*(i-len(values)+1) for i in range(count))
paths = self.sequences.connecting(count) if self.sequences else ()
paths = [p for p in paths if max(map(abs,p.steps))<=(3 if mode=='arpeggio' else 2) and (p.offsets()[-1]>0)==(direction>0) and sum(a*b<0 for a,b in zip(p.steps,p.steps[1:]))<=1]
span = self.harmony.at(time)
if paths and span and span.scale:
path = min(paths,key=lambda p:(abs(abs(p.offsets()[-1])-(count-1)),p.rank()))
if mode=='connect':
return path.offsets()
base = generator.solo.types.scale_pitch(span.scale,0)
return tuple(generator.solo.types.scale_pitch(span.scale,i).number()-base.number() for i in path.offsets())
if mode=='connect':
return tuple(direction*i for i in range(count))
return tuple(direction*i*(3 if mode=='arpeggio' else 2) for i in range(count))
def make(self,start,end,incoming,home,history,variant):
if not any(s.scale and s.start<end and s.end>start for s in self.harmony.spans):
self.proposals['space'] += 1
evaluation = generator.solo.selection.evaluate((),start,end,self.harmony)
return generator.solo.types.Phrase('space:'+str(start),start,end,(),'space','level','open',evaluation=evaluation),()
motif = idea(home,'realized_theme') if home else self.choose(self.ideas,'source',variant) if self.ideas else None
purpose = 'statement' if not home else self.choose(('return','develop','answer','extend'),'purpose',(start,variant))
if purpose=='return' and history and history[-1].purpose=='return':
purpose = 'develop'
self.proposals[purpose] += 1
center = self.center+self.choose((-5,0,5),'register',(start,variant))
if incoming:
center = max(incoming.pitch.number()-7,min(incoming.pitch.number()+7,center))
direction = self.choose((-1,1),'direction',(start,variant))
hold,release = self.choose(self.arrivals,'arrival',(start,variant))
close = end==self.end or self.choose((True,True,False),'closure',(start,variant))
arrival = max(start,end-release-hold) if close and end-start>=4 else end
notes,contracts,sources = [],[],[]
cursor = start
if start>=0 and purpose!='return':
cursor += self.choose((fractions.Fraction(),fractions.Fraction(1,2),fractions.Fraction(1)),'entrance',(start,variant))
signature_notes = (); group_index = 0; fast_groups = 0
while cursor<arrival:
span = self.harmony.at(cursor)
if not span or not span.scale:
cursor = min(arrival,span.end if span else arrival); continue
remaining = arrival-cursor
if group_index==0 and motif and motif.length<=remaining:
pattern,length,source = motif.rhythm,motif.length,motif.source
mode = purpose if purpose!='extend' else 'develop'
else:
def suitable(value):
_,pattern,length,purposes = value
fast = any((b-a).denominator not in (1,2) for a,b in pattern)
return (length<=remaining and length>=min(remaining,1)
and bool(set(purposes)&({'continue','connect','develop','answer','scalar','arpeggio'}|({'entrance'} if group_index==0 else set())))
and (not fast or fast_groups<generator.vocabulary.data('solo')['subdivision_groups'])
and generator.solo.groups.valid(tuple((cursor+a,cursor+b) for a,b in pattern)))
eligible = [v for v in self.patterns if suitable(v)]
if eligible:
source,pattern,length,_ = self.choose(eligible,'rhythm',(start,variant,group_index))
else:
length = remaining; pattern,source = ((fractions.Fraction(),length),),'broadening'
mode = self.choose(('connect','develop','arpeggio'),'movement',(start,variant,group_index))
rhythm = tuple((cursor+a,cursor+b) for a,b in pattern)
desired = self.contour(len(pattern),direction,mode,motif,cursor)
phase = float((cursor-start)/max(1,end-start))
local_center = center+direction*round(5*(1-abs(2*phase-1)))
realized = self.route(rhythm,desired,notes[-1] if notes else incoming,local_center,mode,degree_path=mode=='connect')
if realized is None:
self.rejections['route'] += 1; return None
ns,cs = realized
if not signature_notes and len((*notes,*ns))>=3:
signature_notes = tuple((*notes,*ns))
fast_groups += int(any((b-a).denominator not in (1,2) for a,b in pattern))
notes.extend(ns); contracts.extend(cs); sources.append(source)
cursor += length; group_index += 1
anchor = None; ending = 'open'
if arrival<end:
sounding_end = end-release
previous = notes[-1].pitch.number() if notes else center
options = self.options(arrival,sounding_end,previous,True)
if not options:
self.rejections['arrival'] += 1; return None
def destination_cost(value):
note,cs = value
degrees = dict(self.harmony.at(arrival).roles.degrees).get(note.pitch.number()%12,())
return (abs(note.pitch.number()-previous)+abs(note.pitch.number()-center)/3-int(bool(set(degrees)&{3,7})),note.pitch.number())
target,support = sorted(options,key=destination_cost)[variant%min(3,len(options))]
notes.append(target); contracts.extend(support)
ending = 'handoff' if end==self.end else 'complete' if release else 'settle'
anchor = generator.solo.types.Anchor('arrival:'+str(arrival),arrival,arrival,target.pitch,frozenset({target.pitch.number()%12}),
self.harmony.profile.register,sounding_end-arrival,'terminal' if ending in ('complete','handoff') else 'intermediate',
self.harmony.at(arrival).chord,False,sounding_end,ending)
if not notes and any(s.scale and s.start<end and s.end>start for s in self.harmony.spans):
return None
if self.enabled and variant%3 and notes:
notes,contracts = self.expression(tuple(notes),tuple(contracts),signature_notes,start,variant)
checked = self.harmony.evaluate(notes,expressions=contracts,until=end,complete=True)
if not checked.valid:
self.rejections[checked.error] += 1; return None
evaluation = generator.solo.selection.evaluate(notes,start,end,self.harmony,incoming=(incoming,) if incoming else (),
home=home,purpose=purpose,contracts=contracts,center=self.center)
key = generator.solo.policy.stable_rank(self.seed,(start,end,variant))[:12]
pattern = tuple((n.start-start,n.end-start) for n in notes)
try:
groups = generator.solo.groups.describe(pattern,origin=start,source=purpose)
except ValueError:
self.rejections['rhythm_group'] += 1; return None
phrase = generator.solo.types.Phrase(key,start,end,tuple(notes),purpose,'up' if direction>0 else 'down',ending,
anchor,tuple(contracts),groups,'home' if home else key,float(evaluation.total),evaluation,tuple(sources))
return phrase,signature_notes
def expression(self,notes,contracts,protected,start,variant):
protected_ids = {n.event_id for n in protected}
used = {n.event_id for c in contracts for n in (*c.events,c.target)}
targets = [n for n in notes if n.event_id not in used|protected_ids and n.end-n.start>=1 and n.pitch.number()%12 in self.harmony.members(n.start,n.end)]
if not targets:
return notes,contracts
target = self.choose(targets,'expression_target',(start,variant))
previous = next((n for n in reversed(notes) if n.end<=target.start),None)
region = self.harmony.at(target.start)
editable = previous and previous.event_id not in used|protected_ids and previous.end==target.start
if editable and region.start==target.start and target is not notes[-1] and target.end-target.start>=fractions.Fraction(3,2):
for rule in generator.vocabulary.data('harmony').get('suspensions',[]):
if target.pitch.number()-previous.pitch.number() not in rule['steps']:
continue
held = dataclasses.replace(previous,end=target.start+fractions.Fraction(1,2),event_id=previous.event_id+':held',role='suspension')
resolved = dataclasses.replace(target,start=held.end,event_id=target.event_id+':resolved')
c = generator.solo.types.ExpressionContract(held.event_id,'suspension',rule['id'],(held,),resolved,region.chord,boundary=region.start)
revised = tuple(held if n is previous else resolved if n is target else n for n in notes)
if self.harmony.evaluate(revised,expressions=(*contracts,c),complete=True).valid:
return revised,(*contracts,c)
# Anticipation changes the actual rhythm before phrase selection. The
# original committed arrival interval remains sounding in its entirety.
if editable and region.start==target.start and previous.end-previous.start>=1:
early = dataclasses.replace(target,start=target.start-fractions.Fraction(1,2),event_id=target.event_id+':early')
before = dataclasses.replace(previous,end=early.start,event_id=previous.event_id+':short')
contract = generator.solo.types.ExpressionContract(early.event_id,'anticipation','early_chord_tone',(early,),early,region.chord,boundary=region.start)
revised = tuple(early if n is target else before if n is previous else n for n in notes)
if self.harmony.evaluate(revised,expressions=(*contracts,contract),complete=True).valid:
return revised,(*contracts,contract)
# Altered colors resolve to an existing destination; the rule, not the
# annotation or accidental, authorizes their complete exposed interval.
if editable and variant%2 and previous.end-previous.start<=1:
span = self.harmony.at(previous.start)
for rule in generator.vocabulary.data('harmony')['colors']:
if not rule.get('resolve') or not generator.solo.expressions.eligible_color(rule,span,self.harmony):
continue
pitch = generator.solo.expressions.degree_pitch(generator.solo.expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],target.pitch.number())
color = dataclasses.replace(previous,pitch=pitch,event_id=previous.event_id+':color',role='color')
c = generator.solo.types.ExpressionContract(color.event_id,'color',rule['id'],(color,),target,region.chord)
revised = tuple(color if n is previous else n for n in notes)
if self.harmony.profile.permits(pitch.number()) and self.harmony.evaluate(revised,expressions=(*contracts,c),complete=True).valid:
return revised,(*contracts,c)
rules = [r for r in generator.vocabulary.data('lead_ins')['patterns'] if r['kind'] in ('approach','enclosure') and (not r.get('difficulty') or self.analysis.tempo<=generator.vocabulary.data('roles')['difficulty'][r['difficulty']+'_max_bpm'])]
rules = sorted(rules,key=lambda r:generator.solo.policy.stable_rank(self.seed+'expression',(start,variant,r['id'])))
for rule in rules:
gaps = tuple(map(fractions.Fraction,rule.get('internal_gaps',[])))
cursor = target.start-sum(map(fractions.Fraction,rule['durations']),fractions.Fraction())-sum(gaps,fractions.Fraction())-fractions.Fraction(rule['gap'])
affected = [n for n in notes if n.start<target.start and n.end>cursor]
if cursor<start or any(n.event_id in used|protected_ids or n.start<cursor for n in affected):
continue
gesture = []
for i,(delta,length) in enumerate(zip(rule['semitones'],rule['durations'])):
pitch = generator.solo.expressions.chromatic(target.pitch,delta)
gesture.append(self.note(cursor,cursor+fractions.Fraction(length),pitch,rule['kind']))
cursor += fractions.Fraction(length)+(gaps[i] if i<len(gaps) else 0)
if any(not self.harmony.profile.permits(n.pitch.number()) for n in gesture):
continue
before = next((n for n in reversed(notes) if n.end<=gesture[0].start),None)
if before and (abs(before.pitch.number()-gesture[0].pitch.number())>7 or before.end==gesture[0].start and before.pitch==gesture[0].pitch):
continue
revised = tuple(sorted((*[n for n in notes if n not in affected],*gesture),key=lambda n:n.start))
contract = generator.solo.types.ExpressionContract(target.event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(gesture),target,self.harmony.at(target.start).chord)
if self.harmony.evaluate(revised,expressions=(*contracts,contract),complete=True).valid:
return revised,(*contracts,contract)
return notes,contracts
+1 -1
View File
@@ -4,7 +4,7 @@ import dataclasses, hashlib
class Policy:
long_note: float = 2.
stopping_note: float = 1.
composition_version: str = 'phrase-frontiers-2'
composition_version: str = 'melodic-phrases-1'
DEFAULT=Policy()
def stable_rank(seed, value):
return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
-15
View File
@@ -25,24 +25,9 @@ class Instrument:
def permits(self, number):
return any(p.string_number in self.strings for p in generator.fretboard.single_positions_for_pitch(F(number)))
def cost(self, number):
return .5 * min(1., max(0, abs(number - self.reference) - 7) / 12) if self.reference is not None else 0.
UNRESTRICTED = Instrument()
def weighted_center(notes):
values = sorted((n.pitch.number(), n.end - n.start) for n in notes)
total = sum((d for _, d in values), F())
elapsed = F()
for number, duration in values:
elapsed += duration
if elapsed >= total / 2:
return number
return 60
def for_part(part, analysis=None):
if part not in ('acoustic', 'electric'):
raise ValueError('unknown solo instrument: ' + part)
+1 -1
View File
@@ -36,7 +36,7 @@ def structure(solo):
def describe(solo,scales,measure_offset=0,time_offset=0,*,detail=None):
detail=detail or structure(solo)
lines=[f'Independent joint-action solo: {solo.part}', 'Times are exact quarter beats from the chorus start; negative times are allocated lead-ins. Pitches sound one octave below guitar notation.',
lines=[f'Independent melodic-phrase solo: {solo.part}', 'Times are exact quarter beats from the chorus start; negative times are allocated lead-ins. Pitches sound one octave below guitar notation.',
'Approximate fingering costs are not a biomechanical guarantee.',json.dumps(detail['phrasing'])]
for p in solo.phrases:
lines.append(f'{p.id}: {p.start}–{p.end}; {p.purpose}, {p.direction}, {p.ending}; theme {p.theme}; score {p.score:.4f}')
+1 -2
View File
@@ -17,7 +17,7 @@ class Rhythms:
self.groups.append((item['id'],tuple(pattern),cursor,tuple(item['purpose'])))
for cell in vocabulary.rhythm_cells(tempo):
# Imported authored cells remain whole. They are never sliced to fit.
if groups.valid(cell.pattern):self.groups.append((cell.id,cell.pattern,cell.duration,('develop','answer') if cell.purpose=='answer' else ('arpeggio','scalar','develop','neighbor')))
if groups.valid(cell.pattern):self.groups.append((cell.id,cell.pattern,cell.duration,(cell.purpose,)))
# Meter-indexed catalog representatives: file counts do not set style frequency.
self.catalog={}
for meter,items in library.snippets.items():
@@ -30,4 +30,3 @@ class Rhythms:
descriptor=(len(pattern),pattern[0][0]>0,any(a[1]<b[0] for a,b in zip(pattern,pattern[1:])),len(set(b-a for a,b in pattern)))
representatives.setdefault(descriptor,(item.id,pattern,bar.length,('develop','answer')))
self.catalog[meter]=tuple(representatives.values())
self.cache={}
+1 -1
View File
@@ -1,4 +1,4 @@
"""The authoritative action-evaluation record, independent of guitar travel."""
"""The complete-phrase evaluation record, independent of guitar travel."""
from dataclasses import dataclass
from fractions import Fraction as F
+43 -360
View File
@@ -1,366 +1,49 @@
"""Canonical sounded-event evaluation, shared by inference and training.
"""One complete-event evaluator. No learned or constructor-family rewards."""
import collections
import fractions
import generator.solo.scoring
import generator.solo.types
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='phrase-selection-2'
DEFAULT={'excessive_leap':12,'register_distance':1,'oscillation':6,'repeated_attack':3,
'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'}
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}
allowed|={f'register/{i}' for i in range(-4,5)}
allowed|={f'obligation/step/{i}' for i in range(-12,13)}
allowed|={f'obligation/delay/{str(F(i,4)).replace("/","_")}' for i in range(1,21)}
allowed|={f'obligation/hold/{d}' for d in durations}
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():
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):
raise ValueError('Noncanonical fitted feature: '+key)
return dict(weights)
VERSION = 'melodic-phrases-1'
WEIGHTS = {'continuity': 2, 'repetition': 3, 'interruption': 2, 'activity': 2,
'harmony': 2, 'register': 1, 'identity': 3}
def signature(notes):
if not notes:return ()
first=notes[0]
return tuple((n.start-first.start,n.end-n.start,n.pitch.number()-first.pitch.number()) for n in notes)
notes = generator.solo.types.sounding_notes(notes)
if not notes:
return ()
return tuple((n.start-notes[0].start, n.end-n.start, n.pitch.number()-notes[0].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',{}))
self.source_intervals=Counter(b.pitch.number()-a.pitch.number() for a,b in zip(analysis.melody,analysis.melody[1:]))
self.source_pairs=Counter((b.pitch.number()-a.pitch.number(),c.pitch.number()-b.pitch.number())
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)
for contract in action.contracts:
if contract.target.start>=action.end and contract.events:
# Pending destinations must be visible to selection. Identical heard
# attacks with different future obligations are different actions.
last=contract.events[-1];target=contract.target
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
for n in action.notes:
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)
# 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:
length=int((n.end-n.start)*12)
if previous:
delta=n.pitch.number()-previous.pitch.number()
token=max(-12,min(12,delta));label=str(n.end-n.start).replace('/','_')
values[f'motion/{token}/{label}']+=length
if intervals:
# Direction changes are contextual, not a blanket leap prohibition.
sign=lambda x: 'up' if x>0 else 'down' if x<0 else 'repeat'
values[f'continuation/{sign(intervals[-1])}/{sign(delta)}']+=length
if len(intervals)>=2 and intervals[-2]==-intervals[-1]==delta and delta:prior['oscillation']+=length
prior['excessive_leap']+=length*max(0,abs(delta)-7)
prior['repeated_attack']+=length if delta==0 and n.start==previous.end else 0
# Ordered relationships, not the set of available intervals. Weight
# by sounding duration so subdivisions cannot multiply a reward.
if intervals and (intervals[-1],delta) in self.source_pairs:prior['source_relation']+=length
if 0<abs(delta)<=2 and intervals and delta*intervals[-1]>0:prior['directed_motion']+=length
intervals.append(delta)
previous=n
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)
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() 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()}
total=sum(contributions.values(),F())
return scoring.Evaluation(VERSION,True,raw,weights,contributions,total,
{'span':str(action.end-action.start),'ticks_per_quarter':12,'attacks':len(types.sounding_notes(action.notes))})
def cost(self,action,recent=(),home=(),previous_kind=''):
"""Identical selection total without serializing every rejected breakdown."""
features,prior=self.features(action,recent,home,previous_kind)
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())
def evaluate(notes, start, end, harmony, *, incoming=(), home=(), purpose='develop', contracts=(), center=60):
notes = generator.solo.types.sounding_notes(notes)
duration = end-start
if duration <= 0:
raise ValueError('Evaluation requires a positive span')
checked = harmony.evaluate(notes, expressions=contracts, until=end, complete=True)
sounding = sum((n.end-n.start for n in notes), fractions.Fraction())
pitches = [n.pitch.number() for n in notes]
joined = [n.pitch.number() for n in incoming[-1:]]+pitches
intervals = [b-a for a,b in zip(joined, joined[1:])]
continuity = sum(max(0, abs(d)-7) for d in intervals)/max(1,len(intervals))
oscillation = sum(a == -b == c and a != 0 for a,b,c in zip(intervals,intervals[1:],intervals[2:]))
figures = collections.Counter(tuple(pitches[i:i+4]) for i in range(max(0,len(pitches)-3)))
repetition = (oscillation+sum(a==b for a,b in zip(pitches,pitches[1:]))+sum(max(0,n-2) for n in figures.values()))/max(1,len(notes))
interruption = sum(a.end<b.start and a.end-a.start<1 and b.end-b.start<1 for a,b in zip(notes,notes[1:]))/max(1,len(notes))
density = fractions.Fraction(len(notes)*4,1)/duration
active = any(s.scale and s.start<end and s.end>start for s in harmony.spans)
activity = max(0,density-7)+(2 if active and not notes else 0)
if notes and sounding<duration/3:
activity += (duration/3-sounding)/duration
identity = 0
if home and purpose == 'return':
sample = notes[:len(home)]
identity = 2 if len(sample)!=len(home) else sum((a[0],a[1])!=(b[0],b[1]) or (a[2]>0)!=(b[2]>0) for a,b in zip(signature(home),signature(sample)))/len(home)
register = sum(float(n.end-n.start)*max(0,abs(n.pitch.number()-center)-10) for n in notes)/max(1,float(sounding))
values = dict(continuity=continuity,repetition=repetition,interruption=interruption,
activity=activity,harmony=checked.exposure,register=register,identity=identity)
raw = {k:fractions.Fraction(str(v)).limit_denominator(1000000) for k,v in values.items()}
contributions = {k:v*WEIGHTS[k] for k,v in raw.items()}
return generator.solo.scoring.Evaluation(VERSION,checked.valid,raw,dict(WEIGHTS),contributions,
sum(contributions.values(),fractions.Fraction()),{'span':str(duration),'attacks':len(notes),'sounding':str(sounding)})
-28
View File
@@ -5,8 +5,6 @@ import pathlib
import types
import functools
import generator.solo.policy
import generator.model
import generator.musicxml
@@ -24,30 +22,6 @@ class Sequence:
offsets = self.offsets()
return max(map(abs, self.steps)), max(offsets) - min(offsets), sum(a * b < 0 for a, b in zip(self.steps, self.steps[1:])), self.id
@dataclasses.dataclass(frozen=True)
class Path:
steps: tuple[int, ...]
sources: tuple[str, ...]
@functools.lru_cache(maxsize=32768)
def offsets(self):
result = [0]
for step in self.steps:
result.append(result[-1] + step)
return tuple(result)
@functools.lru_cache(maxsize=32768)
def family(steps):
if not steps or all(s == 0 for s in steps):
return 'repeated'
turns = sum(a * b < 0 for a, b in zip(steps, steps[1:]))
if not turns:
return 'rising' if sum(steps) > 0 else 'falling'
if max(map(abs, steps)) >= 4:
return 'leap_return'
if sum(steps) == 0 and max(map(abs, steps)) == 1:
return 'neighbor'
return ('arch' if steps[0] > 0 else 'valley') if turns == 1 else 'wave'
def diverse(items, rank, category, limit):
"""Round-robin eligible musical families before a destructive cutoff."""
groups = {}
@@ -70,7 +44,6 @@ def diverse(items, rank, category, limit):
@dataclasses.dataclass(frozen=True, init=False)
class Library:
paths: types.MappingProxyType
chains: dict
def __init__(self, sequences: tuple[Sequence, ...]):
paths, identities = {}, set()
for sequence in sequences:
@@ -79,7 +52,6 @@ class Library:
identities.add(sequence.id)
paths.setdefault(len(sequence.steps) + 1, []).append(sequence)
object.__setattr__(self, 'paths', types.MappingProxyType({count: tuple(sorted(items, key=lambda s: s.rank())) for count, items in paths.items()}))
object.__setattr__(self, 'chains', {})
def connecting(self, count: int, displacement: int | None = None) -> tuple[Sequence, ...]:
return tuple(s for s in self.paths.get(count, ()) if displacement is None or s.offsets()[-1] == displacement)