220 lines
8.8 KiB
Python
220 lines
8.8 KiB
Python
"""`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
|
|
|
|
_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 <json>` 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"}]}'
|
|
)
|
|
try:
|
|
data = json.loads(payload)
|
|
except json.JSONDecodeError as exc:
|
|
raise DialogueError(
|
|
f"couldn't parse the JSON ({exc}). It 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}
|
|
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})
|
|
if not played:
|
|
return f"[dialogue] not played ({len(lines)} lines)"
|
|
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)}"
|
|
)
|