"""Construct complete phrases directly from harmonic/rhythmic purposes. The head is optional material, never a compulsory prefix or rhythmic template. No reference score, completed part, or previous published output enters here. """ import dataclasses import time from collections import Counter from fractions import Fraction as F from generator import vocabulary, harmony as chords, model from generator.solo import types, expressions, groups, routes, rhythm, scoring, placement, policy class SearchExhausted(ValueError):pass def choose(values,seed,domain): return min(values,key=lambda v:policy.stable_rank(seed,(domain,v))) class Builder: def __init__(self,analysis,measures,harmony,sequences,rhythms,end,seed,enabled=True): self.analysis=analysis;self.harmony=harmony;self.end=end;self.seed=seed;self.enabled=enabled self.rules=vocabulary.data('solo');self.budgets=self.rules['search'];self.routes=routes.Routes(harmony,sequences) self.rhythms=rhythm.Rhythms(rhythms,analysis.tempo);self.windows=[];cursor=F() for m in measures: length=model.fraction(m['duration']);self.windows.append((cursor,cursor+length,m['time_signature']));cursor+=length self.coverage={a:Counter() for a in self.rules['actions']};self.failures=Counter();self.cache={};self.decisions=[];self.timings=Counter() def at(self,time):return next((m for m in self.windows if m[0]<=time1 and history[-1].purpose!='return':result.append('return') if self.analysis.melody and len(history)>2:result.append('head') preferred=vocabulary.role().get('preferred_actions',[]) return sorted(result,key=lambda a:(a not in preferred,preferred.index(a) if a in preferred else 0,a)) def propose(self,start,history,variant,batch=0): incoming=next((p.notes[-1] for p in reversed(history) if p.notes),None) home=next((p for p in history if len(p.notes)>=4),None) seed=policy.stable_rank(self.seed,('phrase',start,tuple(p.id for p in history[-2:]),variant,batch)) purpose=self.actions(history)[variant%len(self.actions(history))] self.coverage[purpose]['proposed']+=1 _,_,meter=self.at(start);bar=F(meter.split('/')[0])*4/int(meter.split('/')[1]) span=self.harmony.at(start) if not span or not span.scale:return None limit=min(self.end,start+self.rules['max_phrase_bars']*bar) # Source harmony boundaries and available time supply opportunities, not mandatory closes. opportunities=sorted({min(self.end,start+2*bar),*(s.start for s in self.harmony.spans if start+2<=s.startcenter+7:shift=min(0,shift) if incoming and incoming.pitch.number()0 and arrival==boundary+offset: previous=self.harmony.at(boundary-F(1,2));rule=vocabulary.data('harmony')['suspensions'][0] if previous and previous.scale: candidates=[p for p in self.routes.pitches(boundary-F(1,2),arrival) if p.number()%12 in previous.roles.basic and target_pitch.number()-p.number() in rule['steps']] if candidates: p=choose(candidates,seed,'suspension pitch');lead=(types.Note(boundary-F(1,2),arrival,p,role='suspension',event_id=seed[:16]+':suspension'),);movement_end=lead[0].start contracts.append(types.ExpressionContract(seed[:16]+':suspension','suspension',rule['id'],lead,target,target_span.chord,boundary=boundary)) entrance=F() if history and history[-1].ending=='open' else choose(tuple(map(F,self.rules['entrances'])),seed,'entrance') entry=start+entrance if movement_end-entrymovement_end:return None pattern=tuple((entry+n.start-hint[0].start,entry+n.end-hint[0].start) for n in hint) tail,tail_groups,tail_sources=self.rhythms.realize(entry+duration,movement_end,'develop',seed,meter) timing=(*pattern,*tail) if not groups.valid(tuple((a-entry,b-entry) for a,b in timing)):return None rhythm_groups=(*groups.describe(tuple((a-entry,b-entry) for a,b in pattern),origin=entry,source='heard_theme' if purpose=='return' else 'head'),*tail_groups) sources=(purpose,*tail_sources) self.coverage[purpose]['rhythm_valid']+=1 first_span=self.harmony.at(entry) starting=[p for p in self.routes.pitches(*timing[0]) if p.number()%12 in first_span.roles.basic and abs(p.number()-(incoming.pitch.number() if incoming else goal))<=7] if not starting:return None first=choose(starting,seed,'entry pitch') notes=tuple(types.Note(a,b,first,role=purpose,event_id=seed[:16]+':'+str(i)) for i,(a,b) in enumerate(timing)) all_notes=(*notes,*lead,target) fixed={0,*range(len(notes),len(all_notes))} direction=choose(self.rules['directions'],seed,'contour') route_began=time.monotonic() paths=self.routes.realize(all_notes,fixed,purpose,direction,seed,goal,incoming,hint) self.timings['route_seconds']+=time.monotonic()-route_began self.coverage[purpose]['complete_routes']+=len(paths) for actual in paths: bound=[] for c in contracts: prior=next((n for n in reversed(actual) if n.end<=c.events[0].start),None) bound.append(dataclasses.replace(c,preparation=prior if c.kind in ('connection','blues') else c.preparation)) validation_began=time.monotonic() checked=self.harmony.evaluate(actual,until=finish,complete=True,expressions=tuple(bound)) self.timings['harmonic_seconds']+=time.monotonic()-validation_began if not checked.valid:self.failures[checked.error.split(':')[-1]]+=1;continue anchor=None if ending=='open' else types.Anchor(seed[:16]+':destination',arrival,arrival,target_pitch,members|frozenset({target_pitch.number()%12}),self.harmony.profile.register,release-arrival,'terminal' if ending in ('complete','handoff') else 'intermediate',target_span.chord,False,release,ending) phrase=types.Phrase(seed[:16],start,finish,actual,purpose,direction,ending,anchor,tuple(bound),rhythm_groups,home.id if home else seed[:16],sources=sources) evaluation_began=time.monotonic() evaluation=scoring.evaluate(phrase,incoming,history,center) self.timings['evaluation_seconds']+=time.monotonic()-evaluation_began self.coverage[purpose]['valid']+=1 return dataclasses.replace(phrase,score=evaluation.total,evaluation=evaluation) return None def candidates(self,start,history): key=(start,len(history),next(((p.id,p.notes) for p in history if len(p.notes)>=4),None),tuple((p.id,p.purpose,p.ending,p.notes,p.expressions) for p in history[-4:])) if key in self.cache:return self.cache[key] span=self.harmony.at(start) if not span or not span.scale: finish=min(self.end,span.end if span else self.end) result=(types.Phrase('space:'+str(start),start,finish,(),'space','none','open'),) self.cache[key]=result;return result for action in self.actions(history):self.coverage[action]['eligible_contexts']+=1 proposals=[] for batch in range(self.budgets['replans']+1): for variant in range(self.budgets['proposals']): p=self.propose(start,history,variant,batch) if p:proposals.append(p) if proposals:break if not proposals:raise SearchExhausted(f'at beat {start}: no complete phrase within deterministic budget; rejections {self.failures.most_common(3)}; not proof of harmonic incompatibility') from generator.solo.sequences import diverse finals=diverse(proposals,lambda p:(p.score,policy.stable_rank(self.seed,p.id)),lambda p:(p.purpose,p.ending,p.direction),self.budgets['finalists']) placed=[];incoming=next((p.notes[-1] for p in reversed(history) if p.notes),None) for p in finals: self.coverage[p.purpose]['finalists']+=1 placement_began=time.monotonic() options=placement.alternatives(p.notes,self.analysis.tempo,incoming,self.budgets['fingerings'],self.harmony.profile) self.timings['placement_seconds']+=time.monotonic()-placement_began if options: # Physical economy selects a fingering of this same melody, not the tune itself. placed.append(dataclasses.replace(p,notes=options[0][1]));self.coverage[p.purpose]['placed']+=1 self.cache[key]=tuple(placed) return self.cache[key] def compose(self): history=[];start=F() while start