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>
256 lines
12 KiB
Python
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
|