from __future__ import annotations import dataclasses import fractions import functools 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.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, max(41, register[0] - 7), min(84, 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) 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' @dataclasses.dataclass(frozen=True) class BeamState: score: float decisions: tuple music: generator.solo.types.ContinuationState travel: int = 0 fidelity: tuple[float, ...] = () def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None): context = context if context is not None else SearchContext() key = (plan, previous) if key in context.results: context.diagnostics['cache_hits'] += 1 return context.results[key] context.diagnostics['searches'] += 1 harmony = generator.solo.harmonic.Harmony(choices) incoming = previous if isinstance(previous, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=previous) continuing = bool(plan.passage_id and incoming.passage_id == plan.passage_id) initial = incoming if continuing else generator.solo.types.ContinuationState(plan.passage_id, last=incoming.last, treatments=plan.treatments) backbones = plan.anchor_options or tuple([()]) # A caller explicitly scheduling quotation owns its rhythm and pitches. Such # plans must provide compatible anchors; no automatic rewrite is permitted. states = [BeamState(0, (), dataclasses.replace(initial, backbone=i), initial.travel, initial.fidelity) for i in ([initial.backbone] if continuing else range(len(backbones)))] if plan not in context.pools: context.pools[plan] = cells(plan, measures, rhythms, choices) evaluation_plan = dataclasses.replace(plan, start=plan.passage_start if plan.passage_start is not None else plan.start, end=plan.passage_end or plan.end) last_error = '' for cell in context.pools[plan]: evaluation = generator.solo.scoring.Evaluation(evaluation_plan, choices, cell.end) following, pools = [], {} for state in states: music = state.music anchors = backbones[music.backbone] reference = generator.solo.motifs.reference(plan, cell.gesture, music.motifs) # Concrete results depend on pitch, fingering, motif, anchor progress, # rhythm history and unresolved treatments. Abstract rhythms alone cache # across unrelated incoming lines. pool_key = (music, anchors) if pool_key not in pools: pool = candidates(cell, plan, choices, sequences, music, anchors, harmony) context.diagnostics['generated_candidates'] += len(pool) for candidate in pool: family = note_family(candidate.notes) counts = context.diagnostics['candidate_families'] counts[family] = counts.get(family, 0) + 1 pools[pool_key] = generator.solo.sequences.diverse(pool, lambda c: (c.rank, c.source), lambda c: (len(c.notes), note_family(c.notes), bool(c.notes and c.notes[0].continuation)), CELL_LIMIT) for candidate in pools[pool_key]: context.diagnostics['evaluated_extensions'] += 1 placed = generator.solo.placement.place(candidate.notes, tempo, music.last) if placed is None: continue comfort, notes = placed canonical = generator.solo.types.sounding_notes((*music.notes, *notes)) completed = cell.end == (plan.passage_end or plan.end) harmonic = harmony.evaluate(canonical, candidate.treatments, until=cell.end, complete=completed) valid, error = generator.solo.harmonic.anchors_valid(anchors, canonical, cell.end, complete=completed, closure=plan.closure) if not harmonic.valid or not valid: last_error = harmonic.error or error continue if plan.target and (plan.target.latest < cell.end or cell.end == plan.end) and not target_ok(canonical, plan.target): last_error = 'legacy destination was not reached' continue motifs, fidelity = music.motifs, state.fidelity if cell.gesture and cell.end == cell.gesture.end: valid, mismatch, identity = generator.solo.motifs.complete(cell.gesture, reference, canonical, harmony) if not valid: last_error = f'{cell.gesture.id}: insufficient evidence of {cell.gesture.treatment} from {reference.id}' continue fidelity += (mismatch,) if identity: motifs += (identity,) if not evaluation.feasible(canonical): last_error = 'planned activity, sustain or space not fulfilled' continue features = evaluation.features(canonical) features['fidelity'] = (sum(fidelity) + candidate.fidelity) / (len(fidelity) + 1) features['harmonic_exposure'] = harmonic.exposure features['connection'] = min(1, abs(canonical[0].pitch.number() - incoming.last.pitch.number()) / 12) if canonical and incoming.last and not continuing else 0 features['movement'] = min(1, (state.travel + comfort) / max(1, len(canonical)) / 2000) if cell.gesture: recent = tuple(n for n in canonical if cell.gesture.start <= n.start < cell.gesture.end) intended_repeats = sum(a.pitch == b.pitch == c.pitch and a.start == x.start - cell.gesture.start and b.start == y.start - cell.gesture.start and c.start == z.start - cell.gesture.start for a, b, c in zip(reference.notes, reference.notes[1:], reference.notes[2:]) for x, y, z in zip(recent, recent[1:], recent[2:]) if x.pitch == y.pitch == z.pitch) features['repetition'] = max(0, features['repetition'] - intended_repeats / max(1, len(canonical) - 2)) total = generator.solo.scoring.value(features) last = canonical[-1] if canonical else music.last progress = tuple(a.id for a in anchors if generator.solo.harmonic.anchor_note(a, canonical)) direction = (last.pitch.number() > music.last.pitch.number()) - (last.pitch.number() < music.last.pitch.number()) if last and music.last else music.direction carried = generator.solo.types.ContinuationState(plan.passage_id, canonical, motifs, candidate.treatments, progress, direction, last, music.backbone, fidelity, state.travel + comfort) decision = generator.solo.types.Decision(cell.start, cell.end, candidate.source, candidate.treatment, notes, candidate.fidelity, cell.gesture.id if cell.gesture else '', reference.id) following.append(BeamState(total, (*state.decisions, decision), carried, state.travel + comfort, fidelity)) if not following: raise SearchExhausted(f'{plan.id}, beats {cell.start + 1}-{cell.end + 1}: bounded solo search exhausted; {last_error or "no eligible continuation"}; {BEAM_WIDTH} states / {CELL_LIMIT} extensions (not proof that the passage is unplayable)') def rank(state): return state.score, tuple((n.pitch, n.position, n.start, n.end) for n in state.music.notes) unique = {} for state in sorted(following, key=rank): unique.setdefault(state.music, state) # A rolling one-gesture window keeps the longer plan bounded. Relevant # motif/anchor/treatment state survives; a matching last fret is not identity. limit = generator.solo.policy.DEFAULT.alternatives if cell.gesture and cell.end == cell.gesture.end else BEAM_WIDTH states = list(generator.solo.sequences.diverse(unique.values(), rank, lambda s: (s.music.backbone, note_family(s.music.notes[-4:]), s.music.last.pitch if s.music.last else None), limit)) context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states)) selected = generator.solo.sequences.diverse(states, lambda s: (s.score, tuple(n.pitch for n in s.music.notes)), lambda s: (s.music.backbone, note_family(s.music.notes[-4:])), generator.solo.policy.DEFAULT.alternatives) for state in selected: family = note_family(state.music.notes[-4:]) counts = context.diagnostics['surviving_families'] counts[family] = counts.get(family, 0) + 1 result = tuple(generator.solo.types.Phrase(plan, state.decisions, state.score, state.music, backbones[state.music.backbone]) for state in selected) 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]