Spaces:
Running
Running
| from __future__ import annotations | |
| from collections import Counter, defaultdict | |
| from dataclasses import dataclass | |
| from html import escape | |
| from pathlib import Path | |
| from tempfile import TemporaryDirectory | |
| import threading | |
| from typing import Any, get_args | |
| import mido | |
| try: | |
| from ctor.classic import ClassicContinuator | |
| except ImportError: | |
| from ctor.continuator import Continuator2 as ClassicContinuator | |
| try: | |
| from ctor.context_bp import ContextBPContinuator | |
| except ImportError: | |
| ContextBPContinuator = None | |
| try: | |
| from ctor.vo_regular_bp import VORegularBPContinuator | |
| except ImportError: | |
| VORegularBPContinuator = None | |
| from .schemas import ( | |
| EngineKind, | |
| GenerationConstraintsStatus, | |
| GenerationConstraintState, | |
| GenerationTraceStep, | |
| MidiEvent, | |
| PhraseNote, | |
| PhrasePayload, | |
| PlaybackMidiEvent, | |
| ViewpointSeed, | |
| ) | |
| class NoContinuationAvailable(RuntimeError): | |
| """Raised when the engine cannot generate a continuation.""" | |
| class MidiImportError(RuntimeError): | |
| """Raised when uploaded MIDI files cannot be imported.""" | |
| ENGINE_KINDS: set[str] = set(get_args(EngineKind)) | |
| GRAPH_SELECTION_MODES = {"slice", "all", "most_used", "neighborhood"} | |
| class ImportedMidiPhrase: | |
| file_name: str | |
| payload: PhrasePayload | |
| class _GraphEdge: | |
| source: tuple[object, ...] | |
| target: tuple[object, ...] | |
| symbol: object | |
| weight: float | |
| def _round_float(value: float) -> float: | |
| return round(float(value), 6) | |
| def _normalize_uploaded_file_name(raw_name: str | None, fallback: str) -> str: | |
| candidate = (raw_name or fallback).replace("\\", "/") | |
| parts = [part for part in candidate.split("/") if part and part not in {".", ".."}] | |
| normalized = "/".join(parts) | |
| return normalized or fallback | |
| def _normalize_notes(notes: list[object]) -> list[object]: | |
| normalized = [note.copy() if hasattr(note, "copy") else note for note in notes] | |
| if not normalized: | |
| return normalized | |
| min_start = min(float(note.start_time) for note in normalized) | |
| if min_start < 0: | |
| for note in normalized: | |
| note.start_time = float(note.start_time) - min_start | |
| normalized.sort(key=lambda note: (float(note.start_time), int(note.pitch), float(note.duration))) | |
| return normalized | |
| def _note_to_schema(note: object) -> PhraseNote: | |
| start_seconds = _round_float(note.start_time / 2.0) | |
| duration_seconds = _round_float(note.duration / 2.0) | |
| end_seconds = _round_float(start_seconds + duration_seconds) | |
| return PhraseNote( | |
| pitch=int(note.pitch), | |
| velocity=int(note.velocity), | |
| start_seconds=start_seconds, | |
| duration_seconds=duration_seconds, | |
| end_seconds=end_seconds, | |
| start_beats=_round_float(note.start_time), | |
| duration_beats=_round_float(note.duration), | |
| ) | |
| def _note_to_viewpoint(note: object) -> tuple[int, int, bool, bool]: | |
| return ( | |
| int(note.pitch), | |
| int(float(note.duration)), | |
| bool(note.overlaps_left()), | |
| bool(note.overlaps_right()), | |
| ) | |
| def _viewpoint_to_schema(viewpoint: tuple[int, int, bool, bool] | None) -> ViewpointSeed | None: | |
| if viewpoint is None: | |
| return None | |
| pitch, duration_bin, overlaps_left, overlaps_right = viewpoint | |
| return ViewpointSeed( | |
| pitch=int(pitch), | |
| duration_bin=max(0, int(duration_bin)), | |
| overlaps_left=bool(overlaps_left), | |
| overlaps_right=bool(overlaps_right), | |
| ) | |
| def _schema_to_viewpoint(seed: ViewpointSeed | None) -> tuple[int, int, bool, bool] | None: | |
| if seed is None: | |
| return None | |
| return ( | |
| int(seed.pitch), | |
| max(0, int(seed.duration_bin)), | |
| bool(seed.overlaps_left), | |
| bool(seed.overlaps_right), | |
| ) | |
| def _pitch_label(pitch: int) -> str: | |
| names = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B") | |
| return f"{names[pitch % 12]}{pitch // 12 - 1}" | |
| def _viewpoint_label(viewpoint: tuple[int, int, bool, bool] | None) -> str | None: | |
| if viewpoint is None: | |
| return None | |
| pitch, duration_bin, overlaps_left, overlaps_right = viewpoint | |
| overlap_label = "" | |
| if overlaps_left or overlaps_right: | |
| sides = [] | |
| if overlaps_left: | |
| sides.append("left") | |
| if overlaps_right: | |
| sides.append("right") | |
| overlap_label = f", overlaps {'/'.join(sides)}" | |
| return f"{_pitch_label(int(pitch))}, duration bin {int(duration_bin)}{overlap_label}" | |
| def _svg_text_lines( | |
| lines: list[str], | |
| *, | |
| x: float, | |
| y: float, | |
| class_name: str, | |
| anchor: str = "start", | |
| line_height: int = 14, | |
| ) -> str: | |
| if not lines: | |
| return "" | |
| escaped_lines = [escape(line) for line in lines] | |
| tspans = [ | |
| f'<tspan x="{x:.1f}" dy="{0 if index == 0 else line_height}">{line}</tspan>' | |
| for index, line in enumerate(escaped_lines) | |
| ] | |
| return ( | |
| f'<text class="{class_name}" x="{x:.1f}" y="{y:.1f}" ' | |
| f'text-anchor="{anchor}">{"".join(tspans)}</text>' | |
| ) | |
| def _format_graph_weight(weight: float) -> str: | |
| if abs(weight - round(weight)) < 0.001: | |
| return str(int(round(weight))) | |
| return f"{weight:.2f}".rstrip("0").rstrip(".") | |
| def _constraint_state_label(state: GenerationConstraintState | None) -> str: | |
| if state is None: | |
| return "not requested" | |
| if state.applied: | |
| return "applied" | |
| if state.relaxed: | |
| return "relaxed" | |
| if state.requested: | |
| return "requested" | |
| return "disabled" | |
| def _notes_to_events(notes: list[object]) -> list[PlaybackMidiEvent]: | |
| timed_events: list[dict[str, float | int | str]] = [] | |
| for note in notes: | |
| start_seconds = note.start_time / 2.0 | |
| end_seconds = (note.start_time + note.duration) / 2.0 | |
| timed_events.append( | |
| { | |
| "type": "note_on", | |
| "note": int(note.pitch), | |
| "velocity": int(note.velocity), | |
| "channel": 0, | |
| "time_seconds": start_seconds, | |
| } | |
| ) | |
| timed_events.append( | |
| { | |
| "type": "note_off", | |
| "note": int(note.pitch), | |
| "velocity": 0, | |
| "channel": 0, | |
| "time_seconds": end_seconds, | |
| } | |
| ) | |
| timed_events.sort( | |
| key=lambda item: ( | |
| float(item["time_seconds"]), | |
| 0 if item["type"] == "note_off" else 1, | |
| int(item["note"]), | |
| ) | |
| ) | |
| events: list[PlaybackMidiEvent] = [] | |
| current_time = 0.0 | |
| for item in timed_events: | |
| absolute_time = _round_float(float(item["time_seconds"])) | |
| delta = _round_float(max(0.0, absolute_time - current_time)) | |
| current_time = absolute_time | |
| events.append( | |
| PlaybackMidiEvent( | |
| type=str(item["type"]), | |
| note=int(item["note"]), | |
| velocity=int(item["velocity"]), | |
| channel=int(item["channel"]), | |
| delta_seconds=delta, | |
| time_seconds=absolute_time, | |
| ) | |
| ) | |
| return events | |
| def _build_phrase_payload(notes: list[object]) -> PhrasePayload: | |
| raw_notes = list(notes) | |
| normalized_notes = _normalize_notes(notes) | |
| note_payload = [_note_to_schema(note) for note in normalized_notes] | |
| events = _notes_to_events(normalized_notes) | |
| duration_seconds = max((note.end_seconds for note in note_payload), default=0.0) | |
| handoff_seconds = None | |
| handoff_viewpoint = None | |
| if raw_notes: | |
| last_note = raw_notes[-1] | |
| next_onset_beats = max( | |
| 0.0, | |
| float(last_note.start_time) | |
| + max(0.0, float(last_note.duration) + float(getattr(last_note, "next_start_delta", 0.0))), | |
| ) | |
| handoff_seconds = _round_float(next_onset_beats / 2.0) | |
| handoff_viewpoint = _viewpoint_to_schema(_note_to_viewpoint(last_note)) | |
| return PhrasePayload( | |
| event_count=len(events), | |
| note_count=len(note_payload), | |
| duration_seconds=_round_float(duration_seconds), | |
| handoff_seconds=handoff_seconds, | |
| handoff_viewpoint=handoff_viewpoint, | |
| events=events, | |
| notes=note_payload, | |
| ) | |
| def _event_to_mido_message(event: MidiEvent) -> mido.Message: | |
| velocity = event.velocity if event.type == "note_on" else 0 | |
| return mido.Message( | |
| event.type, | |
| note=event.note, | |
| velocity=velocity, | |
| channel=event.channel, | |
| time=float(event.delta_seconds), | |
| ) | |
| class ContinuatorSessionEngine: | |
| def __init__( | |
| self, | |
| learn_input: bool = True, | |
| transposition: bool = False, | |
| forget_past: bool = False, | |
| keep_last_inputs: int = 20, | |
| decay_mode: str = "full", | |
| engine_kind: EngineKind = "classic", | |
| markov_order: int = 4, | |
| seed_midi_file: Path | None = None, | |
| seed_midi_folder: Path | None = None, | |
| ) -> None: | |
| if engine_kind not in ENGINE_KINDS: | |
| raise ValueError(f"Unknown Continuator engine kind: {engine_kind}") | |
| self._default_learn_input = learn_input | |
| self._transposition = transposition | |
| self._forget_past = forget_past | |
| self._keep_last_inputs = keep_last_inputs | |
| self._decay_mode = decay_mode | |
| self._engine_kind = engine_kind | |
| self._markov_order = markov_order | |
| self._seed_midi_file = seed_midi_file | |
| self._seed_midi_folder = seed_midi_folder | |
| self._seed_sequence_count = 0 | |
| self._lock = threading.RLock() | |
| self._continuator = self._create_engine() | |
| def _engine_class(self) -> type[Any]: | |
| if self._engine_kind == "classic": | |
| return ClassicContinuator | |
| if self._engine_kind == "context_bp": | |
| if ContextBPContinuator is None: | |
| raise RuntimeError( | |
| "ContextBPContinuator is not available. Install a continuator " | |
| "package revision that includes ctor.context_bp." | |
| ) | |
| return ContextBPContinuator | |
| if self._engine_kind == "vo_regular_bp": | |
| if VORegularBPContinuator is None: | |
| raise RuntimeError( | |
| "VORegularBPContinuator is not available. Install a continuator " | |
| "package revision that includes ctor.vo_regular_bp and vo_regular_bp." | |
| ) | |
| return VORegularBPContinuator | |
| raise ValueError(f"Unknown Continuator engine kind: {self._engine_kind}") | |
| def _create_engine(self, *, load_seed_material: bool = True) -> Any: | |
| midi_file = None | |
| if load_seed_material and self._seed_midi_file: | |
| midi_file = str(self._seed_midi_file) | |
| engine = self._engine_class()( | |
| midi_file=midi_file, | |
| kmax=self._markov_order, | |
| transposition=self._transposition, | |
| ) | |
| if load_seed_material and self._seed_midi_folder: | |
| engine.learn_folder(str(self._seed_midi_folder), transpose=self._transposition) | |
| engine.set_learn_input(self._default_learn_input) | |
| engine.set_transpose(self._transposition) | |
| engine.set_forget(self._forget_past) | |
| engine.set_keep_last(self._keep_last_inputs) | |
| set_decay_mode = getattr(engine, "set_decay_mode", None) | |
| if callable(set_decay_mode): | |
| set_decay_mode(self._decay_mode) | |
| midi_store = self._engine_midi_store(engine) | |
| self._seed_sequence_count = ( | |
| len(getattr(midi_store, "input_sequences", [])) if load_seed_material else 0 | |
| ) | |
| return engine | |
| def _engine_midi_store(engine: object) -> object | None: | |
| store_for_engine = getattr(engine, "_midi_store", None) | |
| if callable(store_for_engine): | |
| try: | |
| return store_for_engine() | |
| except AttributeError: | |
| pass | |
| store = getattr(engine, "realization_store", None) | |
| if store is not None: | |
| return store | |
| return getattr(engine, "vom", None) | |
| def _midi_store(self) -> object | None: | |
| return self._engine_midi_store(self._continuator) | |
| def _input_sequences(self) -> list[object]: | |
| store = self._midi_store() | |
| if store is None: | |
| return [] | |
| return list(getattr(store, "input_sequences", [])) | |
| def _has_viewpoint(self, viewpoint: object) -> bool: | |
| store = self._midi_store() | |
| if store is None: | |
| return False | |
| has_viewpoint = getattr(store, "has_viewpoint", None) | |
| if callable(has_viewpoint): | |
| return bool(has_viewpoint(viewpoint)) | |
| return viewpoint in getattr(store, "viewpoints_realizations", {}) | |
| def _realize_vp_sequence( | |
| self, | |
| vp_sequence: list[object], | |
| *, | |
| force_ending_realization: bool = False, | |
| ) -> list[object]: | |
| if force_ending_realization and vp_sequence: | |
| return self._continuator.realize_vp_sequence( | |
| [*vp_sequence, self._continuator.get_end_vp()] | |
| ) | |
| return self._continuator.realize_vp_sequence(vp_sequence) | |
| def _last_generation_trace(self) -> list[GenerationTraceStep] | None: | |
| get_trace = getattr(self._continuator, "get_last_generation_trace", None) | |
| if get_trace is None: | |
| return None | |
| trace = get_trace() | |
| if not trace: | |
| return None | |
| return [GenerationTraceStep.model_validate(step) for step in trace] | |
| def _trace_value(self, value: object) -> object: | |
| if self._is_start_symbol(value): | |
| return "START" | |
| if self._is_end_symbol(value): | |
| return "END" | |
| if value is None or isinstance(value, (bool, int, float, str)): | |
| return value | |
| if hasattr(value, "item"): | |
| try: | |
| scalar_value = value.item() | |
| except ValueError: | |
| scalar_value = None | |
| if isinstance(scalar_value, (bool, int, float, str)): | |
| return scalar_value | |
| if isinstance(value, tuple): | |
| return [self._trace_value(item) for item in value] | |
| if isinstance(value, list): | |
| return [self._trace_value(item) for item in value] | |
| return repr(value) | |
| def _synthetic_generation_trace( | |
| self, | |
| sequence: list[object] | None, | |
| *, | |
| initial_context: list[object] | None = None, | |
| ) -> list[GenerationTraceStep] | None: | |
| if not sequence: | |
| return None | |
| graph_counts, _model_label = self._graph_counts() | |
| raw_context = list(initial_context or []) | |
| kmax = max(1, int(self._markov_order)) | |
| trace: list[GenerationTraceStep] = [] | |
| for position, symbol in enumerate(sequence): | |
| effective_order = min(kmax, len(raw_context)) | |
| candidate_orders: list[int] = [] | |
| candidate_counts: list[int] = [] | |
| chosen_order = 0 | |
| for order in range(effective_order, 0, -1): | |
| candidate_context = tuple(raw_context[-order:]) | |
| continuations = graph_counts.get(candidate_context) | |
| if not continuations: | |
| continue | |
| candidate_orders.append(order) | |
| candidate_counts.append(len(continuations)) | |
| if not chosen_order and symbol in continuations: | |
| chosen_order = order | |
| if not chosen_order and candidate_orders: | |
| chosen_order = candidate_orders[0] | |
| context = raw_context[-effective_order:] if effective_order else [] | |
| trace.append( | |
| GenerationTraceStep( | |
| position=position, | |
| symbol=self._trace_value(symbol), | |
| order=chosen_order, | |
| effective_order=effective_order, | |
| context=[self._trace_value(value) for value in context], | |
| policy="generated path", | |
| candidate_orders=candidate_orders, | |
| candidate_counts=candidate_counts, | |
| ) | |
| ) | |
| raw_context.append(symbol) | |
| return trace | |
| def _generation_trace_or_path( | |
| self, | |
| sequence: list[object] | None, | |
| *, | |
| initial_context: list[object] | None = None, | |
| ) -> list[GenerationTraceStep] | None: | |
| return self._last_generation_trace() or self._synthetic_generation_trace( | |
| sequence, | |
| initial_context=initial_context, | |
| ) | |
| def _sample_memory_sequence( | |
| self, | |
| *, | |
| length: int, | |
| constraints: dict[int, object], | |
| enforce_start_constraint: bool, | |
| ) -> list[object] | None: | |
| if not enforce_start_constraint: | |
| return self._continuator.sample_sequence( | |
| prefix=None, | |
| length=length, | |
| constraints=constraints, | |
| ) | |
| if self._engine_kind == "context_bp": | |
| return self._continuator.sample_sequence( | |
| prefix=[], | |
| length=length, | |
| constraints=constraints, | |
| ) | |
| return self._continuator.sample_sequence( | |
| prefix=None, | |
| start_vp=self._continuator.get_start_vp(), | |
| length=length, | |
| constraints=constraints, | |
| relax_prefix_on_fail=False, | |
| relax_pos0_on_fail=False, | |
| ) | |
| def apply_settings( | |
| self, | |
| *, | |
| learn_input: bool | None = None, | |
| transposition: bool | None = None, | |
| forget_past: bool | None = None, | |
| keep_last_inputs: int | None = None, | |
| decay_mode: str | None = None, | |
| engine_kind: EngineKind | None = None, | |
| markov_order: int | None = None, | |
| ) -> None: | |
| with self._lock: | |
| if engine_kind is not None and engine_kind not in ENGINE_KINDS: | |
| raise ValueError(f"Unknown Continuator engine kind: {engine_kind}") | |
| rebuild_required = ( | |
| (markov_order is not None and markov_order != self._markov_order) | |
| or (engine_kind is not None and engine_kind != self._engine_kind) | |
| ) | |
| preserved_payloads: list[PhrasePayload] = [] | |
| preserved_seed_count = self._seed_sequence_count | |
| if rebuild_required: | |
| preserved_payloads, preserved_seed_count = self.get_memory_snapshot() | |
| if learn_input is not None: | |
| self._default_learn_input = learn_input | |
| if transposition is not None: | |
| self._transposition = transposition | |
| if forget_past is not None: | |
| self._forget_past = forget_past | |
| if keep_last_inputs is not None: | |
| self._keep_last_inputs = keep_last_inputs | |
| if decay_mode is not None: | |
| self._decay_mode = decay_mode | |
| if engine_kind is not None: | |
| self._engine_kind = engine_kind | |
| if markov_order is not None: | |
| self._markov_order = markov_order | |
| if rebuild_required: | |
| self._continuator = self._create_engine(load_seed_material=False) | |
| for payload in preserved_payloads: | |
| phrase_events = [MidiEvent.model_validate(event) for event in payload.events] | |
| try: | |
| self._learn_phrase_events_locked(phrase_events, transpose=False) | |
| except NoContinuationAvailable: | |
| continue | |
| self._seed_sequence_count = min(preserved_seed_count, len(preserved_payloads)) | |
| return | |
| if learn_input is not None: | |
| self._continuator.set_learn_input(learn_input) | |
| if transposition is not None: | |
| self._continuator.set_transpose(transposition) | |
| if forget_past is not None: | |
| self._continuator.set_forget(forget_past) | |
| if keep_last_inputs is not None: | |
| self._continuator.set_keep_last(keep_last_inputs) | |
| if decay_mode is not None: | |
| set_decay_mode = getattr(self._continuator, "set_decay_mode", None) | |
| if callable(set_decay_mode): | |
| set_decay_mode(decay_mode) | |
| def reset(self) -> None: | |
| with self._lock: | |
| self._continuator = self._create_engine() | |
| def _is_start_symbol(self, symbol: object) -> bool: | |
| try: | |
| start_symbol = self._continuator.get_start_vp() | |
| except Exception: | |
| return False | |
| if symbol is start_symbol: | |
| return True | |
| try: | |
| return bool(symbol == start_symbol) | |
| except Exception: | |
| return False | |
| def _is_end_symbol(self, symbol: object) -> bool: | |
| try: | |
| end_symbol = self._continuator.get_end_vp() | |
| except Exception: | |
| return False | |
| if symbol is end_symbol: | |
| return True | |
| try: | |
| return bool(symbol == end_symbol) | |
| except Exception: | |
| return False | |
| def _graph_symbol_key(self, symbol: object) -> str: | |
| if self._is_start_symbol(symbol): | |
| return "__start__" | |
| if self._is_end_symbol(symbol): | |
| return "__end__" | |
| if isinstance(symbol, str): | |
| normalized = symbol.strip() | |
| if normalized in {"START", "<START>"}: | |
| return "__start__" | |
| if normalized in {"END", "<END>"}: | |
| return "__end__" | |
| return f"{type(symbol).__module__}.{type(symbol).__qualname__}:{repr(symbol)}" | |
| def _graph_context_key(self, context: tuple[object, ...]) -> str: | |
| return "|".join(self._graph_symbol_key(symbol) for symbol in context) | |
| def _graph_symbol_label(self, symbol: object) -> str: | |
| if self._is_start_symbol(symbol): | |
| return "START" | |
| if self._is_end_symbol(symbol): | |
| return "END" | |
| if isinstance(symbol, str): | |
| normalized = symbol.strip() | |
| if normalized in {"START", "<START>"}: | |
| return "START" | |
| if normalized in {"END", "<END>"}: | |
| return "END" | |
| return normalized | |
| if isinstance(symbol, tuple) and len(symbol) == 4: | |
| try: | |
| pitch = int(symbol[0]) | |
| duration_bin = int(symbol[1]) | |
| overlaps_left = bool(symbol[2]) | |
| overlaps_right = bool(symbol[3]) | |
| suffixes = [] | |
| if duration_bin: | |
| suffixes.append(f"d{duration_bin}") | |
| if overlaps_left: | |
| suffixes.append("L") | |
| if overlaps_right: | |
| suffixes.append("R") | |
| suffix = f" {'/'.join(suffixes)}" if suffixes else "" | |
| return f"{_pitch_label(pitch)}{suffix}" | |
| except (TypeError, ValueError): | |
| pass | |
| if hasattr(symbol, "item"): | |
| try: | |
| scalar_value = symbol.item() | |
| except ValueError: | |
| scalar_value = None | |
| if isinstance(scalar_value, (bool, int, float, str)): | |
| return str(scalar_value) | |
| raw_label = repr(symbol) | |
| if raw_label.startswith("<") and raw_label.endswith(">"): | |
| return raw_label[1:-1].upper() | |
| return raw_label | |
| def _trace_symbol_label(self, symbol: object) -> str: | |
| if symbol is None: | |
| return "none" | |
| if isinstance(symbol, list): | |
| symbol = tuple(symbol) | |
| return self._graph_symbol_label(symbol) | |
| def _graph_context_label_lines(self, context: tuple[object, ...]) -> list[str]: | |
| if not context: | |
| return ["empty"] | |
| symbol_labels = [self._graph_symbol_label(symbol) for symbol in context] | |
| if len(symbol_labels) == 1: | |
| return [self._truncate_graph_label(symbol_labels[0], 24)] | |
| visible_labels = symbol_labels[-5:] | |
| prefix = "... " if len(symbol_labels) > len(visible_labels) else "" | |
| if len(visible_labels) == 2: | |
| label = f"{prefix}{visible_labels[0]} -> {visible_labels[1]}" | |
| else: | |
| label = f"{prefix}{' '.join(visible_labels[:-1])} -> {visible_labels[-1]}" | |
| return [self._truncate_graph_label(label, 30)] | |
| def _truncate_graph_label(label: str, limit: int) -> str: | |
| compact = " ".join(str(label).split()) | |
| if len(compact) <= limit: | |
| return compact | |
| return f"{compact[: max(1, limit - 3)]}..." | |
| def _wrap_graph_label(self, label: str, *, limit: int, max_lines: int) -> list[str]: | |
| words = " ".join(str(label).split()).split(" ") | |
| lines: list[str] = [] | |
| current = "" | |
| for word in words: | |
| candidate = word if not current else f"{current} {word}" | |
| if len(candidate) <= limit: | |
| current = candidate | |
| continue | |
| if current: | |
| lines.append(current) | |
| current = word | |
| if len(lines) >= max_lines - 1: | |
| break | |
| if current and len(lines) < max_lines: | |
| lines.append(current) | |
| if len(lines) == max_lines and " ".join(words) != " ".join(lines): | |
| lines[-1] = self._truncate_graph_label(lines[-1], limit) | |
| return [self._truncate_graph_label(line, limit) for line in lines] or [ | |
| self._truncate_graph_label(label, limit) | |
| ] | |
| def _extract_classic_graph_counts(self, store: object) -> dict[tuple[object, ...], Counter]: | |
| raw_contexts = getattr(store, "ctx_to_continuations", {}) | |
| unique_viewpoints = list(getattr(store, "all_unique_viewpoints", [])) | |
| now = getattr(store, "global_step", 0) | |
| if getattr(store, "decay_freeze_at", None) is not None: | |
| now = getattr(store, "decay_freeze_at") | |
| graph_counts: dict[tuple[object, ...], Counter] = {} | |
| for raw_context, multi_counter in raw_contexts.items(): | |
| weights = {} | |
| counter_weights = getattr(multi_counter, "weights", None) | |
| if callable(counter_weights): | |
| weights = counter_weights(now, self._decay_mode) | |
| elif hasattr(multi_counter, "full"): | |
| weights = getattr(multi_counter, "full") | |
| edge_counts = Counter() | |
| for raw_index, raw_weight in weights.items(): | |
| try: | |
| symbol = unique_viewpoints[int(raw_index)] | |
| weight = float(raw_weight) | |
| except (IndexError, TypeError, ValueError): | |
| continue | |
| if weight > 0: | |
| edge_counts[symbol] += weight | |
| if edge_counts: | |
| graph_counts[tuple(raw_context)] = edge_counts | |
| return graph_counts | |
| def _extract_context_bp_graph_counts(model: object) -> dict[tuple[object, ...], Counter]: | |
| vocabulary = getattr(model, "vocabulary", None) | |
| raw_counts = getattr(getattr(model, "counts", None), "counts", {}) | |
| if vocabulary is None: | |
| return {} | |
| decode = getattr(vocabulary, "decode", None) | |
| if not callable(decode): | |
| return {} | |
| graph_counts: dict[tuple[object, ...], Counter] = {} | |
| for raw_context, raw_counter in raw_counts.items(): | |
| context = tuple(decode(symbol_id) for symbol_id in raw_context) | |
| counter = Counter() | |
| for raw_symbol, raw_weight in raw_counter.items(): | |
| weight = float(raw_weight) | |
| if weight > 0: | |
| counter[decode(raw_symbol)] += weight | |
| if counter: | |
| graph_counts[context] = counter | |
| return graph_counts | |
| def _extract_order_model_graph_counts(model: object) -> dict[tuple[object, ...], Counter]: | |
| raw_counts = getattr(model, "counts", {}) | |
| graph_counts: dict[tuple[object, ...], Counter] = {} | |
| for raw_context, raw_counter in raw_counts.items(): | |
| counter = Counter() | |
| for symbol, raw_weight in raw_counter.items(): | |
| weight = float(raw_weight) | |
| if weight > 0: | |
| counter[symbol] += weight | |
| if counter: | |
| graph_counts[tuple(raw_context)] = counter | |
| return graph_counts | |
| def _graph_counts(self) -> tuple[dict[tuple[object, ...], Counter], str]: | |
| store = self._midi_store() | |
| if store is not None and hasattr(store, "ctx_to_continuations"): | |
| return self._extract_classic_graph_counts(store), "Classic variable-order model" | |
| context_model = getattr(self._continuator, "context_model", None) | |
| if context_model is not None: | |
| return self._extract_context_bp_graph_counts(context_model), "Context BP model" | |
| order_model = getattr(self._continuator, "order_model", None) | |
| if order_model is not None: | |
| return self._extract_order_model_graph_counts(order_model), "VO regular BP order model" | |
| return {}, "Unknown model" | |
| def _graph_focus_symbol_keys( | |
| self, | |
| focus_symbols: list[object] | None, | |
| ) -> set[str]: | |
| keys: set[str] = set() | |
| for symbol in focus_symbols or []: | |
| if isinstance(symbol, list): | |
| symbol = tuple(symbol) | |
| keys.add(self._graph_symbol_key(symbol)) | |
| return keys | |
| def _graph_context_has_focus( | |
| self, | |
| context: tuple[object, ...], | |
| focus_symbol_keys: set[str], | |
| ) -> bool: | |
| if not focus_symbol_keys: | |
| return False | |
| return any(self._graph_symbol_key(symbol) in focus_symbol_keys for symbol in context) | |
| def _successor_context( | |
| context: tuple[object, ...], | |
| symbol: object, | |
| all_contexts: set[tuple[object, ...]], | |
| kmax: int, | |
| ) -> tuple[object, ...]: | |
| tokens = (*context, symbol) | |
| for order in range(min(kmax, len(tokens)), 0, -1): | |
| candidate = tuple(tokens[-order:]) | |
| if candidate in all_contexts: | |
| return candidate | |
| return (symbol,) | |
| def render_graph_svg( | |
| self, | |
| *, | |
| max_nodes: int = 96, | |
| max_edges: int = 220, | |
| graph_mode: str = "slice", | |
| order_filter: int | None = None, | |
| focus_symbols: list[object] | None = None, | |
| ) -> str: | |
| with self._lock: | |
| graph_counts, model_label = self._graph_counts() | |
| memory_count = len(self._input_sequences()) | |
| kmax = max(1, int(self._markov_order)) | |
| max_nodes = max(8, min(240, int(max_nodes))) | |
| max_edges = max(1, min(600, int(max_edges))) | |
| graph_mode = graph_mode if graph_mode in GRAPH_SELECTION_MODES else "slice" | |
| if order_filter is not None: | |
| order_filter = max(1, min(kmax, int(order_filter))) | |
| graph_counts = { | |
| context: counter | |
| for context, counter in graph_counts.items() | |
| if len(context) == order_filter | |
| } | |
| edges: list[_GraphEdge] = [] | |
| all_contexts = set(graph_counts) | |
| for context, continuations in graph_counts.items(): | |
| for symbol, weight in continuations.items(): | |
| if weight <= 0: | |
| continue | |
| target = self._successor_context(context, symbol, all_contexts, kmax) | |
| edges.append( | |
| _GraphEdge( | |
| source=context, | |
| target=target, | |
| symbol=symbol, | |
| weight=float(weight), | |
| ) | |
| ) | |
| return self._render_graph_svg( | |
| model_label=model_label, | |
| graph_counts=graph_counts, | |
| edges=edges, | |
| memory_count=memory_count, | |
| max_nodes=max_nodes, | |
| max_edges=max_edges, | |
| graph_mode=graph_mode, | |
| order_filter=order_filter, | |
| focus_symbols=focus_symbols, | |
| ) | |
| def _render_graph_svg( | |
| self, | |
| *, | |
| model_label: str, | |
| graph_counts: dict[tuple[object, ...], Counter], | |
| edges: list[_GraphEdge], | |
| memory_count: int, | |
| max_nodes: int, | |
| max_edges: int, | |
| graph_mode: str, | |
| order_filter: int | None, | |
| focus_symbols: list[object] | None, | |
| ) -> str: | |
| context_counts = defaultdict(int) | |
| for context in graph_counts: | |
| context_counts[len(context)] += 1 | |
| total_contexts = len(graph_counts) | |
| total_edges = len(edges) | |
| selected_nodes: dict[str, tuple[object, ...]] = {} | |
| selected_edges: list[_GraphEdge] = [] | |
| sorted_contexts = sorted( | |
| graph_counts, | |
| key=lambda candidate: ( | |
| len(candidate), | |
| self._graph_context_key(candidate), | |
| ), | |
| ) | |
| def add_node(context: tuple[object, ...]) -> bool: | |
| key = self._graph_context_key(context) | |
| if key in selected_nodes: | |
| return True | |
| if len(selected_nodes) >= max_nodes: | |
| return False | |
| selected_nodes[key] = context | |
| return True | |
| def add_edge(edge: _GraphEdge) -> bool: | |
| if len(selected_edges) >= max_edges: | |
| return False | |
| source_key = self._graph_context_key(edge.source) | |
| target_key = self._graph_context_key(edge.target) | |
| needed = int(source_key not in selected_nodes) + int(target_key not in selected_nodes) | |
| if len(selected_nodes) + needed > max_nodes: | |
| return False | |
| add_node(edge.source) | |
| add_node(edge.target) | |
| selected_edges.append(edge) | |
| return True | |
| default_edge_key = lambda edge: ( | |
| 0 if len(edge.source) == 1 else 1, | |
| -edge.weight, | |
| len(edge.source), | |
| self._graph_context_key(edge.source), | |
| self._graph_context_key(edge.target), | |
| ) | |
| if graph_mode == "all": | |
| for context in sorted_contexts: | |
| if not add_node(context): | |
| break | |
| for edge in sorted(edges, key=default_edge_key): | |
| if len(selected_edges) >= max_edges: | |
| break | |
| if ( | |
| self._graph_context_key(edge.source) in selected_nodes | |
| and self._graph_context_key(edge.target) in selected_nodes | |
| ): | |
| selected_edges.append(edge) | |
| elif graph_mode == "most_used": | |
| for edge in sorted( | |
| edges, | |
| key=lambda edge: ( | |
| -edge.weight, | |
| len(edge.source), | |
| self._graph_context_key(edge.source), | |
| self._graph_context_key(edge.target), | |
| ), | |
| ): | |
| add_edge(edge) | |
| elif graph_mode == "neighborhood": | |
| focus_symbol_keys = self._graph_focus_symbol_keys(focus_symbols) | |
| if focus_symbol_keys: | |
| focused_contexts = [ | |
| context | |
| for context in sorted_contexts | |
| if self._graph_context_has_focus(context, focus_symbol_keys) | |
| ] | |
| for context in focused_contexts: | |
| add_node(context) | |
| for edge in sorted( | |
| edges, | |
| key=lambda edge: ( | |
| -int(self._graph_context_has_focus(edge.source, focus_symbol_keys)), | |
| -int(self._graph_context_has_focus(edge.target, focus_symbol_keys)), | |
| -edge.weight, | |
| len(edge.source), | |
| self._graph_context_key(edge.source), | |
| self._graph_context_key(edge.target), | |
| ), | |
| ): | |
| source_focused = self._graph_context_has_focus(edge.source, focus_symbol_keys) | |
| target_focused = self._graph_context_has_focus(edge.target, focus_symbol_keys) | |
| if source_focused or target_focused: | |
| add_edge(edge) | |
| if not selected_nodes: | |
| graph_mode = "slice" | |
| if graph_mode == "slice": | |
| for context in sorted_contexts: | |
| if len(context) > 1: | |
| break | |
| if not add_node(context): | |
| break | |
| for edge in sorted(edges, key=default_edge_key): | |
| if len(selected_edges) >= max_edges: | |
| break | |
| add_edge(edge) | |
| width = 1080 | |
| if not selected_nodes: | |
| height = 520 | |
| return self._render_empty_graph_svg( | |
| width=width, | |
| height=height, | |
| model_label=model_label, | |
| memory_count=memory_count, | |
| ) | |
| nodes_by_order: dict[int, list[tuple[object, ...]]] = defaultdict(list) | |
| for context in selected_nodes.values(): | |
| nodes_by_order[len(context)].append(context) | |
| for contexts in nodes_by_order.values(): | |
| contexts.sort(key=lambda context: self._graph_context_key(context)) | |
| orders = sorted(nodes_by_order) | |
| left_margin = 54 | |
| top_margin = 120 | |
| node_width = 106 | |
| node_height = 20 | |
| column_gap = max(110, (width - left_margin * 2 - node_width) / max(1, len(orders) - 1)) | |
| row_gap = 7 | |
| max_column_size = max(len(contexts) for contexts in nodes_by_order.values()) | |
| height = max(560, top_margin + max_column_size * (node_height + row_gap) + 84) | |
| node_positions: dict[str, tuple[float, float]] = {} | |
| for column_index, order in enumerate(orders): | |
| contexts = nodes_by_order[order] | |
| column_x = left_margin + column_index * column_gap | |
| used_height = len(contexts) * node_height + max(0, len(contexts) - 1) * row_gap | |
| start_y = top_margin + max(0, (height - top_margin - 86 - used_height) / 2) | |
| for row_index, context in enumerate(contexts): | |
| node_positions[self._graph_context_key(context)] = ( | |
| column_x, | |
| start_y + row_index * (node_height + row_gap), | |
| ) | |
| max_weight = max((edge.weight for edge in selected_edges), default=1.0) | |
| svg_parts = [ | |
| '<svg xmlns="http://www.w3.org/2000/svg" ' | |
| f'viewBox="0 0 {width} {height}" width="{width}" height="{height}" ' | |
| 'role="img" aria-labelledby="title desc">', | |
| "<title id=\"title\">Continuator internal context graph</title>", | |
| ( | |
| "<desc id=\"desc\">A static graph snapshot of learned Continuator " | |
| "contexts and continuations.</desc>" | |
| ), | |
| "<style>", | |
| "svg{background:#071217;color:#ecf4ef;font-family:Avenir Next,Segoe UI,Trebuchet MS,sans-serif}", | |
| ".title{fill:#ecf4ef;font-size:28px;font-weight:750}", | |
| ".meta{fill:#9bb8b1;font-size:13px}", | |
| ".order{fill:#6dd3ce;font-size:12px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}", | |
| ".node{stroke-width:.9;filter:url(#shadow)}", | |
| ".node-label{fill:#ecf4ef;font-size:5px;font-weight:700}", | |
| ".edge{fill:none;stroke:#6dd3ce;stroke-opacity:.38}", | |
| ".edge-label{fill:#f7d3b9;font-size:5px;font-weight:800}", | |
| ".footer{fill:#9bb8b1;font-size:12px}", | |
| "</style>", | |
| "<defs>", | |
| ( | |
| '<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">' | |
| '<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity=".28"/>' | |
| "</filter>" | |
| ), | |
| ( | |
| '<marker id="arrow" viewBox="0 0 10 10" refX="8.5" refY="5" ' | |
| 'markerWidth="6" markerHeight="6" orient="auto-start-reverse">' | |
| '<path d="M 0 0 L 10 5 L 0 10 z" fill="#6dd3ce" fill-opacity=".72"/>' | |
| "</marker>" | |
| ), | |
| "</defs>", | |
| '<rect x="0" y="0" width="100%" height="100%" fill="#071217"/>', | |
| ( | |
| '<rect x="24" y="24" width="1032" height="72" rx="18" ' | |
| 'fill="#ffffff" fill-opacity=".035" stroke="#ffffff" stroke-opacity=".08"/>' | |
| ), | |
| '<text class="title" x="52" y="58">Continuator Memory Graph</text>', | |
| ( | |
| f'<text class="meta" x="52" y="80">{escape(model_label)} - ' | |
| f'{memory_count} learned phrase{"s" if memory_count != 1 else ""} - ' | |
| f'{total_contexts} contexts - {total_edges} transitions</text>' | |
| ), | |
| ] | |
| for column_index, order in enumerate(orders): | |
| x = left_margin + column_index * column_gap | |
| svg_parts.append( | |
| f'<text class="order" x="{x:.1f}" y="116">ORDER {order}</text>' | |
| ) | |
| for edge in selected_edges: | |
| source_key = self._graph_context_key(edge.source) | |
| target_key = self._graph_context_key(edge.target) | |
| source_position = node_positions.get(source_key) | |
| target_position = node_positions.get(target_key) | |
| if source_position is None or target_position is None: | |
| continue | |
| sx = source_position[0] + node_width | |
| sy = source_position[1] + node_height / 2 | |
| tx = target_position[0] | |
| ty = target_position[1] + node_height / 2 | |
| if tx <= sx: | |
| sx = source_position[0] + node_width / 2 | |
| tx = target_position[0] + node_width / 2 | |
| curve = max(64, abs(ty - sy) * 0.5 + 54) | |
| path = f"M {sx:.1f} {sy:.1f} C {sx + curve:.1f} {sy - curve:.1f}, {tx + curve:.1f} {ty + curve:.1f}, {tx:.1f} {ty:.1f}" | |
| else: | |
| curve = max(62, (tx - sx) * 0.42) | |
| path = f"M {sx:.1f} {sy:.1f} C {sx + curve:.1f} {sy:.1f}, {tx - curve:.1f} {ty:.1f}, {tx:.1f} {ty:.1f}" | |
| stroke_width = 1.2 + 4.0 * (edge.weight / max_weight) ** 0.5 | |
| svg_parts.append( | |
| f'<path class="edge" d="{path}" stroke-width="{stroke_width:.2f}" ' | |
| 'marker-end="url(#arrow)"/>' | |
| ) | |
| if len(selected_edges) <= 90: | |
| lx = (sx + tx) / 2 | |
| ly = (sy + ty) / 2 - 5 | |
| svg_parts.append( | |
| f'<text class="edge-label" x="{lx:.1f}" y="{ly:.1f}" ' | |
| f'text-anchor="middle">{escape(_format_graph_weight(edge.weight))}</text>' | |
| ) | |
| for key, context in selected_nodes.items(): | |
| x, y = node_positions[key] | |
| first_symbol = context[0] if context else None | |
| last_symbol = context[-1] if context else None | |
| if self._is_start_symbol(first_symbol): | |
| fill = "#14393d" | |
| stroke = "#6dd3ce" | |
| elif self._is_end_symbol(last_symbol): | |
| fill = "#3b241d" | |
| stroke = "#f4a261" | |
| else: | |
| fill = "#12262d" | |
| stroke = "#ffffff" | |
| svg_parts.append( | |
| f'<rect class="node" x="{x:.1f}" y="{y:.1f}" width="{node_width}" ' | |
| f'height="{node_height}" rx="5" fill="{fill}" stroke="{stroke}"/>' | |
| ) | |
| label_lines = self._graph_context_label_lines(context) | |
| svg_parts.append( | |
| _svg_text_lines( | |
| label_lines[:1], | |
| x=x + 6, | |
| y=y + 13, | |
| class_name="node-label", | |
| line_height=6, | |
| ) | |
| ) | |
| truncated_nodes = len(selected_nodes) < total_contexts | |
| truncated_edges = len(selected_edges) < total_edges | |
| mode_labels = { | |
| "slice": "weighted slice", | |
| "all": "all contexts", | |
| "most_used": "most-used transitions", | |
| "neighborhood": "latest phrase neighborhood", | |
| } | |
| mode_note = f" - mode: {mode_labels.get(graph_mode, graph_mode)}" | |
| if order_filter is not None: | |
| mode_note += f", order {order_filter}" | |
| truncation_note = "" | |
| if truncated_nodes or truncated_edges: | |
| truncation_note = ( | |
| " - showing visible subset" | |
| f" ({len(selected_nodes)}/{total_contexts} contexts, " | |
| f"{len(selected_edges)}/{total_edges} transitions)" | |
| ) | |
| order_summary = ", ".join( | |
| f"{order}:{count}" for order, count in sorted(context_counts.items()) | |
| ) | |
| svg_parts.append( | |
| f'<text class="footer" x="52" y="{height - 34}">' | |
| f'{escape(f"Contexts by order {order_summary}{mode_note}{truncation_note}")}</text>' | |
| ) | |
| svg_parts.append("</svg>") | |
| return "\n".join(svg_parts) | |
| def _render_empty_graph_svg( | |
| self, | |
| *, | |
| width: int, | |
| height: int, | |
| model_label: str, | |
| memory_count: int, | |
| ) -> str: | |
| return "\n".join( | |
| [ | |
| '<svg xmlns="http://www.w3.org/2000/svg" ' | |
| f'viewBox="0 0 {width} {height}" width="{width}" height="{height}" ' | |
| 'role="img" aria-labelledby="title desc">', | |
| "<title id=\"title\">Continuator internal context graph</title>", | |
| ( | |
| "<desc id=\"desc\">The Continuator graph is empty because no " | |
| "state transitions have been learned yet.</desc>" | |
| ), | |
| "<style>", | |
| "svg{background:#071217;font-family:Avenir Next,Segoe UI,Trebuchet MS,sans-serif}", | |
| ".title{fill:#ecf4ef;font-size:30px;font-weight:750}", | |
| ".meta{fill:#9bb8b1;font-size:14px}", | |
| ".copy{fill:#ecf4ef;font-size:18px;font-weight:700}", | |
| ".hint{fill:#9bb8b1;font-size:14px}", | |
| "</style>", | |
| '<rect x="0" y="0" width="100%" height="100%" fill="#071217"/>', | |
| ( | |
| '<rect x="80" y="88" width="920" height="344" rx="28" ' | |
| 'fill="#ffffff" fill-opacity=".04" stroke="#ffffff" stroke-opacity=".1"/>' | |
| ), | |
| '<text class="title" x="120" y="148">Continuator Memory Graph</text>', | |
| ( | |
| f'<text class="meta" x="120" y="174">{escape(model_label)} - ' | |
| f'{memory_count} learned phrase{"s" if memory_count != 1 else ""}</text>' | |
| ), | |
| '<text class="copy" x="120" y="252">No transitions learned yet.</text>', | |
| ( | |
| '<text class="hint" x="120" y="282">Play or import a MIDI phrase, ' | |
| "then open the graph again.</text>" | |
| ), | |
| "</svg>", | |
| ] | |
| ) | |
| def render_constraint_graph_svg( | |
| self, | |
| *, | |
| trace: list[GenerationTraceStep] | None, | |
| constraints: GenerationConstraintsStatus | None, | |
| request_id: str | None = None, | |
| created_at: str | None = None, | |
| max_steps: int = 96, | |
| ) -> str: | |
| with self._lock: | |
| max_steps = max(1, min(240, int(max_steps))) | |
| return self._render_constraint_graph_svg( | |
| trace=trace or [], | |
| constraints=constraints, | |
| request_id=request_id, | |
| created_at=created_at, | |
| max_steps=max_steps, | |
| ) | |
| def _render_constraint_graph_svg( | |
| self, | |
| *, | |
| trace: list[GenerationTraceStep], | |
| constraints: GenerationConstraintsStatus | None, | |
| request_id: str | None, | |
| created_at: str | None, | |
| max_steps: int, | |
| ) -> str: | |
| width = 1080 | |
| shown_trace = trace[:max_steps] | |
| columns = 5 | |
| node_width = 172 | |
| node_height = 104 | |
| left_margin = 58 | |
| top_margin = 174 | |
| column_gap = 32 | |
| row_gap = 58 | |
| row_count = max(1, (len(shown_trace) + columns - 1) // columns) | |
| height = max(560, top_margin + row_count * (node_height + row_gap) + 76) | |
| context = [ | |
| f"request {request_id[:8]}" if request_id else "no request id", | |
| created_at or "not generated yet", | |
| f"{len(trace)} trace step{'s' if len(trace) != 1 else ''}", | |
| ] | |
| if len(shown_trace) < len(trace): | |
| context.append(f"showing first {len(shown_trace)}") | |
| svg_parts = [ | |
| '<svg xmlns="http://www.w3.org/2000/svg" ' | |
| f'viewBox="0 0 {width} {height}" width="{width}" height="{height}" ' | |
| 'role="img" aria-labelledby="title desc">', | |
| "<title id=\"title\">Continuator constrained generation graph</title>", | |
| ( | |
| "<desc id=\"desc\">A static graph snapshot of the latest constrained " | |
| "generation trace.</desc>" | |
| ), | |
| "<style>", | |
| "svg{background:#071217;color:#ecf4ef;font-family:Avenir Next,Segoe UI,Trebuchet MS,sans-serif}", | |
| ".title{fill:#ecf4ef;font-size:28px;font-weight:750}", | |
| ".meta{fill:#9bb8b1;font-size:13px}", | |
| ".badge{fill:#12262d;stroke:#ffffff;stroke-opacity:.14;stroke-width:1}", | |
| ".badge-text{fill:#ecf4ef;font-size:12px;font-weight:800}", | |
| ".node{fill:#10252d;stroke:#f4a261;stroke-opacity:.76;stroke-width:1.3;filter:url(#shadow)}", | |
| ".node-subtle{fill:#9bb8b1;font-size:10px}", | |
| ".node-label{fill:#ecf4ef;font-size:12px;font-weight:800}", | |
| ".node-detail{fill:#f7d3b9;font-size:10px;font-weight:700}", | |
| ".edge{fill:none;stroke:#f4a261;stroke-opacity:.58;stroke-width:2.2}", | |
| ".empty-title{fill:#ecf4ef;font-size:22px;font-weight:800}", | |
| ".empty-copy{fill:#9bb8b1;font-size:14px}", | |
| ".footer{fill:#9bb8b1;font-size:12px}", | |
| "</style>", | |
| "<defs>", | |
| ( | |
| '<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">' | |
| '<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity=".28"/>' | |
| "</filter>" | |
| ), | |
| ( | |
| '<marker id="arrow" viewBox="0 0 10 10" refX="8.5" refY="5" ' | |
| 'markerWidth="6" markerHeight="6" orient="auto-start-reverse">' | |
| '<path d="M 0 0 L 10 5 L 0 10 z" fill="#f4a261" fill-opacity=".86"/>' | |
| "</marker>" | |
| ), | |
| "</defs>", | |
| '<rect x="0" y="0" width="100%" height="100%" fill="#071217"/>', | |
| ( | |
| '<rect x="24" y="24" width="1032" height="112" rx="18" ' | |
| 'fill="#ffffff" fill-opacity=".035" stroke="#ffffff" stroke-opacity=".08"/>' | |
| ), | |
| '<text class="title" x="52" y="58">Last Constraint Graph</text>', | |
| f'<text class="meta" x="52" y="82">{escape(" - ".join(context))}</text>', | |
| ] | |
| badge_data = [ | |
| ("Start", constraints.start if constraints else None), | |
| ("End", constraints.end if constraints else None), | |
| ] | |
| for index, (label, state) in enumerate(badge_data): | |
| x = 52 + index * 240 | |
| y = 100 | |
| value = _constraint_state_label(state) | |
| if state and state.value: | |
| value = f"{value}: {self._truncate_graph_label(str(state.value), 22)}" | |
| svg_parts.append( | |
| f'<rect class="badge" x="{x}" y="{y}" width="218" height="24" rx="12"/>' | |
| ) | |
| svg_parts.append( | |
| f'<text class="badge-text" x="{x + 12}" y="{y + 16}">' | |
| f'{escape(f"{label} {value}")}</text>' | |
| ) | |
| if not shown_trace: | |
| svg_parts.extend( | |
| [ | |
| ( | |
| '<rect x="80" y="190" width="920" height="250" rx="28" ' | |
| 'fill="#ffffff" fill-opacity=".04" stroke="#ffffff" stroke-opacity=".1"/>' | |
| ), | |
| '<text class="empty-title" x="120" y="260">No constrained generation trace yet.</text>', | |
| ( | |
| '<text class="empty-copy" x="120" y="292">Generate from memory or send a phrase, ' | |
| "then open this tab again.</text>" | |
| ), | |
| ( | |
| '<text class="empty-copy" x="120" y="322">This view is kept separate from the ' | |
| "learned memory graph because it is tied to one generation request.</text>" | |
| ), | |
| "</svg>", | |
| ] | |
| ) | |
| return "\n".join(svg_parts) | |
| positions: list[tuple[float, float]] = [] | |
| for index, _step in enumerate(shown_trace): | |
| column = index % columns | |
| row = index // columns | |
| if row % 2 == 0: | |
| column_index = column | |
| else: | |
| column_index = columns - 1 - column | |
| x = left_margin + column_index * (node_width + column_gap) | |
| y = top_margin + row * (node_height + row_gap) | |
| positions.append((x, y)) | |
| for index in range(1, len(positions)): | |
| source_x, source_y = positions[index - 1] | |
| target_x, target_y = positions[index] | |
| sx = source_x + node_width / 2 | |
| sy = source_y + node_height | |
| tx = target_x + node_width / 2 | |
| ty = target_y | |
| if abs(source_y - target_y) < 1: | |
| sx = source_x + (node_width if target_x > source_x else 0) | |
| sy = source_y + node_height / 2 | |
| tx = target_x + (0 if target_x > source_x else node_width) | |
| ty = target_y + node_height / 2 | |
| curve = max(40, abs(tx - sx) * 0.28 + abs(ty - sy) * 0.18) | |
| path = ( | |
| f"M {sx:.1f} {sy:.1f} C {sx:.1f} {sy + curve:.1f}, " | |
| f"{tx:.1f} {ty - curve:.1f}, {tx:.1f} {ty:.1f}" | |
| ) | |
| if abs(source_y - target_y) < 1: | |
| path = ( | |
| f"M {sx:.1f} {sy:.1f} C {(sx + tx) / 2:.1f} {sy:.1f}, " | |
| f"{(sx + tx) / 2:.1f} {ty:.1f}, {tx:.1f} {ty:.1f}" | |
| ) | |
| svg_parts.append(f'<path class="edge" d="{path}" marker-end="url(#arrow)"/>') | |
| for index, step in enumerate(shown_trace): | |
| x, y = positions[index] | |
| symbol_label = self._truncate_graph_label( | |
| self._trace_symbol_label(step.symbol), | |
| 24, | |
| ) | |
| policy = step.policy or "generation" | |
| candidates = ", ".join( | |
| f"k{order}:{count}" | |
| for order, count in zip(step.candidate_orders, step.candidate_counts) | |
| ) | |
| if not candidates: | |
| candidates = f"k{step.order}" | |
| context_label = " / ".join( | |
| self._truncate_graph_label(self._trace_symbol_label(value), 10) | |
| for value in step.context[-3:] | |
| ) | |
| if not context_label: | |
| context_label = "free start" if step.order == 0 else "empty" | |
| svg_parts.append( | |
| f'<rect class="node" x="{x:.1f}" y="{y:.1f}" width="{node_width}" ' | |
| f'height="{node_height}" rx="16"/>' | |
| ) | |
| svg_parts.append( | |
| f'<text class="node-subtle" x="{x + 12:.1f}" y="{y + 18:.1f}">' | |
| f'{escape(f"position {step.position} - order {step.order}")}</text>' | |
| ) | |
| svg_parts.append( | |
| _svg_text_lines( | |
| self._wrap_graph_label(symbol_label, limit=22, max_lines=2), | |
| x=x + 12, | |
| y=y + 42, | |
| class_name="node-label", | |
| line_height=14, | |
| ) | |
| ) | |
| svg_parts.append( | |
| f'<text class="node-detail" x="{x + 12:.1f}" y="{y + 76:.1f}">' | |
| f'{escape(self._truncate_graph_label(policy, 24))}</text>' | |
| ) | |
| svg_parts.append( | |
| f'<text class="node-subtle" x="{x + 12:.1f}" y="{y + 94:.1f}">' | |
| f'{escape(self._truncate_graph_label(candidates, 28))}</text>' | |
| ) | |
| svg_parts.append( | |
| f'<title>{escape(f"context: {context_label}")}</title>' | |
| ) | |
| footer_copy = ( | |
| "Trace path after compiling/gating the generation constraints. " | |
| "This is distinct from the learned memory graph." | |
| ) | |
| svg_parts.append( | |
| f'<text class="footer" x="52" y="{height - 34}">' | |
| f"{escape(footer_copy)}</text>" | |
| ) | |
| svg_parts.append("</svg>") | |
| return "\n".join(svg_parts) | |
| def get_memory_snapshot(self) -> tuple[list[PhrasePayload], int]: | |
| with self._lock: | |
| sequences = self._input_sequences() | |
| payloads = [_build_phrase_payload(sequence) for sequence in sequences] | |
| seed_count = min(self._seed_sequence_count, len(payloads)) | |
| return payloads, seed_count | |
| def _learn_phrase_events_locked( | |
| self, | |
| phrase_events: list[MidiEvent], | |
| *, | |
| transpose: bool, | |
| ) -> PhrasePayload: | |
| messages = [_event_to_mido_message(event) for event in phrase_events] | |
| input_phrase = self._continuator.get_phrase_from_mido(messages) | |
| if not input_phrase: | |
| raise NoContinuationAvailable( | |
| "The stored phrase did not contain any complete notes to rebuild." | |
| ) | |
| self._continuator.learn_phrase(input_phrase, transpose) | |
| return _build_phrase_payload(input_phrase) | |
| def learn_phrase_events(self, phrase_events: list[MidiEvent]) -> PhrasePayload: | |
| with self._lock: | |
| return self._learn_phrase_events_locked( | |
| phrase_events, | |
| transpose=self._continuator.transpose, | |
| ) | |
| def replace_live_memory(self, payloads: list[PhrasePayload]) -> list[PhrasePayload]: | |
| with self._lock: | |
| self._continuator = self._create_engine(load_seed_material=True) | |
| rebuilt_payloads: list[PhrasePayload] = [] | |
| for payload in payloads: | |
| phrase_events = [MidiEvent.model_validate(event) for event in payload.events] | |
| try: | |
| rebuilt_payloads.append( | |
| self._learn_phrase_events_locked( | |
| phrase_events, | |
| transpose=False, | |
| ) | |
| ) | |
| except NoContinuationAvailable: | |
| continue | |
| return rebuilt_payloads | |
| def import_midi_files( | |
| self, | |
| midi_files: list[tuple[str, bytes]], | |
| ) -> tuple[list[ImportedMidiPhrase], list[str]]: | |
| with self._lock: | |
| imported: list[ImportedMidiPhrase] = [] | |
| skipped: list[str] = [] | |
| with TemporaryDirectory(prefix="continuator-midi-import-") as temp_dir: | |
| temp_root = Path(temp_dir) | |
| for index, (raw_name, raw_bytes) in enumerate(midi_files): | |
| file_name = _normalize_uploaded_file_name( | |
| raw_name, | |
| f"imported_{index + 1}.mid", | |
| ) | |
| suffix = Path(file_name).suffix.lower() | |
| if suffix not in {".mid", ".midi"} or not raw_bytes: | |
| skipped.append(file_name) | |
| continue | |
| temp_path = temp_root / f"upload_{index:04d}{suffix}" | |
| temp_path.write_bytes(raw_bytes) | |
| try: | |
| notes = list(self._continuator.extract_notes(str(temp_path))) | |
| except Exception: | |
| skipped.append(file_name) | |
| continue | |
| if not notes: | |
| skipped.append(file_name) | |
| continue | |
| self._continuator.learn_phrase(notes, self._continuator.transpose) | |
| imported.append( | |
| ImportedMidiPhrase( | |
| file_name=file_name, | |
| payload=_build_phrase_payload(notes), | |
| ) | |
| ) | |
| if not imported: | |
| raise MidiImportError("No importable MIDI files were found in the selection.") | |
| return imported, skipped | |
| def generate_phrase( | |
| self, | |
| note_count: int | None = None, | |
| enforce_start_constraint: bool = True, | |
| enforce_end_constraint: bool = True, | |
| ) -> tuple[ | |
| PhrasePayload, | |
| GenerationConstraintsStatus, | |
| list[GenerationTraceStep] | None, | |
| str | None, | |
| ]: | |
| with self._lock: | |
| if not self._input_sequences(): | |
| raise NoContinuationAvailable( | |
| "The Continuator memory is empty. Load MIDI or learn a phrase first." | |
| ) | |
| target_note_count = note_count or 12 | |
| status_messages: list[str] = [] | |
| constraints_status = GenerationConstraintsStatus( | |
| start=GenerationConstraintState( | |
| requested=enforce_start_constraint, | |
| applied=enforce_start_constraint, | |
| value="beginning marker" if enforce_start_constraint else None, | |
| reason=( | |
| None | |
| if enforce_start_constraint | |
| else "Start constraint was disabled." | |
| ), | |
| ), | |
| end=GenerationConstraintState( | |
| requested=enforce_end_constraint, | |
| applied=enforce_end_constraint, | |
| value="ending marker" if enforce_end_constraint else None, | |
| reason=None if enforce_end_constraint else "Ending constraint was disabled.", | |
| ), | |
| ) | |
| attempts: list[tuple[bool, bool]] = [] | |
| if enforce_start_constraint and enforce_end_constraint: | |
| attempts = [ | |
| (True, True), | |
| (False, True), | |
| (True, False), | |
| (False, False), | |
| ] | |
| elif enforce_start_constraint: | |
| attempts = [(True, False), (False, False)] | |
| elif enforce_end_constraint: | |
| attempts = [(False, True), (False, False)] | |
| else: | |
| attempts = [(False, False)] | |
| generated_sequence = None | |
| applied_start_constraint = False | |
| applied_end_constraint = False | |
| for attempt_start, attempt_end in attempts: | |
| constraints = ( | |
| {target_note_count: self._continuator.get_end_vp()} | |
| if attempt_end | |
| else {} | |
| ) | |
| generated_sequence = self._sample_memory_sequence( | |
| length=target_note_count + (1 if attempt_end else 0), | |
| constraints=constraints, | |
| enforce_start_constraint=attempt_start, | |
| ) | |
| if generated_sequence is None: | |
| continue | |
| applied_start_constraint = attempt_start | |
| applied_end_constraint = attempt_end | |
| break | |
| if generated_sequence is None: | |
| raise NoContinuationAvailable( | |
| "The Continuator could not generate a fresh phrase from the current memory." | |
| ) | |
| if enforce_start_constraint and not applied_start_constraint: | |
| constraints_status.start.applied = False | |
| constraints_status.start.relaxed = True | |
| constraints_status.start.reason = "The exact-start version had no solution." | |
| status_messages.append( | |
| "Generated from memory without the hard start constraint " | |
| "because the exact-start version had no solution." | |
| ) | |
| if enforce_end_constraint and not applied_end_constraint: | |
| constraints_status.end.applied = False | |
| constraints_status.end.relaxed = True | |
| constraints_status.end.reason = "The exact-ending version had no solution." | |
| status_messages.append( | |
| "Generated from memory without the hard end constraint " | |
| "because the exact-ending version had no solution." | |
| ) | |
| rendered_vp_sequence = generated_sequence | |
| ends_with_end_marker = bool( | |
| rendered_vp_sequence | |
| and rendered_vp_sequence[-1] == self._continuator.get_end_vp() | |
| ) | |
| if ends_with_end_marker: | |
| rendered_vp_sequence = rendered_vp_sequence[:-1] | |
| if not rendered_vp_sequence: | |
| raise NoContinuationAvailable( | |
| "The Continuator returned an empty phrase from the current memory." | |
| ) | |
| rendered_sequence = self._realize_vp_sequence( | |
| rendered_vp_sequence, | |
| force_ending_realization=ends_with_end_marker, | |
| ) | |
| trace_initial_context = ( | |
| [self._continuator.get_start_vp()] if applied_start_constraint else [] | |
| ) | |
| return ( | |
| _build_phrase_payload(rendered_sequence), | |
| constraints_status, | |
| self._generation_trace_or_path( | |
| generated_sequence, | |
| initial_context=trace_initial_context, | |
| ), | |
| " ".join(status_messages) or None, | |
| ) | |
| def continue_phrase( | |
| self, | |
| phrase_events: list[MidiEvent], | |
| learn_input: bool | None = None, | |
| continuation_note_count: int | None = None, | |
| enforce_end_constraint: bool = True, | |
| handoff_viewpoint: ViewpointSeed | None = None, | |
| ) -> tuple[ | |
| PhrasePayload, | |
| PhrasePayload, | |
| GenerationConstraintsStatus, | |
| list[GenerationTraceStep] | None, | |
| str | None, | |
| ]: | |
| with self._lock: | |
| messages = [_event_to_mido_message(event) for event in phrase_events] | |
| input_phrase = self._continuator.get_phrase_from_mido(messages) | |
| if not input_phrase: | |
| raise NoContinuationAvailable( | |
| "The incoming phrase did not contain any complete notes." | |
| ) | |
| should_learn = self._default_learn_input if learn_input is None else learn_input | |
| input_payload = _build_phrase_payload(input_phrase) | |
| target_note_count = continuation_note_count or len(input_phrase) | |
| if should_learn: | |
| self._continuator.learn_phrase(input_phrase, self._continuator.transpose) | |
| status_messages: list[str] = [] | |
| prefix_for_generation = input_phrase | |
| start_viewpoint = None | |
| requested_handoff_viewpoint = _schema_to_viewpoint(handoff_viewpoint) | |
| input_handoff_viewpoint = self._continuator.get_viewpoint(input_phrase[-1]) | |
| displayed_start_viewpoint = requested_handoff_viewpoint or input_handoff_viewpoint | |
| constraints_status = GenerationConstraintsStatus( | |
| start=GenerationConstraintState( | |
| requested=True, | |
| applied=True, | |
| value=_viewpoint_label(displayed_start_viewpoint), | |
| reason=( | |
| "Using preserved handoff viewpoint." | |
| if requested_handoff_viewpoint is not None | |
| else "Using final input viewpoint." | |
| ), | |
| ), | |
| end=GenerationConstraintState( | |
| requested=enforce_end_constraint, | |
| applied=enforce_end_constraint, | |
| value="ending marker" if enforce_end_constraint else None, | |
| reason=None if enforce_end_constraint else "Ending constraint was disabled.", | |
| ), | |
| ) | |
| if requested_handoff_viewpoint is not None: | |
| if self._has_viewpoint(requested_handoff_viewpoint): | |
| prefix_for_generation = None | |
| start_viewpoint = requested_handoff_viewpoint | |
| else: | |
| constraints_status.start.applied = False | |
| constraints_status.start.relaxed = True | |
| constraints_status.start.reason = ( | |
| "Preserved handoff viewpoint was outside the learned vocabulary." | |
| ) | |
| status_messages.append( | |
| "Ignored the preserved handoff viewpoint because it was outside " | |
| "the learned vocabulary." | |
| ) | |
| if start_viewpoint is None: | |
| if not self._has_viewpoint(input_handoff_viewpoint): | |
| prefix_for_generation = None | |
| constraints_status.start.applied = False | |
| constraints_status.start.relaxed = True | |
| constraints_status.start.reason = ( | |
| "Final input viewpoint was outside the learned vocabulary." | |
| ) | |
| status_messages.append( | |
| "Relaxed the continuation handoff because the final input state " | |
| "was outside the learned vocabulary." | |
| ) | |
| def sample_with_current_start(length: int, constraints: dict[int, object]): | |
| return self._continuator.sample_sequence( | |
| prefix=prefix_for_generation, | |
| start_vp=start_viewpoint, | |
| length=length, | |
| constraints=constraints, | |
| relax_prefix_on_fail=False, | |
| ) | |
| def sample_with_relaxed_start(length: int, constraints: dict[int, object]): | |
| return self._continuator.sample_sequence( | |
| prefix=None, | |
| start_vp=None, | |
| length=length, | |
| constraints=constraints, | |
| ) | |
| def relax_start_constraint(reason: str) -> None: | |
| if constraints_status.start.applied: | |
| constraints_status.start.applied = False | |
| constraints_status.start.relaxed = True | |
| constraints_status.start.reason = reason | |
| status_messages.append( | |
| "Relaxed the continuation handoff because the requested start " | |
| "had no valid continuation." | |
| ) | |
| if enforce_end_constraint: | |
| constraints = {target_note_count: self._continuator.get_end_vp()} | |
| generated_sequence = sample_with_current_start( | |
| target_note_count + 1, | |
| constraints, | |
| ) | |
| if generated_sequence is None and constraints_status.start.applied: | |
| relax_start_constraint( | |
| "The requested handoff had no exact-ending continuation." | |
| ) | |
| generated_sequence = sample_with_relaxed_start( | |
| target_note_count + 1, | |
| constraints, | |
| ) | |
| if generated_sequence is None: | |
| generated_sequence = ( | |
| sample_with_current_start(target_note_count, {}) | |
| if constraints_status.start.applied | |
| else sample_with_relaxed_start(target_note_count, {}) | |
| ) | |
| status_messages.append( | |
| "Used a same-length continuation without the hard end constraint " | |
| "because the exact-ending version had no solution." | |
| ) | |
| constraints_status.end.applied = False | |
| constraints_status.end.relaxed = True | |
| constraints_status.end.reason = "The exact-ending version had no solution." | |
| if generated_sequence is None: | |
| relax_start_constraint("The requested handoff had no valid continuation.") | |
| generated_sequence = sample_with_relaxed_start(target_note_count, {}) | |
| else: | |
| generated_sequence = sample_with_current_start(target_note_count, {}) | |
| if generated_sequence is None: | |
| relax_start_constraint("The requested handoff had no valid continuation.") | |
| generated_sequence = sample_with_relaxed_start(target_note_count, {}) | |
| if generated_sequence is None: | |
| raise NoContinuationAvailable("The Continuator could not find a valid continuation.") | |
| rendered_vp_sequence = generated_sequence | |
| ends_with_end_marker = bool( | |
| rendered_vp_sequence | |
| and rendered_vp_sequence[-1] == self._continuator.get_end_vp() | |
| ) | |
| if ends_with_end_marker: | |
| rendered_vp_sequence = rendered_vp_sequence[:-1] | |
| rendered_sequence = self._realize_vp_sequence( | |
| rendered_vp_sequence, | |
| force_ending_realization=ends_with_end_marker, | |
| ) | |
| status_message = " ".join(status_messages) or None | |
| trace_initial_context: list[object] = [] | |
| if constraints_status.start.applied: | |
| if start_viewpoint is not None: | |
| trace_initial_context = [start_viewpoint] | |
| elif prefix_for_generation is not None: | |
| trace_initial_context = [ | |
| self._continuator.get_viewpoint(note) for note in prefix_for_generation | |
| ] | |
| return ( | |
| input_payload, | |
| _build_phrase_payload(rendered_sequence), | |
| constraints_status, | |
| self._generation_trace_or_path( | |
| generated_sequence, | |
| initial_context=trace_initial_context, | |
| ), | |
| status_message, | |
| ) | |