From 9d4dd9b1f1b03c342a42ca920a81babc75730596 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:38:22 -0700 Subject: [PATCH 01/10] refactor(tools): collapse command-provider quoting/process-tree/config guards into single expressions --- tools/tts_command_provider.py | 123 ++++++++++++---------------------- 1 file changed, 42 insertions(+), 81 deletions(-) diff --git a/tools/tts_command_provider.py b/tools/tts_command_provider.py index 852d1c48b1..0724b71a32 100644 --- a/tools/tts_command_provider.py +++ b/tools/tts_command_provider.py @@ -30,6 +30,8 @@ import time from pathlib import Path from typing import Any, Dict, FrozenSet, Optional +from utils import is_truthy_value + def shell_quote_context(command_template: str, position: int) -> Optional[str]: """Return the shell quote char (``'``/``"``) active right before *position*, or None.""" @@ -48,10 +50,8 @@ def shell_quote_context(command_template: str, position: int) -> Optional[str]: escaped = True elif char == '"': quote = None - elif char == "'": - quote = "'" - elif char == '"': - quote = '"' + elif char in ("'", '"'): + quote = char elif char == "\\": i += 1 i += 1 @@ -64,9 +64,7 @@ def quote_command_placeholder(value: str, quote_context: Optional[str]) -> str: return value.replace("'", r"'\''") if quote_context == '"': return value.replace("\\", "\\\\").replace('"', r'\"').replace("$", r"\$").replace("`", r"\`") - if os.name == "nt": - return subprocess.list2cmdline([value]) - return shlex.quote(value) + return subprocess.list2cmdline([value]) if os.name == "nt" else shlex.quote(value) def render_command_template(command_template: str, placeholders: Dict[str, str]) -> str: @@ -110,35 +108,28 @@ def terminate_command_process_tree(proc: subprocess.Popen) -> None: return if os.name == "nt": try: - subprocess.run( - ["taskkill", "/F", "/T", "/PID", str(proc.pid)], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5, stdin=subprocess.DEVNULL, - ) + subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=5, stdin=subprocess.DEVNULL) except Exception: proc.kill() return try: import psutil # type: ignore except ImportError: - proc.terminate() - try: - proc.wait(timeout=2) - except subprocess.TimeoutExpired: - proc.kill() - return - _signal_process_tree(psutil, proc, "terminate") + psutil = None + # Without psutil only the shell itself is signalled (children may survive). + signal = (lambda m: getattr(proc, m)()) if psutil is None else (lambda m: _signal_process_tree(psutil, proc, m)) + signal("terminate") try: proc.wait(timeout=2) except subprocess.TimeoutExpired: - _signal_process_tree(psutil, proc, "kill") + signal("kill") def command_env_passthrough(config: Dict[str, Any]) -> list: """``env_passthrough`` allowlist: parent env vars copied back into the secret-scrubbed child env.""" raw = config.get("env_passthrough") - if not isinstance(raw, (list, tuple)): - return [] - return [str(item).strip() for item in raw if str(item).strip()] + return [str(item).strip() for item in raw if str(item).strip()] if isinstance(raw, (list, tuple)) else [] def command_failure_detail(exc: subprocess.CalledProcessError) -> str: @@ -164,17 +155,13 @@ def run_command_provider( value = os.environ.get(key) if value is not None: scrubbed[key] = value - popen_kwargs: Dict[str, Any] = { - "shell": True, "stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "text": True, - # Lossy UTF-8 decode: locale-mismatched bytes must not raise in the reader threads. - "encoding": "utf-8", "errors": "replace", "env": delegated_child_subprocess_env(scrubbed), - } - if os.name == "nt": - popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) - else: - popen_kwargs["start_new_session"] = True - - proc = subprocess.Popen(command, **popen_kwargs, stdin=subprocess.DEVNULL) + # Own process group so the whole tree can be signalled on idle timeout. Lossy UTF-8 decode: + # locale-mismatched bytes must not raise in the reader threads. + group = ({"creationflags": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)} if os.name == "nt" + else {"start_new_session": True}) + proc = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + encoding="utf-8", errors="replace", env=delegated_child_subprocess_env(scrubbed), + stdin=subprocess.DEVNULL, **group) output_queue: "queue.Queue[tuple[str, Optional[str]]]" = queue.Queue() chunks: Dict[str, list[str]] = {"stdout": [], "stderr": []} open_streams = {"stdout", "stderr"} @@ -191,13 +178,10 @@ def run_command_provider( finally: output_queue.put((name, None)) - readers = [ - threading.Thread(target=read_stream, args=(name, stream), daemon=True) - for name, stream in (("stdout", proc.stdout), ("stderr", proc.stderr)) - ] + readers = [threading.Thread(target=read_stream, args=(name, stream), daemon=True) + for name, stream in (("stdout", proc.stdout), ("stderr", proc.stderr))] for reader in readers: reader.start() - deadline = time.monotonic() + timeout timed_out = False while open_streams: @@ -225,20 +209,14 @@ def run_command_provider( terminate_command_process_tree(proc) for reader in readers: reader.join(timeout=0.5) - while True: - try: - name, chunk = output_queue.get_nowait() - except queue.Empty: - break + while not output_queue.empty(): + name, chunk = output_queue.get_nowait() if chunk: chunks[name].append(chunk) - stdout, stderr = "".join(chunks["stdout"]), "".join(chunks["stderr"]) - try: - raise subprocess.TimeoutExpired(command, timeout) - except subprocess.TimeoutExpired as exc: - raise subprocess.TimeoutExpired(command, timeout, output=stdout, stderr=stderr) from exc - stdout, stderr = "".join(chunks["stdout"]), "".join(chunks["stderr"]) + if timed_out: + raise subprocess.TimeoutExpired(command, timeout, output=stdout, stderr=stderr) from ( + subprocess.TimeoutExpired(command, timeout)) if proc.returncode: raise subprocess.CalledProcessError(proc.returncode, command, output=stdout, stderr=stderr) return subprocess.CompletedProcess(command, proc.returncode, stdout, stderr) @@ -251,9 +229,7 @@ def run_command_provider( def _get_provider_section(config: Dict[str, Any], name: str) -> Dict[str, Any]: """Return ``config[name]`` if it's a dict, else an empty dict.""" - if not isinstance(config, dict): - return {} - section = config.get(name) + section = config.get(name) if isinstance(config, dict) else None return section if isinstance(section, dict) else {} @@ -266,9 +242,7 @@ def _named_provider_config(config: Dict[str, Any], name: str, builtins: FrozenSe section = _get_provider_section(config, "providers").get(name) if isinstance(section, dict): return section - if name.lower() not in builtins: - return _get_provider_section(config, name) - return {} + return _get_provider_section(config, name) if name.lower() not in builtins else {} def _is_command_provider_config(config: Dict[str, Any]) -> bool: @@ -276,20 +250,16 @@ def _is_command_provider_config(config: Dict[str, Any]) -> bool: 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()) + return ptype in ("", "command") and isinstance(command, str) and bool(command.strip()) def _resolve_command_config( provider: str, config: Dict[str, Any], reserved: FrozenSet[str], ) -> Optional[Dict[str, Any]]: """Config of a user-declared command provider; None for *reserved* names, unknown or non-command.""" - if not provider: - return None - key = provider.lower().strip() - if key in reserved: + key = (provider or "").lower().strip() + if not key or key in reserved: return None named = _named_provider_config(config, key, reserved) return named if _is_command_provider_config(named) else None @@ -334,6 +304,10 @@ def _resolve_command_provider_config(provider: str, tts_config: Dict[str, Any]) return _resolve_command_config(provider, tts_config, BUILTIN_TTS_PROVIDERS) +def _get_command_tts_timeout(config: Dict[str, Any]) -> float: + return _command_timeout(config, DEFAULT_COMMAND_TTS_TIMEOUT_SECONDS) + + def _iter_command_providers(tts_config: Dict[str, Any]): """Yield (name, config) pairs for every declared command-type provider.""" for name, cfg in _get_provider_section(tts_config, "providers").items(): @@ -341,25 +315,17 @@ def _iter_command_providers(tts_config: Dict[str, Any]): yield name, cfg -def _get_command_tts_timeout(config: Dict[str, Any]) -> float: - return _command_timeout(config, DEFAULT_COMMAND_TTS_TIMEOUT_SECONDS) - - def _get_command_tts_output_format(config: Dict[str, Any], output_path: Optional[str] = None) -> str: """Validated output format: the output path's suffix wins, then ``format``/``output_format``.""" - if output_path: - suffix = Path(output_path).suffix.lower().strip().lstrip(".") - if suffix in COMMAND_TTS_OUTPUT_FORMATS: - return suffix + suffix = Path(output_path).suffix.lower().strip().lstrip(".") if output_path else "" + if suffix in COMMAND_TTS_OUTPUT_FORMATS: + return suffix return _command_output_format(config, COMMAND_TTS_OUTPUT_FORMATS, DEFAULT_COMMAND_TTS_OUTPUT_FORMAT) def _is_command_tts_voice_compatible(config: Dict[str, Any]) -> bool: """True only when the user explicitly opted in to voice delivery.""" - value = config.get("voice_compatible", False) - if isinstance(value, str): - return value.strip().lower() in {"1", "true", "yes", "on"} - return bool(value) + return is_truthy_value(config.get("voice_compatible", False)) def _configured_command_tts_output_path(path: Path, config: Dict[str, Any]) -> Path: @@ -383,24 +349,19 @@ def _generate_command_tts( output.parent.mkdir(parents=True, exist_ok=True) if output.exists(): output.unlink() - timeout = _get_command_tts_timeout(config) - output_format = _get_command_tts_output_format(config, str(output)) - speed = config.get("speed", tts_config.get("speed", "")) - with tempfile.TemporaryDirectory() as tmpdir: text_path = Path(tmpdir) / "input.txt" text_path.write_text(text, encoding="utf-8") placeholders = { "input_path": str(text_path), "text_path": str(text_path), "output_path": str(output), - "format": output_format, "voice": str(config.get("voice", "")), - "model": str(config.get("model", "")), "speed": str(speed), + "format": _get_command_tts_output_format(config, str(output)), "voice": str(config.get("voice", "")), + "model": str(config.get("model", "")), "speed": str(config.get("speed", tts_config.get("speed", ""))), } command = render_command_template(command_template, placeholders) try: # Resolved through the origin so tests patching ``tools.tts_tool._run_command_tts`` still intercept. from tools.tts_tool import _run_command_tts - _run_command_tts(command, timeout, env_passthrough=command_env_passthrough(config)) except subprocess.TimeoutExpired as exc: raise RuntimeError(f"TTS provider '{provider_name}' timed out after {timeout:g}s") from exc From 67a6769c6905f1b1f9881633862b032028f87824 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:40:27 -0700 Subject: [PATCH 02/10] =?UTF-8?q?refactor(tools):=20fold=20streaming=20TTS?= =?UTF-8?q?=20provider=20bodies=20=E2=80=94=20inline=20single-use=20locals?= =?UTF-8?q?,=20drop=20async=20shim,=20squeeze=20import=20blanks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/tts_streaming.py | 58 +++++++++++++----------------------------- 1 file changed, 17 insertions(+), 41 deletions(-) diff --git a/tools/tts_streaming.py b/tools/tts_streaming.py index a84625048f..b08255d7cb 100644 --- a/tools/tts_streaming.py +++ b/tools/tts_streaming.py @@ -32,7 +32,6 @@ def _resolve_key(env_var: str, provider_id: str) -> str: """ try: from tools.tts_tool import _resolve_provider_key - return _resolve_provider_key(env_var, provider_id) or "" except Exception: return get_env_value(env_var) or "" @@ -98,8 +97,7 @@ class SentenceChunker: def flush(self) -> List[str]: """Drain the tail (end-of-text or long-idle flush).""" - tail = _THINK_BLOCK_RE.sub("", self.buf).strip() - self.buf = "" + tail, self.buf = _THINK_BLOCK_RE.sub("", self.buf).strip(), "" return [tail] if tail else [] @@ -164,11 +162,7 @@ def resolve_streaming_provider( """ pinned = str((tts_config.get("streaming") or {}).get("provider") or "").lower().strip() if pinned == "auto": - for name in _PROVIDER_PRIORITY: - inst = _try_instantiate(name, tts_config) - if inst is not None: - return inst - return None + return next((inst for name in _PROVIDER_PRIORITY if (inst := _try_instantiate(name, tts_config))), None) return _try_instantiate(pinned or (preferred or _get_provider(tts_config)).lower().strip(), tts_config) @@ -198,26 +192,23 @@ class ElevenLabsStreamer(StreamingTTSProvider): from tools.tts_tool_providers import ( DEFAULT_ELEVENLABS_STREAMING_MODEL_ID, DEFAULT_ELEVENLABS_VOICE_ID, _elevenlabs_environment_kwargs, ) - client = _import_elevenlabs()( api_key=_resolve_key("ELEVENLABS_API_KEY", "elevenlabs"), **_elevenlabs_environment_kwargs(self.section), ) - voice_id = self.section.get("voice_id", DEFAULT_ELEVENLABS_VOICE_ID) - model_id = self.section.get( - "streaming_model_id", self.section.get("model_id", DEFAULT_ELEVENLABS_STREAMING_MODEL_ID), - ) yield from client.text_to_speech.convert( - text=text, voice_id=voice_id, model_id=model_id, output_format="pcm_24000", + text=text, voice_id=self.section.get("voice_id", DEFAULT_ELEVENLABS_VOICE_ID), + model_id=self.section.get("streaming_model_id", + self.section.get("model_id", DEFAULT_ELEVENLABS_STREAMING_MODEL_ID)), + output_format="pcm_24000", ) def _openai_config_api_key() -> str: """Return ``tts.openai.api_key`` from config.yaml, or empty string.""" try: - openai_cfg = (_load_tts_config().get("openai") or {}) + return (_load_tts_config().get("openai") or {}).get("api_key") or "" except Exception: return "" - return openai_cfg.get("api_key") or "" @register("openai") @@ -232,7 +223,6 @@ class OpenAIStreamer(StreamingTTSProvider): def stream(self, text: str) -> Iterator[bytes]: from openai import OpenAI - client = OpenAI( api_key=(self.section.get("api_key") or resolve_openai_audio_api_key()), base_url=(self.section.get("base_url") or get_env_value("OPENAI_BASE_URL") or None), @@ -257,13 +247,10 @@ class GeminiStreamer(StreamingTTSProvider): def stream(self, text: str) -> Iterator[bytes]: import base64 import json as _json - import requests - from tools.tts_tool_providers import ( DEFAULT_GEMINI_TTS_BASE_URL, DEFAULT_GEMINI_TTS_MODEL, DEFAULT_GEMINI_TTS_VOICE, ) - api_key = _gemini_key() model = str(self.section.get("model", DEFAULT_GEMINI_TTS_MODEL)).strip() or DEFAULT_GEMINI_TTS_MODEL voice = str(self.section.get("voice", DEFAULT_GEMINI_TTS_VOICE)).strip() or DEFAULT_GEMINI_TTS_VOICE @@ -288,13 +275,11 @@ class GeminiStreamer(StreamingTTSProvider): if not line or not line.startswith("data: "): continue try: - event = _json.loads(line[len("data: "):]) - parts = event["candidates"][0]["content"]["parts"] + parts = _json.loads(line[len("data: "):])["candidates"][0]["content"]["parts"] except (ValueError, KeyError, IndexError, TypeError): continue for part in parts: - inline = part.get("inlineData") or part.get("inline_data") or {} - b64 = inline.get("data", "") + b64 = (part.get("inlineData") or part.get("inline_data") or {}).get("data", "") if not b64: continue try: @@ -320,9 +305,7 @@ class XAIStreamer(StreamingTTSProvider): def available() -> bool: try: from tools.xai_http import resolve_xai_http_credentials - - creds = resolve_xai_http_credentials() - return bool(str(creds.get("api_key") or "").strip()) + return bool(str(resolve_xai_http_credentials().get("api_key") or "").strip()) except Exception: return False @@ -332,21 +315,16 @@ class XAIStreamer(StreamingTTSProvider): def _collect_async(self, text: str) -> List[bytes]: import asyncio - return asyncio.run(self._drain_async(text)) - - async def _drain_async(self, text: str) -> List[bytes]: - return [frame async for frame in self._async_frames(text)] + async def _drain() -> List[bytes]: + return [frame async for frame in self._async_frames(text)] + return asyncio.run(_drain()) async def _async_frames(self, text: str): import json as _json - import websockets - from tools.tts_tool_providers import DEFAULT_XAI_VOICE_ID from tools.xai_http import resolve_xai_http_credentials - - creds = resolve_xai_http_credentials() - api_key = str(creds.get("api_key") or "").strip() + api_key = str(resolve_xai_http_credentials().get("api_key") or "").strip() if not api_key: raise RuntimeError("No xAI credentials for streaming TTS") voice = str(self.section.get("voice_id", DEFAULT_XAI_VOICE_ID)).strip() or DEFAULT_XAI_VOICE_ID @@ -367,15 +345,13 @@ class XAIStreamer(StreamingTTSProvider): return continue etype = envelope.get("type") - if etype == "done": - return if etype == "error": logger.warning( "xAI WS error envelope: %s", envelope.get("error") or envelope.get("message") or envelope, ) + if etype in ("done", "error"): return except Exception as exc: - if exc.__class__.__name__ == "ConnectionClosed": - return - logger.warning("xAI WS receive failed: %s", exc) + if exc.__class__.__name__ != "ConnectionClosed": + logger.warning("xAI WS receive failed: %s", exc) return From f09b63a26e2bc26d31f82d41ec5199aaef3a1626 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:44:04 -0700 Subject: [PATCH 03/10] =?UTF-8?q?refactor(tools):=20tighten=20STT=20dispat?= =?UTF-8?q?cher=20=E2=80=94=20probe=20flags,=20resolver=20early-returns,?= =?UTF-8?q?=20watcher=20loop,=20local-fallback=20locals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/transcription_tools.py | 77 +++++++++++++----------------------- 1 file changed, 27 insertions(+), 50 deletions(-) diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index da1a8ac01b..fe9bfcd39a 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -91,10 +91,8 @@ def _safe_find_spec(module_name: str) -> bool: 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") +_HAS_FASTER_WHISPER, _HAS_OPENAI, _HAS_MISTRAL, _HAS_PILK = map( + _safe_find_spec, ("faster_whisper", "openai", "mistralai", "pilk")) # Local model singleton; the lock guards check-then-load against concurrent voice messages. _local_model: Optional[object] = None @@ -123,9 +121,8 @@ def _load_stt_config() -> dict: def is_stt_enabled(stt_config: Optional[dict] = None) -> bool: - if stt_config is None: - stt_config = _load_stt_config() - return is_truthy_value(stt_config.get("enabled", True), default=True) + cfg = _load_stt_config() if stt_config is None else stt_config + return is_truthy_value(cfg.get("enabled", True), default=True) def _resolve_stt_language( @@ -143,10 +140,7 @@ def _resolve_stt_language( 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 + return next((c.strip() for c in candidates if isinstance(c, str) and c.strip()), None) def _openai_audio_unavailable_reason() -> Optional[str]: @@ -172,9 +166,8 @@ def _is_local_stt_provider(provider: str, stt_config: Dict[str, Any]) -> bool: 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) or (needs_mistral and not _HAS_MISTRAL): - return False - return bool(_resolve_provider_key(env_var, provider)) + sdk_ok = (not needs_openai or _HAS_OPENAI) and (not needs_mistral or _HAS_MISTRAL) + return sdk_ok and bool(_resolve_provider_key(env_var, provider)) return probe @@ -201,18 +194,15 @@ def _detect_local_backend() -> Optional[str]: """faster-whisper > local whisper CLI > lazy-installed faster-whisper; None when nothing local works.""" if _HAS_FASTER_WHISPER: return "local" - if _has_local_command(): - return "local_command" - return "local" if _try_lazy_install_stt() else None + return "local_command" if _has_local_command() else ("local" if _try_lazy_install_stt() else None) def _resolve_explicit_local() -> str: backend = _detect_local_backend() - if backend: - return backend - logger.warning("STT provider 'local' configured but unavailable " - "(install faster-whisper or set HERMES_LOCAL_STT_COMMAND)") - return "none" + if not backend: + logger.warning("STT provider 'local' configured but unavailable " + "(install faster-whisper or set HERMES_LOCAL_STT_COMMAND)") + return backend or "none" def _resolve_explicit_local_command() -> str: @@ -273,12 +263,9 @@ def _resolve_explicit_provider(provider: str) -> str: if resolver is not None: return resolver() spec = _CLOUD_PROVIDER_SPECS.get(provider) - if spec is None: + if spec is None or spec[0](): return provider - probe, _auto, warning, _log = spec - if probe(): - return provider - logger.warning(warning) + logger.warning(spec[2]) return "none" @@ -339,9 +326,7 @@ def _start_idle_unload_watcher(timeout_seconds: int) -> None: return def _watch(initial_timeout=timeout_seconds): - while not _idle_unload_stop.is_set(): - if _idle_unload_stop.wait(_IDLE_UNLOAD_CHECK_INTERVAL) or _local_model is None: - break + while not _idle_unload_stop.wait(_IDLE_UNLOAD_CHECK_INTERVAL) and _local_model is not None: try: timeout = _get_idle_unload_seconds(_load_stt_config().get("local") or {}) except Exception: # noqa: BLE001 - keep the seed value @@ -375,10 +360,8 @@ def _get_or_load_local_model(model_name: str, local_cfg: Dict[str, Any]): 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 pin a configuration where ``auto`` mis-detects. - _local_model = _load_local_whisper_model( - model_name, device=local_cfg.get("device", "auto"), - compute_type=local_cfg.get("compute_type", "auto"), - ) + _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 @@ -390,8 +373,7 @@ def _replace_cached_model_on_cpu(model_name: str): 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 + _local_model, _local_model_name = model, model_name return model @@ -409,12 +391,9 @@ def _transcribe_local( 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") - 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 + transcribe_kwargs = build_local_transcribe_kwargs(stt_config) + transcribe_kwargs.update({k: v for k, v in (("language", language), ("initial_prompt", prompt)) if v}) try: segments, info = model.transcribe(file_path, **transcribe_kwargs) except Exception as exc: @@ -470,11 +449,11 @@ def _transcribe_prepared_audio( 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"): - file_path = _convert_caf_to_wav(file_path) - if not file_path: - return _error_result("CAF audio could not be converted to WAV.") + # Convert CAF (iMessage voice notes) to WAV for cloud STT providers. + if Path(file_path).suffix.lower() == ".caf": + file_path = _convert_caf_to_wav(file_path) + if not file_path: + return _error_result("CAF audio could not be converted to WAV.") # Best-effort pre-upload silence trim for built-in cloud providers. trim_cleanup_dir: Optional[str] = None if provider in CLOUD_STT_PROVIDERS: @@ -520,8 +499,7 @@ def _dispatch_stt_provider( """Route *file_path* to the handler for *provider* (built-in > command > plugin).""" # Static ``stt.prompt`` is the base; hook results mutate on top (last hook to set a field wins). prompt = stt_config.get("prompt") - if not isinstance(prompt, str) or not prompt.strip(): - prompt = None + prompt = prompt if isinstance(prompt, str) and prompt.strip() else None # Fires after provider resolution and BEFORE any backend; ``language`` stays None unless a hook sets it. model, language, prompt = _apply_pre_transcription_hook( file_path=file_path, provider=provider, model=model, @@ -607,8 +585,7 @@ def transcribe_audio_local_fallback(file_path: str, model: Optional[str] = None) 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) + local_model = model or (_load_stt_config().get("local") or {}).get("model", DEFAULT_LOCAL_MODEL) if _HAS_FASTER_WHISPER: return _transcribe_local(file_path, _normalize_local_model(local_model)) if _has_local_command(): From 35c32f6498554011624edaabc11c50692c3b31d5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:46:20 -0700 Subject: [PATCH 04/10] =?UTF-8?q?refactor(tools):=20fold=20cloud=20STT=20r?= =?UTF-8?q?equest=20builders=20=E2=80=94=20single-use=20locals,=20hoisted?= =?UTF-8?q?=20ASR=20regex,=20merged=20with-blocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/transcription_cloud.py | 88 +++++++++++++++--------------------- 1 file changed, 37 insertions(+), 51 deletions(-) diff --git a/tools/transcription_cloud.py b/tools/transcription_cloud.py index f3a3414b4b..25714431d8 100644 --- a/tools/transcription_cloud.py +++ b/tools/transcription_cloud.py @@ -26,6 +26,10 @@ from tools.transcription_common import ( # Log-record parity with the origin module. logger = logging.getLogger("tools.transcription_tools") +# Voxtral-style ``language xx ...`` prefix some SDK responses carry. +_ASR_TEXT_RE = re.compile( + r"\s*language\s+[\w.-]+(?:\s*[^<]*)?\s*\s*(?P.*)", + flags=re.IGNORECASE | re.DOTALL) def _has_xai_stt_credentials() -> bool: @@ -92,9 +96,9 @@ def _transcribe_groq( language = language or _resolve_stt_language("groq") def _run(client): - create_kwargs = {"model": model_name, "response_format": "text", **_sdk_prompt_kwargs(language, prompt)} with open(file_path, "rb") as audio_file: - transcription = client.audio.transcriptions.create(file=audio_file, **create_kwargs) + transcription = client.audio.transcriptions.create( + file=audio_file, model=model_name, response_format="text", **_sdk_prompt_kwargs(language, prompt)) 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)) @@ -137,8 +141,8 @@ def _transcribe_openai( "model": model_name, "response_format": "text" if model_name == "whisper-1" else "json", } if language: + # gpt-transcribe takes a ``languages`` list and rejects the legacy field. if model_name == "gpt-transcribe": - # gpt-transcribe takes a ``languages`` list and rejects the legacy field. create_kwargs["extra_body"] = {"languages": [language]} else: create_kwargs["language"] = language @@ -152,8 +156,7 @@ def _transcribe_openai( 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")): + if not any(k in str(exc).lower() for k in ("unsupported", "corrupted", "invalid file")): raise # Newer models reject containers whisper-1 accepted (Ogg/Opus voice notes): transcode, retry once. converted_path, transcode_error = _transcode_audio_for_stt(file_path, work_dir) @@ -181,18 +184,17 @@ def _transcribe_mistral( try: _lazy_ensure_quietly("stt.mistral") from mistralai.client import Mistral - with Mistral(api_key=api_key) as client: - with open(file_path, "rb") as audio_file: - # Language: hook override > stt.mistral.language > stt.language > env > auto. - language = language or _resolve_stt_language("mistral") - result = client.audio.transcriptions.complete( - model=model_name, file={"content": audio_file, "file_name": Path(file_path).name}, - **_sdk_prompt_kwargs(language, prompt), - ) - 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") + with Mistral(api_key=api_key) as client, open(file_path, "rb") as audio_file: + # Language: hook override > stt.mistral.language > stt.language > env > auto. + language = language or _resolve_stt_language("mistral") + result = client.audio.transcriptions.complete( + model=model_name, file={"content": audio_file, "file_name": Path(file_path).name}, + **_sdk_prompt_kwargs(language, prompt), + ) + 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 Exception as e: return _cloud_failure(e, file_path, "Mistral transcription", type(e).__name__) @@ -245,11 +247,9 @@ def _transcribe_xai( # STT is API-billed: prefer the explicit XAI_API_KEY over the xAI OAuth/Grok-subscription # credential, which may be valid for Grok yet hit 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() + 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("/") + } if direct_api_key else 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") @@ -258,10 +258,9 @@ def _transcribe_xai( def _resolve_base_url(resolved_creds: Dict[str, str]) -> str: # OAuth bearers are pinned to the resolver-validated origin; overrides apply to API keys only. - if resolved_creds.get("provider") == "xai-oauth": - url = resolved_creds.get("base_url") - else: - url = xai_config.get("base_url") or get_env_value("XAI_STT_BASE_URL") or resolved_creds.get("base_url") + url = resolved_creds.get("base_url") + if resolved_creds.get("provider") != "xai-oauth": + url = xai_config.get("base_url") or get_env_value("XAI_STT_BASE_URL") or url return str(url or XAI_STT_BASE_URL).strip().rstrip("/") # Language: hook override > stt.xai.language > stt.language > env. @@ -270,9 +269,8 @@ def _transcribe_xai( def _post() -> Any: from tools.xai_http import hermes_xai_user_agent data: Dict[str, str] = {"language": language} if language else {} - for flag, default in (("format", True), ("diarize", False)): - if is_truthy_value(xai_config.get(flag, default)): - data[flag] = "true" + data.update({flag: "true" for flag, default in (("format", True), ("diarize", False)) + if is_truthy_value(xai_config.get(flag, default))}) def _post_transcription(bearer: str, endpoint_base_url: str): headers = {"Authorization": f"Bearer {bearer}", "User-Agent": hermes_xai_user_agent()} @@ -329,9 +327,8 @@ def _transcribe_elevenlabs( "model_id": model_name, "tag_audio_events": str(is_truthy_value(elevenlabs_config.get("tag_audio_events", False))).lower(), "diarize": str(is_truthy_value(elevenlabs_config.get("diarize", False))).lower(), + **({"language_code": language_code} if language_code else {}), } - if language_code: - data["language_code"] = language_code return _post_audio_multipart(f"{base_url}/speech-to-text", {"xi-api-key": api_key}, file_path, data) def _log(transcript_text: str, _body: Dict[str, Any]) -> None: @@ -353,14 +350,12 @@ def _transcribe_deepinfra( 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")) + model_name = model_name or next(iter(deepinfra_model_ids("stt")), None) 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 _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." + ) return _transcribe_openai(file_path, model_name, api_key=api_key, base_url=base_url, provider_label="deepinfra", language=language, prompt=prompt) @@ -374,11 +369,9 @@ def _is_local_or_private_url(url: str) -> bool: 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 - addr = ipaddress.ip_address(host) + addr = ipaddress.ip_address(host) # raises for "" and non-IP hostnames return addr.is_private or addr.is_loopback except Exception: # unparsable URL or non-IP hostname return False @@ -419,18 +412,14 @@ def _resolve_openai_audio_client_config() -> tuple[str, str]: managed = _managed() if managed is None: raise ValueError(selection_error( - "stt", NOUS_MANAGED_PROVIDER, - "the Nous Tool Gateway is not available (not entitled or unreachable)", - )) + "stt", NOUS_MANAGED_PROVIDER, "the Nous Tool Gateway is not available (not entitled or unreachable)")) return managed direct = _direct_openai_credentials(openai_cfg.get("api_key", ""), openai_cfg.get("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", - )) + "stt", selected, "neither stt.openai.api_key in config nor VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set")) managed = _managed() if managed is None: message = "Neither stt.openai.api_key in config nor VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set" @@ -446,8 +435,5 @@ def _extract_transcript_text(transcription: Any) -> str: if not isinstance(value, str) and isinstance(transcription, dict): value = transcription.get("text") text = (value if isinstance(value, str) else str(transcription)).strip() - match = re.match( - r"\s*language\s+[\w.-]+(?:\s*[^<]*)?\s*\s*(?P.*)", - text, flags=re.IGNORECASE | re.DOTALL, - ) + match = _ASR_TEXT_RE.match(text) return match.group("text").strip() if match else text From 97b554f2005aa3a0b36138b79f19e7b3d13b7cef Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:48:24 -0700 Subject: [PATCH 05/10] =?UTF-8?q?refactor(tools):=20fold=20command/plugin?= =?UTF-8?q?=20STT=20and=20audio=20prep=20bodies=20=E2=80=94=20inline=20sin?= =?UTF-8?q?gle-use=20locals,=20merged=20guards,=20squeezed=20blanks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/transcription_audio.py | 54 ++++++++++---------------------- tools/transcription_command.py | 57 +++++++++------------------------- 2 files changed, 30 insertions(+), 81 deletions(-) diff --git a/tools/transcription_audio.py b/tools/transcription_audio.py index 15413f370a..51935a2b80 100644 --- a/tools/transcription_audio.py +++ b/tools/transcription_audio.py @@ -65,11 +65,8 @@ _STT_M4A_ENCODE_ARGS = ("-vn", "-ac", "1", "-ar", "16000", "-c:a", "aac", "-b:a" 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 semantics.""" - 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) + filter_args = ["-af", audio_filter] if audio_filter else [] + _run_quiet([ffmpeg, "-y", "-i", input_path, *filter_args, *_STT_M4A_ENCODE_ARGS, output_path], timeout=120) def _transcode_audio_for_stt(file_path: str, work_dir: str) -> tuple[Optional[str], Optional[str]]: @@ -121,13 +118,10 @@ def _validate_audio_source_file(file_path: str, *, enforce_size_limit: bool = Tr 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 + if source_error or suffix.lower() in SUPPORTED_FORMATS: + return source_error + return _error_result(f"Unsupported format: {suffix}. Supported: {', '.join(sorted(SUPPORTED_FORMATS))}") def _prepare_audio_for_transcription(file_path: str) -> tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]: @@ -143,12 +137,10 @@ def _prepare_audio_for_transcription(file_path: str) -> tuple[Optional[str], Opt 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") @@ -165,11 +157,9 @@ def _prepare_local_audio(file_path: str, work_dir: str) -> tuple[Optional[str], 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) @@ -194,9 +184,7 @@ def _convert_caf_to_wav(file_path: str) -> Optional[str]: ("ffmpeg", [ffmpeg, "-y", "-i", file_path, wav_path] if ffmpeg else None), ("afconvert", [afconvert, file_path, wav_path, "-d", "LEI16", "-f", "WAVE"] if afconvert else None), ) - for label, command in candidates: - if not command: - continue + for label, command in ((label, cmd) for label, cmd in candidates if cmd): try: _run_quiet(command, timeout=300) return wav_path @@ -236,11 +224,9 @@ def _probe_audio_duration(file_path: str) -> Optional[float]: 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()) + return float(_run_quiet([ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", + "default=noprint_wrappers=1:nokey=1", file_path], timeout=30).stdout.strip()) except Exception: # noqa: BLE001 - probe is best-effort return None @@ -274,12 +260,9 @@ def _trim_silence_for_cloud_stt(file_path: str, stt_config: Dict[str, Any]) -> O return None name = Path(file_path).name if original_duration < _CLOUD_TRIM_MIN_INPUT_SECONDS: - logger.debug( - "Cloud STT silence trim skipped for %s: %.1fs is below the %.0fs gate", - name, original_duration, _CLOUD_TRIM_MIN_INPUT_SECONDS, - ) + logger.debug("Cloud STT silence trim skipped for %s: %.1fs is below the %.0fs gate", + 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. filter_expr = ( @@ -296,20 +279,15 @@ def _trim_silence_for_cloud_stt(file_path: str, stt_config: Dict[str, Any]) -> O _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)", name, trimmed_duration or 0.0, - ) + logger.debug("Cloud STT silence trim discarded for %s: trimmed result ~empty (%.2fs)", + 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)", - name, _CLOUD_TRIM_MIN_SAVING * 100, original_duration, trimmed_duration, - ) + logger.debug("Cloud STT silence trim discarded for %s: saves <%.0f%% (%.1fs -> %.1fs)", + 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%%)", - name, original_duration, trimmed_duration, round((1 - trimmed_duration / original_duration) * 100), - ) + logger.info("Trimmed silence from %s before cloud STT upload (%.1fs -> %.1fs, -%d%%)", + 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 diff --git a/tools/transcription_command.py b/tools/transcription_command.py index 52e00c3dea..f26767163d 100644 --- a/tools/transcription_command.py +++ b/tools/transcription_command.py @@ -63,15 +63,9 @@ def _get_command_stt_output_format(config: Dict[str, Any]) -> str: def _read_command_stt_output(output_path: Path, stdout: str, fmt: str) -> str: """Transcript: non-empty output file > non-empty stdout (curl one-liners) > RuntimeError. JSON is returned raw.""" - 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() + content = output_path.read_bytes().decode("utf-8", errors="replace").strip() if output_path.exists() else "" + if content or (stdout or "").strip(): + return content or stdout.strip() raise RuntimeError(f"Command STT provider wrote no output file at {output_path} and produced no stdout") @@ -96,28 +90,21 @@ def _transcribe_command_stt( 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 "" - + language = (language_override or config.get("language") + or _resolve_stt_language(provider_name, stt_config) or DEFAULT_COMMAND_STT_LANGUAGE) try: with tempfile.TemporaryDirectory(prefix=f"hermes-cmd-stt-{provider_name}-") as tmpdir: output_path = Path(tmpdir) / f"transcript.{output_format}" - placeholders = { + command = _render_command_stt_template(command_template, { "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) + "language": str(language), "model": str(model_override or config.get("model") or ""), + }) logger.info("Transcribing %s via command STT provider '%s'...", audio.name, provider_name) result = _run_command_stt(command, timeout, env_passthrough=_command_stt_env_passthrough(config)) transcript_text = _read_command_stt_output(output_path, result.stdout or "", output_format) @@ -131,7 +118,6 @@ def _transcribe_command_stt( 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) @@ -159,17 +145,14 @@ def _dispatch_to_plugin_provider( ``is_available() == False`` returns an error envelope — not None — because the user explicitly opted in via ``stt.provider``. Provider exceptions become the error envelope. """ - if not provider: - return None - key = provider.lower().strip() - if key in _NON_COMMAND_STT_NAMES: + key = (provider or "").lower().strip() + if not key or key in _NON_COMMAND_STT_NAMES: 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: @@ -182,7 +165,6 @@ def _dispatch_to_plugin_provider( 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: @@ -198,7 +180,6 @@ def _dispatch_to_plugin_provider( 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. @@ -208,7 +189,6 @@ def _dispatch_to_plugin_provider( 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) @@ -229,10 +209,8 @@ _WHISPER_PROMPT_CAPPED_PROVIDERS = frozenset({"local", "openai", "groq", "deepin def _enforce_prompt_length_limit(prompt: Optional[str], provider: str) -> Optional[str]: """Truncate *prompt* to the whisper-family token cap, keeping the TAIL (whisper conditions on the final context window, so the newest hints survive). Other providers self-validate.""" - 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: + if not prompt or provider not in _WHISPER_PROMPT_CAPPED_PROVIDERS or len(prompt) <= max_chars: return prompt logger.warning( "Transcription prompt is ~%d tokens; whisper-family provider '%s' " @@ -255,19 +233,15 @@ def _apply_pre_transcription_hook( """ 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(): + for key, value in (hook_result.items() if isinstance(hook_result, dict) else ()): if key == "file_path": logger.warning( "pre_transcription hook attempted to change " @@ -281,13 +255,10 @@ def _apply_pre_transcription_hook( ) else: overrides[key] = value - - if "model" in overrides: - model = overrides["model"] + # Hooks win over the static ``stt.prompt`` config; "" clears it. 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 + return overrides.get("model", 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 From 404a324ea55fdae2489e3cc438f59e0360125f77 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:52:11 -0700 Subject: [PATCH 06/10] refactor(tools): fold local STT loader/gates and TTS normalizer passes into table-driven substitutions --- tools/transcription_local.py | 70 ++++++++++-------------------------- tools/tts_text_normalize.py | 64 +++++++++++---------------------- 2 files changed, 39 insertions(+), 95 deletions(-) diff --git a/tools/transcription_local.py b/tools/transcription_local.py index 306ca7d516..883feadbdd 100644 --- a/tools/transcription_local.py +++ b/tools/transcription_local.py @@ -36,12 +36,8 @@ def _get_local_command_template() -> Optional[str]: 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 + return (f"{shlex.quote(whisper_binary)} {{input_path}} --model {{model}} --output_format txt " + "--output_dir {output_dir} --language {language}") if whisper_binary else None def _has_local_command() -> bool: @@ -52,7 +48,7 @@ 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: + if model_name in OPENAI_MODELS | 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 " @@ -99,16 +95,14 @@ _CUDA_LIB_ERROR_MARKERS = ( 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) + return any(marker in str(exc) 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() + return subprocess.check_output(["/usr/sbin/sysctl", "-n", name], stderr=subprocess.DEVNULL, text=True, + timeout=2).strip() except Exception: return "" @@ -143,25 +137,18 @@ def _load_local_whisper_model(model_name: str, device: str = "auto", compute_typ # 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" - ) + 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, - ) + 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") @@ -180,7 +167,6 @@ def build_local_transcribe_kwargs(stt_config: Optional[Dict[str, Any]] = None) - from tools.transcription_tools import _load_stt_config, _resolve_stt_language stt_config = stt_config if isinstance(stt_config, dict) else _load_stt_config() local_cfg = stt_config.get("local") or {} - # ``vad: null`` in YAML means "default on". vad_enabled = local_cfg.get("vad", True) kwargs: Dict[str, Any] = { @@ -192,30 +178,24 @@ def build_local_transcribe_kwargs(stt_config: Optional[Dict[str, Any]] = None) - kwargs["vad_parameters"] = { "min_silence_duration_ms": _config_number(local_cfg, "vad_min_silence_ms", _VAD_MIN_SILENCE_MS_DEFAULT, int) } - # Push the confidence gate into faster-whisper itself: its internal defaults drop # low-confidence segments BEFORE our post-filter sees them, so 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. kwargs["no_speech_threshold"], kwargs["log_prob_threshold"] = _confidence_thresholds(local_cfg) - 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), - ) + 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: @@ -225,11 +205,10 @@ def _is_hallucinated_segment(segment: Any, no_speech_threshold: float, logprob_t confidence, so quiet-but-real speech survives. Unknown segment shapes are never dropped. """ try: - no_speech_prob = float(getattr(segment, "no_speech_prob")) - avg_logprob = float(getattr(segment, "avg_logprob")) + return (float(segment.no_speech_prob) > no_speech_threshold + and float(segment.avg_logprob) < logprob_threshold) except (AttributeError, 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: @@ -238,11 +217,9 @@ def _join_confident_segments(segments: Any, local_cfg: Dict[str, Any]) -> str: 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")), - ) + 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() @@ -259,37 +236,28 @@ def _transcribe_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_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), - ) + 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: diff --git a/tools/tts_text_normalize.py b/tools/tts_text_normalize.py index 45213cd9ce..1c45bcf465 100644 --- a/tools/tts_text_normalize.py +++ b/tools/tts_text_normalize.py @@ -31,6 +31,7 @@ _MD_HR_RE = re.compile(r"^\s*[-*_]{3,}\s*$", flags=re.MULTILINE) _MD_TABLE_PIPE_RE = re.compile(r"\s*\|\s*") _URL_RE = re.compile(r"https?://\S+") +_DEGREE_UNITS = (("C", "Celsius"), ("F", "Fahrenheit")) # Unit suffix (regex, after a digit) -> spoken word; km/h variants before the bare "m". _UNIT_WORDS = ( (r"km\s*/\s*h", "kilometres per hour"), (r"km/h", "kilometres per hour"), @@ -56,7 +57,6 @@ def strip_markdown_for_tts(text: str) -> str: """Strip Markdown/Telegram formatting while preserving readable words.""" if not text: return "" - text = html.unescape(str(text)) text = _MD_CODE_BLOCK_RE.sub(" ", text) text = _MD_IMAGE_RE.sub(lambda m: f" {m.group(1)} " if m.group(1) else " ", text) @@ -73,23 +73,20 @@ def strip_markdown_for_tts(text: str) -> str: text = _MD_BLOCKQUOTE_RE.sub("", text) text = _MD_LIST_ITEM_RE.sub("", text) text = _MD_HR_RE.sub("", text) - # Leftover table pipes become pauses instead of a spoken "vertical bar". - text = _MD_TABLE_PIPE_RE.sub("; ", text) - return text + return _MD_TABLE_PIPE_RE.sub("; ", text) def _normalize_temperature_ranges(text: str) -> str: """``11-17°C`` -> ``11 to 17 degrees Celsius`` (en/em dash or hyphen; unicode minus normalized).""" number = r"([-+\u2212]?\d+(?:\.\d+)?)" - for unit, word in (("C", "Celsius"), ("F", "Fahrenheit")): + for unit, word in _DEGREE_UNITS: text = re.sub( r"(? str: """Expand common symbols/shorthand into words a TTS engine reads well.""" if not text: return "" - - text = str(text) - text = re.sub("[   ]", " ", text) # non-breaking / thin spaces - text = text.replace("\u2212", "-") # minus sign - text = text.replace("…", "...") # ellipsis + text = re.sub("[   ]", " ", str(text)) # non-breaking / thin spaces + text = text.replace("\u2212", "-").replace("…", "...") # minus sign, ellipsis text = _normalize_temperature_ranges(text) - # Temperatures with a number first, then bare units ("measured in degrees C"), # then any remaining degree symbol (angles, stray cases). - for unit, word in (("C", "Celsius"), ("F", "Fahrenheit")): + for unit, word in _DEGREE_UNITS: text = re.sub( r"(? "5 per month"). Requiring digit-then-letter # keeps "and/or", "N/A", "TCP/IP" and dates like "2026/06" intact. text = re.sub(r"(?<=\d)\s*/\s*(?=[A-Za-z])", " per ", text) - # Money and percentages. The integer part must END in a digit so a trailing # comma ("A$50, ...") is not swallowed into the spoken amount. Prefixed # currencies run first so "$" doesn't eat "NZ$". for symbol, word, flags in _CURRENCY_WORDS: text = re.sub(symbol + r"\s*([\d,]*\d(?:\.\d+)?)", r"\1 " + word, text, flags=flags) text = re.sub(r"(?<=\d)\s*%", " percent", text) - # Operators and separators that commonly leak from formatted answers. - text = text.replace("&", " and ") - text = re.sub("[•◦▪▫]", " ", text) # bullet glyphs + text = re.sub("[•◦▪▫]", " ", text.replace("&", " and ")) # bullet glyphs for symbol, word in (("→", " to "), ("⇒", " to "), ("≈", " about "), ("~", " about ")): text = text.replace(symbol, word) - - text = _VARIATION_SELECTOR_RE.sub("", text) - text = _EMOJI_RE.sub("", text) - return text + return _EMOJI_RE.sub("", _VARIATION_SELECTOR_RE.sub("", text)) def smooth_whitespace_for_tts(text: str) -> str: @@ -150,7 +134,6 @@ def smooth_whitespace_for_tts(text: str) -> str: """ if not text: return "" - raw_lines = text.splitlines() add_sentence_pauses = sum(1 for raw_line in raw_lines if raw_line.replace(_HEAD, "").strip()) > 1 lines: list[str] = [] @@ -180,15 +163,11 @@ def smooth_whitespace_for_tts(text: str) -> str: if add_sentence_pauses and line[-1] not in ".!?;:": line += "." lines.append(line) - flush_pending() - text = "\n".join(lines) - text = re.sub(r"\n{3,}", "\n\n", text) - text = re.sub(r"[ \t]{2,}", " ", text) - text = re.sub(r"\s+([,.;:!?])", r"\1", text) - text = re.sub(r"([,.;:!?])([A-Za-z])", r"\1 \2", text) - text = re.sub(r"\.{4,}", "...", text) + for pattern, repl in ((r"\n{3,}", "\n\n"), (r"[ \t]{2,}", " "), (r"\s+([,.;:!?])", r"\1"), + (r"([,.;:!?])([A-Za-z])", r"\1 \2"), (r"\.{4,}", "...")): + text = re.sub(pattern, repl, text) return text.strip() @@ -219,11 +198,9 @@ def flatten_newlines_for_payload(text: str) -> str: """ if not text: return "" - text = re.sub(r"\n{2,}", ". ", text) - text = re.sub(r"(?<=[.!?;:,])\n", " ", text) - text = text.replace("\n", ". ") - text = re.sub(r"\.\s*\.", ".", text) - text = re.sub(r"[ \t]{2,}", " ", text) + for pattern, repl in ((r"\n{2,}", ". "), (r"(?<=[.!?;:,])\n", " "), (r"\n", ". "), (r"\.\s*\.", "."), + (r"[ \t]{2,}", " ")): + text = re.sub(pattern, repl, text) return text.strip() @@ -265,7 +242,6 @@ def _strip_markdown_for_tts(text: str) -> str: try: return prepare_spoken_text(text, max_chars=None) except Exception: - pass - for pattern, repl in _LEGACY_TTS_STRIP_STEPS: - text = pattern.sub(repl, text) - return text.strip() + for pattern, repl in _LEGACY_TTS_STRIP_STEPS: + text = pattern.sub(repl, text) + return text.strip() From d85b86e97a9abffc0c0679d4a079d9a7d59b4b26 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:53:14 -0700 Subject: [PATCH 07/10] refactor(tools): AST-neutral closer/bracket hug pass over STT/TTS modules --- tools/transcription_audio.py | 9 +++----- tools/transcription_cloud.py | 21 ++++++----------- tools/transcription_command.py | 24 +++++++------------ tools/transcription_common.py | 3 +-- tools/transcription_local.py | 18 +++++---------- tools/transcription_tools.py | 42 ++++++++++++---------------------- tools/tts_command_provider.py | 6 ++--- tools/tts_streaming.py | 16 ++++--------- tools/tts_text_normalize.py | 12 ++++------ 9 files changed, 50 insertions(+), 101 deletions(-) diff --git a/tools/transcription_audio.py b/tools/transcription_audio.py index 51935a2b80..295b2cfa8c 100644 --- a/tools/transcription_audio.py +++ b/tools/transcription_audio.py @@ -21,8 +21,7 @@ from hermes_cli._subprocess_compat import windows_hide_flags from utils import is_truthy_value from tools.transcription_common import ( COMMON_LOCAL_BIN_DIRS, LOCAL_NATIVE_AUDIO_FORMATS, MAX_FILE_SIZE, SUPPORTED_FORMATS, - _config_number, _error_result, _lazy_ensure_quietly, _process_error_detail, -) + _config_number, _error_result, _lazy_ensure_quietly, _process_error_detail) # Log-record parity with the origin module. logger = logging.getLogger("tools.transcription_tools") @@ -54,8 +53,7 @@ def _run_quiet(command: list, *, timeout: float, env: Optional[dict] = None) -> 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(), - ) + stdin=subprocess.DEVNULL, env=env, creationflags=windows_hide_flags()) # Shared encode profile for every STT-bound m4a (transcode and silence-trim): @@ -268,8 +266,7 @@ def _trim_silence_for_cloud_stt(file_path: str, stt_config: Dict[str, Any]) -> O 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}" - ) + 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: output that is solely kept pause must never upload as "speech". diff --git a/tools/transcription_cloud.py b/tools/transcription_cloud.py index 25714431d8..5627b8a410 100644 --- a/tools/transcription_cloud.py +++ b/tools/transcription_cloud.py @@ -21,8 +21,7 @@ from tools.transcription_audio import _transcode_audio_for_stt from tools.transcription_common import ( DEFAULT_GROQ_STT_MODEL, DEFAULT_STT_MODEL, ELEVENLABS_STT_BASE_URL, GROQ_BASE_URL, GROQ_MODELS, OPENAI_BASE_URL, OPENAI_MODELS, XAI_STT_BASE_URL, _error_result, _get_stt_section, - _lazy_ensure_quietly, _log_prompt_unsupported, _ok_result, -) + _lazy_ensure_quietly, _log_prompt_unsupported, _ok_result) # Log-record parity with the origin module. logger = logging.getLogger("tools.transcription_tools") @@ -110,8 +109,7 @@ def _transcribe_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]: + prompt: Optional[str] = None) -> Dict[str, Any]: """Transcribe via the OpenAI ``audio.transcriptions.create`` SDK shape. Shared by every OpenAI-compatible endpoint (DeepInfra etc.): explicit ``api_key``/ @@ -189,8 +187,7 @@ def _transcribe_mistral( language = language or _resolve_stt_language("mistral") result = client.audio.transcriptions.complete( model=model_name, file={"content": audio_file, "file_name": Path(file_path).name}, - **_sdk_prompt_kwargs(language, prompt), - ) + **_sdk_prompt_kwargs(language, prompt)) 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)) @@ -210,8 +207,7 @@ def _post_audio_multipart(url: str, headers: Dict[str, str], file_path: str, dat def _rest_provider( file_path: str, provider: str, label: str, post: Callable[[], Any], extract_detail, - extract_text, log: Callable[[str, Dict[str, Any]], None], -) -> Dict[str, Any]: + extract_text, log: Callable[[str, Dict[str, Any]], None]) -> Dict[str, Any]: """Shared multipart REST flow: ``post()`` -> ``log(text, body)`` -> ok envelope. Non-200 -> ``"