"""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', expressions=()): if expressions: return planned_candidates(notes,fixed,harmony,register,relationship,expressions) # 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 planned_candidates(notes,fixed,harmony,register,relationship,contracts): # Cache complete contract-conditioned pitch work, not a next-note suggestion. # IDs/provenance are restored below; timing, pitch, role, obligations and # profile remain in the key. The cache is owned by this harmonic timeline. ids={n.event_id:str(i) for i,n in enumerate(notes)} canonical=tuple(dataclasses.replace(n,sources=(),event_id=str(i)) for i,n in enumerate(notes)) def note(n):return canonical[int(ids[n.event_id])] obligations=tuple(dataclasses.replace(c,id=str(i),events=tuple(map(note,c.events)), target=note(c.target),preparation=note(c.preparation) if c.preparation else None) for i,c in enumerate(contracts)) locked=frozenset(ids[i] for i in fixed) key=(canonical,locked,register,harmony.profile,obligations) if not hasattr(harmony,'planned_route_cache'):harmony.planned_route_cache={} if key not in harmony.planned_route_cache: harmony.planned_route_cache[key]=_planned_candidates(canonical,locked,harmony,register,'',obligations) if len(harmony.planned_route_cache)>8192:harmony.planned_route_cache.pop(next(iter(harmony.planned_route_cache))) result=[] for route in harmony.planned_route_cache[key]: actual=tuple(dataclasses.replace(original,pitch=realized.pitch,position=realized.position) for original,realized in zip(notes,route.notes)) result.append(dataclasses.replace(route,notes=actual,destination=actual[-1],relationship=relationship)) return tuple(result) def _planned_candidates(notes,fixed,harmony,register,relationship,contracts): """Solve the whole route around explicit, preselected harmonic obligations. Fixed expressive pitches have narrow permissions; all other attacks use the map. Complete contracts are checked after realization, never inferred merely from a label. No incomplete route is handed back to the next cell. """ from generator.solo import gestures proposal=gestures.Proposal(notes,contracts,relationship) locked={n.event_id for c in contracts for n in (*c.events,c.target)}|set(fixed)|{notes[0].event_id,notes[-1].event_id} special={n.event_id for c in contracts for n in c.events} states=[(0.,(notes[0],))] for i,n in enumerate(notes[1:],1): span=harmony.at(n.start) if not span or not span.scale:return () allowed=types.permitted(harmony.choices,n.start,n.end) if n.end-n.start>=2 and n.event_id not in special:allowed &= harmony.members(n.start,n.end) target=next((a for a in notes[i:] if a.event_id in locked),notes[-1]) remaining=max(1,sum(a.start12:continue # Continuation aims at the known future destination. Selected thirds # and fourths remain feasible; repeated oscillation is not progress. distance=abs(target.pitch.number()-number) remaining_cost=max(0,distance-5*remaining)/12 repeating=len(path)>1 and path[-2].pitch==prior.pitch==pitch value=cost+max(0,abs(motion)-7)/12+.2*repeating following.append((value,(*path,dataclasses.replace(n,pitch=pitch,position=None)),remaining_cost)) chosen=generator.solo.sequences.diverse(following,lambda v:(v[0]+v[2],tuple(n.pitch.number() for n in v[1])), lambda v:(generator.solo.sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(v[1],v[1][1:]))),v[1][-1].pitch.number()),8) states=[(cost,path) for cost,path,_ in chosen] if not states:return () result=[] for _,actual in states: bound=proposal.bind(actual) if not harmony.evaluate(actual,until=actual[-1].end,complete=True,expressions=bound).valid:continue identity=generator.solo.policy.stable_rank('planned-route',tuple((n.start,n.end,n.pitch) for n in actual))[:16] result.append(types.RouteCandidate(identity,actual,actual[-1],(),relationship,tuple('gesture:'+c.rule for c in bound))) return tuple(result) 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.startfirst.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)first.number())-(destination