Cleanup
This commit is contained in:
+40
-129
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user