diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 790c76ddfd..0dd14e086a 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -88,13 +88,18 @@ from tools.tts_tool_delivery import ( # noqa: F401 — historical names re-expo _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, @@ -106,12 +111,14 @@ from tools.tts_tool_providers import ( # noqa: F401 — historical names re-exp 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 stream_tts_to_speaker # noqa: F401 — historical name re-exported from tools.tts_text_normalize import _strip_markdown_for_tts # noqa: F401 — historical name re-exported @@ -122,6 +129,9 @@ from tools.tts_tool_plugins import ( # noqa: F401 — historical names re-expor ) 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, diff --git a/tools/tts_tool_lifecycle.py b/tools/tts_tool_lifecycle.py index 7e02c12b2d..d38cb9a17d 100644 --- a/tools/tts_tool_lifecycle.py +++ b/tools/tts_tool_lifecycle.py @@ -1,18 +1,11 @@ """Local-engine lifecycle for ``tools.tts_tool``: warm-up / release leases. -Local engines load their model lazily on first synthesis, so the first spoken -reply after a user turns speech output on pays the whole load as dead air, -and the model then stays resident forever. The toggles ARE the intent -signal: every surface that flips speech output on holds a *lease* here -(warming the configured engine); when the last lease is released the local -model caches are dropped. Lease-counting keeps one surface's "off" from -unloading a model another surface in this process still needs. Cloud -providers have nothing resident; warming them only ensures the lazily -installed SDK is importable. - -Seams tests monkeypatch on the origin (``_load_tts_config``, ``_get_provider``, -``warm_tts_provider``, ``_run_command_tts``) are resolved through -:func:`_origin` at call time. +Local engines load their model lazily on first synthesis (dead air on the first spoken +reply) and then stay resident forever. Every surface that flips speech output on holds a +*lease* here (warming the configured engine); when the last lease is released the local +model caches are dropped, so one surface's "off" can't unload a model another surface +still needs. Cloud providers have nothing resident; warming only ensures the SDK imports. +Seams tests monkeypatch on the origin are resolved through :func:`_origin` at call time. """ from __future__ import annotations @@ -31,9 +24,7 @@ from tools.tts_command_provider import ( render_command_template as _render_command_tts_template, ) from tools.tts_tool_local import ( - _LOCAL_TTS_MODEL_CACHES, - _load_kittentts_model_for_config, - _load_piper_voice_for_config, + _LOCAL_TTS_MODEL_CACHES, _load_kittentts_model_for_config, _load_piper_voice_for_config, ) from tools.tts_tool_plugins import _lookup_plugin_provider @@ -59,23 +50,18 @@ def _local_tts_warmers() -> Dict[str, Callable[[Dict[str, Any]], Any]]: } -def _lazy_sdk_feature_for_provider(provider: str) -> Optional[str]: - """tools.lazy_deps feature key for providers whose SDK installs on first use.""" - return { - "edge": "tts.edge", - "elevenlabs": "tts.elevenlabs", - "mistral": "tts.mistral", - }.get(provider) +# tools.lazy_deps feature key for providers whose SDK installs on first use. +_LAZY_SDK_FEATURES = {"edge": "tts.edge", "elevenlabs": "tts.elevenlabs", "mistral": "tts.mistral"} def _signal_user_tts_provider(name: str, tts_config: Dict[str, Any], hook: str) -> Optional[str]: """Forward a lease ``hook`` (``"warm"`` / ``"release"``) to a user-declared provider. - Command providers run their optional ``warm_command`` / ``release_command`` - (same template/env/timeout rules as ``command``; output discarded) on a - background thread so a toggle never waits on a model server. Plugin - providers get :meth:`TTSProvider.warm` / :meth:`TTSProvider.release`. - Best-effort: failures are logged at debug. Returns the action taken. + Command providers run their optional ``warm_command`` / ``release_command`` (same + template/env/timeout rules as ``command``; output discarded) on a background thread so + a toggle never waits on a model server. Plugin providers get :meth:`TTSProvider.warm` + / :meth:`TTSProvider.release`. Best-effort: failures are logged at debug. Returns the + action taken. """ if not name or name in BUILTIN_TTS_PROVIDERS: return None @@ -110,17 +96,13 @@ def _signal_user_tts_provider(name: str, tts_config: Dict[str, Any], hook: str) return "error" -def warm_tts_provider( - tts_config: Optional[Dict[str, Any]] = None, - provider: Optional[str] = None, -) -> Dict[str, Any]: +def warm_tts_provider(tts_config: Optional[Dict[str, Any]] = None, provider: Optional[str] = None) -> Dict[str, Any]: """Pre-load the configured TTS provider so the next synthesis starts hot. - Local engines load their voice/model into the same LRU slot synthesis - reads (including first-use download); lazily-installed cloud SDKs are - made importable; user-declared providers get ``warm_command`` / - :meth:`TTSProvider.warm`; everything else is ``action: "noop"``. - Never raises — the result dict carries ``warmed`` / ``action`` / + Local engines load their voice/model into the same LRU slot synthesis reads (including + first-use download); lazily-installed cloud SDKs are made importable; user-declared + providers get ``warm_command`` / :meth:`TTSProvider.warm`; everything else is + ``action: "noop"``. Never raises — the result dict carries ``warmed`` / ``action`` / ``error``. Blocking; UI threads should run it in the background. """ if tts_config is None: @@ -150,10 +132,11 @@ def warm_tts_provider( signalled = _signal_user_tts_provider(name, tts_config, "warm") if signalled is not None: - result.update(warmed=signalled != "error", action="warmed" if signalled != "error" else "error") + ok = signalled != "error" + result.update(warmed=ok, action="warmed" if ok else "error") return result - feature = _lazy_sdk_feature_for_provider(name) + feature = _LAZY_SDK_FEATURES.get(name) if feature is not None: try: from tools.lazy_deps import ensure, is_available @@ -172,10 +155,9 @@ def warm_tts_provider( def release_tts_provider(provider: Optional[str] = None) -> Dict[str, Any]: """Drop resident local TTS models so their memory is returned. - With ``provider`` given only that engine's cache is cleared; otherwise - every local cache is, and the configured user-declared provider is - signalled (plugin ``release()`` / command ``release_command``). Returns - ``{"released": }``. + With ``provider`` given only that engine's cache is cleared; otherwise every local + cache is, and the configured user-declared provider is signalled (plugin ``release()`` + / command ``release_command``). Returns ``{"released": }``. """ name = (provider or "").lower().strip() if not name: @@ -195,8 +177,8 @@ def release_tts_provider(provider: Optional[str] = None) -> Dict[str, Any]: def acquire_tts_lease(lease: str, tts_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Register ``lease`` (e.g. ``"desktop:read-aloud"``) as a live consumer and warm the provider. - Re-acquiring is idempotent but still re-warms (cheap on a cache hit, and - heals a cache cleared elsewhere). + Re-acquiring is idempotent but still re-warms (cheap on a cache hit, and heals a cache + cleared elsewhere). """ with _tts_lease_lock: _tts_leases.add(lease) @@ -209,8 +191,8 @@ def acquire_tts_lease(lease: str, tts_config: Optional[Dict[str, Any]] = None) - def release_tts_lease(lease: str) -> Dict[str, Any]: """Drop ``lease``; when it was the last one, unload resident local models. - Releasing a never-acquired lease is a no-op (still reports the holder - count) so surfaces can call it unconditionally on their "off" path. + Releasing a never-acquired lease is a no-op (still reports the holder count) so + surfaces can call it unconditionally on their "off" path. """ with _tts_lease_lock: _tts_leases.discard(lease) diff --git a/tools/tts_tool_local.py b/tools/tts_tool_local.py index 0786ca3d09..f0c41e1bfa 100644 --- a/tools/tts_tool_local.py +++ b/tools/tts_tool_local.py @@ -1,11 +1,10 @@ """Local on-device TTS engines for ``tools.tts_tool``: NeuTTS, Piper, KittenTTS. -All three synthesize WAV natively; :func:`_finalize_wav_output` (shared) then -converts/renames to the caller's requested container. Piper and KittenTTS keep -their loaded models in small LRU caches registered in -``_LOCAL_TTS_MODEL_CACHES`` so the origin module's warm/release lifecycle can -pre-load or drop them. ``_import_piper`` / ``_import_kittentts`` are resolved -through the origin module at call time so test monkeypatches there apply. +All three synthesize WAV natively; :func:`_finalize_wav_output` then converts/renames to +the caller's requested container. Piper and KittenTTS keep loaded models in small LRU +caches registered in ``_LOCAL_TTS_MODEL_CACHES`` so the warm/release lifecycle can +pre-load or drop them. ``_import_piper`` / ``_import_kittentts`` are resolved through the +origin module at call time so test monkeypatches there apply. """ from __future__ import annotations @@ -23,6 +22,7 @@ logger = logging.getLogger("tools.tts_tool") DEFAULT_KITTENTTS_MODEL = "KittenML/kitten-tts-nano-0.8-int8" # 25MB DEFAULT_KITTENTTS_VOICE = "Jasper" DEFAULT_PIPER_VOICE = "en_US-lessac-medium" # balanced size/quality +_NEUTTS_SAMPLES = Path(__file__).parent / "neutts_samples" def _origin(): @@ -31,32 +31,28 @@ def _origin(): return tts_tool -# =========================================================================== +# --------------------------------------------------------------------------- # Bounded model caches -# =========================================================================== -# Each cached entry is a whole loaded model (tens of MB); an unbounded dict -# would pin one per distinct voice for the process lifetime. Small LRU: most -# sessions use one or two voices and a cold reload is cheap. +# --------------------------------------------------------------------------- +# Each cached entry is a whole loaded model (tens of MB); an unbounded dict would pin one +# per distinct voice for the process lifetime. Most sessions use one or two voices and a +# cold reload is cheap. _TTS_MODEL_CACHE_MAX = 3 -# Provider name → the model cache it populates. Consulted by -# warm_tts_provider() / release_tts_provider() in the origin module; a new -# local engine adds one row here plus a loader in _local_tts_warmers(). -_LOCAL_TTS_MODEL_CACHES: Dict[str, Dict[str, Any]] = {} - +# Provider name → the model cache it populates (consulted by warm/release in +# tts_tool_lifecycle; a new local engine adds a row here plus a loader in _local_tts_warmers()). # Piper voices keyed on absolute .onnx path (+cuda flag); KittenTTS on model name. _piper_voice_cache: Dict[str, Any] = {} _kittentts_model_cache: Dict[str, Any] = {} -_LOCAL_TTS_MODEL_CACHES["piper"] = _piper_voice_cache -_LOCAL_TTS_MODEL_CACHES["kittentts"] = _kittentts_model_cache +_LOCAL_TTS_MODEL_CACHES: Dict[str, Dict[str, Any]] = {"piper": _piper_voice_cache, "kittentts": _kittentts_model_cache} def _tts_cache_get_or_load(cache: Dict[str, Any], key: str, load: Callable[[], Any]) -> Any: """Get ``key`` from ``cache`` or load it, keeping the cache LRU-bounded. - A hit refreshes recency (pop + reinsert on the insertion-ordered dict); a - miss loads then evicts LRU entries beyond ``_TTS_MODEL_CACHE_MAX``. Callers - holding an evicted reference keep it alive; only the slot is released. + A hit refreshes recency (pop + reinsert on the insertion-ordered dict); a miss loads + then evicts LRU entries beyond ``_TTS_MODEL_CACHE_MAX``. Callers holding an evicted + reference keep it alive; only the slot is released. """ if key in cache: cache[key] = cache.pop(key) @@ -68,17 +64,19 @@ def _tts_cache_get_or_load(cache: Dict[str, Any], key: str, load: Callable[[], A return value -# =========================================================================== +def _section(tts_config: Any, key: str) -> Dict[str, Any]: + return (tts_config.get(key) or {}) if isinstance(tts_config, dict) else {} + + +def _run_helper(cmd: list, timeout: int) -> subprocess.CompletedProcess: + return subprocess.run( + cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout, stdin=subprocess.DEVNULL, + ) + + +# --------------------------------------------------------------------------- # NeuTTS (subprocess via tools/neutts_synth.py so the ~500MB model exits after use) -# =========================================================================== - -def _default_neutts_ref_audio() -> str: - return str(Path(__file__).parent / "neutts_samples" / "jo.wav") - - -def _default_neutts_ref_text() -> str: - return str(Path(__file__).parent / "neutts_samples" / "jo.txt") - +# --------------------------------------------------------------------------- def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str: neutts_config = tts_config.get("neutts") or {} @@ -87,12 +85,12 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) -> sys.executable, str(Path(__file__).parent / "neutts_synth.py"), "--text", text, "--out", wav_path, - "--ref-audio", neutts_config.get("ref_audio", "") or _default_neutts_ref_audio(), - "--ref-text", neutts_config.get("ref_text", "") or _default_neutts_ref_text(), + "--ref-audio", neutts_config.get("ref_audio", "") or str(_NEUTTS_SAMPLES / "jo.wav"), + "--ref-text", neutts_config.get("ref_text", "") or str(_NEUTTS_SAMPLES / "jo.txt"), "--model", neutts_config.get("model", "neuphonic/neutts-air-q4-gguf"), "--device", neutts_config.get("device", "cpu"), ] - result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=120, stdin=subprocess.DEVNULL) + result = _run_helper(cmd, 120) if result.returncode != 0: # The synth script reports success lines as "OK:" on stderr too. error_lines = [l for l in result.stderr.strip().splitlines() if not l.startswith("OK:")] @@ -100,9 +98,9 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) -> return _finalize_wav_output(wav_path, output_path) -# =========================================================================== +# --------------------------------------------------------------------------- # Piper (local neural VITS, 44 languages) -# =========================================================================== +# --------------------------------------------------------------------------- def _get_piper_voices_dir() -> Path: """``/cache/piper-voices/`` so voice downloads follow profile boundaries.""" @@ -115,13 +113,11 @@ def _get_piper_voices_dir() -> Path: def _resolve_piper_voice_path(voice: str, download_dir: Path) -> str: """Resolve *voice* (an .onnx path or a voice name) to a concrete .onnx file. - Names like ``en_US-lessac-medium`` are downloaded into *download_dir* on - first use via ``python -m piper.download_voices``. Raises RuntimeError - when the model can't be located or downloaded. + Names like ``en_US-lessac-medium`` are downloaded into *download_dir* on first use via + ``python -m piper.download_voices``. Raises RuntimeError when the model can't be + located or downloaded. """ - if not voice: - voice = DEFAULT_PIPER_VOICE - + voice = voice or DEFAULT_PIPER_VOICE candidate = Path(voice).expanduser() if candidate.suffix.lower() == ".onnx" and candidate.exists(): return str(candidate) @@ -132,19 +128,14 @@ def _resolve_piper_voice_path(voice: str, download_dir: Path) -> str: logger.info("[Piper] Downloading voice '%s' to %s (first use)", voice, download_dir) try: - result = subprocess.run( - [sys.executable, "-m", "piper.download_voices", voice, - "--download-dir", str(download_dir)], - capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=300, - stdin=subprocess.DEVNULL, + result = _run_helper( + [sys.executable, "-m", "piper.download_voices", voice, "--download-dir", str(download_dir)], 300, ) except subprocess.TimeoutExpired as exc: raise RuntimeError(f"Piper voice download timed out after 300s for '{voice}'") from exc - if result.returncode != 0: stderr = (result.stderr or "").strip() or "no stderr output" raise RuntimeError(f"Piper voice download failed for '{voice}': {stderr[:400]}") - if not cached.exists(): raise RuntimeError( f"Piper voice download completed but {cached} is missing — " @@ -157,20 +148,19 @@ def _resolve_piper_voice_path(voice: str, download_dir: Path) -> str: def _load_piper_voice_for_config(tts_config: Dict[str, Any]) -> Tuple[Any, Dict[str, Any]]: """Resolve + load (or fetch from cache) the Piper voice ``tts_config`` selects. - Shared by synthesis and ``warm_tts_provider`` so a warm-up fills exactly - the cache slot the next synthesis hits. Returns ``(voice, piper_config)``. + Shared by synthesis and ``warm_tts_provider`` so a warm-up fills exactly the cache + slot the next synthesis hits. Returns ``(voice, piper_config)``. """ PiperVoice = _origin()._import_piper() - - piper_config = tts_config.get("piper") or {} if isinstance(tts_config, dict) else {} + piper_config = _section(tts_config, "piper") voice_name = piper_config.get("voice") or DEFAULT_PIPER_VOICE download_dir = Path(piper_config.get("voices_dir") or _get_piper_voices_dir()).expanduser() download_dir.mkdir(parents=True, exist_ok=True) use_cuda = bool(piper_config.get("use_cuda", False)) model_path = _resolve_piper_voice_path(voice_name, download_dir) - # speaker_id is applied per call via syn_config, so one PiperVoice - # instance serves every speaker and stays out of the cache key. + # speaker_id is applied per call via syn_config, so one PiperVoice instance serves + # every speaker and stays out of the cache key. cache_key = f"{model_path}::cuda={use_cuda}" def _load_piper_voice(): @@ -179,8 +169,10 @@ def _load_piper_voice_for_config(tts_config: Dict[str, Any]) -> Tuple[Any, Dict[ logger.info("[Piper] Voice loaded") return v - voice = _tts_cache_get_or_load(_piper_voice_cache, cache_key, _load_piper_voice) - return voice, piper_config + return _tts_cache_get_or_load(_piper_voice_cache, cache_key, _load_piper_voice), piper_config + + +_PIPER_ADVANCED_KNOBS = ("length_scale", "noise_scale", "noise_w_scale", "volume", "normalize_audio", "speaker_id") def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str: @@ -188,19 +180,15 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any]) voice, piper_config = _load_piper_voice_for_config(tts_config) - # Bad speaker_id input drops to 0 (Piper's default); booleans are rejected - # outright since True/False would silently coerce to 1/0. + # Bad speaker_id input drops to 0 (Piper's default); booleans are rejected outright + # since True/False would silently coerce to 1/0. _raw_speaker = piper_config.get("speaker_id", 0) speaker_id = 0 if isinstance(_raw_speaker, bool) or not isinstance(_raw_speaker, int) else _raw_speaker - # Only build a SynthesisConfig when an advanced knob is configured, so we - # don't depend on a newer piper-tts than the user's unless we must. + # Only build a SynthesisConfig when an advanced knob is configured, so we don't + # depend on a newer piper-tts than the user's unless we must. syn_config = None - has_advanced = any( - k in piper_config - for k in ("length_scale", "noise_scale", "noise_w_scale", "volume", "normalize_audio", "speaker_id") - ) - if has_advanced: + if any(k in piper_config for k in _PIPER_ADVANCED_KNOBS): try: from piper import SynthesisConfig # type: ignore syn_config = SynthesisConfig( @@ -212,10 +200,7 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any]) speaker_id=speaker_id, ) except ImportError: - logger.warning( - "[Piper] SynthesisConfig not available in this piper-tts " - "version — advanced knobs ignored" - ) + logger.warning("[Piper] SynthesisConfig not available in this piper-tts version — advanced knobs ignored") wav_path = _wav_sidecar_path(output_path) with wave.open(wav_path, "wb") as wav_file: @@ -226,15 +211,14 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any]) return _finalize_wav_output(wav_path, output_path) -# =========================================================================== +# --------------------------------------------------------------------------- # KittenTTS (local ONNX, 25-80MB models, CPU only) -# =========================================================================== +# --------------------------------------------------------------------------- def _load_kittentts_model_for_config(tts_config: Dict[str, Any]) -> Tuple[Any, Dict[str, Any]]: """Load (or fetch from cache) the KittenTTS model; returns ``(model, kittentts_config)``.""" KittenTTS = _origin()._import_kittentts() - kt_config = tts_config.get("kittentts", {}) if isinstance(tts_config, dict) else {} - kt_config = kt_config or {} + kt_config = _section(tts_config, "kittentts") model_name = kt_config.get("model", DEFAULT_KITTENTTS_MODEL) def _load_kittentts_model(): @@ -243,8 +227,7 @@ def _load_kittentts_model_for_config(tts_config: Dict[str, Any]) -> Tuple[Any, D logger.info("[KittenTTS] Model loaded successfully") return m - model = _tts_cache_get_or_load(_kittentts_model_cache, model_name, _load_kittentts_model) - return model, kt_config + return _tts_cache_get_or_load(_kittentts_model_cache, model_name, _load_kittentts_model), kt_config def _generate_kittentts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str: diff --git a/tools/tts_tool_openai.py b/tools/tts_tool_openai.py index c9e00c7eb6..6553e2a20f 100644 --- a/tools/tts_tool_openai.py +++ b/tools/tts_tool_openai.py @@ -1,12 +1,10 @@ """OpenAI-compatible TTS backends for ``tools.tts_tool``: OpenAI and DeepInfra. -Also owns the managed-gateway (Nous portal ``openai-audio`` proxy) route -selection that decides where the OpenAI client points. Seams that tests -monkeypatch on the origin module (``_load_tts_config``, ``read_selection``, -``resolve_openai_audio_api_key``, ``resolve_managed_tool_gateway``, -``_import_openai_client``, ``_resolve_openai_audio_client_config``, -``_resolve_provider_key``, ``_generate_openai_tts``) are resolved through -:func:`_origin` at call time so those patches keep applying. +Also owns the managed-gateway (Nous portal ``openai-audio`` proxy) route selection that +decides where the OpenAI client points. Seams tests monkeypatch on the origin module +(``_load_tts_config``, ``read_selection``, ``resolve_openai_audio_api_key``, +``resolve_managed_tool_gateway``, ``_import_openai_client``, ``_resolve_provider_key``, +``_generate_openai_tts``, ...) are resolved through :func:`_origin` at call time. """ from __future__ import annotations @@ -17,9 +15,7 @@ from typing import Any, Dict, Optional from urllib.parse import urljoin from tools.tool_backend_helpers import ( - NOUS_MANAGED_PROVIDER, - nous_tool_gateway_unavailable_message, - selection_error, + NOUS_MANAGED_PROVIDER, nous_tool_gateway_unavailable_message, selection_error, ) from tools.tts_tool_providers import _tts_response_format_from_path @@ -34,8 +30,7 @@ def _origin(): DEFAULT_OPENAI_MODEL = "gpt-4o-mini-tts" -# The managed OpenAI audio gateway (Nous portal proxy) only proxies these -# speech models; anything else is 400 "Unsupported managed OpenAI speech model". +# The managed OpenAI audio gateway only proxies these; anything else is 400 "Unsupported". MANAGED_OPENAI_TTS_MODELS = frozenset({"gpt-4o-mini-tts"}) DEFAULT_OPENAI_VOICE = "alloy" DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1" @@ -50,63 +45,50 @@ def _managed_openai_audio_route() -> Optional[tuple]: return gateway.nous_user_token, urljoin(f"{gateway.gateway_origin.rstrip('/')}/", "v1"), True +def _openai_section(tts_config: Any, key: str) -> Dict[str, Any]: + """``tts.`` as a dict (``tts.openai: null`` in YAML yields None — coalesce so .get() is safe).""" + section = tts_config.get(key) if isinstance(tts_config, dict) else None + return section if isinstance(section, dict) else {} + + def _resolve_openai_audio_client_config() -> tuple[str, str, bool]: """Return ``(api_key, base_url, is_managed)`` for the OpenAI audio client. - ``is_managed`` marks the Nous managed audio gateway (a restricted proxy) - so callers can coerce the request to what it supports. Strict selection - semantics on the stored ``tts`` provider: - - ``"nous"`` → managed gateway ONLY; unentitled/unreachable is an error. - - any other stored provider → direct credentials ONLY (``tts.openai.api_key`` - then ``VOICE_TOOLS_OPENAI_KEY``/``OPENAI_API_KEY``); no silent managed fallback. - - never-configured tts section → legacy ladder: config key → env key → managed. + ``is_managed`` marks the Nous managed audio gateway (a restricted proxy) so callers can + coerce the request to what it supports. Strict selection semantics on the stored + ``tts`` provider: ``"nous"`` → managed gateway ONLY (unentitled/unreachable is an + error); any other stored provider → direct credentials ONLY (``tts.openai.api_key`` + then ``VOICE_TOOLS_OPENAI_KEY``/``OPENAI_API_KEY``), no silent managed fallback; + never-configured tts section → legacy ladder: config key → env key → managed. """ - tts_config = _origin()._load_tts_config() - openai_cfg = (tts_config.get("openai") if isinstance(tts_config, dict) else None) or {} - cfg_api_key = openai_cfg.get("api_key") or "" - cfg_base_url = openai_cfg.get("base_url") or "" - direct_base = cfg_base_url or DEFAULT_OPENAI_BASE_URL - - selected = _origin().read_selection("tts") + origin = _origin() + openai_cfg = _openai_section(origin._load_tts_config(), "openai") + direct_base = openai_cfg.get("base_url") or DEFAULT_OPENAI_BASE_URL + selected = origin.read_selection("tts") if selected == NOUS_MANAGED_PROVIDER: route = _managed_openai_audio_route() if route is None: raise ValueError(selection_error( - "tts", - NOUS_MANAGED_PROVIDER, - "the Nous Tool Gateway is not available (not entitled or " - "unreachable)", + "tts", NOUS_MANAGED_PROVIDER, + "the Nous Tool Gateway is not available (not entitled or unreachable)", )) return route - if cfg_api_key: - return cfg_api_key, direct_base, False - direct_api_key = _origin().resolve_openai_audio_api_key() + direct_api_key = openai_cfg.get("api_key") or origin.resolve_openai_audio_api_key() if direct_api_key: return direct_api_key, direct_base, False - if selected is not None: raise ValueError(selection_error( - "tts", - selected, - "neither tts.openai.api_key in config nor " - "VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set", + "tts", selected, + "neither tts.openai.api_key in config nor VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set", )) route = _managed_openai_audio_route() if route is None: - message = ( - "Neither tts.openai.api_key in config nor " - "VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set" - ) - if _origin().managed_nous_tools_enabled(): - message += ( - ". " - + nous_tool_gateway_unavailable_message( - "managed OpenAI audio for TTS", - ) - ) + message = "Neither tts.openai.api_key in config nor VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set" + if origin.managed_nous_tools_enabled(): + message += ". " + nous_tool_gateway_unavailable_message("managed OpenAI audio for TTS") raise ValueError(message) return route @@ -130,18 +112,16 @@ def _generate_openai_tts( model: Optional[str] = None, voice: Optional[str] = None, speed: Optional[float] = None, - instructions: Optional[str] = None, -) -> str: + instructions: Optional[str] = None) -> str: """Generate audio via the OpenAI ``audio.speech.create`` SDK shape. Explicit kwargs let OpenAI-compatible backends (DeepInfra) pass their own - credentials/model/voice and skip ``_resolve_openai_audio_client_config`` - (the managed-gateway path). When None: ``api_key`` comes from the OpenAI - auth chain, ``base_url`` from ``tts.openai.base_url`` then the auth-chain - fallback then the OpenAI default, model/voice/speed from ``tts.openai`` - (speed falling back to global ``tts.speed``). ``instructions`` is - forwarded only when truthy so ``tts-1`` and strict OpenAI-compatible - servers that reject unknown kwargs are unaffected. + credentials/model/voice and skip ``_resolve_openai_audio_client_config`` (the + managed-gateway path). When None: ``api_key`` comes from the OpenAI auth chain, + ``base_url`` from ``tts.openai.base_url`` then the auth-chain fallback then the OpenAI + default, model/voice/speed from ``tts.openai`` (speed falling back to global + ``tts.speed``). ``instructions`` is forwarded only when truthy so ``tts-1`` and strict + OpenAI-compatible servers that reject unknown kwargs are unaffected. """ fallback_base: Optional[str] = None is_managed = False @@ -149,31 +129,23 @@ def _generate_openai_tts( if api_key is None: api_key, fallback_base, is_managed = _origin()._resolve_openai_audio_client_config() - # ``tts.openai: null`` in YAML yields None — coalesce so .get() is safe. - oai_config = (tts_config.get("openai") if isinstance(tts_config, dict) else None) or {} + oai_config = _openai_section(tts_config, "openai") if model is None: model = oai_config.get("model", DEFAULT_OPENAI_MODEL) if voice is None: voice = oai_config.get("voice", DEFAULT_OPENAI_VOICE) config_base_url = oai_config.get("base_url") if base_url is None: - # Config override beats the auth-chain fallback; an explicit arg - # (DeepInfra) skipped this block and always wins. + # Config override beats the auth-chain fallback; an explicit arg (DeepInfra) always wins. base_url = config_base_url or fallback_base or DEFAULT_OPENAI_BASE_URL if speed is None: speed_default = tts_config.get("speed", 1.0) if isinstance(tts_config, dict) else 1.0 speed = float(oai_config.get("speed", speed_default)) language = oai_config.get("language") - # The managed gateway only proxies MANAGED_OPENAI_TTS_MODELS; coerce a - # direct-OpenAI model (e.g. "tts-1-hd") unless the user redirected - # base_url to their own endpoint. - if ( - is_managed - and not explicit_base_url - and not config_base_url - and model not in MANAGED_OPENAI_TTS_MODELS - ): + # The managed gateway only proxies MANAGED_OPENAI_TTS_MODELS; coerce a direct-OpenAI + # model (e.g. "tts-1-hd") unless the user redirected base_url to their own endpoint. + if is_managed and not explicit_base_url and not config_base_url and model not in MANAGED_OPENAI_TTS_MODELS: logger.warning( "TTS: managed OpenAI audio gateway does not support model %r; " "falling back to %s. Set VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY " @@ -182,8 +154,6 @@ def _generate_openai_tts( ) model = DEFAULT_OPENAI_MODEL - response_format = _tts_response_format_from_path(output_path) - OpenAIClient = _origin()._import_openai_client() client = OpenAIClient(api_key=api_key, base_url=base_url) try: @@ -191,7 +161,7 @@ def _generate_openai_tts( "model": model, "voice": voice, "input": text, - "response_format": response_format, + "response_format": _tts_response_format_from_path(output_path), "extra_headers": {"x-idempotency-key": str(uuid.uuid4())}, } if speed != 1.0: @@ -213,22 +183,15 @@ def _generate_openai_tts( def _generate_deepinfra_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str: """Resolve DeepInfra credentials/model, then delegate to the OpenAI handler. - DeepInfra's audio endpoint is OpenAI-compatible. Model ids come live from - the shared ``hermes_cli.models`` catalog helpers (no hardcoded ids, so - retired models disappear without a patch). + DeepInfra's audio endpoint is OpenAI-compatible. Model ids come live from the shared + ``hermes_cli.models`` catalog helpers (no hardcoded ids, so retired models disappear + without a patch). """ api_key = _origin()._resolve_provider_key("DEEPINFRA_API_KEY", "deepinfra") if not api_key: - raise ValueError( - "DEEPINFRA_API_KEY not set. Run `hermes setup` to configure, " - "or set the env var directly." - ) - - # ``tts.deepinfra: null`` yields None (no DEFAULT_CONFIG block to merge over). - di_config = tts_config.get("deepinfra") if isinstance(tts_config, dict) else None - if not isinstance(di_config, dict): - di_config = {} + raise ValueError("DEEPINFRA_API_KEY not set. Run `hermes setup` to configure, or set the env var directly.") + di_config = _openai_section(tts_config, "deepinfra") from hermes_cli.models import deepinfra_base_url, deepinfra_model_ids model = di_config.get("model") diff --git a/tools/tts_tool_plugins.py b/tools/tts_tool_plugins.py index b290819ec6..46c0d8f7e8 100644 --- a/tools/tts_tool_plugins.py +++ b/tools/tts_tool_plugins.py @@ -1,10 +1,9 @@ """Plugin-registered TTS providers for ``tools.tts_tool``. -Routes ``tts.provider: `` values that are neither built-in nor a -``type: command`` entry to a :class:`agent.tts_provider.TTSProvider` -registered by a plugin. Discovery goes through -``hermes_cli.plugins._ensure_plugins_discovered`` (imported lazily so the -tool module stays importable without the plugin machinery). +Routes ``tts.provider: `` values that are neither built-in nor a ``type: command`` +entry to a :class:`agent.tts_provider.TTSProvider` registered by a plugin. Discovery goes +through ``hermes_cli.plugins._ensure_plugins_discovered`` (imported lazily so the tool +module stays importable without the plugin machinery). """ from __future__ import annotations @@ -25,10 +24,9 @@ logger = logging.getLogger("tools.tts_tool") def _lookup_plugin_provider(key: str, *, discover: bool = True, retry: bool = False): """The registered ``TTSProvider`` named *key*, or None. - ``discover`` runs plugin discovery first; ``retry`` re-discovers with - ``force=True`` on a miss (long-lived sessions may have discovered plugins - before this one was installed/enabled). Raises on registry/discovery - failure — callers decide whether that is fatal. + ``discover`` runs plugin discovery first; ``retry`` re-discovers with ``force=True`` on + a miss (long-lived sessions may have discovered plugins before this one was + installed/enabled). Raises on registry/discovery failure — callers decide if fatal. """ from agent.tts_registry import get_provider @@ -43,24 +41,14 @@ def _lookup_plugin_provider(key: str, *, discover: bool = True, retry: bool = Fa return plugin_provider -def _dispatch_to_plugin_provider( - text: str, - output_path: str, - provider: str, - tts_config: Dict[str, Any], -) -> Optional[str]: +def _dispatch_to_plugin_provider(text: str, output_path: str, provider: str, tts_config: Dict[str, Any]) -> Optional[str]: """Route to a plugin-registered TTS provider; None means "fall through". - Invariants enforced here even though the caller checks them too, so a - caller refactor can't silently break them: - - 1. Built-in names never reach the plugin registry. - 2. A same-named ``type: command`` provider wins over a plugin. - 3. Dispatch fires only for a registered :class:`TTSProvider` whose name - equals the configured value; unknown names return None. - - Plugin exceptions propagate — the outer ``text_to_speech_tool`` converts - them to the standard error envelope. + Invariants enforced here even though the caller checks them too, so a caller refactor + can't silently break them: built-in names never reach the plugin registry; a same-named + ``type: command`` provider wins over a plugin; dispatch fires only for a registered + :class:`TTSProvider` whose name equals the configured value (unknown names -> None). + Plugin exceptions propagate — ``text_to_speech_tool`` converts them to the error envelope. """ if not provider: return None @@ -77,8 +65,8 @@ def _dispatch_to_plugin_provider( if plugin_provider is None: return None - # voice/model/speed/format are optional per the TTSProvider.synthesize - # contract; providers fall back to their own defaults on None. + # voice/model/speed/format are optional per the TTSProvider.synthesize contract; + # providers fall back to their own defaults on None. cfg = tts_config if isinstance(tts_config, dict) else {} voice = cfg.get("voice") model = cfg.get("model") @@ -87,8 +75,7 @@ def _dispatch_to_plugin_provider( logger.info("Generating speech with plugin TTS provider '%s'...", key) written = plugin_provider.synthesize( - text, - output_path, + text, output_path, voice=voice if isinstance(voice, str) and voice else None, model=model if isinstance(model, str) and model else None, speed=float(speed) if isinstance(speed, (int, float)) else None, @@ -99,10 +86,7 @@ def _dispatch_to_plugin_provider( def _plugin_provider_is_voice_compatible(provider: str) -> bool: - """True when the registered plugin provider opts into voice-bubble delivery. - - Any registry/property failure means False (safe default, like command providers). - """ + """True when the registered plugin provider opts into voice-bubble delivery (any failure -> False).""" if not provider: return False key = provider.lower().strip() @@ -110,9 +94,7 @@ def _plugin_provider_is_voice_compatible(provider: str) -> bool: return False try: plugin_provider = _lookup_plugin_provider(key, discover=False) - if plugin_provider is None: - return False - return bool(plugin_provider.voice_compatible) + return plugin_provider is not None and bool(plugin_provider.voice_compatible) except Exception as exc: # noqa: BLE001 logger.debug("tts plugin voice_compatible check failed for '%s': %s", key, exc) return False diff --git a/tools/tts_tool_providers.py b/tools/tts_tool_providers.py index 8a7ec8f964..bb1f35e6f4 100644 --- a/tools/tts_tool_providers.py +++ b/tools/tts_tool_providers.py @@ -533,8 +533,7 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any payload = {"model": model, "text": text, "voice_id": voice_id} response = _post_json(base_url, payload, { - "Content-Type": "application/json", - "Authorization": f"Bearer {runtime.api_key}", + "Content-Type": "application/json", "Authorization": f"Bearer {runtime.api_key}" }) if is_t2a_v2: