refactor(tools): collapse tts_tool/delivery/local/lifecycle/plugins guards and single-use locals; tighten docstrings

This commit is contained in:
Teknium
2026-09-03 01:00:06 -07:00
parent 282d714837
commit 6df5cbb6d1
5 changed files with 109 additions and 182 deletions

View File

@@ -1,16 +1,16 @@
#!/usr/bin/env python3
"""Text-to-speech tool: config resolution, built-in provider dispatch, output policy, registration.
Built-in providers: Edge (free default), ElevenLabs, OpenAI, DeepInfra, MiniMax, Mistral,
Gemini, xAI, and the local NeuTTS / KittenTTS / Piper engines; plus any ``type: command``
provider under ``tts.providers.<name>`` and plugin-registered providers. Output is Opus
(.ogg) for voice-bubble platforms, MP3 elsewhere. Sibling ``tts_tool_*`` modules hold the
backends/delivery/lifecycle; their names are re-imported here so ``tools.tts_tool.<name>``
keeps resolving and tests patching ``tools.tts_tool.<seam>`` still take effect (siblings
resolve those seams through ``_origin()`` at call time).
Built-ins: Edge (free default), ElevenLabs, OpenAI, DeepInfra, MiniMax, Mistral, Gemini, xAI,
local NeuTTS / KittenTTS / Piper; plus ``type: command`` providers under ``tts.providers.<name>``
and plugin-registered ones. Output is Opus (.ogg) on voice-bubble platforms, MP3 elsewhere.
Sibling ``tts_tool_*`` modules hold backends/delivery/lifecycle; their names are re-imported
here so ``tools.tts_tool.<name>`` resolves and test patches on this module still apply
(siblings read those seams through ``_origin()`` at call time).
"""
import asyncio
import contextlib
import datetime
import importlib.util
import json
@@ -92,16 +92,14 @@ from tools.tts_tool_lifecycle import ( # noqa: F401 — historical names re-exp
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."""
``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:
try:
with contextlib.suppress(Exception):
from tools.lazy_deps import ensure
ensure(feature, prompt=False)
except Exception:
pass
mod = importlib.import_module(module)
return getattr(mod, attr) if attr else mod
_import.__name__ = f"_import_{module.split('.')[0]}"
@@ -146,8 +144,7 @@ def _get_default_output_dir() -> str:
return str(get_hermes_dir("cache/audio", "audio_cache"))
DEFAULT_OUTPUT_DIR = _get_default_output_dir()
_DEFAULT_OUTPUT_DIR_AT_IMPORT = DEFAULT_OUTPUT_DIR
DEFAULT_OUTPUT_DIR = _DEFAULT_OUTPUT_DIR_AT_IMPORT = _get_default_output_dir()
def _default_output_dir() -> str:
@@ -172,8 +169,8 @@ def _load_tts_config() -> Dict[str, Any]:
def _get_provider(tts_config: Dict[str, Any]) -> str:
"""The configured TTS provider, or the free default — inference credentials never imply consent
to paid speech. ``nous`` is serviced by the OpenAI path via the managed openai-audio gateway."""
"""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
@@ -187,8 +184,7 @@ _FFMPEG_OPUS_PROVIDERS = frozenset({"edge", "neutts", "minimax", "xai", "kittent
# --- Built-in provider dispatch ---
# provider -> (availability predicate or None, log label, generator name, "package missing" error).
# Predicates and generator names resolve module globals at call time so tests that monkeypatch
# ``tools.tts_tool._import_x`` / ``_check_x`` / ``_generate_x`` apply.
# 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"),
@@ -221,17 +217,18 @@ def _error_json(message: str) -> str:
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:
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
pool.submit(lambda: asyncio.run(_generate_edge_tts(text, file_str, tts_config))).result(timeout=60)
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=1) as pool:
pool.submit(run).result(timeout=60)
except RuntimeError:
asyncio.run(_generate_edge_tts(text, file_str, tts_config))
run()
def _select_builtin_engine(provider: str) -> tuple:
"""Check a built-in provider's SDK -> ``(engine, None)`` or ``(provider, error_json)``. Unknown
names take the Edge default; without edge-tts NeuTTS is the fallback (engine != provider)."""
"""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
@@ -261,7 +258,7 @@ def _synthesize_builtin(engine: str, text: str, file_str: str, tts_config: Dict[
def _finalize_voice_delivery(
file_str: str, provider: str, command_provider_config: Optional[Dict[str, Any]], want_opus: bool,
) -> tuple:
"""Decide voice-bubble eligibility, Opus-converting when needed -> ``(path, voice_compatible)``.
"""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
@@ -316,8 +313,7 @@ def _resolve_output_base(
if has_traversal_component(output_path):
return None, _error_json(
f"output_path contains '..' traversal component: {output_path}. "
"Use an absolute path or one relative to the current directory "
"without '..'.")
"Use an absolute path or one relative to the current directory without '..'.")
file_path = Path(output_path).expanduser()
if command_provider_config is not None:
file_path = _configured_command_tts_output_path(file_path, command_provider_config)
@@ -327,14 +323,12 @@ def _resolve_output_base(
f"output_path targets a protected credential or system path: "
f"{file_path}. Choose a normal audio output location.")
else:
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
out_dir = Path(_default_output_dir())
out_dir.mkdir(parents=True, exist_ok=True)
if command_provider_config is not None:
ext = _get_command_tts_output_format(command_provider_config)
else:
ext = "ogg" if want_opus and provider in _NATIVE_OPUS_PROVIDERS else "mp3"
file_path = out_dir / f"tts_{timestamp}.{ext}"
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
file_path = Path(_default_output_dir()) / f"tts_{timestamp}.{ext}"
file_path.parent.mkdir(parents=True, exist_ok=True)
return file_path, None
@@ -356,7 +350,7 @@ def _text_to_speech_single(
text: str, file_str: str, *, provider: str, tts_config: Dict[str, Any],
command_provider_config: Optional[Dict[str, Any]], want_opus: bool, instructions: Optional[str],
) -> str:
"""Synthesize one normalized, provider-safe chunk into *file_str*; returns the result envelope.
"""Synthesize one provider-safe chunk into *file_str*; returns the result envelope.
Command providers resolve BEFORE built-in dispatch, but built-in names short-circuit so
``tts.providers.openai.command`` can't shadow OpenAI. Plugins fire only for names that are
@@ -438,9 +432,8 @@ def text_to_speech_tool(
separate valid files and no over-limit artifact is ever returned."""
if not text or not text.strip():
return tool_error("Text is required", success=False)
try: # shared cleaner: markdown, emoji, think blocks, verifier footer, units, newlines
# Shared cleaner: markdown, emoji, think blocks, verifier footer, units, newlines.
try:
from tools.tts_text_normalize import prepare_spoken_text
text = prepare_spoken_text(text, max_chars=None)
except Exception:
@@ -513,7 +506,6 @@ def _minimax_requirements() -> bool:
def _xai_requirements() -> bool:
try:
from tools.xai_http import resolve_xai_http_credentials
return bool(resolve_xai_http_credentials().get("api_key"))
except Exception:
return False
@@ -542,9 +534,7 @@ def check_tts_requirements() -> bool:
if _resolve_command_provider_config(provider, tts_config) is not None:
return True
check = _BUILTIN_REQUIREMENTS.get(provider)
if check is not None:
return check()
return _plugin_provider_is_available(provider)
return check() if check is not None else _plugin_provider_is_available(provider)
# --- Registry ---

View File

@@ -1,10 +1,9 @@
"""Long-form chunking, ffmpeg encoding, container repair and delivery packing.
"""Long-form chunking, ffmpeg encoding, container repair and delivery packing (``tools.tts_tool``).
Provider-agnostic post-processing for ``tools.tts_tool``: split text under a
per-request cap, wrap raw PCM as WAV, convert WAV/MP3 to the target container,
sniff/repair mislabelled ``.ogg`` files, and combine final-encoded chunks under a
destination platform's upload limit. Origin module re-imports every name under
its historical spelling.
Provider-agnostic post-processing: split text under a per-request cap, wrap raw PCM as WAV,
convert WAV/MP3 to the target container, sniff/repair mislabelled ``.ogg`` files, and combine
final-encoded chunks under a destination platform's upload limit. Also home of the sibling
helpers ``_origin`` / ``_section`` / ``_remove_quietly``.
"""
from __future__ import annotations
@@ -43,11 +42,9 @@ def _section(tts_config: Any, key: str) -> Dict[str, Any]:
return section if isinstance(section, dict) else {}
# Final fallback when provider isn't recognised at all.
FALLBACK_MAX_TEXT_LENGTH = 4000
FALLBACK_MAX_TEXT_LENGTH = 4000 # provider not recognised at all
# Per-provider input-character caps (from official provider docs); override
# via ``tts.<provider>.max_text_length``.
# Per-provider input-character caps (official docs); override: ``tts.<provider>.max_text_length``.
PROVIDER_MAX_TEXT_LENGTH: Dict[str, int] = {
"edge": 5000, # edge-tts practical sync limit
"openai": 4096, # https://platform.openai.com/docs/guides/text-to-speech
@@ -75,9 +72,7 @@ ELEVENLABS_MODEL_MAX_TEXT_LENGTH: Dict[str, int] = {
def _positive_int(value: Any) -> Optional[int]:
"""*value* when it is a positive non-bool int, else None."""
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
return None
return value
return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None
def _resolve_max_text_length(provider: Optional[str], tts_config: Optional[Dict[str, Any]] = None) -> int:
@@ -93,10 +88,8 @@ def _resolve_max_text_length(provider: Optional[str], tts_config: Optional[Dict[
override = _positive_int(prov_cfg.get("max_text_length"))
if override:
return override
if key == "elevenlabs":
from tools.tts_tool_providers import DEFAULT_ELEVENLABS_MODEL_ID # providers imports this module
model_id = prov_cfg.get("model_id") or DEFAULT_ELEVENLABS_MODEL_ID
mapped = ELEVENLABS_MODEL_MAX_TEXT_LENGTH.get(str(model_id).strip())
if mapped:
@@ -161,11 +154,9 @@ def _resolve_audio_delivery_profile(
def _pack_under_cap(pieces: List[str], max_chars: int, *, slice_oversized: bool = False) -> List[str]:
"""Greedily join *pieces* with single spaces, starting a new chunk past *max_chars*.
With ``slice_oversized`` an over-long piece flushes the running chunk and emits its hard
slices as their own chunks (the tail slice is not merged with following pieces).
"""
"""Greedily join *pieces* with single spaces, starting a new chunk past *max_chars*. With
``slice_oversized`` an over-long piece flushes the running chunk and emits its hard slices as
their own chunks (the tail slice is not merged with following pieces)."""
chunks: List[str] = []
current = ""
for piece in pieces:
@@ -256,9 +247,7 @@ def _remove_quietly(path: Optional[str]) -> None:
def _wav_sidecar_path(output_path: str) -> str:
"""Path a WAV-native engine writes to before conversion to *output_path*'s format."""
if output_path.endswith(".wav"):
return output_path
return output_path.rsplit(".", 1)[0] + ".wav"
return output_path if output_path.endswith(".wav") else output_path.rsplit(".", 1)[0] + ".wav"
def _finalize_wav_output(wav_path: str, output_path: str) -> str:
@@ -279,29 +268,24 @@ def _wrap_pcm_as_wav(
pcm_bytes: bytes, sample_rate: int = GEMINI_TTS_SAMPLE_RATE,
channels: int = GEMINI_TTS_CHANNELS, sample_width: int = GEMINI_TTS_SAMPLE_WIDTH) -> bytes:
"""Wrap raw signed-little-endian PCM (e.g. Gemini's L16) with a minimal WAV RIFF header."""
byte_rate = sample_rate * channels * sample_width
block_align = channels * sample_width
data_size = len(pcm_bytes)
fmt_chunk = struct.pack(
"<4sIHHIIHH", b"fmt ", 16, 1, channels, sample_rate, byte_rate, block_align, sample_width * 8,
)
data_chunk_header = struct.pack("<4sI", b"data", data_size)
riff_size = 4 + len(fmt_chunk) + len(data_chunk_header) + data_size
fmt_chunk = struct.pack("<4sIHHIIHH", b"fmt ", 16, 1, channels, sample_rate,
sample_rate * block_align, block_align, sample_width * 8)
data_chunk_header = struct.pack("<4sI", b"data", len(pcm_bytes))
riff_size = 4 + len(fmt_chunk) + len(data_chunk_header) + len(pcm_bytes)
riff_header = struct.pack("<4sI4s", b"RIFF", riff_size, b"WAVE")
return riff_header + fmt_chunk + data_chunk_header + pcm_bytes
def _write_wav_bytes_as(wav_bytes: bytes, output_path: str) -> str:
"""Write in-memory WAV to *output_path*, ffmpeg-converting to its container.
``.ogg`` is forced to Opus (ffmpeg's .ogg default is Vorbis, which voice bubbles reject).
A failed conversion raises RuntimeError; without ffmpeg the raw WAV is written under
the requested name (misleading extension, but the audio still plays)."""
"""Write in-memory WAV to *output_path*, ffmpeg-converting to its container. ``.ogg`` is forced
to Opus (ffmpeg's .ogg default is Vorbis, which voice bubbles reject). A failed conversion
raises RuntimeError; without ffmpeg the raw WAV is written under the requested name
(misleading extension, but the audio still plays)."""
if output_path.lower().endswith(".wav"):
with open(output_path, "wb") as f:
f.write(wav_bytes)
return output_path
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
tmp.write(wav_bytes)
wav_path = tmp.name
@@ -330,7 +314,6 @@ def _ffmpeg_transcode_to_opus(input_path: str, ogg_path: str) -> Optional[str]:
"""Transcode *input_path* to real Ogg/Opus at *ogg_path* (in-place safe via temp file); None on failure."""
if shutil.which("ffmpeg") is None:
return None
in_place = os.path.abspath(input_path) == os.path.abspath(ogg_path)
work_path = ogg_path + ".tmp.ogg" if in_place else ogg_path
try:
@@ -356,15 +339,13 @@ def _ffmpeg_transcode_to_opus(input_path: str, ogg_path: str) -> Optional[str]:
# --- Container sniffing / repair ---
# Several backends silently ignore the requested opus format (Edge only emits MP3, Piper
# writes WAV, xAI writes MP3, some OpenAI-compatible servers ignore response_format="opus"),
# which breaks native voice bubbles. Sniff the magic bytes once after synthesis and repair
# when they don't match the extension.
# Several backends ignore the requested opus format (Edge/xAI emit MP3, Piper WAV, some
# OpenAI-compatible servers ignore response_format="opus"), which breaks native voice bubbles:
# sniff the magic bytes once after synthesis and repair when they don't match the extension.
def _sniff_audio_container(path: str) -> str:
"""Return a container id ('ogg', 'wav', 'mp3', 'flac', ...) or 'unknown'."""
from tools.audio_container import sniff_container
try:
with open(path, "rb") as fh:
head = fh.read(12)
@@ -381,51 +362,42 @@ def _repair_ogg_container(file_str: str) -> str:
container = _sniff_audio_container(file_str)
if container in ("ogg", "unknown"):
return file_str
logger.info("TTS wrote %s bytes into a .ogg path (%s) — transcoding to real Ogg/Opus", container, file_str)
repaired = _ffmpeg_transcode_to_opus(file_str, file_str)
if repaired:
return repaired
honest = file_str[:-4] + "." + container
try:
os.replace(file_str, honest)
logger.warning(
"Could not transcode %s to Ogg/Opus — renamed to %s so the "
"file is delivered with its real format", file_str, honest)
return honest
except OSError:
return file_str
logger.warning("Could not transcode %s to Ogg/Opus — renamed to %s so the "
"file is delivered with its real format", file_str, honest)
return honest
# --- Long-form audio combination and delivery packing ---
def _concat_audio_files(audio_paths: List[str], output_path: str, *, voice_compatible: bool = False) -> Optional[str]:
"""Combine independently encoded chunks with ffmpeg (never byte-joined).
OGG/Opus is always re-encoded (even without voice opt-in); matching MP3 chunks keep their
frames (``-c:a copy``). None when ffmpeg is missing/fails so callers keep the valid parts."""
"""Combine independently encoded chunks with ffmpeg (never byte-joined). OGG/Opus is always
re-encoded (even without voice opt-in); matching MP3 chunks keep their frames (``-c:a copy``).
None when ffmpeg is missing/fails so callers keep the valid parts."""
if not audio_paths:
raise ValueError("No audio chunks to combine")
if len(audio_paths) == 1:
source = audio_paths[0]
if os.path.abspath(source) != os.path.abspath(output_path):
shutil.copyfile(source, output_path)
if os.path.abspath(audio_paths[0]) != os.path.abspath(output_path):
shutil.copyfile(audio_paths[0], output_path)
return output_path
ffmpeg = shutil.which("ffmpeg")
if not ffmpeg:
return None
destination = Path(output_path)
destination.parent.mkdir(parents=True, exist_ok=True)
concat_path = destination.with_name(f".{destination.name}.{uuid.uuid4().hex}.concat.txt")
temp_output = destination.with_name(f".{destination.stem}.{uuid.uuid4().hex}.combining{destination.suffix}")
try:
with concat_path.open("w", encoding="utf-8") as concat_file:
for path in audio_paths:
concat_file.write(f"file {shlex.quote(os.path.abspath(path))}\n")
entries = "".join(f"file {shlex.quote(os.path.abspath(p))}\n" for p in audio_paths)
concat_path.write_text(entries, encoding="utf-8")
args = ["-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", str(concat_path), "-vn"]
suffix = destination.suffix.lower()
if voice_compatible or suffix in {".ogg", ".opus"}:
@@ -433,7 +405,6 @@ def _concat_audio_files(audio_paths: List[str], output_path: str, *, voice_compa
elif suffix == ".mp3" and all(Path(path).suffix.lower() == ".mp3" for path in audio_paths):
args += ["-c:a", "copy"]
args.append(str(temp_output))
result = _ffmpeg_run(ffmpeg, args, timeout=120)
if result.returncode == 0 and temp_output.exists() and temp_output.stat().st_size > 0:
os.replace(temp_output, destination)
@@ -450,7 +421,7 @@ def _concat_audio_files(audio_paths: List[str], output_path: str, *, voice_compa
def _build_audio_delivery_files(
audio_paths: List[str], output_path: str, profile: AudioDeliveryProfile, *, voice_compatible: bool = False,
) -> Tuple[List[str], bool]:
"""Pack final-encoded chunks and enforce the hard upload limit; returns ``(final_paths, combined_any)``.
"""Pack final-encoded chunks under the hard upload limit -> ``(final_paths, combined_any)``.
Groups are packed against the conservative target, then each combined artifact is checked
at its real size; an over-limit group is split in half and retried. A failed combine
@@ -463,7 +434,6 @@ def _build_audio_delivery_files(
raise ValueError(
f"Final-encoded TTS chunk exceeds {profile.platform} delivery "
f"limit ({size} > {profile.max_file_bytes} bytes): {path}")
base = Path(output_path)
scratch_outputs: List[str] = []
combined_any = False
@@ -486,15 +456,12 @@ def _build_audio_delivery_files(
midpoint = max(1, len(group) // 2)
return emit(group[:midpoint]) + emit(group[midpoint:])
packed: List[str] = []
for group in _pack_audio_files_for_delivery(audio_paths, profile):
packed.extend(emit(group))
groups = _pack_audio_files_for_delivery(audio_paths, profile)
packed = [path for group in groups for path in emit(group)]
final_paths: List[str] = []
for index, source in enumerate(packed, start=1):
if len(packed) == 1:
destination = base
else:
destination = base
if len(packed) > 1:
destination = base.with_name(f"{base.stem}.part{index:02d}{Path(source).suffix or base.suffix}")
if os.path.abspath(source) != os.path.abspath(destination):
destination.parent.mkdir(parents=True, exist_ok=True)
@@ -502,7 +469,6 @@ def _build_audio_delivery_files(
if destination.stat().st_size > profile.max_file_bytes:
raise ValueError(f"Final TTS deliverable exceeds {profile.platform} delivery limit: {destination}")
final_paths.append(str(destination))
try:
return final_paths, combined_any
finally:

View File

@@ -1,11 +1,10 @@
"""Local-engine lifecycle for ``tools.tts_tool``: warm-up / release leases.
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.
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, 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. Origin seams are resolved through :func:`_origin` per call.
"""
from __future__ import annotations
@@ -43,7 +42,6 @@ _LAZY_SDK_FEATURES = {"edge": "tts.edge", "elevenlabs": "tts.elevenlabs", "mistr
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 ``<hook>_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."""
@@ -62,8 +60,9 @@ def _signal_user_tts_provider(name: str, tts_config: Dict[str, Any], hook: str)
def _run() -> None:
try:
_origin()._run_command_tts(command, _get_command_tts_timeout(cfg),
env_passthrough=_command_provider_env_passthrough(cfg))
_origin()._run_command_tts(
command, _get_command_tts_timeout(cfg),
env_passthrough=_command_provider_env_passthrough(cfg))
except Exception as exc: # noqa: BLE001 — best-effort hook
logger.debug("[TTS] %s_command for %s failed: %s", hook, name, exc)
@@ -81,7 +80,6 @@ def _signal_user_tts_provider(name: str, tts_config: Dict[str, Any], hook: str)
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``."""
@@ -92,8 +90,8 @@ def warm_tts_provider(tts_config: Optional[Dict[str, Any]] = None, provider: Opt
warmer = _local_tts_warmers().get(name)
if warmer is not None:
cache = _LOCAL_TTS_MODEL_CACHES.get(name)
before = len(cache) if cache is not None else 0
cache = _LOCAL_TTS_MODEL_CACHES.get(name, {})
before = len(cache)
started = time.monotonic()
try:
warmer(tts_config)
@@ -101,9 +99,8 @@ def warm_tts_provider(tts_config: Optional[Dict[str, Any]] = None, provider: Opt
logger.warning("[TTS] warm-up for %s failed: %s", name, exc)
result.update(action="error", error=str(exc))
return result
after = len(cache) if cache is not None else 0
result.update(
warmed=True, action="loaded" if after > before else "cached",
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
@@ -118,7 +115,6 @@ def warm_tts_provider(tts_config: Optional[Dict[str, Any]] = None, provider: Opt
if feature is not None:
try:
from tools.lazy_deps import ensure, is_available
if is_available(feature):
result.update(warmed=True, action="cached")
else:
@@ -139,10 +135,9 @@ def release_tts_provider(provider: Optional[str] = None) -> Dict[str, Any]:
_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 name and cache_name != name:
continue
released += len(cache)
cache.clear()
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}

View File

@@ -1,10 +1,9 @@
"""Local on-device TTS engines for ``tools.tts_tool``: NeuTTS, Piper, KittenTTS.
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.
All three synthesize WAV natively; :func:`_finalize_wav_output` converts/renames to the requested
container. Piper and KittenTTS keep loaded models in small LRU caches registered in
``_LOCAL_TTS_MODEL_CACHES`` so warm/release can pre-load or drop them. ``_import_piper`` /
``_import_kittentts`` are resolved through the origin module at call time (test monkeypatches).
"""
from __future__ import annotations
@@ -25,14 +24,13 @@ DEFAULT_PIPER_VOICE = "en_US-lessac-medium" # balanced size/quality
_NEUTTS_SAMPLES = Path(__file__).parent / "neutts_samples"
# --- 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. Most sessions use one or two voices and a
# cold reload is cheap.
# Each entry is a whole loaded model (tens of MB); unbounded, one would be pinned per distinct
# voice for the process lifetime. Most sessions use one or two voices; a cold reload is cheap.
_TTS_MODEL_CACHE_MAX = 3
# 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.
# Provider name -> the cache it populates (warm/release in tts_tool_lifecycle; a new local engine
# adds a row here plus a loader in _local_tts_warmers()). Piper 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: Dict[str, Dict[str, Any]] = {"piper": _piper_voice_cache, "kittentts": _kittentts_model_cache}
@@ -71,8 +69,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) ->
"--model", neutts_config.get("model", "neuphonic/neutts-air-q4-gguf"),
"--device", neutts_config.get("device", "cpu")]
result = _run_helper(cmd, 120)
if result.returncode != 0:
# The synth script reports success lines as "OK:" on stderr too.
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:")]
raise RuntimeError(f"NeuTTS synthesis failed: {chr(10).join(error_lines) or 'unknown error'}")
return _finalize_wav_output(wav_path, output_path)
@@ -95,11 +92,9 @@ def _resolve_piper_voice_path(voice: str, download_dir: Path) -> str:
candidate = Path(voice).expanduser()
if candidate.suffix.lower() == ".onnx" and candidate.exists():
return str(candidate)
cached = download_dir / f"{voice}.onnx"
if cached.exists() and (download_dir / f"{voice}.onnx.json").exists():
return str(cached)
logger.info("[Piper] Downloading voice '%s' to %s (first use)", voice, download_dir)
try:
result = _run_helper(
@@ -127,11 +122,7 @@ def _load_piper_voice_for_config(tts_config: Dict[str, Any]) -> Tuple[Any, Dict[
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.
cache_key = f"{model_path}::cuda={use_cuda}"
def _load_piper_voice():
logger.info("[Piper] Loading voice: %s", model_path)
@@ -139,6 +130,8 @@ def _load_piper_voice_for_config(tts_config: Dict[str, Any]) -> Tuple[Any, Dict[
logger.info("[Piper] Voice loaded")
return v
# speaker_id is applied per call via syn_config, so one instance serves every speaker.
cache_key = f"{model_path}::cuda={use_cuda}"
return _tts_cache_get_or_load(_piper_voice_cache, cache_key, _load_piper_voice), piper_config
@@ -149,14 +142,11 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any])
import wave
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 drops to 0 (Piper's default); bools are rejected (they'd 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.
speaker_id = _raw_speaker if type(_raw_speaker) is int else 0
# 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
if any(k in piper_config for k in _PIPER_ADVANCED_KNOBS):
try:
@@ -199,12 +189,9 @@ def _load_kittentts_model_for_config(tts_config: Dict[str, Any]) -> Tuple[Any, D
def _generate_kittentts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
model, kt_config = _load_kittentts_model_for_config(tts_config)
audio = model.generate(
text,
voice=kt_config.get("voice", DEFAULT_KITTENTTS_VOICE),
speed=kt_config.get("speed", 1.0),
clean_text=kt_config.get("clean_text", True),
) # numpy array at 24kHz
audio = model.generate( # numpy array at 24kHz
text, voice=kt_config.get("voice", DEFAULT_KITTENTTS_VOICE),
speed=kt_config.get("speed", 1.0), clean_text=kt_config.get("clean_text", True))
import soundfile as sf
wav_path = _wav_sidecar_path(output_path)

View File

@@ -1,9 +1,7 @@
"""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).
"""Plugin-registered TTS providers for ``tools.tts_tool``: routes ``tts.provider: <name>`` values
that are neither built-in nor ``type: command`` to a plugin :class:`agent.tts_provider.TTSProvider`.
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
@@ -23,10 +21,8 @@ def _lookup_plugin_provider(key: str, *, discover: bool = True, retry: bool = Fa
``retry`` re-discovers with ``force=True`` on a miss (a long-lived session may predate the
plugin's install). Raises on registry/discovery failure — callers decide if fatal."""
from agent.tts_registry import get_provider
if discover:
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
plugin_provider = get_provider(key)
if plugin_provider is None and retry:
@@ -41,10 +37,8 @@ def _dispatch_to_plugin_provider(text: str, output_path: str, provider: str, tts
Invariants re-checked here so a caller refactor can't break them: built-in names never reach
the registry; a same-named ``type: command`` provider wins; only an exact registered name
dispatches. Plugin exceptions propagate to ``text_to_speech_tool``'s error envelope."""
if not provider:
return None
key = provider.lower().strip()
if key in BUILTIN_TTS_PROVIDERS:
key = (provider or "").lower().strip()
if not key or key in BUILTIN_TTS_PROVIDERS:
return None
if _is_command_provider_config(_get_named_provider_config(tts_config, key)):
return None
@@ -55,9 +49,7 @@ def _dispatch_to_plugin_provider(text: str, output_path: str, provider: str, tts
return None
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 TTSProvider.synthesize; providers default on None.
cfg = tts_config if isinstance(tts_config, dict) else {}
voice, model, speed = cfg.get("voice"), cfg.get("model"), cfg.get("speed")
fmt = cfg.get("output_format", DEFAULT_COMMAND_TTS_OUTPUT_FORMAT)
@@ -67,16 +59,13 @@ def _dispatch_to_plugin_provider(text: str, output_path: str, provider: str, tts
model=model if isinstance(model, str) and model else None,
speed=float(speed) if isinstance(speed, (int, float)) else None,
format=str(fmt).lower() if fmt else "mp3")
# Contract: returns the (possibly rewritten) output path; tolerate None.
return written if isinstance(written, str) and written else output_path
def _plugin_provider_is_voice_compatible(provider: str) -> bool:
"""True when the registered plugin provider opts into voice-bubble delivery (any failure -> False)."""
if not provider:
return False
key = provider.lower().strip()
if key in BUILTIN_TTS_PROVIDERS:
key = (provider or "").lower().strip()
if not key or key in BUILTIN_TTS_PROVIDERS:
return False
try:
plugin_provider = _lookup_plugin_provider(key, discover=False)