feat(tts): fold voice search into text_to_speech's ElevenLabs schema (find_voices)

Teknium: the ElevenLabs variant of text_to_speech is where voice search belongs, not a
separate tts_voices tool. The ElevenLabs schema now carries voice_id plus a find_voices
object (text optional there); other providers' schema is byte-identical to main.

Live search findings folded in: the library is the default source and sorts by a year of
usage (most-used first); use_case (e.g. characters_animation) and sort filters; library
voices with a custom credit rate show credit_rate; the description tells the model the
library matches words literally, so a role word that finds nothing ('dwarf') falls back to
describing the sound ('gravelly old' + filters).
This commit is contained in:
Teknium committed 2026-10-25 15:58:23 -07:00
1 parent 10a30ead42
commit f219708926
13 files changed
+259 -312

No files matched your search

+1 -1
View File
@@ -24,7 +24,7 @@ TOOL_KIND_MAP: dict[str, ToolKind] = {
"search": ("search_files",),
"execute": ("terminal", "process", "execute_code", "browser_click", "browser_type", "browser_scroll",
"browser_press", "browser_back", "delegate_task", "image_generate", "text_to_speech"),
"fetch": ("web_search", "web_extract", "browser_navigate", "tts_voices"),
"fetch": ("web_search", "web_extract", "browser_navigate"),
"other": ("todo",),
"think": ("_thinking",),
}.items()
+5 -3
View File
@@ -353,7 +353,7 @@ def _browser_exec_step_label(args: dict, max_chars: int = 80) -> str | None:
_PRIMARY_ARGS = {
"terminal": "command", "web_search": "query", "web_extract": "urls", "read_file": "path",
"write_file": "path", "patch": "path", "search_files": "pattern", "browser_navigate": "url",
"browser_click": "ref", "browser_type": "text", "image_generate": "prompt", "text_to_speech": "text", "tts_voices": "query",
"browser_click": "ref", "browser_type": "text", "image_generate": "prompt", "text_to_speech": "text",
"vision_analyze": "question", "skill_view": "name", "skills_list": "category", "cronjob_manage": "action",
"execute_code": "code", "browser_exec": "code", "delegate_task": "goal",
"skill_manage": "name",
@@ -1264,8 +1264,10 @@ _CUTE_LINES = {
"skills_list": lambda a, r: _cute_row("📚", "skills", t("display.cute.skills_list", category=a.get("category") or t("display.cute.skills_all"))),
"skill_view": _cute_skill_view,
"image_generate": lambda a, r: _cute_row("🎨", "create", _cute_trunc(a.get("prompt", ""))),
"text_to_speech": lambda a, r: _cute_row("🔊", "speak", _cute_trunc(a.get("text", ""))),
"tts_voices": lambda a, r: _cute_row("🗣️ ", "voices", _cute_trunc(a.get("query") or a.get("source") or "account")),
"text_to_speech": lambda a, r: (
_cute_row("🗣️ ", "voices", _cute_trunc((a.get("find_voices") or {}).get("query") or "browse"))
if isinstance(a.get("find_voices"), dict) and a["find_voices"]
else _cute_row("🔊", "speak", _cute_trunc(a.get("text", "")))),
"vision_analyze": lambda a, r: _cute_row("👁️ ", "vision", _cute_trunc(a.get("question", ""))),
"send_message": lambda a, r: _cute_row("📨", "send", f"{a.get('target', '?')}: \"{_cute_trunc(a.get('message', ''))}\""),
"cronjob_manage": _cute_cronjob,
+1 -1
View File
@@ -183,7 +183,7 @@ _LEGACY_TOOLSET_MAP = {
"skills_tools": ["skills_list", "skill_view", "skill_manage"],
"cronjob_tools": ["cronjob_manage"],
"file_tools": ["read_file", "write_file", "patch", "search_files"],
"tts_tools": ["text_to_speech", "tts_voices"],
"tts_tools": ["text_to_speech"],
}
+9 -18
View File
@@ -5,7 +5,7 @@
"stable": 1829,
"volatile": 1528
},
"tool_tokens": 11549,
"tool_tokens": 11242,
"tools": [
"browser_cdp",
"browser_dialog",
@@ -26,7 +26,6 @@
"tool_call",
"tool_describe",
"tool_search",
"tts_voices",
"vision_analyze",
"web_extract",
"web_search",
@@ -39,7 +38,7 @@
"stable": 1225,
"volatile": 1528
},
"tool_tokens": 11549,
"tool_tokens": 11242,
"tools": [
"browser_cdp",
"browser_dialog",
@@ -60,7 +59,6 @@
"tool_call",
"tool_describe",
"tool_search",
"tts_voices",
"vision_analyze",
"web_extract",
"web_search",
@@ -73,7 +71,7 @@
"stable": 1646,
"volatile": 1527
},
"tool_tokens": 11549,
"tool_tokens": 11242,
"tools": [
"browser_cdp",
"browser_dialog",
@@ -94,7 +92,6 @@
"tool_call",
"tool_describe",
"tool_search",
"tts_voices",
"vision_analyze",
"web_extract",
"web_search",
@@ -107,7 +104,7 @@
"stable": 2420,
"volatile": 1524
},
"tool_tokens": 11705,
"tool_tokens": 11398,
"tools": [
"browser_cdp",
"browser_dialog",
@@ -128,7 +125,6 @@
"tool_call",
"tool_describe",
"tool_search",
"tts_voices",
"vision_analyze",
"web_extract",
"web_search",
@@ -141,7 +137,7 @@
"stable": 1225,
"volatile": 1528
},
"tool_tokens": 11549,
"tool_tokens": 11242,
"tools": [
"browser_cdp",
"browser_dialog",
@@ -162,7 +158,6 @@
"tool_call",
"tool_describe",
"tool_search",
"tts_voices",
"vision_analyze",
"web_extract",
"web_search",
@@ -175,7 +170,7 @@
"stable": 1211,
"volatile": 1528
},
"tool_tokens": 11549,
"tool_tokens": 11242,
"tools": [
"browser_cdp",
"browser_dialog",
@@ -196,7 +191,6 @@
"tool_call",
"tool_describe",
"tool_search",
"tts_voices",
"vision_analyze",
"web_extract",
"web_search",
@@ -209,7 +203,7 @@
"stable": 1473,
"volatile": 1529
},
"tool_tokens": 12205,
"tool_tokens": 11898,
"tools": [
"browser_cdp",
"browser_dialog",
@@ -231,7 +225,6 @@
"tool_call",
"tool_describe",
"tool_search",
"tts_voices",
"vision_analyze",
"web_extract",
"web_search",
@@ -244,7 +237,7 @@
"stable": 1244,
"volatile": 1529
},
"tool_tokens": 11549,
"tool_tokens": 11242,
"tools": [
"browser_cdp",
"browser_dialog",
@@ -265,7 +258,6 @@
"tool_call",
"tool_describe",
"tool_search",
"tts_voices",
"vision_analyze",
"web_extract",
"web_search",
@@ -278,7 +270,7 @@
"stable": 1276,
"volatile": 1529
},
"tool_tokens": 11549,
"tool_tokens": 11242,
"tools": [
"browser_cdp",
"browser_dialog",
@@ -299,7 +291,6 @@
"tool_call",
"tool_describe",
"tool_search",
"tts_voices",
"vision_analyze",
"web_extract",
"web_search",
-46
View File
@@ -1,46 +0,0 @@
{
"description": "Search ElevenLabs voices to pick a voice_id for text_to_speech. source 'account' = the user's voices (premade, saved, cloned, designed); 'library' = the public Voice Library (thousands of community voices; paid plans only). Paginated: pass next_page back as page. You can't hear voices, so choose from the labels and description, and offer the user preview_url links when the pick matters.",
"name": "tts_voices",
"parameters": {
"properties": {
"accent": {
"description": "Filter, e.g. british, american, scottish.",
"type": "string"
},
"age": {
"description": "Filter, e.g. young, middle_aged, old.",
"type": "string"
},
"gender": {
"description": "Filter, e.g. male, female, neutral.",
"type": "string"
},
"language": {
"description": "Filter by ISO 639-1 code, e.g. en.",
"type": "string"
},
"page": {
"description": "next_page from the previous result.",
"type": "string"
},
"page_size": {
"description": "1-100, default 20.",
"type": "integer"
},
"query": {
"description": "Free-text search over names, descriptions and labels, e.g. 'gruff old dwarf'.",
"type": "string"
},
"source": {
"description": "Default account.",
"enum": [
"account",
"library"
],
"type": "string"
}
},
"required": [],
"type": "object"
}
}
+43 -49
View File
@@ -1,5 +1,5 @@
"""Model-selected ElevenLabs voices: the ``voice_id`` field on ``text_to_speech`` and the
``tts_voices`` browse/search tool exist only while ElevenLabs is the configured provider."""
"""Model-selected ElevenLabs voices: ``text_to_speech`` gains ``voice_id`` and ``find_voices`` (a
paginated voice search) only while ElevenLabs is the configured provider."""
import json
from unittest.mock import patch
@@ -7,7 +7,7 @@ from unittest.mock import patch
import httpx
import pytest
from tools import tts_tool, tts_voices_tool
from tools import tts_tool
_EL_CONFIG = {"provider": "elevenlabs", "elevenlabs": {"voice_id": "configuredVoice001"}}
@@ -15,15 +15,15 @@ _EL_CONFIG = {"provider": "elevenlabs", "elevenlabs": {"voice_id": "configuredVo
@pytest.mark.parametrize("config, exposed", [
(_EL_CONFIG, True), ({"provider": "edge"}, False), ({"provider": "openai"}, False)])
def test_voice_selection_surface_follows_provider(config, exposed):
with patch("tools.tts_tool._load_tts_config", return_value=config), \
patch("tools.tts_tool._resolve_provider_key", return_value="el-test-key"):
with patch("tools.tts_tool._load_tts_config", return_value=config):
schema = tts_tool._tts_schema_overrides()
assert ("voice_id" in schema["parameters"]["properties"]) is exposed
assert ("tts_voices" in schema.get("description", "")) is exposed
assert tts_voices_tool.check_tts_voices_requirements() is exposed
with patch("tools.tts_tool._load_tts_config", return_value=_EL_CONFIG), \
patch("tools.tts_tool._resolve_provider_key", return_value=""):
assert tts_voices_tool.check_tts_voices_requirements() is False
searched = json.loads(tts_tool.text_to_speech_tool("", find_voices={"query": "orc"}))
props = schema["parameters"]["properties"]
assert ("voice_id" in props) is exposed and ("find_voices" in props) is exposed
assert schema["parameters"]["required"] == ([] if exposed else ["text"])
assert ("find_voices" in schema.get("description", "")) is exposed
if not exposed: # a schema cached before a provider switch must not search
assert not searched["success"] and "elevenlabs" in searched["error"]
def test_voice_id_reaches_elevenlabs_and_is_ignored_elsewhere(tmp_path):
@@ -50,25 +50,28 @@ def test_voice_id_reaches_elevenlabs_and_is_ignored_elsewhere(tmp_path):
assert seen == ["pickedVoice0042", "configuredVoice001"]
assert _EL_CONFIG["elevenlabs"]["voice_id"] == "configuredVoice001" # cached config untouched
with patch("tools.tts_tool._load_tts_config", return_value={"provider": "edge"}):
config, error = tts_tool._apply_voice_override({"provider": "edge"}, "edge", "pickedVoice0042")
config, error = tts_tool._apply_voice_override({"provider": "edge"}, "edge", "pickedVoice0042")
assert error is None and "elevenlabs" not in config
@pytest.mark.parametrize("source, path, first_page, cursor_param, body, next_page", [
("account", "/v2/voices", "", "next_page_token",
@pytest.mark.parametrize("find, path, expected_params, body, next_page", [
({"query": "gravelly old", "gender": "Male", "use_case": "characters_animation", "page": "3",
"page_size": 500}, "/v1/shared-voices",
{"search": "gravelly old", "gender": "male", "use_cases": ["characters_animation"],
"sort": "usage_character_count_1y", "page": 3, "page_size": 100},
{"voices": [{"voice_id": "lib1", "name": "Gruff", "gender": "male", "age": "old",
"accent": "scottish", "descriptive": "gruff", "use_case": "characters_animation",
"category": "professional", "rate": 2, "description": "x" * 400}],
"has_more": True, "total_count": 812}, "4"),
({"query": "orc", "source": "account", "page": "tok-1"}, "/v2/voices",
{"search": "orc", "next_page_token": "tok-1", "page_size": 20},
{"voices": [{"voice_id": "acct1", "name": "Bram", "category": "premade",
"labels": {"gender": "male", "age": "old", "accent": "scottish",
"use case": "characters"},
"preview_url": "https://example.test/bram.mp3"}],
"has_more": True, "next_page_token": "tok-2"}, "tok-2"),
("library", "/v1/shared-voices", "3", "page",
{"voices": [{"voice_id": "lib1", "name": "Gruff", "gender": "male", "age": "old",
"accent": "scottish", "descriptive": "gruff", "use_case": "characters",
"category": "professional", "description": "x" * 400}],
"has_more": True, "total_count": 812}, "4"),
])
def test_tts_voices_pages_both_sources(source, path, first_page, cursor_param, body, next_page):
def test_find_voices_pages_both_sources(find, path, expected_params, body, next_page):
calls = []
def fake_get(url, params=None, headers=None, timeout=None):
@@ -77,49 +80,40 @@ def test_tts_voices_pages_both_sources(source, path, first_page, cursor_param, b
with patch("tools.tts_tool._load_tts_config", return_value=_EL_CONFIG), \
patch("tools.tts_tool._resolve_provider_key", return_value="el-test-key"), \
patch("tools.tts_voices_tool.httpx.get", side_effect=fake_get):
result = json.loads(tts_voices_tool.tts_voices_tool(
query="gruff dwarf", source=source, gender="Male", page=first_page, page_size=500))
patch("tools.tts_elevenlabs_voices.httpx.get", side_effect=fake_get):
result = json.loads(tts_tool.text_to_speech_tool("", find_voices=find))
bad_page = json.loads(tts_tool.text_to_speech_tool("", find_voices={"page": "tok-2"}))
url, params, headers = calls[0]
assert url == "https://api.elevenlabs.io" + path
assert headers["xi-api-key"] == "el-test-key"
assert params["search"] == "gruff dwarf" and params["gender"] == "male"
assert params["page_size"] == 100
if first_page:
assert str(params[cursor_param]) == first_page
assert url == "https://api.elevenlabs.io" + path and headers["xi-api-key"] == "el-test-key"
assert {k: params[k] for k in expected_params} == expected_params
row = result["voices"][0]
assert result["success"] and result["next_page"] == next_page
assert row["gender"] == "male" and row["accent"] == "scottish" and row["use_case"] == "characters"
assert row["gender"] == "male" and row["accent"] == "scottish"
assert result["configured_voice_id"] == "configuredVoice001"
if source == "library":
assert result["total"] == 812 and len(row["description"]) == 240
if path == "/v1/shared-voices":
assert result["total"] == 812 and len(row["description"]) == 240 and row["credit_rate"] == 2
assert not bad_page["success"] and len(calls) == 1 # a cursor token is not a library page
def test_tts_voices_rejects_a_non_numeric_library_page():
with patch("tools.tts_tool._load_tts_config", return_value=_EL_CONFIG), \
patch("tools.tts_tool._resolve_provider_key", return_value="el-test-key"), \
patch("tools.tts_voices_tool.httpx.get") as get:
result = json.loads(tts_voices_tool.tts_voices_tool(source="library", page="tok-2"))
assert not result["success"] and get.call_count == 0
def test_provider_switch_shows_and_hides_tts_voices_at_once(monkeypatch):
"""The check is config-backed and uncached: a switch to ElevenLabs right after an Edge
listing must expose tts_voices (a cached False would stick under the new fingerprint)."""
def test_provider_switch_reshapes_the_schema_at_once(monkeypatch):
"""The ElevenLabs fields come from config at every listing: a switch right after an OpenAI
listing must add them, and switching back must drop them."""
import os
import model_tools
from hermes_constants import get_hermes_home
monkeypatch.setenv("ELEVENLABS_API_KEY", "el-test-key")
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
config_path = get_hermes_home() / "config.yaml"
def listed(provider: str, stamp: int) -> set:
def tts_props(provider: str, stamp: int) -> set:
config_path.write_text(f"tts:\n provider: {provider}\n")
os.utime(config_path, (stamp, stamp))
defs = model_tools.get_tool_definitions(enabled_toolsets=["tts"], quiet_mode=True)
return {d["function"]["name"] for d in defs}
(tts,) = [d for d in defs if d["function"]["name"] == "text_to_speech"]
return set(tts["function"]["parameters"]["properties"])
assert "tts_voices" not in listed("edge", 1_700_000_000)
assert "tts_voices" in listed("elevenlabs", 1_700_000_100)
assert "tts_voices" not in listed("edge", 1_700_000_200)
assert "find_voices" not in tts_props("openai", 1_700_000_000)
assert {"find_voices", "voice_id"} <= tts_props("elevenlabs", 1_700_000_100)
assert "find_voices" not in tts_props("openai", 1_700_000_200)
+158
View File
@@ -0,0 +1,158 @@
"""ElevenLabs voice search behind ``text_to_speech``'s ``find_voices`` argument.
The argument (and ``voice_id``) only exist in the ElevenLabs variant of the ``text_to_speech``
schema, so other providers never see voice search. Two sources share one paginated shape:
``library`` (``GET /v1/shared-voices``: the public Voice Library, the default) and ``account``
(``GET /v2/voices``: premade + the user's saved, cloned and designed voices). ``next_page`` is
opaque to the model: a page number for the library and a cursor token for the account.
"""
from __future__ import annotations
import json
import logging
from typing import Any, Optional
import httpx
from tools.registry import tool_error
logger = logging.getLogger(__name__)
DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io"
SOURCES = ("library", "account")
# GET /v1/shared-voices ``sort`` keys; the default ranks by a year of real usage.
LIBRARY_SORTS = ("usage_character_count_1y", "trending", "cloned_by_count", "created_date")
_DEFAULT_PAGE_SIZE = 20
_MAX_PAGE_SIZE = 100
_DESCRIPTION_MAX_CHARS = 240
_TIMEOUT_SECONDS = 20.0
_FILTERS = ("gender", "age", "accent", "language")
FIND_VOICES_SCHEMA = {
"type": "object",
"description": (
"Search voices instead of speaking (text is then not needed). Returns a page of voices "
"with voice_id, labels, description and preview_url; pass next_page back as page. "
"source 'library' (default) = the public Voice Library, thousands of community voices, "
"most-used first, usable directly by voice_id; 'account' = the user's own voices. The "
"library search matches words in names and descriptions literally: when a role word "
"('dwarf', 'blacksmith') finds nothing, describe the sound instead ('gravelly old', "
"'deep growl') with gender/age and use_case 'characters_animation'. You can't hear "
"voices: choose from labels and description, and give the user preview_url links when "
"the pick matters."),
"properties": {
"query": {"type": "string", "description": "Free-text search, e.g. 'orc' or 'gravelly old'."},
"source": {"type": "string", "enum": list(SOURCES)},
"gender": {"type": "string", "description": "e.g. male, female, neutral."},
"age": {"type": "string", "description": "e.g. young, middle_aged, old."},
"accent": {"type": "string", "description": "e.g. british, american, scottish."},
"language": {"type": "string", "description": "ISO 639-1 code, e.g. en."},
"use_case": {"type": "string", "description": (
"Library only, e.g. characters_animation, narrative_story, conversational.")},
"sort": {"type": "string", "enum": list(LIBRARY_SORTS),
"description": "Library only; default usage_character_count_1y (most used)."},
"page": {"type": "string", "description": "next_page from the previous result."},
"page_size": {"type": "integer", "description": "1-100, default 20."},
},
}
def _clip(text: Any) -> str:
text = " ".join(str(text or "").split())
return text if len(text) <= _DESCRIPTION_MAX_CHARS else text[: _DESCRIPTION_MAX_CHARS - 1] + "…"
def _voice_row(voice: dict[str, Any]) -> dict[str, Any]:
"""One compact voice: library rows carry the traits at top level, account rows under ``labels``."""
raw_labels = voice.get("labels")
labels: dict[str, Any] = raw_labels if isinstance(raw_labels, dict) else {}
row = {"voice_id": voice.get("voice_id"), "name": voice.get("name")}
for key in ("gender", "age", "accent", "language", "descriptive", "use_case"):
value = voice.get(key) or labels.get(key) or labels.get(key.replace("_", " "))
if value:
row[key] = value
if voice.get("category"):
row["category"] = voice["category"]
if voice.get("description"):
row["description"] = _clip(voice["description"])
if voice.get("rate") not in (None, 1, 1.0): # library voice billed at a custom credit multiplier
row["credit_rate"] = voice["rate"]
if voice.get("preview_url"):
row["preview_url"] = voice["preview_url"]
return row
def _request_params(source: str, query: str, filters: dict[str, str], use_case: str, sort: str,
page: str, page_size: int) -> tuple[str, dict[str, Any]]:
"""``(path, params)`` for one source; raises ValueError on a bad cursor or sort."""
params: dict[str, Any] = {"page_size": page_size, **filters}
if query:
params["search"] = query
if source == "account":
params["include_total_count"] = "false"
if page:
params["next_page_token"] = page
return "/v2/voices", params
if page and not page.isdigit():
raise ValueError(f"Invalid library page {page!r}: pass next_page from the previous result.")
if sort and sort not in LIBRARY_SORTS:
raise ValueError(f"find_voices.sort must be one of {', '.join(LIBRARY_SORTS)}")
params["sort"] = sort or LIBRARY_SORTS[0]
if use_case:
params["use_cases"] = [use_case]
params["page"] = int(page or 0)
return "/v1/shared-voices", params
def _next_page(source: str, body: dict[str, Any], page: str) -> Optional[str]:
if not body.get("has_more"):
return None
if source == "account":
return body.get("next_page_token") or None
return str(int(page or 0) + 1)
def search_voices(find: dict[str, Any], tts_config: dict[str, Any], api_key: str) -> str:
"""Search one ElevenLabs voice source and return a compact, paginated JSON page."""
def arg(name: str) -> str:
return str(find.get(name) or "").strip()
source = (arg("source") or SOURCES[0]).lower()
if source not in SOURCES:
return tool_error(f"find_voices.source must be one of {', '.join(SOURCES)}", success=False)
try:
size = max(1, min(_MAX_PAGE_SIZE, int(find.get("page_size") or _DEFAULT_PAGE_SIZE)))
except (TypeError, ValueError):
size = _DEFAULT_PAGE_SIZE
filters = {name: arg(name).lower() for name in _FILTERS if arg(name)}
page = arg("page")
try:
path, params = _request_params(source, arg("query"), filters, arg("use_case").lower(),
arg("sort").lower(), page, size)
except ValueError as exc:
return tool_error(str(exc), success=False)
el_config = tts_config.get("elevenlabs") or {}
base_url = (el_config.get("base_url") or DEFAULT_ELEVENLABS_BASE_URL).rstrip("/")
try:
response = httpx.get(base_url + path, params=params, timeout=_TIMEOUT_SECONDS,
headers={"xi-api-key": api_key, "accept": "application/json"})
except httpx.HTTPError as exc:
return tool_error(f"ElevenLabs voice search failed: {exc}", success=False)
if response.status_code != 200:
logger.warning("ElevenLabs %s returned HTTP %s", path, response.status_code)
return tool_error(f"ElevenLabs voice search failed (HTTP {response.status_code}): "
f"{response.text[:300]}", success=False)
try:
body = response.json()
except ValueError:
return tool_error("ElevenLabs voice search returned a non-JSON body", success=False)
voices = [_voice_row(v) for v in body.get("voices") or [] if isinstance(v, dict)]
result: dict[str, Any] = {
"success": True, "source": source, "count": len(voices), "voices": voices,
"next_page": _next_page(source, body, page),
"configured_voice_id": el_config.get("voice_id") or None,
}
if source == "library" and body.get("total_count"):
result["total"] = body["total_count"]
return json.dumps(result, ensure_ascii=False)
+27 -7
View File
@@ -311,7 +311,7 @@ _ELEVENLABS_VOICE_ID_RE = re.compile(r"^[A-Za-z0-9]{8,64}$")
def elevenlabs_voice_selection_active(tts_config: Optional[dict[str, Any]] = None) -> bool:
"""Whether the model may pick voices: built-in ElevenLabs is the configured provider (gates
the ``voice_id`` schema field and the ``tts_voices`` tool, so other providers see neither)."""
the ``voice_id`` and ``find_voices`` schema fields, so other providers see neither)."""
tts_config = _load_tts_config() if tts_config is None else tts_config
return (_get_provider(tts_config) == "elevenlabs"
and _resolve_command_provider_config("elevenlabs", tts_config) is None)
@@ -329,13 +329,27 @@ def _apply_voice_override(tts_config: dict[str, Any], provider: str, voice_id: O
return tts_config, None
if not _ELEVENLABS_VOICE_ID_RE.match(voice_id):
return tts_config, tool_error(
f"Invalid ElevenLabs voice_id {voice_id!r}: use a voice_id returned by tts_voices.",
f"Invalid ElevenLabs voice_id {voice_id!r}: use a voice_id returned by find_voices.",
success=False)
section = dict(tts_config.get("elevenlabs") or {})
section["voice_id"] = voice_id
return {**tts_config, "elevenlabs": section}, None
def _find_voices(find: Any) -> str:
"""``text_to_speech(find_voices=...)``: one page of ElevenLabs voices to pick a voice_id from."""
if not isinstance(find, dict):
return tool_error("find_voices must be an object, e.g. {\"query\": \"orc\"}", success=False)
tts_config = _load_tts_config()
if not elevenlabs_voice_selection_active(tts_config):
return tool_error("find_voices needs tts.provider: elevenlabs", success=False)
api_key = _resolve_provider_key("ELEVENLABS_API_KEY", "elevenlabs")
if not api_key:
return tool_error("ELEVENLABS_API_KEY not set. Get one at https://elevenlabs.io/", success=False)
from tools.tts_elevenlabs_voices import search_voices
return search_voices(find, tts_config, api_key)
def _session_platform() -> tuple:
"""``(platform, wants_opus)`` — platforms delivering voice bubbles only as Ogg/Opus want Opus."""
from gateway.session_context import get_session_env
@@ -488,12 +502,15 @@ def _synthesize_chunks(chunks: list[str], base_path: Path, generated_artifacts:
def text_to_speech_tool(
text: str, output_path: Optional[str] = None, speed: Optional[float] = None,
instructions: Optional[str] = None, provider: Optional[str] = None,
voice_id: Optional[str] = None) -> str:
voice_id: Optional[str] = None, find_voices: Optional[dict[str, Any]] = None) -> str:
"""Convert text to speech with long-form chunking; returns the JSON result envelope.
Text is normalized, split into provider-safe chunks (never silently truncated), synthesized
sequentially, then packed against the platform's upload limit: a failed combine keeps the
separate valid files and no over-limit artifact is ever returned."""
separate valid files and no over-limit artifact is ever returned. ``find_voices`` (ElevenLabs
only) searches voices instead of speaking."""
if find_voices:
return _find_voices(find_voices)
if not text or not text.strip():
return tool_error("Text is required", success=False)
try: # shared cleaner: markdown, emoji, think blocks, verifier footer, units, newlines
@@ -672,16 +689,19 @@ def _tts_schema_overrides() -> dict:
params["properties"]["output_path"]["description"] = _output_path_description(display_hermes_home())
if not elevenlabs_voice_selection_active():
return {"parameters": params}
from tools.tts_elevenlabs_voices import FIND_VOICES_SCHEMA
params["properties"]["voice_id"] = {
"type": "string",
"description": ("ElevenLabs voice for this audio (a voice_id from tts_voices). "
"description": ("ElevenLabs voice for this audio (a voice_id from find_voices). "
"Omit to use the user's configured voice.")}
params["properties"]["find_voices"] = copy.deepcopy(FIND_VOICES_SCHEMA)
params["required"] = [] # a find_voices call carries no text
return {"parameters": params, "description": TTS_SCHEMA["description"].replace(
_CONFIGURED_VOICE_SENTENCE, _ELEVENLABS_VOICE_SENTENCE)}
_CONFIGURED_VOICE_SENTENCE = "Voice and provider are user-configured (built-in providers like edge/openai or custom command providers under tts.providers.<name>), not model-selected."
_ELEVENLABS_VOICE_SENTENCE = "The provider is user-configured (ElevenLabs); pick a voice per call with voice_id, found via tts_voices."
_ELEVENLABS_VOICE_SENTENCE = "The provider is user-configured (ElevenLabs); pick a voice per call with voice_id, found with find_voices (one character per voice, e.g. for game NPCs)."
TTS_SCHEMA = {
"name": "text_to_speech",
@@ -721,7 +741,7 @@ registry.register(
schema=TTS_SCHEMA,
handler=lambda args, **kw: text_to_speech_tool(
text=args.get("text", ""),
**{k: args.get(k) for k in ("output_path", "speed", "instructions", "voice_id")}),
**{k: args.get(k) for k in ("output_path", "speed", "instructions", "voice_id", "find_voices")}),
check_fn=check_tts_requirements,
emoji="🔊",
dynamic_schema_overrides=_tts_schema_overrides)
-175
View File
@@ -1,175 +0,0 @@
"""``tts_voices``: browse and search ElevenLabs voices so the agent can pick one per
``text_to_speech`` call (``voice_id``).
Registered in the ``tts`` toolset but only exposed while ``tts.provider`` is ElevenLabs with a
key, the same gate as the ``voice_id`` schema field. Two sources share one paginated shape:
``account`` (``GET /v2/voices``: premade + the user's saved, cloned and designed voices) and
``library`` (``GET /v1/shared-voices``: the public Voice Library). ``next_page`` is opaque to the
model: a cursor token for the account and a page number for the library.
"""
from __future__ import annotations
import json
import logging
from typing import Any, Optional
import httpx
from tools.registry import no_cache_check_fn, registry, tool_error
logger = logging.getLogger(__name__)
DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io"
_SOURCES = ("account", "library")
_DEFAULT_PAGE_SIZE = 20
_MAX_PAGE_SIZE = 100
_DESCRIPTION_MAX_CHARS = 240
_TIMEOUT_SECONDS = 20.0
_FILTERS = ("gender", "age", "accent", "language")
@no_cache_check_fn # config-backed and local: a provider switch must show/hide it at once
def check_tts_voices_requirements() -> bool:
"""Exposed only when ElevenLabs is the configured TTS provider and its key resolves."""
from tools import tts_tool
return (tts_tool.elevenlabs_voice_selection_active()
and bool(tts_tool._resolve_provider_key("ELEVENLABS_API_KEY", "elevenlabs")))
def _clip(text: Any) -> str:
text = " ".join(str(text or "").split())
return text if len(text) <= _DESCRIPTION_MAX_CHARS else text[: _DESCRIPTION_MAX_CHARS - 1] + "…"
def _voice_row(voice: dict[str, Any]) -> dict[str, Any]:
"""One compact voice: library rows carry the traits at top level, account rows under ``labels``."""
raw_labels = voice.get("labels")
labels: dict[str, Any] = raw_labels if isinstance(raw_labels, dict) else {}
row = {"voice_id": voice.get("voice_id"), "name": voice.get("name")}
for key in ("gender", "age", "accent", "language", "descriptive", "use_case"):
value = voice.get(key) or labels.get(key) or labels.get(key.replace("_", " "))
if value:
row[key] = value
if voice.get("category"):
row["category"] = voice["category"]
if voice.get("description"):
row["description"] = _clip(voice["description"])
if voice.get("preview_url"):
row["preview_url"] = voice["preview_url"]
return row
def _request_params(source: str, query: str, filters: dict[str, str], page: str,
page_size: int) -> tuple[str, dict[str, Any]]:
"""``(path, params)`` for one source; raises ValueError on a cursor of the wrong kind."""
params: dict[str, Any] = {"page_size": page_size, **filters}
if query:
params["search"] = query
if source == "account":
params["include_total_count"] = "false"
if page:
params["next_page_token"] = page
return "/v2/voices", params
if page and not page.isdigit():
raise ValueError(f"Invalid library page {page!r}: pass next_page from the previous result.")
params["page"] = int(page or 0)
return "/v1/shared-voices", params
def _next_page(source: str, body: dict[str, Any], page: str) -> Optional[str]:
if not body.get("has_more"):
return None
if source == "account":
return body.get("next_page_token") or None
return str(int(page or 0) + 1)
def tts_voices_tool(query: str = "", source: str = "account", gender: str = "", age: str = "",
accent: str = "", language: str = "", page: str = "",
page_size: Optional[int] = None) -> str:
"""Search one ElevenLabs voice source and return a compact, paginated JSON page."""
from tools import tts_tool
source = (source or "account").strip().lower()
if source not in _SOURCES:
return tool_error(f"source must be one of {', '.join(_SOURCES)}", success=False)
tts_config = tts_tool._load_tts_config()
if not tts_tool.elevenlabs_voice_selection_active(tts_config):
return tool_error("tts_voices needs tts.provider: elevenlabs", success=False)
api_key = tts_tool._resolve_provider_key("ELEVENLABS_API_KEY", "elevenlabs")
if not api_key:
return tool_error("ELEVENLABS_API_KEY not set. Get one at https://elevenlabs.io/", success=False)
try:
size = max(1, min(_MAX_PAGE_SIZE, int(page_size or _DEFAULT_PAGE_SIZE)))
except (TypeError, ValueError):
size = _DEFAULT_PAGE_SIZE
filters = {}
for name, value in zip(_FILTERS, (gender, age, accent, language)):
if str(value or "").strip():
filters[name] = str(value).strip().lower()
page = str(page or "").strip()
try:
path, params = _request_params(source, str(query or "").strip(), filters, page, size)
except ValueError as exc:
return tool_error(str(exc), success=False)
el_config = tts_config.get("elevenlabs") or {}
base_url = (el_config.get("base_url") or DEFAULT_ELEVENLABS_BASE_URL).rstrip("/")
try:
response = httpx.get(base_url + path, params=params, timeout=_TIMEOUT_SECONDS,
headers={"xi-api-key": api_key, "accept": "application/json"})
except httpx.HTTPError as exc:
return tool_error(f"ElevenLabs voice search failed: {exc}", success=False)
if response.status_code != 200:
detail = response.text[:300]
logger.warning("ElevenLabs %s returned HTTP %s", path, response.status_code)
return tool_error(f"ElevenLabs voice search failed (HTTP {response.status_code}): {detail}",
success=False)
try:
body = response.json()
except ValueError:
return tool_error("ElevenLabs voice search returned a non-JSON body", success=False)
voices = [_voice_row(v) for v in body.get("voices") or [] if isinstance(v, dict)]
result: dict[str, Any] = {
"success": True, "source": source, "count": len(voices), "voices": voices,
"next_page": _next_page(source, body, page),
"configured_voice_id": el_config.get("voice_id") or None,
}
if source == "library" and body.get("total_count"):
result["total"] = body["total_count"]
return json.dumps(result, ensure_ascii=False)
TTS_VOICES_SCHEMA = {
"name": "tts_voices",
"description": (
"Search ElevenLabs voices to pick a voice_id for text_to_speech. source 'account' = the "
"user's voices (premade, saved, cloned, designed); 'library' = the public Voice Library "
"(thousands of community voices; paid plans only). Paginated: pass next_page back as page. "
"You can't hear voices, so choose from the labels and description, and offer the user "
"preview_url links when the pick matters."),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string",
"description": "Free-text search over names, descriptions and labels, e.g. 'gruff old dwarf'."},
"source": {"type": "string", "enum": list(_SOURCES), "description": "Default account."},
"gender": {"type": "string", "description": "Filter, e.g. male, female, neutral."},
"age": {"type": "string", "description": "Filter, e.g. young, middle_aged, old."},
"accent": {"type": "string", "description": "Filter, e.g. british, american, scottish."},
"language": {"type": "string", "description": "Filter by ISO 639-1 code, e.g. en."},
"page": {"type": "string", "description": "next_page from the previous result."},
"page_size": {"type": "integer", "description": "1-100, default 20."},
},
"required": [],
},
}
registry.register(
name="tts_voices",
toolset="tts",
schema=TTS_VOICES_SCHEMA,
handler=lambda args, **kw: tts_voices_tool(
**{k: args.get(k) for k in ("query", "source", "gender", "age", "accent", "language",
"page", "page_size") if args.get(k) is not None}),
check_fn=check_tts_voices_requirements,
emoji="🗣️")
+4 -4
View File
@@ -17,7 +17,7 @@ _HERMES_CORE_TOOLS = [
"browser_exec", # THE browser: Python over CDP (browser-harness)
"browser_cdp", "browser_dialog",
"browser_vault", # rides with the browser; deferred by default (tools.tool_search.defer)
"text_to_speech", "tts_voices", # tts_voices: check_fn-gated to the ElevenLabs provider
"text_to_speech",
"todo_list", "memory",
"session_search",
"clarify",
@@ -61,7 +61,7 @@ def _core_without(*excluded, kanban=True):
# Coding posture: everything you reach for while pairing on code; drops messaging,
# tts, image_gen, cron, kanban and computer-use.
_CODING_TOOLS = _core_without("image_generate", "text_to_speech", "tts_voices", "cronjob_manage", "computer_use", kanban=False)
_CODING_TOOLS = _core_without("image_generate", "text_to_speech", "cronjob_manage", "computer_use", kanban=False)
# Toolsets a CLIENT adds to its own sessions (tui_gateway/server.py::_gui_surface_toolsets), never
# config: another surface lacking them made no configuration choice.
@@ -121,7 +121,7 @@ TOOLSETS = {
"search (content + files)",
["read_file", "write_file", "patch", "search_files"],
),
"tts": _ts("Text-to-speech: convert text to audio with Edge TTS (free), ElevenLabs, OpenAI, or xAI", ["text_to_speech", "tts_voices"]),
"tts": _ts("Text-to-speech: convert text to audio with Edge TTS (free), ElevenLabs, OpenAI, or xAI", ["text_to_speech"]),
"todo": _ts("Task planning and tracking for multi-step work", ["todo_list"]),
"memory": _ts("Persistent memory across sessions (personal notes + user profile)", ["memory"]),
"context_engine": _ts("Runtime tools exposed by the active context engine"),
@@ -193,7 +193,7 @@ TOOLSETS = {
"hermes-api-server": _ts(
"OpenAI-compatible API server — full agent tools accessible via HTTP (no "
"interactive UI tools like clarify or send_message)",
_core_without("text_to_speech", "tts_voices", "clarify", "computer_use", kanban=False),
_core_without("text_to_speech", "clarify", "computer_use", kanban=False),
),
"hermes-cli": _bundle("Full interactive CLI toolset - all default tools plus cronjob management"),
@@ -344,7 +344,6 @@ The single `video_generate` tool covers both modalities — pass `image_url` to
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `text_to_speech` | Convert text to speech audio. Returns a MEDIA: path that the platform delivers as a voice message. On Telegram it plays as a voice bubble, on Discord/WhatsApp as an audio attachment. In CLI mode, saves to ~/voice-memos/. Voice and provider… | — |
| `tts_voices` | Search ElevenLabs voices (your account or the public Voice Library, paginated) to pick a `voice_id` for `text_to_speech`. Only exposed while `tts.provider` is `elevenlabs`. | ELEVENLABS_API_KEY |
## `discord` toolset
+1 -1
View File
@@ -80,7 +80,7 @@ Or in-session:
| `skills` | `skill_manage`, `skill_view`, `skills_list` | Skill CRUD and browsing. |
| `terminal` | `process_manage`, `terminal` | Shell command execution and background process management. |
| `todo` | `todo_list` | Task list management within a session. |
| `tts` | `text_to_speech`, `tts_voices` | Text-to-speech audio generation; `tts_voices` (voice search) only appears with the ElevenLabs provider. |
| `tts` | `text_to_speech` | Text-to-speech audio generation. |
| `vision` | `vision_analyze` | Image analysis via vision-capable models. |
| `video` | `video_analyze` | Video analysis and understanding tools (opt-in, not in the default toolset — add explicitly via `--toolsets`). |
| `web` | `web_extract`, `web_search` | Web search and page content extraction. |
+10 -6
View File
@@ -230,13 +230,17 @@ If you want voice bubbles without installing ffmpeg, switch to the OpenAI, Eleve
### ElevenLabs: agent-selected voices
With `tts.provider: elevenlabs` and `ELEVENLABS_API_KEY` set, the agent can pick a different voice for each `text_to_speech` call, for example one voice per character in a script. Two things appear only for ElevenLabs (other providers keep the configured voice and see neither):
With `tts.provider: elevenlabs` and `ELEVENLABS_API_KEY` set, the agent can pick a different voice for each `text_to_speech` call, for example one voice per character in a script. The ElevenLabs version of `text_to_speech` has two extra fields that other providers never see (they keep the configured voice):
- `text_to_speech` gains an optional `voice_id`. Leaving it out uses `tts.elevenlabs.voice_id`; the config is never rewritten.
- The `tts_voices` tool searches voices, 20 per page by default (up to 100), with `next_page` for the following page:
- `source: account` (default) lists the voices on your account: premade ones plus anything you saved, cloned or designed.
- `source: library` searches the public Voice Library (thousands of community voices). ElevenLabs only allows library voices over the API on paid plans.
- Filters: free-text `query` (name, description, labels), `gender`, `age`, `accent`, `language` (ISO code like `en`).
- `voice_id` picks the voice for this call. Leaving it out uses `tts.elevenlabs.voice_id`; the config is never rewritten.
- `find_voices` searches for voices instead of speaking. It returns 20 voices per page by default (up to 100), with `next_page` for the following page:
- `source: library` (default) searches the public Voice Library: thousands of community voices, most-used first. Library voices work directly by `voice_id` without saving them first; ElevenLabs only allows them over the API on paid plans, and a few carry a higher credit rate (shown as `credit_rate`).
- `source: account` lists the voices on your account: premade ones plus anything you saved, cloned or designed.
- Filters: free-text `query`, `gender`, `age`, `accent`, `language` (ISO code like `en`), and for the library `use_case` (e.g. `characters_animation`) and `sort` (`usage_character_count_1y`, `trending`, `cloned_by_count`, `created_date`).
The library search matches words in voice names and descriptions literally. "orc" finds orc voices, but "dwarf" finds none, so the agent is told to fall back to describing the sound ("gravelly old" with `age: old` and `use_case: characters_animation`).
The ElevenLabs API key needs the **Text to Speech** permission to speak and **Voices: Read** to search.
The agent can't listen to voices. It chooses from each voice's labels and description, and every result carries a `preview_url` you can open to hear the voice before committing to it.