From 5fff5a165e29f1b5d6cfd7043534e8ee39a56a2d Mon Sep 17 00:00:00 2001 From: khodowany Date: Fri, 2 Oct 2026 06:33:01 -0500 Subject: [PATCH] Solo --- TECHNICAL_GUIDE.md | 16 +- generator/solo/audition.py | 23 ++- generator/solo/drafts.py | 3 + generator/solo/leads.py | 158 +++++++++++++------- generator/solo/phrasing.py | 161 ++++++++++++++++++++ generator/solo/policy.py | 10 +- generator/solo/report.py | 55 ++++++- generator/solo/routes.py | 109 +++++++++----- generator/solo/themes.py | 289 +++++++++++++++++++++++------------- generator/solo/types.py | 19 +++ tests/CMakeLists.txt | 4 +- tests/test_solo_phrasing.py | 133 +++++++++++++++++ tests/test_solo_themes.py | 17 ++- 13 files changed, 786 insertions(+), 211 deletions(-) create mode 100644 generator/solo/phrasing.py create mode 100644 tests/test_solo_phrasing.py diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index 7a61291..cd25876 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -190,23 +190,25 @@ Scale labels appear above chord symbols at collection changes and system starts. ### Phrase Composition -`generator/solo/` is build-only. `analysis.py` extracts one- and two-bar monophonic motifs from the normalized melody, collapsing ties while retaining repeated attacks, leading rests, source event IDs, and harmonic context. `planning.py` selects up to three recurring motif identities and plans statements, returns, variations and answers around two- to four-bar boundaries. Each phrase has active passages, a sustained arrival and optional intentional space, plus entrance, intermediate and arrival register windows. Melody breathing points and harmonic destinations influence phrase boundaries. `types.py` owns the exact rational timing models. No annotations are added to source XML. +`generator/solo/` is build-only. The active path is `arrangement.apply` → `composer.compose_pair` → `leads.thematic_options` → `themes.phrase_candidates` → `realization.checked_phrase`. The song's actual melody supplies a selected rhythmic, contour, opening, or interval seed. Each instrument introduces its own realized solo theme; later development refers to that heard identity, not each successive phrase of the head. Close paraphrase remains available through `mode='paraphrase'` for comparisons. The older cell-level planner/refiner is not the production theme constructor. -`sequences.py` validates all 2-, 3- and 4-note paths in `data/sequences`, then retrieves representatives by displacement, direction, shape and destination. Longer paths chain catalog entries through a shared endpoint and report every source. Steps accumulate through octaves, including negative scale indices. Sequence meter describes the source container, not the realized duration. `realization.py` fits paths to attacks from `data/rhythms`, retaining connected eighth-note patterns and slicing at the actual bar offset without stretching time. It also supports single targets, repeated notes, holds, and silence. Thematic contour and harmonic adaptation continue across cells and collection changes; every held pitch must remain permitted for its entire duration. +`phrasing.py` proposes four-, five-, six-, and eight-bar endpoints from actual measure boundaries and harmony, with explicit missing-harmony interruptions and a trimmed final handoff. Two promising endpoint alternatives compete through realization from each incoming history. A soft preference aims for approximately six substantial closes per 32 bars (five to seven is the initial range). Ending alternatives choose a chord-valid pitch, arrival, two-to-four-beat sounding interval, and optional release together before constructing the approach. Intermediate search units do not close the thought. A held target must remain one sounding event for the promised interval; repeated attacks or touching and abandoning it do not fulfill the contract. Cross-bar holds must remain valid across every chord and mapped collection they overlap. -`scoring.py` evaluates complete sounding prefixes against the activity planned so far. Attack density and sounding occupancy are separate, and explicit ties count as one attack. Active or sustained passages cannot disappear through a missing register value; intentional space and no-harmony regions have different obligations. A register corridor charges only pitches outside its bounds. Intermediate landmarks are evaluated once their windows close. Duration-normalized, bounded penalties use the curated weights in `policy.py`: activity 4, occupancy 4, motif fidelity 3, landmarks 3, register/contour/connection/repetition 1 each, guitar movement 0.5. Fixed evaluation windows and bounded caches avoid repeating harmonic analysis for every extension. Soft ranking uses floating-point penalties; timing, scale validity, protected quotes, required arrivals and guitar placement retain exact constraints. +Theme appearances distinguish clear returns, developed appearances, and freer continuation. A clear return preserves an identifying melodic relationship with its timing; development can preserve melodic intervals while altering rhythm, or a characteristic rhythm while recomposing pitches. Feature selection looks for a heard turn or leap rather than automatically protecting the first events. A uniform quarter-note rhythm alone is not sufficient identity for a rhythmic development. Approximately two clear returns are preferred, independently of phrase closures. Once a feature contract is fulfilled, additional copying is not rewarded. -Motif fidelity follows the planned contract: `exact_quote` preserves pitches and attack/release timing, permitting only a whole-fragment octave relocation; `rhythmic_reference` preserves timing while changing pitches; `contour_reference` retains direction changes; `opening_reference` protects the beginning; `answer` permits a changed ending; and `invented` develops its own sequence-derived identity. Exact quotation remains explicitly available but is not scheduled automatically. Invented motifs depend on harmony, starting register, shape and destination. Stable musical-content identities ignore file paths and titles; reports record both identity and the policy fingerprint. +`sequences.py` validates relative paths in `data/sequences` and chains them into complete routes using octave-aware scale indices. `themes.py` combines these with exact rhythms from `data/rhythms`, including rhythmic alteration, diminution, displacement already present in catalog windows, and sustained endings. Concrete routes retain their complete entry, pitch path, timing, and destination. Up to eight rhythm alternatives and six destinations are retrieved; sixteen joint rhythm/start/destination jobs preserve coverage before expensive realization, expanding to thirty-two during a bounded replan. Eight intermediate, phrase, and chorus alternatives are retained. Cache keys include the actual musical state used in construction; history-dependent ranking occurs after shared concrete retrieval. -Phrase search retains at most 64 states and considers 32 diverse extensions per state. Its identity includes activity, rhythm, landmark and motif progress as well as the placed ending. Up to four completed alternatives are retained, and `composer.py` evaluates the next phrase from those endings before committing. Closed arrivals prefer defining chord tones; open statements may target a compatible fifth or extension, respecting explicit alterations and omissions. A bounded-search failure identifies the passage and limits, not mathematical impossibility. +A rolling sixteen-bar history measures exact and transposed pitch/rhythm recurrence, repeated oscillations, and cadence formulas. Recent reuse receives more pressure than distant reuse, and deliberate returns receive a discount rather than immunity. Ties collapse to one attack. Register goals offer wider acoustic development and lower electric answers after high passages. These are musical preferences, not range or string quotas. Phrase activity retains a broad preference of four to six attacks per four quarter-note beats, including its held ending; individual closing cells have no attack quota. Occupancy is diagnostic. Explicit active, sustained-arrival, and space spans prevent a silent candidate from satisfying a moving passage. -Acoustic begins with a transformed melody reference; electric begins with a contextual invention. Soft activity goals average 4.5 and 5.5 attacks per four beats respectively, distributed unevenly across movement and arrival. These are not per-bar quotas. Boundaries may connect, sustain or breathe; only the returning vocal pickup has mandatory protection. Sounding registers start around G3–D5 and B3–G5 respectively; written guitar notation is one octave above sound, exactly once. `placement.py` uses frets 1–20 with no open strings and preserves positions through explicit sustain continuations. Movement cost uses the first declared source tempo, or 120 quarter notes/minute. Runtime tempo remains adjustable, so this is not a guarantee of ease at every tempo. Chromatic ornaments, expressive articulation, dynamics and swing playback remain deferred. +`leads.elaborate` may replace a complete internal span of shorter notes, as well as an editable portion of a hold. Feature contracts and protected sounding tails are rechecked against the complete result. Exact source quotations remain immutable. The plain theme is a complete playable line, and elaboration is optional. Harmonic validity, exact timing, scale membership, monophony, required holds, and guitar placement remain hard constraints. A bounded replan may reconsider eligible ending alternatives; exhausted search reports its failed passage without claiming mathematical incompatibility or falling back to generic material. + +Acoustic/electric selection compares complete alternatives, preserving different related themes and discouraging unintended long copies. Placement uses sounding pitches on all six strings, frets 1–12 without open strings; written guitar pitch is one octave above sound exactly once. Ties retain string and fret. The first declared source tempo, or 120 quarter notes/minute, informs movement costs; runtime tempo remains adjustable. No out-of-collection chromatic substitutions, accents, dynamics, velocity shaping, or swing changes are part of this composer. `arrangement.py` analyzes each song once and replaces only the two solo-section windows after normal head/accompaniment placement. Electric backs Acoustic Solo; acoustic backs Electric Solo. Neither guitarist accompanies itself. Backing rhythms use the generated solo's foreground and the reserved vocal return, not the absent original melody. Electric may reuse the corresponding placed head grip only when its pitches fit the active solo collection; otherwise ordinary chord-tone placement applies. Existing chord constraints remain unchanged. All non-solo measures and the complete vocal part are regression boundaries. The vocal pickup cutoff is reserved before composing the electric ending. `notation.py` converts placed attack spans into ordinary score events, rests, and tied continuations across notation/bar boundaries. Engraving, playback and C++ consumption retain their existing formats. `report.py` appends motif/source IDs, transformation contracts, activity and landmark plans, target windows, sounding pitches, TAB decisions and search diagnostics. Generation still stages and validates the complete output before publication. Listening and playing remain necessary to accept the musical result. -`python -m generator.solo.audition --capture D` captures arrangements, reports, full-solo metrics, source hashes, policy and implementation fingerprints under `build/solo_auditions/D`. The build-only utility refuses to overwrite a snapshot. Capture `baseline`, `A`, `B` and `C` from their corresponding implementation checkpoints before capture D; these labels do not select runtime presets. `--render` creates eight-bar MusicXML and WAV comparisons for each song and guitar using the baseline backing, plus final responsive-backing clips. All clips use existing tone generation, source tempo and a shared gain. `index.html` provides playback and links to reports; `manifest.json` records reproducibility metadata. Metrics cover activity, occupancy, unplanned gaps, connected eighths, quotation and surviving candidate families. They diagnose behavior rather than establish musical quality. +`python -m generator.solo.audition --output build/solo_phrasing_auditions --capture before` preserves the current implementation, and `--capture after` captures its successor. Existing snapshots are never overwritten. `--render` exports full solos, opening eight bars, and closing four bars as MusicXML and WAV, with baseline backing held constant and a separate responsive arrangement. It also exports the new plain theme and unaccompanied plain/elaborated full solos. Source hashes, implementation/policy fingerprints, tempo, and shared mix gain are recorded. Reports distinguish completed sustained endings from ordinary long notes, show retained theme features, recurrence counts, movement/arrival activity, local register distribution, and forced versus optional first-string use. These diagnostics establish behavior; listening is still needed to judge musical quality. ### Engraving Boundary diff --git a/generator/solo/audition.py b/generator/solo/audition.py index 5f545c2..2f41303 100644 --- a/generator/solo/audition.py +++ b/generator/solo/audition.py @@ -240,6 +240,20 @@ def render(root): samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache) clips.append((path, samples)) manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'window': label, 'first': offset, 'count': count, 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']}) + if stage == 'after' and 'refined' in solo.get('lead_stages', {}): + plain = excerpt(solo['lead_stages']['refined']['document'], offset, count) + plain_prefix = prefix.with_name(prefix.name + '_plain') + generator.musicxml_writer.write_musicxml(plain_prefix.with_suffix('.musicxml'), plain) + backing = excerpt(baseline['parts'][other], first, count) + vocal = excerpt(baseline['parts']['vocal'], first, count) + path = plain_prefix.with_name(plain_prefix.name + '_fixed').with_suffix('.wav') + clips.append((path, mix((plain, backing, vocal), fractions.Fraction(solo['tempo']), root, cache))) + manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'plain_theme', 'window': label}) + if label == 'full': + for kind, document in (('plain', plain), ('elaborated', foreground)): + path = prefix.with_name(prefix.name + '_' + kind + '_solo').with_suffix('.wav') + clips.append((path, mix((document,), fractions.Fraction(solo['tempo']), root, cache))) + manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': kind + '_unaccompanied', 'window': label}) for draft in solo.get('draft_documents', ()): prefix = root / stage / f'{song_name}_{part}_draft_{draft["id"]}' generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), draft['document']) @@ -314,14 +328,21 @@ def write_passage_index(root, snapshots): for stage in ('before', 'after'): metric = snapshots[stage]['songs'][name]['solos'][part]['metrics'] body.append(f"

