"""Local-engine lifecycle for ``tools.tts_tool``: warm-up / release leases. Local engines load lazily on first synthesis (dead air on the first spoken reply) and then stay resident. 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 after a keep-warm window (``tts.keep_warm_seconds``), so one surface's "off" can't unload a model another surface still needs and a wake-word loop that re-acquires within the window reuses the loaded model. Cloud providers have nothing resident; warming only ensures the SDK imports. Origin seams (``_load_tts_config``, ``_get_provider``) are resolved through :func:`_origin` per call. """ from __future__ import annotations import logging import threading import time from typing import Any, Callable, Dict, List, Optional from agent.memory_provider import ctx_bound from tools import tts_command_provider from tools.tts_command_provider import ( BUILTIN_TTS_PROVIDERS, _get_command_tts_timeout, _get_named_provider_config, _is_command_provider_config, command_env_passthrough as _command_provider_env_passthrough, render_command_template as _render_command_tts_template) from tools.tts_tool_delivery import _origin from tools.tts_tool_local import ( _LOCAL_TTS_MODEL_CACHES, _load_kittentts_model_for_config, _load_piper_voice_for_config) from tools.tts_tool_plugins import _lookup_plugin_provider logger = logging.getLogger("tools.tts_tool") _tts_lease_lock = threading.Lock() _tts_leases: set = set() # Pending keep-warm unload; the generation lets a timer that already woke up see it was superseded. _DEFAULT_KEEP_WARM_SECONDS = 60.0 _make_keep_warm_timer = threading.Timer # test seam _keep_warm_timer: Optional[threading.Timer] = None _keep_warm_generation = 0 def _local_tts_warmers() -> dict[str, Callable[[dict[str, Any]], Any]]: """Provider name → loader populating that engine's cache slot (same key synthesis uses).""" return { "piper": lambda cfg: _load_piper_voice_for_config(cfg)[0], "kittentts": lambda cfg: _load_kittentts_model_for_config(cfg)[0]} # pm extra for providers whose SDK installs on first use. _LAZY_SDK_FEATURES = {"edge": "edge-tts", "elevenlabs": "tts-premium", "mistral": "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; returns the action. Command providers run their optional ``_command`` (same template/env/timeout rules as ``command``) on a background thread so a toggle never waits on a model server; plugins get :meth:`TTSProvider.warm`/``release``. Best-effort: failures are logged at debug.""" if not name or name in BUILTIN_TTS_PROVIDERS: return None cfg = _get_named_provider_config(tts_config, name) try: if _is_command_provider_config(cfg): template = str(cfg.get(f"{hook}_command") or "").strip() if not template: return None command = _render_command_tts_template(template, { "voice": str(cfg.get("voice", "")), "model": str(cfg.get("model", "")), "speed": str(cfg.get("speed", tts_config.get("speed", "")))}) def _run() -> None: try: tts_command_provider.run_command_provider( command, _get_command_tts_timeout(cfg), env_passthrough=_command_provider_env_passthrough(cfg)) except Exception as exc: logger.debug("[TTS] %s_command for %s failed: %s", hook, name, exc) # ctx_bound: env_passthrough resolves through the caller's profile secret scope. threading.Thread(target=ctx_bound(_run), name=f"tts-{hook}-{name}", daemon=True).start() return hook plugin_provider = _lookup_plugin_provider(name) if plugin_provider is None: return None getattr(plugin_provider, hook)() return hook except Exception as exc: logger.debug("[TTS] %s hook for %s failed: %s", hook, name, exc) return "error" 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 (blocking; never raises). Local engines fill the same LRU slot synthesis reads (including first-use download); lazily installed cloud SDKs are made importable; user-declared providers get their warm hook; everything else is ``action: "noop"``. The result carries ``warmed`` / ``action`` / ``error``.""" if tts_config is None: tts_config = _origin()._load_tts_config() name = (provider or _origin()._get_provider(tts_config) or "").lower().strip() result: dict[str, Any] = {"provider": name, "warmed": False, "action": "noop"} warmer = _local_tts_warmers().get(name) if warmer is not None: cache = _LOCAL_TTS_MODEL_CACHES.get(name, {}) before, started = len(cache), time.monotonic() try: warmer(tts_config) except Exception as exc: # engine missing, download failed, bad voice… logger.warning("[TTS] warm-up for %s failed: %s", name, exc) result.update(action="error", error=str(exc)) return result result.update( warmed=True, action="loaded" if len(cache) > before else "cached", elapsed_ms=int((time.monotonic() - started) * 1000)) logger.info("[TTS] warm-up %s: %s in %dms", name, result["action"], result["elapsed_ms"]) return result signalled = _signal_user_tts_provider(name, tts_config, "warm") if signalled is not None: ok = signalled != "error" result.update(warmed=ok, action="warmed" if ok else "error") return result feature = _LAZY_SDK_FEATURES.get(name) if feature is not None: try: import pm if pm.available(feature): result.update(warmed=True, action="cached") else: pm.ensure_import(feature) result.update(warmed=True, action="installed") except Exception as exc: logger.debug("[TTS] SDK warm-up for %s skipped: %s", name, exc) result.update(action="error", error=str(exc)) return result def release_tts_provider(provider: Optional[str] = None) -> dict[str, Any]: """Drop resident local models -> ``{"released": }``. With ``provider`` only that engine's cache is cleared; otherwise every cache is, and the configured user provider is signalled.""" name = (provider or "").lower().strip() if not name: tts_config = _origin()._load_tts_config() _signal_user_tts_provider(_origin()._get_provider(tts_config), tts_config, "release") released = 0 for cache_name, cache in _LOCAL_TTS_MODEL_CACHES.items(): if not name or cache_name == name: released += len(cache) cache.clear() if released: logger.info("[TTS] released %d resident local model(s)", released) return {"released": released} def _keep_warm_seconds() -> float: """``tts.keep_warm_seconds``, read per call so each profile's config applies; ``0`` = unload now.""" raw = _origin()._load_tts_config().get("keep_warm_seconds", _DEFAULT_KEEP_WARM_SECONDS) try: return max(0.0, float(raw)) except (TypeError, ValueError): return _DEFAULT_KEEP_WARM_SECONDS def _cancel_keep_warm_locked() -> None: global _keep_warm_timer, _keep_warm_generation _keep_warm_generation += 1 if _keep_warm_timer is not None: _keep_warm_timer.cancel() _keep_warm_timer = None def _schedule_release_locked() -> int: """No holders left: (re)start the keep-warm window, or unload inline when it is 0. Returns the count released inline.""" global _keep_warm_timer _cancel_keep_warm_locked() delay = _keep_warm_seconds() if delay <= 0: return release_tts_provider()["released"] # ctx_bound: the unload reads config/secrets under the releasing caller's profile scope. timer = _make_keep_warm_timer( delay, ctx_bound(_release_after_keep_warm), args=(_keep_warm_generation,)) timer.daemon = True _keep_warm_timer = timer timer.start() return 0 def _release_after_keep_warm(generation: int) -> None: global _keep_warm_timer with _tts_lease_lock: if generation != _keep_warm_generation or _tts_leases: return _keep_warm_timer = None release_tts_provider() def acquire_tts_lease(lease: str, tts_config: Optional[dict[str, Any]] = None) -> dict[str, Any]: """Register ``lease`` (e.g. ``"desktop:read-aloud"``) and warm the provider. Re-acquiring is idempotent but still re-warms (cheap on a cache hit; heals a cache cleared elsewhere). An acquire inside the keep-warm window cancels the pending unload.""" with _tts_lease_lock: _cancel_keep_warm_locked() _tts_leases.add(lease) holders = len(_tts_leases) result = warm_tts_provider(tts_config) with _tts_lease_lock: # Every holder released while the load ran: the window starts now that the model is resident. if not _tts_leases: _schedule_release_locked() return {**result, "leases": holders} def release_tts_lease(lease: str) -> dict[str, Any]: """Drop ``lease``; the last one out unloads resident local models once the keep-warm window passes with no new acquire. A never-acquired lease is a no-op (still reports the holder count) so surfaces can call this unconditionally. ``released`` counts models unloaded inline.""" with _tts_lease_lock: held = lease in _tts_leases _tts_leases.discard(lease) holders = len(_tts_leases) released = _schedule_release_locked() if held and holders == 0 else 0 return {"leases": holders, "released": released} def tts_lease_holders() -> list[str]: """Snapshot of live lease names (diagnostics / tests).""" with _tts_lease_lock: return sorted(_tts_leases) def _reset_tts_leases_for_tests() -> None: with _tts_lease_lock: _cancel_keep_warm_locked() _tts_leases.clear()