196 lines
10 KiB
Python
196 lines
10 KiB
Python
"""Instantiate complete authored routes; a failed path is never repaired note by note."""
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import fractions
|
|
|
|
import generator.fretboard
|
|
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:
|
|
degree = types.scale_index(scale, previous)
|
|
return tuple([types.scale_pitch(scale, degree + step)])
|
|
except ValueError:
|
|
# At a changed collection count actual intervening scale tones from the
|
|
# incoming sound. No old degree number is transferred to the new scale.
|
|
if not step:
|
|
return ()
|
|
nearby = sorted((types.scale_pitch(scale, i) for i in range(-28, 35)), key=lambda p: p.number())
|
|
outgoing = [p for p in nearby if (p.number() - previous) * step > 0]
|
|
if step < 0:
|
|
outgoing.reverse()
|
|
return tuple(outgoing[abs(step) - 1:abs(step)])
|
|
|
|
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'):
|
|
harmony.route_cache={}
|
|
harmony.route_libraries={}
|
|
harmony.concrete_routes={}
|
|
harmony.route_libraries[id(library)]=library
|
|
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
|
|
# feasibility. Cache the pitch work across different thematic histories,
|
|
# then restore this caller's provenance before ranking complete routes.
|
|
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,purpose)
|
|
if key not in harmony.route_cache:
|
|
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)
|
|
for i,(n,r) in enumerate(zip(notes,route.notes)))
|
|
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:(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, purpose='connect'):
|
|
"""Every returned candidate includes its entry, complete rhythm and destination.
|
|
|
|
Callers validate the whole surrounding sounding context and guitar path.
|
|
This function applies exact scale paths, fixed events and range constraints.
|
|
"""
|
|
if not 2 <= len(notes) <= 16:
|
|
return ()
|
|
first, destination = notes[0], notes[-1]
|
|
span = harmony.at(first.start)
|
|
if not span or not span.scale:
|
|
return ()
|
|
contexts=tuple((harmony.at(n.start).scale if harmony.at(n.start) else None,
|
|
types.permitted(harmony.choices,n.start,n.end) & (harmony.members(n.start,n.end) if n.end-n.start>=2 else frozenset(range(12))),
|
|
n.pitch.number() if n.event_id in fixed or i==len(notes)-1 else None) for i,n in enumerate(notes))
|
|
if not hasattr(harmony,'pitch_routes'):harmony.pitch_routes={}
|
|
key=(first.pitch,contexts,id(library),register,harmony.profile)
|
|
if key not in harmony.pitch_routes:
|
|
harmony.pitch_routes[key]=pitch_paths(first.pitch,contexts,library,register,harmony.profile)
|
|
if len(harmony.pitch_routes)>8192:harmony.pitch_routes.pop(next(iter(harmony.pitch_routes)))
|
|
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=[]
|
|
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]
|
|
result.append(types.RouteCandidate(identity,realized,destination,route_spans,'connect theme',sources))
|
|
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.
|
|
|
|
Sounding durations and chord roles are still validated on each concrete
|
|
realization. Equal collection contexts can reuse paths across rhythmic edits.
|
|
"""
|
|
from generator.solo.profiles import UNRESTRICTED
|
|
profile = profile or UNRESTRICTED
|
|
if not profile.permits(first.number()): return ()
|
|
destination=contexts[-1][2]
|
|
try:
|
|
displacement=types.scale_index(contexts[0][0],destination)-types.scale_index(contexts[0][0],first.number())
|
|
displacements=(displacement,)
|
|
except ValueError:
|
|
displacements=tuple(range(-7,8))
|
|
direction=(destination>first.number())-(destination<first.number())
|
|
paths=dict.fromkeys(path for d in displacements for path in library.retrieve(len(contexts),d,direction))
|
|
low,high=generator.fretboard.single_register((register[0]-7,register[1]+5))
|
|
low, high = max(low, profile.register[0]), min(high, profile.register[1])
|
|
result=[]
|
|
# Reattacks are an explicit primitive, separate from interval catalog paths
|
|
# (whose zero steps are deliberately excluded). Tied holds remain one event.
|
|
if all(first.number()%12 in allowed and (fixed is None or fixed==first.number())
|
|
for _,allowed,fixed in contexts):
|
|
result.append(((first,)*len(contexts),('primitive:repeated_attack',)))
|
|
for path in paths:
|
|
realized=[first]
|
|
for (scale,allowed,fixed),step in zip(contexts[1:],path.steps):
|
|
if not scale:break
|
|
options=advance(scale,realized[-1].number(),step)
|
|
if not options:break
|
|
pitch=options[0];number=pitch.number()
|
|
if not low<=number<=high or number%12 not in allowed:break
|
|
if fixed is not None and number!=fixed:break
|
|
if not generator.solo.placement.positions(number,profile):break
|
|
realized.append(pitch)
|
|
if len(realized)==len(contexts):result.append((tuple(realized),path.sources))
|
|
return tuple(result)
|