"""Source-related melodic sentences realized as whole pitch/rhythm routes. Original song analysis and general vocabulary are the only musical inputs. """ import collections import dataclasses import fractions import functools import generator.model import generator.solo.expressions import generator.solo.groups import generator.solo.policy import generator.solo.rhythm import generator.solo.selection import generator.solo.types import generator.vocabulary @dataclasses.dataclass(frozen=True) class Idea: rhythm: tuple intervals: tuple source: str @property def length(self): return self.rhythm[-1][1] def idea(notes, source): first = notes[0] return Idea(tuple((n.start-first.start,n.end-first.start) for n in notes), tuple(n.pitch.number()-first.pitch.number() for n in notes),source) def source_ideas(analysis): found = {} for i in range(len(analysis.melody)): for count in (3,4,5,6): notes = analysis.melody[i:i+count] if len(notes)!=count or not 2<=notes[-1].end-notes[0].start<=6: continue value = idea(notes,'head:'+str(notes[0].start)) if not generator.solo.groups.valid(value.rhythm): continue key = (value.rhythm,value.intervals) old,number = found.get(key,(value,0)); found[key] = (old,number+1) def rank(row): value,count = row intervals = [b-a for a,b in zip(value.intervals,value.intervals[1:])] turns = sum(a*b<0 for a,b in zip(intervals,intervals[1:])) durations = len({b-a for a,b in value.rhythm}) return (-min(count,3),-durations,-min(turns,2),-min(7,max(value.intervals)-min(value.intervals)),value.source) return tuple(v for v,_ in sorted(found.values(),key=rank)[:12]) class Phrases: def __init__(self,analysis,harmony,sequences,rhythms,start,end,seed,enabled): self.analysis,self.harmony = analysis,harmony self.start,self.end,self.seed,self.enabled = start,end,seed,enabled self.sequences = sequences self.ideas = source_ideas(analysis) library = generator.solo.rhythm.Rhythms(rhythms,analysis.tempo) self.patterns = tuple(library.groups) self.arrivals = tuple(sorted({(cell.pattern[-1][1]-cell.pattern[-1][0],cell.duration-cell.pattern[-1][1]) for cell in generator.vocabulary.rhythm_cells(analysis.tempo,'arrival') if cell.pattern and cell.pattern[-1][1]-cell.pattern[-1][0]>=1})) # Catalog families contribute one peer per rhythmic function, not one # vote per file. In particular the large 5/4 family cannot dominate 4/4. representatives = {} lengths = {b-a for a,b in zip(analysis.boundaries,analysis.boundaries[1:])} for values in library.catalog.values(): for name,pattern,length,purposes in values: if length not in lengths or not 2<=len(pattern)<=6: continue descriptor = (any(a[1]1) representatives.setdefault(descriptor,(name,pattern,length,purposes)) self.patterns += tuple(representatives.values()) self.center = harmony.profile.reference or 60 self.rejections = collections.Counter() self.proposals = collections.Counter() def choose(self,values,domain,key): return min(values,key=lambda x:generator.solo.policy.stable_rank(self.seed+domain,(key,x))) def horizon(self,start): region = self.harmony.at(start) if not region or not region.scale: return min(self.end,region.end if region else self.end) gap = next((s.start for s in self.harmony.spans if startn.end) for i,n in enumerate(self.analysis.melody)) return (abs(boundaries.index(end)+1-4)-2*int(cadence)-int(breath),end) return min(gap,min(candidates,key=cost)) @functools.lru_cache(maxsize=8192) def pitches(self,start,end): span = self.harmony.at(start) if not span or not span.scale: return () permitted = self.harmony.permitted(start,end) return tuple(p for index in range(-28,29) if self.harmony.profile.permits((p:=generator.solo.types.scale_pitch(span.scale,index)).number()) and p.number()%12 in permitted) @staticmethod def note(start,end,pitch,role): return generator.solo.types.Note(start,end,pitch,role=role,event_id=f'{start}:{end}:{pitch.number()}') @functools.lru_cache(maxsize=16384) def options(self,start,end,near,structural=False): result = [] span = self.harmony.at(start) if not span or not span.scale: return () for p in self.pitches(start,end): if abs(p.number()-near)>12: continue note = self.note(start,end,p,'arrival' if structural else 'motion') if self.harmony.evaluate((note,),complete=True).valid: if not structural or p.number()%12 in self.harmony.members(start,end): result.append((note,())) if self.enabled and end-start>=1: for rule in generator.vocabulary.data('harmony')['colors']: if rule.get('resolve') or not generator.solo.expressions.eligible_color(rule,span,self.harmony): continue pitch = generator.solo.expressions.degree_pitch(generator.solo.expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],p.number()) if pitch.number()!=p.number(): continue color = generator.solo.types.ExpressionContract(note.event_id+':'+rule['id'],'color',rule['id'],(note,),note,span.chord) if self.harmony.evaluate((note,),expressions=(color,),complete=True).valid: result.append((dataclasses.replace(note,role='color'),(color,))) return tuple(result) def scale_step(self,scale,number,step): """Continue from sounding pitch, never transfer a degree between maps.""" try: index = generator.solo.types.scale_index(scale,number) except ValueError: # The preceding attack may belong to the previous collection. Its # pitch stays untouched; enter the new one in the requested direction. if not step: return None entries = [generator.solo.types.scale_pitch(scale,i) for i in range(-28,29)] entries = [p for p in entries if (p.number()-number)*step>0] if not entries: return None nearest = min(entries,key=lambda p:abs(p.number()-number)) index = generator.solo.types.scale_index(scale,nearest.number()) step -= 1 if step>0 else -1 return generator.solo.types.scale_pitch(scale,index+step) def route(self,rhythm,contour,incoming,center,mode='develop',*,degree_path=False): """DP compares completed paths; no single route step is committed alone.""" if not rhythm: return None initial = incoming.pitch.number() if incoming else center if incoming and mode in ('connect','arpeggio'): direction = 1 if contour[-1]>=contour[0] else -1 center = initial+direction*2 options = self.options(*rhythm[0],center) if mode=='arpeggio': options = tuple(v for v in options if v[0].pitch.number()%12 in self.harmony.at(rhythm[0][0]).roles.basic) starts = sorted(options,key=lambda v:(abs(v[0].pitch.number()-center)+max(0,abs(v[0].pitch.number()-initial)-5),v[0].pitch.number()))[:3] states = [(0,(n,),c,n.pitch.number()) for n,c in starts] for index,(a,b) in enumerate(rhythm[1:],1): next_states = [] for cost,notes,contracts,origin in states: wanted = origin+contour[min(index,len(contour)-1)] expected = contour[min(index,len(contour)-1)]-contour[min(index-1,len(contour)-1)] if degree_path: span = self.harmony.at(a) target = self.scale_step(span.scale,notes[-1].pitch.number(),expected) if span and span.scale else None if target is None: continue wanted = target.number() expected = wanted-notes[-1].pitch.number() for note,support in self.options(a,b,wanted): if degree_path and note.pitch.number()!=wanted: continue delta = note.pitch.number()-notes[-1].pitch.number() if mode=='arpeggio' and (note.pitch.number()%12 not in self.harmony.at(a).roles.basic or delta==0): continue error = abs(note.pitch.number()-wanted)+3*int(delta==0 and expected!=0)+max(0,abs(delta)-7)*2 if b-a>=1 and note.pitch.number()%12 not in self.harmony.at(a).roles.basic and not support: error += 2 next_states.append((cost+error,(*notes,note),(*contracts,*support),origin)) if not next_states: return None states = sorted(next_states,key=lambda s:(s[0],tuple(n.pitch.number() for n in s[1])))[:8] for _,notes,contracts,_ in sorted(states,key=lambda s:(s[0],tuple(n.pitch.number() for n in s[1]))): if self.harmony.evaluate(notes,expressions=contracts,complete=True).valid: return notes,contracts return None def contour(self,count,direction,mode,motif,time): if mode in ('statement','return','develop','answer') and motif: values = motif.intervals sign = -1 if mode=='answer' else 1 # Explicit reduction/extension; never cycle a route to fit its rhythm. return tuple(sign*values[i] if i0)==(direction>0) and sum(a*b<0 for a,b in zip(p.steps,p.steps[1:]))<=1] span = self.harmony.at(time) if paths and span and span.scale: path = min(paths,key=lambda p:(abs(abs(p.offsets()[-1])-(count-1)),p.rank())) if mode=='connect': return path.offsets() base = generator.solo.types.scale_pitch(span.scale,0) return tuple(generator.solo.types.scale_pitch(span.scale,i).number()-base.number() for i in path.offsets()) if mode=='connect': return tuple(direction*i for i in range(count)) return tuple(direction*i*(3 if mode=='arpeggio' else 2) for i in range(count)) def make(self,start,end,incoming,home,history,variant): if not any(s.scale and s.startstart for s in self.harmony.spans): self.proposals['space'] += 1 evaluation = generator.solo.selection.evaluate((),start,end,self.harmony) return generator.solo.types.Phrase('space:'+str(start),start,end,(),'space','level','open',evaluation=evaluation),() motif = idea(home,'realized_theme') if home else self.choose(self.ideas,'source',variant) if self.ideas else None purpose = 'statement' if not home else self.choose(('return','develop','answer','extend'),'purpose',(start,variant)) if purpose=='return' and history and history[-1].purpose=='return': purpose = 'develop' self.proposals[purpose] += 1 center = self.center+self.choose((-5,0,5),'register',(start,variant)) if incoming: center = max(incoming.pitch.number()-7,min(incoming.pitch.number()+7,center)) direction = self.choose((-1,1),'direction',(start,variant)) hold,release = self.choose(self.arrivals,'arrival',(start,variant)) close = end==self.end or self.choose((True,True,False),'closure',(start,variant)) arrival = max(start,end-release-hold) if close and end-start>=4 else end notes,contracts,sources = [],[],[] cursor = start if start>=0 and purpose!='return': cursor += self.choose((fractions.Fraction(),fractions.Fraction(1,2),fractions.Fraction(1)),'entrance',(start,variant)) signature_notes = (); group_index = 0; fast_groups = 0 while cursor=min(remaining,1) and bool(set(purposes)&({'continue','connect','develop','answer','scalar','arpeggio'}|({'entrance'} if group_index==0 else set()))) and (not fast or fast_groups=3: signature_notes = tuple((*notes,*ns)) fast_groups += int(any((b-a).denominator not in (1,2) for a,b in pattern)) notes.extend(ns); contracts.extend(cs); sources.append(source) cursor += length; group_index += 1 anchor = None; ending = 'open' if arrivalstart for s in self.harmony.spans): return None if self.enabled and variant%3 and notes: notes,contracts = self.expression(tuple(notes),tuple(contracts),signature_notes,start,variant) checked = self.harmony.evaluate(notes,expressions=contracts,until=end,complete=True) if not checked.valid: self.rejections[checked.error] += 1; return None evaluation = generator.solo.selection.evaluate(notes,start,end,self.harmony,incoming=(incoming,) if incoming else (), home=home,purpose=purpose,contracts=contracts,center=self.center) key = generator.solo.policy.stable_rank(self.seed,(start,end,variant))[:12] pattern = tuple((n.start-start,n.end-start) for n in notes) try: groups = generator.solo.groups.describe(pattern,origin=start,source=purpose) except ValueError: self.rejections['rhythm_group'] += 1; return None phrase = generator.solo.types.Phrase(key,start,end,tuple(notes),purpose,'up' if direction>0 else 'down',ending, anchor,tuple(contracts),groups,'home' if home else key,float(evaluation.total),evaluation,tuple(sources)) return phrase,signature_notes def expression(self,notes,contracts,protected,start,variant): protected_ids = {n.event_id for n in protected} used = {n.event_id for c in contracts for n in (*c.events,c.target)} targets = [n for n in notes if n.event_id not in used|protected_ids and n.end-n.start>=1 and n.pitch.number()%12 in self.harmony.members(n.start,n.end)] if not targets: return notes,contracts target = self.choose(targets,'expression_target',(start,variant)) previous = next((n for n in reversed(notes) if n.end<=target.start),None) region = self.harmony.at(target.start) editable = previous and previous.event_id not in used|protected_ids and previous.end==target.start if editable and region.start==target.start and target is not notes[-1] and target.end-target.start>=fractions.Fraction(3,2): for rule in generator.vocabulary.data('harmony').get('suspensions',[]): if target.pitch.number()-previous.pitch.number() not in rule['steps']: continue held = dataclasses.replace(previous,end=target.start+fractions.Fraction(1,2),event_id=previous.event_id+':held',role='suspension') resolved = dataclasses.replace(target,start=held.end,event_id=target.event_id+':resolved') c = generator.solo.types.ExpressionContract(held.event_id,'suspension',rule['id'],(held,),resolved,region.chord,boundary=region.start) revised = tuple(held if n is previous else resolved if n is target else n for n in notes) if self.harmony.evaluate(revised,expressions=(*contracts,c),complete=True).valid: return revised,(*contracts,c) # Anticipation changes the actual rhythm before phrase selection. The # original committed arrival interval remains sounding in its entirety. if editable and region.start==target.start and previous.end-previous.start>=1: early = dataclasses.replace(target,start=target.start-fractions.Fraction(1,2),event_id=target.event_id+':early') before = dataclasses.replace(previous,end=early.start,event_id=previous.event_id+':short') contract = generator.solo.types.ExpressionContract(early.event_id,'anticipation','early_chord_tone',(early,),early,region.chord,boundary=region.start) revised = tuple(early if n is target else before if n is previous else n for n in notes) if self.harmony.evaluate(revised,expressions=(*contracts,contract),complete=True).valid: return revised,(*contracts,contract) # Altered colors resolve to an existing destination; the rule, not the # annotation or accidental, authorizes their complete exposed interval. if editable and variant%2 and previous.end-previous.start<=1: span = self.harmony.at(previous.start) for rule in generator.vocabulary.data('harmony')['colors']: if not rule.get('resolve') or not generator.solo.expressions.eligible_color(rule,span,self.harmony): continue pitch = generator.solo.expressions.degree_pitch(generator.solo.expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],target.pitch.number()) color = dataclasses.replace(previous,pitch=pitch,event_id=previous.event_id+':color',role='color') c = generator.solo.types.ExpressionContract(color.event_id,'color',rule['id'],(color,),target,region.chord) revised = tuple(color if n is previous else n for n in notes) if self.harmony.profile.permits(pitch.number()) and self.harmony.evaluate(revised,expressions=(*contracts,c),complete=True).valid: return revised,(*contracts,c) rules = [r for r in generator.vocabulary.data('lead_ins')['patterns'] if r['kind'] in ('approach','enclosure') and (not r.get('difficulty') or self.analysis.tempo<=generator.vocabulary.data('roles')['difficulty'][r['difficulty']+'_max_bpm'])] rules = sorted(rules,key=lambda r:generator.solo.policy.stable_rank(self.seed+'expression',(start,variant,r['id']))) for rule in rules: gaps = tuple(map(fractions.Fraction,rule.get('internal_gaps',[]))) cursor = target.start-sum(map(fractions.Fraction,rule['durations']),fractions.Fraction())-sum(gaps,fractions.Fraction())-fractions.Fraction(rule['gap']) affected = [n for n in notes if n.startcursor] if cursor7 or before.end==gesture[0].start and before.pitch==gesture[0].pitch): continue revised = tuple(sorted((*[n for n in notes if n not in affected],*gesture),key=lambda n:n.start)) contract = generator.solo.types.ExpressionContract(target.event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(gesture),target,self.harmony.at(target.start).chord) if self.harmony.evaluate(revised,expressions=(*contracts,contract),complete=True).valid: return revised,(*contracts,contract) return notes,contracts