from __future__ import annotations import dataclasses import fractions import functools import time import generator.solo.drafts 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 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 route = expected if prior and anchor and span and span.scale: try: start_degree = generator.solo.types.scale_index(span.scale, prior.pitch.number()) target_degree = generator.solo.types.scale_index(span.scale, target) displacement = target_degree - start_degree count = min(16, max(2, remaining + 1)) paths = sequences.retrieve(count, displacement, (displacement > 0) - (displacement < 0)) if paths: route = generator.solo.types.scale_pitch(span.scale, start_degree + paths[0].steps[0]).number() except ValueError: # Collection changes preserve the incoming sound, never its old index. route = prior.pitch.number() + ((target > prior.pitch.number()) - (target < prior.pitch.number())) * 2 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) if identifying and reference and direction != intended: continue 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 score = cost + motion_cost + direction_cost + 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 structural.extend(n.event_id for n in actual[:3]) 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 group.operation == 'close' else ('rhythm', 'opening_contour') 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) 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 = generator.solo.scoring.Evaluation(evaluation_plan(plan, draft.anchors), harmony.choices, plan.end) 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)) # Fidelity is checked against explicit transformed identities, not against # the source head as one undifferentiated phrase-long quotation. features['fidelity'] = features['development'] 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)) 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) # Route both sides of a join as one path, with the original outside pitches. if 1 < right - left < 16: scale = harmony.at(notes[left].start).scale try: first = generator.solo.types.scale_index(scale, notes[left].pitch.number()) displacement = generator.solo.types.scale_index(scale, notes[right].pitch.number()) - first paths = sequences.retrieve(right - left + 1, displacement, (displacement > 0) - (displacement < 0)) except ValueError: paths = () for path in paths[:generator.solo.policy.DEFAULT.join_alternatives]: updated = list(notes) for i in editable: offset = path.offsets()[i - left] intended = generator.solo.types.scale_pitch(scale, first + offset).number() available = options(harmony.choices, notes[i].start, notes[i].end, phrase.plan.register) if available: pitch = min(available, key=lambda p: (abs(p.number() - intended), p)) updated[i] = dataclasses.replace(notes[i], pitch=pitch, position=None) candidates.append((tuple(updated), 'sequence ' + ' + '.join(path.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')) if kind == 'elaboration' and index + 1 < len(notes) and fractions.Fraction(1) <= note.end - note.start < 2: # Add motion only as an alternative evaluated against the unchanged # activity envelope; keeping the original draft is always available. split = note.end - fractions.Fraction(1, 2) scale = harmony.at(split).scale target = notes[index + 1].pitch try: degree = generator.solo.types.scale_index(scale, note.pitch.number()) finish = generator.solo.types.scale_index(scale, target.number()) pitch = generator.solo.types.scale_pitch(scale, degree + ((finish > degree) - (finish < degree))) addition = generator.solo.types.Note(split, note.end, pitch, note.sources, 'elaboration', event_id=note.event_id + '_passing') candidates.append(((*notes[:index], dataclasses.replace(note, end=split), addition, *notes[index + 1:]), 'interpolate toward next structural event')) except ValueError: pass 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 notes == 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(revised) 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 elaborate(phrase, incoming, harmony, sequences, tempo, context): started = time.monotonic() for transform in phrase.draft.transformations: phrase = improve_window(phrase, incoming, harmony, sequences, tempo, transform.start, transform.end, 'elaboration', context) 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 for join in phrase.draft.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 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() # 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)) key = (*pool_key, context.revise) 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) complete, failure = [], '' for rhythm in constructions: paths, error = draft_pitch_paths(rhythm, 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, plan, harmony, incoming) phrase = checked_phrase(plan, draft, actual, contracts, incoming, harmony, tempo) if phrase: complete.append(dataclasses.replace(phrase, score=phrase.score + 0.1 * cost)) 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) selected = tuple(elaborate(p, incoming, harmony, sequences, tempo, context) for p in selected) 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 phrase in selected: if context.revise: phrase = revise_joins(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.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]