Files
product-vamp/generator/solo/realization.py
T
khodowany 04c6ea2e00 Soloing
2026-10-03 12:44:53 -05:00

930 lines
60 KiB
Python

from __future__ import annotations
import dataclasses
import fractions
import functools
import time
import generator.solo.drafts
import generator.solo.references
import generator.solo.routes
import generator.fretboard
import generator.model
import generator.rhythm
import generator.scales.selection
import generator.solo.harmonic
import generator.solo.motifs
import generator.solo.placement
import generator.solo.planning
import generator.solo.scoring
import generator.solo.sequences
import generator.solo.policy
import generator.solo.types
BEAM_WIDTH = generator.solo.policy.DEFAULT.beam_width
CELL_LIMIT = generator.solo.policy.DEFAULT.extensions
class SearchExhausted(ValueError):
pass
@dataclasses.dataclass(frozen=True)
class Cell:
start: fractions.Fraction
end: fractions.Fraction
patterns: tuple[tuple[str, tuple[tuple[fractions.Fraction, fractions.Fraction], ...]], ...]
motif: generator.solo.types.Motif | None = None
gesture: generator.solo.types.Gesture | None = None
@dataclasses.dataclass(frozen=True)
class Candidate:
notes: tuple[generator.solo.types.Note, ...]
source: str
treatment: str
rank: tuple
fidelity: float = 0
treatments: tuple[generator.solo.types.HarmonicTreatment, ...] = ()
def rhythm_patterns(library: generator.rhythm.Library, meter: str, length: fractions.Fraction, activity: fractions.Fraction, offset=fractions.Fraction()):
result = {}
for snippet in library.for_meter(meter):
for index, bar in enumerate(snippet.bars):
spans = []
for event in bar.events:
if not event.sounding:
continue
if event.continuation:
if spans and spans[-1][1] == event.onset:
spans[-1] = (spans[-1][0], event.onset + event.duration)
else:
spans.append((event.onset, event.onset + event.duration))
# Slice exact authored rhythm; later attacks outside the window do not
# disqualify a useful prefix. A tied continuation never becomes an attack.
pattern = tuple((start - offset, min(end - offset, length)) for start, end in spans if offset <= start < offset + length)
result.setdefault(pattern, f'rhythm {snippet.id}:{index + 1}@{offset}+{length}')
def rank(item):
pattern, identifier = item
return abs(len(pattern) - length * activity), abs(sum(end - start for start, end in pattern) - length * fractions.Fraction(3, 4)), identifier
def family(item):
pattern = item[0]
return len(pattern), bool(pattern and pattern[0][0]), any(a.denominator > 1 for a, _ in pattern)
selected = generator.solo.sequences.diverse(result.items(), rank, family, 12)
return tuple((identifier, pattern) for pattern, identifier in selected)
def cells(plan, measures, rhythms, choices=()):
result, boundaries = [], [fractions.Fraction()]
for measure in measures:
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
start, music_end = plan.start, plan.end - plan.release
protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference')
if protected:
motif = plan.motif
if not motif.notes or motif.duration > music_end - start:
raise SearchExhausted(f'{plan.id}: protected motif rhythm does not fit the phrase')
spans = tuple((n.start, n.end) for n in motif.notes)
result.append(Cell(start, start + motif.duration, ((motif.id, spans),), motif))
start += motif.duration
while start < music_end:
index = next(i for i in range(len(measures)) if boundaries[i] <= start < boundaries[i + 1])
passage = next((p for p in plan.passages if p.start <= start < p.end), None)
gesture = next((g for g in plan.gestures if g.start <= start < g.end), None)
chord_end = next((c.span.end for c in choices if c.span.start <= start < c.span.end), music_end)
end = min(music_end, boundaries[index + 1], passage.end if passage else music_end, gesture.end if gesture else music_end, chord_end)
activity = passage.activity if passage else plan.activity
patterns = rhythm_patterns(rhythms, measures[index]['time_signature'], end - start, activity, start - boundaries[index])
patterns += (('hold', ((fractions.Fraction(), end - start),)), ('space', ()))
if plan.target and start <= plan.target.latest and plan.target.earliest < end:
onset = max(start, plan.target.earliest) - start
patterns = (('arrival_and_release', ((onset, end - start),)), *patterns)
motif = plan.motif if not result else None
result.append(Cell(start, end, patterns, motif, gesture))
start = end
if plan.release:
result.append(Cell(music_end, plan.end, (('planned_release', ()),)))
return tuple(result)
@functools.lru_cache(maxsize=2048)
def scale_options(scale, allowed, low, high):
return tuple(generator.solo.types.scale_pitch(scale, index) for index in range(-21, 28) if low <= generator.solo.types.scale_pitch(scale, index).number() <= high and generator.solo.types.scale_pitch(scale, index).number() % 12 in allowed and generator.fretboard.single_positions_for_pitch(fractions.Fraction(generator.solo.types.scale_pitch(scale, index).number())))
def valid_notes(notes, choices):
return all(n.pitch.number() % 12 in generator.solo.types.permitted(choices, n.start, n.end) for n in notes)
def options(choices, start, end, register):
scale = next((c.scale for c in choices if c.span.start <= start < c.span.end), None)
allowed = generator.solo.types.permitted(choices, start, end)
return scale_options(scale, allowed, *generator.fretboard.single_register((register[0] - 7, register[1] + 5))) if scale and allowed else ()
def target_ok(notes, target):
return target is None or any(target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches for n in notes)
def adapt_path(pitches, spans, choices, register):
"""Connect actual pitches across collections without carrying a stale degree index."""
states = [(0, ())]
for index, ((start, end), intended) in enumerate(zip(spans, pitches)):
available = options(choices, start, end, register)
following = []
for cost, path in states:
desired = path[-1].pitch.number() + intended.number() - pitches[index - 1].number() if path else intended.number()
for pitch in sorted(available, key=lambda p: (abs(p.number() - desired), p))[:2]:
following.append((cost + abs(pitch.number() - desired), (*path, generator.solo.types.Note(start, end, pitch, role='adapted_path'))))
states = sorted(following, key=lambda item: (item[0], tuple(n.pitch for n in item[1])))[:8]
return [notes for _, notes in states]
def anchor_treatments(anchors, harmony):
result = []
for anchor in anchors:
span = harmony.at(anchor.earliest)
if span and anchor.pitch.number() % 12 in span.roles.specified:
result.append(generator.solo.types.HarmonicTreatment(anchor.id + '_color', 'color', anchor.pitch.number(), anchor.earliest, anchor.hold_until or anchor.latest + anchor.duration))
return tuple(result)
def rhythm_variants(cell, plan, state, anchors, reference):
patterns = cell.patterns
gesture = cell.gesture
if cell.motif and plan.treatment in ('quote', 'exact_quote'):
return [(name, tuple((cell.start + a, cell.start + b) for a, b in pattern), {}, False) for name, pattern in patterns]
if gesture and gesture.treatment == 'rhythmic_reference' and state.motifs:
# The identity is the rhythm actually heard, including its rests. Cropping
# at a search boundary does not create a new rhythmic attack.
pattern = tuple((max(cell.start, gesture.start + n.start) - cell.start, min(cell.end, gesture.start + n.end) - cell.start) for n in reference.notes if gesture.start + n.start < cell.end and gesture.start + n.end > cell.start)
patterns = ((reference.id + ':rhythm', pattern),)
due = [a for a in anchors if cell.start <= a.latest and a.earliest < cell.end and not generator.solo.harmonic.anchor_note(a, state.notes)]
holding = next((a for a in anchors if state.last and state.last.role != 'quote' and a.hold_until and a.hold_until > cell.start and generator.solo.harmonic.anchor_note(a, state.notes, until=cell.start)), None)
if holding:
stop = min(cell.end, holding.hold_until)
return [('anchor_sustain', ((cell.start, stop),), {cell.start: holding.pitch}, True)]
result = []
for name, pattern in patterns:
spans = tuple((cell.start + a, cell.start + b) for a, b in pattern)
variants = [(spans, {})]
for anchor in due:
following = []
for current, forced in variants:
onsets = [a for a, b in current if anchor.earliest <= a <= anchor.latest and b - a >= min(anchor.duration, cell.end - a)]
if gesture and gesture.treatment == 'rhythmic_reference':
times = onsets[:2]
else:
times = list(dict.fromkeys(onsets[:2] + [max(cell.start, anchor.earliest)]))
for time in times:
stop = min(cell.end, anchor.hold_until or time + anchor.duration)
original_end = next((b for a, b in current if a == time), stop)
stop = max(stop, original_end)
if gesture and gesture.treatment == 'rhythmic_reference' and stop != original_end:
continue
overlaid = [(a, min(b, time)) for a, b in current if a < time]
overlaid += [(time, stop)] + [(a, b) for a, b in current if a >= stop]
following.append((tuple((a, b) for a, b in overlaid if b > a), {**forced, time: anchor.pitch}))
variants = following
result.extend((name, spans, forced, False) for spans, forced in variants)
return result
def realize_route(spans, first, steps, forced, choices, plan, harmony, contracts, reference):
"""Instantiate relative motion at actual pitches, adapting only at collection changes."""
path = []
for index, (start, end) in enumerate(spans):
available = options(choices, start, end, plan.register)
if not available:
return None
if start in forced:
available = tuple(p for p in available if p.number() == forced[start].number())
if end - start >= generator.solo.policy.DEFAULT.long_note:
members = harmony.members(start, end)
available = tuple(p for p in available if p.number() % 12 in members or any(t.pitch == p.number() and t.start <= start and t.end >= end for t in contracts))
if not available:
return None
intended = first.number()
if path:
prior = path[-1].pitch.number()
scale = harmony.at(start).scale
step, semitones = steps[(index - 1) % len(steps)] if steps else (0, 0)
try:
intended = generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, prior) + step).number() if step is not None else prior + semitones
except ValueError:
# The old pitch is not in the new collection: preserve its sounding
# coordinate and adapt its intended motion, never transfer degree IDs.
intended = prior + semitones
stopping = end - start >= generator.solo.policy.DEFAULT.stopping_note
basic = harmony.at(start).roles.basic
pitch = min(available, key=lambda p: (abs(p.number() - intended) + (0.6 if stopping and p.number() % 12 not in basic else 0), p))
source = reference.notes[min(index, len(reference.notes) - 1)].sources if reference.notes else ()
path.append(generator.solo.types.Note(start, end, pitch, source, 'motif'))
return tuple(path)
def candidates(cell, plan, choices, sequences, state=None, anchors=(), harmony=None):
state = state or generator.solo.types.ContinuationState(plan.passage_id)
harmony = harmony or generator.solo.harmonic.Harmony(choices)
reference = generator.solo.motifs.reference(plan, cell.gesture, state.motifs)
treatment = cell.gesture.treatment if cell.gesture else plan.treatment if cell.motif else 'continue'
contracts = tuple(dict.fromkeys((*state.treatments, *anchor_treatments(anchors, harmony))))
protected = cell.motif and plan.treatment in ('quote', 'exact_quote')
local_plan = dataclasses.replace(plan, start=cell.start, end=cell.end, release=fractions.Fraction(), passages=tuple(dataclasses.replace(p, start=max(cell.start, p.start), end=min(cell.end, p.end)) for p in plan.passages if p.start < cell.end and p.end > cell.start))
evaluation = generator.solo.scoring.Evaluation(local_plan, choices)
result = []
for identifier, spans, forced, sustain in rhythm_variants(cell, plan, state, anchors, reference):
active = tuple((a, b) for a, b in spans if generator.solo.types.permitted(choices, a, b))
if active != spans:
if protected:
continue
spans = active
identifier += ':harmonic_fragment'
if not spans:
if not protected:
result.append(Candidate((), identifier, 'space', (generator.solo.scoring.value(evaluation.features(())),), treatments=contracts))
continue
available = options(choices, *spans[0], plan.register)
pending = next((t for t in state.treatments if t.kind == 'suspension' and t.deadline >= cell.start and state.last and state.last.pitch.number() == t.pitch and state.last.end == cell.start and not any(n.pitch.number() == t.resolution and n.start >= t.start for n in state.notes)), None)
if pending:
resolution = next((p for p in available if p.number() == pending.resolution), None)
if resolution is None or spans[0][0] != cell.start:
continue
forced = {**forced, spans[0][0]: resolution}
sustain = False
last = state.last
next_anchor = next((a for a in anchors if a.latest >= cell.start and not generator.solo.harmonic.anchor_note(a, state.notes)), None)
destination = next_anchor.pitch.number() if next_anchor else last.pitch.number() if last else reference.notes[0].pitch.number()
if protected:
paths = []
for octave in (0, 1, -1, 2, -2):
notes = tuple(generator.solo.types.Note(a, b, dataclasses.replace(n.pitch, octave=n.pitch.octave + octave), n.sources, 'quote') for (a, b), n in zip(spans, cell.motif.notes))
if valid_notes(notes, choices):
paths.append((notes, f'quote octave {octave:+}'))
elif sustain and last:
paths = [((generator.solo.types.Note(*spans[0], last.pitch, role='sustain', continuation=True),), 'anchor_sustain')]
else:
near = last.pitch.number() if last else destination
starts = sorted(available, key=lambda p: (abs(p.number() - near), p))[:3]
if spans[0][0] in forced:
starts = [forced[spans[0][0]]]
scale = harmony.at(spans[0][0]).scale
target = min(available, key=lambda p: abs(p.number() - destination))
paths = []
motif_steps = generator.solo.motifs.interval_steps(reference, harmony)
gesture_start = cell.gesture.start if cell.gesture else cell.start
heard = tuple(n for n in state.notes if n.start >= gesture_start)
cursor = len(heard)
if motif_steps and last:
step, semitones = motif_steps[max(0, cursor - 1) % len(motif_steps)]
try:
expected = generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, last.pitch.number()) + step).number() if step is not None else last.pitch.number() + semitones
except ValueError:
expected = last.pitch.number() + semitones
supported = min(available, key=lambda p: (abs(p.number() - expected), p))
if supported not in starts:
starts.append(supported)
if motif_steps:
motif_steps = motif_steps[cursor % len(motif_steps):] + motif_steps[:cursor % len(motif_steps)]
for first in starts:
displacement = generator.solo.types.scale_index(scale, target.number()) - generator.solo.types.scale_index(scale, first.number())
if next_anchor and next_anchor.earliest > cell.end:
displacement = round(displacement * float((cell.end - cell.start) / (next_anchor.earliest - cell.start)))
direction = (displacement > 0) - (displacement < 0)
routes = [(motif_steps, 'develop ' + reference.id)]
retrieved = sequences.retrieve(len(spans), displacement, direction)
selected = generator.solo.sequences.diverse(retrieved, lambda p: (abs(p.offsets()[-1] - displacement), p.sources), lambda p: generator.solo.sequences.family(p.steps), 4)
for path in selected:
steps = tuple((step, generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, first.number()) + step).number() - first.number()) for step in path.steps)
if motif_steps and treatment != 'rhythmic_reference' and not (cell.gesture and cell.gesture.relationship == 'close'):
# Preserve identifying motion, then let the authored vocabulary
# connect it toward the next anchor. The next cell uses this result.
keep = min(2, len(steps))
steps = tuple(motif_steps[i % len(motif_steps)] for i in range(keep)) + steps[keep:]
routes.append((steps, 'sequences ' + ' + '.join(path.sources) + '; develop ' + reference.id))
for steps, name in routes:
notes = realize_route(spans, first, steps, forced, choices, plan, harmony, contracts, reference)
if notes:
paths.append((notes, name))
if last and last.end == cell.start and spans[0][0] == cell.start and not protected:
# An existing sustain is a real alternative, independent of reattack.
for notes, name in list(paths):
if notes[0].pitch.number() == last.pitch.number() and last.role != 'quote':
paths.append(((dataclasses.replace(notes[0], pitch=last.pitch, continuation=True, role='sustain'), *notes[1:]), 'common_tone; ' + name))
# Plan a prepared suspension from the incoming sound before constructing
# its continuation. The contract survives subsequent search boundaries.
if last and last.role != 'quote' and last.end == cell.start and cell.end - cell.start >= 1 and last.start <= cell.start - fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_preparation)):
span = harmony.at(cell.start)
prepared = last.pitch.number() % 12 in harmony.members(cell.start - fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_preparation)), cell.start)
if prepared and span and span.scale and last.pitch.number() % 12 in span.scale.pitches and last.pitch.number() % 12 not in span.roles.basic:
resolution_time = cell.start + fractions.Fraction(1, 2)
resolving = [p for p in options(choices, resolution_time, min(cell.end, resolution_time + fractions.Fraction(1, 2)), plan.register) if p.number() % 12 in span.roles.basic and harmony.step(last.pitch.number(), p.number(), resolution_time)]
for pitch in resolving:
contract = generator.solo.types.HarmonicTreatment(f'{plan.passage_id}_suspension_{cell.start}_{pitch.number()}', 'suspension', last.pitch.number(), cell.start, resolution_time, pitch.number(), cell.start + fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_resolution)))
for original, name in paths[:4]:
tail = tuple(n for n in original if n.start > resolution_time)
end = tail[0].start if tail else cell.end
suspended = (generator.solo.types.Note(cell.start, resolution_time, last.pitch, role='sustain', continuation=True), generator.solo.types.Note(resolution_time, end, pitch, role='motif'), *tail)
raw = tuple(dataclasses.replace(n, position=None) for n in state.notes)
if not raw:
continue
combined = generator.solo.types.sounding_notes((*raw, *suspended))
checked = harmony.evaluate(combined, (*contracts, contract), until=cell.end)
if checked.valid:
features = evaluation.features(tuple(dataclasses.replace(n, continuation=False) for n in suspended))
features['harmonic_exposure'] = checked.exposure
fidelity = generator.solo.scoring.motif_fidelity(tuple(n for n in combined if n.start >= (cell.gesture.start if cell.gesture else cell.start)), reference, treatment)
features['fidelity'] = fidelity
result.append(Candidate(suspended, identifier + '; prepared_suspension; ' + name, treatment, (generator.solo.scoring.value(features),), fidelity, (*contracts, contract)))
for notes, name in paths:
# Continuations are canonicalized with their incoming sounding event.
raw = tuple(dataclasses.replace(n, position=None) for n in state.notes)
combined = generator.solo.types.sounding_notes((*raw, *notes)) if raw or not notes[0].continuation else ()
if not combined:
continue
harmonic = harmony.evaluate(combined, contracts, until=cell.end)
if not harmonic.valid:
continue
local = tuple(dataclasses.replace(n, continuation=False) for n in notes)
features = evaluation.features(local)
gesture_notes = tuple(n for n in combined if n.start >= (cell.gesture.start if cell.gesture else cell.start))
fidelity = (generator.solo.scoring.motif_fidelity(gesture_notes, reference, treatment) if len(gesture_notes) > 1 else 0.5) if cell.gesture or cell.motif else 0
features['fidelity'] = fidelity
features['harmonic_exposure'] = harmonic.exposure
features['connection'] = min(1, abs(notes[0].pitch.number() - last.pitch.number()) / 12) if last else 0
result.append(Candidate(notes, identifier + '; ' + name, name if protected else treatment, (generator.solo.scoring.value(features),), fidelity, contracts))
unique = {}
for candidate in sorted(result, key=lambda c: (c.rank, c.source, tuple(n.pitch for n in c.notes))):
unique.setdefault((candidate.notes, candidate.treatments), candidate)
return list(unique.values())
@dataclasses.dataclass
class SearchContext:
pools: dict = dataclasses.field(default_factory=dict)
results: dict = dataclasses.field(default_factory=dict)
resources: dict = dataclasses.field(default_factory=dict)
revise: bool = True
batch: int = 0
references: tuple = ()
stage: str = 'elaborated'
diagnostics: dict = dataclasses.field(default_factory=lambda: {'searches': 0, 'cache_hits': 0, 'generated_candidates': 0, 'evaluated_extensions': 0, 'peak_states': 0, 'candidate_families': {}, 'surviving_families': {}})
def note_family(notes):
return generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))) if notes else 'space'
def phrase_context(plan, incoming, notes):
return generator.solo.drafts.canonical_context(notes, incoming, plan)
def evaluation_plan(plan, anchors):
landmarks = tuple(generator.solo.types.Landmark(a.earliest, a.latest, a.pitch.number(), a.pitch.number(), name=a.role) for a in anchors)
return dataclasses.replace(plan, start=plan.passage_start if plan.passage_start is not None else plan.start, end=plan.passage_end or plan.end, landmarks=landmarks)
def inherited_treatments(plan, incoming):
if plan.passage_id and plan.passage_id == incoming.passage_id:
return incoming.treatments
retained = []
for treatment in incoming.treatments:
future = treatment.start >= plan.start
color = treatment.kind == 'color' and incoming.last and treatment.end > incoming.last.start
pending = treatment.kind == 'suspension' and incoming.last and incoming.last.pitch.number() == treatment.pitch and incoming.last.end > treatment.start and not any(n.start >= treatment.start and n.pitch.number() == treatment.resolution for n in incoming.notes)
if future or color or pending:
retained.append(treatment)
# Completed contracts remain in their original phrase reports; only the
# sounding tail and unresolved obligations require context in a new passage.
return tuple(retained)
def draft_pitch_paths(rhythm, plan, choices, harmony, sequences, incoming, context):
"""Instantiate a complete timed line. Boundaries here are musical groups."""
F = fractions.Fraction
contracts = tuple(dict.fromkeys((*inherited_treatments(plan, incoming), *plan.treatments, *anchor_treatments(rhythm.anchors, harmony))))
# Cost, actual pitches so far, and the identity of the introduced statement.
states = [(0.0, (), rhythm.reference.notes if isinstance(rhythm.reference, generator.solo.types.RealizedMotif) else ())]
next_anchor = [next((a for a in rhythm.anchors if a.latest >= s.start), None) for s in rhythm.slots]
for index, slot in enumerate(rhythm.slots):
available = options(choices, slot.start, slot.end, plan.register)
pending = next((t for t in contracts if t.kind == 'suspension' and t.deadline is not None and incoming.last and incoming.last.pitch.number() == t.pitch and incoming.last.end == slot.start and t.start <= slot.start <= t.deadline and not any(n.start >= t.start and n.pitch.number() == t.resolution for n in incoming.notes)), None)
if pending:
available = tuple(p for p in available if p.number() == pending.resolution)
if slot.forced:
available = tuple(p for p in available if p == slot.forced)
quoted = slot.protected and plan.treatment in ('quote', 'exact_quote')
if not quoted and slot.end - slot.start >= generator.solo.policy.DEFAULT.long_note:
members = harmony.members(slot.start, slot.end)
available = tuple(p for p in available if p.number() % 12 in members or any(t.pitch == p.number() and t.start <= slot.start and t.end >= slot.end for t in contracts))
if not available:
return (), f'{slot.id}: no valid pitch for sounding interval {slot.start}-{slot.end}'
following = []
group = rhythm.groups[slot.group]
local_index = sum(s.group == slot.group for s in rhythm.slots[:index])
remaining = sum(s.start >= slot.start and (not next_anchor[index] or s.start <= next_anchor[index].latest) for s in rhythm.slots[index:])
span = harmony.at(slot.start)
for cost, notes, home in states:
prior = notes[-1] if notes else incoming.last
reference = home if home and slot.group else rhythm.reference.notes
if local_index >= 3 and group.operation in ('extend', 'extend_diminished', 'answer', 'continue'):
previous_ids = {s.id for s in rhythm.slots if s.group == slot.group - 1}
recent = tuple(n for n in notes if n.event_id in previous_ids) if slot.group else incoming.development.notes if incoming.development else ()
reference = recent or reference
contour = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(reference, reference[1:]))
step = contour[(max(0, slot.reference - 1)) % len(contour)] if contour else 0
if slot.reference == 0 and local_index == 0:
# A group continues the incoming line; it does not teleport to the
# source motif's register or to the center of a fresh bar.
step = contour[-1] if contour and group.operation == 'extend' else 0
expected = prior.pitch.number() + step if prior else rhythm.reference.notes[0].pitch.number()
anchor = next_anchor[index]
target = anchor.pitch.number() if anchor else expected
# Theme invention follows a complete paired rhythmic/interval idea.
# Authored sequence routes are instantiated as whole alternatives later.
route = expected if not anchor else target
ranked = sorted(available, key=lambda p: (abs(p.number() - expected), p))[:3]
if available:
bridge = min(available, key=lambda p: (abs(p.number() - route), p))
if bridge not in ranked:
ranked.append(bridge)
for pitch in ranked:
direction = (pitch.number() > prior.pitch.number()) - (pitch.number() < prior.pitch.number()) if prior else 0
intended = (step > 0) - (step < 0)
identifying = bool(local_index in (1, 2) and group.operation not in ('close', 'answer') and not slot.forced)
jump = abs(pitch.number() - prior.pitch.number()) if prior else 0
stopping = slot.end - slot.start >= generator.solo.policy.DEFAULT.stopping_note
motion_cost = abs(pitch.number() - expected) / 12 * (1.5 if identifying else 0.35)
direction_cost = max(0, abs(pitch.number() - target) - 2 * max(0, remaining - 1)) / 12 if anchor else 0
exposure = 0.6 if stopping and span and pitch.number() % 12 not in span.roles.basic and not quoted else 0
supported_leap = reference and abs(step) >= 3 and abs(pitch.number() - expected) <= 2
score = cost + motion_cost + direction_cost + (0 if supported_leap else max(0, jump - 5) / 12) + exposure
sources = reference[min(slot.reference, len(reference) - 1)].sources if reference else ()
note = generator.solo.types.Note(slot.start, slot.end, pitch, sources, 'quote' if quoted else 'motif', event_id=slot.id)
path = (*notes, note)
identity = home
if not identity and (index + 1 == len(rhythm.slots) or rhythm.slots[index + 1].group != slot.group):
identity = tuple(n for n in path if n.event_id in {s.id for s in rhythm.slots if s.group == slot.group})
following.append((score, path, identity))
if not following:
return (), f'{group.id}: identifying motion cannot reach the selected anchor within the draft budget'
boundary = index + 1 == len(rhythm.slots) or rhythm.slots[index + 1].group != slot.group
limit = generator.solo.policy.DEFAULT.draft_frontier if boundary else BEAM_WIDTH
# Relevant home/contour alternatives survive; no barline participates.
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], tuple(n.pitch for n in item[1])), lambda item: (tuple(n.pitch for n in item[2][:3]), item[1][-1].pitch), limit))
context.diagnostics['evaluated_extensions'] += len(following)
context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states))
return tuple((notes, cost / max(1, len(notes)), contracts) for cost, notes, _ in states[:4]), ''
def make_identity(identifier, parent, notes, start, end, relationship, harmony):
local = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start, position=None) for n in notes)
degrees = tuple(dict(harmony.at(n.start).roles.degrees).get(n.pitch.number() % 12, ()) for n in notes)
return generator.solo.types.RealizedMotif(identifier, parent, local, end - start, relationship, 'developed_rhythm', degrees, start)
def make_draft(rhythm, notes, plan, harmony, incoming):
transforms, structural, protected = [], [], []
home = incoming.home if rhythm.relationship not in ('statement', 'contrast') else None
if rhythm.relationship == 'recall' and isinstance(rhythm.reference, generator.solo.types.RealizedMotif):
home = rhythm.reference
parent = incoming.development.id if incoming.development and home else rhythm.reference.id
for index, group in enumerate(rhythm.groups):
ids = {s.id for s in rhythm.slots if s.group == index}
actual = tuple(n for n in notes if n.event_id in ids)
if not actual:
continue
next_start = rhythm.groups[index + 1].start if index + 1 < len(rhythm.groups) else group.end
start, end = group.start, max(min(group.end, next_start), actual[-1].end)
identity = make_identity(group.id, parent, actual, start, end, group.operation, harmony)
if home is None:
home = identity
directions = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(actual, actual[1:]))
# Closing simplification explicitly releases the characteristic core.
preserves = ()
if plan.treatment == 'rhythmic_reference':
preserves = ('rhythm',)
transforms.append(generator.solo.types.Transformation(group.id, parent, home.id, group.operation, start, end, tuple(n.event_id for n in actual), tuple((n.start - start, n.end - start) for n in actual), directions, preserves, group.source))
parent = identity.id
protected.extend(s.id for s in rhythm.slots if s.protected)
for anchor in rhythm.anchors:
note = generator.solo.harmonic.anchor_note(anchor, notes)
if note:
structural.append(note.event_id)
joins = tuple(t.start for t in transforms[1:])
return generator.solo.types.MelodicDraft(rhythm.id, plan.start, plan.end, notes, tuple(transforms), tuple(dict.fromkeys(structural)), tuple(protected), rhythm.anchors, home, rhythm.relationship, joins, reference=rhythm.reference.id, reference_motif=rhythm.reference)
def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edits=()):
valid, _ = generator.solo.drafts.contract_valid(draft, notes)
if not valid:
return None
placed = generator.solo.placement.place(notes, tempo, incoming.last, harmony.profile)
if placed is None:
return None
travel, notes = placed
try:
canonical = phrase_context(plan, incoming, notes)
except ValueError:
return None
complete = plan.end == (plan.passage_end or plan.end)
checked = harmony.evaluate(canonical, contracts, until=plan.end, complete=complete, enclosures=draft.enclosures)
valid, _ = generator.solo.harmonic.anchors_valid(draft.anchors, canonical, plan.end, complete=complete, closure=plan.closure)
if not checked.valid or not valid or not target_ok(notes, plan.target):
return None
shared = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
score_notes = canonical if shared or notes and notes[0].continuation else generator.solo.drafts.local_sounding(notes)
evaluation_key = (plan, draft.anchors)
if evaluation_key not in harmony.evaluations:
harmony.evaluations[evaluation_key] = generator.solo.scoring.Evaluation(evaluation_plan(plan, draft.anchors), harmony.choices, plan.end)
evaluation = harmony.evaluations[evaluation_key]
if not evaluation.feasible(score_notes):
return None
features = evaluation.features(score_notes, previous=incoming.last if not shared else None, movement=travel + (incoming.travel if shared else 0))
features.update(generator.solo.drafts.relationship_features(draft, notes))
if draft.lead_identity or draft.source_phrase:
supported = {e for feature in draft.features for e in feature.events}
motions = [(a, b) for a, b in zip(notes, notes[1:]) if not (a.event_id in supported and b.event_id in supported)]
features['contour'] = sum(min(1, max(0, abs(b.pitch.number() - a.pitch.number()) - 5) / 12) for a, b in motions) / max(1, len(motions))
# Fidelity is checked against explicit transformed identities, not against
# the source head as one undifferentiated phrase-long quotation.
features.pop('fidelity', None)
features['harmonic_exposure'] = checked.exposure
intended_repeats = 0
if draft.home:
reference = draft.home.notes
for transform in draft.transformations:
actual = tuple(n for n in notes if n.event_id in transform.events)
for i, (a, b, c) in enumerate(zip(actual, actual[1:], actual[2:])):
expected = tuple(reference[j % len(reference)].pitch for j in (i, i + 1, i + 2)) if reference else ()
if a.pitch == b.pitch == c.pitch and len(expected) == 3 and expected[0] == expected[1] == expected[2]:
intended_repeats += 1
features['repetition'] = max(0, features['repetition'] - intended_repeats / max(1, len(score_notes) - 2))
score = generator.solo.scoring.value(features)
motifs = list(incoming.motifs) if shared else []
decisions = []
for transform in draft.transformations:
actual = tuple(n for n in notes if n.event_id in transform.events or transform.start <= n.start < transform.end and n.event_id not in {e for t in draft.transformations for e in t.events})
if not actual:
continue
identity = make_identity(transform.id, transform.parent, actual, transform.start, transform.end, transform.operation, harmony)
motifs.append(identity)
treatment = f'quote octave {round((actual[0].pitch.number() - plan.motif.notes[0].pitch.number()) / 12):+}' if actual[0].role == 'quote' else transform.operation
decisions.append(generator.solo.types.Decision(min(n.start for n in actual), max(n.end for n in actual), 'phrase draft ' + draft.id + '; ' + transform.operation, treatment, actual, 0, transform.id, transform.parent))
# Output ordering follows absolute events, even when a transformation's tail
# was reopened by an overlapping extension.
decisions.sort(key=lambda d: d.start)
emitted = tuple(n for d in decisions for n in d.notes)
if emitted != notes:
return None
home = next((m for m in motifs if draft.home and m.id == draft.home.id), draft.home)
themes = tuple(dict.fromkeys((*incoming.themes, *((home,) if home else ()))))
state_notes = canonical if shared or notes and notes[0].continuation else generator.solo.drafts.local_sounding(notes)
last = state_notes[-1] if state_notes else incoming.last
state = generator.solo.types.ContinuationState(plan.passage_id, state_notes, tuple(motifs), contracts,
tuple(a.id for a in draft.anchors if generator.solo.harmonic.anchor_note(a, canonical)),
(notes[-1].pitch.number() > notes[0].pitch.number()) - (notes[-1].pitch.number() < notes[0].pitch.number()) if notes else incoming.direction,
last, 0, (), travel + (incoming.travel if shared else 0), home, motifs[-1] if motifs else incoming.development, themes, draft.anchors, plan.ending)
actual_plan = dataclasses.replace(plan, relationship=draft.relationship)
return generator.solo.types.Phrase(actual_plan, tuple(decisions), score, state, draft.anchors, draft, edits)
def legacy_target_plan(plan, choices):
if not plan.target or plan.anchor_options:
return plan
target = plan.target
duration = fractions.Fraction(1, 2)
arrival = next((p for p in plan.passages if p.start <= target.earliest < p.end and p.intent == 'sustained_arrival'), None)
if arrival and arrival.activity <= fractions.Fraction(1, 4):
duration = arrival.end - target.earliest
pitches = options(choices, target.earliest, target.earliest + duration, plan.register)
anchors = tuple((generator.solo.types.Anchor(plan.id + '_target', target.earliest, target.latest, pitch, target.pitches, plan.register, duration, 'terminal', target.chord, True, target.earliest + duration),) for pitch in pitches if pitch.number() % 12 in target.pitches)
return dataclasses.replace(plan, anchor_options=anchors)
def emitted_notes(phrase):
return tuple(n for d in phrase.decisions for n in d.notes)
def window_variants(phrase, low, high, harmony, sequences, kind):
"""Two fixed outside endpoints bound edits to the existing melody."""
notes = emitted_notes(phrase)
protected = set((*phrase.draft.structural, *phrase.draft.protected, *(e for f in phrase.draft.features for e in f.events), *(n.event_id for r in phrase.routes for n in r.notes)))
inside = [i for i, n in enumerate(notes) if low <= n.start and n.end <= high]
editable = [i for i in inside if notes[i].event_id not in protected]
if not editable:
return ()
candidates = []
left, right = max(0, inside[0] - 1), min(len(notes) - 1, inside[-1] + 1)
# Compare complete routes through both fixed endpoints. Reject an invalid
# path as a unit; never substitute nearest pitches for individual steps.
for route in generator.solo.routes.candidates(notes[left:right + 1], protected, harmony, sequences, phrase.plan.register):
candidates.append(((*notes[:left], *route.notes, *notes[right + 1:]), 'complete route ' + ' + '.join(route.sources)))
for index in editable:
note = notes[index]
if index + 1 < len(notes) and notes[index + 1].start <= high:
after = notes[index + 1]
if after.event_id not in protected and note.end == after.start and note.pitch == after.pitch:
candidates.append(((*notes[:index], dataclasses.replace(note, end=after.end), *notes[index + 2:]), 'shared common-tone arrival'))
if kind == 'join' and after.event_id not in protected and note.end == after.start and note.end - note.start > fractions.Fraction(1, 2):
# The tail and pickup move together, preserving total sounding time.
time = after.start - fractions.Fraction(1, 2)
candidates.append(((*notes[:index], dataclasses.replace(note, end=time), dataclasses.replace(after, start=time), *notes[index + 2:]), 'reopen tail into pickup'))
return tuple(candidates)
def improve_window(phrase, incoming, harmony, sequences, tempo, low, high, kind, context):
original = emitted_notes(phrase)
viable = [phrase]
proposals = window_variants(phrase, low, high, harmony, sequences, kind)
# Keep distinct rhythmic and pitch revisions; no generic unlimited search.
proposals = generator.solo.sequences.diverse(proposals, lambda p: (abs(len(p[0]) - len(original)), p[1], tuple(n.pitch for n in p[0])), lambda p: (p[1].split()[0], len(p[0])), generator.solo.policy.DEFAULT.join_alternatives)
for notes, source in proposals:
if tuple((n.start, n.end, n.pitch, n.continuation) for n in notes) == tuple((n.start, n.end, n.pitch, n.continuation) for n in original):
continue
changed_before = tuple(n.event_id for n in original if n not in notes)
changed_after = tuple(n.event_id for n in notes if n not in original)
edit = generator.solo.types.DraftEdit(kind, low, high, changed_before, changed_after, source)
revised = checked_phrase(phrase.plan, phrase.draft, notes, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
context.diagnostics[kind + '_attempts'] = context.diagnostics.get(kind + '_attempts', 0) + 1
if revised:
viable.append(dataclasses.replace(revised, initial=phrase.initial, refined=phrase.refined, routes=phrase.routes))
chosen = min(viable, key=lambda p: (p.score, len(p.edits), tuple((n.pitch, n.start, n.end) for n in emitted_notes(p))))
if chosen is not phrase:
context.diagnostics[kind + '_accepted'] = context.diagnostics.get(kind + '_accepted', 0) + 1
return chosen
def rebase_draft(draft, notes, harmony):
"""Reassign edited events by absolute group time, retaining source contracts."""
notes = tuple(dataclasses.replace(n, position=None) for n in notes)
groups = []
original_groups = {g.id: g for g in draft.transformations}
for i, group in enumerate(draft.transformations):
low = draft.start if i == 0 else group.start
high = draft.transformations[i + 1].start if i + 1 < len(draft.transformations) else draft.end
actual = tuple(n for n in notes if low <= n.start < high)
if actual:
parent = group.parent
while parent in original_groups and parent not in {g.id for g in groups}:
parent = original_groups[parent].parent
groups.append(dataclasses.replace(group, parent=parent, events=tuple(n.event_id for n in actual), rhythm=group.rhythm if group.preserves else tuple((n.start - group.start, n.end - group.start) for n in actual)))
home = draft.home
if home and groups and home.id == groups[0].id:
actual = tuple(n for n in notes if n.event_id in groups[0].events)
home = make_identity(home.id, home.parent, actual, groups[0].start, groups[0].end, home.relationship, harmony)
return dataclasses.replace(draft, notes=notes, transformations=tuple(groups), home=home)
def replace_idea(phrase, donor, low, high, incoming, harmony, tempo):
"""A whole two-bar idea is editable until its identifying features are selected."""
original = emitted_notes(phrase)
if any(n.start < low < n.end or n.start < high < n.end for n in original):
return None
replacement = tuple(n for n in emitted_notes(donor) if low <= n.start and n.end <= high)
if not replacement or tuple((n.start, n.end, n.pitch) for n in replacement) == tuple((n.start, n.end, n.pitch) for n in original if low <= n.start < high):
return None
protected = set((*phrase.draft.structural, *phrase.draft.protected))
identifiers = {}
for before in original:
if before.event_id in protected and low <= before.start < high:
after = next((n for n in replacement if (n.start, n.end, n.pitch) == (before.start, before.end, before.pitch)), None)
if not after:
return None
identifiers[after.event_id] = before.event_id
replacement = tuple(dataclasses.replace(n, event_id=identifiers.get(n.event_id, n.event_id + '_replacement'), position=None) for n in replacement)
notes = tuple(n for n in original if n.end <= low) + replacement + tuple(n for n in original if n.start >= high)
valid, _ = generator.solo.drafts.contract_valid(phrase.draft, notes)
if not valid:
return None
draft = rebase_draft(phrase.draft, notes, harmony)
edit = generator.solo.types.DraftEdit('replace_idea', low, high, tuple(n.event_id for n in original if low <= n.start < high), tuple(n.event_id for n in replacement), donor.draft.reference + '; ' + donor.draft.id)
return checked_phrase(phrase.plan, draft, notes, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
def refine(phrase, pool, incoming, harmony, sequences, tempo, context, search=True):
initial = phrase.draft
for low in (phrase.plan.start, phrase.plan.start + 8) if search else ():
high = min(phrase.plan.end, low + 8)
if high - low < 8 or phrase.draft.protected:
continue
viable = [phrase]
for donor in pool:
if donor.draft.id == phrase.draft.id:
continue
context.diagnostics['idea_replacements_tried'] = context.diagnostics.get('idea_replacements_tried', 0) + 1
revised = replace_idea(phrase, donor, low, high, incoming, harmony, tempo)
if revised:
viable.append(revised)
chosen = min(viable, key=lambda p: (p.score, len(p.edits), p.draft.id))
if chosen is not phrase:
context.diagnostics['idea_replacements_selected'] = context.diagnostics.get('idea_replacements_selected', 0) + 1
phrase = chosen
if search and context.revise:
phrase = revise_joins(phrase, incoming, harmony, sequences, tempo, context)
notes = emitted_notes(phrase)
draft = rebase_draft(phrase.draft, notes, harmony)
# Only now commit a selected, inspectable relationship for elaboration.
features = generator.solo.references.identifying_features(notes, draft.reference)
draft = dataclasses.replace(draft, features=features)
# Rebuild lineage after group removal or replacement, including when no run
# is selected afterward. The introduced home is the actual refined tune.
rebuilt = checked_phrase(phrase.plan, draft, notes, phrase.state.treatments, incoming, harmony, tempo, phrase.edits)
if rebuilt is None:
raise ValueError(phrase.plan.id + ': refined theme lost a committed contract')
return dataclasses.replace(rebuilt, initial=initial, refined=draft)
def elaborate(phrase, incoming, harmony, sequences, tempo, context):
started = time.monotonic()
protected = set((*phrase.draft.structural, *phrase.draft.protected, *(e for f in phrase.draft.features for e in f.events)))
if phrase.draft.protected:
return phrase
# Elaborate selected approaches in the existing tune, at most two per phrase.
notes = emitted_notes(phrase)
windows = []
for right in range(2, len(notes)):
if notes[right].event_id not in protected and right != len(notes) - 1:
continue
left = next((i for i in range(right - 1, -1, -1) if notes[right].start - notes[i].start >= 4), None)
if left is not None and notes[right].start - notes[left].start <= 4:
windows.append((notes[left].start, notes[right].start))
limit = max(1, min(2, int((phrase.plan.end - phrase.plan.start) / 16))) if phrase.draft.lead_identity else 2
if phrase.draft.lead_identity:
intended = {phrase.plan.start + a for reference in context.references for a, _ in reference.runs}
windows.sort(key=lambda window: (sum(n.event_id in protected for n in notes if window[0] <= n.start < window[1]), window[0] not in intended, window))
selected_windows = windows[:limit]
else:
selected_windows = windows[-limit:]
for low, high in selected_windows:
if any(low < r.destination.end and high > r.notes[0].start for r in phrase.routes):
continue
notes = emitted_notes(phrase)
left = next(i for i, n in enumerate(notes) if n.start == low)
right = next(i for i, n in enumerate(notes) if n.start == high)
patterns = [notes[left:right + 1]]
# A full approach rhythm, preserving every committed event. Existing
# silence remains a candidate; short-note motion is never compulsory.
moving = []
timepoint = low
while timepoint < high:
original = next((n for n in notes[left:right] if n.start == timepoint), None)
end = min(high, timepoint + fractions.Fraction(1, 2))
moving.append(dataclasses.replace(original, end=end, position=None) if original else generator.solo.types.Note(timepoint, end, notes[left].pitch, role='run', event_id=f'route_{phrase.draft.id}_{timepoint}'))
timepoint = end
patterns.append((*moving, notes[right]))
for reference in context.references:
for a, b in reference.runs:
if high - low != b - a:
continue
profile = tuple(n for n in reference.decorated if a <= n.start and n.end <= b)
timed = []
for index, event in enumerate(profile):
start, end = low + event.start - a, low + event.end - a
original = next((n for n in notes[left:right] if n.start == start), None)
timed.append(dataclasses.replace(original, end=end, position=None) if original else generator.solo.types.Note(start, end, notes[left].pitch, (reference.id,), 'run', event_id=f'profile_{phrase.draft.id}_{start}'))
if timed:
patterns.append((*timed, notes[right]))
viable = [phrase]
for pattern in dict.fromkeys(patterns):
# Fixed events retain their duration and attack identity as well as pitch.
if any(not any((n.event_id, n.start, n.end) == (p.event_id, p.start, p.end) for p in pattern) for n in notes[left:right + 1] if n.event_id in protected):
continue
for route in generator.solo.routes.candidates(pattern, protected, harmony, sequences, phrase.plan.register, 'approach the theme destination'):
if phrase.draft.lead_identity:
# A run must actually contain connected eighth-note movement;
# relabeling the unchanged quarter-note answer is not elaboration.
run = peak = 0
previous_end = None
for note in route.notes:
eighth = note.end - note.start == fractions.Fraction(1, 2)
run = run + 1 if eighth and previous_end == note.start else int(eighth)
peak = max(peak, run)
previous_end = note.end
if peak < 4:
continue
actual = (*notes[:left], *route.notes, *notes[right + 1:])
if phrase.draft.lead_identity and len(actual) > generator.solo.policy.DEFAULT.lead_activity_range[1] * float(phrase.plan.end - phrase.plan.start):
continue
context.diagnostics['complete_routes_tried'] = context.diagnostics.get('complete_routes_tried', 0) + 1
if not generator.solo.drafts.contract_valid(phrase.draft, actual)[0]:
continue
draft = rebase_draft(phrase.draft, actual, harmony)
edit = generator.solo.types.DraftEdit('complete_route', low, notes[right].end, tuple(n.event_id for n in notes[left:right + 1]), tuple(n.event_id for n in route.notes), ' + '.join(route.sources))
revised = checked_phrase(phrase.plan, draft, actual, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
if revised:
# Retain the refined theme, not the ornamental version, as home.
state = dataclasses.replace(revised.state, home=phrase.state.home, themes=phrase.state.themes)
viable.append(dataclasses.replace(revised, state=state, initial=phrase.initial, refined=phrase.refined, routes=(*phrase.routes, route)))
else:
context.diagnostics['complete_routes_rejected'] = context.diagnostics.get('complete_routes_rejected', 0) + 1
# A selected approach is an explicit musical operation. Its complete valid
# routes compete with one another; a zero-edit movement advantage must not
# silently cancel every requested run. Failure retains the plain tune.
eligible = viable[1:] if phrase.draft.lead_identity and len(viable) > 1 else viable
chosen = min(eligible, key=lambda p: (p.score, len(p.edits), tuple(n.pitch for n in emitted_notes(p))))
if chosen is not phrase:
context.diagnostics['complete_routes_selected'] = context.diagnostics.get('complete_routes_selected', 0) + 1
phrase = chosen
context.diagnostics['elaboration_seconds'] = context.diagnostics.get('elaboration_seconds', 0) + time.monotonic() - started
return phrase
def revise_joins(phrase, incoming, harmony, sequences, tempo, context):
started = time.monotonic()
width = fractions.Fraction(str(generator.solo.policy.DEFAULT.join_window))
for _ in range(generator.solo.policy.DEFAULT.revision_sweeps):
before = phrase
notes = emitted_notes(phrase)
def weakness(join):
before = next((n for n in reversed(notes) if n.start < join), None)
after = next((n for n in notes if n.start >= join), None)
return (max(0, abs(after.pitch.number() - before.pitch.number()) - 4) + float(max(0, after.start - before.end))) if before and after else 0
joins = sorted(phrase.draft.joins, key=lambda j: (-weakness(j), j))[:generator.solo.policy.DEFAULT.revision_joins]
for join in joins:
phrase = improve_window(phrase, incoming, harmony, sequences, tempo, max(phrase.plan.start, join - width), min(phrase.plan.end, join + width), 'join', context)
if phrase == before:
break
context.diagnostics['revision_seconds'] = context.diagnostics.get('revision_seconds', 0) + time.monotonic() - started
return phrase
def revise_pair(first, second, incoming, choices, sequences, tempo, context):
"""Revise an uncommitted connected phrase seam, then rebuild both states."""
if first.routes or second.routes or first.refined or second.refined or not context.revise or not first.draft or not second.draft or first.plan.ending != 'connected' or first.plan.passage_id != second.plan.passage_id:
return first, second
incoming = incoming if isinstance(incoming, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=incoming)
harmony = generator.solo.harmonic.Harmony(choices)
seam = first.plan.end
width = fractions.Fraction(str(generator.solo.policy.DEFAULT.join_window))
combined = (*emitted_notes(first), *emitted_notes(second))
draft = dataclasses.replace(first.draft, end=second.plan.end, notes=(*first.draft.notes, *second.draft.notes), transformations=(*first.draft.transformations, *second.draft.transformations), structural=tuple(dict.fromkeys((*first.draft.structural, *second.draft.structural))), protected=(*first.draft.protected, *second.draft.protected), joins=(*first.draft.joins, seam, *second.draft.joins))
joint = dataclasses.replace(first, decisions=(generator.solo.types.Decision(first.plan.start, second.plan.end, 'provisional phrase pair', 'continue', combined),), draft=draft)
candidates = [(second.score + generator.solo.drafts.relationship_features(draft, combined)['join_coherence'], first, second)]
left_ids = {n.event_id for n in emitted_notes(first)}
for notes, source in window_variants(joint, seam - width, seam + width, harmony, sequences, 'join')[:generator.solo.policy.DEFAULT.join_alternatives]:
left = tuple(n for n in notes if n.event_id in left_ids)
right = tuple(n for n in notes if n.event_id not in left_ids)
if any(n.end > seam for n in left) or any(n.start < seam for n in right):
continue
edit = generator.solo.types.DraftEdit('phrase_join', seam - width, seam + width, tuple(n.event_id for n in combined if n not in notes), tuple(n.event_id for n in notes if n not in combined), source)
if not edit.removed:
continue
revised_left = checked_phrase(first.plan, first.draft, left, first.state.treatments, incoming, harmony, tempo, (*first.edits, edit))
if not revised_left:
continue
revised_right = checked_phrase(second.plan, second.draft, right, second.state.treatments, revised_left.state, harmony, tempo, (*second.edits, edit))
context.diagnostics['pair_revision_attempts'] = context.diagnostics.get('pair_revision_attempts', 0) + 1
if revised_right:
realized = (*emitted_notes(revised_left), *emitted_notes(revised_right))
score = revised_right.score + generator.solo.drafts.relationship_features(draft, realized)['join_coherence']
candidates.append((score, revised_left, revised_right))
_, left, right = min(candidates, key=lambda item: (item[0], len(item[1].edits) + len(item[2].edits)))
if left is not first:
context.diagnostics['pair_revisions_accepted'] = context.diagnostics.get('pair_revisions_accepted', 0) + 1
return left, right
def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None):
context = context if context is not None else SearchContext()
if plan.treatment == 'lead':
raise ValueError('generic lead identities are retired; use composer.compose for source variations')
# Keep resources alive while identity-keyed cache entries exist. A later
# library cannot accidentally reuse the identity of a collected object.
context.resources[id(sequences)] = sequences
context.resources[id(rhythms)] = rhythms
pool_key = (plan, previous, generator.solo.policy.stable_rank('harmonic-cache', choices), tempo, id(sequences), id(rhythms), tuple(r.version for r in context.references))
key = (*pool_key, context.revise, context.stage)
if key in context.results:
context.diagnostics['cache_hits'] += 1
return context.results[key]
started = time.monotonic()
context.diagnostics['searches'] += 1
incoming = previous if isinstance(previous, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=previous)
harmony = generator.solo.harmonic.Harmony(choices)
plan = legacy_target_plan(plan, choices)
if pool_key in context.pools:
selected = context.pools[pool_key]
context.diagnostics['reused_draft_pools'] = context.diagnostics.get('reused_draft_pools', 0) + 1
else:
constructions = generator.solo.drafts.rhythms(plan, incoming, measures, choices, rhythms, context.batch, context.references)
complete, failure = [], ''
for rhythm in constructions:
candidate_plan = generator.solo.drafts.ending_plan(plan, rhythm.anchors)
paths, error = draft_pitch_paths(rhythm, candidate_plan, choices, harmony, sequences, incoming, context)
failure = error or failure
context.diagnostics['generated_candidates'] += len(paths)
for notes, cost, contracts in paths:
family = note_family(notes)
counts = context.diagnostics['candidate_families']
counts[family] = counts.get(family, 0) + 1
realizations = [notes]
if notes and incoming.last and incoming.last.end == notes[0].start and incoming.last.pitch == notes[0].pitch and notes[0].role != 'quote' and not any(a.onset_required and a.earliest <= notes[0].start <= a.latest for a in rhythm.anchors):
realizations.append((dataclasses.replace(notes[0], continuation=True), *notes[1:]))
for actual in realizations:
draft = make_draft(rhythm, actual, candidate_plan, harmony, incoming)
phrase = checked_phrase(candidate_plan, draft, actual, contracts, incoming, harmony, tempo)
if phrase:
complete.append(phrase)
if not complete:
raise SearchExhausted(f'{plan.id}: bounded phrase draft search exhausted; {failure or "no complete draft satisfies timing, anchors, treatments and motif contracts"}; {len(constructions)} constructions / {BEAM_WIDTH} pitch states (not proof that the phrase is unplayable)')
context.diagnostics['construction_seconds'] = context.diagnostics.get('construction_seconds', 0) + time.monotonic() - started
selected = generator.solo.sequences.diverse(complete, lambda p: (p.score, p.draft.id, tuple(n.pitch for n in p.draft.notes)), lambda p: (p.draft.relationship, note_family(p.draft.notes), p.anchors[-1].duration if p.anchors else None), generator.solo.policy.DEFAULT.phrase_alternatives)
context.pools[pool_key] = selected
input_id = generator.solo.policy.stable_rank(plan.seed, (plan, previous, choices))[:16]
pool_id = generator.solo.policy.stable_rank(plan.seed, tuple((p.draft.id, emitted_notes(p)) for p in selected))
context.diagnostics.setdefault('draft_pool_fingerprints', {})[input_id] = pool_id
result = []
for index, phrase in enumerate(selected):
if context.stage == 'initial':
phrase = dataclasses.replace(phrase, initial=phrase.draft)
else:
phrase = refine(phrase, selected, incoming, harmony, sequences, tempo, context, search=index < generator.solo.policy.DEFAULT.refinement_alternatives)
if context.stage == 'elaborated':
phrase = elaborate(phrase, incoming, harmony, sequences, tempo, context)
result.append(phrase)
result = tuple(sorted(result, key=lambda p: (p.score, p.draft.id)))
for phrase in result:
family = note_family(emitted_notes(phrase))
counts = context.diagnostics['surviving_families']
counts[family] = counts.get(family, 0) + 1
context.diagnostics.setdefault('complete_ideas', {})[input_id] = [{'id': p.draft.id, 'reference': p.draft.reference, 'score': round(p.score, 6), 'attacks': len(emitted_notes(p)), 'ending': p.plan.ending, 'hold': str(p.anchors[-1].duration) if p.anchors else '', 'edits': [e.kind for e in p.edits]} for p in result]
context.results[key] = result
return result
def compose(plan, measures, choices, sequences, rhythms, tempo, previous=None):
return alternatives(plan, measures, choices, sequences, rhythms, tempo, previous)[0]