Files
hermes-agent/tools/voice_client_config.py
Teknium 00adf69d92 feat(stt): live transcription while you speak on CLI, TUI and Desktop (stt.streaming)
Voice input was record-then-upload on every surface: nothing showed while the user
talked, and the transcript arrived only after the whole take uploaded. With
`stt.streaming: true` the openai, xai and elevenlabs providers now stream microphone
PCM to their realtime endpoints while the user speaks. Partial text renders live and
the final transcript is ready at end of speech.

- tools/transcription_streaming.py: one session API (push_audio / end_audio /
  finalize / cancel, an on_partial callback, a worker thread that owns the socket)
  with three wires, all verified live: the OpenAI Realtime `type: transcription`
  session (gpt-live-transcribe, 16->24 kHz resample since the endpoint refuses
  < 24 kHz), xAI wss /v1/stt with interim results, and ElevenLabs Scribe v2
  realtime. Plugin providers opt in through
  TranscriptionProvider.streaming_capable / open_stream_session.
- tools/voice_mode.py: AudioRecorder taps its capture callback into the session and
  parks it under the WAV path. transcribe_recording uses the live transcript and
  falls back to the file on any failure or empty result, so CLI push-to-talk, the
  continuous VAD loop and the TUI gateway gain streaming with no call-site changes.
- Classic CLI and Ink TUI show partial text in the input placeholder while recording
  (new `voice.partial` gateway event, contract regenerated). Desktop dictation
  streams PCM to the new /api/audio/transcribe-stream socket (provider keys stay on
  the host, and the host resamples) and shows partial text in the dictation pill.
  The recorded blob stays the fallback.

The plugin streaming contract (streaming_capable / open_stream_session /
TranscriptionStreamSession) and the transcribe-stream socket shape come from #103173.

Co-authored-by: Vinicius <timelord1025@gmail.com>
2026-10-06 12:50:31 -07:00

256 lines
12 KiB
Python

