This commit is contained in:
khodowany
2026-10-02 00:35:31 -05:00
parent f2f371b951
commit a0bd19b3ba
25 changed files with 1421 additions and 586 deletions
+35 -31
View File
@@ -15,45 +15,49 @@ import generator.solo.policy
import generator.solo.realization
import generator.solo.types
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated'):
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated', mode='thematic'):
timeline = generator.scales.timeline.read({'measures': measures}, initial=initial)
region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo')
choices = generator.scales.selection.select(timeline, region, scales)
for choice in choices:
if choice.span.chord and choice.scale is None:
raise ValueError(f'{part} solo, source measure {choice.span.source_measure}, beat {choice.span.beat}, {generator.harmony.symbol(choice.span.chord)}: No exact match within the permitted catalog')
plans = generator.solo.planning.plans(analysis, measures, choices, end, part, sequences, lead=bool(references))
retained = tuple((plan.start + n.start, plan.start + n.end, {n.pitch.number() % 12}) for plan in plans if plan.treatment in ('quote', 'exact_quote') for n in plan.motif.notes)
if retained and not all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
choices = generator.scales.selection.select(timeline, region, scales, retained=retained)
if not all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
raise ValueError(f'{part} solo: protected quotation has no exact compatible scale collection')
choices = tuple(dataclasses.replace(c, reason=c.reason + '; retained quotation') if any(a < c.span.end and b > c.span.start for a, b, _ in retained) else c for c in choices)
from generator.solo import leads
context = context if context is not None else generator.solo.realization.SearchContext()
context.revise = revise
context.references = references
context.stage = stage
def realize(plan, previous):
for attempt in range(generator.solo.policy.DEFAULT.replan_attempts + 1):
context.batch = attempt
try:
options = generator.solo.realization.alternatives(plan, measures, choices, sequences, rhythms, analysis.tempo, previous, context)
return options
except generator.solo.realization.SearchExhausted as error:
if attempt == generator.solo.policy.DEFAULT.replan_attempts or plan.treatment in ('quote', 'exact_quote', 'lead'):
raise
context.diagnostics.setdefault('replanning', []).append({'phrase': plan.id, 'attempt': attempt + 1, 'reason': str(error), 'adjustment': 'next diverse complete rhythm and anchor constructions; contracts unchanged'})
phrases = choose_phrases(plans, realize, lambda first, second, incoming: generator.solo.realization.revise_pair(first, second, incoming, choices, sequences, analysis.tempo, context))
context.diagnostics['policy'] = generator.solo.policy.describe()['fingerprint']
context.diagnostics['musical_identity'] = generator.solo.analysis.identity(analysis, measures, part)
passage_ids = tuple(dict.fromkeys(p.passage_id for p in plans if p.passage_id))
passages = tuple(generator.solo.types.MelodicPassagePlan(identifier, min(p.start for p in plans if p.passage_id == identifier), max(p.end for p in plans if p.passage_id == identifier), tuple(p.id for p in plans if p.passage_id == identifier), next(p.seed for p in plans if p.passage_id == identifier)) for identifier in passage_ids)
result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end, context.diagnostics, passages)
options = leads.solo_options(analysis, measures, part, end, choices, sequences, rhythms, context, stage, mode)
result = options[0][1]
validate(result)
return result
def compose_pair(analysis, requests, *, scales, sequences, rhythms, contexts=None, stage='elaborated', mode='thematic'):
"""Select two recognizable interpretations together, before arranging backing."""
from generator.solo import leads
pools = {}
for part, (measures,end,initial) in requests.items():
timeline = generator.scales.timeline.read({'measures': measures},initial=initial)
choices = generator.scales.selection.select(timeline,generator.scales.timeline.Solo(0,end,part),scales)
for c in choices:
if c.span.chord and c.scale is None:
raise ValueError(f'{part} solo, source measure {c.span.source_measure}: No exact match within the permitted catalog')
context = (contexts or {}).get(part) or generator.solo.realization.SearchContext()
pools[part] = leads.solo_options(analysis,measures,part,end,choices,sequences,rhythms,context,stage,mode)
def theme_key(solo):
home=next((p.state.home for p in solo.phrases if p.state and p.state.home),None)
if not home or not home.notes:return ()
return tuple((n.start-home.notes[0].start,n.end-n.start,n.pitch.number()-home.notes[0].pitch.number()) for n in home.notes)
pairs = [(a_cost+e_cost+2*leads.similarity(a,e),a,e) for a_cost,a in pools['acoustic'] for e_cost,e in pools['electric']
if mode!='thematic' or not theme_key(a) or theme_key(a)!=theme_key(e)]
if not pairs:
raise generator.solo.realization.SearchExhausted('Distinct acoustic/electric themes not found within the bounded pair search')
_,acoustic,electric = min(pairs,key=lambda v:(v[0],generator.solo.policy.stable_rank('solo-pair',(v[1].notes(),v[2].notes()))))
overlap = leads.similarity(acoustic,electric)
for solo in (acoustic,electric):
solo.diagnostics.update(pair_similarity=overlap,pairs_compared=len(pairs),solo_alternatives=len(pools[solo.part]))
validate(solo)
return acoustic,electric
def phrase_notes(phrase):
notes = tuple(n for d in phrase.decisions for n in d.notes)
if notes and notes[0].continuation:
@@ -116,8 +120,8 @@ def validate(solo):
for note in solo.notes():
if note.start < 0 or note.end > solo.end or note.end <= note.start or previous and previous.end > note.start:
raise ValueError(f'{solo.part} solo: overlapping or out-of-bounds note at {note.start}')
# Harmony.evaluate below owns scale permission and the narrow, verified
# chromatic exception; no separate permissive final-validation path.
if note.pitch.number() % 12 not in generator.solo.types.permitted(solo.choices, note.start, note.end):
raise ValueError(f'{solo.part} solo: note outside mapped collection at {note.start}')
if note.position not in generator.fretboard.single_positions_for_pitch(fractions.Fraction(note.pitch.number())):
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
previous = note