#!/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.`` 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. """ 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 # --- 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 ``tools.lazy_deps`` feature 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 tools.lazy_deps import ensure ensure(feature, prompt=False) 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: 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: pip install elevenlabs"), "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 and choose NeuTTS, or install espeak-ng and run python -m pip install -U neutts[all]."), "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, or install manually: " "pip install https://github.com/KittenML/KittenTTS/releases/download/0.8.1/kittentts-0.8.1-py3-none-any.whl"), "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, or install manually: " "pip install piper-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. 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*.""" 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 ``