"""Resolve the active profile's STT/TTS config for CLIENT-DIRECT voice.
Single resolver behind ``GET /api/audio/voice-config``: the desktop skips the
audio relay hop by calling providers directly with the profile's own
credentials, resolved through the exact chains of ``tools.transcription_tools``
and ``tools.tts_tool`` so the client gets byte-for-byte what the gateway uses.
Rules: same trust boundary as every REST route (keys never logged, never hit
client disk); relay is the floor, not an error (server-host-only providers and
resolution failures return ``{"mode": "relay"}``); no new key stores.
Config gate: ``voice.client_direct`` (default ``true``).
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# Wire shapes the desktop knows how to speak. Anything else → relay.
# openai-multipart : POST {base_url}/audio/transcriptions (multipart, Bearer)
# xai-stt : POST {base_url}/stt (multipart, Bearer, model, format=true)
# elevenlabs-stt : POST {base_url}/speech-to-text (multipart, xi-api-key)
# openai-speech : POST {base_url}/audio/speech (JSON, Bearer) → audio bytes
# elevenlabs-tts : POST {base_url}/text-to-speech/{voice_id} (JSON, xi-api-key)
STT_WIRE_OPENAI = "openai-multipart"
STT_WIRE_XAI = "xai-stt"
STT_WIRE_ELEVENLABS = "elevenlabs-stt"
TTS_WIRE_OPENAI = "openai-speech"
TTS_WIRE_ELEVENLABS = "elevenlabs-tts"
def _client_direct_enabled() -> bool:
try:
from hermes_cli.config import load_config
voice_cfg = load_config().get("voice") or {}
if not isinstance(voice_cfg, dict):
return True
value = voice_cfg.get("client_direct", True)
except Exception:
return True
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() not in {"0", "false", "no", "off"}
return True
def _relay(reason: str) -> Dict[str, Any]:
"""A relay verdict that tells the client WHY, without secrets."""
return {"mode": "relay", "reason": reason}
def _section(config: Any, provider: str) -> Dict[str, Any]:
"""The provider's own sub-dict of an STT/TTS config, shape-guarded."""
section = config.get(provider) if isinstance(config, dict) else None
return section if isinstance(section, dict) else {}
def _direct(wire: str, provider: str, base_url: Any, api_key: str, model: Any, **extra: Any) -> Dict[str, Any]:
return {"mode": "direct", "wire": wire, "provider": provider, "base_url": base_url,
"api_key": api_key, "model": model, **extra}
def stt_hallucination_filter() -> Dict[str, Any]:
"""The Whisper-silence hallucination contract the relay path applies
(``transcribe_recording`` → ``is_whisper_hallucination``), shipped to the
client so a client-direct transcription agrees with a relayed one instead
of submitting "thank you" on silence as a real turn."""
from tools.voice_mode_transcript import WHISPER_HALLUCINATIONS
return {
"phrases": sorted(WHISPER_HALLUCINATIONS),
# Python's _HALLUCINATION_REPEAT_RE (IGNORECASE) for repetitive filler
# like "OK. OK. OK." — a JS regex source, so a single backslash.
"repeat_regex": "^(?:thank you|thanks|bye|you|ok|okay|the end|[.,!\\s])+$",
}
def _deepinfra_model(section: Dict[str, Any], kind: str) -> Optional[str]:
"""Configured model, else the first catalog model of ``kind`` (stt/tts)."""
from hermes_cli.models import deepinfra_model_ids
return section.get("model") or next(iter(deepinfra_model_ids(kind)), None)
# ── STT ──
# provider -> (env var, default-model attr on transcription_common, base_url).
# ``base_url`` is a transcription_common attr name or a literal URL.
_STT_KEYED: Dict[str, tuple[str, str, str]] = {
"groq": ("GROQ_API_KEY", "DEFAULT_GROQ_STT_MODEL", "GROQ_BASE_URL"),
"mistral": ("MISTRAL_API_KEY", "DEFAULT_MISTRAL_STT_MODEL", "https://api.mistral.ai/v1"),
}
def _resolve_stt_client_config() -> Dict[str, Any]:
from tools import transcription_common as tc
from tools import transcription_tools as tt
stt_config = tt._load_stt_config()
if not tt.is_stt_enabled(stt_config):
return _relay("stt disabled")
provider = tt._get_provider(stt_config)
# Server-host-only: local whisper, the env-var command escape hatch,
# declared command providers, and anything plugin-registered.
if tt._is_local_stt_provider(provider, stt_config):
return _relay("local provider")
if provider not in tc.BUILTIN_STT_PROVIDERS:
return _relay("command/plugin provider")
language = tt._resolve_stt_language(
provider, stt_config, extra_keys=("language_code",) if provider == "elevenlabs" else ())
section = _section(stt_config, provider)
# Same deadline the gateway's own transcription client applies
# (``stt.openai.timeout``; riders such as groq/deepinfra inherit it), so a
# slow endpoint fails the Desktop's direct request instead of hanging it.
timeout_s = tc._config_number(_section(stt_config, "openai"), "timeout", 60.0)
def direct(wire: str, base_url: Any, api_key: str, model: Any) -> Dict[str, Any]:
return _direct(wire, provider, base_url, api_key, model, language=language, timeout_s=timeout_s,
hallucination_filter=stt_hallucination_filter())
def env_base_url(env_var: str, default: str) -> str:
from hermes_cli.config import get_env_value
return str(section.get("base_url") or get_env_value(env_var) or default).strip().rstrip("/")
if provider in _STT_KEYED:
env_var, default_model, base = _STT_KEYED[provider]
api_key = tt._resolve_provider_key(env_var, provider)
if not api_key:
return _relay("no credentials")
return direct(STT_WIRE_OPENAI, getattr(tc, base, base), api_key,
section.get("model") or getattr(tc, default_model))
if provider == "openai":
# Covers the Nous-managed selection too: the resolver returns the user's
# own gateway token + managed base URL — exactly what the client should use.
try:
api_key, base_url = tt._resolve_openai_audio_client_config()
except ValueError as exc:
return _relay(f"openai resolution failed: {exc}")
return direct(STT_WIRE_OPENAI, base_url, api_key, section.get("model") or tc.DEFAULT_STT_MODEL)
if provider == "xai":
# API key only: an xAI OAuth bearer refreshes server-side mid-session and
# would strand the client on the first 401.
from hermes_cli.config import get_env_value
api_key = str(get_env_value("XAI_API_KEY") or "").strip()
if not api_key:
return _relay("xai oauth (server-managed) or no credentials")
return direct(STT_WIRE_XAI, env_base_url("XAI_STT_BASE_URL", tc.XAI_STT_BASE_URL), api_key,
tc.normalize_xai_stt_model(section.get("model")))
if provider == "elevenlabs":
api_key = tt._resolve_provider_key("ELEVENLABS_API_KEY", "elevenlabs")
if not api_key:
return _relay("no credentials")
return direct(STT_WIRE_ELEVENLABS, env_base_url("ELEVENLABS_STT_BASE_URL", tc.ELEVENLABS_STT_BASE_URL),
api_key, section.get("model") or tc.DEFAULT_ELEVENLABS_STT_MODEL)
if provider == "deepinfra":
api_key = tt._resolve_provider_key("DEEPINFRA_API_KEY", "deepinfra")
if not api_key:
return _relay("no credentials")
from hermes_cli.models import deepinfra_base_url
model = _deepinfra_model(section, "stt")
if not model:
return _relay("no deepinfra stt model")
return direct(STT_WIRE_OPENAI, deepinfra_base_url(section), api_key, model)
return _relay(f"provider {provider!r} has no client wire")
# ── TTS ──
def _resolve_tts_client_config() -> Dict[str, Any]:
from tools import tts_tool as tts
from tools import tts_tool_openai, tts_tool_providers
tts_config = tts._load_tts_config()
provider = tts._get_provider(tts_config)
if provider not in tts.BUILTIN_TTS_PROVIDERS:
return _relay("command/plugin provider")
# The desktop's client-direct sentence cutter honours the same tts.streaming.min_len as
# the gateway/CLI chunkers, so a short CJK opener is spoken alone on every surface.
from tools.tts_streaming import SentenceChunker
min_len = SentenceChunker.from_config(tts_config).min_len
if provider == "openai":
# Covers the direct-key, custom-base_url, and Nous-managed selections.
try:
api_key, base_url, is_managed = tts_tool_openai._resolve_openai_audio_client_config()
except ValueError as exc:
return _relay(f"openai resolution failed: {exc}")
oai = _section(tts_config, "openai")
model = oai.get("model") or tts_tool_openai.DEFAULT_OPENAI_MODEL
config_base = oai.get("base_url")
base_url = config_base or base_url
# The managed gateway only proxies MANAGED_OPENAI_TTS_MODELS — same
# coercion text_to_speech applies server-side.
if is_managed and not config_base and model not in tts_tool_openai.MANAGED_OPENAI_TTS_MODELS:
model = tts_tool_openai.DEFAULT_OPENAI_MODEL
speed_default = tts_config.get("speed", 1.0) if isinstance(tts_config, dict) else 1.0
try:
speed = float(oai.get("speed", speed_default))
except (TypeError, ValueError):
speed = 1.0
return _direct(TTS_WIRE_OPENAI, "openai", base_url, api_key, model,
voice=oai.get("voice") or tts_tool_openai.DEFAULT_OPENAI_VOICE, speed=speed,
extra_body=tts_tool_openai._openai_extra_body(oai),
min_len=min_len)
if provider == "elevenlabs":
api_key = tts._resolve_provider_key("ELEVENLABS_API_KEY", "elevenlabs")
if not api_key:
return _relay("no credentials")
el = _section(tts_config, "elevenlabs")
return _direct(TTS_WIRE_ELEVENLABS, "elevenlabs",
str(el.get("base_url") or "https://api.elevenlabs.io/v1").rstrip("/"),
api_key, el.get("model_id") or tts_tool_providers.DEFAULT_ELEVENLABS_MODEL_ID,
voice=el.get("voice_id") or tts_tool_providers.DEFAULT_ELEVENLABS_VOICE_ID, speed=None,
min_len=min_len)
if provider == "deepinfra":
api_key = tts._resolve_provider_key("DEEPINFRA_API_KEY", "deepinfra")
if not api_key:
return _relay("no credentials")
from hermes_cli.models import deepinfra_base_url
di = _section(tts_config, "deepinfra")
model = _deepinfra_model(di, "tts")
if not model:
return _relay("no deepinfra tts model")
return _direct(TTS_WIRE_OPENAI, "deepinfra", deepinfra_base_url(di), api_key, model,
voice=di.get("voice") or "af_bella", speed=None, min_len=min_len)
# edge / minimax / xai / mistral / gemini / neutts / kittentts / piper: server-host-only
# engines or wire shapes the desktop doesn't speak yet; the relay path serves them.
return _relay(f"provider {provider!r} has no client wire")
def resolve_client_voice_config() -> Dict[str, Any]:
"""Resolve both directions for the CURRENT profile scope.
Callers scope the profile via ``hermes_constants.set_hermes_home_override``
(the web server's ``_config_profile_scope``) before calling — identical to
how ``/api/audio/transcribe`` scopes ``transcribe_recording``.
"""
out: Dict[str, Any] = {}
if not _client_direct_enabled():
out = {"stt": _relay("voice.client_direct disabled"), "tts": _relay("voice.client_direct disabled")}
else:
for key, resolver in (("stt", _resolve_stt_client_config), ("tts", _resolve_tts_client_config)):
try:
out[key] = resolver()
except Exception:
logger.exception("client voice-config %s resolution failed", key.upper())
out[key] = _relay("resolution error")
# Live dictation always goes through the host's /api/audio/transcribe-stream socket (keys stay
# server-side), independent of client_direct; the verdict above remains the blob fallback.
from tools.transcription_streaming import streaming_available
try:
out["stt"]["streaming"] = streaming_available()
except Exception:
logger.debug("live STT capability probe failed", exc_info=True)
return out