Files
hermes-agent/tools/tts_tool_plugins.py
Teknium 10f5530967 fix(tts): plugin PCM streamers keep the sync dispatcher's voice and routing
Follow-up to the salvaged plugin-streaming commit. The adapter read
voice/model from the plugin's own tts.<name> section, so a streamed reply
spoke the plugin default while synthesize() got tts.voice: the voice
changed with the surface. It also skipped the sync dispatcher's routing:
a same-named `type: command` provider (which wins for synthesize) lost
to the plugin on the streaming path, and an is_available() == False
plugin was streamed anyway.

- tools/tts_tool_plugins: one routing guard (_plugin_route_key) and one
  voice/model/speed builder (_plugin_voice_kwargs), shared by
  synthesize() dispatch and the new _plugin_pcm_streaming_provider
  (streams_pcm + positive stream_sample_rate + is_available(), read at
  resolve time)
- tools/tts_streaming: _PluginPCMStreamer stays a thin adapter;
  stream_channels/stream_sample_width dropped (the contract is int16
  mono, which every sink assumes)
- agent/tts_provider: streams_pcm / stream_sample_rate declared on the
  ABC with the contract
- tests: two invariant tests next to the plugin dispatch tests (the
  streaming test module skips without numpy); docs for plugin authors
2026-10-06 00:52:41 -07:00

131 lines
6.3 KiB
Python

"""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
import logging
from typing import Any, Dict, Optional
from tools.tts_command_provider import (
BUILTIN_TTS_PROVIDERS, DEFAULT_COMMAND_TTS_OUTPUT_FORMAT, _get_named_provider_config,
_is_command_provider_config)
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 (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:
_ensure_plugins_discovered(force=True)
plugin_provider = get_provider(key)
return plugin_provider
def _plugin_route_key(provider: str, tts_config: Dict[str, Any]) -> Optional[str]:
"""Normalized *provider* when a plugin may service it, else None: built-in names never reach the
registry and a same-named ``type: command`` provider wins (config is more local than a plugin)."""
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
return key
def _plugin_voice_kwargs(tts_config: Dict[str, Any]) -> Dict[str, Any]:
"""``voice`` / ``model`` / ``speed`` for a plugin call (None = provider default). Shared by
``synthesize`` and the streaming path so a streamed reply keeps the configured voice."""
cfg = tts_config if isinstance(tts_config, dict) else {}
voice, model, speed = cfg.get("voice"), cfg.get("model"), cfg.get("speed")
return {"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}
def _plugin_pcm_streaming_provider(provider: str, tts_config: Dict[str, Any]):
"""The plugin that services *provider* when it opted into raw-PCM streaming, else None.
Opt-in = ``streams_pcm`` truthy AND a positive ``stream_sample_rate`` AND ``is_available()`` —
all read at resolve time, so per-profile state (key present, user opted out) belongs there.
A missing rate is refused loudly: PCM played at a guessed rate garbles speech.
"""
key = _plugin_route_key(provider, tts_config)
if key is None:
return None
try:
plugin = _lookup_plugin_provider(key)
if plugin is None or not plugin.streams_pcm or not plugin.is_available():
return None
rate = plugin.stream_sample_rate
except Exception as exc: # noqa: BLE001 — a broken plugin falls back to per-sentence synthesis
logger.debug("plugin TTS streaming lookup failed for '%s': %s", key, exc)
return None
if isinstance(rate, bool) or not isinstance(rate, (int, float)) or rate <= 0:
logger.warning("TTS provider '%s' sets streams_pcm without a positive stream_sample_rate; "
"using per-sentence synthesis instead", key)
return None
return plugin
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 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.
Resolution invariants enforced here (matches issue #30398):
1. The caller is responsible for the elif chain that handles ``edge``/``openai``/etc.; this function
explicitly rejects those names defensively. 2. 3. Plugin dispatch fires only when ``provider`` matches a
registered :class:`TTSProvider` whose ``name`` equals the configured value. Unknown names return None
(caller falls through to Edge default). See #17843.
"""
key = _plugin_route_key(provider, tts_config)
if key is None:
return None
try:
plugin_provider = _lookup_plugin_provider(key, retry=True)
except Exception as exc: # noqa: BLE001 — discovery failure is non-fatal
logger.debug("tts plugin dispatch skipped (discovery failed): %s", exc)
return None
if plugin_provider is None:
return None
fmt = (tts_config if isinstance(tts_config, dict) else {}).get(
"output_format", DEFAULT_COMMAND_TTS_OUTPUT_FORMAT)
logger.info("Generating speech with plugin TTS provider '%s'...", key)
written = plugin_provider.synthesize(
text, output_path, format=str(fmt).lower() if fmt else "mp3", **_plugin_voice_kwargs(tts_config))
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)."""
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)
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
def _plugin_provider_is_available(provider: str) -> bool:
"""``check_fn`` leg for plugin names: discovered provider reports ``is_available()``; any failure is False."""
try:
plugin = _lookup_plugin_provider(provider)
return bool(plugin and plugin.is_available())
except Exception:
return False