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) 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) 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: 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': from generator.solo import leads return leads.alternatives(plan, measures, choices, sequences, tempo, previous, context) # 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]