"""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 from generator.solo import types, policy, sequences, routes, references, similarity 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 windows(analysis, measures, end, harmony): """Existing phrase representation; source extraction seams do not close it.""" bounds=[F()] for measure in measures:bounds.append(bounds[-1]+generator.model.fraction(measure['duration'])) melody=source_notes(analysis); result=[]; index=0 while bounds[index]start) result.append(types.SourcePhrase(f'theme_window_{start}_{finish}',start,finish,notes,(),'')) index=stop # Explicit missing harmony interrupts sounding material, not thematic memory. divided=[] for window in result: cuts=sorted({window.start,window.end,*(max(window.start,s.start) for s in harmony.spans if not s.scale and s.startwindow.start),*(min(window.end,s.end) for s in harmony.spans if not s.scale and s.startwindow.start)}) for start,finish in zip(cuts,cuts[1:]): notes=tuple(dataclasses.replace(n,start=max(start,n.start),end=min(finish,n.end),continuation=n.startstart) divided.append(dataclasses.replace(window,id=f'theme_window_{start}_{finish}',start=start,end=finish,notes=notes)) return tuple(divided) 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,(41,76),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) 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),(41,76),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,(41,76),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) 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)): group=melody[i:i+count] if len(group)!=count or group[-1].end-group[0].start>max(bar,F(3)) or group[0].start>=8*bar: 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)total or any(a=len(ref.notes):values[(*head,(arrival,length-release))]=label+f'; arrival {hold}, release {release}' return sequences.diverse(tuple(values.items()),lambda x:(max(0,len(x[0])-1.5*float(length),float(length)-len(x[0])),policy.stable_rank(ref.id,x[0])), lambda x:(len(x[0]),x[0][-1][1]-x[0][-1][0],x[0][0][0]),4) def pitches(harmony,note,base,*,members=False,limit=6): from generator.solo.leads import available domain=available(harmony,note.start,note.end,members=members or note.end-note.start>=2) 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 41<=p.number()<=76 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): 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=[] 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,(41,76),'seed '+ref.id)) valid=[p for p in paths if not references.feature_error(feature(ref,p),p)] return sequences.diverse(valid,lambda ns:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(ns,ns[1:])),tuple(n.pitch for n in ns)), lambda ns:sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ns,ns[1:]))),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,...] = () def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0): # Position and the recent realization participate even when two paths share a last pitch. if not hasattr(harmony,'fingerprint'):harmony.fingerprint=policy.stable_rank('harmony',harmony.choices) key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,home,operation,terminal,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) for pattern,label in patterns(ref,start,finish,rhythms,meter,context,home=home,operation=operation,terminal=terminal): if len(pattern)3 and notes[-1].end>home.duration/2:notes=notes[:-1] return types.SourceReference(home.id,home.parent,home.source_start,home.source_start+home.duration,notes, 'rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','extend','fragment') else 'contour_seed') 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)) def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False): from generator.solo import leads,realization if source.end-source.start<2: return short_fragment(source,incoming,part,harmony,tempo,seed,final) bar=F(meter.split('/')[0])*4/int(meter.split('/')[1]) unit_length=2*bar head=source_notes(analysis) seed_key=('source-seeds',analysis,bar,seed) if seed_key not in context.pools:context.pools[seed_key]=seeds(analysis,bar,seed) roots=context.pools[seed_key] states=[(0.,(),incoming.home,incoming)] cursor=source.start while cursor=8*bar and cursor==source.start and used<2: requests.extend((dataclasses.replace(r,treatment='callback'),'callback') for r in roots[:2] if r.end-r.start<=bar) for ref,operation in requests: if ref.notes[-1].end>finish-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='answer' options=unit_candidates(ref,cursor,finish,state,harmony,library,rhythms,meter,context,home=home,operation=operation, terminal=final and finish==source.end,batch=batch) 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) following=dataclasses.replace(state,home=actual_home,last=unit.notes[-1],notes=unit.notes,source_reference=state.source_reference or ref) next_states.append((cost+unit.score,(*units,unit),actual_home,following)) states=sequences.diverse(next_states,lambda v:(v[0],policy.stable_rank(seed,tuple(u.notes for u in v[1]))), lambda v:(v[2].id,v[1][-1].notes[-1].pitch.number()),2 if incoming.home and finishcursor:activity.append(types.ActivitySpan(cursor,n.start,'space',F(),F())) activity.append(types.ActivitySpan(n.start,n.end,'active',F(5,4),F())) cursor=n.end if cursor