From db7966a430460415cc73904a5abcfeda3d80f745 Mon Sep 17 00:00:00 2001 From: khodowany Date: Mon, 5 Oct 2026 17:24:16 -0500 Subject: [PATCH] Simplification --- TECHNICAL_GUIDE.md | 12 +- generator/solo/composer.py | 3 +- generator/solo/construction.py | 23 +- generator/solo/groups.py | 9 + generator/solo/phrases.py | 469 ++++++++++++++++++++++++++------- generator/solo/policy.py | 2 +- generator/solo/report.py | 3 +- generator/solo/rhythm.py | 2 +- generator/solo/selection.py | 85 ++++-- generator/solo/types.py | 2 + tests/solo_compare.py | 26 ++ tests/test_phrase_composer.py | 126 +++++++++ 12 files changed, 639 insertions(+), 123 deletions(-) diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index 4e02d45..c618d4a 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -194,17 +194,21 @@ Scale labels appear above chord symbols at collection changes and system starts. The solo path is `composer.compose → construction.Builder → phrases.Phrases → placement → validate → notation/export`. Source pitch/rhythm relationships seed one realized idea. The composer coordinates whole rhythmic groups, written-chord arpeggios, complete scale routes, deliberate rests and harmonic arrivals into a complete phrase candidate. It considers at most 24 alternatives per incoming history, retaining four histories at common source-derived horizons (up to eight measures). These are bounded search settings, not a library of 24 licks. Boundaries may remain open; source breaths inform them without forcing a cadence at every bar. Introductory identity and current development remain distinct. -Rhythm choices are purpose-aware. Arrival values come from authored arrival cells. Fast subdivision groups have a difficulty budget rather than being selected uniformly from every catalog entry. Sequence indexing retains octave crossings. Catalog families contribute representatives, not probabilities proportional to XML file count. Source notes are never changed by generated rhythm transformations. +The `directed-phrases-2` constructor chooses the destination pitch, harmonic time, sounding duration and release before the incoming route. It compares direct, on-time, delayed and explicitly contracted anticipated arrivals. Local settling can precede further development; completing arrivals and source-defined handoffs preserve the full selected hold. Adjacent common-tone motion can share a sounding event with an arrival when complete-duration validation allows it. A dominant-to-dominant movement is local settling, not automatic cadential completion. -The complete-phrase evaluator reports continuity, contextual repetition, rhythmic interruption, broad activity feasibility, harmonic exposure, soft register and declared return fidelity. Histories covering the same span use duration-weighted phrase totals. The final report also contains an independent whole-stream diagnostic; it is labeled separately from the selection total. No reference-fitting schema, target-loss term, constructor bonus or optional training dependency remains. This evaluator is an inspectable engineering preference, not a model of listener enjoyment or a guarantee of globally optimal melody. +Rhythm retrieval retains ordered attack/rest timing and the trailing breath, rather than reducing patterns to attack counts or Boolean categories. A clear return preserves the introduced rhythm. Development can rephrase that rhythm while keeping selected opening intervals; extension can reopen a provisional ending. An answer responds through its register and continuation rather than merely inverting every interval. These transformations affect generated material only. Source notes remain untouched. Fast subdivision groups retain the existing tempo/difficulty budget. Catalog families contribute alternatives without probabilities proportional to file count. + +Up to three complete routes remain available for each realized group. The route leading into an arrival knows its actual destination; scalar displacement uses octave-aware scale indices. Earlier groups can retain thematic character while approaching the planned register. Approaches and enclosures are constructed together with their destination before the preceding body rhythm; a small local refinement pass still supports prepared suspensions and resolving colors. Protected identifying events and all committed arrivals remain intact. Optional stable colors have their own explicitly validated pitch pool, so the ordinary-map filter cannot silently make an approved color unreachable. + +The complete-phrase evaluator reports continuity, recent phrase repetition, rhythmic interruption, broad activity feasibility, harmonic exposure, soft register, declared thematic relationship and preparation of planned arrivals. Returns compare ordered intervals and exact relative rhythm; development, answers and extensions compare the selected opening intervals while allowing rhythmic change. Insufficient notes cannot satisfy a multi-note identity. Required holds are distinguished from unplanned broad notes inside moving material, and a breath after an arrival is not treated as a broken run. Repetition evaluates attacks and relative figures separately from sustained duration. Arrival fulfillment is a hard check, in addition to the approach preference. Contributions sum exactly to the selection total. Constructor labels earn no bonus. Histories covering the same span use duration-weighted phrase totals. The final report also contains an independent whole-stream diagnostic; it is labeled separately from the selection total. No reference-fitting schema, target-loss term, constructor bonus or optional training dependency remains. This evaluator is an inspectable engineering preference, not a model of listener enjoyment or a guarantee of globally optimal melody. Both solo roles allow all six strings at frets 1–12 without opens. Overlapping register preferences never impose octave copies or string quotas. Ordinary notes remain in the mapped collection throughout their full sounding interval. Typed color, approach, enclosure, anticipation and suspension rules validate real targets, preparation and exposure. Tied fragments reconstruct one sounding event; reattacks remain distinct. The 21 scale patterns and authoritative written chord semantics are unchanged. Unsupported written harmony fails rather than silently substituting another chord. Accompaniment remains source-derived and canonical across iterations, with independently chosen sparse voicings and rhythms. It neither reads nor reacts to solo notes. Source-defined participation masks keep accompaniment off the physical guitar while it solos. Source vocals, electric melody, pickup timing and runtime representation are preserved. -Production exports `*-solo1.xml` (acoustic), `*-solo2.xml` (electric), and `manifest.solo`. Optional `python -m tests.solo_compare` accepts references only after completed generation; it cannot affect candidate selection. It preserves annotations and raw encoding and separately applies the confirmed guitar-written octave convention. No exact-output reproduction is promised. Compare whole pitches, onsets, releases, rests and phrase relationships; attack counts alone are insufficient. The previous fitting experiments and `audition/` and `docs/` directories are removed. +Production exports `*-solo1.xml` (acoustic), `*-solo2.xml` (electric), and `manifest.solo`. Optional `python -m tests.solo_compare` accepts references only after completed generation; it cannot affect candidate selection. It preserves annotations and raw encoding and separately applies the confirmed guitar-written octave convention. No exact-output reproduction is promised. Compare whole pitches, onsets, releases, rests and phrase relationships; attack counts alone are insufficient. The report also exports four distinct completed opening candidates where available, marking which survived into the full solo and showing its evaluation separately from alternatives. The optional comparison command renders these source-only candidates without rescoring or recomposing them. The previous fitting experiments and `audition/` and `docs/` directories are removed. -Source songs, scales and useful rhythm/sequence catalogs remain in `data/`. Versioned general rules live in `data/composition`; optional references stay in `build/`, and compatibility expectations live in `tests/fixtures`. Keep only the current comparison and one compact baseline under `build/solo_comparison`. Cached catalogs are checksummed derivatives of production data; a clean build recreates them. Legal TAB is a feasibility approximation, not a finger-assignment or ergonomic guarantee. +Source songs, scales and useful rhythm/sequence catalogs remain in `data/`. Versioned general rules live in `data/composition`; optional references stay in `build/`, and compatibility expectations live in `tests/fixtures`. Keep the current comparison and compact pre-change baseline under `build/solo_comparison`; staged score-only assets remain unpublished until validation succeeds. Cached catalogs are checksummed derivatives of production data; a clean build recreates them. Legal TAB is a feasibility approximation, not a finger-assignment or ergonomic guarantee. Current resource and code migration: diff --git a/generator/solo/composer.py b/generator/solo/composer.py index 35138f7..dc91de7 100644 --- a/generator/solo/composer.py +++ b/generator/solo/composer.py @@ -27,6 +27,7 @@ def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,e result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'source_melodic_phrases','version':policy.DEFAULT.composition_version, 'construction_seconds':time.monotonic()-began,**builder.timings,'operation_coverage':{k:dict(v) for k,v in builder.coverage.items()}, 'whole_stream_diagnostic':dataclasses.asdict(builder.winner_evaluation), + 'opening_candidates':builder.opening_candidates, 'frontiers':builder.frontiers,'completed_candidates':[{'rank':i+1,'evaluation':{'selection_total': str(s.total / (end-start)), 'phrases': [dataclasses.asdict(p.evaluation) for p in s.phrases]},'events':[(str(n.start),str(n.end),n.pitch.number()) for n in s.notes]} for i,s in enumerate(builder.completed)],'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile,start) validate(result) return result @@ -44,6 +45,6 @@ def validate(solo): for p in solo.phrases: if p.start!=cursor or p.end<=p.start:raise ValueError('phrase coverage gap or overlap') cursor=p.end - valid,error=harmonic.anchors_valid((p.destination,) if p.destination else (),p.notes,p.end,complete=True,closure='closed' if p.ending in ('complete','handoff') else 'open') + valid,error=harmonic.anchors_valid(p.arrivals or ((p.destination,) if p.destination else ()),p.notes,p.end,complete=True,closure='closed' if p.ending in ('complete','handoff') else 'open') if not valid:raise ValueError(error) if cursor!=solo.end:raise ValueError('incomplete solo duration') diff --git a/generator/solo/construction.py b/generator/solo/construction.py index 36fd130..d4c826d 100644 --- a/generator/solo/construction.py +++ b/generator/solo/construction.py @@ -33,6 +33,7 @@ class Builder: self.rules = generator.vocabulary.data('solo')['search'] self.timings = {}; self.frontiers = []; self.coverage = collections.defaultdict(collections.Counter); self.decisions = [] self.failures = self.phrases.rejections + self.opening_candidates = [] def candidates(self,start,end,state): incoming = state.notes[-1] if state.notes else None @@ -51,8 +52,18 @@ class Builder: def rank(self,state): return state.total,generator.solo.policy.stable_rank(self.seed,tuple((n.start,n.end,n.pitch.number()) for n in state.notes)) + @staticmethod + def character(state): + phrase = state.phrases[-1] + ns = phrase.notes + # Diversity describes the realized phrase, not its constructor label. + return (tuple((n.start-phrase.start,n.end-n.start) for n in ns[:4]), + tuple((b.pitch.number()>a.pitch.number())-(b.pitch.number()=0 and len(introduced)>=3 else ()),total)) if not successors: raise SearchExhausted(f'No valid complete phrase at {start}–{end}; failures: {dict(self.failures)}') + if start>=0 and not openings: + openings = [s.phrases[-1] for s in successors] successors.sort(key=self.rank) states = tuple(generator.solo.sequences.diverse(successors,self.rank, - lambda s:(s.phrases[-1].purpose,s.phrases[-1].direction,s.phrases[-1].ending),self.rules['histories'])) + self.character,self.rules['histories'])) for state in states: self.coverage[state.phrases[-1].purpose]['retained_histories'] += 1 self.frontiers.append({'start':str(start),'end':str(end),'completed':len(successors),'retained':len(states),'best':str(min(s.total for s in states))}) start = end self.completed = tuple(sorted(states,key=self.rank)) winner = self.completed[0] + selected_opening = next((p for p in winner.phrases if p.start>=0),None) + if selected_opening is not None: + remaining = [p for p in openings if p.notes!=selected_opening.notes] + examples = (selected_opening,*generator.solo.sequences.diverse(remaining, + lambda p:(p.evaluation.total,p.id),lambda p:self.character(State((p,))),3)) + self.opening_candidates = [{'selected_in_final_solo':p is selected_opening,'start':str(p.start),'end':str(p.end), + 'purpose':p.purpose,'ending':p.ending,'evaluation':dataclasses.asdict(p.evaluation), + 'events':[(str(n.start),str(n.end),n.pitch.number()) for n in p.notes]} for p in examples] self.timings['phrase_search_seconds'] = time.monotonic()-began self.winner_evaluation = generator.solo.selection.evaluate(winner.notes,self.start,self.end,self.harmony, home=winner.home,contracts=tuple(c for p in winner.phrases for c in p.expressions),center=self.phrases.center) diff --git a/generator/solo/groups.py b/generator/solo/groups.py index 4a39ddf..0cb8aeb 100644 --- a/generator/solo/groups.py +++ b/generator/solo/groups.py @@ -61,6 +61,15 @@ def signature(notes): for i,n in enumerate(notes)) +def descriptor(pattern, duration): + """Retain ordered attacks, durations and space, including a trailing breath. + + Absolute placement belongs to the caller. Equal counts or Boolean labels + do not make two different rhythmic sentences interchangeable. + """ + return (duration, tuple(pattern)) + + def broaden(pattern, arrival, release): """Reopen an editable binary tail; never slice a tuplet or inherit its phase.""" if (arrival*2).denominator!=1 or (release*2).denominator!=1:return () diff --git a/generator/solo/phrases.py b/generator/solo/phrases.py index bebb0e0..400b849 100644 --- a/generator/solo/phrases.py +++ b/generator/solo/phrases.py @@ -11,6 +11,7 @@ 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 @@ -54,6 +55,14 @@ def source_ideas(analysis): 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 @@ -62,6 +71,7 @@ class Phrases: 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 @@ -72,9 +82,14 @@ class Phrases: for name,pattern,length,purposes in values: if length not in lengths or not 2<=len(pattern)<=6: continue - descriptor = (any(a[1]1) + 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() @@ -118,31 +133,181 @@ class Phrases: 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): + @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 p in self.pitches(start,end): - if abs(p.number()-near)>12: - continue - note = self.note(start,end,p,'arrival' if structural else 'motion') + 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 p.number()%12 in self.harmony.members(start,end): + if not structural or pitch.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(): + 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((dataclasses.replace(note,role='color'),(color,))) + 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: @@ -161,53 +326,94 @@ class Phrases: 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.""" + 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 None + return () initial = incoming.pitch.number() if incoming else center if incoming and mode in ('connect','arpeggio'): - direction = 1 if contour[-1]>=contour[0] else -1 + 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()))[:3] - states = [(0,(n,),c,n.pitch.number()) for n,c in starts] + 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: - 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: + if not span or not span.scale: continue - wanted = target.number() + 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): - 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): + 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 self.harmony.at(a).roles.basic and not support: + 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 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]))): + 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: - return notes,contracts - return None + 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 if mode=='answer' else 1 + sign = 1 # Explicit reduction/extension; never cycle a route to fit its rhythm. return tuple(sign*values[i] if istart for s in self.harmony.spans): self.proposals['space'] += 1 @@ -234,86 +468,125 @@ class Phrases: 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)) - 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 = [],[],[] + 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 - 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): + # Select complete rhythmic groups by their musical function, keeping + # ordered timing diversity until pitches and destination are known. + eligible = self.rhythm_candidates(cursor,remaining,bool(target and remaining<=4), + fast_groups (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 and notes: - notes,contracts = self.expression(tuple(notes),tuple(contracts),signature_notes,start,variant) + 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=self.center) + 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: - groups = generator.solo.groups.describe(pattern,origin=start,source=purpose) + 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)) + # 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()0,any(a[1]start for s in harmony.spans) activity = max(0,density-7)+(2 if active and not notes else 0) if notes and sounding0)!=(b[2]>0) for a,b in zip(signature(home),signature(sample)))/len(home) - register = sum(float(n.end-n.start)*max(0,abs(n.pitch.number()-center)-10) for n in notes)/max(1,float(sounding)) + # Unplanned broad notes in the moving portion have a different function from + # a fulfilled arrival. This does not penalize the required held destination. + motion = [n for n in notes if n.event_id not in arrival_ids and n.role!='quote'] + activity += sum((max(0,n.end-n.start-2) for n in motion),fractions.Fraction())/duration + identity_cost = theme_distance(home,identity or notes,purpose) + register = sum(( (n.end-n.start)*max(0,abs(n.pitch.number()-center)-10) for n in notes),fractions.Fraction())/max(1,sounding) + arrival_cost = fractions.Fraction() + for anchor in destinations: + target = generator.solo.harmonic.anchor_note(anchor,notes) + if target is None: + arrival_cost += 2 + continue + before = [n for n in notes if n.end<=target.start][-3:] + if before: + distances = [abs(n.pitch.number()-target.pitch.number()) for n in before]+[0] + # A contour may turn. What matters here is whether the final approach + # prepares its already selected destination, rather than jumping to it. + arrival_cost += fractions.Fraction(max(0,distances[-2]-2),6) + arrival_cost += fractions.Fraction(sum(max(0,b-a) for a,b in zip(distances,distances[1:])),12) + if before[-1].end=1:raise ValueError('Candidate excerpt would clip at comparison gain') + name='audio/'+role.replace('/','_')+f'_candidate_{index}.wav' + playback.write_pcm(output/name,(.65*v for v in audio)) + selected=candidate['selected_in_final_solo'] + clips.append({'file':name,'version':f'Completed opening {index}'+(' — selected in final solo' if selected else ' — alternative'), + 'context':'source-only candidate, solo','peak':peak}) + return clips + + def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None,baseline_dir=None): generated_dir,output=Path(generated_dir),Path(output) manifest=json.loads((generated_dir/'manifest.solo').read_text()) @@ -348,6 +365,9 @@ def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None if assets:review['audition']=audition_pair(actual,expected,role,assets,output,tone_cache,baseline) review['production_trace']=item['trace'] review['production_phrases']=item.get('phrase_report',{}) + candidates=review['production_phrases'].get('selection_trace',{}).get('opening_candidates',[]) + if assets and candidates: + review['audition']['clips']+=candidate_clips(candidates,role,F(review['audition']['tempo']),output,tone_cache) # Preserve the real role cutoff; reference material beyond it is not trimmed. phrases=review['production_phrases'].get('phrases',[]) if phrases: @@ -384,6 +404,12 @@ def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None for purpose,counts in sorted(funnel.items()): rows.append(''+html.escape(purpose)+''+''.join(''+str(counts.get(k,0))+'' for k in stages)+'') rows.append('') + candidates=r['production_phrases'].get('selection_trace',{}).get('opening_candidates',[]) + if candidates: + rows.append('
Completed source-only opening candidates

