"""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.sequences 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]) @dataclasses.dataclass(frozen=True) class Arrival: note: generator.solo.types.Note anchor: generator.solo.types.Anchor contracts: tuple = () resume: fractions.Fraction = fractions.Fraction() 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.authored_ids = frozenset(v[0] for v in self.patterns) 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 = generator.solo.groups.descriptor(pattern,length) representatives.setdefault(descriptor,(name,pattern,length,purposes)) self.patterns += tuple(representatives.values()) self.patterns = tuple(dict((generator.solo.groups.descriptor(p,l),(name,p,l,roles)) for name,p,l,roles in self.patterns).values()) self.patterns_by_length = {} for value in self.patterns: self.patterns_by_length.setdefault(value[2],[]).append(value) 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=4096) def event_options(self,start,end,structural,enabled): """Validate the immutable harmonic interval once, not at every near pitch.""" result = [] span = self.harmony.at(start) if not span or not span.scale: return () for pitch in self.pitches(start,end): note = self.note(start,end,pitch,'arrival' if structural else 'motion') if self.harmony.evaluate((note,),complete=True).valid: if not structural or pitch.number()%12 in self.harmony.members(start,end): result.append((note,())) if 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 low,high = self.harmony.profile.register for near in range(low,high+12,12): pitch = generator.solo.expressions.degree_pitch(generator.solo.expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],near) if not self.harmony.profile.permits(pitch.number()): continue note = self.note(start,end,pitch,'color') 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((note,(color,))) return tuple(dict.fromkeys(result)) def options(self,start,end,near,structural=False): return tuple(v for v in self.event_options(start,end,structural,self.enabled) if abs(v[0].pitch.number()-near)<=12) def arrival_options(self,onset,release,harmonic_time,center,effect,resume=None): """Choose the sounding target before realizing its incoming gesture.""" region = self.harmony.at(harmonic_time) if not region or not region.scale or not onset10: continue note = self.note(onset,release,pitch,'arrival') if onset=3: release = end-breath base = max(start+1,release-hold) times = [(base,base)] # Near a harmonic change compare anticipation, on-time and delayed # entry, rather than retiming an already selected strict melody. for span in self.harmony.spans: if start+2<=span.start=motif.intervals[-2] else -2 values.append(Idea(rhythm,(*motif.intervals,motif.intervals[-1]+step,motif.intervals[-1]+2*step),motif.source+':extend')) for name,pattern,length,roles in self.patterns: if (len(pattern)==len(motif.intervals) and 1<=length<=4 and set(roles)&{'entrance','continue','connect','develop','answer','scalar'}): values.append(Idea(pattern,motif.intervals,motif.source+':rhythm:'+name)) # A full rhythmic recurrence is still a legitimate candidate, not a duty. values.append(motif) return tuple(dict(((v.rhythm,v.intervals),v) for v in values).values()) @functools.lru_cache(maxsize=1024) def rhythm_candidates(self,cursor,remaining,approaching,fast_allowed): desired_roles = {'connect','continue','scalar'} if approaching else {'continue','connect','develop','answer','scalar','arpeggio'} eligible = [] for length,values in sorted(self.patterns_by_length.items()): if not min(remaining,2)<=length<=remaining: continue for value in values: name,pattern,length,roles = value fast = any((b-a).denominator not in (1,2) for a,b in pattern) if (set(roles)&desired_roles and (not fast or fast_allowed) and generator.solo.groups.valid(tuple((cursor+a,cursor+b) for a,b in pattern))): eligible.append(value) return tuple(eligible) 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,target=None): values = self.routes(rhythm,contour,incoming,center,mode,degree_path=degree_path,target=target,limit=1) return values[0] if values else None def routes(self,rhythm,contour,incoming,center,mode='develop',*,degree_path=False,target=None,limit=3): return self._routes(rhythm,contour,incoming,center,mode,degree_path,target,limit,self.enabled) @functools.lru_cache(maxsize=1024) def _routes(self,rhythm,contour,incoming,center,mode,degree_path,target,limit,enabled): """Keep complete paths toward a known future event, not one next step.""" if not rhythm: return () initial = incoming.pitch.number() if incoming else center if incoming and mode in ('connect','arpeggio'): direction = (1 if target.pitch.number()>=initial else -1) if target else (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()))[:6] states = [(abs(n.pitch.number()-initial)/4,(n,),c,n.pitch.number()) for n,c in starts] def finish_distance(note): if target is None: return 0 distance = abs(note.pitch.number()-target.pitch.number()) return max(0,distance-2)*2+int(distance==0 and note.end==target.start)*2 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)] span = self.harmony.at(a) if degree_path: if not span or not span.scale: continue if target is not None: # Use octave-aware indices when both pitches belong to this map. # Across maps the target remains an actual sounding pitch. try: displacement = generator.solo.types.scale_index(span.scale,target.pitch.number())-generator.solo.types.scale_index(span.scale,notes[-1].pitch.number()) step = max(-2,min(2,round(displacement/(len(rhythm)-index+1)))) except ValueError: step = 1 if target.pitch.number()>notes[-1].pitch.number() else -1 pitch = self.scale_step(span.scale,notes[-1].pitch.number(),step) else: pitch = self.scale_step(span.scale,notes[-1].pitch.number(),expected) if pitch is None: continue wanted = pitch.number() expected = wanted-notes[-1].pitch.number() for note,support in self.options(a,b,wanted): delta = note.pitch.number()-notes[-1].pitch.number() if degree_path: if target is None and note.pitch.number()!=wanted: continue if target is not None: try: step = generator.solo.types.scale_index(span.scale,note.pitch.number())-generator.solo.types.scale_index(span.scale,notes[-1].pitch.number()) if abs(step)>2: continue except ValueError: if abs(delta)>3: continue if mode=='arpeggio' and (note.pitch.number()%12 not in span.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 span.roles.basic and not support: error += 2 next_states.append((cost+error,(*notes,note),(*contracts,*support),origin)) if not next_states: return () def rank(state): # Admissible reachability pressure uses the actual remaining attacks; # a partial short route is never compared to a complete long route. distance = max(0,abs(state[1][-1].pitch.number()-target.pitch.number())-3*(len(rhythm)-index)) if target else 0 return state[0]+distance,tuple(n.pitch.number() for n in state[1]) states = generator.solo.sequences.diverse(next_states,rank, lambda s:(s[1][0].pitch.number(),s[1][-1].pitch.number()),8) values = [] for cost,notes,contracts,_ in sorted(states,key=lambda s:(s[0]+finish_distance(s[1][-1]),tuple(n.pitch.number() for n in s[1]))): if self.harmony.evaluate(notes,expressions=contracts,complete=True).valid: values.append((notes,contracts)) return tuple(values[:limit]) def contour(self,count,direction,mode,motif,time): if mode in ('statement','return','develop','answer') and motif: values = motif.intervals sign = 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 approach(self,arrival,start,variant): """Compose a complete optional lead-in before choosing the body rhythm.""" if not self.enabled: return (),() family = self.choose(('direct','approach','enclosure'),'approach_family',(start,variant,arrival.note.start)) if family=='direct': return (),() rules = [r for r in generator.vocabulary.data('lead_ins')['patterns'] if r['kind']==family and (not r.get('difficulty') or self.analysis.tempo<=generator.vocabulary.data('roles')['difficulty'][r['difficulty']+'_max_bpm'])] for rule in sorted(rules,key=lambda r:generator.solo.policy.stable_rank(self.seed+'lead_in',(start,variant,r['id']))): gaps = tuple(map(fractions.Fraction,rule.get('internal_gaps',[]))) cursor = arrival.note.start-sum(map(fractions.Fraction,rule['durations']),fractions.Fraction())-sum(gaps,fractions.Fraction())-fractions.Fraction(rule['gap']) if cursorstart 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 purpose=='answer' and home: movement = home[-1].pitch.number()-home[0].pitch.number() center -= 3 if movement>0 else -3 if movement<0 else 0 if incoming: center = max(incoming.pitch.number()-7,min(incoming.pitch.number()+7,center)) destinations = self.destinations(start,end,center,variant) if destinations is None: self.rejections['destination_feasibility'] += 1; return None ending = destinations[-1].anchor.effect if destinations and destinations[-1].resume==end else 'open' terminal = destinations[-1].anchor if ending!='open' else None developments = self.developments(motif,purpose) notes,contracts,sources,rhythm_groups = [],[],[],[] 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 landmarks = (*destinations,*((None,) if not destinations or destinations[-1].resume (previous.pitch.number() if previous else center) else -1 if target else self.choose((-1,1),'direction',(start,variant)) desired = self.contour(len(pattern),direction,mode,motif,cursor) # Only the group immediately reaching a target must finish its approach. # Earlier material sees the future register but can retain its identity. next_target = target if cursor+length==stop else None local_center = round((center+target.pitch.number())/2) if target else center routes = self.routes(rhythm,desired,previous,local_center,mode,degree_path=mode=='connect',target=next_target) if not routes: self.rejections['route'] += 1; return None ns,cs = routes[variant%len(routes)] if not signature_notes and group_index==0 and len(ns)>=3: signature_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) rhythm_groups.append(generator.solo.groups.RhythmGroup(cursor,cursor+length,'gesture',rhythm, cursor%1,source+':'+mode)) cursor += length; group_index += 1 if landmark: if approach and notes and notes[-1].end==approach[0].start and notes[-1].pitch==approach[0].pitch: # Recompose the join as a direct route in another candidate; never # keep an accidental duplicated entrance because the label is valid. self.rejections['duplicated_approach_entrance'] += 1; return None notes.extend(approach); contracts.extend(support) note = landmark.note if notes and not approach and not landmark.contracts and notes[-1].end==note.start and notes[-1].pitch==note.pitch: before = notes[-1] used = {n.event_id for c in contracts for n in (*c.events,c.target)} held = dataclasses.replace(note,start=before.start,event_id=note.event_id+':carried') if before not in signature_notes and before.event_id not in used and self.harmony.evaluate((held,),complete=True).valid: notes.pop(); note=held notes.append(note); contracts.extend(landmark.contracts) rhythm_groups.append(generator.solo.groups.RhythmGroup( approach[0].start if approach else note.start,landmark.resume,'arrival', tuple((n.start,n.end) for n in (*approach,note)),note.start%1,landmark.anchor.effect)) sources.append('destination:'+landmark.anchor.effect) cursor = landmark.resume if not notes: return None if self.enabled and variant%3: # Only genuinely local refinements remain here. Every committed # arrival and the treatment's identifying events are protected. protected = (*signature_notes,*(n for n in notes if any(n.start<=d.note.start and n.end>=d.note.end and n.pitch==d.note.pitch for d in destinations))) notes,contracts = self.expression(tuple(notes),tuple(contracts),protected,start,variant) checked = self.harmony.evaluate(notes,expressions=contracts,until=end,complete=True) if not checked.valid: self.rejections[checked.error] += 1; return None anchors = tuple(d.anchor for d in destinations) evaluation = generator.solo.selection.evaluate(notes,start,end,self.harmony,incoming=(incoming,) if incoming else (), home=home,purpose=purpose,contracts=contracts,center=center,destinations=anchors,history=history,identity=signature_notes) if not evaluation.valid: self.rejections['arrival_contract'] += 1; return None 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: generator.solo.groups.describe(pattern,origin=start,source=purpose) except ValueError: self.rejections['rhythm_group'] += 1; return None # Refine diagnostics against the chosen sounding events. Local expression # and shared arrival sustains must not leave stale pre-edit rhythm reports. rhythm_groups = tuple(dataclasses.replace(g,events=tuple((n.start,n.end) for n in notes if g.start<=n.startnotes[0].pitch.number() else 'down' if notes[-1].pitch.number()=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