#!/usr/bin/env python3 """ Text-to-Speech Tool Module Built-in TTS providers: - Edge TTS (default, free, no API key): Microsoft Edge neural voices - ElevenLabs (premium): High-quality voices, needs ELEVENLABS_API_KEY - OpenAI TTS: Good quality, needs OPENAI_API_KEY - MiniMax TTS: High-quality with voice cloning, needs the selected region's key - Mistral (Voxtral TTS): Multilingual, native Opus, needs MISTRAL_API_KEY - Google Gemini TTS: Controllable, 30 prebuilt voices, needs GEMINI_API_KEY - xAI TTS: Grok voices, uses xAI Grok OAuth credentials or XAI_API_KEY - NeuTTS (local, free, no API key): On-device TTS via neutts - KittenTTS (local, free, no API key): On-device 25MB model - Piper (local, free, no API key): OHF-Voice/piper1-gpl neural VITS, 44 languages Custom command providers: any number of named ``type: command`` providers under ``tts.providers.`` in ``~/.hermes/config.yaml``; Hermes writes the text to a temp file and runs the shell template (see the Local Command section of ``website/docs/user-guide/features/tts.md``). Output: Opus (.ogg) for voice-bubble platforms (Telegram etc.), MP3 elsewhere. Configuration lives under the ``tts:`` key; the user chooses provider/voice, the model just sends text. Module layout: this file owns config resolution, lazy SDK importers, built-in provider dispatch, output-path policy, the long-form tool entry point, the ``check_fn`` and the tool registration. Sibling modules: ``tts_command_provider`` (``type: command`` providers), ``tts_tool_plugins`` (plugin-registered providers), ``tts_tool_openai`` (OpenAI/DeepInfra + managed gateway), ``tts_tool_providers`` (other cloud backends), ``tts_tool_local`` (on-device engines + model caches), ``tts_tool_lifecycle`` (warm/release leases), ``tts_tool_delivery`` (caps / chunking / ffmpeg / packing), ``tts_tool_speaker`` (streaming speaker pipeline). Their names are re-imported here so ``tools.tts_tool.`` keeps resolving and tests patching ``tools.tts_tool.`` still take effect (siblings resolve those seams through ``_origin()`` at call time). Usage: from tools.tts_tool import text_to_speech_tool, check_tts_requirements result = text_to_speech_tool(text="Hello world") """ import asyncio import datetime import importlib.util import json import logging import os import tempfile # noqa: F401 — tests/gateway patch ``tts_tool.tempfile.NamedTemporaryFile`` from pathlib import Path from typing import Callable, Dict, Any, List, Optional from hermes_constants import display_hermes_home logger = logging.getLogger(__name__) def get_env_value(name, default=None): """Read env values through the live config module. Resolved at call time: tests monkeypatch/restore ``hermes_cli.config.get_env_value`` and must not leave TTS holding a stale function for the rest of the process. """ try: from hermes_cli.config import get_env_value as _get_env_value except ImportError: return os.getenv(name, default) value = _get_env_value(name) return default if value is None else value def _resolve_provider_key(env_var: str, provider_id: str) -> str: """Resolve a TTS provider API key via the shared voice-key resolver. ``tools.tool_backend_helpers.resolve_provider_secret`` is the single owner of STT/TTS key resolution (config > env/.env > credential pool). Resolved at call time so tests that reload the helpers module see the live function. """ try: from tools.tool_backend_helpers import resolve_provider_secret except ImportError: # pragma: no cover — helpers are in-repo return str(get_env_value(env_var) or "").strip() return resolve_provider_secret(env_var, provider_id, env_getter=get_env_value) from tools.managed_tool_gateway import resolve_managed_tool_gateway # noqa: F401 — seam patched by tests from tools.tts_command_provider import ( # noqa: F401 — historical names re-exported BUILTIN_TTS_PROVIDERS, COMMAND_TTS_OUTPUT_FORMATS, DEFAULT_COMMAND_TTS_MAX_TEXT_LENGTH, DEFAULT_COMMAND_TTS_OUTPUT_FORMAT, DEFAULT_COMMAND_TTS_TIMEOUT_SECONDS, _configured_command_tts_output_path, _generate_command_tts, _get_command_tts_output_format, _get_command_tts_timeout, _get_named_provider_config, _is_command_provider_config, _is_command_tts_voice_compatible, _iter_command_providers, _resolve_command_provider_config, command_env_passthrough as _command_provider_env_passthrough, render_command_template as _render_command_tts_template, run_command_provider as _run_command_tts, shell_quote_context as _shell_quote_context, ) from tools.tool_backend_helpers import ( # noqa: F401 — seams patched by tests, resolved via tts_tool_openai._origin() NOUS_MANAGED_PROVIDER, managed_nous_tools_enabled, read_selection, resolve_openai_audio_api_key, ) from tools.tts_tool_delivery import ( # noqa: F401 — historical names re-exported FALLBACK_MAX_TEXT_LENGTH, PROVIDER_MAX_TEXT_LENGTH, _resolve_max_text_length, AudioDeliveryProfile, _build_audio_delivery_files, _concat_audio_files, _convert_to_opus, _pack_audio_files_for_delivery, _repair_ogg_container, _resolve_audio_delivery_profile, _sniff_audio_container, _split_oversized_sentence, _split_text_for_tts, _wrap_pcm_as_wav, ) from tools.tts_tool_providers import ( # noqa: F401 — historical names re-exported DEFAULT_ELEVENLABS_MODEL_ID, DEFAULT_ELEVENLABS_VOICE_ID, DEFAULT_GEMINI_TTS_MODEL, DEFAULT_GEMINI_TTS_VOICE, DEFAULT_MINIMAX_BASE_URL, DEFAULT_MINIMAX_CN_BASE_URL, TTS_RESPONSE_BODY_LIMIT_BYTES, _XAI_FIRST_SENTENCE_RE, _XAI_INLINE_SPEECH_TAGS, _XAI_WRAPPING_SPEECH_TAGS, _apply_xai_auto_speech_tags, _elevenlabs_environment_kwargs, _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 ( # noqa: F401 — historical names re-exported DEFAULT_PIPER_VOICE, _LOCAL_TTS_MODEL_CACHES, _TTS_MODEL_CACHE_MAX, _generate_kittentts, _generate_neutts, _generate_piper_tts, _kittentts_model_cache, _piper_voice_cache, _resolve_piper_voice_path, _tts_cache_get_or_load, ) from tools.tts_tool_speaker import ( # noqa: F401 — historical names re-exported stream_tts_to_speaker, ) from tools.tts_text_normalize import _strip_markdown_for_tts # noqa: F401 — historical name re-exported from tools.tts_tool_plugins import ( # noqa: F401 — historical names re-exported _dispatch_to_plugin_provider, _plugin_provider_is_available, _plugin_provider_is_voice_compatible, ) from tools.tts_tool_openai import ( # noqa: F401 — historical names re-exported DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_OPENAI_VOICE, MANAGED_OPENAI_TTS_MODELS, _generate_deepinfra_tts, _generate_openai_tts, _has_openai_audio_backend, _resolve_openai_audio_client_config, ) from tools.tts_tool_lifecycle import ( # noqa: F401 — historical names re-exported _local_tts_warmers, _reset_tts_leases_for_tests, acquire_tts_lease, release_tts_lease, release_tts_provider, tts_lease_holders, warm_tts_provider, ) # --------------------------------------------------------------------------- # Lazy imports -- providers are imported only when actually used to avoid # crashing in headless environments (SSH, Docker, WSL, no PortAudio). # --------------------------------------------------------------------------- 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 ``tools.lazy_deps`` feature to best-effort install first (users who enabled a provider by editing config.yaml never ran the post-setup hook); any failure there falls through so the raw import still raises a clean ImportError. sounddevice additionally raises OSError when PortAudio is unavailable. """ def _import(): if feature: try: from tools.lazy_deps import ensure ensure(feature, prompt=False) except Exception: pass 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 _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 # =========================================================================== 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 = _get_default_output_dir() _DEFAULT_OUTPUT_DIR_AT_IMPORT = DEFAULT_OUTPUT_DIR def _default_output_dir() -> str: """Return the active profile's audio output dir at call time. Long-lived multi-profile runtimes (dashboard, TUI/Desktop backend, cron) import this module once and later switch profiles via ``set_hermes_home_override()``; a frozen constant would keep writing into the launch profile's cache. ``DEFAULT_OUTPUT_DIR`` stays as a module attribute for tests/patchers and wins whenever it has been patched. """ configured = DEFAULT_OUTPUT_DIR if configured != _DEFAULT_OUTPUT_DIR_AT_IMPORT: return configured return _get_default_output_dir() # Back-compat alias. Prefer ``_resolve_max_text_length()`` for new code. MAX_TEXT_LENGTH = FALLBACK_MAX_TEXT_LENGTH # =========================================================================== # Config loader -- reads tts: section from ~/.hermes/config.yaml # =========================================================================== def _load_tts_config() -> Dict[str, Any]: """Return the ``tts`` config section ({} when unavailable).""" try: from hermes_cli.config import load_config config = load_config() return config.get("tts") or {} except ImportError: logger.debug("hermes_cli.config not available, using default TTS config") return {} 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: """The explicitly configured TTS provider, or the free default. Inference credentials do not imply consent to paid speech generation: cloud TTS is opt-in via ``tts.provider``. The managed selection (``tts.provider: nous``) is serviced by the OpenAI implementation, routed through the managed openai-audio gateway by ``_resolve_openai_audio_client_config``. """ provider = (tts_config.get("provider") or DEFAULT_PROVIDER).lower().strip() if provider == NOUS_MANAGED_PROVIDER: return "openai" return provider # Platforms whose native voice-bubble delivery requires Ogg/Opus audio # (MP3 renders as a broken attachment there). OPUS_VOICE_PLATFORMS = frozenset({"telegram", "matrix", "feishu", "whatsapp", "signal"}) # Built-ins that emit Opus natively when asked for .ogg (no ffmpeg needed). _NATIVE_OPUS_PROVIDERS = frozenset({"openai", "elevenlabs", "mistral", "gemini"}) # Built-ins whose native output (MP3/WAV) needs ffmpeg for voice-bubble delivery. _FFMPEG_OPUS_PROVIDERS = frozenset({"edge", "neutts", "minimax", "xai", "kittentts", "piper"}) def _has_any_command_tts_provider(tts_config: Optional[Dict[str, Any]] = None) -> bool: """Return True when any command-type TTS provider is configured.""" if tts_config is None: tts_config = _load_tts_config() for _name, _cfg in _iter_command_providers(tts_config): return True return False # =========================================================================== # Built-in provider dispatch # =========================================================================== # provider -> (importer-name or None, "package missing" error, log line, # generator-name). Names are looked up in module globals at call time so # tests that monkeypatch ``tools.tts_tool._import_x`` / ``_generate_x`` apply. _BUILTIN_DISPATCH: Dict[str, tuple] = { "elevenlabs": ( "_import_elevenlabs", "ElevenLabs provider selected but 'elevenlabs' package not installed. Run: pip install elevenlabs", "Generating speech with ElevenLabs...", "_generate_elevenlabs", ), "openai": ( "_import_openai_client", "OpenAI provider selected but 'openai' package not installed.", "Generating speech with OpenAI TTS...", "_generate_openai_tts", ), "deepinfra": ( "_import_openai_client", "DeepInfra TTS uses the 'openai' SDK but it isn't installed.", "Generating speech with DeepInfra TTS...", "_generate_deepinfra_tts", ), "minimax": (None, None, "Generating speech with MiniMax TTS...", "_generate_minimax_tts"), "xai": (None, None, "Generating speech with xAI TTS...", "_generate_xai_tts"), "mistral": ( "_import_mistral_client", "Mistral provider selected but 'mistralai' package not installed. " "Run `hermes setup` to install Mistral support.", "Generating speech with Mistral Voxtral TTS...", "_generate_mistral_tts", ), "gemini": (None, None, "Generating speech with Google Gemini TTS...", "_generate_gemini_tts"), "kittentts": ( "_import_kittentts", "KittenTTS provider selected but 'kittentts' package not installed. " "Run 'hermes setup tts' and choose KittenTTS, or install manually: " "pip install https://github.com/KittenML/KittenTTS/releases/download/0.8.1/kittentts-0.8.1-py3-none-any.whl", "Generating speech with KittenTTS (local, ~25MB)...", "_generate_kittentts", ), "piper": ( "_import_piper", "Piper provider selected but 'piper-tts' package not installed. " "Run 'hermes tools' and select Piper under TTS, or install manually: " "pip install piper-tts", "Generating speech with Piper (local)...", "_generate_piper_tts", ), } _NEUTTS_MISSING_ERROR = ( "NeuTTS provider selected but neutts is not installed. " "Run hermes setup and choose NeuTTS, or install espeak-ng and run python -m pip install -U neutts[all]." ) 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).""" try: import concurrent.futures with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: pool.submit( lambda: asyncio.run(_generate_edge_tts(text, file_str, tts_config)) ).result(timeout=60) except RuntimeError: asyncio.run(_generate_edge_tts(text, file_str, tts_config)) def _select_builtin_engine(provider: str) -> tuple: """Check a built-in provider's SDK. Returns ``(engine, None)`` or ``(provider, error_json)``. Unknown names take the Edge default; when edge-tts is missing, NeuTTS is the local fallback (``engine`` then differs from ``provider``). """ entry = _BUILTIN_DISPATCH.get(provider) if entry is not None: importer_name, missing_error = entry[0], entry[1] if importer_name is not None and not _importable(globals()[importer_name]): return provider, _error_json(missing_error) return provider, None if provider == "neutts": if not _check_neutts_available(): return provider, _error_json(_NEUTTS_MISSING_ERROR) logger.info("Generating speech with NeuTTS (local)...") return provider, 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. Install edge-tts (pip install edge-tts) " "or set up 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* (the caller logs the engine-selection line).""" entry = _BUILTIN_DISPATCH.get(engine) if entry is not None: logger.info(entry[2]) if engine == "openai": _generate_openai_tts(text, file_str, tts_config, instructions=instructions) else: globals()[entry[3]](text, file_str, tts_config) elif engine == "neutts": _generate_neutts(text, file_str, tts_config) else: logger.info("Generating speech with Edge TTS...") _run_edge_tts(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: """Decide voice-bubble eligibility and Opus-convert when needed. Command and plugin providers are documents by default and opt in via ``voice_compatible``; native-Opus built-ins are voice-compatible when the platform wants Opus and they wrote .ogg; MP3/WAV built-ins are converted with ffmpeg only when the platform needs Opus. Returns ``(path, voice_compatible)``. """ voice_compatible = False 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) if opus_path: return opus_path, True return file_str, False elif provider in _NATIVE_OPUS_PROVIDERS: return file_str, want_opus and file_str.endswith(".ogg") else: return file_str, False if opted_in: if not file_str.endswith(".ogg"): opus_path = _convert_to_opus(file_str) if opus_path: file_str = opus_path voice_compatible = file_str.endswith(".ogg") return file_str, voice_compatible # =========================================================================== # 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) and resolve the provider name.""" if speed is not None: clamped = max(0.25, min(4.0, float(speed))) tts_config = dict(tts_config) # shallow copy to avoid mutating the cache tts_config["speed"] = clamped provider = provider.lower().strip() if provider else _get_provider(tts_config) return tts_config, provider 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. Returns ``(Path, None)`` or ``(None, error_json)``. A caller-supplied path is rejected on ``..`` traversal (bug or prompt-injection; an absolute path is fine) and on protected credential/ system locations. Command providers get their configured extension. Default: ``