This commit is contained in:
khodowany
2026-10-04 18:48:02 -05:00
parent 68df7292ee
commit 0e68f5364b
58 changed files with 1416 additions and 7567 deletions
+40 -129
View File
@@ -1,136 +1,47 @@
from __future__ import annotations
import dataclasses
import fractions
"""The single production entry point for independent phrase composition."""
from fractions import Fraction as F
import time
import dataclasses
from generator import harmony as chords, vocabulary
from generator.scales import timeline, selection
from generator.solo import types, profiles, harmonic, construction, placement, policy
import generator.solo.enclosures
import generator.solo.profiles
import generator.fretboard
import generator.harmony
import generator.model
import generator.scales.selection
import generator.scales.timeline
import generator.solo.harmonic
import generator.solo.analysis
import generator.solo.planning
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', mode='thematic', enclosures_enabled=True):
if references or context is not None and getattr(context,'references',()):
raise ValueError('Audition references are not production composition inputs')
started = time.monotonic()
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')
from generator.solo import leads
context = context if context is not None else generator.solo.realization.SearchContext()
context.expressions_enabled = enclosures_enabled
options = leads.solo_options(analysis, measures, part, end, choices, sequences, rhythms, context, stage, mode)
result = options[0][1]
result.diagnostics['strict_composition_seconds'] = time.monotonic()-started
from generator.solo import expressions
if mode!='thematic':result = expressions.refine(result,False)
result.diagnostics['enclosures_enabled']=enclosures_enabled
result.diagnostics['expressions_selected']=[e.kind for p in result.phrases for e in p.draft.expressions]
def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,expressions_enabled=True):
began=time.monotonic()
choices=selection.select(timeline.read({'measures':measures},initial=initial),timeline.Solo(F(),end,part.capitalize()+' Solo'),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}, beat {c.span.beat}, {chords.symbol(c.span.chord)}: No exact match within the permitted catalog')
profile=profiles.for_part(part,analysis);harmony=harmonic.Harmony(choices,profile)
source=tuple((n.start,n.end,n.pitch) for n in analysis.melody)
seed=vocabulary.role().get('choice_seed') or policy.stable_rank(part,(source,tuple((c.span.start,c.span.end,c.span.chord) for c in choices),vocabulary.role(part+'_solo'),policy.DEFAULT.composition_version))
builder=construction.Builder(analysis,measures,harmony,sequences,rhythms,end,seed,expressions_enabled)
phrases=builder.compose()
# Resolve hand-position transitions over the selected whole melody. This
# cannot reselect pitches, rhythm or another independently generated part.
raw=tuple(n for p in phrases for n in p.notes)
placed=placement.place(raw,analysis.tempo,profile=profile) if raw else (0,())
if placed is None:raise construction.SearchExhausted('selected melody has no complete legal guitar path')
by_id={n.event_id:n for n in placed[1]}
phrases=tuple(dataclasses.replace(p,notes=tuple(by_id[n.event_id] for n in p.notes)) for p in phrases)
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'complete_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()},
'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile)
validate(result)
return result
def compose_pair(analysis, requests, *, scales, sequences, rhythms, contexts=None, stage='elaborated', mode='thematic', enclosures_enabled=True):
"""Compatibility batch helper: each winner is selected independently."""
return tuple(compose(analysis, measures, part, end, scales=scales, sequences=sequences,
rhythms=rhythms, initial=initial, context=(contexts or {}).get(part), stage=stage,
mode=mode, enclosures_enabled=enclosures_enabled)
for part, (measures, end, initial) in requests.items())
def phrase_notes(phrase):
notes = tuple(n for d in phrase.decisions for n in d.notes)
if notes and notes[0].continuation:
notes = (dataclasses.replace(notes[0], continuation=False), *notes[1:])
return generator.solo.types.sounding_notes(notes)
def transition_cost(first, second):
before, after = phrase_notes(first), phrase_notes(second)
if not before or not after:
return 0.0
gap = max(0, after[0].start - before[-1].end - first.plan.release)
jump = abs(after[0].pitch.number() - before[-1].pitch.number())
# The next phrase's local score already includes physical movement. Here we
# evaluate pacing and conversational contrast at the join, once per pair.
before_shape = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(before, before[1:]))
after_shape = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(after, after[1:]))
repeated_answer = second.plan.relationship == 'answer' and before_shape == after_shape
return min(1, float(gap) / 2) + 0.5 * min(1, max(0, jump - 5) / 12) + 0.5 * repeated_answer
def choose_phrases(plans, realize, revise_pair=None):
"""Commit only after the next phrase has been evaluated from each ending."""
result, previous, pending = [], None, None
for index, plan in enumerate(plans):
options = realize(plan, previous)
if pending is not None and pending not in options:
options = (pending, *options)
ranked, failure = [], None
for option in options:
notes = phrase_notes(option)
last = notes[-1] if notes else previous
incoming = transition_cost(result[-1], option) if result else 0
if index + 1 == len(plans):
ranked.append((option.score + incoming, option, None))
continue
try:
following = realize(plans[index + 1], option.state or last)
except generator.solo.realization.SearchExhausted as error:
failure = error
continue
length = float(option.plan.end - option.plan.start)
next_length = float(plans[index + 1].end - plans[index + 1].start)
shared_passage = bool(plan.passage_id and plan.passage_id == plans[index + 1].passage_id)
for future in following:
left, right = revise_pair(option, future, previous) if revise_pair else (option, future)
cost = (right.score if shared_passage else (left.score * length + right.score * next_length) / (length + next_length)) + transition_cost(left, right)
ranked.append((cost + incoming, left, right))
if not ranked:
raise failure or generator.solo.realization.SearchExhausted(f'{plan.id}: no feasible phrase transition')
_, chosen, pending = min(ranked, key=lambda item: (item[0], tuple((n.pitch, n.start, n.end, n.position) for n in phrase_notes(item[1]))))
result.append(chosen)
notes = phrase_notes(chosen)
if chosen.state:
previous = chosen.state
elif notes:
previous = notes[-1]
return tuple(result)
def validate(solo):
previous = None
profile = solo.profile or generator.solo.profiles.for_part(solo.part)
previous=None
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}')
if note.position not in generator.solo.placement.positions(note.pitch.number(), profile):
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
previous = note
for phrase in solo.phrases:
notes = phrase_notes(phrase)
if not generator.solo.realization.target_ok(notes, phrase.plan.target):
raise ValueError(f'{phrase.plan.id}: planned harmonic arrival was not realized')
harmony = generator.solo.harmonic.Harmony(solo.choices)
contracts = tuple(dict.fromkeys(t for phrase in solo.phrases if phrase.state for t in phrase.state.treatments))
enclosures = tuple(e for p in solo.phrases if p.draft for e in p.draft.enclosures)
expressions = tuple(e for p in solo.phrases if p.draft for e in p.draft.expressions)
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True,enclosures=enclosures,expressions=expressions)
if not checked.valid:
raise ValueError(f'{solo.part} solo: {checked.error}')
for phrase in solo.phrases:
if phrase.state:
complete = phrase.plan.end == (phrase.plan.passage_end or phrase.plan.end)
valid, error = generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=complete, closure=phrase.plan.closure)
if not valid:
raise ValueError(error)
if note.start<0 or note.end>solo.end or note.end<=note.start or previous and previous.end>note.start:raise ValueError('overlapping/out-of-bounds sounding event')
if note.position not in placement.positions(note.pitch.number(),solo.profile):raise ValueError('invalid guitar placement')
previous=note
expressions=tuple(c for p in solo.phrases for c in p.expressions)
checked=harmonic.Harmony(solo.choices).evaluate(solo.notes(),until=solo.end,complete=True,expressions=expressions)
if not checked.valid:raise ValueError(checked.error)
cursor=F()
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')
if not valid:raise ValueError(error)
if cursor!=solo.end:raise ValueError('incomplete solo duration')