Files
hermes-agent/tools/tts_tool.py
ethernet 029cabb466 feat(pm): hermes pm install --extra NAME; one shared hint for missing extras
Twenty-five call sites told users to run
`python -c "from pm import sync_venv; sync_venv(['x'], explicit=True)"`
because `hermes pm install` only took package names. Add `--extra`
(repeatable; syncs the venv with the named extras and nothing else) and
pm.install_hint(extra), the single builder every site now uses, so the
advice stays correct when the command changes.

A cold PM runtime under allow_lazy_installs:false now reports the extra
the caller wanted and the command that provisions both, instead of a
bare "pm-runtime: not installed".
2026-09-21 19:08:19 -04:00

765 lines
40 KiB
Python

#!/usr/bin/env python3
"""Text-to-speech tool: config resolution, built-in provider dispatch, output policy, registration.
Built-ins: Edge (free default), ElevenLabs, OpenAI, DeepInfra, MiniMax, Mistral, Gemini, xAI,
local NeuTTS / KittenTTS / Piper; plus ``type: command`` providers under ``tts.providers.<name>``
and plugin-registered ones. Output is Opus (.ogg) on voice-bubble platforms, MP3 elsewhere.
Sibling ``tts_tool_*`` modules hold backends/delivery/lifecycle; they read the seams defined
here (config, provider resolution, lazy SDK importers) through ``_origin()`` at call time.
"""
from pm import install_hint
import asyncio
import contextlib
import datetime
import importlib.util
import json
import logging
import os
import re
import tempfile
from pathlib import Path
from typing import Callable, Dict, Any, List, Optional
import copy
from hermes_constants import display_hermes_home
logger = logging.getLogger(__name__)
def _resolve_provider_key(env_var: str, provider_id: str) -> str:
"""Resolve a TTS provider API key via the shared voice-key resolver (config > env/.env > pool)."""
from tools.tool_backend_helpers import resolve_provider_secret
return resolve_provider_secret(env_var, provider_id)
from tools.tts_command_provider import (
BUILTIN_TTS_PROVIDERS, _configured_command_tts_output_path, _generate_command_tts,
_get_command_tts_output_format, _is_command_tts_voice_compatible, _resolve_command_provider_config)
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER
from tools.tts_tool_delivery import (
_resolve_max_text_length, _build_audio_delivery_files, _convert_to_opus, _remove_quietly,
_repair_ogg_container, _resolve_audio_delivery_profile, _split_text_for_tts)
from tools.tts_tool_providers import (
_generate_edge_tts, _generate_elevenlabs, _generate_gemini_tts, _generate_minimax_tts,
_generate_mistral_tts, _generate_xai_tts, _resolve_minimax_tts_runtime)
from tools.tts_tool_local import _generate_kittentts, _generate_neutts, _generate_piper_tts
from tools.tts_tool_plugins import (
_dispatch_to_plugin_provider, _plugin_provider_is_available,
_plugin_provider_is_voice_compatible)
from tools.tts_tool_openai import _generate_deepinfra_tts, _generate_openai_tts, _has_openai_audio_backend
_PM_FEATURE_ALIASES = {"tts.edge": "edge-tts", "tts.elevenlabs": "tts-premium", "tts.mistral": "mistral"}
# --- Lazy SDK importers -- providers import only when used (headless boxes lack PortAudio etc.) ---
def _sdk_importer(module: str, attr: Optional[str] = None, feature: Optional[str] = None) -> Callable[[], Any]:
"""Lazy SDK importer: returns ``module`` (or ``module.attr``), raising ImportError when absent.
``feature`` names a ``pm.ensure_import`` extra to best-effort install first (users who enabled
a provider in config.yaml never ran the post-setup hook); any failure there falls through so
the raw import still raises cleanly. sounddevice also raises OSError without PortAudio."""
def _import():
if feature:
with contextlib.suppress(Exception):
from pm import ensure_import as _pm_ensure
_pm_ensure(_PM_FEATURE_ALIASES.get(feature, feature))
mod = importlib.import_module(module)
return getattr(mod, attr) if attr else mod
_import.__name__ = f"_import_{module.split('.')[0]}"
return _import
_import_edge_tts = _sdk_importer("edge_tts", feature="tts.edge")
_import_elevenlabs = _sdk_importer("elevenlabs.client", "ElevenLabs", feature="tts.elevenlabs")
_import_openai_client = _sdk_importer("openai", "OpenAI")
_import_mistral_client = _sdk_importer("mistralai.client", "Mistral", feature="tts.mistral")
_import_sounddevice = _sdk_importer("sounddevice")
_import_kittentts = _sdk_importer("kittentts", "KittenTTS")
_import_piper = _sdk_importer("piper", "PiperVoice") # piper-tts wheels embed espeak-ng
def _importable(importer: Callable[[], Any]) -> bool:
try:
importer()
return True
except ImportError:
return False
def _package_installed(name: str) -> bool:
try:
return importlib.util.find_spec(name) is not None
except Exception:
return False
def _check_neutts_available() -> bool: return _package_installed("neutts")
def _check_kittentts_available() -> bool: return _package_installed("kittentts")
def _check_piper_available() -> bool: return _package_installed("piper")
# --- Defaults / config ---
DEFAULT_PROVIDER = "edge"
def _get_default_output_dir() -> str:
from hermes_constants import get_hermes_dir
return str(get_hermes_dir("cache/audio", "audio_cache"))
DEFAULT_OUTPUT_DIR = _DEFAULT_OUTPUT_DIR_AT_IMPORT = _get_default_output_dir()
def _default_output_dir() -> str:
"""The active profile's audio output dir at call time (long-lived runtimes switch profiles
after import); a monkeypatched ``DEFAULT_OUTPUT_DIR`` wins.
Same bug class as skills_tool (f8723c478) and skills_sync (#65828): long-lived multi-profile runtimes
(dashboard console, TUI/Desktop backend, cron, kanban workers) import this module once under the launch
HERMES_HOME and later scope requests to a different profile via
``hermes_constants.set_hermes_home_override()`` — a frozen module constant keeps writing synthesized
audio into the launch profile's cache instead of the active profile's (#98749). Keep the legacy
``DEFAULT_OUTPUT_DIR`` module attribute for tests and external patchers; when it has not been patched,
re-resolve from the live profile-scoped HERMES_HOME on every call.
"""
if DEFAULT_OUTPUT_DIR != _DEFAULT_OUTPUT_DIR_AT_IMPORT:
return DEFAULT_OUTPUT_DIR
return _get_default_output_dir()
def _load_tts_config() -> Dict[str, Any]:
"""Return the ``tts`` config section ({} when unavailable)."""
try:
from hermes_cli.config import load_config
return load_config().get("tts") or {}
except ImportError:
logger.debug("hermes_cli.config not available, using default TTS config")
except Exception as e:
logger.warning("Failed to load TTS config: %s", e, exc_info=True)
return {}
def _get_provider(tts_config: Dict[str, Any]) -> str:
"""Configured provider or the free default (inference credentials never imply consent to paid
speech); ``nous`` is serviced by the OpenAI path through the managed openai-audio gateway."""
provider = (tts_config.get("provider") or DEFAULT_PROVIDER).lower().strip()
return "openai" if provider == NOUS_MANAGED_PROVIDER else provider
# Platforms whose native voice-bubble delivery requires Ogg/Opus (MP3 renders broken there).
OPUS_VOICE_PLATFORMS = frozenset({"telegram", "matrix", "feishu", "whatsapp", "signal"})
# MEDIA:<path> is a line-level gateway protocol. A filename containing an anchored media
# directive forges a second attachment whenever the path is echoed into the tool result
# (media_tag / file_path fields, error text): the collector scans producer output with a
# bare MEDIA: matcher and cannot tell a filename from a directive. Mirrors the collector's
# grammar (gateway.platforms.base.MEDIA_TAG_CLEANUP_RE): an anchored path OR a quoted payload,
# which the collector accepts with no anchor and no extension.
_MEDIA_DIRECTIVE_RE = re.compile(r"media:\s*[`'\"*_]*(?:[`'\"]|[a-z]:[/\\]|~?/)", re.IGNORECASE)
# Built-ins that emit Opus natively when asked for .ogg; the rest need ffmpeg for voice bubbles.
_NATIVE_OPUS_PROVIDERS = frozenset({"openai", "elevenlabs", "mistral", "gemini"})
_FFMPEG_OPUS_PROVIDERS = frozenset({"edge", "neutts", "minimax", "xai", "kittentts", "piper"})
# --- Built-in provider dispatch ---
# provider -> (availability predicate or None, log label, generator name, "package missing" error).
# Predicates/generator names resolve module globals at call time so test monkeypatches apply.
_BUILTIN_DISPATCH: Dict[str, tuple] = {
"elevenlabs": (lambda: _importable(_import_elevenlabs), "ElevenLabs", "_generate_elevenlabs",
"ElevenLabs provider selected but 'elevenlabs' package not installed. Run: "
f"{install_hint('tts-premium')}"),
"openai": (lambda: _importable(_import_openai_client), "OpenAI TTS", "_generate_openai_tts",
"OpenAI provider selected but 'openai' package not installed."),
"deepinfra": (lambda: _importable(_import_openai_client), "DeepInfra TTS", "_generate_deepinfra_tts",
"DeepInfra TTS uses the 'openai' SDK but it isn't installed."),
"minimax": (None, "MiniMax TTS", "_generate_minimax_tts", None),
"xai": (None, "xAI TTS", "_generate_xai_tts", None),
"mistral": (lambda: _importable(_import_mistral_client), "Mistral Voxtral TTS", "_generate_mistral_tts",
"Mistral provider selected but 'mistralai' package not installed. "
"Run `hermes setup` to install Mistral support."),
"gemini": (None, "Google Gemini TTS", "_generate_gemini_tts", None),
"neutts": (lambda: _check_neutts_available(), "NeuTTS (local)", "_generate_neutts",
"NeuTTS provider selected but neutts is not installed. "
"Run hermes setup tts and choose NeuTTS; espeak-ng is also required."),
"kittentts": (lambda: _importable(_import_kittentts), "KittenTTS (local, ~25MB)", "_generate_kittentts",
"KittenTTS provider selected but 'kittentts' package not installed. "
"Run 'hermes setup tts' and choose KittenTTS."),
"piper": (lambda: _importable(_import_piper), "Piper (local)", "_generate_piper_tts",
"Piper provider selected but 'piper-tts' package not installed. "
"Run 'hermes tools' and select Piper under TTS.")}
def _error_json(message: str) -> str:
return json.dumps({"success": False, "error": message}, ensure_ascii=False)
def _run_edge_tts(text: str, file_str: str, tts_config: Dict[str, Any]) -> None:
"""Run the async Edge generator from sync code (worker thread; direct run if that fails)."""
run = lambda: asyncio.run(_generate_edge_tts(text, file_str, tts_config)) # noqa: E731
try:
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=1) as pool:
pool.submit(run).result(timeout=60)
except RuntimeError:
run()
def _select_builtin_engine(provider: str) -> tuple:
"""SDK check -> ``(engine, None)`` or ``(provider, error_json)``. Unknown names take the Edge
default; without edge-tts NeuTTS is the fallback (engine != provider)."""
entry = _BUILTIN_DISPATCH.get(provider)
if entry is not None:
available, _label, _generator, missing_error = entry
return provider, (_error_json(missing_error) if available is not None and not available() else None)
if _importable(_import_edge_tts):
return provider, None # Edge default; the reported provider stays as configured
if _check_neutts_available():
logger.info("Edge TTS not available, falling back to NeuTTS (local)...")
return "neutts", None
return provider, _error_json(
"No TTS provider available. Enable Edge TTS with: "
f"{install_hint('edge-tts')} "
"or run 'hermes setup tts' and choose NeuTTS for local synthesis.")
def _synthesize_builtin(engine: str, text: str, file_str: str, tts_config: Dict[str, Any], instructions: Optional[str]) -> None:
"""Run the already-selected built-in *engine*."""
entry = _BUILTIN_DISPATCH.get(engine)
logger.info("Generating speech with %s...", entry[1] if entry else "Edge TTS")
if entry is None:
_run_edge_tts(text, file_str, tts_config)
elif engine == "openai":
_generate_openai_tts(text, file_str, tts_config, instructions=instructions)
else:
globals()[entry[2]](text, file_str, tts_config)
def _finalize_voice_delivery(
file_str: str, provider: str, command_provider_config: Optional[Dict[str, Any]], want_opus: bool,
) -> tuple:
"""Voice-bubble eligibility (Opus-converting when needed) -> ``(path, voice_compatible)``.
Command/plugin providers are documents unless they opt in via ``voice_compatible``; native-Opus
built-ins qualify when the platform wants Opus and they wrote .ogg; MP3/WAV built-ins are
ffmpeg-converted only when the platform needs Opus."""
if command_provider_config is not None:
opted_in = _is_command_tts_voice_compatible(command_provider_config)
elif provider not in BUILTIN_TTS_PROVIDERS:
opted_in = _plugin_provider_is_voice_compatible(provider)
elif want_opus and provider in _FFMPEG_OPUS_PROVIDERS and not file_str.endswith(".ogg"):
opus_path = _convert_to_opus(file_str)
return (opus_path, True) if opus_path else (file_str, False)
else:
native = provider in _NATIVE_OPUS_PROVIDERS
return file_str, native and want_opus and file_str.endswith(".ogg")
if not opted_in:
return file_str, False
# Plugin-registered provider (issue #30398). Voice-bubble delivery opts in via
# ``TTSProvider.voice_compatible`` (mirrors the command-provider opt-in). Plugins that already write
# Opus skip the ffmpeg conversion.
if not file_str.endswith(".ogg"):
file_str = _convert_to_opus(file_str) or file_str
return file_str, file_str.endswith(".ogg")
# --- Main tool function ---
def _apply_call_overrides(tts_config: Dict[str, Any], speed: Optional[float], provider: Optional[str]):
"""Apply per-call ``speed`` (clamped, on a shallow copy so the cached config isn't mutated) and
resolve the provider name."""
if speed is not None:
tts_config = {**tts_config, "speed": max(0.25, min(4.0, float(speed)))}
return tts_config, provider.lower().strip() if provider else _get_provider(tts_config)
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
platform = get_session_env("HERMES_SESSION_PLATFORM", "").lower()
return platform, platform in OPUS_VOICE_PLATFORMS
def _resolve_output_base(
output_path: Optional[str], provider: str, command_provider_config: Optional[Dict[str, Any]], want_opus: bool,
) -> tuple:
"""Pick the output file -> ``(Path, None)`` or ``(None, error_json)``.
A caller path is rejected on ``..`` traversal (bug or prompt-injection; absolute is fine) and
on protected credential/system locations. Default ``<audio cache>/tts_<timestamp>.<ext>``: the
command format, ``.ogg`` for native-Opus providers on Opus platforms, else ``.mp3``."""
if output_path:
from tools.path_security import has_traversal_component, has_unsafe_path_chars
if has_unsafe_path_chars(output_path):
return None, _error_json(
"output_path contains control characters or line separators; "
"use a plain filesystem path")
# Must precede the traversal/protected checks: their error text echoes the path,
# and a MEDIA: substring in it would forge a delivery tag downstream.
if _MEDIA_DIRECTIVE_RE.search(output_path):
return None, _error_json(
"output_path must not contain a media directive (MEDIA:<path>)")
if has_traversal_component(output_path):
return None, _error_json(
f"output_path contains '..' traversal component: {output_path}. "
"Use an absolute path or one relative to the current directory without '..'.")
file_path = Path(output_path).expanduser()
if command_provider_config is not None:
file_path = _configured_command_tts_output_path(file_path, command_provider_config)
from agent.file_safety import is_write_approval_required, is_write_denied
if is_write_denied(str(file_path)) or is_write_approval_required(str(file_path)):
return None, _error_json(
f"output_path targets a protected credential or system path: "
f"{file_path}. Choose a normal audio output location.")
else:
if command_provider_config is not None:
ext = _get_command_tts_output_format(command_provider_config)
else:
ext = "ogg" if want_opus and provider in _NATIVE_OPUS_PROVIDERS else "mp3"
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
file_path = Path(_default_output_dir()) / f"tts_{timestamp}.{ext}"
file_path.parent.mkdir(parents=True, exist_ok=True)
return file_path, None
def _media_tag(paths: List[str], voice_compatible: bool) -> str:
"""``MEDIA:<path>`` lines; the ``[[audio_as_voice]]`` marker asks the platform for a voice bubble."""
media_tag = "\n".join(f"MEDIA:{path}" for path in paths)
return f"[[audio_as_voice]]\n{media_tag}" if voice_compatible else media_tag
def _tool_failure(prefix: str, provider: str, exc: BaseException) -> str:
"""Log and wrap a synthesis failure as the standard error envelope (traceback except for config errors)."""
error_msg = f"{prefix} ({provider}): {exc}"
logger.error("%s", error_msg, exc_info=not isinstance(exc, ValueError))
return tool_error(error_msg, success=False)
def _text_to_speech_single(
text: str, file_str: str, *, provider: str, tts_config: Dict[str, Any],
command_provider_config: Optional[Dict[str, Any]], want_opus: bool, instructions: Optional[str],
) -> str:
"""Synthesize one provider-safe chunk into *file_str*; returns the result envelope.
Command providers resolve BEFORE built-in dispatch, but built-in names short-circuit so
``tts.providers.openai.command`` can't shadow OpenAI. Plugins fire only for names that are
neither; a None return falls through to built-in dispatch (unknown -> Edge default)."""
try:
if command_provider_config is not None:
logger.info("Generating speech with command TTS provider '%s'...", provider)
file_str = _generate_command_tts(
text, file_str, provider, command_provider_config, tts_config)
# Plugin-registered TTS backend (issue #30398). Fires when the configured provider is neither a
# built-in nor a command-type entry, AND a plugin is registered under that name. The walrus binds
# `_plugin_path` only when the dispatcher returns a path (i.e. a plugin was actually found); a None
# return falls through to the built-in elif chain so unknown names hit the Edge TTS default at the
# bottom. The dispatcher itself enforces built-ins-always-win + command-wins-over-plugin
# defensively.
elif provider not in BUILTIN_TTS_PROVIDERS and (
_plugin_path := _dispatch_to_plugin_provider(text, file_str, provider, tts_config)
) is not None:
file_str = _plugin_path
else:
provider, error = _select_builtin_engine(provider)
if error:
return error
_synthesize_builtin(provider, text, file_str, tts_config, instructions)
if not os.path.exists(file_str) or os.path.getsize(file_str) == 0:
return _error_json(f"TTS generation produced no output (provider: {provider})")
# Sniff once for every provider: MP3/WAV bytes in a .ogg path render as 0-second bubbles.
file_str = _repair_ogg_container(file_str)
file_str, voice_compatible = _finalize_voice_delivery(
file_str, provider, command_provider_config, want_opus)
logger.info("TTS audio saved: %s (%s bytes, provider: %s)", file_str, f"{os.path.getsize(file_str):,}", provider)
return json.dumps({
"success": True, "file_path": file_str, "media_tag": _media_tag([file_str], voice_compatible),
"provider": provider, "voice_compatible": voice_compatible,
}, ensure_ascii=False)
except ValueError as e:
return _tool_failure("TTS configuration error", provider, e)
except FileNotFoundError as e:
return _tool_failure("TTS dependency missing", provider, e)
except Exception as e:
return _tool_failure("TTS generation failed", provider, e)
class _ChunkFailed(Exception):
"""One chunk's synthesis returned an error envelope; message is the final tool error text."""
def _synthesize_chunks(chunks: List[str], base_path: Path, generated_artifacts: set, **single_kwargs) -> tuple:
"""Synthesize chunks into ``<base>.chunkNNN<ext>`` (or ``base`` alone) -> ``(encoded_paths, results)``.
Every touched path lands in *generated_artifacts* for the caller's sweep. Raises
:class:`_ChunkFailed` on a reported failure, ``RuntimeError`` on garbage or missing audio."""
provider = single_kwargs["provider"]
encoded_paths: List[str] = []
chunk_results: List[Dict[str, Any]] = []
for index, chunk in enumerate(chunks, start=1):
chunk_path = base_path
if len(chunks) > 1:
chunk_path = base_path.with_name(f"{base_path.stem}.chunk{index:03d}{base_path.suffix}")
generated_artifacts.add(str(chunk_path))
raw_result = _text_to_speech_single(chunk, str(chunk_path), **single_kwargs)
try:
chunk_result = json.loads(raw_result)
except (json.JSONDecodeError, TypeError):
raise RuntimeError(f"TTS chunk {index} returned invalid JSON: {str(raw_result)[:200]}")
if not chunk_result.get("success"):
error_msg = chunk_result.get("error", "unknown error")
raise _ChunkFailed(f"TTS chunk {index} failed ({provider}): {error_msg}")
actual_path = str(chunk_result.get("file_path") or chunk_path)
if not os.path.isfile(actual_path) or os.path.getsize(actual_path) <= 0:
raise RuntimeError(f"TTS chunk {index} produced no final audio: {actual_path}")
generated_artifacts.add(actual_path)
encoded_paths.append(actual_path)
chunk_results.append(chunk_result)
return encoded_paths, chunk_results
def text_to_speech_tool(
text: str, output_path: Optional[str] = None, speed: Optional[float] = None,
instructions: Optional[str] = None, provider: Optional[str] = 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."""
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
from tools.tts_text_normalize import prepare_spoken_text
text = prepare_spoken_text(text, max_chars=None)
except Exception:
text = text.strip()
if not text:
return tool_error("Text is empty after TTS cleanup", success=False)
tts_config, provider = _apply_call_overrides(_load_tts_config(), speed, provider)
command_provider_config = _resolve_command_provider_config(provider, tts_config)
max_len = _resolve_max_text_length(provider, tts_config)
chunks = _split_text_for_tts(text, max_len)
if not chunks:
return tool_error("Text is required", success=False)
if len(chunks) > 1:
logger.info("TTS text for provider %s split into %d chunks (input=%d chars, cap=%d)",
provider, len(chunks), len(text), max_len)
platform, want_opus = _session_platform()
delivery_profile = _resolve_audio_delivery_profile(platform, tts_config)
base_path, error = _resolve_output_base(
output_path, provider, command_provider_config, want_opus)
if error:
return error
generated_artifacts: set[str] = set()
final_paths: List[str] = []
try:
encoded_paths, chunk_results = _synthesize_chunks(
chunks, base_path, generated_artifacts, provider=provider, tts_config=tts_config,
command_provider_config=command_provider_config, want_opus=want_opus,
instructions=instructions)
voice_compatible = bool(chunk_results) and all(bool(r.get("voice_compatible")) for r in chunk_results)
delivery_base = base_path.with_suffix(Path(encoded_paths[0]).suffix)
final_paths, combined_chunks = _build_audio_delivery_files(
encoded_paths, str(delivery_base), delivery_profile, voice_compatible=voice_compatible)
for path in final_paths:
logger.info("TTS audio saved: %s (%s bytes, provider: %s)", path, f"{os.path.getsize(path):,}", provider)
return json.dumps({
"success": True, "file_path": final_paths[0], "file_paths": final_paths,
"media_tag": _media_tag(final_paths, voice_compatible),
"provider": chunk_results[0].get("provider", provider), "voice_compatible": voice_compatible,
"chunk_count": len(chunks), "delivery_file_count": len(final_paths),
"combined_chunks": bool(combined_chunks),
"delivery_profile": {
"platform": delivery_profile.platform, "max_file_bytes": delivery_profile.max_file_bytes,
"target_file_bytes": delivery_profile.target_file_bytes},
}, ensure_ascii=False)
except _ChunkFailed as exc:
return tool_error(str(exc), success=False)
except ValueError as exc:
return _tool_failure("TTS delivery error", provider, exc)
except Exception as exc:
return _tool_failure("TTS long-form generation failed", provider, exc)
finally:
final_absolute = {os.path.abspath(path) for path in final_paths}
for artifact in generated_artifacts:
if os.path.abspath(artifact) not in final_absolute:
_remove_quietly(artifact)
# --- check_fn ---
def _minimax_requirements() -> bool:
try:
_resolve_minimax_tts_runtime(_load_tts_config())
return True
except ValueError:
return False
def _xai_requirements() -> bool:
try:
from tools.xai_http import resolve_xai_http_credentials
# Same ordering as _generate_xai_tts / XAIStreamer: an explicit key wins over the
# subscription OAuth bearer (which 403s on metered /v1/tts) — never touch the OAuth
# pool for an availability probe when a key is configured. See #87045, #113727.
return bool(resolve_xai_http_credentials(prefer_api_key=True).get("api_key"))
except Exception:
return False
# Must mirror text_to_speech_tool dispatch: unrelated cloud credentials never make the Edge
# default usable, and an explicit provider is checked on its own.
#
# PASSIVE ONLY: every entry answers from availability/credentials and never installs. The SDK
# importers (`_import_edge_tts`/`_import_elevenlabs`/`_import_mistral_client`) call
# ``pm.ensure_import`` on import, so reaching them from here turned ``check_tts_requirements``
# — the ``text_to_speech`` tool's ``check_fn`` — into an installer that ran during every tool
# listing.
_BUILTIN_REQUIREMENTS: Dict[str, Callable[[], bool]] = {
"edge": lambda: _pm_extra_available("edge-tts") or _check_neutts_available(),
"elevenlabs": lambda: _pm_extra_available("tts-premium") and bool(_resolve_provider_key("ELEVENLABS_API_KEY", "elevenlabs")),
"openai": lambda: _package_installed("openai") and _has_openai_audio_backend(),
"deepinfra": lambda: _package_installed("openai") and bool(_resolve_provider_key("DEEPINFRA_API_KEY", "deepinfra")),
"minimax": _minimax_requirements,
"xai": _xai_requirements,
"gemini": lambda: bool(_resolve_provider_key("GEMINI_API_KEY", "gemini") or _resolve_provider_key("GOOGLE_API_KEY", "gemini")),
"mistral": lambda: _pm_extra_available("mistral") and bool(_resolve_provider_key("MISTRAL_API_KEY", "mistral")),
"neutts": lambda: _check_neutts_available(),
"kittentts": lambda: _check_kittentts_available(),
"piper": lambda: _check_piper_available()}
def _pm_extra_available(extra: str) -> bool:
"""Whether the extra's anchor is importable, by pm's own answer for it.
Not ``find_spec`` on a hand-written module name: ``pm.extras`` owns the extra→anchor table and
counts a module already in ``sys.modules`` as installed, which is how every other feature in
the tree reports availability.
"""
try:
from pm.extras import available
except Exception:
return False
return bool(available(extra))
# Providers whose SDK pm installs on first use: provider -> the credential it needs REGARDLESS of
# the install (an install cannot conjure a key; None = none). Extra names come from
# ``_PM_FEATURE_ALIASES`` (upstream's ``tts.<provider>`` ids), so that table stays their one
# source. The install belongs to synthesis (``_select_builtin_engine`` and the command/streaming
# paths), never to a requirement check — so a missing-but-installable SDK counts as READY here.
_SDK_ON_DEMAND: Dict[str, Optional[str]] = {
"edge": None,
"elevenlabs": "ELEVENLABS_API_KEY",
"mistral": "MISTRAL_API_KEY"}
def _ready_after_first_use_install(provider: str) -> bool:
"""True when the SDK is absent but pm may install it at first synthesis AND this machine
could actually get it (platform gate open) AND the provider's own credential is present.
Installs nothing itself."""
if provider not in _SDK_ON_DEMAND:
return False
feature = _PM_FEATURE_ALIASES.get(f"tts.{provider}")
if feature is None:
return False
key_env = _SDK_ON_DEMAND[provider]
if key_env and not _resolve_provider_key(key_env, provider):
return False
try:
from pm.install import lazy_installs_allowed
from pm.extras import extra_supported
except Exception:
return False
return extra_supported(feature) and bool(lazy_installs_allowed())
def check_tts_requirements() -> bool:
"""Return whether the explicitly resolved TTS provider can run — now, or after the
first-use SDK install that synthesis performs. This is the ``text_to_speech`` tool's
``check_fn``, so it runs on every tool listing and must never install."""
tts_config = _load_tts_config()
provider = _get_provider(tts_config)
if _resolve_command_provider_config(provider, tts_config) is not None:
return True
check = _BUILTIN_REQUIREMENTS.get(provider)
if check is not None:
return bool(check()) or _ready_after_first_use_install(provider)
return _plugin_provider_is_available(provider)
# --- Registry ---
from tools.registry import registry, tool_error
def _output_path_description(home: str) -> str:
return f"Optional custom file path to save the audio. Defaults to {home}/audio_cache/<timestamp>.mp3"
def _tts_schema_overrides() -> dict:
"""Rebuild the ``output_path`` default hint from the ACTIVE profile at every get_definitions():
the multiplexed gateway serves every profile from one process, so a path baked in at import
would name the launch profile's home for everyone else (#95685)."""
params = copy.deepcopy(TTS_SCHEMA["parameters"])
params["properties"]["output_path"]["description"] = _output_path_description(display_hermes_home())
return {"parameters": params}
TTS_SCHEMA = {
"name": "text_to_speech",
"description": "Convert text to speech audio. Returns a MEDIA: path that the platform delivers as native audio. Compatible providers render as a voice bubble on Telegram; otherwise audio is sent as a regular attachment. In CLI mode, saves to ~/voice-memos/. Voice and provider are user-configured (built-in providers like edge/openai or custom command providers under tts.providers.<name>), not model-selected.",
"parameters": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The text to convert to speech. Provider-specific per-request character caps apply automatically (OpenAI 4096, xAI 15000, MiniMax 10000, ElevenLabs 5k-40k depending on model); longer input is split into ordered chunks without silent truncation."
},
"output_path": {
"type": "string",
"description": _output_path_description("the profile HERMES_HOME")
},
"speed": {
"type": "number",
"description": "Playback speed multiplier. 1.0 = normal, 0.5 = very slow (language learning), 2.0 = fast. Range: 0.25-4.0. Overrides the speed configured in config.yaml."
},
"instructions": {
"type": "string",
"description": (
"Optional voice-design guidance: tone, emotion, pacing, accent, "
"whispering, impressions (e.g. 'Speak in a cheerful, excited whisper'). "
"Forwarded to the OpenAI backend (gpt-4o-mini-tts and OpenAI-compatible "
"voice-design servers). Silently ignored by backends that don't support it."
)
},
"provider": {
"type": "string",
"description": (
"Optional TTS provider override. Accepts built-in names "
"(edge, openai, elevenlabs, minimax, xai, mistral, gemini, "
"neutts, kittentts, piper), user-declared command provider "
"names from tts.providers.<name>, or plugin-registered names. "
"When omitted, the configured tts.provider from config.yaml is used."
)
}
},
"required": ["text"]
}
}
registry.register(
name="text_to_speech",
toolset="tts",
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", "provider")}),
check_fn=check_tts_requirements,
emoji="🔊",
dynamic_schema_overrides=_tts_schema_overrides)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from concurrent.futures import Future # noqa: F401,E402
from typing import Iterator # noqa: F401,E402
from concurrent.futures import ThreadPoolExecutor # noqa: F401,E402
from typing import Tuple # noqa: F401,E402
import base64 # noqa: F401,E402
from dataclasses import dataclass # noqa: F401,E402
from dataclasses import field # noqa: F401,E402
import platform # noqa: F401,E402
import queue # noqa: F401,E402
import re # noqa: F401,E402
import shlex # noqa: F401,E402
import shutil # noqa: F401,E402
import subprocess # noqa: F401,E402
import threading # noqa: F401,E402
import time # noqa: F401,E402
from urllib.parse import urljoin # noqa: F401,E402
from urllib.parse import urlparse # noqa: F401,E402
import uuid # noqa: F401,E402
GEMINI_TTS_CHANNELS = 1
GEMINI_TTS_SAMPLE_RATE = 24000
GEMINI_TTS_SAMPLE_WIDTH = 2 # 16-bit PCM (L16)
FALLBACK_MAX_TEXT_LENGTH = 4000
MAX_TEXT_LENGTH = FALLBACK_MAX_TEXT_LENGTH
_PLUGIN_COMPAT_LAZY = {
'AudioDeliveryProfile': ('tools.tts_tool_delivery', 'AudioDeliveryProfile'),
'COMMAND_TTS_OUTPUT_FORMATS': ('tools.tts_command_provider', 'COMMAND_TTS_OUTPUT_FORMATS'),
'DEFAULT_COMMAND_TTS_MAX_TEXT_LENGTH': ('tools.tts_command_provider', 'DEFAULT_COMMAND_TTS_MAX_TEXT_LENGTH'),
'DEFAULT_COMMAND_TTS_OUTPUT_FORMAT': ('tools.tts_command_provider', 'DEFAULT_COMMAND_TTS_OUTPUT_FORMAT'),
'DEFAULT_COMMAND_TTS_TIMEOUT_SECONDS': ('tools.tts_command_provider', 'DEFAULT_COMMAND_TTS_TIMEOUT_SECONDS'),
'DEFAULT_DEEPINFRA_TTS_VOICE': ('tools.tts_tool_openai', 'DEFAULT_DEEPINFRA_TTS_VOICE'),
'DEFAULT_EDGE_VOICE': ('tools.tts_tool_providers', 'DEFAULT_EDGE_VOICE'),
'DEFAULT_ELEVENLABS_MODEL_ID': ('tools.tts_tool_providers', 'DEFAULT_ELEVENLABS_MODEL_ID'),
'DEFAULT_ELEVENLABS_STREAMING_MODEL_ID': ('tools.tts_tool_providers', 'DEFAULT_ELEVENLABS_STREAMING_MODEL_ID'),
'DEFAULT_ELEVENLABS_VOICE_ID': ('tools.tts_tool_providers', 'DEFAULT_ELEVENLABS_VOICE_ID'),
'DEFAULT_GEMINI_AUDIO_TAGS': ('tools.tts_tool_providers', 'DEFAULT_GEMINI_AUDIO_TAGS'),
'DEFAULT_GEMINI_TTS_BASE_URL': ('tools.tts_tool_providers', 'DEFAULT_GEMINI_TTS_BASE_URL'),
'DEFAULT_GEMINI_TTS_MODEL': ('tools.tts_tool_providers', 'DEFAULT_GEMINI_TTS_MODEL'),
'DEFAULT_GEMINI_TTS_VOICE': ('tools.tts_tool_providers', 'DEFAULT_GEMINI_TTS_VOICE'),
'DEFAULT_KITTENTTS_MODEL': ('tools.tts_tool_local', 'DEFAULT_KITTENTTS_MODEL'),
'DEFAULT_KITTENTTS_VOICE': ('tools.tts_tool_local', 'DEFAULT_KITTENTTS_VOICE'),
'DEFAULT_MINIMAX_BASE_URL': ('tools.tts_tool_providers', 'DEFAULT_MINIMAX_BASE_URL'),
'DEFAULT_MINIMAX_CN_BASE_URL': ('tools.tts_tool_providers', 'DEFAULT_MINIMAX_CN_BASE_URL'),
'DEFAULT_MINIMAX_MODEL': ('tools.tts_tool_providers', 'DEFAULT_MINIMAX_MODEL'),
'DEFAULT_MINIMAX_VOICE_ID': ('tools.tts_tool_providers', 'DEFAULT_MINIMAX_VOICE_ID'),
'DEFAULT_MISTRAL_TTS_MODEL': ('tools.tts_tool_providers', 'DEFAULT_MISTRAL_TTS_MODEL'),
'DEFAULT_MISTRAL_TTS_VOICE_ID': ('tools.tts_tool_providers', 'DEFAULT_MISTRAL_TTS_VOICE_ID'),
'DEFAULT_OPENAI_BASE_URL': ('tools.tts_tool_openai', 'DEFAULT_OPENAI_BASE_URL'),
'DEFAULT_OPENAI_MODEL': ('tools.tts_tool_openai', 'DEFAULT_OPENAI_MODEL'),
'DEFAULT_OPENAI_VOICE': ('tools.tts_tool_openai', 'DEFAULT_OPENAI_VOICE'),
'DEFAULT_PIPER_VOICE': ('tools.tts_tool_local', 'DEFAULT_PIPER_VOICE'),
'DEFAULT_XAI_AUTO_SPEECH_TAGS': ('tools.tts_tool_providers', 'DEFAULT_XAI_AUTO_SPEECH_TAGS'),
'DEFAULT_XAI_BASE_URL': ('tools.tts_tool_providers', 'DEFAULT_XAI_BASE_URL'),
'DEFAULT_XAI_BIT_RATE': ('tools.tts_tool_providers', 'DEFAULT_XAI_BIT_RATE'),
'DEFAULT_XAI_LANGUAGE': ('tools.tts_tool_providers', 'DEFAULT_XAI_LANGUAGE'),
'DEFAULT_XAI_OPTIMIZE_STREAMING_LATENCY_DEFAULT': ('tools.tts_tool_providers', 'DEFAULT_XAI_OPTIMIZE_STREAMING_LATENCY_DEFAULT'),
'DEFAULT_XAI_SAMPLE_RATE': ('tools.tts_tool_providers', 'DEFAULT_XAI_SAMPLE_RATE'),
'DEFAULT_XAI_SPEED_DEFAULT': ('tools.tts_tool_providers', 'DEFAULT_XAI_SPEED_DEFAULT'),
'DEFAULT_XAI_SPEED_MAX': ('tools.tts_tool_providers', 'DEFAULT_XAI_SPEED_MAX'),
'DEFAULT_XAI_SPEED_MIN': ('tools.tts_tool_providers', 'DEFAULT_XAI_SPEED_MIN'),
'DEFAULT_XAI_TEXT_NORMALIZATION_DEFAULT': ('tools.tts_tool_providers', 'DEFAULT_XAI_TEXT_NORMALIZATION_DEFAULT'),
'DEFAULT_XAI_VOICE_ID': ('tools.tts_tool_providers', 'DEFAULT_XAI_VOICE_ID'),
'ELEVENLABS_MODEL_MAX_TEXT_LENGTH': ('tools.tts_tool_delivery', 'ELEVENLABS_MODEL_MAX_TEXT_LENGTH'),
'FALLBACK_MAX_TEXT_LENGTH': ('tools.tts_tool_delivery', 'FALLBACK_MAX_TEXT_LENGTH'),
'GEMINI_AUDIO_TAG_REWRITE_TASK': ('tools.tts_tool_providers', 'GEMINI_AUDIO_TAG_REWRITE_TASK'),
'MANAGED_OPENAI_TTS_MODELS': ('tools.tts_tool_openai', 'MANAGED_OPENAI_TTS_MODELS'),
'PROVIDER_MAX_TEXT_LENGTH': ('tools.tts_tool_delivery', 'PROVIDER_MAX_TEXT_LENGTH'),
'TTS_RESPONSE_BODY_CHUNK_BYTES': ('tools.tts_tool_providers', 'TTS_RESPONSE_BODY_CHUNK_BYTES'),
'TTS_RESPONSE_BODY_LIMIT_BYTES': ('tools.tts_tool_providers', 'TTS_RESPONSE_BODY_LIMIT_BYTES'),
'acquire_tts_lease': ('tools.tts_tool_lifecycle', 'acquire_tts_lease'),
'hermes_xai_user_agent': ('tools.xai_http', 'hermes_xai_user_agent'),
'managed_nous_tools_enabled': ('tools.tool_backend_helpers', 'managed_nous_tools_enabled'),
'nous_tool_gateway_unavailable_message': ('tools.tool_backend_helpers', 'nous_tool_gateway_unavailable_message'),
'read_selection': ('tools.tool_backend_helpers', 'read_selection'),
'release_tts_lease': ('tools.tts_tool_lifecycle', 'release_tts_lease'),
'release_tts_provider': ('tools.tts_tool_lifecycle', 'release_tts_provider'),
'resolve_managed_tool_gateway': ('tools.managed_tool_gateway', 'resolve_managed_tool_gateway'),
'resolve_openai_audio_api_key': ('tools.tool_backend_helpers', 'resolve_openai_audio_api_key'),
'selection_error': ('tools.tool_backend_helpers', 'selection_error'),
'stream_tts_to_speaker': ('tools.tts_tool_speaker', 'stream_tts_to_speaker'),
'tts_lease_holders': ('tools.tts_tool_lifecycle', 'tts_lease_holders'),
'warm_tts_provider': ('tools.tts_tool_lifecycle', 'warm_tts_provider'),
'windows_hide_flags': ('hermes_cli._subprocess_compat', 'windows_hide_flags'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----