"""Bounded target-driven ornaments after strict thematic pair selection. The main tune and its home identity remain strictly mapped. Every exception is an atomic upper/lower/target gesture, never a replacement policy for single notes. """ from __future__ import annotations import dataclasses import math import time from fractions import Fraction as F import generator.model from generator.solo import types, harmonic, placement, policy, drafts, routes, phrasing def mapped_pitch(number, start, end, harmony): if number%12 not in types.permitted(harmony.choices,start,end):return None span=harmony.at(start) return types.scale_pitch(span.scale,types.scale_index(span.scale,number)) def approach(target, harmony): """Spelling follows the sounding collection, or explicitly spells resolution.""" t=target.start; number=target.pitch.number() upper=next((p for n in range(number+1,number+4) if (p:=mapped_pitch(n,t-1,t-F(1,2),harmony))),None) if upper is None:return None lower=mapped_pitch(number-1,t-F(1,2),t,harmony) if lower is None: if target.pitch.alter>0: lower=dataclasses.replace(target.pitch,alter=target.pitch.alter-1) else: letters='cdefgab'; index=letters.index(target.pitch.letter) letter=letters[(index-1)%7]; octave=target.pitch.octave-(index==0) alter=number-1-generator.model.NATURAL[letter]-12*(octave+1) lower=types.Pitch(letter,F(alter),octave) if not all(placement.positions(p.number()) for p in (upper,lower)):return None identity=target.event_id+':enclosure' return (types.Note(t-1,t-F(1,2),upper,target.sources,'enclosure_upper',event_id=identity+':upper'), types.Note(t-F(1,2),t,lower,target.sources,'enclosure_lower',event_id=identity+':lower')) def declared_targets(phrase,harmony): """Declare eligible existing stops before proposing their ornaments.""" if not phrase.draft:return () terminal={a.pitch.number():a for a in phrase.anchors if a.role=='terminal'} values=[] held=phrase.draft.notes[0].event_id if phrase.draft.notes and phrase.draft.notes[0].continuation else None for note in drafts.local_sounding(phrase.draft.notes): if note.event_id==held:continue if note.continuation or note.role=='quote' or note.end-note.start<1:continue if note.pitch.number()%12 not in harmony.members(note.start,note.end):continue anchor=terminal.get(note.pitch.number()) role='terminal' if anchor and anchor.earliest<=note.start<=anchor.latest else 'intermediate' rank=(role!='terminal',phrasing.destination_cost(note.pitch,note.start,note.end,harmony,terminal=role=='terminal'),-(note.end-note.start),note.start) values.append((rank,note,role)) return tuple((n,role) for _,n,role in sorted(values)[:policy.DEFAULT.enclosure_targets]) def replace_window(notes,start,end,replacement): """Only rewrite continuously sounding material; preserve meaningful space.""" cursor=start;prefix=[];suffix=[] for note in notes: if note.end<=start:prefix.append(note);continue if note.start>=end:suffix.append(note);continue if note.start>cursor:return None cursor=max(cursor,note.end) if note.startend:return None # never clip a pre-existing target sustain if cursorstart and (n.event_id in ids or n.role=='quote') for n in draft.notes) and not any(t.startstart for t in draft.sustained_tails) def movement(notes): return sum(max(0,abs(b.pitch.number()-a.pitch.number())-3)/12 for a,b in zip(notes,notes[1:]))/max(1,len(notes)-1) def candidates(phrase,harmony,library,tempo,rejections): draft=phrase.draft if not draft:return () if draft.notes and draft.notes[0].continuation: # This local budget does not reopen a committed preceding phrase. Never # invent a new attack by detaching its already-sounding entrance. rejections.append({'target':draft.notes[0].event_id,'reason':'cross-phrase sustain retained; no local rewrite of its context'}) return () notes=types.sounding_notes(draft.notes);result=[] for target,role in declared_targets(phrase,harmony): start=target.start-1 if startstart or not editable(draft,first.end,target.start):continue templates=tuple(n for n in notes if first.start<=n.start=maximum:continue spacing=.08*max(0,1-float(enclosure.target.start-arrivals[-1][0])/16) if enclosure and arrivals else 0. repeated=.06*sum(pc==enclosure.target.pitch.number()%12 for _,pc in arrivals) if enclosure else 0. history=(*arrivals,(enclosure.target.start,enclosure.target.pitch.number()%12)) if enclosure else arrivals following.append((cost+delta+spacing+repeated,(*chosen,option),history)) states=sorted(following,key=lambda x:(x[0]+sparse(len(x[2])),policy.stable_rank('enclosure-selection',x[2])))[:policy.DEFAULT.enclosure_frontier] _,phrases,arrivals=min(states,key=lambda x:(x[0]+sparse(len(x[2])),policy.stable_rank('enclosure-selection',x[2]))) # Resolve the complete guitar path once, including both sides of phrase joins. raw=tuple(n for p in phrases for d in p.decisions for n in d.notes) placed=placement.place(raw,solo.tempo) if placed is None:raise ValueError('selected enclosure solo has no complete guitar path') by_id={n.event_id:n for n in placed[1]};final=[] for phrase in phrases: decisions=tuple(dataclasses.replace(d,notes=tuple(by_id[n.event_id] for n in d.notes)) for d in phrase.decisions) notes=tuple(n for d in decisions for n in d.notes) state=dataclasses.replace(phrase.state,notes=notes,last=notes[-1] if notes else phrase.state.last) if phrase.state else None final.append(dataclasses.replace(phrase,decisions=decisions,draft=dataclasses.replace(phrase.draft,notes=notes) if phrase.draft else None,state=state)) diagnostics={**solo.diagnostics,'enclosures_enabled':True,'enclosure_seconds':time.monotonic()-started, 'enclosure_candidates':options_count,'enclosure_rejections':rejections,'enclosures_selected':len(arrivals)} return dataclasses.replace(solo,phrases=tuple(final),diagnostics=diagnostics)