{stage.title()}: {metric['attacks_per_four_beats']:.2f} attacks / four beats; {metric['sounding_occupancy']:.1%} occupancy; longest unplanned gap {metric['longest_unplanned_gap_beats']:g} beats.

") - body.append('') + plain_available='refined' in song['solos'][part].get('lead_stages',{}) + body.append('
PassageBefore, fixed backingAfter, fixed backingAfter, responsive backing
' + ('' if plain_available else '') + '') for window in ('full', 'opening8', 'closing4'): body.append(f'') for stage, mix_name in (('before', 'fixed'), ('after', 'fixed'), ('after', 'responsive')): prefix = stage + '/' + name + '_' + part + '_' + window body.append(f'') + if plain_available: + prefix=f'after/{name}_{part}_{window}_plain' + body.append(f'') body.append('') body.append('
PassageBefore, fixed backingAfter, fixed backingAfter, responsive backingPlain theme, fixed backing
{window}MusicXML · Report' + (f' · Passage data' if stage == 'after' else '') + 'Plain theme notation
') + if plain_available: + prefix=f'after/{name}_{part}_full' + body.append(f'

Unaccompanied plain theme: Elaborated:

') (root / 'index.html').write_text(''.join(body) + '\n') def main(): diff --git a/generator/solo/drafts.py b/generator/solo/drafts.py index 79a424b..06a670e 100644 --- a/generator/solo/drafts.py +++ b/generator/solo/drafts.py @@ -350,6 +350,9 @@ def local_sounding(notes): def contract_valid(draft, notes): by_id = {n.event_id: n for n in local_sounding(notes)} + for tail in draft.sustained_tails: + if not any(n.pitch.number() == tail.pitch and n.start <= tail.start and n.end >= tail.end for n in by_id.values()): + return False, f'{tail.start}-{tail.end}: required sounding arrival tail changed' original = {n.event_id: n for n in draft.notes} for identity in (*draft.structural, *draft.protected): before, after = original[identity], by_id.get(identity) diff --git a/generator/solo/leads.py b/generator/solo/leads.py index d42b34a..f6e7725 100644 --- a/generator/solo/leads.py +++ b/generator/solo/leads.py @@ -10,7 +10,7 @@ import fractions import generator.model import generator.fretboard -from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts +from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts, references F = fractions.Fraction @@ -160,49 +160,54 @@ def connection_patterns(start,end,entry,destination,rhythms,meter): return result -def elaborate(notes, features, harmony, library, rhythms, meter, source, *, sustain_entry=False): - """Edit an exposed held tail or a complete interior span, with both endpoints known.""" - fixed={e for f in features for e in f.events} +def elaborate(notes, features, harmony, library, rhythms, meter, source, *, sustain_entry=False, tails=None): + """Replace complete internal spans while retaining identity and sounding tails.""" + legacy=tails is None + tails=tails or () + fixed={e for f in features for e in f.events} if legacy else {n.event_id for n in notes if n.role=='quote'} windows=[] for i,(note,target) in enumerate(zip(notes,notes[1:])): - if note.event_id in fixed or note.end != target.start: - continue + if note.event_id in fixed or note.end!=target.start:continue if note.end-note.start>=F(3,2): start=max(note.start+F(1,2),target.start-3) - windows.append((i,start,target,False)) - # A source-held ending can arrive later via a related in-scale turn. - if notes and notes[-1].event_id not in fixed and notes[-1].end-notes[-1].start>=F(5,2): + windows.append((i,i+1,start,target)) + if not legacy: + for j in range(i+2,min(i+5,len(notes))): + target=notes[j] + if not 1<=target.start-note.start<=3:continue + if any(n.event_id in fixed for n in notes[i:j]):continue + if any(t.startnote.start for t in tails):continue + windows.append((i,j,note.start,target)) + if legacy and notes and notes[-1].event_id not in fixed and notes[-1].end-notes[-1].start>=F(5,2): tail=notes[-1] for duration in (F(1,2),F(1),F(3,2),F(2)): target=dataclasses.replace(tail,start=tail.end-duration,event_id=tail.event_id+':arrival') start=max(tail.start+F(1,2),target.start-3) - if target.start-start>=1: - windows.append((len(notes)-1,start,target,True)) + if target.start-start>=1:windows.append((len(notes)-1,len(notes)-1,start,target)) + # Consequential edits only; do not exhaustively branch at each passing note. + windows=sequences.diverse(windows,lambda w:(policy.stable_rank(source.id,w),),lambda w:w[0],8) result=[] - for i,start,target,terminal in windows: - entry=notes[i];end=target.start + for i,j,start,target in windows: + entry=notes[i] prefix=dataclasses.replace(entry,end=start) seed=dataclasses.replace(entry,start=start,event_id=entry.event_id+':tail',role='connection') - for pattern,rhythm_id in connection_patterns(start,end,seed,target,rhythms,meter): + for pattern,rhythm_id in connection_patterns(start,target.start,seed,target,rhythms,meter): for route in routes.candidates(pattern,{target.event_id},harmony,library,(41,76),'develop '+source.id): - if sustain_entry: - # The route owns its complete sounding entry, including the old hold. + if sustain_entry or start==entry.start: joined=dataclasses.replace(route.notes[0],start=entry.start,event_id=entry.event_id) route=dataclasses.replace(route,notes=(joined,*route.notes[1:])) - actual=(*notes[:i],*route.notes,*notes[i+1 if terminal else i+2:]) + actual=(*notes[:i],*route.notes,*notes[j+1:]) else: - actual=(*notes[:i],prefix,*route.notes,*notes[i+1 if terminal else i+2:]) - if not harmony.evaluate(actual,complete=True).valid: - continue - if any(abs(b.pitch.number()-a.pitch.number())>7 for a,b in zip(route.notes,route.notes[1:])): - continue + actual=(*notes[:i],prefix,*route.notes,*notes[j+1:]) + if any(references.feature_error(f,actual) for f in features):continue + if any(not any(n.pitch.number()==t.pitch and n.start<=t.start and n.end>=t.end for n in actual) for t in tails):continue + if not harmony.evaluate(actual,complete=True).valid:continue + if any(abs(b.pitch.number()-a.pitch.number())>7 for a,b in zip(route.notes,route.notes[1:])):continue route=dataclasses.replace(route,sources=(*route.sources,'rhythm:'+rhythm_id)) - edit=types.DraftEdit('complete_route',start,target.start,(entry.event_id,),tuple(n.event_id for n in route.notes), - source.id+'; '+rhythm_id+'; '+('later terminal arrival on ' if terminal else 'lead into ')+target.pitch.label()) + edit=types.DraftEdit('complete_route',start,target.start,tuple(n.event_id for n in notes[i:j+1]),tuple(n.event_id for n in route.notes), + source.id+'; '+rhythm_id+'; lead into '+target.pitch.label()) result.append((actual,edit,route)) - # Whole routes compete; bounded diversity retains different locations and rhythms. - return sequences.diverse(result,lambda v:(abs(len(v[0])*4/float(source.end-source.start)-5), - sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(v[2].notes,v[2].notes[1:])),v[2].id), + return sequences.diverse(result,lambda v:(max(0,4-len(v[0])*4/float(source.end-source.start),len(v[0])*4/float(source.end-source.start)-6),v[2].id), lambda v:(v[1].start,v[2].destination.end-v[2].destination.start,len(v[2].notes)),8) @@ -315,13 +320,81 @@ def transition(first,second): return .2*leap+.15*bool(repeated_end) -def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'): - from generator.solo import realization, themes - if mode not in ('thematic','paraphrase'):raise ValueError('Unknown solo treatment: '+mode) +def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage): + from generator.solo import realization,themes,phrasing context.resources[id(library)]=library context.resources[id(rhythms)]=rhythms harmony=harmonic.Harmony(choices) - sources=themes.windows(analysis,measures,end,harmony) if mode=='thematic' else clipped_sources(analysis,end) + seed=policy.stable_rank('source_variations',(analysis.melody,choices,policy.SEED_NAMESPACE)) + states=[(0.,())] + failures=[] + for _ in range(2*len(measures)+1): + if all(history and history[-1].plan.end==end for _,history in states):break + following=[] + for cost,history in states: + start=history[-1].plan.end if history else F() + if start==end: + following.append((cost,history));continue + incoming=history[-1].state if history else types.ContinuationState('') + sources=phrasing.window_options(analysis,measures,start,end,harmony,incoming) + elapsed=F();meter=measures[0]['time_signature'] + for measure in measures: + elapsed+=generator.model.fraction(measure['duration']) + if elapsed>start: + meter=measure['time_signature'];break + bar=F(meter.split('/')[0])*4/int(meter.split('/')[1]) + for source in sources: + key=('phrasing',source,incoming,part,end,stage,seed,id(library),id(rhythms),policy.DEFAULT) + if key in context.results: + pool=context.results[key];context.diagnostics['cache_hits']+=1 + else: + pool=() + if not harmony.at(start) or not harmony.at(start).scale: + pool=themes.silence(source,incoming,part,harmony,analysis.tempo,seed) + else: + for batch in range(policy.DEFAULT.replan_attempts): + pool=themes.phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,analysis.tempo,context,seed, + stage=stage,batch=batch,final=source.end==end,solo_end=end) + if pool:break + context.diagnostics['theme_replans']=context.diagnostics.get('theme_replans',0)+1 + context.results[key]=pool + context.diagnostics.setdefault('source_candidate_pools',{}).setdefault(source.id,[]).extend( + {'id':p.draft.id,'score':p.score,'attacks':len(p.draft.notes),'theme':p.state.home.id if p.state.home else None, + 'ending':str(p.anchors[-1].duration) if p.anchors else None,'returns':len(p.state.returns)} for p in pool) + for candidate in pool: + span=float((source.end-source.start)/end) + total=cost+span*(candidate.score+phrasing.endpoint_cost(source.end,start,end,incoming.closures,harmony,bar)) + total+=transition(history[-1] if history else None,candidate) + following.append((total,(*history,candidate))) + if not pool:failures.append(f'{source.id}: planned sustained destination, feature or incoming continuation') + if not following: + raise realization.SearchExhausted(f'{part}: {"; ".join(failures[-4:])} failed within the bounded search; not proof of incompatibility') + def rank(value): + cost,history=value;state=history[-1].state;elapsed=history[-1].plan.end + musical=cost/max(.001,float(elapsed/end)) + if elapsed==end: + target=policy.DEFAULT.settling_target*float(end)/(32*4) + musical+=.1*max(0,abs(state.closures-target)-1) + musical+=.15*abs(len(state.returns)-policy.DEFAULT.return_target*float(end)/(32*4)) + return musical,policy.stable_rank(seed,tuple(p.draft.id for p in history)) + states=sequences.diverse(following,rank, + lambda v:(v[1][-1].state.home.id if v[1][-1].state.home else '',v[1][-1].plan.end,len(v[1][-1].state.returns)),policy.DEFAULT.chorus_frontier) + finished=[(cost,history) for cost,history in states if history[-1].plan.end==end] + if not finished:raise realization.SearchExhausted(f'{part}: phrase-boundary search budget exhausted; not proof of incompatibility') + return tuple((rank((cost,phrases))[0],types.Solo(part,choices,phrases,analysis.tempo,end, + {**context.diagnostics,'construction':'thematic_recomposition','policy':policy.describe()['fingerprint']}, + tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases))) for cost,phrases in finished) + + +def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'): + from generator.solo import realization + if mode not in ('thematic','paraphrase'):raise ValueError('Unknown solo treatment: '+mode) + if mode=='thematic': + return thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage) + context.resources[id(library)]=library + context.resources[id(rhythms)]=rhythms + harmony=harmonic.Harmony(choices) + sources=clipped_sources(analysis,end) seed=policy.stable_rank('source_variations', (analysis.melody,choices,policy.SEED_NAMESPACE)) states=[(0.,())] for source in sources: @@ -329,30 +402,15 @@ def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stag following=[] for cost,history in states: incoming=history[-1].state if history else types.ContinuationState('') - key=(mode,source,incoming if mode=='thematic' else None,policy.stable_rank("harmony",choices),part,analysis.tempo,seed,stage,id(library),id(rhythms),policy.DEFAULT) + key=(mode,source,None,policy.stable_rank("harmony",choices),part,analysis.tempo,seed,stage,id(library),id(rhythms),policy.DEFAULT) if key in context.results: pool=context.results[key];context.diagnostics['cache_hits']+=1 else: - if mode=='thematic' and any(c.scale and c.span.startsource.start for c in choices): - pool=() - for batch in range(2): - pool=themes.phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,analysis.tempo,context,seed,stage=stage,batch=batch,final=source.end==end) - if pool:break - context.diagnostics['theme_replans']=context.diagnostics.get('theme_replans',0)+1 - elif mode=='thematic': - pool=themes.silence(source,incoming,part,harmony,analysis.tempo,seed) - else: - pool=candidates(source,harmony,library,rhythms,meter,part,analysis.tempo,seed,stage=stage) + pool=candidates(source,harmony,library,rhythms,meter,part,analysis.tempo,seed,stage=stage) context.results[key]=pool;context.diagnostics['generated_candidates']+=len(pool) context.diagnostics.setdefault('source_candidate_pools', {}).setdefault(source.id,[]).extend( {'id':p.draft.id,'score':p.score,'octave':p.draft.octave_transfer,'attacks':len(p.draft.notes),'edits':[e.kind for e in p.edits],'source_family':source.family,'theme':p.state.home.id if p.state.home else None} for p in pool) for candidate in pool: - # The thematic constructor has already placed all decisions from the - # actual incoming state; do not collapse its motif groups into one bar. - if mode=='thematic': - total=cost+candidate.score*float(source.end-source.start)/float(end)+transition(history[-1] if history else None,candidate) - following.append((total,(*history,candidate))) - continue last=incoming.last placements=placement.alternatives(tuple(n for d in candidate.decisions for n in d.notes),analysis.tempo,last,limit=2) for travel,notes in placements: @@ -364,8 +422,8 @@ def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stag if not following: raise realization.SearchExhausted(f'{part} {source.id}: {mode} feature, harmonic destination or continuation failed within the bounded search; not proof of incompatibility') states=sequences.diverse(following,lambda v:(v[0],policy.stable_rank(seed,tuple((p.draft.id,p.state.last.position if p.state.last else None) for p in v[1]))), - lambda v:(v[1][-1].state.home.id if mode=='thematic' and v[1][-1].state.home else tuple(p.draft.octave_transfer for p in v[1])),policy.DEFAULT.chorus_frontier) - return tuple((cost,types.Solo(part,choices,phrases,analysis.tempo,end,{**context.diagnostics,'construction':'thematic_recomposition' if mode=='thematic' else 'source_phrase_variations','policy':policy.describe()['fingerprint']}, + lambda v:(tuple(p.draft.octave_transfer for p in v[1])),policy.DEFAULT.chorus_frontier) + return tuple((cost,types.Solo(part,choices,phrases,analysis.tempo,end,{**context.diagnostics,'construction':'source_phrase_variations','policy':policy.describe()['fingerprint']}, tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases))) for cost,phrases in states) diff --git a/generator/solo/phrasing.py b/generator/solo/phrasing.py new file mode 100644 index 0000000..5fff7e0 --- /dev/null +++ b/generator/solo/phrasing.py @@ -0,0 +1,161 @@ +"""Sustained destinations and bounded memory for the existing theme composer.""" +from __future__ import annotations + +import dataclasses +import functools +from fractions import Fraction as F + +import generator.model +import generator.harmony +from generator.solo import types, policy, sequences + + +def heard(notes, operation, anchor=None): + notes = types.sounding_notes(notes) + first = notes[0].start + events = tuple((n.start-first, n.end-n.start, n.pitch.number()) for n in notes) + ending = (anchor.pitch.number()%12, anchor.earliest%4, anchor.duration) if anchor and anchor.role == 'terminal' else () + return types.HeardFigure(first, notes[-1].end, events, operation, ending) + + +def remember(history, figure, bar): + return tuple(h for h in (*history, figure) if h.end > figure.end-policy.DEFAULT.history_bars*bar) + + +@functools.lru_cache(maxsize=4096) +def fingerprints(events, relative=False): + result = set() + for size in (3, 4, 6, 8): + for i in range(len(events)-size+1): + group = events[i:i+size] + start, _, pitch = group[0] + result.add(tuple((a-start, d, p-pitch if relative else p) for a,d,p in group)) + return frozenset(result) + + +def repetition(figure, history, bar): + """Bounded overuse, not a reward for arbitrary difference or extra attacks.""" + exact, relative = fingerprints(figure.events), fingerprints(figure.events, True) + overuse = cadence = 0. + for old in history: + age = max(0., float((figure.start-old.end)/bar)) + recent = max(0., 1-age/policy.DEFAULT.history_bars) + a,b = fingerprints(old.events), fingerprints(old.events, True) + exact_match = len(exact & a)/max(1,min(len(exact),len(a))) + shifted_match = len(relative & b)/max(1,min(len(relative),len(b))) + overuse += recent*(.65*exact_match+.35*shifted_match) + if figure.ending and figure.ending == old.ending: + cadence += recent + pitches = tuple(p for _,_,p in figure.events) + oscillation = sum(a==c and b==d for a,b,c,d in zip(pitches,pitches[1:],pitches[2:],pitches[3:]))/max(1,len(pitches)-3) + discount = .45 if figure.operation == 'return' else 1. + return min(1., discount*overuse/2) + .3*oscillation + .3*min(1.,cadence/2) + + +def register_goal(incoming, part, fallback): + recent = [p for h in incoming.history[-2:] for _,_,p in h.events] + if not recent: + return fallback + center = sum(recent)/len(recent) + if part == 'electric' and (center >= 67 or incoming.register_peak >= 72 and center >= 64): + return max(48, round(center)-7) + if part == 'acoustic' and max(recent)-min(recent) < 8: + home = sum(n.pitch.number() for n in incoming.home.notes)/len(incoming.home.notes) if incoming.home else center + return min(72, round(center)+5) if center <= home+7 else max(45, round(center)-5) + return round(center) + + +def endpoint_cost(finish, start, end, count, harmony, bar): + length = float((finish-start)/bar) + remaining = float((end-finish)/bar) + projected = count+1+remaining/(32/policy.DEFAULT.settling_target) + low, high = policy.DEFAULT.settling_range + pacing = .2*max(0,low-projected,projected-high)+.025*abs(projected-policy.DEFAULT.settling_target) + span = harmony.at(finish-F(1,100)) + # A dominant may close an open question, but a nearby resolution is preferred. + degrees=generator.harmony.degrees(generator.harmony.parse_symbol(span.chord)) if span and span.scale else {} + dominant=degrees.get(3)==0 and degrees.get(7)==-1 + short_remainder=.2*max(0,4-remaining) if remaining else 0. + return pacing+.035*abs(length-5)+.16*dominant+.1*(length==8)+short_remainder + + +def window_options(analysis, measures, start, end, harmony, incoming, *, limit=2): + from generator.solo.themes import source_notes + bounds=[F()] + for measure in measures:bounds.append(bounds[-1]+generator.model.fraction(measure['duration'])) + index=next((i for i,b in enumerate(bounds[:-1]) if b<=startstart),default=end) + ends=tuple(sorted({min(end,barrier,bounds[min(index+n,len(measures))]) for n in (4,5,6,8)})) + if barrier>=end: + # The returning pickup may shorten the last measure. Do not strand a + # one-beat "new phrase" after an otherwise completed sustained close. + ends=tuple(sorted({end if 0start) + values.append(types.SourcePhrase(f'theme_window_{start}_{finish}',start,finish,notes,(),'')) + return tuple(sorted(values,key=lambda s:(endpoint_cost(s.end,start,end,incoming.closures,harmony,bar),s.end))[:limit]) + + +def endings(source, incoming, part, harmony, bar, *, limit=8): + """Choose pitch AND sounding interval before generating the approach.""" + from generator.solo.leads import available + base=incoming.last.pitch.number() if incoming.last else source.notes[0].pitch.number() if source.notes else 55 + goal=register_goal(incoming,part,base) + values=[] + duration=source.end-source.start + holds=(F(2),F(5,2),F(3),F(7,2),F(4)) if duration>=4 else (min(F(2),duration/2),) + for hold in holds: + for release in (F(),F(1,2),F(1),F(2)): + arrival=source.end-release-hold + if arrival-source.start12:continue + anchor=types.Anchor(f'{source.id}:close:{arrival}:{pitch.number()}',arrival,arrival,pitch, + harmony.members(arrival,arrival+hold),(41,76),hold,'terminal',harmony.at(arrival).chord, + hold_until=arrival+hold) + cadence=sum(h.ending==(pitch.number()%12,arrival%4,hold) for h in incoming.history) + rank=abs(pitch.number()-goal)/24+.16*min(2,cadence)+.035*abs(float(hold)-3)+.015*float(release) + values.append((rank,anchor)) + selected=sequences.diverse(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])), + lambda x:(x[1].duration,x[1].earliest%bar,x[1].pitch.number()>goal),limit) + return tuple(a for _,a in selected) + + +def appearance_cost(operation, incoming, start, end): + expected=policy.DEFAULT.return_target*float(start/max(end,F(1))) + actual=len(incoming.returns) + if operation=='return': + gap=start-(incoming.returns[-1] if incoming.returns else F()) + return .65*max(0,actual+1-expected)+.5*max(0,1-float(gap)/24) + return .45*max(0,expected-actual) + + +def activity(source, ending): + spans=[] + if ending.earliest>source.start: + spans.append(types.ActivitySpan(source.start,ending.earliest,'active',F(5,4),F())) + spans.append(types.ActivitySpan(ending.earliest,ending.hold_until,'sustained_arrival',F(),F(1))) + if ending.hold_until=2 for e in endings), + 'endings':endings,'appearances':appearances,'clear_returns':sum(a['operation']=='return' for a in appearances), + 'exact_four_note_recurrences':sum(max(0,n-1) for n in exact.values()), + 'interval_rhythm_four_note_recurrences':sum(max(0,n-1) for n in relative.values()), + 'activity':activity,'register_regions':regions} def fretboard_report(solo): diff --git a/generator/solo/routes.py b/generator/solo/routes.py index 871856c..5091ade 100644 --- a/generator/solo/routes.py +++ b/generator/solo/routes.py @@ -31,11 +31,34 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t if not hasattr(harmony,'route_cache'): harmony.route_cache={} harmony.route_libraries={} + harmony.concrete_routes={} harmony.route_libraries[id(library)]=library - key=(notes,frozenset(fixed),id(library),register) + concrete_key=(notes,frozenset(fixed),id(library),register) + if concrete_key in harmony.concrete_routes: + return tuple(dataclasses.replace(r,relationship=relationship) for r in harmony.concrete_routes[concrete_key]) + # Event/source IDs and non-fixed placeholder pitches do not affect route + # feasibility. Cache the pitch work across different thematic histories, + # then restore this caller's provenance before ranking complete routes. + fixed_indices=frozenset(i for i,n in enumerate(notes) if n.event_id in fixed) + canonical=tuple(dataclasses.replace(n,sources=(),event_id=str(i),role='connection',position=None, + pitch=n.pitch if i==0 or i==len(notes)-1 or i in fixed_indices else types.Pitch('c',fractions.Fraction(),0)) for i,n in enumerate(notes)) + key=(canonical,fixed_indices,id(library),register) if key not in harmony.route_cache: - harmony.route_cache[key]=_candidates(notes,fixed,harmony,library,register) - return tuple(dataclasses.replace(r,relationship=relationship) for r in harmony.route_cache[key]) + harmony.route_cache[key]=_candidates(canonical,{str(i) for i in fixed_indices},harmony,library,register) + result=[] + for route in harmony.route_cache[key]: + actual=tuple(n if i==0 else dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==r.pitch.number() else r.pitch,position=None) + for i,(n,r) in enumerate(zip(notes,route.notes))) + identity=generator.solo.policy.stable_rank('complete-route',(actual,route.sources))[:16] + result.append(dataclasses.replace(route,id=identity,notes=actual,destination=notes[-1],relationship=relationship)) + selected=generator.solo.sequences.diverse(result, + lambda r:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(r.notes,r.notes[1:])),r.id), + lambda r:generator.solo.sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(r.notes,r.notes[1:]))), + generator.solo.policy.DEFAULT.join_alternatives) + if len(harmony.concrete_routes)>=8192:harmony.concrete_routes.pop(next(iter(harmony.concrete_routes))) + if len(harmony.route_cache)>=8192:harmony.route_cache.pop(next(iter(harmony.route_cache))) + harmony.concrete_routes[concrete_key]=selected + return selected def _candidates(notes, fixed, harmony, library, register): """Every returned candidate includes its entry, complete rhythm and destination. @@ -49,41 +72,51 @@ def _candidates(notes, fixed, harmony, library, register): span = harmony.at(first.start) if not span or not span.scale: return () + contexts=tuple((harmony.at(n.start).scale if harmony.at(n.start) else None, + types.permitted(harmony.choices,n.start,n.end), + n.pitch.number() if n.event_id in fixed or i==len(notes)-1 else None) for i,n in enumerate(notes)) + if not hasattr(harmony,'pitch_routes'):harmony.pitch_routes={} + key=(first.pitch,contexts,id(library),register) + if key not in harmony.pitch_routes: + harmony.pitch_routes[key]=pitch_paths(first.pitch,contexts,library,register) + if len(harmony.pitch_routes)>8192:harmony.pitch_routes.pop(next(iter(harmony.pitch_routes))) + route_spans=tuple((max(first.start,c.span.start),min(destination.end,c.span.end),c.scale.label()) + for c in harmony.choices if c.scale and c.span.startfirst.start) + result=[] + for pitches,sources in harmony.pitch_routes[key]: + realized=(first,*(dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==p.number() else p,position=None) + for n,p in zip(notes[1:],pitches[1:]))) + identity=generator.solo.policy.stable_rank('complete-route',(realized,sources))[:16] + result.append(types.RouteCandidate(identity,realized,destination,route_spans,'connect theme',sources)) + return tuple(result) + + +def pitch_paths(first, contexts, library, register): + """Time-independent pitch work; context includes every overlapped collection. + + Sounding durations and chord roles are still validated on each concrete + realization. Equal collection contexts can reuse paths across rhythmic edits. + """ + destination=contexts[-1][2] try: - displacement = types.scale_index(span.scale, destination.pitch.number()) - types.scale_index(span.scale, first.pitch.number()) - displacements = tuple([displacement]) + displacement=types.scale_index(contexts[0][0],destination)-types.scale_index(contexts[0][0],first.number()) + displacements=(displacement,) except ValueError: - # Enumerate possible degree routes when the destination changes collection; - # actual endpoint equality below decides compatibility, not a pitch estimate. - displacements = tuple(range(-7, 8)) - direction = (destination.pitch.number() > first.pitch.number()) - (destination.pitch.number() < first.pitch.number()) - paths = dict.fromkeys(path for d in displacements for path in library.retrieve(len(notes), d, direction)) - result = [] - low, high = generator.fretboard.single_register((register[0] - 7, register[1] + 5)) - allowed = {n.start: types.permitted(harmony.choices, n.start, n.end) for n in notes} + displacements=tuple(range(-7,8)) + direction=(destination>first.number())-(destination first.start) - identity = generator.solo.policy.stable_rank('complete-route', (tuple(realized), path.sources))[:16] - result.append(types.RouteCandidate(identity, tuple(realized), destination, route_spans, 'connect theme', path.sources)) - return generator.solo.sequences.diverse(result, - lambda r: (sum(abs(b.pitch.number() - a.pitch.number()) for a, b in zip(r.notes, r.notes[1:])), r.id), - lambda r: generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(r.notes, r.notes[1:]))), - generator.solo.policy.DEFAULT.join_alternatives) + realized=[first] + for (scale,allowed,fixed),step in zip(contexts[1:],path.steps): + if not scale:break + options=advance(scale,realized[-1].number(),step) + if not options:break + pitch=options[0];number=pitch.number() + if not low<=number<=high or number%12 not in allowed:break + if fixed is not None and number!=fixed:break + if not generator.solo.placement.positions(number):break + realized.append(pitch) + if len(realized)==len(contexts):result.append((tuple(realized),path.sources)) + return tuple(result) diff --git a/generator/solo/themes.py b/generator/solo/themes.py index 3883d0d..6a708ff 100644 --- a/generator/solo/themes.py +++ b/generator/solo/themes.py @@ -10,7 +10,7 @@ from fractions import Fraction as F import generator.model import generator.harmony -from generator.solo import types, policy, sequences, routes, references, similarity +from generator.solo import types, policy, sequences, routes, references, similarity, phrasing def source_notes(analysis): @@ -18,34 +18,6 @@ def source_notes(analysis): event_id=n.sources[0],position=None) for n in analysis.melody if n.end>analysis.body_offset) -def windows(analysis, measures, end, harmony): - """Existing phrase representation; source extraction seams do not close it.""" - bounds=[F()] - for measure in measures:bounds.append(bounds[-1]+generator.model.fraction(measure['duration'])) - melody=source_notes(analysis); result=[]; index=0 - while bounds[index]start) - result.append(types.SourcePhrase(f'theme_window_{start}_{finish}',start,finish,notes,(),'')) - index=stop - # Explicit missing harmony interrupts sounding material, not thematic memory. - divided=[] - for window in result: - cuts=sorted({window.start,window.end,*(max(window.start,s.start) for s in harmony.spans if not s.scale and s.startwindow.start),*(min(window.end,s.end) for s in harmony.spans if not s.scale and s.startwindow.start)}) - for start,finish in zip(cuts,cuts[1:]): - notes=tuple(dataclasses.replace(n,start=max(start,n.start),end=min(finish,n.end),continuation=n.startstart) - divided.append(dataclasses.replace(window,id=f'theme_window_{start}_{finish}',start=start,end=finish,notes=notes)) - return tuple(divided) - def silence(source,incoming,part,harmony,tempo,seed): from generator.solo import realization @@ -55,7 +27,8 @@ def silence(source,incoming,part,harmony,tempo,seed): draft=types.MelodicDraft(plan.id,source.start,source.end,(),(),(),(),(),incoming.home,'space',source_phrase=source,source_reference=incoming.source_reference) phrase=realization.checked_phrase(plan,draft,(),incoming.treatments,incoming,harmony,tempo) if phrase is None:return () - state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks) + state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks, + history=incoming.history,returns=incoming.returns,closures=incoming.closures,register_peak=incoming.register_peak) original=dataclasses.replace(draft,notes=source.notes) return (dataclasses.replace(phrase,state=state,initial=original,refined=draft,score=0.),) @@ -76,7 +49,8 @@ def short_fragment(source,incoming,part,harmony,tempo,seed,final): plan=types.PhrasePlan(draft.id,source.start,source.end,motif,'fragment','thematic_recomposition',None,(41,76),F(5,4),F(),ending='sustain' if final else 'connected',closure='closed' if final else 'open',passage_id=source.id,passage_start=source.start,passage_end=source.end) phrase=realization.checked_phrase(plan,draft,(note,),incoming.treatments,incoming,harmony,tempo) if phrase: - state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks) + state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks, + history=incoming.history,returns=incoming.returns,closures=incoming.closures,register_peak=incoming.register_peak) result.append(dataclasses.replace(phrase,state=state,initial=draft,refined=draft)) return tuple(result) @@ -107,7 +81,9 @@ def feature(reference, actual, *, treatment=None): required={'rhythmic_seed':('rhythm',),'contour_seed':('contour',), 'opening_fragment':('rhythm','pitches'),'characteristic_interval':('exact_intervals',), 'repeat_with_harmonic_adaptation':('rhythm','contour'),'answer':('contour',), - 'extend':('rhythm','contour'),'fragment':('rhythm',),'callback':('rhythm','pitches')}[treatment] + 'extend':('contour',),'fragment':('rhythm',),'callback':('rhythm','pitches'), + 'return':('rhythm','intervals'), 'develop_rhythm':('rhythm',), + 'develop_contour':('intervals',), 'continue':('contour',)}[treatment] original=reference.notes origin=original[0].start octave=12*round((actual[0].pitch.number()-original[0].pitch.number())/12) @@ -122,6 +98,17 @@ def rhythm_windows(library,meter,length,context): key=('theme-rhythm',id(library),meter,length) if key not in context.pools: patterns={} + bar=F(meter.split('/')[0])*4/int(meter.split('/')[1]) + if length>bar: + # The two-bar catalog contains quarter-note masks. Include combinations + # of the richer one-bar material as complete timing alternatives, rather + # than accidentally excluding eighths/dotted values by snippet length. + # Pitch paths and feature contracts span the assembled rhythm's seams. + split=bar + for left,left_id in rhythm_windows(library,meter,split,context): + for right,right_id in rhythm_windows(library,meter,length-split,context): + combined=(*left,*((a+split,b+split) for a,b in right)) + if len(combined)<=16:patterns.setdefault(combined,left_id+' + '+right_id) for identifier,pattern,total in drafts.catalog(library,meter): if total=len(ref.notes):values[(*head,(arrival,length-release))]=label+f'; arrival {hold}, release {release}' - return sequences.diverse(tuple(values.items()),lambda x:(max(0,len(x[0])-1.5*float(length),float(length)-len(x[0])),policy.stable_rank(ref.id,x[0])), - lambda x:(len(x[0]),x[0][-1][1]-x[0][-1][0],x[0][0][0]),4) + for i,(a,b) in enumerate(original): + if b-a==1: + values[(*original[:i],(a,a+F(1,2)),(a+F(1,2),b),*original[i+1:])]=label+'; diminution' + for i,((a,b),(c,d)) in enumerate(zip(original,original[1:])): + if b!=c:continue + for split in (a+F(1,2),a+F(3,2),d-F(1,2)): + if a=len(ref.notes): + arrivals[(*head,(arrival,ending.hold_until-start))]=label+'; planned sustained close' + values=arrivals + return sequences.diverse(tuple(values.items()),lambda x:(len(x[0])>16,policy.stable_rank(ref.id,x[0])), + lambda x:(len(x[0]),tuple(b-a for a,b in x[0][:3]),x[0][0][0]),policy.DEFAULT.theme_rhythm_alternatives) def pitches(harmony,note,base,*,members=False,limit=6): @@ -196,6 +197,14 @@ def instantiate(templates,first,steps,harmony): def prefix_paths(templates,ref,first,harmony,library): + if not hasattr(harmony,'theme_prefixes'):harmony.theme_prefixes={} + key=(templates[:len(ref.notes)],ref,first,id(library)) + if key not in harmony.theme_prefixes: + harmony.theme_prefixes[key]=_prefix_paths(templates,ref,first,harmony,library) + return harmony.theme_prefixes[key] + + +def _prefix_paths(templates,ref,first,harmony,library): count=len(ref.notes); original=ref.notes if count==1:return ((dataclasses.replace(templates[0],pitch=first),),) if ref.treatment in ('opening_fragment','characteristic_interval','callback'): @@ -232,64 +241,91 @@ class Unit: routes: tuple[types.RouteCandidate,...] = () -def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0): - # Position and the recent realization participate even when two paths share a last pitch. +def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None): + # History is evaluated by the caller after retrieving these concrete options. + # Every state component used to construct this pool is in its cache key. if not hasattr(harmony,'fingerprint'):harmony.fingerprint=policy.stable_rank('harmony',harmony.choices) - key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,home,operation,terminal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT) + key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,home,operation,ending,goal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT) if key in context.results: context.diagnostics['cache_hits']+=1;return context.results[key] result=[]; count=len(ref.notes) - for pattern,label in patterns(ref,start,finish,rhythms,meter,context,home=home,operation=operation,terminal=terminal): - if len(pattern)16:continue templates=tuple(types.Note(start+a,start+b,ref.notes[0].pitch,tuple(s for n in ref.notes for s in n.sources), 'theme',event_id=f'{ref.id}:{start}:{i}') for i,(a,b) in enumerate(pattern)) base=incoming.last.pitch.number() if incoming.last else ref.notes[0].pitch.number() firsts=pitches(harmony,templates[0],base,limit=6 if batch else 3) + targets=(ending.pitch,) if ending else pitches(harmony,templates[-1],goal if goal is not None else base,members=True,limit=policy.DEFAULT.theme_destinations) for first in firsts: + for target in targets: + # Rank joint jobs, retaining rhythm and destination coverage before + # expensive path instantiation. The budget is spent on complete routes. + rank=max(0,abs(first.number()-base)-5)/12+abs(target.number()-(goal if goal is not None else base))/48 + jobs.append((rank,templates,label,first,target)) + jobs.sort(key=lambda j:(j[0],policy.stable_rank(ref.id,(j[1],j[3],j[4])))) + selected=[];rhythm_seen=set();target_seen=set();entry_seen=set() + for job in jobs: + rhythm=tuple((n.start,n.end) for n in job[1]);target=job[4].number();entry=job[3].number() + if rhythm not in rhythm_seen or target not in target_seen or entry not in entry_seen: + selected.append(job);rhythm_seen.add(rhythm);target_seen.add(target);entry_seen.add(entry) + job_limit=policy.DEFAULT.theme_route_jobs*(batch+1) + for job in jobs: + if len(selected)>=job_limit:break + if job not in selected:selected.append(job) + for _,templates,label,first,target in selected[:job_limit]: proposed=[] - if home and operation=='repeat_with_harmonic_adaptation' and len(templates)==len(home.notes): + if home and operation in ('repeat_with_harmonic_adaptation','return') and len(templates)==len(home.notes): heard=instantiate(templates,first,home.degree_steps,harmony) - if heard:proposed.append((heard,())) - for prefix in prefix_paths(templates,ref,first,harmony,library): + if heard and heard[-1].pitch==target:proposed.append((heard,())) + for prefix in prefix_paths(templates,ref,first,harmony,library)[:2+batch]: if len(prefix)==len(templates): - proposed.append((prefix,()));continue - base=prefix[-1].pitch.number() - for target in pitches(harmony,templates[-1],base,members=True,limit=4 if batch else 2): - tail=(prefix[-1],*templates[count:]) - tail=(*tail[:-1],dataclasses.replace(tail[-1],pitch=target)) - for route in routes.candidates(tail,{tail[-1].event_id},harmony,library,(41,76),operation+' '+ref.id): - proposed.append(((*prefix[:-1],*route.notes),(route,))) + if prefix[-1].pitch==target:proposed.append((prefix,())) + continue + tail=(prefix[-1],*templates[count:]) + tail=(*tail[:-1],dataclasses.replace(tail[-1],pitch=target)) + for route in routes.candidates(tail,{tail[-1].event_id},harmony,library,(41,76),operation+' '+ref.id)[:3+batch]: + proposed.append(((*prefix[:-1],*route.notes),(route,))) + seen=set() for actual,connections in proposed: + identity=tuple(n.pitch.number() for n in actual) + if identity in seen:continue + seen.add(identity) core=actual[:count]; contract=feature(ref,core,treatment=operation if operation!='introduce' else None) if references.feature_error(contract,actual):continue last=actual[-1] if last.pitch.number()%12 not in harmony.members(last.start,last.end):continue checked=harmony.evaluate(actual,until=finish,complete=True) if not checked.valid:continue - density=len(actual)*4/float(finish-start) entry=max(0,abs(first.number()-incoming.last.pitch.number())-5)/12 if incoming.last else 0. leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(actual,actual[1:]))/max(1,len(actual)-1) - # A repeat preserves the full heard idea. Answers and extensions - # preserve their declared fragment, not every event of the old ending. - development=references.relationship(actual,home) if home and operation=='repeat_with_harmonic_adaptation' else 0. - score=2*checked.exposure+entry+leaps+development+.35*max(0,4-density,density-6) - anchor=types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),(41,76), - last.end-last.start,'terminal' if terminal else 'intermediate',harmony.at(last.start).chord,hold_until=last.end) + direction=min(1,abs(last.pitch.number()-goal)/12) if goal is not None else 0. + # Activity belongs to the complete phrase; contracts are pass/fail. + score=2*checked.exposure+entry+leaps+.35*direction + anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),(41,76), + last.end-last.start,'intermediate',harmony.at(last.start).chord,hold_until=last.end) result.append(Unit(actual,contract,ref,operation,anchor,score,label,connections)) unique={tuple((n.start,n.end,n.pitch) for n in u.notes):u for u in result} selected=sequences.diverse(tuple(unique.values()),lambda u:(u.score,policy.stable_rank(ref.id,u.notes)), - lambda u:(u.notes[0].pitch.number()//12,u.notes[-1].pitch.number()%12,tuple(n.end-n.start for n in u.notes[:count])),8) + lambda u:(u.notes[0].pitch.number()//12,u.notes[-1].pitch.number(),tuple(n.end-n.start for n in u.notes[:count])),policy.DEFAULT.theme_unit_alternatives) context.diagnostics['generated_candidates']+=len(selected) context.results[key]=selected return selected def home_reference(home,operation): - notes=home.notes[:min(4,len(home.notes))] - # A short characteristic beginning leaves room for a newly composed answer. - while len(notes)>3 and notes[-1].end>home.duration/2:notes=notes[:-1] - return types.SourceReference(home.id,home.parent,home.source_start,home.source_start+home.duration,notes, - 'rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','extend','fragment') else 'contour_seed') + # Prefer a distinguishing turn/leap wherever it actually occurred. The heard + # home remains immutable; a development never becomes the new source theme. + groups=[home.notes[i:i+3] for i in range(max(1,len(home.notes)-2))] + def rank(group): + intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:])] + turn=len(intervals)>1 and intervals[0]*intervals[1]<0 + return (-int(turn),-max((abs(x) for x in intervals),default=0),group[0].start) + notes=min(groups,key=rank) + origin=notes[0].start + notes=tuple(dataclasses.replace(n,start=n.start-origin,end=n.end-origin) for n in notes) + treatment='rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','fragment','return','develop_rhythm') else 'contour_seed' + return types.SourceReference(home.id,home.parent,home.source_start,home.source_start+home.duration,notes,treatment) def make_home(unit,harmony,start=None,end=None): @@ -300,96 +336,135 @@ def make_home(unit,harmony,start=None,end=None): return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony)) -def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False): +def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False,solo_end=None): from generator.solo import leads,realization if source.end-source.start<2: return short_fragment(source,incoming,part,harmony,tempo,seed,final) bar=F(meter.split('/')[0])*4/int(meter.split('/')[1]) unit_length=2*bar + solo_end=solo_end or max(c.span.end for c in harmony.choices) head=source_notes(analysis) seed_key=('source-seeds',analysis,bar,seed) if seed_key not in context.pools:context.pools[seed_key]=seeds(analysis,bar,seed) roots=context.pools[seed_key] - states=[(0.,(),incoming.home,incoming)] + ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=8*(batch+1)) + states=[(0.,(),incoming.home,incoming,ending) for ending in ending_options] cursor=source.start - while cursorsource.start else ['develop_contour','develop_rhythm'] + if cursor==source.start:operations.append('return') requests=[(home_reference(home,op),op) for op in operations] - used=incoming.callbacks+sum(u.operation=='callback' for u in units) - if cursor>=8*bar and cursor==source.start and used<2: - requests.extend((dataclasses.replace(r,treatment='callback'),'callback') for r in roots[:2] if r.end-r.start<=bar) + # A uniform duration alone is not identifying musical evidence. + requests=[(r,op) for r,op in requests if op!='develop_rhythm' or len({n.end-n.start for n in r.notes})>1 or any(a.end!=b.start for a,b in zip(r.notes,r.notes[1:]))] + if cursor>=8*bar and cursor==source.start and incoming.callbacks<2: + requests.extend((dataclasses.replace(r,treatment='callback'),'callback') for r in roots[:1] if r.end-r.start<=bar) for ref,operation in requests: + if introducing and source.end-source.start<2*bar and ending.earliest-cursorfinish-cursor: reduced=tuple(n for n in ref.notes if n.end<=finish-cursor) if len(reduced)<2:continue ref=dataclasses.replace(ref,notes=reduced,treatment='contour_seed') - operation='answer' + operation='continue' + terminal=finish==source.end + base=state.last.pitch.number() if state.last else ref.notes[0].pitch.number() + goal=ending.pitch.number() if terminal else round(base+(ending.pitch.number()-base)*float((finish-cursor)/(source.end-cursor))) options=unit_candidates(ref,cursor,finish,state,harmony,library,rhythms,meter,context,home=home,operation=operation, - terminal=final and finish==source.end,batch=batch) + terminal=terminal,batch=batch,ending=ending if terminal else None,goal=goal) for unit in options: if introducing and similarity.near_copy(unit.notes,head,cursor,finish)['near_copy']: context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue actual_home=home or make_home(unit,harmony,cursor,finish) - following=dataclasses.replace(state,home=actual_home,last=unit.notes[-1],notes=unit.notes,source_reference=state.source_reference or ref) - next_states.append((cost+unit.score,(*units,unit),actual_home,following)) - states=sequences.diverse(next_states,lambda v:(v[0],policy.stable_rank(seed,tuple(u.notes for u in v[1]))), - lambda v:(v[2].id,v[1][-1].notes[-1].pitch.number()),2 if incoming.home and finishcursor:activity.append(types.ActivitySpan(cursor,n.start,'space',F(),F())) - activity.append(types.ActivitySpan(n.start,n.end,'active',F(5,4),F())) - cursor=n.end - if cursor=8 for a,b in pattern) for pattern,_ in values)) + for pattern,_ in values: + self.assertTrue(all(0<=a=5 for n in actual)) + self.assertTrue(harmony.evaluate(actual,complete=True).valid) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_solo_themes.py b/tests/test_solo_themes.py index c8b23a6..2e6f904 100644 --- a/tests/test_solo_themes.py +++ b/tests/test_solo_themes.py @@ -93,10 +93,10 @@ class ThemeProduction(unittest.TestCase): for phrase in self.solo.phrases: self.assertTrue(drafts.contract_valid(phrase.draft,composer.phrase_notes(phrase))[0]) self.assertFalse(similarity.near_copy(phrase.refined.notes,themes.source_notes(self.analysis),phrase.plan.start,phrase.plan.end)['near_copy']) - self.assertEqual(self.solo.phrases[0].plan.closure,'open') - self.assertTrue(all(a.role=='intermediate' for a in self.solo.phrases[0].anchors)) + self.assertEqual(self.solo.phrases[0].plan.closure,'closed') + self.assertTrue(all(a.duration>=2 for a in self.solo.phrases[0].anchors)) self.assertEqual(self.solo.phrases[-1].anchors[-1].role,'terminal') - self.assertTrue(any(t.operation=='repeat_with_harmonic_adaptation' for p in self.solo.phrases for t in p.draft.transformations)) + self.assertTrue(any(t.operation in ('develop_contour','develop_rhythm','continue','return') for p in self.solo.phrases for t in p.draft.transformations)) def test_library_routes_match_the_actual_complete_theme(self): self.assertTrue(any(p.routes for p in self.solo.phrases)) @@ -108,6 +108,17 @@ class ThemeProduction(unittest.TestCase): self.assertEqual((note.start,note.end,note.pitch),(actual.start,actual.end,actual.pitch)) self.assertEqual(route.notes[-1].pitch,route.destination.pitch) + def test_report_can_compare_a_source_hold_cut_by_new_phrase_boundaries(self): + from generator.solo import report + phrases=[] + for i,p in enumerate(self.solo.phrases): + held=notes_fixture.note(p.plan.start,p.plan.end,'c',event_id='source_hold',continuation=i>0) + phrases.append(dataclasses.replace(p,initial=dataclasses.replace(p.initial,notes=(held,)))) + solo=dataclasses.replace(self.solo,phrases=tuple(phrases)) + detail=report.structure(solo) + self.assertEqual(len(detail['phrases']),len(phrases)) + self.assertTrue(phrases[-1].initial.notes[0].continuation) + def test_incoming_pitch_changes_concrete_continuation_pool(self): harmony=harmonic.Harmony(self.solo.choices) home=self.solo.phrases[0].state.home