"""Thematic transformations used by the existing complete-draft composer. Source features initialize a heard theme. Subsequent complete routes develop that realization, conditioned on the actual incoming musical state. """ from __future__ import annotations import dataclasses from fractions import Fraction as F import generator.model import generator.harmony import generator.vocabulary from generator.solo import types, policy, sequences, routes, features as references, similarity, phrasing, profiles def source_notes(analysis): return tuple(dataclasses.replace(n,start=max(F(),n.start-analysis.body_offset),end=n.end-analysis.body_offset, event_id=n.sources[0],position=None) for n in analysis.melody if n.end>analysis.body_offset) def silence(source,incoming,part,harmony,tempo,seed): from generator.solo import realization motif=types.Motif('intentional_space',(),source.end-source.start,(),'explicit missing harmony') plan=types.PhrasePlan(part+'_'+source.id,source.start,source.end,motif,'space','thematic_recomposition',None,harmony.profile.register,F(),F(), (types.ActivitySpan(source.start,source.end,'space',F(),F()),),passage_id=source.id,passage_start=source.start,passage_end=source.end) draft=types.MelodicDraft(plan.id,source.start,source.end,(),(),(),(),(),incoming.home,'space',source_phrase=source,source_reference=incoming.source_reference) phrase=realization.checked_phrase(plan,draft,(),incoming.treatments,incoming,harmony,tempo) if phrase is None:return () state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks, history=incoming.history,returns=incoming.returns,closures=incoming.closures,register_peak=incoming.register_peak) original=dataclasses.replace(draft,notes=source.notes) return (dataclasses.replace(phrase,state=state,initial=original,refined=draft,score=0.),) def short_fragment(source,incoming,part,harmony,tempo,seed,final): """An explicitly reduced pickup around missing harmony; not a new theme.""" from generator.solo import leads,realization reference=incoming.home.notes if incoming.home else source.notes if not reference:return () template=dataclasses.replace(reference[0],start=source.start,end=source.end,position=None,continuation=False,event_id=source.id+':reduction') result=[] for pitch in pitches(harmony,template,incoming.last.pitch.number() if incoming.last else template.pitch.number(),members=True): note=dataclasses.replace(template,pitch=pitch) motif=types.Motif('reduction',reference,source.end-source.start,(),'explicit short fragment; no claim of full motif fidelity') anchor=types.Anchor(note.event_id+':arrival',note.start,note.start,pitch,harmony.members(note.start,note.end),harmony.profile.register,note.end-note.start,'terminal' if final else 'intermediate',harmony.at(note.start).chord,hold_until=note.end) transform=types.Transformation(note.event_id,reference[0].event_id,incoming.home.id if incoming.home else '', 'fragment',note.start,note.end,(note.event_id,),((F(),note.end-note.start),),preserves=()) draft=types.MelodicDraft(part+'_'+note.event_id,source.start,source.end,(note,),(transform,),(note.event_id,),(),(anchor,),incoming.home,'fragment',source_phrase=source,source_reference=incoming.source_reference) plan=types.PhrasePlan(draft.id,source.start,source.end,motif,'fragment','thematic_recomposition',None,harmony.profile.register,F(5,4),F(),ending='sustain' if final else 'connected',closure='closed' if final else 'open',passage_id=source.id,passage_start=source.start,passage_end=source.end) phrase=realization.checked_phrase(plan,draft,(note,),incoming.treatments,incoming,harmony,tempo) if phrase: state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks, history=incoming.history,returns=incoming.returns,closures=incoming.closures,register_peak=incoming.register_peak) result.append(dataclasses.replace(phrase,state=state,initial=draft,refined=draft)) return tuple(result) def seeds(analysis, bar, seed): melody=tuple(dataclasses.replace(n,start=n.start-analysis.body_offset,end=n.end-analysis.body_offset, event_id=n.sources[0]) for n in analysis.melody) groups=[] for i in range(len(melody)): for count in ((len(melody),) if len(melody)<3 else (3,4,5,6,7,8)): group=melody[i:i+count] if len(group)!=count or group[-1].end-group[0].start>max(2*bar,F(3)): continue local=tuple(dataclasses.replace(n,start=n.start-group[0].start,end=n.end-group[0].start) for n in group) parent=next((s.id for s in analysis.source_phrases if s.start<=max(0,group[0].start)=4 and len(values)==1 else 0. if purpose in ('continue','connect'): # Silence may articulate an idea; disconnected tiny islands do not carry # unfinished motion. No minimum occupancy or chorus attack quota. return isolated+.25*float(sum(gaps,F())/length)+uniform if purpose=='arrival': return isolated+float(max(F(),F(1)- (pattern[-1][1]-pattern[-1][0])))/2+uniform return isolated+uniform def rhythm_windows(library,meter,length,context,purpose='continue'): from generator.solo import drafts tempo=getattr(context,'tempo',120) key=('theme-rhythm',id(library),meter,length,tempo,purpose) if key not in context.pools: patterns={} bar=F(meter.split('/')[0])*4/int(meter.split('/')[1]) if length>bar: # The two-bar catalog contains quarter-note masks. Include combinations # of the richer one-bar material as complete timing alternatives, rather # than accidentally excluding eighths/dotted values by snippet length. # Pitch paths and feature contracts span the assembled rhythm's seams. split=bar for left,left_id in rhythm_windows(library,meter,split,context,'continue' if purpose=='arrival' else purpose): for right,right_id in rhythm_windows(library,meter,length-split,context,purpose): combined=(*left,*((a+split,b+split) for a,b in right)) if len(combined)<=16:patterns.setdefault(combined,left_id+' + '+right_id) for identifier,pattern,total in drafts.catalog(library,meter): if totaltotal or any(alength:continue for offset in (F(),F(1,2),max(F(),length-total)): if offset+total>length:continue head=tuple((a+offset,b+offset) for a,b in pattern) # Keep representative compatible continuations; never let dictionary # insertion order secretly choose the only tail for an authored cell. tails=sequences.diverse(tuple(patterns),lambda p:(rhythm_cost(p,length,purpose),policy.stable_rank('rhythm-tail',p)), lambda p:(len(p),p[-1][1]-p[-1][0]),2) or ((),) for tail in tails: if any(a=offset+total)) if 2<=len(combined)<=16:patterns.setdefault(combined,'authored:'+identifier) context.pools[key]=sequences.diverse(tuple(patterns.items()), lambda x:(rhythm_cost(x[0],length,purpose)+(0 if x[1].startswith('authored:') else .05),policy.stable_rank('rhythm-window',x[0])), lambda x:(x[1].startswith('authored:'),len(x[0]),x[0][0][0],any(a[1]!=b[0] for a,b in zip(x[0],x[0][1:]))),8) return context.pools[key] def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operation='introduce', terminal=False, ending=None): length=finish-start; values={} purpose=rhythmic_purpose(operation,terminal) if home and operation in ('repeat_with_harmonic_adaptation','return'): pattern=tuple((n.start,n.end) for n in home.notes if n.end<=length) if pattern:values[pattern]='heard-theme' prefix=tuple((n.start,n.end) for n in ref.notes) fixed_rhythm=ref.treatment in ('rhythmic_seed','opening_fragment','callback') or operation in ('fragment','repeat_with_harmonic_adaptation','return','develop_rhythm') if fixed_rhythm: duration=prefix[-1][1] if duration==length:values[prefix]=ref.id if duration=len(ref.notes): arrivals[(*head,(arrival,ending.hold_until-start))]=label+'; planned sustained close' values=arrivals return sequences.diverse(tuple(values.items()),lambda x:(rhythm_cost(x[0],length,purpose),policy.stable_rank(ref.id,x[0])), lambda x:(len(x[0]),tuple(b-a for a,b in x[0][:3]),x[0][0][0]),policy.DEFAULT.theme_rhythm_alternatives) def pitches(harmony,note,base,*,members=False,limit=6,incoming=None): from generator.solo.leads import available domain=available(harmony,note.start,note.end,members=members or note.end-note.start>=2) if members: return phrasing.destination_options(domain,lambda p:(phrasing.destination_cost(p,note.start,note.end,harmony,incoming,base),p.number()), lambda p:p,lambda p:note.start,harmony,limit) return sequences.diverse(domain,lambda p:(abs(p.number()-base),p.number()), lambda p:((p.number()-base>0)-(p.number()-base<0),p.number()//12),limit) def relative_steps(notes,harmony): result=[] for a,b in zip(notes,notes[1:]): scale=harmony.at(b.start).scale try:step=types.scale_index(scale,b.pitch.number())-types.scale_index(scale,a.pitch.number()) except ValueError: lo,hi=sorted((a.pitch.number(),b.pitch.number())) intervening=range(lo+1,hi+1) if b.pitch.number()>a.pitch.number() else range(lo,hi) count=sum(p%12 in scale.pitches for p in intervening) step=count if b.pitch.number()>a.pitch.number() else -count result.append(step) return tuple(result) def instantiate(templates,first,steps,harmony): result=[dataclasses.replace(templates[0],pitch=first)] for note,step in zip(templates[1:],steps): span=harmony.at(note.start) if not span or not span.scale:return () options=routes.advance(span.scale,result[-1].pitch.number(),step) if not options:return () p=options[0] if not harmony.profile.permits(p.number()) or p.number()%12 not in types.permitted(harmony.choices,note.start,note.end):return () result.append(dataclasses.replace(note,pitch=p)) return tuple(result) if len(result)==len(templates) else () def prefix_paths(templates,ref,first,harmony,library): if not hasattr(harmony,'theme_prefixes'):harmony.theme_prefixes={} key=(templates[:len(ref.notes)],ref,first,id(library)) if key not in harmony.theme_prefixes: harmony.theme_prefixes[key]=_prefix_paths(templates,ref,first,harmony,library) return harmony.theme_prefixes[key] def _prefix_paths(templates,ref,first,harmony,library): count=len(ref.notes); original=ref.notes if count==1:return ((dataclasses.replace(templates[0],pitch=first),),) if ref.treatment in ('opening_fragment','characteristic_interval','callback'): delta=first.number()-original[0].pitch.number() if ref.treatment in ('opening_fragment','callback') and delta%12:return () result=[] from generator.solo.leads import available for template,n in zip(templates,original): target=n.pitch.number()+delta p=next((p for p in available(harmony,template.start,template.end,members=template.end-template.start>=2) if p.number()==target),None) if p is None:return () result.append(dataclasses.replace(template,pitch=p)) return (tuple(result),) paths=[] if ref.treatment == 'return': from generator.solo.leads import available delta=first.number()-original[0].pitch.number() direct=[] for template,n in zip(templates,original): p=next((p for p in available(harmony,template.start,template.end,members=template.end-template.start>=2) if p.number()==n.pitch.number()+delta),None) if p is None: break direct.append(dataclasses.replace(template,pitch=p)) if len(direct)==count: paths.append(tuple(direct)) end_template=templates[count-1] source_motion=original[-1].pitch.number()-original[0].pitch.number() for target in pitches(harmony,end_template,first.number()+source_motion,limit=4): template=tuple(dataclasses.replace(n,pitch=first if i==0 else target) for i,n in enumerate(templates[:count])) paths.extend(r.notes for r in routes.candidates(template,{template[-1].event_id},harmony,library,harmony.profile.register,'seed '+ref.id,purpose='entrance')) valid=[p for p in paths if not references.feature_error(feature(ref,p),p)] return sequences.diverse(valid,lambda ns:(routes.path_cost(ns),policy.stable_rank(ref.id,ns)), lambda ns:routes.path_family(ns,harmony),4) @dataclasses.dataclass(frozen=True) class Unit: notes: tuple[types.Note,...] feature: types.IdentifyingFeature reference: types.SourceReference operation: str anchor: types.Anchor score: float source: str routes: tuple[types.RouteCandidate,...] = () start: F = F() end: F = F() def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None): # History is evaluated by the caller after retrieving these concrete options. # Every state component used to construct this pool is in its cache key. if not hasattr(harmony,'fingerprint'):harmony.fingerprint=policy.stable_rank('harmony',(harmony.choices,harmony.profile)) key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,incoming.history,home,operation,ending,goal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT) if key in context.results: context.diagnostics['cache_hits']+=1;return context.results[key] result=[]; count=len(ref.notes) jobs=[] for pattern,label in patterns(ref,start,finish,rhythms,meter,context,home=home,operation=operation,terminal=terminal,ending=ending): if len(pattern)16:continue templates=tuple(types.Note(start+a,start+b,ref.notes[0].pitch,tuple(s for n in ref.notes for s in n.sources), 'theme',event_id=f'{ref.id}:{start}:{i}') for i,(a,b) in enumerate(pattern)) base=incoming.last.pitch.number() if incoming.last else harmony.profile.reference if harmony.profile.reference is not None else ref.notes[0].pitch.number() firsts=pitches(harmony,templates[0],base,limit=6 if batch else 3) targets=(ending.pitch,) if ending else pitches(harmony,templates[-1],goal if goal is not None else base,members=True,limit=policy.DEFAULT.theme_destinations,incoming=incoming) for first in firsts: for target in targets: # Rank joint jobs, retaining rhythm and destination coverage before # expensive path instantiation. The budget is spent on complete routes. rank=harmony.profile.cost(first.number())+max(0,abs(first.number()-base)-5)/12+phrasing.destination_cost(target,templates[-1].start,templates[-1].end,harmony,incoming,goal if goal is not None else base,terminal) jobs.append((rank,templates,label,first,target)) jobs.sort(key=lambda j:(j[0],policy.stable_rank(ref.id,(j[1],j[3],j[4])))) selected=[];rhythm_seen=set();target_seen=set();entry_seen=set();degrees_seen=set() for job in jobs: family=phrasing.destination_family(job[4],job[1][-1].start,harmony) if family not in degrees_seen: selected.append(job);degrees_seen.add(family) for job in jobs: rhythm=tuple((n.start,n.end) for n in job[1]);target=job[4].number();entry=job[3].number() if rhythm not in rhythm_seen or target not in target_seen or entry not in entry_seen: if job not in selected:selected.append(job) rhythm_seen.add(rhythm);target_seen.add(target);entry_seen.add(entry) job_limit=policy.DEFAULT.theme_route_jobs*(batch+1) for job in jobs: if len(selected)>=job_limit:break if job not in selected:selected.append(job) for _,templates,label,first,target in selected[:job_limit]: proposed=[] if home and operation in ('repeat_with_harmonic_adaptation','return') and len(templates)==len(home.notes): heard=instantiate(templates,first,home.degree_steps,harmony) if heard and heard[-1].pitch==target:proposed.append((heard,())) for prefix in prefix_paths(templates,ref,first,harmony,library)[:2+batch]: if len(prefix)==len(templates): if prefix[-1].pitch==target:proposed.append((prefix,())) continue tail=(prefix[-1],*templates[count:]) tail=(*tail[:-1],dataclasses.replace(tail[-1],pitch=target)) for route in routes.candidates(tail,{tail[-1].event_id},harmony,library,harmony.profile.register,operation+' '+ref.id,purpose=rhythmic_purpose(operation,terminal))[:3+batch]: proposed.append(((*prefix[:-1],*route.notes),(route,))) seen=set() for actual,connections in proposed: identity=tuple(n.pitch.number() for n in actual) if identity in seen:continue seen.add(identity) core=actual[:count]; contract=feature(ref,core,treatment=operation if operation!='introduce' else None) if references.feature_error(contract,actual):continue last=actual[-1] if last.pitch.number()%12 not in harmony.members(last.start,last.end):continue checked=harmony.evaluate(actual,until=finish,complete=True) if not checked.valid:continue entry=max(0,abs(first.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(actual,actual[1:]))/max(1,len(actual)-1) direction=min(1,abs(last.pitch.number()-goal)/12) if goal is not None else 0. # Activity belongs to the complete phrase; contracts are pass/fail. score=harmony.profile.cost(profiles.weighted_center(actual))+2*checked.exposure+entry+leaps+phrasing.destination_cost(last.pitch,last.start,last.end,harmony,incoming,goal,terminal) anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),harmony.profile.register, last.end-last.start,'intermediate',harmony.at(last.start).chord,hold_until=last.end) score+=.25*rhythm_cost(tuple((n.start-start,n.end-start) for n in actual),finish-start,rhythmic_purpose(operation,terminal)) result.append(Unit(actual,contract,ref,operation,anchor,score,label,connections,start,finish)) unique={tuple((n.start,n.end,n.pitch) for n in u.notes):u for u in result} selected=sequences.diverse(tuple(unique.values()),lambda u:(u.score,policy.stable_rank(ref.id,u.notes)), lambda u:(u.notes[0].pitch.number()//12,u.notes[-1].pitch.number(),tuple(n.end-n.start for n in u.notes[:count])),policy.DEFAULT.theme_unit_alternatives) context.diagnostics['generated_candidates']+=len(selected) context.results[key]=selected return selected def home_reference(home,operation): # A return refers to the opening the listener heard, not whichever later # three-note turn happens to rank best. Development may use its smaller core. notes = home.signature or home.notes[:min(4, len(home.notes))] if operation not in ('return', 'repeat_with_harmonic_adaptation'): notes = notes[:3] origin=notes[0].start notes=tuple(dataclasses.replace(n,start=n.start-origin,end=n.end-origin) for n in notes) treatment='return' if operation=='return' else 'rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','fragment','develop_rhythm') else 'contour_seed' return types.SourceReference(home.id,home.parent,home.source_start,home.source_start+home.duration,notes,treatment) def make_home(unit,harmony,start=None,end=None): from generator.solo.realization import make_identity start=unit.notes[0].start if start is None else start identity=policy.stable_rank('solo-theme',(unit.reference.id,unit.notes))[:16] home=make_identity('solo_theme_'+identity,unit.reference.id,unit.notes,start,unit.notes[-1].end if end is None else end,'introduce',harmony) return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony),signature=home.notes[:len(unit.reference.notes)]) def unit_end(source,cursor,home,ending,bar): """Bound search by time without cutting the heard signature at a barline.""" length=2*bar if home and home.signature: length=home.signature[-1].end while 0=source.end: next_states.append((cost,units,home,state,ending)) continue finish=unit_end(source,cursor,home,ending,bar) introducing=home is None if introducing: requests=[(r,'introduce') for r in roots] else: operations=['continue'] if finish==source.end and cursor>source.start else ['develop_contour','develop_rhythm'] if cursor==source.start:operations.append('return') requests=[(home_reference(home,op),op) for op in operations] # A uniform duration alone is not identifying musical evidence. requests=[(r,op) for r,op in requests if op!='develop_rhythm' or len({n.end-n.start for n in r.notes})>1 or any(a.end!=b.start for a,b in zip(r.notes,r.notes[1:]))] if cursor>=8*bar and cursor==source.start and incoming.callbacks<2: requests.extend((dataclasses.replace(r,treatment='callback'),'callback') for r in roots[:1] if r.end-r.start<=bar) for ref,operation in requests: if introducing and source.end-source.start<2*bar and ending.earliest-cursorfinish-cursor: reduced=tuple(n for n in ref.notes if n.end<=finish-cursor) if len(reduced)<2:continue ref=dataclasses.replace(ref,notes=reduced,treatment='contour_seed') operation='continue' terminal=finish==source.end base=state.last.pitch.number() if state.last else ref.notes[0].pitch.number() goal=ending.pitch.number() if terminal else round(base+(ending.pitch.number()-base)*float((finish-cursor)/(source.end-cursor))) options=unit_candidates(ref,cursor,finish,state,harmony,library,rhythms,meter,context,home=home,operation=operation, terminal=terminal,batch=batch,ending=ending if terminal else None,goal=goal) for unit in options: if introducing and similarity.near_copy(unit.notes,head,cursor,finish)['near_copy']: context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue actual_home=home or make_home(unit,harmony,cursor,finish) figure=phrasing.heard(unit.notes,operation,unit.anchor,bar) repetition=phrasing.repetition(figure,state.history,bar) appearance=phrasing.appearance_cost(operation,state,cursor,solo_end) if home else 0. history=phrasing.remember(state.history,figure,bar) following=dataclasses.replace(state,home=actual_home,last=unit.notes[-1],notes=unit.notes,source_reference=state.source_reference or ref, history=history,returns=(*state.returns,*((cursor,) if operation=='return' else ())), register_peak=max(state.register_peak,*(n.pitch.number() for n in unit.notes))) next_states.append((cost+float(finish-cursor)*(unit.score+.8*repetition+appearance),(*units,unit),actual_home,following,ending)) def rank(value): cost,units,home,state,ending=value elapsed=units[-1].end-source.start return cost/max(.001,float(elapsed)),policy.stable_rank(seed,tuple(u.notes for u in units)) states=sequences.diverse(next_states,rank, lambda v:(v[2].id,v[4].pitch.number(),v[4].duration,len(v[1][-1].notes),v[1][-1].operation),policy.DEFAULT.theme_continuations) if not states:return () result=[] for cost,units,home,state,ending in states: plain=tuple(n for u in units for n in u.notes) if similarity.near_copy(plain,head,source.start,source.end)['near_copy']: context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue features=tuple(u.feature for u in units) # Cell tails are not compulsory melodic arrivals. Only the selected close # has a sounding contract; intermediate turns remain editable. anchors=(ending,) tails=(types.SustainedTail(ending.earliest,ending.hold_until,ending.pitch.number()),) options=[(plain,(),())] if stage=='elaborated': for actual,edit,route in leads.elaborate(plain,features,harmony,library,rhythms,meter,source,sustain_entry=True,tails=tails): options.append((actual,(edit,),(route,))) for notes,edits,selected_routes in options: identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,leads.signature(notes))[:12] transforms=tuple(types.Transformation(identifier+f':{i}',u.reference.id,home.id,u.operation,u.notes[0].start,u.notes[-1].end, tuple(n.event_id for n in notes if u.notes[0].start<=n.start