"""Source-phrase variations: edit actual music, then realize complete routes. The external lead references are audition controls, never production seeds. All notes use sounding coordinates and all time uses quarter-note Fractions. """ from __future__ import annotations import dataclasses import fractions import functools import generator.vocabulary import generator.model import generator.fretboard from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts, features as references, profiles F = fractions.Fraction def signature(notes): return tuple((n.start, n.end, n.pitch.number()) for n in notes) def shift(notes, octave): return tuple(dataclasses.replace(n, pitch=dataclasses.replace(n.pitch, octave=n.pitch.octave+octave), position=None, role='source', event_id=n.event_id or n.sources[0]) for n in notes) def available(harmony, start, end, *, members=False): if not hasattr(harmony,'available_pitches'):harmony.available_pitches={} span=harmony.at(start) allowed=types.permitted(harmony.choices,start,end) if members:allowed &= harmony.members(start,end) if not span or not span.scale:return () key=(span.scale,allowed,harmony.profile) if key not in harmony.available_pitches: harmony.available_pitches[key]=tuple(p for i in range(-35,28) if harmony.profile.register[0]<=(p:=types.scale_pitch(span.scale,i)).number()<=harmony.profile.register[1] and p.number()%12 in allowed and placement.positions(p.number(),harmony.profile)) return harmony.available_pitches[key] def adapt(notes, harmony): """Document required harmonic rewrites; never repair a sequence one step at a time.""" result, edits = [], [] for index, note in enumerate(notes): terminal = index == len(notes)-1 choices = available(harmony,note.start,note.end,members=terminal or note.end-note.start >= 2) pieces = [(note.start,note.end,choices)] if not choices: pieces = [(a,b,available(harmony,a,b,members=terminal or b-a >= 2)) for _,a,b in harmony.segments(note)] for j,(start,end,domain) in enumerate(pieces): if not domain: if harmony.at(start) and harmony.at(start).scale: return (), () edits.append(types.DraftEdit('missing_harmony',start,end,(note.event_id,),(), 'explicit no-chord space')) continue pitch = min(domain,key=lambda p:(abs(p.number()-note.pitch.number()),p.number())) identifier = note.event_id if j == 0 else note.event_id+f':harmonic:{j}' actual = dataclasses.replace(note,start=start,end=end,pitch=pitch,event_id=identifier) result.append(actual) if (start,end,pitch) != (note.start,note.end,note.pitch): edits.append(types.DraftEdit('harmonic_adaptation',start,end,(note.event_id,),(identifier,), f'{note.pitch.label()} to {pitch.label()}: sounding duration / {harmony.at(start).chord}')) if result: last=result[-1] domain=available(harmony,last.start,last.end,members=True) if not domain: return (),() if last.pitch.number()%12 not in harmony.members(last.start,last.end): pitch=min(domain,key=lambda p:(abs(p.number()-last.pitch.number()),p.number())) result[-1]=dataclasses.replace(last,pitch=pitch) edits.append(types.DraftEdit('harmonic_destination',last.start,last.end,(last.event_id,),(last.event_id,), f'{last.pitch.label()} to {pitch.label()}: final sounding arrival before release')) return tuple(result),tuple(edits) def identifying(notes, source): """A complete heard figure, chosen by its musical content rather than its index.""" if not notes: return () count = min(4,max(1,len(notes)//2)) windows = [notes[i:i+count] for i in range(len(notes)-count+1)] def rank(group): intervals = tuple(b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:])) rhythm = {(n.end-n.start) for n in group} # Favor a rhythm/turn with information; leave long holds available for development. return (-len(rhythm),-len(set(intervals)),sum(max(F(),n.end-n.start-1) for n in group),group[0].start) group = min(windows,key=rank) return (types.IdentifyingFeature(source+':figure',tuple(n.event_id for n in group), tuple((n.start-group[0].start,n.end-n.start) for n in group), tuple(b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:])),source),) def variations(notes, source, harmony): """One purposeful timing or pitch edit; no generic replacement openings.""" result=[] if source.pickup and notes and source.start==0: length=-source.pickup[0].start if notes[0].end-length>=F(1,2): pickup=tuple(dataclasses.replace(n,start=n.start+length,end=n.end+length) for n in source.pickup) changed=(*pickup,dataclasses.replace(notes[0],start=length),*notes[1:]) if harmony.evaluate(changed,complete=True).valid: result.append((changed,(types.DraftEdit('pickup_entrance',F(),notes[0].end,(notes[0].event_id,), tuple(n.event_id for n in pickup)+(notes[0].event_id,),source.id+': bring source pickup inside the solo and shorten its first hold'),))) for i,(before,note) in enumerate(zip(notes,notes[1:]),1): if before.end == note.start and before.end-before.start >= 1 and note.end-note.start >= F(1,2): for displacement in (F(-1,2),F(1,2)): time = note.start+displacement if time-before.start < F(1,2) or note.end-time < F(1,2): continue changed=(*notes[:i-1],dataclasses.replace(before,end=time),dataclasses.replace(note,start=time),*notes[i+1:]) if harmony.evaluate(changed,complete=True).valid: edit=types.DraftEdit('rhythmic_paraphrase',before.start,note.end,(before.event_id,note.event_id),(before.event_id,note.event_id), f'{source.id}: retain both pitches and their order; move shared boundary {displacement} beat') result.append((changed,(edit,))) # Structural color is chosen in relation to the original turn, never a new random contour. if 0 < i < len(notes)-1: after=notes[i+1] for p in available(harmony,note.start,note.end,members=note.end-note.start>=1): if p == note.pitch or abs(p.number()-note.pitch.number())>2: continue direction=lambda a,b:(b>a)-(b= F(3,2): tail=dataclasses.replace(notes[-1],end=notes[-1].end-F(1,2)) result.append(((*notes[:-1],tail),(types.DraftEdit('ending_release',tail.end,notes[-1].end,(tail.event_id,),(tail.event_id,),source.id+': half-beat breath after the arrival'),))) return tuple(result) def rhythm_catalog(library, meter): # Keep complete snippet objects. Slices below retain real rests and durations. return drafts.catalog(library,meter) @functools.lru_cache(maxsize=256) def connection_windows(catalog,length): spans={( (F(),length), ):'held'} for identifier,pattern,total in catalog: if total < length: continue # Retrieve every exact-duration window, including offbeat and cross-bar windows. for offset in sorted({F(),*(a for a,_ in pattern)}): if offset+length>total: continue if any(a=2: result.append(((*events,destination),identifier)) return result def elaborate(notes, features, harmony, library, rhythms, meter, source, *, sustain_entry=False, tails=None): """Replace complete internal spans while retaining identity and sounding tails.""" legacy=tails is None tails=tails or () fixed={e for f in features for e in f.events} if legacy else {n.event_id for n in notes if n.role=='quote'} windows=[] for i,(note,target) in enumerate(zip(notes,notes[1:])): if note.event_id in fixed or note.end!=target.start:continue if note.end-note.start>=F(3,2): start=max(note.start+F(1,2),target.start-3) windows.append((i,i+1,start,target)) if not legacy: for j in range(i+2,min(i+5,len(notes))): target=notes[j] if not 1<=target.start-note.start<=3:continue if any(n.event_id in fixed for n in notes[i:j]):continue if any(t.startnote.start for t in tails):continue windows.append((i,j,note.start,target)) if legacy and notes and notes[-1].event_id not in fixed and notes[-1].end-notes[-1].start>=F(5,2): tail=notes[-1] for duration in (F(1,2),F(1),F(3,2),F(2)): target=dataclasses.replace(tail,start=tail.end-duration,event_id=tail.event_id+':arrival') start=max(tail.start+F(1,2),target.start-3) if target.start-start>=1:windows.append((len(notes)-1,len(notes)-1,start,target)) # Consequential edits only; do not exhaustively branch at each passing note. windows=sequences.diverse(windows,lambda w:(policy.stable_rank(source.id,w),),lambda w:w[0],8) result=[] for i,j,start,target in windows: entry=notes[i] prefix=dataclasses.replace(entry,end=start) seed=dataclasses.replace(entry,start=start,event_id=entry.event_id+':tail',role='connection') for pattern,rhythm_id in connection_patterns(start,target.start,seed,target,rhythms,meter): for route in routes.candidates(pattern,{target.event_id},harmony,library,(41,76),'develop '+source.id): if sustain_entry or start==entry.start: joined=dataclasses.replace(route.notes[0],start=entry.start,event_id=entry.event_id) route=dataclasses.replace(route,notes=(joined,*route.notes[1:])) actual=(*notes[:i],*route.notes,*notes[j+1:]) else: actual=(*notes[:i],prefix,*route.notes,*notes[j+1:]) if any(references.feature_error(f,actual) for f in features):continue if any(not any(n.pitch.number()==t.pitch and n.start<=t.start and n.end>=t.end for n in actual) for t in tails):continue if not harmony.evaluate(actual,complete=True).valid:continue if any(abs(b.pitch.number()-a.pitch.number())>7 for a,b in zip(route.notes,route.notes[1:])):continue route=dataclasses.replace(route,sources=(*route.sources,'rhythm:'+rhythm_id)) edit=types.DraftEdit('complete_route',start,target.start,tuple(n.event_id for n in notes[i:j+1]),tuple(n.event_id for n in route.notes), source.id+'; '+rhythm_id+'; lead into '+target.pitch.label()) result.append((actual,edit,route)) return sequences.diverse(result,lambda v:(max(0,4-len(v[0])*4/float(source.end-source.start),len(v[0])*4/float(source.end-source.start)-6),v[2].id), lambda v:(v[1].start,v[2].destination.end-v[2].destination.start,len(v[2].notes)),8) def make_phrase(source, original, refined, notes, edits, route_list, octave, part, harmony, tempo, seed): from generator.solo import realization features=identifying(refined,source.id) # Only fully retained source turns become committed anchor events. anchors=[] for i,n in enumerate(notes): if n.pitch.number()%12 not in harmony.members(n.start,n.end): continue role='terminal' if i==len(notes)-1 else 'opening' if i==0 else 'intermediate' if role=='intermediate' and n.end-n.start<2: continue anchors.append(types.Anchor(n.event_id+':anchor',n.start,n.start,n.pitch,harmony.members(n.start,n.end), (41,76),n.end-n.start,role,harmony.at(n.start).chord,hold_until=n.end)) if notes and (not anchors or anchors[-1].role!='terminal'): return None identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,signature(notes))[:12] motif=types.Motif(source.id,tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start) for n in refined), source.end-source.start,tuple(s.chord for s in harmony.spans if s.startsource.start),'song melody '+source.family) home=realization.make_identity(identifier,source.id,refined,source.start,source.end,'source_variation',harmony) transform=types.Transformation(identifier,source.id,source.family,'source_variation',source.start,source.end, tuple(n.event_id for n in notes),tuple((n.start-source.start,n.end-source.start) for n in notes),preserves=(),source=source.id) draft=types.MelodicDraft(identifier,source.start,source.end,notes,(transform,), tuple(a.id.removesuffix(':anchor') for a in anchors),(),tuple(anchors),home,'source_variation',features=features, reference=source.id,reference_motif=motif,source_phrase=source,octave_transfer=octave, source_links=tuple((n.event_id,n.sources) for n in notes)) spaces=[];cursor=source.start for n in notes: if n.start>cursor:spaces.append(types.ActivitySpan(cursor,n.start,'space',F(),F())) spaces.append(types.ActivitySpan(n.start,n.end,'active',F(5,4),F())) cursor=n.end if cursor=end:break finish=min(source.end,end) notes=tuple(dataclasses.replace(n,start=max(F(),n.start),end=min(n.end,finish)) for n in source.notes if n.start0) result.append(dataclasses.replace(source,end=finish,notes=notes,rests=tuple((a,min(b,finish)) for a,b in source.rests if astart: meter=measure['time_signature'];break bar=F(meter.split('/')[0])*4/int(meter.split('/')[1]) harmony.bar=bar for source in sources: key=('phrasing',source,incoming,part,end,stage,seed,id(library),id(rhythms),policy.DEFAULT,harmony.profile) if key in context.results: pool=context.results[key];context.diagnostics['cache_hits']+=1 else: pool=() if not harmony.at(start) or not harmony.at(start).scale: pool=themes.silence(source,incoming,part,harmony,analysis.tempo,seed) else: for batch in range(policy.DEFAULT.replan_attempts): pool=themes.phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,analysis.tempo,context,seed, stage=stage,batch=batch,final=source.end==end,solo_end=end) if pool:break context.diagnostics['theme_replans']=context.diagnostics.get('theme_replans',0)+1 context.results[key]=pool context.diagnostics.setdefault('source_candidate_pools',{}).setdefault(source.id,[]).extend( {'id':p.draft.id,'score':p.score,'attacks':len(p.draft.notes),'theme':p.state.home.id if p.state.home else None, 'ending':str(p.anchors[-1].duration) if p.anchors else None,'returns':len(p.state.returns),'evaluation':dataclasses.asdict(p.evaluation) if p.evaluation else None} for p in pool) for candidate in pool: span=float((source.end-source.start)/end) total=cost+span*candidate.score following.append((total,(*history,candidate))) if not pool:failures.append(f'{source.id}: planned sustained destination, feature or incoming continuation') if not following: raise realization.SearchExhausted(f'{part}: {"; ".join(failures[-4:])} failed within the bounded search; not proof of incompatibility') def rank(value): cost,history=value;state=history[-1].state;elapsed=history[-1].plan.end musical=cost/max(.001,float(elapsed/end)) return musical,policy.stable_rank(seed,tuple(p.draft.id for p in history)) states=sequences.diverse(following,rank, lambda v:(v[1][-1].state.home.id if v[1][-1].state.home else '',v[1][-1].plan.end,len(v[1][-1].state.returns)),policy.DEFAULT.chorus_frontier) finished=[(cost,history) for cost,history in states if history[-1].plan.end==end] if not finished:raise realization.SearchExhausted(f'{part}: phrase-boundary search budget exhausted; not proof of incompatibility') return tuple((rank((cost,phrases))[0],types.Solo(part,choices,phrases,analysis.tempo,end, {**context.diagnostics,'selection_total':rank((cost,phrases))[0], 'selection_scope':'duration-weighted complete phrase evaluations; partial frontiers use elapsed-duration rates', 'construction':'thematic_recomposition','policy':policy.describe()['fingerprint']}, tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases),harmony.profile)) for cost,phrases in finished) def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'): from generator.solo import realization if mode not in ('thematic','paraphrase'):raise ValueError('Unknown solo treatment: '+mode) if mode=='thematic': return thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage) context.resources[id(library)]=library context.resources[id(rhythms)]=rhythms harmony=harmonic.Harmony(choices, profiles.for_part(part,analysis)) sources=clipped_sources(analysis,end) seed=policy.stable_rank('source_variations', (analysis.melody,choices,policy.SEED_NAMESPACE)) states=[(0.,())] for source in sources: meter=measures[min(len(measures)-1,int(source.start//generator.model.fraction(measures[0]['duration'])))]['time_signature'] following=[] for cost,history in states: incoming=history[-1].state if history else types.ContinuationState('') key=(mode,source,None,policy.stable_rank("harmony",choices),part,analysis.tempo,seed,stage,id(library),id(rhythms),policy.DEFAULT) if key in context.results: pool=context.results[key];context.diagnostics['cache_hits']+=1 else: pool=candidates(source,harmony,library,rhythms,meter,part,analysis.tempo,seed,stage=stage) context.results[key]=pool;context.diagnostics['generated_candidates']+=len(pool) context.diagnostics.setdefault('source_candidate_pools', {}).setdefault(source.id,[]).extend( {'id':p.draft.id,'score':p.score,'octave':p.draft.octave_transfer,'attacks':len(p.draft.notes),'edits':[e.kind for e in p.edits],'source_family':source.family,'theme':p.state.home.id if p.state.home else None} for p in pool) for candidate in pool: last=incoming.last placements=placement.alternatives(tuple(n for d in candidate.decisions for n in d.notes),analysis.tempo,last,limit=2) for travel,notes in placements: decision=dataclasses.replace(candidate.decisions[0],notes=notes) if candidate.decisions else None state=dataclasses.replace(candidate.state,notes=notes,last=notes[-1] if notes else last,travel=travel) placed=dataclasses.replace(candidate,decisions=(decision,) if decision else (),state=state) total=cost+candidate.score*float(source.end-source.start)/float(end)+.05*travel/max(1,len(notes))/2000+transition(history[-1] if history else None,placed) following.append((total,(*history,placed))) if not following: raise realization.SearchExhausted(f'{part} {source.id}: {mode} feature, harmonic destination or continuation failed within the bounded search; not proof of incompatibility') states=sequences.diverse(following,lambda v:(v[0],policy.stable_rank(seed,tuple((p.draft.id,p.state.last.position if p.state.last else None) for p in v[1]))), lambda v:(tuple(p.draft.octave_transfer for p in v[1])),policy.DEFAULT.chorus_frontier) return tuple((cost,types.Solo(part,choices,phrases,analysis.tempo,end,{**context.diagnostics,'construction':'source_phrase_variations','policy':policy.describe()['fingerprint']}, tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases),harmony.profile)) for cost,phrases in states) def similarity(first,second): """Compare composition independently of octave and fingering; callbacks are explicit.""" def events(solo): callbacks={e for p in solo.phrases if p.draft for f in p.draft.features if f.id.endswith(':callback') for e in f.events} return tuple(n for p in solo.phrases if p.plan.relationship!='callback' for d in p.decisions for n in d.notes if n.event_id not in callbacks) a,b=events(first),events(second) def tokens(notes): return {(n.start,n.end,n.pitch.number()%12) for n in notes} left,right=tokens(a),tokens(b) literal=len(left&right)/max(1,min(len(left),len(right))) def runs(notes): return {tuple((n.start-notes[i].start,n.end-n.start,n.pitch.number()-notes[i].pitch.number()) for n in notes[i:i+4]) for i in range(len(notes)-3)} left,right=runs(a),runs(b) interval=len(left&right)/max(1,min(len(left),len(right))) return .65*literal+.35*interval