"""`dialoguectl` — multi-voice dialogue playback (ElevenLabs Text to Dialogue). Normal replies are one voice saying one thing (audio/tts.py). This is the other mode: a short *scene* — two or more voices, with delivery tags the v3 model acts on (`[cheerfully]`, `[stuttering]`, `[whispering]`) — synthesized as a single take so the timing and reactions between lines actually sound like a conversation rather than clips glued together. Wire format, the same discipline as file_ops.py and for the same reason: it rides the server's ordinary `command` tool marker, whose extractor only captures up to the next newline, so the payload is a **single-line compact JSON object**. dialoguectl {"lines": [{"voice": "self", "text": "[cheerfully] Morning!"}, {"voice": "narrator", "text": "[whispering] He lies."}]} The ElevenLabs field names are accepted too (`inputs` / `voice_id`), because the model has read that API and copying its shape is the obvious thing to try: dialoguectl {"inputs": [{"voice_id": "9BWtsMINqrJLrRacOk9x", "text": "hi"}]} Voices are *named*, not pasted as ids. `DIALOGUE_VOICES` in .env maps names to ids (`narrator:9BWts…,villain:IKne3…`), and `self` always means the voice the pet is speaking with right now — including a voice the server picked mid-conversation with `speak_as`, so a scene featuring Bolt sounds like whoever Bolt currently is. Pure parsing and validation here; the HTTP call is `audio/tts.synthesize_dialogue` and the playback/state handling is `controller._play_dialogue`, matching the parse/execute split used by pet_actions.py and file_ops.py. The API's own limits are enforced *here*, before the request goes out, so a mistake comes back through the tool-result relay as a sentence Bolt can act on ("too many characters, split it") rather than as an HTTP 422 he can't see. """ from __future__ import annotations import json import re from typing import Iterable, Optional from . import relay_json _PREFIXES = ("dialoguectl", "dialogue", "scene") # ElevenLabs Text to Dialogue limits (docs, 2026-07): at most 10 distinct # voice ids per request and ~2000 characters across all inputs. MAX_VOICES = 10 MAX_CHARS = 2000 # Names that always mean "the voice the pet is using right now". SELF_NAMES = ("self", "bolt", "me", "pet") # A raw ElevenLabs voice id: 20 URL-safe characters, no separators. Used to # tell "the model pasted an id" from "the model used a name". _VOICE_ID_RE = re.compile(r"^[A-Za-z0-9]{20}$") class DialogueError(Exception): """Bad dialoguectl syntax or an unusable request — reported back to the server as this command's output.""" def is_dialogue_command(command: str) -> bool: parts = (command or "").strip().split(None, 1) return bool(parts) and parts[0].lower() in _PREFIXES def parse(command: str) -> Optional[dict]: """Parse `dialoguectl ` into {"lines": [{"voice", "text"}], ...}. Returns None if this isn't a dialogue command at all (the caller then tries filectl, then a real shell command). Raises DialogueError on a dialogue command that doesn't make sense.""" if not is_dialogue_command(command): return None _, _, payload = (command or "").strip().partition(" ") payload = payload.strip() if not payload: raise DialogueError( 'dialoguectl needs a JSON argument, e.g. dialoguectl {"lines": ' '[{"voice": "self", "text": "[cheerfully] hello"}]}' ) # Same lenient parse as filectl (see relay_json): machine-written JSON # fails in a handful of repeatable ways, and a stray quote shouldn't cost # a turn — but the repair is reported back rather than hidden. try: data, repairs = relay_json.loads(payload) except relay_json.RelayJsonError as exc: raise DialogueError( f"couldn't parse the JSON — {exc}\nIt must be one line of compact " "JSON — put line breaks inside text as \\n, never as real newlines." ) from exc if not isinstance(data, dict): raise DialogueError("the argument must be a JSON object, not a list or a bare value") raw_lines = data.get("lines") if raw_lines is None: raw_lines = data.get("inputs") # the ElevenLabs field name if not isinstance(raw_lines, list) or not raw_lines: raise DialogueError('needs a non-empty "lines" array of {"voice", "text"} objects') lines: list[dict] = [] for index, entry in enumerate(raw_lines, start=1): if not isinstance(entry, dict): raise DialogueError(f"line {index} must be an object with 'voice' and 'text'") text = str(entry.get("text") or "").strip() if not text: raise DialogueError(f"line {index} has no text") voice = str(entry.get("voice") or entry.get("voice_id") or "self").strip() lines.append({"voice": voice, "text": text}) action = {"action": "dialogue", "lines": lines} if repairs: action["_repairs"] = repairs model = str(data.get("model") or data.get("model_id") or "").strip() if model: action["model"] = model stability = data.get("stability") if stability is not None: try: action["stability"] = min(1.0, max(0.0, float(stability))) except (TypeError, ValueError): raise DialogueError("stability must be a number between 0 and 1") from None return action def parse_voice_map(spec: str) -> dict[str, str]: """Parse DIALOGUE_VOICES ("narrator:9BWts…, villain:IKne3…") into a map. Malformed entries are skipped rather than raising: a typo in .env should cost that one voice, not the whole feature.""" voices: dict[str, str] = {} for chunk in str(spec or "").split(","): name, separator, voice_id = chunk.partition(":") name, voice_id = name.strip().lower(), voice_id.strip() if separator and name and voice_id: voices[name] = voice_id return voices def resolve( action: dict, *, voices: Optional[dict] = None, self_voice: str = "", ) -> list[dict]: """Turn parsed lines into the API's `inputs`, resolving names to ids. *self_voice* is the pet's current voice (which may be a `speak_as` pick, not the configured default), so "self" tracks whoever Bolt sounds like right now.""" known = dict(voices or {}) resolved: list[dict] = [] for index, line in enumerate(action.get("lines") or [], start=1): name = str(line.get("voice") or "self") key = name.lower() if key in SELF_NAMES: voice_id = self_voice if not voice_id: raise DialogueError( "no voice is configured for the pet itself — set " "ELEVENLABS_VOICE_ID, or name a voice from DIALOGUE_VOICES" ) elif key in known: voice_id = known[key] elif _VOICE_ID_RE.match(name): voice_id = name # a raw id pasted straight from the voice library else: available = ", ".join(sorted(known) + list(SELF_NAMES[:1])) or "self" raise DialogueError( f"line {index}: unknown voice {name!r}. Known names: {available}. " "Use one of those, 'self' for your own voice, or a raw voice id." ) resolved.append({"text": str(line.get("text") or ""), "voice_id": voice_id}) check_limits(resolved) return resolved def check_limits(inputs: Iterable[dict], *, max_voices: int = MAX_VOICES, max_chars: int = MAX_CHARS) -> None: """Enforce the API's own limits before spending a request on a 422.""" entries = list(inputs) if not entries: raise DialogueError("no lines to speak") distinct = {entry["voice_id"] for entry in entries} if len(distinct) > max_voices: raise DialogueError( f"{len(distinct)} different voices — the limit is {max_voices} per scene" ) total = sum(len(entry["text"]) for entry in entries) if total > max_chars: raise DialogueError( f"{total} characters — the limit is {max_chars} per scene. " "Split it into two dialoguectl calls." ) def spoken_text(action: dict) -> str: """The scene as readable text, for the speech bubble and the transcript. Delivery tags are stripped: `[cheerfully]` is a stage direction for the model, not something to show (or, via tts.speak's sanitizer, to read out).""" parts = [] for line in action.get("lines") or []: text = re.sub(r"\[[^\]]{1,40}\]", " ", str(line.get("text") or "")) text = " ".join(text.split()) if text: parts.append(text) return " ".join(parts) def describe(action: dict, *, played: bool = True) -> str: """The tool-result string handed back to the server.""" lines = action.get("lines") or [] voices = sorted({str(line.get("voice") or "self") for line in lines}) note = relay_json.repair_note(action.get("_repairs") or []) if not played: return f"[dialogue] not played ({len(lines)} lines){note}" return ( f"[dialogue] played {len(lines)} line{'s' if len(lines) != 1 else ''} " f"in {len(voices)} voice{'s' if len(voices) != 1 else ''}: {', '.join(voices)}{note}" )