refactor(tools): compact tts local/openai/lifecycle/plugins modules, restore module-attr re-exports
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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": <model instances dropped>}``.
|
||||
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": <model instances dropped>}``.
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
"""``<HERMES_HOME>/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:
|
||||
|
||||
@@ -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.<key>`` 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")
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Plugin-registered TTS providers for ``tools.tts_tool``.
|
||||
|
||||
Routes ``tts.provider: <name>`` 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: <name>`` 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user