Solo
ci / checks (push) Failing after 23m43s

This commit is contained in:
khodowany
2026-10-02 06:33:01 -05:00
parent a0bd19b3ba
commit 5fff5a165e
13 changed files with 786 additions and 211 deletions
+161
View File
@@ -0,0 +1,161 @@
"""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
from generator.solo import types, policy, sequences
def heard(notes, operation, anchor=None):
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%4, 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):
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)
projected = count+1+remaining/(32/policy.DEFAULT.settling_target)
low, high = policy.DEFAULT.settling_range
pacing = .2*max(0,low-projected,projected-high)+.025*abs(projected-policy.DEFAULT.settling_target)
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+.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)
ends=tuple(sorted({min(end,barrier,bounds[min(index+n,len(measures))]) for n in (4,5,6,8)}))
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):
"""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)
values=[]
duration=source.end-source.start
holds=(F(2),F(5,2),F(3),F(7,2),F(4)) if duration>=4 else (min(F(2),duration/2),)
for hold in holds:
for release in (F(),F(1,2),F(1),F(2)):
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),(41,76),hold,'terminal',harmony.at(arrival).chord,
hold_until=arrival+hold)
cadence=sum(h.ending==(pitch.number()%12,arrival%4,hold) for h in incoming.history)
rank=abs(pitch.number()-goal)/24+.16*min(2,cadence)+.035*abs(float(hold)-3)+.015*float(release)
values.append((rank,anchor))
selected=sequences.diverse(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])),
lambda x:(x[1].duration,x[1].earliest%bar,x[1].pitch.number()>goal),limit)
return tuple(a for _,a in selected)
def appearance_cost(operation, incoming, start, end):
expected=policy.DEFAULT.return_target*float(start/max(end,F(1)))
actual=len(incoming.returns)
if operation=='return':
gap=start-(incoming.returns[-1] if incoming.returns else F())
return .65*max(0,actual+1-expected)+.5*max(0,1-float(gap)/24)
return .45*max(0,expected-actual)
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 2*checked.exposure+entry+leaps+.35*max(0,4-density,density-6)+.8*history_cost