All candidates were composed without the references. Selection accounts for later phrases; the final choice need not have the cheapest opening alone.

') + for index,c in enumerate(candidates,1): + rows.append(''+''.join('' for v in (index,c['selected_in_final_solo'],c['purpose'],c['evaluation']['contributions']))+'') + rows.append('
CandidateFinal choicePurposeScore contributions
'+html.escape(str(v))+'
') for clip in r.get('audition',{}).get('clips',[]): rows.append('

'+html.escape(clip['version']+' / '+clip['context'])+'

') rows.append('
Exact note differences') diff --git a/tests/test_phrase_composer.py b/tests/test_phrase_composer.py index 781e9fd..dbfe7d9 100644 --- a/tests/test_phrase_composer.py +++ b/tests/test_phrase_composer.py @@ -163,3 +163,129 @@ class ExpressionConstruction(unittest.TestCase): self.assertEqual(cs[0].kind,'anticipation') self.assertLess(ns[-1].start,4);self.assertEqual(ns[-1].end,6) self.assertTrue(b.harmony.evaluate(ns,expressions=cs,complete=True).valid) + + +class DirectedPhraseContracts(unittest.TestCase): + def test_different_destinations_change_the_complete_incoming_route(self): + b=builder(('CM7',),count=2) + rhythm=tuple((fractions.Fraction(i,2),fractions.Fraction(i+1,2)) for i in range(4)) + incoming=fixtures.note(-1,0,'g') + low=fixtures.note(2,4,'c') + high=fixtures.note(2,4,'c',octave=5) + args=(rhythm,(0,1,2,3),incoming,60,'connect') + a=b.phrases.route(*args,degree_path=True,target=low) + z=b.phrases.route(*args,degree_path=True,target=high) + self.assertIsNotNone(a);self.assertIsNotNone(z) + self.assertNotEqual([n.pitch for n in a[0]],[n.pitch for n in z[0]]) + self.assertLess(abs(a[0][-1].pitch.number()-low.pitch.number()),abs(z[0][-1].pitch.number()-low.pitch.number())) + + def test_anticipated_destination_is_planned_before_any_body_exists(self): + b=builder(('G6','B7'),count=2) + choices=b.phrases.arrival_options(fractions.Fraction(7,2),fractions.Fraction(6),fractions.Fraction(4),64,'settle') + target=next(a for a in choices if a.note.pitch.number()==63) + self.assertEqual(target.note.end,6) + self.assertTrue(b.harmony.evaluate((target.note,),expressions=target.contracts).valid) + self.assertFalse(b.harmony.evaluate((target.note,)).valid) + with patch.object(b.phrases,'choose',return_value='enclosure'): + notes,contracts=b.phrases.approach(target,fractions.Fraction(),1) + self.assertTrue(notes) + self.assertTrue(b.harmony.evaluate((*notes,target.note),expressions=(*target.contracts,*contracts)).valid) + self.assertEqual(contracts[0].boundary,4) + self.assertFalse(b.harmony.evaluate(notes,expressions=(*target.contracts,*contracts)).valid) + + def test_development_can_rephrase_source_hold_while_return_preserves_it(self): + from generator.solo.phrases import idea + b=builder(('CM7',),count=2) + original=(fixtures.note(0,1,'c'),fixtures.note(1,2,'e'),fixtures.note(2,6,'d')) + motif=idea(original,'synthetic') + developments=b.phrases.developments(motif,'develop') + self.assertTrue(any(max(y-x for x,y in d.rhythm)<4 for d in developments)) + self.assertTrue(all(d.intervals==motif.intervals for d in developments)) + self.assertEqual(b.phrases.developments(motif,'return'),(motif,)) + extensions=b.phrases.developments(motif,'extend') + reopened=next(v for v in extensions if v.source.endswith(':extend')) + self.assertLess(reopened.rhythm[2][1]-reopened.rhythm[2][0],4) + self.assertGreater(len(reopened.rhythm),len(motif.rhythm)) + self.assertEqual(reopened.intervals[:3],motif.intervals) + + def test_harmonic_pool_memoization_preserves_complete_candidates(self): + from generator.solo.phrases import Phrases + b=builder(count=2) + cached=b.candidates(fractions.Fraction(),b.end,construction.State()) + with patch.object(Phrases,'event_options',Phrases.event_options.__wrapped__): + clean=builder(count=2).candidates(fractions.Fraction(),b.end,construction.State()) + self.assertEqual(cached,clean) + + def test_colors_include_both_legal_octaves_at_a_midpoint(self): + b=builder(('C7','FM7'),count=2) + options=b.phrases.options(fractions.Fraction(),fractions.Fraction(1),50) + flat_six={n.pitch.number() for n,cs in options if any(c.rule=='dominant_flat_thirteenth' for c in cs)} + self.assertEqual(flat_six,{44,56}) + + def test_ordered_rhythmic_relationships_survive_retrieval(self): + from generator.solo.groups import descriptor + first=((fractions.Fraction(),fractions.Fraction(1,2)),(fractions.Fraction(1,2),fractions.Fraction(3,2)),(fractions.Fraction(3,2),fractions.Fraction(2))) + second=((fractions.Fraction(),fractions.Fraction(1)),(fractions.Fraction(1),fractions.Fraction(3,2)),(fractions.Fraction(3,2),fractions.Fraction(2))) + self.assertNotEqual(descriptor(first,2),descriptor(second,2)) + self.assertNotEqual(descriptor(first,2),descriptor(first,3)) # trailing breath + + def test_complete_candidate_contains_planned_local_and_final_arrivals(self): + b=builder(count=4) + candidates=b.candidates(fractions.Fraction(),b.end,construction.State()) + self.assertTrue(any(len(p.arrivals)>1 for p,_ in candidates)) + for p,_ in candidates: + self.assertTrue(harmonic.anchors_valid(p.arrivals,p.notes,p.end,complete=True,closure='closed')[0]) + self.assertEqual(p.evaluation.total,sum(p.evaluation.contributions.values())) + + def test_development_and_answer_require_real_identity_evidence(self): + home=(fixtures.note(0,1,'c'),fixtures.note(1,2,'e'),fixtures.note(2,3,'d')) + developed=(fixtures.note(4,fractions.Fraction(9,2),'d'),fixtures.note(fractions.Fraction(9,2),5,'f',alter=1),fixtures.note(5,6,'e')) + unrelated=(fixtures.note(4,5,'d'),fixtures.note(5,6,'c'),fixtures.note(6,7,'g')) + for purpose in ('develop','answer','extend'): + self.assertEqual(evaluation.theme_distance(home,developed,purpose),0) + self.assertGreater(evaluation.theme_distance(home,unrelated,purpose),0) + self.assertGreater(evaluation.theme_distance(home,developed[:1],purpose),0) + self.assertGreater(evaluation.theme_distance(home,developed,'return'),0) + + def test_arrival_preparation_ranks_realized_motion_not_action_label(self): + b=builder(('CM7',),count=1) + target=fixtures.note(2,4,'e',event_id='destination') + anchor=types.Anchor('end',2,2,target.pitch,frozenset({4}),(41,76),2,'terminal','CM7',hold_until=4) + flowing=(fixtures.note(0,1,'c'),fixtures.note(1,2,'d'),target) + jumping=(fixtures.note(0,1,'c'),fixtures.note(1,2,'d',octave=3),target) + good=evaluation.evaluate(flowing,0,4,b.harmony,destinations=(anchor,)) + poor=evaluation.evaluate(jumping,0,4,b.harmony,destinations=(anchor,)) + self.assertLess(good.raw['arrival'],poor.raw['arrival']) + self.assertLess(good.total,poor.total) + self.assertEqual(good,evaluation.evaluate(flowing,0,4,b.harmony,destinations=(anchor,),purpose='arpeggio')) + + def test_history_cost_distinguishes_attacks_from_one_sustain(self): + b=builder(('CM7',),count=4) + notes=tuple(fixtures.note(i,i+1,s) for i,s in enumerate(('c','e','d','g'))) + prior=types.Phrase('old',0,4,notes,'statement','up','open') + current=tuple(dataclasses.replace(n,start=n.start+4,end=n.end+4) for n in notes) + fresh=evaluation.evaluate(current,4,8,b.harmony) + repeated=evaluation.evaluate(current,4,8,b.harmony,history=(prior,prior,prior)) + self.assertGreater(repeated.raw['repetition'],fresh.raw['repetition']) + held=fixtures.note(4,8,'e') + single=evaluation.evaluate((held,),4,8,b.harmony,history=(prior,prior,prior)) + self.assertEqual(single.raw['repetition'],0) + + def test_held_destination_is_not_inactive_motion_and_is_binding(self): + b=builder(('CM7',),count=1) + target=fixtures.note(0,4,'e',event_id='held') + anchor=types.Anchor('end',0,0,target.pitch,frozenset({4}),(41,76),4,'terminal','CM7',hold_until=4) + planned=evaluation.evaluate((target,),0,4,b.harmony,destinations=(anchor,)) + accidental=evaluation.evaluate((target,),0,4,b.harmony) + self.assertEqual(planned.raw['activity'],0) + self.assertGreater(accidental.raw['activity'],planned.raw['activity']) + self.assertFalse(evaluation.evaluate((dataclasses.replace(target,end=1),),0,4,b.harmony,destinations=(anchor,)).valid) + + def test_configured_weights_change_the_authoritative_contribution(self): + b=builder(('CM7',),count=1) + ns=(fixtures.note(0,1,'c'),fixtures.note(1,2,'c'),fixtures.note(2,4,'e')) + before=evaluation.evaluate(ns,0,4,b.harmony) + with patch.dict(evaluation.WEIGHTS,{'repetition':evaluation.WEIGHTS['repetition']+1}): + after=evaluation.evaluate(ns,0,4,b.harmony) + self.assertEqual(after.total-before.total,before.raw['repetition']) + self.assertEqual(after.total,sum(after.contributions.values()))
Beat relative to formGeneratedReference