249 lines
12 KiB
Python
249 lines
12 KiB
Python
"""Sustained destinations and bounded memory for the existing theme composer."""
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import functools
|
|
from fractions import Fraction as F
|
|
|
|
import generator.model
|
|
import generator.harmony
|
|
import generator.vocabulary
|
|
from generator.solo import types, policy, sequences, profiles
|
|
|
|
|
|
def heard(notes, operation, anchor=None, bar=F(4)):
|
|
notes = types.sounding_notes(notes)
|
|
first = notes[0].start
|
|
events = tuple((n.start-first, n.end-n.start, n.pitch.number()) for n in notes)
|
|
ending = (anchor.pitch.number()%12, anchor.earliest%bar, anchor.duration) if anchor and anchor.role == 'terminal' else ()
|
|
return types.HeardFigure(first, notes[-1].end, events, operation, ending)
|
|
|
|
|
|
def remember(history, figure, bar):
|
|
return tuple(h for h in (*history, figure) if h.end > figure.end-policy.DEFAULT.history_bars*bar)
|
|
|
|
|
|
@functools.lru_cache(maxsize=4096)
|
|
def fingerprints(events, relative=False):
|
|
result = set()
|
|
for size in (3, 4, 6, 8):
|
|
for i in range(len(events)-size+1):
|
|
group = events[i:i+size]
|
|
start, _, pitch = group[0]
|
|
result.add(tuple((a-start, d, p-pitch if relative else p) for a,d,p in group))
|
|
return frozenset(result)
|
|
|
|
|
|
def repetition(figure, history, bar):
|
|
"""Bounded overuse, not a reward for arbitrary difference or extra attacks."""
|
|
exact, relative = fingerprints(figure.events), fingerprints(figure.events, True)
|
|
overuse = cadence = 0.
|
|
for old in history:
|
|
age = max(0., float((figure.start-old.end)/bar))
|
|
recent = max(0., 1-age/policy.DEFAULT.history_bars)
|
|
a,b = fingerprints(old.events), fingerprints(old.events, True)
|
|
exact_match = len(exact & a)/max(1,min(len(exact),len(a)))
|
|
shifted_match = len(relative & b)/max(1,min(len(relative),len(b)))
|
|
overuse += recent*(.65*exact_match+.35*shifted_match)
|
|
if figure.ending and figure.ending == old.ending:
|
|
cadence += recent
|
|
pitches = tuple(p for _,_,p in figure.events)
|
|
oscillation = sum(a==c and b==d for a,b,c,d in zip(pitches,pitches[1:],pitches[2:],pitches[3:]))/max(1,len(pitches)-3)
|
|
discount = .45 if figure.operation == 'return' else 1.
|
|
return min(1., discount*overuse/2) + .3*oscillation + .3*min(1.,cadence/2)
|
|
|
|
|
|
def register_goal(incoming, part, fallback, profile=None):
|
|
if profile and profile.reference is not None:
|
|
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
|
|
center = round(sum(recent)/len(recent)) if recent else profile.reference
|
|
trajectory=generator.vocabulary.role(part+'_solo').get('register_motion',[0,4,-3,0])
|
|
phase=min(len(trajectory)-1,incoming.closures%len(trajectory))
|
|
goal=profile.reference+trajectory[phase]
|
|
return max(profile.register[0]+3,min(profile.register[1]-3,round((center+goal)/2)))
|
|
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
|
|
if not recent:
|
|
return fallback
|
|
center = sum(recent)/len(recent)
|
|
if part == 'electric' and (center >= 67 or incoming.register_peak >= 72 and center >= 64):
|
|
return max(48, round(center)-7)
|
|
if part == 'acoustic' and max(recent)-min(recent) < 8:
|
|
home = sum(n.pitch.number() for n in incoming.home.notes)/len(incoming.home.notes) if incoming.home else center
|
|
return min(72, round(center)+5) if center <= home+7 else max(45, round(center)-5)
|
|
return round(center)
|
|
|
|
|
|
def endpoint_cost(finish, start, end, count, harmony, bar):
|
|
length = float((finish-start)/bar)
|
|
remaining = float((end-finish)/bar)
|
|
pacing = 0.
|
|
span = harmony.at(finish-F(1,100))
|
|
# A dominant may close an open question, but a nearby resolution is preferred.
|
|
degrees=generator.harmony.degrees(generator.harmony.parse_symbol(span.chord)) if span and span.scale else {}
|
|
dominant=degrees.get(3)==0 and degrees.get(7)==-1
|
|
short_remainder=.2*max(0,4-remaining) if remaining else 0.
|
|
return pacing+(.08 if ending_effect(finish-F(1,100),harmony,False)=='local_settle' else 0)+.035*abs(length-5)+.16*dominant+.1*(length==8)+short_remainder
|
|
|
|
|
|
def window_options(analysis, measures, start, end, harmony, incoming, *, limit=2):
|
|
from generator.solo.themes import source_notes
|
|
bounds=[F()]
|
|
for measure in measures:bounds.append(bounds[-1]+generator.model.fraction(measure['duration']))
|
|
index=next((i for i,b in enumerate(bounds[:-1]) if b<=start<bounds[i+1]),len(measures)-1)
|
|
bar=bounds[index+1]-bounds[index]
|
|
current=harmony.at(start)
|
|
if not current or not current.scale:
|
|
ends=(min(end,current.end if current else bounds[index+1]),)
|
|
else:
|
|
barrier=min((s.start for s in harmony.spans if not s.scale and s.start>start),default=end)
|
|
authored=[n.end-analysis.body_offset for n in analysis.melody if n.end-n.start>=2]
|
|
ends=tuple(sorted({min(end,barrier,bounds[min(index+n,len(measures))]) for n in (3,4,5,6,8)} | {t for t in authored if start+3*bar<=t<=min(end,barrier,start+8*bar)}))
|
|
if barrier>=end:
|
|
# The returning pickup may shorten the last measure. Do not strand a
|
|
# one-beat "new phrase" after an otherwise completed sustained close.
|
|
ends=tuple(sorted({end if 0<end-finish<bar else finish for finish in ends}))
|
|
melody=source_notes(analysis)
|
|
values=[]
|
|
for finish in ends:
|
|
if finish<=start:continue
|
|
notes=tuple(dataclasses.replace(n,start=max(start,n.start),end=min(finish,n.end),continuation=n.start<start)
|
|
for n in melody if n.start<finish and n.end>start)
|
|
values.append(types.SourcePhrase(f'theme_window_{start}_{finish}',start,finish,notes,(),''))
|
|
return tuple(sorted(values,key=lambda s:(endpoint_cost(s.end,start,end,incoming.closures,harmony,bar),s.end))[:limit])
|
|
|
|
|
|
def endings(source, incoming, part, harmony, bar, *, limit=8, final=False):
|
|
"""Choose pitch AND sounding interval before generating the approach."""
|
|
from generator.solo.leads import available
|
|
base=incoming.last.pitch.number() if incoming.last else source.notes[0].pitch.number() if source.notes else 55
|
|
goal=register_goal(incoming,part,base,harmony.profile)
|
|
values=[]
|
|
duration=source.end-source.start
|
|
holds=tuple(F(v) for v in generator.vocabulary.data('grammar')['ending_holds']) if duration>=4 else (min(F(2),duration/2),)
|
|
for hold in holds:
|
|
for release in map(F,generator.vocabulary.data('grammar')['ending_releases']):
|
|
arrival=source.end-release-hold
|
|
if arrival-source.start<F(3,2):continue
|
|
for pitch in available(harmony,arrival,arrival+hold,members=True):
|
|
if abs(pitch.number()-goal)>12:continue
|
|
anchor=types.Anchor(f'{source.id}:close:{arrival}:{pitch.number()}',arrival,arrival,pitch,
|
|
harmony.members(arrival,arrival+hold),harmony.profile.register,hold,'terminal',harmony.at(arrival).chord,
|
|
hold_until=arrival+hold,effect=ending_effect(arrival,harmony,final))
|
|
cadence=sum(h.ending==(pitch.number()%12,arrival%bar,hold) for h in incoming.history)
|
|
rank=harmony.profile.cost(pitch.number())+destination_cost(pitch,arrival,arrival+hold,harmony,incoming,goal,True)+.16*min(2,cadence)+.035*abs(float(hold)-3)
|
|
values.append((rank,anchor))
|
|
selected=destination_options(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])),
|
|
lambda x:x[1].pitch,lambda x:x[1].earliest,harmony,limit)
|
|
return tuple(a for _,a in selected)
|
|
|
|
|
|
def appearance_cost(operation, incoming, start, end):
|
|
gap=start-(incoming.returns[-1] if incoming.returns else F())
|
|
if operation=='return':
|
|
return .5*max(0,1-float(gap)/16)
|
|
return .1*min(1,max(0,float(gap)/32-1))
|
|
|
|
|
|
def activity(source, ending):
|
|
spans=[]
|
|
if ending.earliest>source.start:
|
|
spans.append(types.ActivitySpan(source.start,ending.earliest,'active',F(5,4),F()))
|
|
spans.append(types.ActivitySpan(ending.earliest,ending.hold_until,'sustained_arrival',F(),F(1)))
|
|
if ending.hold_until<source.end:
|
|
spans.append(types.ActivitySpan(ending.hold_until,source.end,'space',F(),F()))
|
|
return tuple(spans)
|
|
|
|
|
|
def evaluate(notes, incoming, harmony, start, end, bar):
|
|
"""Final musical cost uses a complete sounding phrase, never cell totals."""
|
|
notes=types.sounding_notes(notes)
|
|
checked=harmony.evaluate(notes,until=end,complete=True)
|
|
if not checked.valid or not notes:return float('inf')
|
|
density=4*len(notes)/float(end-start)
|
|
entry=max(0,abs(notes[0].pitch.number()-incoming.last.pitch.number())-5)/12 if incoming.last else 0.
|
|
leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(notes,notes[1:]))/max(1,len(notes)-1)
|
|
history_cost=repetition(heard(notes,'develop'),incoming.history,bar)
|
|
return harmony.profile.cost(profiles.weighted_center(notes))+2*checked.exposure+entry+leaps+.8*history_cost
|
|
|
|
|
|
@functools.lru_cache(maxsize=512)
|
|
def defining_members(symbol):
|
|
"""Written chord identity, without inventing omitted thirds or sevenths."""
|
|
chord=generator.harmony.parse_symbol(symbol)
|
|
degrees=generator.harmony.degrees(chord)
|
|
root=generator.model.NATURAL[chord['root']['letter']]+generator.model.alteration(chord['root']['alter'])
|
|
selected={d for d in (3,6,7) if d in degrees}
|
|
if chord['kind'] in ('suspended-second','suspended-fourth'):
|
|
selected.update(d for d in (2,4) if d in degrees)
|
|
if degrees.get(5,0):selected.add(5)
|
|
return frozenset(int(root+generator.harmony.degree_semitones(d,degrees[d]))%12 for d in selected)
|
|
|
|
|
|
def destination_cost(pitch, start, end, harmony, incoming=None, goal=None, terminal=False):
|
|
"""Shared bounded function/trajectory cost at retrieval and final selection."""
|
|
span=harmony.at(start)
|
|
if not span or not span.scale:return float('inf')
|
|
number=pitch.number(); pc=number%12
|
|
if pc not in harmony.members(start,end):return float('inf')
|
|
tonic=generator.model.parse_pitch_name(span.scale.root)
|
|
tonic_pc=int(generator.model.NATURAL[tonic['letter']]+generator.model.alteration(tonic['alter']))%12
|
|
function=0. if pc in defining_members(span.chord) or terminal and pc==tonic_pc else .25
|
|
register=min(.15,abs(number-goal)/80) if goal is not None else 0.
|
|
entry=min(.15,max(0,abs(number-incoming.last.pitch.number())-7)/80) if incoming and incoming.last else 0.
|
|
previous=next((h.ending[0] for h in reversed(incoming.history) if h.ending),None) if incoming else None
|
|
recurrence=.04 if previous==pc else 0.
|
|
following=next((s for s in harmony.spans if s.start>=end and s.scale and s.roles.basic),None)
|
|
lookahead=0.
|
|
if following:
|
|
future=[p for p in range(max(41,number-12),min(76,number+12)+1) if p%12 in following.roles.basic and p%12 in following.scale.pitches]
|
|
if future:lookahead=.04*min(1,min(abs(p-number) for p in future)/5)
|
|
# Contour comes from the heard theme, not a universal upward/nearest rule.
|
|
direction=0.
|
|
if incoming and incoming.home and len(incoming.home.notes)>1 and incoming.last:
|
|
motion=incoming.home.notes[-1].pitch.number()-incoming.home.notes[0].pitch.number()
|
|
direction=.025 if (number-incoming.last.pitch.number())*motion<0 else 0.
|
|
pending=.08 if incoming and incoming.next_destination and pc not in incoming.next_destination else 0.
|
|
return function+register+entry+recurrence+lookahead+direction+pending
|
|
|
|
|
|
def destination_family(pitch, time, harmony):
|
|
return next((degrees for pc,degrees in harmony.at(time).roles.degrees if pc==pitch.number()%12),())
|
|
|
|
|
|
def destination_options(values, rank, pitch, time, harmony, limit):
|
|
"""Keep chord-degree representatives before register/timing variants."""
|
|
ordered=sorted(values,key=rank)
|
|
selected=[];seen=set()
|
|
for value in ordered:
|
|
family=destination_family(pitch(value),time(value),harmony)
|
|
if family not in seen:
|
|
selected.append(value);seen.add(family)
|
|
if len(selected)==limit:return tuple(selected)
|
|
for value in ordered:
|
|
if value not in selected:selected.append(value)
|
|
if len(selected)==limit:break
|
|
return tuple(selected)
|
|
|
|
|
|
def ending_effect(time, harmony, final=False):
|
|
if final: return 'handoff'
|
|
current=harmony.at(time)
|
|
previous=next((s for s in reversed(harmony.spans) if current and s.end<=current.start and s.chord!=current.chord and s.scale),None)
|
|
if not current or not current.scale or not previous: return 'local_settle'
|
|
old=generator.harmony.parse_symbol(previous.chord)
|
|
new=generator.harmony.parse_symbol(current.chord)
|
|
degrees=generator.harmony.degrees(old)
|
|
def root(chord):
|
|
p=chord['root'];return int(generator.model.NATURAL[p['letter']]+generator.model.alteration(p['alter']))%12
|
|
if degrees.get(3)==0 and degrees.get(7)==-1 and (root(old)-root(new))%12==7:
|
|
return 'cadential_arrival'
|
|
return 'local_settle'
|
|
|
|
|
|
def next_destination(anchor,harmony):
|
|
if anchor.effect!='local_settle': return ()
|
|
following=[s for s in harmony.spans if s.start>=anchor.hold_until and s.scale]
|
|
resolution=next((s for s in following[:2] if ending_effect(s.start,harmony)=='cadential_arrival'),None)
|
|
selected=resolution or next(iter(following),None)
|
|
return tuple(sorted(selected.roles.basic & selected.scale.pitches)) if selected else ()
|