This commit is contained in:
khodowany
2026-10-04 12:27:43 -05:00
parent 8fdd736e13
commit 405066eb43
18 changed files with 810 additions and 71 deletions
+72 -8
View File
@@ -9,6 +9,22 @@ import generator.solo.placement
import generator.solo.policy
import generator.solo.sequences
import generator.solo.types as types
import generator.vocabulary
def path_cost(notes):
"""Penalize unexplained extremes, not every third or arpeggio interval."""
intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(notes,notes[1:])]
excess=sum(max(0,abs(v)-7) for v in intervals)/max(1,len(intervals))
oscillation=sum(a!=0 and a==-b and a==c and b==d for a,b,c,d in zip(intervals,intervals[1:],intervals[2:],intervals[3:]))
return excess+.1*oscillation
def path_family(notes,harmony):
intervals=tuple(b.pitch.number()-a.pitch.number() for a,b in zip(notes,notes[1:]))
chordal=all(n.pitch.number()%12 in harmony.members(n.start,n.end) for n in notes)
return ('arpeggio' if chordal and any(abs(v)>=3 for v in intervals) else 'mapped',
generator.solo.sequences.family(intervals))
def advance(scale, previous, step):
try:
@@ -25,7 +41,7 @@ def advance(scale, previous, step):
outgoing.reverse()
return tuple(outgoing[abs(step) - 1:abs(step)])
def candidates(notes, fixed, harmony, library, register, relationship='connect theme'):
def candidates(notes, fixed, harmony, library, register, relationship='connect theme', *, purpose='connect'):
# Cache concrete routes only with their endpoints, timing, scale context and
# vocabulary. The per-Harmony cache cannot leak across collection changes.
if not hasattr(harmony,'route_cache'):
@@ -33,7 +49,7 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
harmony.route_libraries={}
harmony.concrete_routes={}
harmony.route_libraries[id(library)]=library
concrete_key=(notes,frozenset(fixed),id(library),register,harmony.profile)
concrete_key=(notes,frozenset(fixed),id(library),register,harmony.profile,purpose)
if concrete_key in harmony.concrete_routes:
return tuple(dataclasses.replace(r,relationship=relationship) for r in harmony.concrete_routes[concrete_key])
# Event/source IDs and non-fixed placeholder pitches do not affect route
@@ -42,9 +58,9 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
fixed_indices=frozenset(i for i,n in enumerate(notes) if n.event_id in fixed)
canonical=tuple(dataclasses.replace(n,sources=(),event_id=str(i),role='connection',position=None,
pitch=n.pitch if i==0 or i==len(notes)-1 or i in fixed_indices else types.Pitch('c',fractions.Fraction(),0)) for i,n in enumerate(notes))
key=(canonical,fixed_indices,id(library),register,harmony.profile)
key=(canonical,fixed_indices,id(library),register,harmony.profile,purpose)
if key not in harmony.route_cache:
harmony.route_cache[key]=_candidates(canonical,{str(i) for i in fixed_indices},harmony,library,register)
harmony.route_cache[key]=_candidates(canonical,{str(i) for i in fixed_indices},harmony,library,register,purpose)
result=[]
for route in harmony.route_cache[key]:
actual=tuple(n if i==0 else dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==r.pitch.number() else r.pitch,position=None)
@@ -52,15 +68,15 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
identity=generator.solo.policy.stable_rank('complete-route',(actual,route.sources))[:16]
result.append(dataclasses.replace(route,id=identity,notes=actual,destination=notes[-1],relationship=relationship))
selected=generator.solo.sequences.diverse(result,
lambda r:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(r.notes,r.notes[1:])),r.id),
lambda r:generator.solo.sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(r.notes,r.notes[1:]))),
lambda r:(path_cost(r.notes),r.id),
lambda r:path_family(r.notes,harmony),
generator.solo.policy.DEFAULT.join_alternatives)
if len(harmony.concrete_routes)>=8192:harmony.concrete_routes.pop(next(iter(harmony.concrete_routes)))
if len(harmony.route_cache)>=8192:harmony.route_cache.pop(next(iter(harmony.route_cache)))
harmony.concrete_routes[concrete_key]=selected
return selected
def _candidates(notes, fixed, harmony, library, register):
def _candidates(notes, fixed, harmony, library, register, purpose='connect'):
"""Every returned candidate includes its entry, complete rhythm and destination.
Callers validate the whole surrounding sounding context and guitar path.
@@ -83,7 +99,24 @@ def _candidates(notes, fixed, harmony, library, register):
route_spans=tuple((max(first.start,c.span.start),min(destination.end,c.span.end),c.scale.label())
for c in harmony.choices if c.scale and c.span.start<destination.end and c.span.end>first.start)
result=[]
for pitches,sources in harmony.pitch_routes[key]:
paths=list(harmony.pitch_routes[key])
# These are traversal operators, not stored phrases. An arpeggio prefix
# adapts to the chord at each note and a complete scale route then connects
# it to this caller's actual destination. No endpoint is attacked twice.
for prefix,rule in chord_prefixes(notes,fixed,harmony,register,purpose):
i=len(prefix)-1
if i==len(notes)-1:
if prefix[-1].pitch.number()==destination.pitch.number():
paths.append((tuple(n.pitch for n in prefix),('chord-path:'+rule,)))
continue
suffix_key=(prefix[-1].pitch,contexts[i:],id(library),register,harmony.profile)
if suffix_key not in harmony.pitch_routes:
while len(harmony.pitch_routes)>=8192:
harmony.pitch_routes.pop(next(iter(harmony.pitch_routes)))
harmony.pitch_routes[suffix_key]=pitch_paths(prefix[-1].pitch,contexts[i:],library,register,harmony.profile)
for tail,sources in harmony.pitch_routes[suffix_key][:2]:
paths.append((tuple(n.pitch for n in prefix[:-1])+tail,('chord-path:'+rule,*sources)))
for pitches,sources in paths:
realized=(first,*(dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==p.number() else p,position=None)
for n,p in zip(notes[1:],pitches[1:])))
identity=generator.solo.policy.stable_rank('complete-route',(realized,sources))[:16]
@@ -91,6 +124,37 @@ def _candidates(notes, fixed, harmony, library, register):
return tuple(result)
def chord_prefixes(notes,fixed,harmony,register,purpose):
from generator.solo import phrasing
if len(notes)<2:return ()
values=[]
first=notes[0];destination=notes[-1]
for rule in generator.vocabulary.data('melodic_vocabulary')['chord_paths']:
if purpose not in rule['purpose']:continue
if rule['require_chord_entry'] and first.pitch.number()%12 not in harmony.members(first.start,first.end):continue
displacement=destination.pitch.number()-first.pitch.number()
direction=1 if rule['direction']=='up' else -1 if rule['direction']=='down' else (1 if displacement>=0 else -1)
for count in rule['prefix_attacks']:
if count>len(notes):continue
prefix=[first]
for i,n in enumerate(notes[1:count]):
span=harmony.at(n.start)
if not span or not span.scale:break
eligible=harmony.members(n.start,n.end)&types.permitted(harmony.choices,n.start,n.end)
if rule['members']=='defining':eligible=eligible&phrasing.defining_members(span.chord)
step=rule['member_steps'][i%len(rule['member_steps'])]
old=prefix[-1].pitch.number()
pitches=sorted((types.scale_pitch(span.scale,j) for j in range(-21,29)),key=lambda p:p.number(),reverse=direction*step<0)
options=[p for p in pitches if p.number()%12 in eligible and 0<(p.number()-old)*direction*step
and abs(p.number()-old)<=rule['max_interval'] and register[0]<=p.number()<=register[1] and harmony.profile.permits(p.number())]
if len(options)<abs(step):break
selected=options[abs(step)-1]
if (n.event_id in fixed or i+1==len(notes)-1) and selected.number()!=n.pitch.number():break
prefix.append(dataclasses.replace(n,pitch=selected))
if len(prefix)==count:values.append((tuple(prefix),rule['id']))
return tuple(values)
def pitch_paths(first, contexts, library, register, profile=None):
"""Time-independent pitch work; context includes every overlapped collection.