Files
hermes-agent/tools/transcription_tools.py
T

2537 lines
101 KiB
Python

#!/usr/bin/env python3
"""Speech-to-text transcription used by the gateway for voice messages.
Built-in providers: local (faster-whisper, default/free), local_command, groq,
openai (also serves the managed ``nous`` selection), mistral, xai, elevenlabs,
deepinfra; plus user-declared command providers and plugin providers.
result = transcribe_audio("/path/to/audio.ogg") # {"success", "transcript", "error"?, "provider"?}
"""
import logging
import os
import platform
import re
import shlex
import shutil
import subprocess
import tempfile
import threading
import time
from pathlib import Path
from typing import Optional, Dict, Any
from urllib.parse import urljoin
from hermes_cli._subprocess_compat import windows_hide_flags
from utils import is_truthy_value
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tts_command_provider import ( # noqa: F401 — aliases are patched by tests
command_env_passthrough as _command_stt_env_passthrough,
quote_command_placeholder as _quote_command_stt_placeholder,
render_command_template as _render_command_stt_template,
run_command_provider as _run_command_stt,
shell_quote_context as _shell_quote_context_stt,
terminate_command_process_tree as _terminate_command_stt_process_tree,
)
from tools.tool_backend_helpers import (
managed_nous_tools_enabled,
nous_tool_gateway_unavailable_message,
resolve_openai_audio_api_key,
)
logger = logging.getLogger(__name__)
def get_env_value(name, default=None):
"""Read env values through the live config module.
Resolved at call time: tests monkeypatch/restore ``hermes_cli.config.get_env_value``
around this module's import, so a cached import would go stale.
"""
try:
from hermes_cli.config import get_env_value as _get_env_value
except ImportError:
return os.getenv(name, default)
value = _get_env_value(name)
return default if value is None else value
def _resolve_provider_key(env_var: str, provider_id: str) -> str:
"""Resolve an STT API key via the shared voice-key resolver (config > env/.env > credential pool).
Resolved at call time so tests that reload the helpers module see the live function.
"""
try:
from tools.tool_backend_helpers import resolve_provider_secret
except ImportError: # pragma: no cover — helpers are in-repo
return str(get_env_value(env_var) or "").strip()
return resolve_provider_secret(env_var, provider_id, env_getter=get_env_value)
# ---------------------------------------------------------------------------
# Optional imports — graceful degradation
# ---------------------------------------------------------------------------
import importlib.util as _ilu
def _safe_find_spec(module_name: str) -> bool:
try:
return _ilu.find_spec(module_name) is not None
except (ImportError, ValueError):
return module_name in globals() or module_name in os.sys.modules
_HAS_FASTER_WHISPER = _safe_find_spec("faster_whisper")
_HAS_OPENAI = _safe_find_spec("openai")
_HAS_MISTRAL = _safe_find_spec("mistralai")
_HAS_PILK = _safe_find_spec("pilk")
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
DEFAULT_PROVIDER = "local"
DEFAULT_LOCAL_MODEL = "base"
DEFAULT_LOCAL_STT_LANGUAGE = "en"
DEFAULT_STT_MODEL = os.getenv("STT_OPENAI_MODEL", "whisper-1")
DEFAULT_GROQ_STT_MODEL = os.getenv("STT_GROQ_MODEL", "whisper-large-v3-turbo")
DEFAULT_MISTRAL_STT_MODEL = os.getenv("STT_MISTRAL_MODEL", "voxtral-mini-latest")
DEFAULT_ELEVENLABS_STT_MODEL = os.getenv("STT_ELEVENLABS_MODEL", "scribe_v2")
LOCAL_STT_COMMAND_ENV = "HERMES_LOCAL_STT_COMMAND"
LOCAL_STT_LANGUAGE_ENV = "HERMES_LOCAL_STT_LANGUAGE"
COMMON_LOCAL_BIN_DIRS = ("/opt/homebrew/bin", "/usr/local/bin")
GROQ_BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1")
OPENAI_BASE_URL = os.getenv("STT_OPENAI_BASE_URL", "https://api.openai.com/v1")
XAI_STT_BASE_URL = os.getenv("XAI_STT_BASE_URL", "https://api.x.ai/v1")
ELEVENLABS_STT_BASE_URL = os.getenv("ELEVENLABS_STT_BASE_URL", "https://api.elevenlabs.io/v1")
# DeepInfra STT base URL is resolved via hermes_cli.models.deepinfra_base_url (shared).
SUPPORTED_FORMATS = {".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg", ".oga", ".opus", ".aac", ".flac", ".caf"}
LOCAL_NATIVE_AUDIO_FORMATS = {".wav", ".aiff", ".aif"}
MAX_FILE_SIZE = 25 * 1024 * 1024 # 25 MB
# Known model sets for auto-correction
OPENAI_MODELS = {"whisper-1", "gpt-4o-mini-transcribe", "gpt-4o-transcribe", "gpt-transcribe"}
GROQ_MODELS = {"whisper-large-v3", "whisper-large-v3-turbo", "distil-whisper-large-v3-en"}
# Singleton for the local model — loaded once, reused across calls. The lock
# guards the check-then-load so two concurrent voice messages can't both
# download/load the model.
_local_model: Optional[object] = None
_local_model_name: Optional[str] = None
_local_model_lock = threading.Lock()
# Idle unload: a single daemon thread checks _last_transcription_time and
# releases the model (hundreds of MB of RAM/VRAM) after a configurable idle
# period, then exits; the next voice message reloads and restarts it.
# _idle_unload_mgmt_lock serializes the start check so two concurrent
# transcriptions can't both observe "no watcher alive" and spawn duplicates.
_last_transcription_time: float = 0.0
_idle_unload_thread: Optional[threading.Thread] = None
_idle_unload_stop = threading.Event()
_idle_unload_mgmt_lock = threading.Lock()
_IDLE_UNLOAD_CHECK_INTERVAL = 30 # seconds between idle checks
def _error_result(error: str, **extra: Any) -> Dict[str, Any]:
"""Standard failure envelope shared by every provider and validator."""
return {"success": False, "transcript": "", "error": error, **extra}
def _ok_result(transcript: str, provider: str) -> Dict[str, Any]:
return {"success": True, "transcript": transcript, "provider": provider}
# ---------------------------------------------------------------------------
# Config helpers
# ---------------------------------------------------------------------------
def _load_stt_config() -> dict:
"""Load the ``stt`` section from user config, falling back to defaults."""
try:
from hermes_cli.config import load_config
return load_config().get("stt") or {}
except Exception:
return {}
def is_stt_enabled(stt_config: Optional[dict] = None) -> bool:
"""Return whether STT is enabled in config."""
if stt_config is None:
stt_config = _load_stt_config()
return is_truthy_value(stt_config.get("enabled", True), default=True)
def _get_stt_section(stt_config: Dict[str, Any], name: str) -> Dict[str, Any]:
"""Return an stt sub-section if it's a dict, else an empty dict."""
if not isinstance(stt_config, dict):
return {}
section = stt_config.get(name)
return section if isinstance(section, dict) else {}
def _resolve_stt_language(
provider_key: str,
stt_config: Optional[Dict[str, Any]] = None,
*,
extra_keys: tuple = (),
) -> Optional[str]:
"""Resolve the language hint for an STT provider; first non-empty wins.
Order: ``stt.<provider>.language`` (plus *extra_keys* aliases, e.g. ElevenLabs'
``language_code``) > ``stt.language`` > ``HERMES_LOCAL_STT_LANGUAGE`` env >
None (provider auto-detects). Never returns "".
"""
if stt_config is None:
stt_config = _load_stt_config()
provider_cfg = _get_stt_section(stt_config, provider_key)
candidates = [provider_cfg.get(key) for key in ("language", *extra_keys)]
if isinstance(stt_config, dict):
candidates.append(stt_config.get("language"))
candidates.append(os.getenv(LOCAL_STT_LANGUAGE_ENV))
for candidate in candidates:
if isinstance(candidate, str) and candidate.strip():
return candidate.strip()
return None
def _has_openai_audio_backend() -> bool:
"""Return True when OpenAI audio can use config credentials, env credentials, or the managed gateway."""
try:
_resolve_openai_audio_client_config()
return True
except ValueError:
return False
def _find_binary(binary_name: str) -> Optional[str]:
"""Find a local binary, checking common Homebrew/local prefixes as well as PATH."""
for directory in COMMON_LOCAL_BIN_DIRS:
candidate = Path(directory) / binary_name
if candidate.exists() and os.access(candidate, os.X_OK):
return str(candidate)
return shutil.which(binary_name)
def _find_ffmpeg_binary() -> Optional[str]:
return _find_binary("ffmpeg")
def _find_ffprobe_binary() -> Optional[str]:
return _find_binary("ffprobe")
def _find_whisper_binary() -> Optional[str]:
return _find_binary("whisper")
def _run_quiet(command: list, *, timeout: float, env: Optional[dict] = None) -> subprocess.CompletedProcess:
"""``subprocess.run`` for STT helper binaries: checked, captured, utf-8 text, no stdin, hidden window."""
return subprocess.run(
command, check=True, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=timeout,
stdin=subprocess.DEVNULL, env=env, creationflags=windows_hide_flags(),
)
# Shared encode profile for every STT-bound m4a (transcode and silence-trim):
# 16 kHz mono 32 kbps AAC, faststart. One owner so codec/bitrate never drift.
_STT_M4A_ENCODE_ARGS = (
"-vn", "-ac", "1", "-ar", "16000",
"-c:a", "aac", "-b:a", "32k", "-movflags", "+faststart",
)
def _run_ffmpeg_stt_encode(
ffmpeg: str, input_path: str, output_path: str, *, audio_filter: Optional[str] = None
) -> None:
"""Run the shared STT m4a encode, optionally with an ``-af`` filter.
Raises on failure — callers own the error semantics (transcode reports, trim swallows).
"""
command = [ffmpeg, "-y", "-i", input_path]
if audio_filter:
command += ["-af", audio_filter]
command += [*_STT_M4A_ENCODE_ARGS, output_path]
_run_quiet(command, timeout=120)
def _transcode_audio_for_stt(file_path: str, work_dir: str) -> tuple[Optional[str], Optional[str]]:
"""Transcode to a compact 16 kHz mono AAC/m4a for STT upload.
Newer OpenAI models reject containers ``whisper-1`` accepted (notably Ogg/Opus
voice notes) and gateway downloads may carry a misleading extension.
Returns ``(converted_path, None)`` or ``(None, error)``.
"""
ffmpeg = _find_ffmpeg_binary()
if not ffmpeg:
return None, "audio needs transcoding for the STT API, but ffmpeg was not found"
converted_path = os.path.join(work_dir, f"{Path(file_path).stem or 'audio'}-stt.m4a")
try:
_run_ffmpeg_stt_encode(ffmpeg, file_path, converted_path)
return converted_path, None
except subprocess.CalledProcessError as exc:
details = exc.stderr.strip() or exc.stdout.strip() or str(exc)
logger.error("ffmpeg STT transcode failed for %s: %s", file_path, details)
return None, f"failed to transcode audio for the STT API: {details}"
except Exception as exc: # noqa: BLE001 - transcode is best-effort
logger.error("unexpected STT transcode failure for %s: %s", file_path, exc, exc_info=True)
return None, f"failed to transcode audio for the STT API: {exc}"
def _get_local_command_template() -> Optional[str]:
configured = os.getenv(LOCAL_STT_COMMAND_ENV, "").strip()
if configured:
return configured
whisper_binary = _find_whisper_binary()
if whisper_binary:
return (
f"{shlex.quote(whisper_binary)} {{input_path}} --model {{model}} --output_format txt "
"--output_dir {output_dir} --language {language}"
)
return None
def _has_local_command() -> bool:
return _get_local_command_template() is not None
def _normalize_local_model(model_name: Optional[str]) -> str:
"""Return a valid faster-whisper size; cloud-only names (``whisper-1`` …) fall back to the default with a warning."""
if not model_name:
return DEFAULT_LOCAL_MODEL
if model_name in OPENAI_MODELS or model_name in GROQ_MODELS:
logger.warning(
"STT model '%s' is a cloud-only name and cannot be used with the local "
"provider. Falling back to '%s'. Set stt.local.model to a valid "
"faster-whisper size (tiny, base, small, medium, large-v3).",
model_name,
DEFAULT_LOCAL_MODEL,
)
return DEFAULT_LOCAL_MODEL
return model_name
_normalize_local_command_model = _normalize_local_model
def _try_lazy_install_stt() -> bool:
"""Lazy-install faster-whisper and re-check dynamically so it's usable without a restart."""
try:
from tools.lazy_deps import ensure
# prompt=False: a bare input() deadlocks under the interactive CLI where
# prompt_toolkit owns stdin; the install is already gated by
# security.allow_lazy_installs, so reaching here is opt-in.
ensure("stt.faster_whisper", prompt=False)
if _ilu.find_spec("faster_whisper"):
return True
logger.warning(
"faster-whisper was installed but importlib still cannot find it "
"(may require Python restart)"
)
except Exception as exc:
logger.warning(
"Lazy install of faster-whisper failed: %s. "
"This is often a permission issue: the Hermes process user cannot "
"write to the virtual environment. Try running manually as the "
"venv owner: `stat -c '%%u' '$(dirname $(dirname $(which python3)))'` "
"then `su - <owner> -c 'VIRTUAL_ENV=/opt/hermes/.venv "
"uv pip install faster-whisper==1.2.1'`",
exc,
)
return False
# Providers with native handlers here. Kept in sync with
# ``agent.transcription_registry._BUILTIN_NAMES`` (a regression test fails on
# drift); plugins may not register under these names and the dispatcher
# short-circuits them before command/plugin lookup.
BUILTIN_STT_PROVIDERS = frozenset({
"local", "local_command", "groq", "openai", "mistral", "xai", "elevenlabs", "deepinfra",
})
# Built-in providers that upload audio to a remote API.
CLOUD_STT_PROVIDERS = frozenset(BUILTIN_STT_PROVIDERS - {"local", "local_command"})
# ---------------------------------------------------------------------------
# Command-provider registry (``stt.providers.<name>: type: command``)
# ---------------------------------------------------------------------------
#
# Mirrors the TTS command-provider registry: same placeholder grammar,
# shell-quote-aware rendering and process-tree termination on timeout.
# Resolution order: built-in name (always wins) > stt.providers.<name> command
# > plugin-registered TranscriptionProvider > "No STT provider available".
# The single-env-var HERMES_LOCAL_STT_COMMAND escape hatch stays untouched via
# the built-in ``local_command`` path.
DEFAULT_COMMAND_STT_TIMEOUT_SECONDS = 300
DEFAULT_COMMAND_STT_LANGUAGE = "en"
DEFAULT_COMMAND_STT_OUTPUT_FORMAT = "txt"
COMMAND_STT_OUTPUT_FORMATS = frozenset({"txt", "json", "srt", "vtt"})
def _get_named_stt_provider_config(
stt_config: Dict[str, Any],
name: str,
) -> Dict[str, Any]:
"""Return the config for a user-declared STT provider, or {}.
``stt.providers.<name>`` is canonical; ``stt.<name>`` is accepted for
back-compat only when *name* is not a built-in, so a user's ``stt.openai``
block still means the OpenAI provider. Built-in sections can't be mistaken
for command providers anyway: ``_is_command_stt_provider_config`` requires
an explicit ``command:``.
"""
providers = _get_stt_section(stt_config, "providers")
section = providers.get(name)
if isinstance(section, dict):
return section
if name.lower() not in BUILTIN_STT_PROVIDERS:
return _get_stt_section(stt_config, name)
return {}
def _is_command_stt_provider_config(config: Dict[str, Any]) -> bool:
"""Return True when *config* declares a command-type STT provider."""
if not isinstance(config, dict):
return False
ptype = str(config.get("type") or "").strip().lower()
if ptype and ptype != "command":
return False
command = config.get("command")
return isinstance(command, str) and bool(command.strip())
def _resolve_command_stt_provider_config(
provider: str,
stt_config: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Return the provider config if *provider* is a command type; None for built-ins, ``none``, unknown."""
if not provider:
return None
key = provider.lower().strip()
if key in BUILTIN_STT_PROVIDERS or key == "none":
return None
config = _get_named_stt_provider_config(stt_config, key)
return config if _is_command_stt_provider_config(config) else None
def _is_local_stt_provider(provider: str, stt_config: Dict[str, Any]) -> bool:
"""Return whether *provider* is exempt from Hermes's remote upload cap."""
return (provider or "").lower().strip() in {"local", "local_command"}
def _get_command_stt_timeout(config: Dict[str, Any]) -> float:
"""Return timeout in seconds, falling back when invalid."""
raw = config.get("timeout", config.get("timeout_seconds", DEFAULT_COMMAND_STT_TIMEOUT_SECONDS))
try:
value = float(raw)
except (TypeError, ValueError):
return float(DEFAULT_COMMAND_STT_TIMEOUT_SECONDS)
return value if value > 0 else float(DEFAULT_COMMAND_STT_TIMEOUT_SECONDS)
def _get_command_stt_output_format(config: Dict[str, Any]) -> str:
"""Return the validated output format (txt/json/srt/vtt)."""
raw = config.get("format") or config.get("output_format") or DEFAULT_COMMAND_STT_OUTPUT_FORMAT
fmt = str(raw).lower().strip().lstrip(".")
return fmt if fmt in COMMAND_STT_OUTPUT_FORMATS else DEFAULT_COMMAND_STT_OUTPUT_FORMAT
def _read_command_stt_output(output_path: Path, stdout: str, fmt: str) -> str:
"""Return the transcript: non-empty output file > non-empty stdout (curl one-liners) > RuntimeError.
JSON output is returned raw — users configure ``format: txt`` or post-process.
"""
if output_path.exists():
try:
content = output_path.read_text(encoding="utf-8").strip()
except UnicodeDecodeError:
content = output_path.read_bytes().decode("utf-8", errors="replace").strip()
if content:
return content
if stdout and stdout.strip():
return stdout.strip()
raise RuntimeError(
f"Command STT provider wrote no output file at {output_path} "
f"and produced no stdout"
)
def _log_prompt_unsupported(label: str) -> None:
logger.debug("%s does not support transcription prompts — proceeding without the prompt.", label)
def _transcribe_command_stt(
file_path: str,
provider_name: str,
config: Dict[str, Any],
stt_config: Dict[str, Any],
model_override: Optional[str] = None,
language_override: Optional[str] = None,
prompt: Optional[str] = None,
) -> Dict[str, Any]:
"""Transcribe via a user-declared ``stt.providers.<name>: type: command``.
Placeholders (all shell-quote-aware; ``{{``/``}}`` stay literal):
``{input_path}`` original audio path, ``{output_path}`` file to write the
transcript to, ``{output_dir}`` its parent, ``{format}`` txt/json/srt/vtt,
``{language}`` (default ``en``), ``{model}`` (empty when unset).
"""
if prompt:
_log_prompt_unsupported(f"Command STT provider '{provider_name}'")
def fail(error: str) -> Dict[str, Any]:
return _error_result(error, provider=provider_name)
command_template = str(config.get("command") or "").strip()
if not command_template:
return fail(f"stt.providers.{provider_name}.command is not configured")
audio = Path(file_path).expanduser()
if not audio.exists():
return fail(f"Audio file not found: {file_path}")
timeout = _get_command_stt_timeout(config)
output_format = _get_command_stt_output_format(config)
language = (
language_override
or config.get("language")
or _resolve_stt_language(provider_name, stt_config)
or DEFAULT_COMMAND_STT_LANGUAGE
)
model = model_override or config.get("model") or ""
try:
with tempfile.TemporaryDirectory(prefix=f"hermes-cmd-stt-{provider_name}-") as tmpdir:
output_path = Path(tmpdir) / f"transcript.{output_format}"
placeholders = {
"input_path": str(audio.resolve()),
"output_path": str(output_path),
"output_dir": str(output_path.parent),
"format": output_format,
"language": str(language),
"model": str(model),
}
command = _render_command_stt_template(command_template, placeholders)
logger.info(
"Transcribing %s via command STT provider '%s'...",
audio.name, provider_name,
)
try:
result = _run_command_stt(
command,
timeout,
env_passthrough=_command_stt_env_passthrough(config),
)
except subprocess.TimeoutExpired:
return fail(f"STT command provider '{provider_name}' timed out after {timeout:g}s")
except subprocess.CalledProcessError as exc:
detail_parts = []
if exc.stderr:
detail_parts.append(f"stderr: {exc.stderr.strip()}")
if exc.stdout:
detail_parts.append(f"stdout: {exc.stdout.strip()}")
detail = "; ".join(detail_parts) or "no command output"
return fail(
f"STT command provider '{provider_name}' exited with code "
f"{exc.returncode}: {detail}"
)
try:
transcript_text = _read_command_stt_output(
output_path, result.stdout or "", output_format,
)
except RuntimeError as exc:
return fail(str(exc))
except OSError as exc:
return fail(f"STT command provider '{provider_name}' failed: {exc}")
logger.info(
"Transcribed %s via command STT provider '%s' (%d chars)",
audio.name, provider_name, len(transcript_text),
)
return _ok_result(transcript_text, provider_name)
# ---------------------------------------------------------------------------
# Provider resolution
# ---------------------------------------------------------------------------
def _has_xai_stt_credentials() -> bool:
from tools.xai_http import resolve_xai_http_credentials
return bool(resolve_xai_http_credentials().get("api_key"))
def _has_xai_stt_credentials_quietly() -> bool:
try:
return _has_xai_stt_credentials()
except Exception:
return False
def _has_key(env_var: str, provider: str, *, needs_openai: bool = False, needs_mistral: bool = False):
"""Availability probe factory: optional SDK flag AND a resolvable API key."""
def probe() -> bool:
if needs_openai and not _HAS_OPENAI:
return False
if needs_mistral and not _HAS_MISTRAL:
return False
return bool(_resolve_provider_key(env_var, provider))
return probe
_has_groq_key = _has_key("GROQ_API_KEY", "groq", needs_openai=True)
_has_mistral_key = _has_key("MISTRAL_API_KEY", "mistral", needs_mistral=True)
_has_elevenlabs_key = _has_key("ELEVENLABS_API_KEY", "elevenlabs")
_has_deepinfra_key = _has_key("DEEPINFRA_API_KEY", "deepinfra", needs_openai=True)
def _resolve_explicit_openai() -> str:
if not _HAS_OPENAI:
logger.warning("STT provider 'openai' configured but no API key available")
return "none"
# Resolve directly rather than via the boolean probe so a managed
# openai-audio gateway outage is logged with its real reason, not a
# generic "no API key" hint.
try:
_resolve_openai_audio_client_config()
return "openai"
except ValueError as exc:
logger.warning("STT provider 'openai' configured but unavailable: %s", exc)
return "none"
def _resolve_explicit_local() -> str:
if _HAS_FASTER_WHISPER:
return "local"
if _has_local_command():
return "local_command"
if _try_lazy_install_stt():
return "local"
logger.warning(
"STT provider 'local' configured but unavailable "
"(install faster-whisper or set HERMES_LOCAL_STT_COMMAND)"
)
return "none"
def _resolve_explicit_local_command() -> str:
if _has_local_command():
return "local_command"
if _HAS_FASTER_WHISPER:
logger.info("Local STT command unavailable, using local faster-whisper")
return "local"
logger.warning("STT provider 'local_command' configured but unavailable")
return "none"
def _explicit_cloud_resolver(name: str, probe, warning: str):
def resolve() -> str:
if probe():
return name
logger.warning(warning)
return "none"
return resolve
# Explicit ``stt.provider`` selections -> resolver returning the provider or "none".
_EXPLICIT_PROVIDER_RESOLVERS = {
"local": _resolve_explicit_local,
"local_command": _resolve_explicit_local_command,
"openai": _resolve_explicit_openai,
"groq": _explicit_cloud_resolver(
"groq", _has_groq_key, "STT provider 'groq' configured but GROQ_API_KEY not set"),
"mistral": _explicit_cloud_resolver(
"mistral", _has_mistral_key,
"STT provider 'mistral' configured but mistralai package "
"not installed or MISTRAL_API_KEY not set"),
"xai": _explicit_cloud_resolver(
"xai", _has_xai_stt_credentials, "STT provider 'xai' configured but no xAI credentials are available"),
"elevenlabs": _explicit_cloud_resolver(
"elevenlabs", _has_elevenlabs_key, "STT provider 'elevenlabs' configured but ELEVENLABS_API_KEY not set"),
"deepinfra": _explicit_cloud_resolver(
"deepinfra", _has_deepinfra_key,
"STT provider 'deepinfra' configured but DEEPINFRA_API_KEY not set "
"(or openai package missing)"),
}
# Auto-detect ladder for cloud providers, in priority order: (check, name, log).
# DeepInfra is LAST so a DEEPINFRA_API_KEY set for the chat surface never
# displaces an existing xAI/ElevenLabs auto-selection. Mistral only
# auto-selects when the SDK is already present — no lazy-install during
# passive auto-detection (explicit ``provider: mistral`` installs on first use).
_AUTO_DETECT_CLOUD = (
(_has_groq_key, "groq", "No local STT available, using Groq Whisper API"),
(lambda: _HAS_OPENAI and _has_openai_audio_backend(),
"openai", "No local STT available, using OpenAI Whisper API"),
(_has_mistral_key, "mistral", "No local STT available, using Mistral Voxtral Transcribe API"),
(_has_xai_stt_credentials_quietly, "xai", "No local STT available, using xAI Grok STT API"),
(_has_elevenlabs_key, "elevenlabs", "No local STT available, using ElevenLabs Scribe STT API"),
(_has_deepinfra_key, "deepinfra", "No local STT available, using DeepInfra Whisper API"),
)
def _get_provider(stt_config: dict) -> str:
"""Determine which STT provider to use.
An explicit ``stt.provider`` is honoured — no silent cloud fallback. With no
provider configured, auto-detect tries local > groq > openai > mistral > xai
> elevenlabs > deepinfra.
"""
if not is_stt_enabled(stt_config):
return "none"
explicit = "provider" in stt_config
provider = stt_config.get("provider", DEFAULT_PROVIDER)
# The managed "Nous Subscription" selection is serviced by the OpenAI
# implementation, routed through the managed gateway by
# _resolve_openai_audio_client_config.
if isinstance(provider, str) and provider.strip().lower() == "nous":
provider = "openai"
if explicit and provider == "local":
# Legacy DEFAULT_CONFIG seeded ``stt.provider: local`` on every install,
# so a merged-config "local" is not proof of a user pick. Only a raw
# config.yaml selection counts as explicit; otherwise autodetect (which
# prefers local first anyway).
try:
from tools.tool_backend_helpers import read_selection
if read_selection("stt") is None:
explicit = False
except Exception: # pragma: no cover — helpers are in-repo
pass
if explicit:
resolver = _EXPLICIT_PROVIDER_RESOLVERS.get(provider)
return resolver() if resolver else provider # Unknown — let it fail downstream
if _HAS_FASTER_WHISPER:
return "local"
if _has_local_command():
return "local_command"
if _try_lazy_install_stt():
return "local"
for available, name, message in _AUTO_DETECT_CLOUD:
if available():
logger.info(message)
return name
return "none"
def _unregistered_stt_provider_error(provider: str) -> Dict[str, Any]:
key = str(provider or "").strip()
return _error_result(
f"stt.provider='{key}' is set but no built-in, command, or plugin "
"provider registered that name. Run `hermes plugins list` to see "
"installed STT plugins, or configure a command provider under "
f"`stt.providers.{key}.command`.",
provider=key,
error_type="provider_not_registered",
)
# ---------------------------------------------------------------------------
# Plugin provider dispatch
# ---------------------------------------------------------------------------
def _dispatch_to_plugin_provider(
file_path: str,
provider: str,
stt_config: Optional[Dict[str, Any]] = None,
*,
model: Optional[str] = None,
language: Optional[str] = None,
prompt: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Route to a plugin-registered transcription provider; None when no plugin claims the name.
Invariants (re-verified here even though the caller short-circuits first,
so a caller refactor can't silently break them): built-in names never reach
the registry; a same-name ``stt.providers.<name>: type: command`` wins over
a plugin. A matched plugin reporting ``is_available() == False`` returns an
error envelope — not None — because the user explicitly opted in via
``stt.provider`` and the generic fall-through message would mislead.
Provider exceptions become the standard error envelope.
"""
if not provider:
return None
key = provider.lower().strip()
if key in BUILTIN_STT_PROVIDERS or key == "none":
return None
if stt_config is not None and _is_command_stt_provider_config(
_get_named_stt_provider_config(stt_config, key)
):
return None
try:
from agent.transcription_registry import get_provider
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
plugin_provider = get_provider(key)
if plugin_provider is None:
# Long-lived sessions may have discovered plugins before a backend
# was patched in or config changed — retry once with a forced refresh.
_ensure_plugins_discovered(force=True)
plugin_provider = get_provider(key)
except Exception as exc: # noqa: BLE001 — discovery failure is non-fatal
logger.debug("STT plugin dispatch skipped (discovery failed): %s", exc)
return None
if plugin_provider is None:
return None
# ``is_available()`` MUST NOT raise per the ABC contract; defend anyway so
# a buggy plugin can't break dispatch for everyone.
try:
available = plugin_provider.is_available()
except Exception as exc: # noqa: BLE001
logger.warning(
"STT plugin provider '%s' is_available() raised: %s — "
"treating as unavailable", key, exc, exc_info=True,
)
available = False
if not available:
logger.info(
"STT plugin provider '%s' reports not available; returning "
"unavailability envelope.", key,
)
return _error_result(
f"STT plugin '{key}' is not available — check that its "
"required credentials / dependencies are configured.",
provider=key,
)
logger.info("Transcribing with plugin STT provider '%s'...", key)
# The prompt travels via the ABC's ``**extra`` kwargs and is only sent when
# set, so pre-prompt providers see byte-identical calls on the no-prompt path.
extra_kwargs: Dict[str, Any] = {}
if prompt is not None:
extra_kwargs["prompt"] = prompt
try:
result = plugin_provider.transcribe(
file_path,
model=model,
language=language,
**extra_kwargs,
)
except Exception as exc: # noqa: BLE001
logger.warning(
"STT plugin provider '%s' raised: %s", key, exc, exc_info=True,
)
return _error_result(f"STT plugin '{key}' raised: {exc}", provider=key)
if not isinstance(result, dict):
return _error_result(f"STT plugin '{key}' returned a non-dict result", provider=key)
result.setdefault("provider", key)
return result
# ---------------------------------------------------------------------------
# pre_transcription plugin hook (STT prompt/vocab threading)
# ---------------------------------------------------------------------------
# Fields a pre_transcription hook may mutate. ``file_path`` is read-only —
# attempts to change it are logged and dropped.
_PRE_TRANSCRIPTION_MUTABLE_FIELDS = ("prompt", "language", "model")
# Whisper-family models only use the final ~224 tokens of the prompt; longer
# values waste upload bytes and can trip stricter OpenAI-compatible servers.
# Enforced client-side (truncate with a warning, never error), ~4 chars/token.
_WHISPER_PROMPT_TOKEN_CAP = 224
_PROMPT_CHARS_PER_TOKEN = 4
_WHISPER_PROMPT_CAPPED_PROVIDERS = frozenset(
{"local", "openai", "groq", "deepinfra"}
)
def _enforce_prompt_length_limit(
prompt: Optional[str], provider: str
) -> Optional[str]:
"""Truncate *prompt* to the whisper-family token cap, keeping the TAIL (fail-open).
Whisper conditions on the final context window, so the most recently
appended hints survive. Other providers own their own validation.
"""
if not prompt or provider not in _WHISPER_PROMPT_CAPPED_PROVIDERS:
return prompt
max_chars = _WHISPER_PROMPT_TOKEN_CAP * _PROMPT_CHARS_PER_TOKEN
if len(prompt) <= max_chars:
return prompt
logger.warning(
"Transcription prompt is ~%d tokens; whisper-family provider '%s' "
"only uses the final ~%d — truncating to the last %d characters.",
len(prompt) // _PROMPT_CHARS_PER_TOKEN,
provider,
_WHISPER_PROMPT_TOKEN_CAP,
max_chars,
)
return prompt[-max_chars:]
def _apply_pre_transcription_hook(
*,
file_path: str,
provider: str,
model: Optional[str],
language: Optional[str],
prompt: Optional[str],
source: Optional[str],
) -> tuple[Optional[str], Optional[str], Optional[str]]:
"""Fire the ``pre_transcription`` plugin hook and merge its results.
Gated on ``has_hook`` so the no-hook path never builds hook kwargs, and
fail-open: any hook-plumbing error leaves the dispatch untouched. Results
arrive in registration order (plugins discovered in sorted order) and are
applied field-by-field, so the last hook to write a field wins. Model
values are accepted as-is and flow through the same per-backend
normalization a caller-supplied model would.
Returns ``(model, language_override, prompt)``; ``language_override`` is
None unless a hook explicitly set ``language``, so backends keep their own
config/env language resolution.
"""
try:
from hermes_cli.plugins import has_hook, invoke_hook
if not has_hook("pre_transcription"):
return model, None, prompt
hook_results = invoke_hook(
"pre_transcription",
file_path=file_path,
provider=provider,
model=model,
language=language,
prompt=prompt,
source=source,
)
overrides: Dict[str, Any] = {}
for hook_result in hook_results:
if not isinstance(hook_result, dict):
continue
for key, value in hook_result.items():
if key == "file_path":
logger.warning(
"pre_transcription hook attempted to change "
"file_path (read-only) — ignoring the attempt."
)
continue
if key not in _PRE_TRANSCRIPTION_MUTABLE_FIELDS:
logger.debug(
"pre_transcription hook returned unsupported field "
"%r — ignoring.", key,
)
continue
if not isinstance(value, str):
logger.debug(
"pre_transcription hook returned non-string value "
"%r for field %r — ignoring.", value, key,
)
continue
overrides[key] = value
if "model" in overrides:
model = overrides["model"]
if "prompt" in overrides:
# Hooks win over the static ``stt.prompt`` config; "" clears it.
prompt = overrides["prompt"] or None
return model, overrides.get("language") or None, prompt
except Exception as _hook_err: # noqa: BLE001 — hook plumbing is fail-open
logger.debug("pre_transcription hook error: %s", _hook_err)
return model, None, prompt
# ---------------------------------------------------------------------------
# Shared validation
# ---------------------------------------------------------------------------
def _validate_audio_file_size(audio_path: Path) -> Optional[Dict[str, Any]]:
"""Return an error when *audio_path* exceeds the remote upload cap."""
try:
file_size = audio_path.stat().st_size
except OSError as e:
return _error_result(f"Failed to access file: {e}")
if file_size > MAX_FILE_SIZE:
return _error_result(
f"File too large: {file_size / (1024*1024):.1f}MB (max {MAX_FILE_SIZE / (1024*1024):.0f}MB)"
)
return None
def _validate_audio_source_file(
file_path: str,
*,
enforce_size_limit: bool = True,
) -> Optional[Dict[str, Any]]:
"""Validate source path safety (and optionally size) before any decoder runs."""
audio_path = Path(file_path)
if os.path.islink(audio_path):
return _error_result(f"Path is a symbolic link: {file_path}")
if not audio_path.exists():
return _error_result(f"Audio file not found: {file_path}")
if not audio_path.is_file():
return _error_result(f"Path is not a file: {file_path}")
if enforce_size_limit:
return _validate_audio_file_size(audio_path)
try:
audio_path.stat()
except OSError as e:
return _error_result(f"Failed to access file: {e}")
return None
def _validate_audio_file(
file_path: str,
*,
enforce_size_limit: bool = True,
) -> Optional[Dict[str, Any]]:
"""Validate a supported, decoder-safe audio file."""
source_error = _validate_audio_source_file(
file_path, enforce_size_limit=enforce_size_limit
)
if source_error:
return source_error
suffix = Path(file_path).suffix
if suffix.lower() not in SUPPORTED_FORMATS:
return _error_result(
f"Unsupported format: {suffix}. Supported: {', '.join(sorted(SUPPORTED_FORMATS))}"
)
return None
def _prepare_audio_for_transcription(
file_path: str,
) -> tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
"""Convert a decoder-safe .silk source to a temporary supported WAV file."""
audio_path = Path(file_path)
if audio_path.suffix.lower() != ".silk":
return file_path, None, None
if not _HAS_PILK:
# pilk is a tiny silk-v3 codec binding — lazy-install on first .silk
# voice note instead of bloating the base install.
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("stt.silk", prompt=False)
except Exception:
pass
if not _safe_find_spec("pilk"):
return None, None, _error_result(
"Unsupported format: .silk. Install the optional 'pilk' dependency to enable WeChat voice transcription."
)
temp_dir = tempfile.mkdtemp(prefix="hermes-silk-")
converted_path = os.path.join(temp_dir, f"{audio_path.stem}.wav")
try:
import pilk
pilk.silk_to_wav(file_path, converted_path)
if not Path(converted_path).is_file() or Path(converted_path).stat().st_size == 0:
raise RuntimeError("pilk did not produce a readable WAV file")
return converted_path, temp_dir, None
except Exception as exc:
shutil.rmtree(temp_dir, ignore_errors=True)
logger.error("Failed to convert .silk audio %s: %s", file_path, exc, exc_info=True)
return None, None, _error_result(f"Failed to convert .silk audio for transcription: {exc}")
# ---------------------------------------------------------------------------
# Provider: local (faster-whisper)
# ---------------------------------------------------------------------------
# Substrings identifying a missing/unloadable CUDA runtime library: when
# ctranslate2 can't dlopen one of these the "auto" device picker has already
# committed to CUDA, so we fall back to CPU and reload. Deliberately narrow
# (library names + dlopen phrasing) so legitimate runtime failures like "CUDA
# out of memory" surface to the user instead of silently running on CPU.
_CUDA_LIB_ERROR_MARKERS = (
"libcublas", "libcudnn", "libcudart", "cannot be loaded", "cannot open shared object",
"no kernel image is available", "CUBLAS_STATUS_NOT_SUPPORTED", "no CUDA-capable device",
"CUDA driver version is insufficient",
)
def _looks_like_cuda_lib_error(exc: BaseException) -> bool:
"""Heuristic: is this a missing/broken CUDA runtime library (not a legitimate runtime failure)?"""
msg = str(exc)
return any(marker in msg for marker in _CUDA_LIB_ERROR_MARKERS)
def _sysctl_value(name: str) -> str:
"""Return a sysctl value, or an empty string when unavailable."""
try:
return subprocess.check_output(
["/usr/sbin/sysctl", "-n", name],
stderr=subprocess.DEVNULL,
text=True,
timeout=2,
).strip()
except Exception:
return ""
def _should_force_faster_whisper_cpu() -> bool:
"""Force CPU on Apple Silicon (incl. x86_64 under Rosetta), where ctranslate2's
``device="auto"`` can abort inside native code before Python can catch it."""
if platform.system() != "Darwin":
return False
if platform.machine().lower() in {"arm64", "aarch64"}:
return True
# Under Rosetta platform.machine() reports x86_64; sysctl.proc_translated
# flags translation and hw.optional.arm64 distinguishes Apple Silicon hosts.
if _sysctl_value("sysctl.proc_translated") == "1":
return True
return _sysctl_value("hw.optional.arm64") == "1"
def _get_idle_unload_seconds(local_cfg: Dict[str, Any]) -> int:
"""Resolve the idle unload timeout from config; 0 = never (default), negatives clamp to 0."""
try:
val = int(local_cfg.get("unload_after_idle_seconds", 0))
except (TypeError, ValueError):
return 0
return max(val, 0)
def _unload_local_model() -> None:
"""Release the cached local whisper model. Thread-safe via the model lock."""
global _local_model, _local_model_name
with _local_model_lock:
if _local_model is not None:
logger.info(
"Unloading local whisper model '%s' after idle timeout",
_local_model_name or "unknown",
)
_local_model = None
_local_model_name = None
def _start_idle_unload_watcher(timeout_seconds: int) -> None:
"""Ensure the single idle-unload watcher thread is running.
Started only when none is alive (one lock + one ``is_alive()`` per
transcription). The loop re-reads ``stt.local.unload_after_idle_seconds``
every cycle so config edits apply within one interval; ``timeout_seconds``
seeds the first cycle so a just-written config is honored even if a
concurrent read races. After unloading, when the timeout becomes 0, or when
the model is already gone, the thread exits; the next transcription restarts it.
"""
global _idle_unload_thread
with _idle_unload_mgmt_lock:
if _idle_unload_thread is not None and _idle_unload_thread.is_alive():
return
def _watch(initial_timeout=timeout_seconds):
timeout = initial_timeout
while not _idle_unload_stop.is_set():
if _idle_unload_stop.wait(_IDLE_UNLOAD_CHECK_INTERVAL):
break
if _local_model is None:
break
try:
timeout = _get_idle_unload_seconds(
_load_stt_config().get("local") or {}
)
except Exception: # noqa: BLE001 - keep the seed value
timeout = initial_timeout
if timeout <= 0:
break # unload disabled mid-flight — stand down
if time.monotonic() - _last_transcription_time >= timeout:
_unload_local_model()
break
_idle_unload_stop.clear()
_idle_unload_thread = threading.Thread(
target=_watch, name="hermes-stt-idle-unload", daemon=True
)
_idle_unload_thread.start()
def _touch_transcription_time() -> None:
"""Record transcription activity (resets the idle timer)."""
global _last_transcription_time
_last_transcription_time = time.monotonic()
def _load_local_whisper_model(model_name: str, device: str = "auto", compute_type: str = "auto"):
"""Load faster-whisper with graceful CUDA → CPU fallback.
``device="auto"`` picks CUDA whenever the ctranslate2 wheel ships CUDA libs,
even on hosts without the NVIDIA runtime (WSL2, headless servers, CPU-only
dev boxes). Try the requested config first; on a CUDA library load failure
fall back to CPU + int8. Pass ``stt.local.device`` / ``compute_type`` to pin.
"""
force_cpu = _should_force_faster_whisper_cpu()
if force_cpu:
# Importing ctranslate2 can itself abort on Apple Silicon/Rosetta when
# multiple Intel OpenMP runtimes are loaded — set before the import.
os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
from faster_whisper import WhisperModel
if force_cpu:
logger.info(
"Apple Silicon/Rosetta detected — loading faster-whisper on CPU "
"(int8) to avoid native device autodetection crashes"
)
return WhisperModel(model_name, device="cpu", compute_type="int8")
try:
return WhisperModel(model_name, device=device, compute_type=compute_type)
except Exception as exc:
if not _looks_like_cuda_lib_error(exc):
raise
logger.warning(
"faster-whisper CUDA load failed (%s) — falling back to CPU (int8). "
"Install the NVIDIA CUDA runtime (libcublas/libcudnn) to use GPU.",
exc,
)
return WhisperModel(model_name, device="cpu", compute_type="int8")
# Silence-hallucination hardening for local faster-whisper (whisper decodes
# junk like "You"/"Thank you." from pure silence). Three layers, all tunable
# under ``stt.local``: Silero VAD so silence never reaches the model
# (``vad: false`` restores raw behaviour for music/ambient audio);
# condition_on_previous_text=False so one hallucinated token can't seed a run;
# and the segment confidence gate in _is_hallucinated_segment.
_VAD_MIN_SILENCE_MS_DEFAULT = 500
_NO_SPEECH_PROB_THRESHOLD_DEFAULT = 0.6
_LOGPROB_THRESHOLD_DEFAULT = -1.0
def _config_number(cfg: Dict[str, Any], key: str, default, cast=float):
"""Read ``cfg[key]`` through *cast*, falling back to *default* on bad values."""
try:
return cast(cfg.get(key, default))
except (TypeError, ValueError):
return default
def build_local_transcribe_kwargs(stt_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Build the kwargs for EVERY local faster-whisper ``model.transcribe`` call.
Single owner for the anti-hallucination hardening — new local-whisper call
sites must go through here instead of hand-rolling kwargs.
"""
stt_config = stt_config if isinstance(stt_config, dict) else _load_stt_config()
local_cfg = stt_config.get("local") or {}
kwargs: Dict[str, Any] = {
"beam_size": 5,
"condition_on_previous_text": False,
}
vad_enabled = local_cfg.get("vad", True)
if vad_enabled is None:
vad_enabled = True
if bool(vad_enabled):
kwargs["vad_filter"] = True
kwargs["vad_parameters"] = {
"min_silence_duration_ms": _config_number(
local_cfg, "vad_min_silence_ms", _VAD_MIN_SILENCE_MS_DEFAULT, int
)
}
else:
kwargs["vad_filter"] = False
# Push the confidence gate into faster-whisper itself: its internal
# defaults drop low-confidence segments BEFORE our post-filter sees them,
# so without this the ``stt.local`` threshold knobs were dead for that
# first gate (non-English speech decodes at lower avg_logprob and was
# silently discarded). Same values feed both gates; defaults unchanged.
no_speech_threshold, log_prob_threshold = _confidence_thresholds(local_cfg)
kwargs["no_speech_threshold"] = no_speech_threshold
kwargs["log_prob_threshold"] = log_prob_threshold
forced_lang = _resolve_stt_language("local", stt_config)
if forced_lang:
kwargs["language"] = forced_lang
initial_prompt = local_cfg.get("initial_prompt")
if isinstance(initial_prompt, str) and initial_prompt.strip():
kwargs["initial_prompt"] = initial_prompt
return kwargs
def _confidence_thresholds(local_cfg: Dict[str, Any]) -> tuple[float, float]:
"""Resolve (no_speech_prob, avg_logprob) gate thresholds from config."""
return (
_config_number(local_cfg, "no_speech_prob_threshold", _NO_SPEECH_PROB_THRESHOLD_DEFAULT),
_config_number(local_cfg, "logprob_threshold", _LOGPROB_THRESHOLD_DEFAULT),
)
def _is_hallucinated_segment(segment: Any, no_speech_threshold: float, logprob_threshold: float) -> bool:
"""True when a segment is very likely a silence hallucination.
Conservative AND gate (openai-whisper's own heuristic): the model must BOTH
think the window is non-speech AND have decoded it with low confidence, so
quiet-but-real speech survives. Unknown segment shapes are never dropped.
"""
no_speech_prob = getattr(segment, "no_speech_prob", None)
avg_logprob = getattr(segment, "avg_logprob", None)
if no_speech_prob is None or avg_logprob is None:
return False
try:
no_speech_prob = float(no_speech_prob)
avg_logprob = float(avg_logprob)
except (TypeError, ValueError):
return False
return no_speech_prob > no_speech_threshold and avg_logprob < logprob_threshold
def _join_confident_segments(segments: Any, local_cfg: Dict[str, Any]) -> str:
"""Join segment texts, dropping probable silence hallucinations."""
no_speech_threshold, logprob_threshold = _confidence_thresholds(local_cfg)
kept: list[str] = []
for segment in segments:
if _is_hallucinated_segment(segment, no_speech_threshold, logprob_threshold):
logger.debug(
"Dropping probable hallucinated segment %r (no_speech_prob=%.3f, avg_logprob=%.3f)",
getattr(segment, "text", ""),
getattr(segment, "no_speech_prob", float("nan")),
getattr(segment, "avg_logprob", float("nan")),
)
continue
kept.append(segment.text.strip())
return " ".join(kept).strip()
def _get_or_load_local_model(model_name: str, local_cfg: Dict[str, Any]):
"""Return the cached faster-whisper model, (re)loading under the lock when needed.
Double-checked lock: concurrent voice messages must not both download/load.
The returned strong reference stays valid even if the idle watcher nulls the
module global mid-transcription.
"""
global _local_model, _local_model_name
model = _local_model
if model is None or _local_model_name != model_name:
with _local_model_lock:
if _local_model is None or _local_model_name != model_name:
logger.info("Loading faster-whisper model '%s' (first load downloads the model)...", model_name)
# stt.local.device / compute_type let users pin a configuration
# where ``auto`` mis-detects; the loader keeps the CUDA→CPU fallback.
_local_model = _load_local_whisper_model(
model_name,
device=local_cfg.get("device", "auto"),
compute_type=local_cfg.get("compute_type", "auto"),
)
_local_model_name = model_name
model = _local_model
return model
def _transcribe_local(
file_path: str,
model_name: str,
*,
language: Optional[str] = None,
prompt: Optional[str] = None,
) -> Dict[str, Any]:
"""Transcribe using faster-whisper (local, free)."""
global _local_model, _local_model_name
if not _HAS_FASTER_WHISPER and not _try_lazy_install_stt():
return _error_result("faster-whisper not installed")
try:
local_cfg = _load_stt_config().get("local") or {}
# Reset the idle timer BEFORE loading/transcribing so the watcher can't
# count a long in-flight transcription as idle time and unload mid-use.
_touch_transcription_time()
model = _get_or_load_local_model(model_name, local_cfg)
if model is None: # defensive: load failed without raising
return _error_result("Local whisper model failed to load")
stt_config = _load_stt_config()
local_config = stt_config.get("local") or {}
transcribe_kwargs = build_local_transcribe_kwargs(stt_config)
# pre_transcription hook overrides win over config-resolved values.
if language:
transcribe_kwargs["language"] = language
if prompt:
transcribe_kwargs["initial_prompt"] = prompt
try:
segments, info = model.transcribe(file_path, **transcribe_kwargs)
transcript = _join_confident_segments(segments, local_config)
except Exception as exc:
# CUDA libs sometimes only fail at dlopen-on-first-use, AFTER the
# model loaded. Evict the poisoned cached model, reload on CPU and
# retry once — otherwise every later voice message fails until restart.
if not _looks_like_cuda_lib_error(exc):
raise
logger.warning(
"faster-whisper CUDA runtime failed mid-transcribe (%s) — "
"evicting cached model and retrying on CPU (int8).",
exc,
)
from faster_whisper import WhisperModel
model = WhisperModel(model_name, device="cpu", compute_type="int8")
with _local_model_lock:
_local_model = model
_local_model_name = model_name
segments, info = model.transcribe(file_path, **transcribe_kwargs)
transcript = _join_confident_segments(segments, local_config)
logger.info(
"Transcribed %s via local whisper (%s, lang=%s, %.1fs audio)",
Path(file_path).name, model_name, info.language, info.duration,
)
_touch_transcription_time()
idle_timeout = _get_idle_unload_seconds(local_cfg)
if idle_timeout > 0:
_start_idle_unload_watcher(idle_timeout)
return _ok_result(transcript, "local")
except Exception as e:
logger.error("Local transcription failed: %s", e, exc_info=True)
return _error_result(f"Local transcription failed: {e}")
def _prepare_local_audio(file_path: str, work_dir: str) -> tuple[Optional[str], Optional[str]]:
"""Normalize audio for local CLI STT when needed."""
audio_path = Path(file_path)
if audio_path.suffix.lower() in LOCAL_NATIVE_AUDIO_FORMATS:
return file_path, None
ffmpeg = _find_ffmpeg_binary()
if not ffmpeg:
return None, "Local STT fallback requires ffmpeg for non-WAV inputs, but ffmpeg was not found"
converted_path = os.path.join(work_dir, f"{audio_path.stem}.wav")
try:
_run_quiet([ffmpeg, "-y", "-i", file_path, converted_path], timeout=300)
return converted_path, None
except subprocess.TimeoutExpired:
logger.error("ffmpeg conversion timed out for %s", file_path)
return None, "Audio conversion for local STT timed out"
except subprocess.CalledProcessError as e:
details = e.stderr.strip() or e.stdout.strip() or str(e)
logger.error("ffmpeg conversion failed for %s: %s", file_path, details)
return None, f"Failed to convert audio for local STT: {details}"
def _convert_caf_to_wav(file_path: str) -> Optional[str]:
"""Convert CAF to WAV using ffmpeg or afconvert (macOS)."""
audio_path = Path(file_path)
wav_path = os.path.join(audio_path.parent, f"{audio_path.stem}.wav")
ffmpeg = _find_ffmpeg_binary()
if ffmpeg:
try:
subprocess.run([ffmpeg, "-y", "-i", file_path, wav_path],
check=True, capture_output=True, text=True,
timeout=300, stdin=subprocess.DEVNULL,
creationflags=windows_hide_flags())
return wav_path
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
logger.warning("ffmpeg CAF to WAV failed for %s: %s", file_path, e)
afconvert = shutil.which("afconvert")
if afconvert:
try:
subprocess.run([afconvert, file_path, wav_path, "-d", "LEI16", "-f", "WAVE"],
check=True, capture_output=True, text=True,
timeout=300, stdin=subprocess.DEVNULL)
return wav_path
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
logger.warning("afconvert CAF to WAV failed for %s: %s", file_path, e)
return None
def _transcribe_local_command(
file_path: str,
model_name: str,
*,
language: Optional[str] = None,
prompt: Optional[str] = None,
) -> Dict[str, Any]:
"""Run the configured local STT command template and read back a .txt transcript."""
if prompt:
_log_prompt_unsupported("STT provider 'local_command'")
command_template = _get_local_command_template()
if not command_template:
return _error_result(
f"{LOCAL_STT_COMMAND_ENV} not configured and no local whisper binary was found"
)
# Language: hook override > stt.local.language > stt.language > env > "en".
language = language or _resolve_stt_language("local") or DEFAULT_LOCAL_STT_LANGUAGE
normalized_model = _normalize_local_command_model(model_name)
try:
with tempfile.TemporaryDirectory(prefix="hermes-local-stt-") as output_dir:
prepared_input, prep_error = _prepare_local_audio(file_path, output_dir)
if prep_error:
return _error_result(prep_error)
command = command_template.format(
input_path=shlex.quote(prepared_input),
output_dir=shlex.quote(output_dir),
language=shlex.quote(language),
model=shlex.quote(normalized_model),
)
# Scrub Hermes secrets from the child env (same policy as _run_command_stt).
from tools.environments.local import hermes_subprocess_env
_run_quiet(
shlex.split(command), timeout=300,
env=hermes_subprocess_env(inherit_credentials=False),
)
txt_files = sorted(Path(output_dir).glob("*.txt"))
if not txt_files:
return _error_result("Local STT command completed but did not produce a .txt transcript")
transcript_text = txt_files[0].read_text(encoding="utf-8").strip()
logger.info(
"Transcribed %s via local STT command (%s, %d chars)",
Path(file_path).name,
normalized_model,
len(transcript_text),
)
return _ok_result(transcript_text, "local_command")
except KeyError as e:
return _error_result(f"Invalid {LOCAL_STT_COMMAND_ENV} template, missing placeholder: {e}")
except subprocess.CalledProcessError as e:
details = e.stderr.strip() or e.stdout.strip() or str(e)
logger.error("Local STT command failed for %s: %s", file_path, details)
return _error_result(f"Local STT failed: {details}")
except Exception as e:
logger.error("Unexpected error during local command transcription: %s", e, exc_info=True)
return _error_result(f"Local transcription failed: {e}")
# ---------------------------------------------------------------------------
# OpenAI-SDK-shaped providers: groq, openai (+ deepinfra via openai)
# ---------------------------------------------------------------------------
def _close_client(client: Any) -> None:
close = getattr(client, "close", None)
if callable(close):
close()
def _openai_sdk_failure(exc: BaseException, file_path: str, log_label: str) -> Dict[str, Any]:
"""Map an OpenAI-SDK-shaped exception to the shared error envelope.
Order matters: APIConnectionError is checked before APITimeoutError (its
subclass) so timeouts report as connection errors, as they always have.
"""
try:
from openai import APIError, APIConnectionError, APITimeoutError
except ImportError: # pragma: no cover — callers gate on _HAS_OPENAI
APIError = APIConnectionError = APITimeoutError = ()
if isinstance(exc, PermissionError):
return _error_result(f"Permission denied: {file_path}")
if isinstance(exc, APIConnectionError):
return _error_result(f"Connection error: {exc}")
if isinstance(exc, APITimeoutError):
return _error_result(f"Request timeout: {exc}")
if isinstance(exc, APIError):
return _error_result(f"API error: {exc}")
logger.error("%s transcription failed: %s", log_label, exc, exc_info=True)
return _error_result(f"Transcription failed: {exc}")
def _transcribe_groq(
file_path: str,
model_name: str,
*,
language: Optional[str] = None,
prompt: Optional[str] = None,
) -> Dict[str, Any]:
"""Transcribe using Groq Whisper API (free tier available).
Language: hook override > ``stt.groq.language`` > ``stt.language`` > env;
otherwise Groq auto-detects.
"""
api_key = _resolve_provider_key("GROQ_API_KEY", "groq")
if not api_key:
return _error_result("GROQ_API_KEY not set")
if not _HAS_OPENAI:
return _error_result("openai package not installed")
# Auto-correct model if caller passed an OpenAI-only model
if model_name in OPENAI_MODELS:
logger.info("Model %s not available on Groq, using %s", model_name, DEFAULT_GROQ_STT_MODEL)
model_name = DEFAULT_GROQ_STT_MODEL
language = language or _resolve_stt_language("groq")
try:
from openai import OpenAI
client = OpenAI(api_key=api_key, base_url=GROQ_BASE_URL, timeout=30, max_retries=0)
try:
create_kwargs = {
"model": model_name,
"response_format": "text",
}
if language:
create_kwargs["language"] = language
if prompt:
# Only sent when set so the no-hook, no-config request stays byte-identical.
create_kwargs["prompt"] = prompt
with open(file_path, "rb") as audio_file:
transcription = client.audio.transcriptions.create(
file=audio_file,
**create_kwargs,
)
transcript_text = str(transcription).strip()
logger.info("Transcribed %s via Groq API (%s, lang=%s, %d chars)",
Path(file_path).name, model_name, language or "auto", len(transcript_text))
return _ok_result(transcript_text, "groq")
finally:
_close_client(client)
except Exception as e:
return _openai_sdk_failure(e, file_path, "Groq")
def _transcribe_openai(
file_path: str,
model_name: str,
*,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
provider_label: str = "openai",
language: Optional[str] = None,
prompt: Optional[str] = None,
) -> Dict[str, Any]:
"""Transcribe via the OpenAI ``audio.transcriptions.create`` SDK shape.
Shared backend for every OpenAI-compatible STT endpoint (DeepInfra etc.):
callers pass explicit ``api_key``/``base_url`` to skip the OpenAI-only auth
chain and a ``provider_label`` for the response's ``provider``.
"""
if api_key is None:
try:
api_key, fallback_base = _resolve_openai_audio_client_config()
except ValueError as exc:
return _error_result(str(exc))
base_url = base_url or fallback_base
# Language: hook override > stt.<provider>.language > stt.language > env > auto.
language = language or _resolve_stt_language(provider_label)
if not _HAS_OPENAI:
return _error_result("openai package not installed")
# Auto-correct a Groq-only model on the native OpenAI path only —
# third-party endpoints may legitimately serve a whisper-large-v3 variant.
if provider_label == "openai" and model_name in GROQ_MODELS:
logger.info("Model %s not available on OpenAI, using %s", model_name, DEFAULT_STT_MODEL)
model_name = DEFAULT_STT_MODEL
try:
from openai import OpenAI, BadRequestError
client = OpenAI(api_key=api_key, base_url=base_url, timeout=30, max_retries=0)
def _create_transcription(path: str):
with open(path, "rb") as audio_file:
create_kwargs = {
"model": model_name,
"file": audio_file,
"response_format": "text" if model_name == "whisper-1" else "json",
}
if language:
if model_name == "gpt-transcribe":
# gpt-transcribe replaces ``language`` with a ``languages``
# list and rejects requests sending the legacy field.
create_kwargs["extra_body"] = {"languages": [language]}
else:
create_kwargs["language"] = language
logger.debug("Using language hint '%s' for OpenAI STT", language)
if prompt:
# Only sent when set so the no-hook, no-config request stays byte-identical.
create_kwargs["prompt"] = prompt
return client.audio.transcriptions.create(**create_kwargs)
try:
with tempfile.TemporaryDirectory(prefix="hermes-stt-") as work_dir:
try:
transcription = _create_transcription(file_path)
except BadRequestError as exc:
message = str(exc).lower()
if not any(k in message for k in ("unsupported", "corrupted", "invalid file")):
raise
# Newer models reject some containers whisper-1 accepted
# (notably Ogg/Opus voice notes): transcode to m4a, retry once.
converted_path, transcode_error = _transcode_audio_for_stt(file_path, work_dir)
if transcode_error:
return _error_result(transcode_error)
logger.info(
"Retrying %s STT after transcoding %s to m4a (API rejected the original container)",
provider_label, Path(file_path).name,
)
transcription = _create_transcription(converted_path)
transcript_text = _extract_transcript_text(transcription)
logger.info(
"Transcribed %s via %s (%s, %d chars)",
Path(file_path).name, provider_label, model_name, len(transcript_text),
)
return _ok_result(transcript_text, provider_label)
finally:
_close_client(client)
except Exception as e:
return _openai_sdk_failure(e, file_path, provider_label)
# ---------------------------------------------------------------------------
# Provider: mistral (Voxtral Transcribe API)
# ---------------------------------------------------------------------------
def _transcribe_mistral(
file_path: str,
model_name: str,
*,
language: Optional[str] = None,
prompt: Optional[str] = None,
) -> Dict[str, Any]:
"""Transcribe with the ``mistralai`` SDK (``/v1/audio/transcriptions``); requires ``MISTRAL_API_KEY``."""
api_key = _resolve_provider_key("MISTRAL_API_KEY", "mistral")
if not api_key:
return _error_result("MISTRAL_API_KEY not set")
try:
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("stt.mistral", prompt=False)
except Exception:
pass
from mistralai.client import Mistral
with Mistral(api_key=api_key) as client:
with open(file_path, "rb") as audio_file:
complete_kwargs: Dict[str, Any] = {
"model": model_name,
"file": {"content": audio_file, "file_name": Path(file_path).name},
}
# Language: hook override > stt.mistral.language > stt.language > env > auto.
language = language or _resolve_stt_language("mistral")
if language:
complete_kwargs["language"] = language
if prompt:
# Only sent when set so the no-hook, no-config request stays byte-identical.
complete_kwargs["prompt"] = prompt
result = client.audio.transcriptions.complete(**complete_kwargs)
transcript_text = _extract_transcript_text(result)
logger.info(
"Transcribed %s via Mistral API (%s, %d chars)",
Path(file_path).name, model_name, len(transcript_text),
)
return _ok_result(transcript_text, "mistral")
except PermissionError:
return _error_result(f"Permission denied: {file_path}")
except Exception as e:
logger.error("Mistral transcription failed: %s", e, exc_info=True)
return _error_result(f"Mistral transcription failed: {type(e).__name__}")
# ---------------------------------------------------------------------------
# REST multipart providers: xAI, ElevenLabs
# ---------------------------------------------------------------------------
def _post_audio_multipart(url: str, headers: Dict[str, str], file_path: str, data: Dict[str, str]):
import requests
with open(file_path, "rb") as audio_file:
return requests.post(
url, headers=headers, files={"file": (Path(file_path).name, audio_file)},
data=data, timeout=120,
)
def _http_error_detail(response, extract) -> str:
"""``extract(json_body)`` -> detail string, falling back to the first 300 chars of the body."""
try:
return extract(response.json()) or response.text[:300]
except Exception:
return response.text[:300]
def _elevenlabs_error_detail(err_body: Dict[str, Any]) -> str:
error_value = err_body.get("detail") or err_body.get("error")
if isinstance(error_value, dict):
return str(error_value.get("message") or error_value)
return str(error_value) if error_value else ""
def _transcribe_xai(
file_path: str,
model_name: str,
*,
language: Optional[str] = None,
prompt: Optional[str] = None,
) -> Dict[str, Any]:
"""Transcribe via xAI ``POST /v1/stt`` (multipart). Supports ITN, diarization, word timestamps."""
from tools.xai_http import resolve_xai_http_credentials
if prompt:
_log_prompt_unsupported("STT provider 'xai'")
# STT is API-billed: prefer the explicit XAI_API_KEY over the general xAI
# OAuth/Grok-subscription credential, which may be valid for Grok yet hit
# personal-team spending-limit errors on /v1/stt.
direct_api_key = str(get_env_value("XAI_API_KEY") or "").strip()
if direct_api_key:
creds = {
"provider": "xai",
"api_key": direct_api_key,
"base_url": str(
get_env_value("XAI_BASE_URL") or "https://api.x.ai/v1"
).strip().rstrip("/"),
}
else:
creds = resolve_xai_http_credentials()
api_key = str(creds.get("api_key") or "").strip()
if not api_key:
return _error_result(
"No xAI credentials found. Configure xAI OAuth in `hermes model` or set XAI_API_KEY"
)
stt_config = _load_stt_config()
xai_config = stt_config.get("xai") or {}
def _resolve_base_url(resolved_creds: Dict[str, str]) -> str:
# OAuth bearers are pinned to the resolver-validated xAI origin;
# config/env base URL overrides only apply to API-key credentials.
if resolved_creds.get("provider") == "xai-oauth":
return str(
resolved_creds.get("base_url") or XAI_STT_BASE_URL
).strip().rstrip("/")
return str(
xai_config.get("base_url")
or get_env_value("XAI_STT_BASE_URL")
or resolved_creds.get("base_url")
or XAI_STT_BASE_URL
).strip().rstrip("/")
base_url = _resolve_base_url(creds)
# Language: hook override > stt.xai.language > stt.language > env.
language = language or _resolve_stt_language("xai", stt_config) or ""
use_format = is_truthy_value(xai_config.get("format", True))
use_diarize = is_truthy_value(xai_config.get("diarize", False))
try:
from tools.xai_http import hermes_xai_user_agent
data: Dict[str, str] = {}
if language:
data["language"] = language
if use_format:
data["format"] = "true"
if use_diarize:
data["diarize"] = "true"
def _post_transcription(bearer: str, endpoint_base_url: str):
return _post_audio_multipart(
f"{endpoint_base_url}/stt",
{"Authorization": f"Bearer {bearer}", "User-Agent": hermes_xai_user_agent()},
file_path, data,
)
response = _post_transcription(api_key, base_url)
if (
response.status_code in {401, 403}
and creds.get("provider") == "xai-oauth"
):
logger.info(
"xAI STT got HTTP %d; refreshing OAuth credentials and retrying once",
response.status_code,
)
try:
refreshed_creds = resolve_xai_http_credentials(
force_refresh=True,
api_key_hint=api_key,
)
refreshed_key = str(refreshed_creds.get("api_key") or "").strip()
if refreshed_key and refreshed_key != api_key:
response = _post_transcription(
refreshed_key,
_resolve_base_url(refreshed_creds),
)
except Exception as retry_exc:
logger.warning(
"xAI STT OAuth refresh-and-retry after HTTP %d failed: %s",
response.status_code,
retry_exc,
)
if response.status_code != 200:
detail = _http_error_detail(response, lambda body: body.get("error", {}).get("message", ""))
return _error_result(f"xAI STT API error (HTTP {response.status_code}): {detail}")
result = response.json()
transcript_text = result.get("text", "").strip()
if not transcript_text:
return _error_result("xAI STT returned empty transcript", no_speech=True)
logger.info(
"Transcribed %s via xAI Grok STT (lang=%s, %.1fs audio, %d chars)",
Path(file_path).name,
result.get("language", language),
result.get("duration", 0),
len(transcript_text),
)
return _ok_result(transcript_text, "xai")
except PermissionError:
return _error_result(f"Permission denied: {file_path}")
except Exception as e:
logger.error("xAI STT transcription failed: %s", e, exc_info=True)
return _error_result(f"xAI STT transcription failed: {e}")
# ---------------------------------------------------------------------------
# Provider: ElevenLabs (Scribe STT API)
# ---------------------------------------------------------------------------
def _transcribe_elevenlabs(
file_path: str,
model_name: str,
*,
language: Optional[str] = None,
prompt: Optional[str] = None,
) -> Dict[str, Any]:
"""Transcribe using ElevenLabs Scribe STT API."""
if prompt:
_log_prompt_unsupported("STT provider 'elevenlabs'")
api_key = _resolve_provider_key("ELEVENLABS_API_KEY", "elevenlabs")
if not api_key:
return _error_result("ELEVENLABS_API_KEY not set")
stt_config = _load_stt_config()
elevenlabs_config = stt_config.get("elevenlabs") or {}
base_url = str(
elevenlabs_config.get("base_url")
or get_env_value("ELEVENLABS_STT_BASE_URL")
or ELEVENLABS_STT_BASE_URL
).strip().rstrip("/")
# Language: hook override > stt.elevenlabs.language(_code) > stt.language.
language_code = language or _resolve_stt_language(
"elevenlabs", stt_config, extra_keys=("language_code",)
) or ""
tag_audio_events = is_truthy_value(elevenlabs_config.get("tag_audio_events", False))
diarize = is_truthy_value(elevenlabs_config.get("diarize", False))
try:
data: Dict[str, str] = {
"model_id": model_name,
"tag_audio_events": "true" if tag_audio_events else "false",
"diarize": "true" if diarize else "false",
}
if language_code:
data["language_code"] = language_code
response = _post_audio_multipart(
f"{base_url}/speech-to-text", {"xi-api-key": api_key}, file_path, data,
)
if response.status_code != 200:
detail = _http_error_detail(response, _elevenlabs_error_detail)
return _error_result(f"ElevenLabs STT API error (HTTP {response.status_code}): {detail}")
transcript_text = _extract_transcript_text(response.json())
if not transcript_text:
return _error_result("ElevenLabs STT returned empty transcript", no_speech=True)
logger.info(
"Transcribed %s via ElevenLabs Scribe (%s, %d chars)",
Path(file_path).name,
model_name,
len(transcript_text),
)
return _ok_result(transcript_text, "elevenlabs")
except PermissionError:
return _error_result(f"Permission denied: {file_path}")
except Exception as e:
logger.error("ElevenLabs STT transcription failed: %s", e, exc_info=True)
return _error_result(f"ElevenLabs STT transcription failed: {e}")
# ---------------------------------------------------------------------------
# Provider: DeepInfra (OpenAI-compatible /v1/audio/transcriptions)
# ---------------------------------------------------------------------------
def _transcribe_deepinfra(
file_path: str,
model_name: str,
*,
language: Optional[str] = None,
prompt: Optional[str] = None,
) -> Dict[str, Any]:
"""Resolve DeepInfra credentials/model (via the shared ``hermes_cli.models``
helpers), then delegate to :func:`_transcribe_openai`."""
api_key = _resolve_provider_key("DEEPINFRA_API_KEY", "deepinfra")
if not api_key:
return _error_result("DEEPINFRA_API_KEY not set")
from hermes_cli.models import deepinfra_base_url, deepinfra_model_ids
# ``stt.deepinfra: null`` in YAML yields None, not {} — coalesce.
base_url = deepinfra_base_url(_get_stt_section(_load_stt_config(), "deepinfra"))
if not model_name:
candidates = deepinfra_model_ids("stt")
if not candidates:
return _error_result(
"No DeepInfra STT model available. Pin one in "
"config.yaml under stt.deepinfra.model, or check "
"connectivity to api.deepinfra.com so the live catalog "
"can be fetched."
)
model_name = candidates[0]
return _transcribe_openai(
file_path,
model_name,
api_key=api_key,
base_url=base_url,
provider_label="deepinfra",
language=language,
prompt=prompt,
)
# ---------------------------------------------------------------------------
# Cloud pre-upload silence trim
# ---------------------------------------------------------------------------
#
# Local faster-whisper gets Silero VAD; cloud providers get the raw file, so
# every second of silence is paid for twice (upload + per-minute billing) and
# cloud Whisper hallucinates on it. Before uploading to a built-in cloud
# provider we collapse long pauses with ffmpeg's silenceremove, keeping
# ``stt.cloud_trim_keep_ms`` of each pause so word boundaries survive.
# Purely best-effort — ANY of these uploads the original untouched:
# ``stt.cloud_trim_silence: false``, ffmpeg/ffprobe missing, trim failure or
# timeout, a ~empty result (the provider, not a dB heuristic, decides "no
# speech"), or <10% saving. Command-type and plugin providers are NOT trimmed:
# they may wrap local CLIs that want the original bytes.
_CLOUD_TRIM_THRESHOLD_DB_DEFAULT = -40 # audio below this level counts as silence
_CLOUD_TRIM_KEEP_MS_DEFAULT = 300 # how much of each pause survives the trim
_CLOUD_TRIM_MIN_SAVING = 0.10 # use the trimmed file only when >=10% shorter
_CLOUD_TRIM_MIN_RESULT_SECONDS = 0.3 # all-silence guard floor: never upload ~empty audio
# Below this the trim can't pay for itself (several providers bill a 10s
# minimum per request) and the encode would sit on the synchronous voice-note path.
_CLOUD_TRIM_MIN_INPUT_SECONDS = 12.0
def _probe_audio_duration(file_path: str) -> Optional[float]:
"""Return the audio duration in seconds via ffprobe, or None.
Canonical sync probe; ``gateway/run.py._probe_audio_duration`` and the
Telegram adapter carry local variants — keep the command shape in sync.
"""
ffprobe = _find_ffprobe_binary()
if not ffprobe:
return None
command = [
ffprobe, "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
file_path,
]
try:
return float(_run_quiet(command, timeout=30).stdout.strip())
except Exception: # noqa: BLE001 - probe is best-effort
return None
def _cloud_trim_settings(stt_config: Dict[str, Any]) -> tuple[bool, int, int]:
"""Resolve (enabled, threshold_db, keep_ms) for the cloud silence trim."""
cfg = stt_config if isinstance(stt_config, dict) else {}
# is_truthy_value: a YAML string "false" must disable, exactly like is_stt_enabled.
enabled = is_truthy_value(cfg.get("cloud_trim_silence", True), default=True)
threshold_db = _config_number(cfg, "cloud_trim_threshold_db", _CLOUD_TRIM_THRESHOLD_DB_DEFAULT, int)
keep_ms = _config_number(cfg, "cloud_trim_keep_ms", _CLOUD_TRIM_KEEP_MS_DEFAULT, int)
return enabled, threshold_db, max(keep_ms, 0)
def _trim_silence_for_cloud_stt(
file_path: str, stt_config: Dict[str, Any]
) -> Optional[str]:
"""Return a silence-trimmed copy of *file_path* for cloud upload, or None.
``None`` always means "upload the original" (disabled, tools missing, clip
too short, trim failed, mostly silence, or not enough saving). On success
the caller owns deleting the returned file's parent directory.
"""
enabled, threshold_db, keep_ms = _cloud_trim_settings(stt_config)
if not enabled:
return None
ffmpeg = _find_ffmpeg_binary()
if not ffmpeg:
logger.debug("Cloud STT silence trim skipped: ffmpeg not found")
return None
original_duration = _probe_audio_duration(file_path)
if not original_duration or original_duration <= 0:
logger.debug("Cloud STT silence trim skipped: could not probe %s", file_path)
return None
if original_duration < _CLOUD_TRIM_MIN_INPUT_SECONDS:
logger.debug(
"Cloud STT silence trim skipped for %s: %.1fs is below the %.0fs gate",
Path(file_path).name, original_duration, _CLOUD_TRIM_MIN_INPUT_SECONDS,
)
return None
keep_seconds = keep_ms / 1000.0
# start_periods=1 strips leading silence; stop_periods=-1 collapses every
# interior/trailing silence, keeping ``keep_seconds`` of each pause.
filter_expr = (
f"silenceremove="
f"start_periods=1:start_threshold={threshold_db}dB:start_silence={keep_seconds}:"
f"stop_periods=-1:stop_threshold={threshold_db}dB:stop_silence={keep_seconds}"
)
work_dir = tempfile.mkdtemp(prefix="hermes-stt-trim-")
trimmed_path = os.path.join(work_dir, f"{Path(file_path).stem or 'audio'}-trimmed.m4a")
# Scale the all-silence guard with keep_ms: an output consisting solely
# of kept pause must never be uploaded as "speech".
min_result_seconds = max(_CLOUD_TRIM_MIN_RESULT_SECONDS, 2 * keep_seconds)
keep_result = False
try:
_run_ffmpeg_stt_encode(ffmpeg, file_path, trimmed_path, audio_filter=filter_expr)
trimmed_duration = _probe_audio_duration(trimmed_path)
if not trimmed_duration or trimmed_duration < min_result_seconds:
logger.debug(
"Cloud STT silence trim discarded for %s: trimmed result ~empty (%.2fs)",
Path(file_path).name, trimmed_duration or 0.0,
)
return None
if trimmed_duration > original_duration * (1 - _CLOUD_TRIM_MIN_SAVING):
logger.debug(
"Cloud STT silence trim discarded for %s: saves <%.0f%% (%.1fs -> %.1fs)",
Path(file_path).name, _CLOUD_TRIM_MIN_SAVING * 100,
original_duration, trimmed_duration,
)
return None
logger.info(
"Trimmed silence from %s before cloud STT upload (%.1fs -> %.1fs, -%d%%)",
Path(file_path).name, original_duration, trimmed_duration,
round((1 - trimmed_duration / original_duration) * 100),
)
keep_result = True
return trimmed_path
except Exception as exc: # noqa: BLE001 - trim is best-effort
logger.debug("Cloud STT silence trim failed for %s: %s", file_path, exc)
return None
finally:
if not keep_result:
shutil.rmtree(work_dir, ignore_errors=True)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def _transcribe_prepared_audio(
file_path: str,
model: Optional[str] = None,
source: Optional[str] = None,
) -> Dict[str, Any]:
"""Transcribe a validated audio file with the configured STT provider.
``model`` overrides the config/provider default; ``source`` is a caller-surface
label (``"gateway"``, ``"voice_mode"``) forwarded to the ``pre_transcription``
hook for observability only. Returns the standard result envelope.
"""
# Refuse to feed a credential / secret store (auth.json, .env, OAuth
# tokens, ...) to an STT provider, which would ship its plaintext to a
# third-party API. Mirrors the image-gen / video-gen read guards.
from agent.file_safety import get_read_block_error
blocked = get_read_block_error(file_path)
if blocked:
return _error_result(blocked)
# Validate before provider resolution so invalid files cannot trigger
# provider setup or lazy installation. The remote-upload size cap is
# enforced below, only for non-local providers.
error = _validate_audio_file(file_path, enforce_size_limit=False)
if error:
return error
stt_config = _load_stt_config()
if not is_stt_enabled(stt_config):
return _error_result("STT is disabled in config.yaml (stt.enabled: false).")
provider = _get_provider(stt_config)
if not _is_local_stt_provider(provider, stt_config):
error = _validate_audio_file_size(Path(file_path))
if error:
return error
# Convert CAF (iMessage voice notes) to WAV for cloud STT providers.
if Path(file_path).suffix.lower() == ".caf" and provider not in ("local", "local_command"):
converted = _convert_caf_to_wav(file_path)
if not converted:
return _error_result("CAF audio could not be converted to WAV.")
file_path = converted
# Best-effort pre-upload silence trim for built-in cloud providers.
trim_cleanup_dir: Optional[str] = None
if provider in CLOUD_STT_PROVIDERS:
trimmed = _trim_silence_for_cloud_stt(file_path, stt_config)
if trimmed:
file_path = trimmed
trim_cleanup_dir = os.path.dirname(trimmed)
try:
return _dispatch_stt_provider(file_path, provider, stt_config, model, source)
finally:
if trim_cleanup_dir:
shutil.rmtree(trim_cleanup_dir, ignore_errors=True)
def _builtin_model_name(provider: str, stt_config: Dict[str, Any], model: Optional[str]) -> str:
"""Resolve the model for a built-in provider: caller override > ``stt.<provider>`` config > default."""
if model:
return model
if provider in ("local", "local_command"):
return _get_stt_section(stt_config, "local").get("model", DEFAULT_LOCAL_MODEL)
if provider == "xai":
return "grok-stt" # xAI STT takes no model parameter — logging only
cfg = _get_stt_section(stt_config, provider)
if provider == "groq":
return cfg.get("model") or DEFAULT_GROQ_STT_MODEL
if provider == "openai":
return cfg.get("model", DEFAULT_STT_MODEL)
if provider == "mistral":
return cfg.get("model", DEFAULT_MISTRAL_STT_MODEL)
if provider == "elevenlabs":
return cfg.get("model_id", DEFAULT_ELEVENLABS_STT_MODEL)
return cfg.get("model") or "" # deepinfra: resolved from the live catalog when empty
# Built-in provider -> handler. Looked up at call time so tests may patch the
# module-level ``_transcribe_*`` functions.
_BUILTIN_STT_HANDLERS = {
"local": lambda *a, **kw: _transcribe_local(*a, **kw),
"local_command": lambda *a, **kw: _transcribe_local_command(*a, **kw),
"groq": lambda *a, **kw: _transcribe_groq(*a, **kw),
"openai": lambda *a, **kw: _transcribe_openai(*a, **kw),
"mistral": lambda *a, **kw: _transcribe_mistral(*a, **kw),
"xai": lambda *a, **kw: _transcribe_xai(*a, **kw),
"elevenlabs": lambda *a, **kw: _transcribe_elevenlabs(*a, **kw),
"deepinfra": lambda *a, **kw: _transcribe_deepinfra(*a, **kw),
}
def _dispatch_stt_provider(
file_path: str,
provider: str,
stt_config: Dict[str, Any],
model: Optional[str] = None,
source: Optional[str] = None,
) -> Dict[str, Any]:
"""Route *file_path* to the handler for *provider* (built-in > command > plugin)."""
# Static ``stt.prompt`` is the base; pre_transcription hook results mutate
# on top in registration order (last hook to set a field wins).
prompt = stt_config.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
prompt = None
# The hook fires after provider resolution and BEFORE any backend is
# invoked; ``language`` stays None unless a hook overrides it.
model, language, prompt = _apply_pre_transcription_hook(
file_path=file_path,
provider=provider,
model=model,
language=_get_stt_section(stt_config, provider).get("language"),
prompt=prompt,
source=source,
)
prompt = _enforce_prompt_length_limit(prompt, provider)
handler = _BUILTIN_STT_HANDLERS.get(provider)
if handler is not None:
model_name = _builtin_model_name(provider, stt_config, model)
if provider in ("local", "local_command"):
model_name = _normalize_local_model(model_name)
return handler(file_path, model_name, language=language, prompt=prompt)
# User-declared command provider: after built-ins (so ``stt.providers.openai
# .command`` can't override the real handler) and BEFORE plugins, because
# config is more local than a plugin install (same precedence as TTS).
command_provider_config = _resolve_command_stt_provider_config(provider, stt_config)
if command_provider_config is not None:
return _transcribe_command_stt(
file_path,
provider,
command_provider_config,
stt_config,
model_override=model,
language_override=language,
prompt=prompt,
)
# Plugin-registered backend. Plugins read per-provider config under
# ``stt.<provider>`` like built-ins; the ``model`` argument overrides it.
plugin_cfg = _get_stt_section(stt_config, provider)
plugin_result = _dispatch_to_plugin_provider(
file_path,
provider,
stt_config,
model=model or plugin_cfg.get("model"),
language=language or _resolve_stt_language(provider, stt_config),
prompt=prompt,
)
if plugin_result is not None:
return plugin_result
provider_key = str(provider or "").strip().lower()
if (
"provider" in stt_config
and provider_key
and provider_key not in BUILTIN_STT_PROVIDERS
and provider_key != "none"
):
return _unregistered_stt_provider_error(provider_key)
# An explicit openai selection flattened to "none" carries a
# selection-specific reason (e.g. managed openai-audio gateway down);
# surface it with its remediation instead of the all-provider hint.
if provider_key == "none" and str(stt_config.get("provider") or "") == "openai" and _HAS_OPENAI:
try:
_resolve_openai_audio_client_config()
except ValueError as exc:
return _error_result(str(exc))
return _error_result(
"No STT provider available. Install faster-whisper for free local "
f"transcription, configure {LOCAL_STT_COMMAND_ENV} or install a local whisper CLI, "
"set GROQ_API_KEY for free Groq Whisper, set MISTRAL_API_KEY for Mistral "
"Voxtral Transcribe, configure xAI OAuth or set XAI_API_KEY for xAI Grok STT, "
"set ELEVENLABS_API_KEY for ElevenLabs Scribe, or set VOICE_TOOLS_OPENAI_KEY "
"or OPENAI_API_KEY for the OpenAI Whisper API."
)
def transcribe_audio(
file_path: str,
model: Optional[str] = None,
source: Optional[str] = None,
) -> Dict[str, Any]:
"""Safely validate, preprocess supported inputs, and dispatch transcription.
``source`` is an optional caller-surface label (``"gateway"``, ``"voice_mode"``)
forwarded to the ``pre_transcription`` hook for observability only.
"""
# Secret-store refusal runs before ANY validation so the error names the
# real reason rather than a format error.
from agent.file_safety import get_read_block_error
blocked = get_read_block_error(file_path)
if blocked:
return _error_result(blocked)
# Cap .silk sources before the decoder runs (decoder safety); for all other
# inputs the upload cap is provider-scoped in _transcribe_prepared_audio,
# so local whisper can handle big files.
is_silk = Path(file_path).suffix.lower() == ".silk"
source_error = _validate_audio_source_file(file_path, enforce_size_limit=is_silk)
if source_error:
return source_error
prepared_path, cleanup_dir, prep_error = _prepare_audio_for_transcription(file_path)
if prep_error:
return prep_error
if prepared_path is None:
return _error_result("Audio preprocessing did not produce a file for transcription.")
try:
prepared_error = _validate_audio_file(prepared_path, enforce_size_limit=False)
if prepared_error:
return prepared_error
return _transcribe_prepared_audio(prepared_path, model, source)
finally:
if cleanup_dir:
shutil.rmtree(cleanup_dir, ignore_errors=True)
def _is_local_or_private_url(url: str) -> bool:
"""True for loopback/RFC-1918/LAN-internal hosts.
Decides whether an empty ``stt.openai.api_key`` is acceptable: local
OpenAI-compatible STT servers ignore the auth header, so users shouldn't
need a sham ``api_key: not-needed``.
"""
try:
from urllib.parse import urlparse
import ipaddress
host = (urlparse(url).hostname or "").lower()
if not host:
return False
if host == "localhost" or host.endswith((".local", ".lan", ".internal")):
return True
try:
return ipaddress.ip_address(host).is_private or ipaddress.ip_address(host).is_loopback
except ValueError:
return False
except Exception:
return False
def transcribe_audio_local_fallback(
file_path: str,
model: Optional[str] = None,
) -> Dict[str, Any]:
"""Try an already-installed local STT backend without changing config.
For passive inbound-media recovery after the configured provider failed:
never lazy-installs or falls through to a cloud provider.
"""
error = _validate_audio_file(file_path)
if error:
return error
local_cfg = _load_stt_config().get("local") or {}
local_model = model or local_cfg.get("model", DEFAULT_LOCAL_MODEL)
if _HAS_FASTER_WHISPER:
return _transcribe_local(file_path, _normalize_local_model(local_model))
if _has_local_command():
return _transcribe_local_command(file_path, _normalize_local_command_model(local_model))
return _error_result("No installed local STT backend is available.", provider="local")
def _direct_openai_credentials(cfg_api_key: str, cfg_base_url: str) -> Optional[tuple[str, str]]:
"""Direct-credential ladder: config key > keyless local base_url > env key; None if none apply.
A local OpenAI-compatible server needs no key — send a placeholder so the
SDK doesn't refuse to construct a client.
"""
if cfg_api_key:
return cfg_api_key, (cfg_base_url or OPENAI_BASE_URL)
if cfg_base_url and _is_local_or_private_url(cfg_base_url):
return "not-needed", cfg_base_url
direct_api_key = resolve_openai_audio_api_key()
if direct_api_key:
return direct_api_key, OPENAI_BASE_URL
return None
def _resolve_openai_audio_client_config() -> tuple[str, str]:
"""Return ``(api_key, base_url)`` for the OpenAI STT client.
Strict selection semantics on the stored ``stt`` provider string:
- ``"nous"`` → managed gateway ONLY; unentitled/unreachable is a
selection-naming error (a direct OPENAI_API_KEY must NOT override it).
- any other stored provider → direct credentials ONLY; missing credentials
is a selection-naming error — no silent managed fallback.
- never-configured stt section → legacy ladder: direct credentials, then
the managed gateway.
"""
from tools.tool_backend_helpers import (
NOUS_MANAGED_PROVIDER,
read_selection,
selection_error,
)
openai_cfg = _load_stt_config().get("openai") or {}
cfg_api_key = openai_cfg.get("api_key", "")
cfg_base_url = openai_cfg.get("base_url", "")
selected = read_selection("stt")
if selected == NOUS_MANAGED_PROVIDER:
managed_gateway = resolve_managed_tool_gateway("openai-audio")
if managed_gateway is None:
raise ValueError(selection_error(
"stt",
NOUS_MANAGED_PROVIDER,
"the Nous Tool Gateway is not available (not entitled or "
"unreachable)",
))
return managed_gateway.nous_user_token, urljoin(
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"
)
direct = _direct_openai_credentials(cfg_api_key, cfg_base_url)
if direct is not None:
return direct
if selected is not None:
raise ValueError(selection_error(
"stt",
selected,
"neither stt.openai.api_key in config nor "
"VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set",
))
managed_gateway = resolve_managed_tool_gateway("openai-audio")
if managed_gateway is None:
message = "Neither stt.openai.api_key in config nor VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set"
if managed_nous_tools_enabled():
message += (
". "
+ nous_tool_gateway_unavailable_message(
"managed OpenAI audio for transcription",
)
)
raise ValueError(message)
return managed_gateway.nous_user_token, urljoin(
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"
)
def _extract_transcript_text(transcription: Any) -> str:
"""Normalize text / object / dict transcription responses to a plain string."""
if isinstance(transcription, str):
text = transcription.strip()
else:
value = getattr(transcription, "text", None)
if not isinstance(value, str) and isinstance(transcription, dict):
value = transcription.get("text")
text = value.strip() if isinstance(value, str) else str(transcription).strip()
match = re.match(
r"\s*language\s+[\w.-]+(?:\s*<audio_language>[^<]*</audio_language>)?\s*<asr_text>\s*(?P<text>.*)",
text,
flags=re.IGNORECASE | re.DOTALL,
)
if match:
text = match.group("text").strip()
return text