"""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, profiles 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, 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 return max(profile.reference-4,min(profile.reference+4,center)) 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+(.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<=startstart),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 0start) 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=(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.start12: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%4,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)+.015*float(release) 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): 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=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 ()