diff --git a/tools/tts_tool.py b/tools/tts_tool.py index c43e591518..a03962924d 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -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.`` 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.`` -keeps resolving and tests patching ``tools.tts_tool.`` 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.`` +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.`` 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 --- diff --git a/tools/tts_tool_delivery.py b/tools/tts_tool_delivery.py index eb3781e768..a28d1ea317 100644 --- a/tools/tts_tool_delivery.py +++ b/tools/tts_tool_delivery.py @@ -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..max_text_length``. +# Per-provider input-character caps (official docs); override: ``tts..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: diff --git a/tools/tts_tool_lifecycle.py b/tools/tts_tool_lifecycle.py index 84179d0329..da65e96b9e 100644 --- a/tools/tts_tool_lifecycle.py +++ b/tools/tts_tool_lifecycle.py @@ -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 ``_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} diff --git a/tools/tts_tool_local.py b/tools/tts_tool_local.py index 6a810a334a..ed0452465b 100644 --- a/tools/tts_tool_local.py +++ b/tools/tts_tool_local.py @@ -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) diff --git a/tools/tts_tool_plugins.py b/tools/tts_tool_plugins.py index 0dd8d8c83b..19960ab67e 100644 --- a/tools/tts_tool_plugins.py +++ b/tools/tts_tool_plugins.py @@ -1,9 +1,7 @@ -"""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). +"""Plugin-registered TTS providers for ``tools.tts_tool``: routes ``tts.provider: `` 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)