diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index 01a7e7a90f..794eedc2cf 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -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() diff --git a/agent/display.py b/agent/display.py index 7519e871da..767af4328b 100644 --- a/agent/display.py +++ b/agent/display.py @@ -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, diff --git a/model_tools.py b/model_tools.py index 56262b9b2b..97f2987a7b 100644 --- a/model_tools.py +++ b/model_tools.py @@ -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"], } diff --git a/tests/fixtures/prompt_surface/surfaces.json b/tests/fixtures/prompt_surface/surfaces.json index 24e9c733f5..04c38c12eb 100644 --- a/tests/fixtures/prompt_surface/surfaces.json +++ b/tests/fixtures/prompt_surface/surfaces.json @@ -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", diff --git a/tests/fixtures/prompt_surface/tools/tts_voices.json b/tests/fixtures/prompt_surface/tools/tts_voices.json deleted file mode 100644 index 0d2242c0e5..0000000000 --- a/tests/fixtures/prompt_surface/tools/tts_voices.json +++ /dev/null @@ -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" - } -} diff --git a/tests/tools/test_tts_voice_selection.py b/tests/tools/test_tts_voice_selection.py index c702cdcd18..4f2ac824bb 100644 --- a/tests/tools/test_tts_voice_selection.py +++ b/tests/tools/test_tts_voice_selection.py @@ -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) diff --git a/tools/tts_elevenlabs_voices.py b/tools/tts_elevenlabs_voices.py new file mode 100644 index 0000000000..1fc80a0f16 --- /dev/null +++ b/tools/tts_elevenlabs_voices.py @@ -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) diff --git a/tools/tts_tool.py b/tools/tts_tool.py index fb27e28529..d6e8cd0376 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -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.), 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) diff --git a/tools/tts_voices_tool.py b/tools/tts_voices_tool.py deleted file mode 100644 index 7a63030918..0000000000 --- a/tools/tts_voices_tool.py +++ /dev/null @@ -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="πŸ—£οΈ") diff --git a/toolsets.py b/toolsets.py index 1360bb750e..a4f73e29c0 100644 --- a/toolsets.py +++ b/toolsets.py @@ -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"), diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index 89f7335c5e..27f14469f6 100644 --- a/website/docs/reference/tools-reference.md +++ b/website/docs/reference/tools-reference.md @@ -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 diff --git a/website/docs/reference/toolsets-reference.md b/website/docs/reference/toolsets-reference.md index fe712dfc42..4173fd7ec6 100644 --- a/website/docs/reference/toolsets-reference.md +++ b/website/docs/reference/toolsets-reference.md @@ -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. | diff --git a/website/docs/user-guide/features/tts.md b/website/docs/user-guide/features/tts.md index ee22af1e81..2a42e8b048 100644 --- a/website/docs/user-guide/features/tts.md +++ b/website/docs/user-guide/features/tts.md @@ -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.