Files
hermes-agent/tui_gateway/methods_voice.py
T
Teknium 99433742dc refactor(tui): extract the prompt turn into prompt_turn.py; compact methods_*/session_*/agent_callbacks; split _start_agent_build into scope/wiring helpers
- server.py 7663 -> 5319: _run_prompt_submit and its goal/loop/voice/scope
  phases move to tui_gateway/prompt_turn.py (bound via method_ctx.bind_module);
  _start_agent_build split into _bind/_release_build_profile_scopes,
  _deferred_build_agent_kwargs, _wire_session_agent, _start_session_services;
  _load_enabled_toolsets split (_enabled_mcp_server_names, _resolve_explicit_toolsets).
- methods_slash: _LIVE_SLASH_OUTPUT dispatch table; methods_tools: _SLASH_BUILTINS,
  _guarded; tool_progress: _PROGRESS_HANDLERS; methods_config_set:
  _REASONING_DISPLAY_WORDS; methods_voice: _VOICE_TOGGLE_ACTIONS.
- Unified: _denied_source (methods_session) replaces _WORKER_SOURCES in
  methods_profiles; _compress_live_with_feedback / _compute_host_slash shared by
  the slash mirror and /compress; _end_voice_chat shared by stop-phrase paths;
  _watcher_mtime_ns; _reaper_session_is_detached_idle; _notif_* helpers.
- Dead: _profile_dir_or_err, _resume_info, _slash_builtin_table (refs.py: 0 hits).
- acp_adapter/server.py: docstring compaction only (AST-identical).
- Every file semantically reviewed hunk-by-hunk for wire/log/lock/order parity.
2026-09-02 14:09:13 -07:00

998 lines
38 KiB
Python

"""Voice / TTS / wake-word JSON-RPC handlers and their process-global state (one microphone, one speaker per process).
Bodies are rebound onto server.py's globals at install time (see
method_ctx.bind_module), so they reference server.py globals bare.
"""
from __future__ import annotations
import threading
from .method_ctx import HandlerRegistry, bind_module
_registry = HandlerRegistry()
method = _registry.method
# ── Voice state ──────────────────────────────────────────────────────────
_voice_sid_lock = threading.Lock()
_voice_event_sid: str = ""
_voice_wake_owner: "Optional[Transport]" = None
def _caller_transport():
return current_transport() or _stdio_transport
def _voice_emit(event: str, payload: dict | None = None) -> None:
"""Emit toward the session that most recently turned voice on (one mic → one
target sid; the TUI treats an empty sid as "active session")."""
with _voice_sid_lock:
sid = _voice_event_sid
_emit(event, sid, payload)
def _resume_voice_wake() -> None:
global _voice_wake_owner
with _voice_sid_lock:
owner, _voice_wake_owner = _voice_wake_owner, None
if owner is not None:
_wake_resume_if_owner(owner)
def _voice_mode_enabled() -> bool:
"""Runtime-only flag (CLI parity): env var only, never config.yaml, so the TUI
can't auto-start in REC because voice was on in a prior session."""
return os.environ.get("HERMES_VOICE", "").strip() == "1"
def _voice_tts_enabled() -> bool:
"""Whether agent replies are spoken back via TTS (runtime only)."""
return os.environ.get("HERMES_VOICE_TTS", "").strip() == "1"
def _end_voice_chat(*, stop_loop: bool, stop_tts: bool) -> None:
"""Flip voice + TTS mode off; optionally halt the continuous loop / cut live TTS.
Every step is best-effort."""
os.environ["HERMES_VOICE"] = "0"
os.environ["HERMES_VOICE_TTS"] = "0"
if stop_loop:
try:
from hermes_cli.voice import stop_continuous
stop_continuous()
except Exception:
pass
if stop_tts:
try:
_tts_stream_stop(user_barge=False)
except Exception:
pass
def _tts_lease_async(lease: str, active: bool) -> None:
"""Acquire/release a TTS engine lease off the RPC thread: acquiring warms the
provider (local engines load a model, maybe download a voice) and must not
block the toggle's reply. Best-effort — failure never affects the toggle."""
def _run():
try:
from tools.tts_tool import acquire_tts_lease, release_tts_lease
if active:
acquire_tts_lease(lease)
else:
release_tts_lease(lease)
except Exception as e:
logger.debug("voice: tts lease %s active=%s failed: %s", lease, active, e)
threading.Thread(target=_run, name=f"tts-lease-{lease}", daemon=True).start()
def _any_session_running() -> bool:
"""Voice busy-probe (``hermes_cli.voice.set_voice_busy_probe``): silent capture
cycles during a long agent turn must not count toward the no-speech limit."""
try:
with _sessions_lock:
return any(s.get("running") for s in _sessions.values())
except Exception:
return False
# ── Streaming TTS (one active pipeline per process — one speaker) ──────────
# Token deltas feed a sentence-buffering consumer (tools.tts_tool.stream_tts_to_speaker)
# so speech starts on the first sentence; a new turn's pipeline barges in on the previous.
_tts_stream_lock = threading.Lock()
_tts_stream_state: Optional[dict] = None
def _tts_stream_begin() -> Optional[queue.Queue]:
"""Start a per-turn streaming TTS consumer; None when TTS can't stream."""
if not _voice_tts_enabled():
return None
try:
from tools.tts_tool import check_tts_requirements, stream_tts_to_speaker
if not check_tts_requirements():
return None
except Exception:
return None
_tts_stream_stop()
text_queue: queue.Queue = queue.Queue()
stop = threading.Event()
done = threading.Event()
threading.Thread(
target=stream_tts_to_speaker, args=(text_queue, stop, done), daemon=True
).start()
global _tts_stream_state
with _tts_stream_lock:
_tts_stream_state = {"stop": stop, "done": done}
if _voice_mode_enabled() and _voice_cfg_dict().get("barge_in", True):
_arm_full_duplex_listener()
return text_queue
def _tts_stream_stop(user_barge: bool = True) -> None:
"""Cut any in-flight streaming TTS. *user_barge* latches the interruption for
the next turn's model note — pass ``False`` for mode changes (/voice off)."""
global _tts_stream_state
with _tts_stream_lock:
state, _tts_stream_state = _tts_stream_state, None
if state is None:
return
if user_barge and not state["done"].is_set():
import traceback as _tb
logger.debug(
"TTS CUT: _tts_stream_stop(user_barge=True) — new turn or "
"interrupt cutting in-flight TTS\n%s",
"".join(_tb.format_stack()),
)
from tools.tts_streaming import mark_speech_interrupted
mark_speech_interrupted()
state["stop"].set()
try:
from tools.voice_mode import stop_playback
stop_playback()
except Exception:
pass
# ── Full-duplex agent-turn listener (one mic, whole turn) ──────────────────
# Arms at utterance-submit, spans generation AND playback (per-playback barge
# monitors were deaf during generation and mis-calibrated against speaker bleed),
# disarms when no session runs, no TTS is pending, and no audio flows.
_fd_listener_lock = threading.Lock()
_fd_listener_active = False
# (stop, done) pairs of fallback whole-reply speak paths: the listener must cut
# their private stop events too, and keep listening while any is still speaking.
_fd_speak_pipelines: "set[tuple[threading.Event, threading.Event]]" = set()
def _arm_full_duplex_listener() -> None:
"""Arm the process-global full-duplex listener (idempotent — one mic)."""
global _fd_listener_active
with _fd_listener_lock:
if _fd_listener_active:
return
_fd_listener_active = True
threading.Thread(target=_full_duplex_listener, daemon=True, name="voice-full-duplex").start()
def _fd_tts_pending() -> bool:
"""True while any TTS (streaming pipeline or fallback speak) is unfinished."""
with _tts_stream_lock:
state = _tts_stream_state
if state is not None and not state["done"].is_set():
return True
with _fd_listener_lock:
pipelines = list(_fd_speak_pipelines)
return any(not done.is_set() for _stop, done in pipelines)
def _full_duplex_listener() -> None:
"""Mic live from utterance-submit to turn-complete; phase-aware trip.
Generation phase: user speech interrupts every running session's turn (the
``agent.interrupt()`` seam ``session.interrupt`` uses) and cuts pending TTS so
the stale reply never plays. Playback phase: cuts TTS (streaming + fallback
speak paths + file player). Either way the utterance is transcribed and emitted
as ``voice.transcript``; a bare stop phrase also ends the voice chat.
"""
global _fd_listener_active
try:
from tools.tts_streaming import mark_speech_interrupted
from tools.voice_mode import (
full_duplex_listen,
is_audio_output_active,
stop_playback,
transcribe_recording,
)
cfg = _voice_cfg_dict()
try:
_mult = float(cfg.get("barge_in_threshold_multiplier", 0) or 0)
except (TypeError, ValueError):
_mult = 0.0
try:
_grace_ms = int(float(cfg.get("barge_in_grace_seconds", 0.5)) * 1000)
except (TypeError, ValueError):
_grace_ms = 500
def _should_stop() -> bool:
if not _voice_mode_enabled():
return True
if _any_session_running():
return False
if _fd_tts_pending():
return False
return not is_audio_output_active()
tripped = threading.Event()
def _cut_all_tts() -> None:
_tts_stream_stop(user_barge=True)
with _fd_listener_lock:
pipelines = list(_fd_speak_pipelines)
for _stop, _done in pipelines:
_stop.set()
stop_playback()
def _on_trigger(phase: str) -> None:
tripped.set()
mark_speech_interrupted()
if phase == "playback":
logger.debug("TTS CUT: full-duplex listener tripped during playback")
_cut_all_tts()
else:
logger.debug(
"full-duplex listener tripped during generation — "
"interrupting running turn(s)"
)
# Cut pending TTS FIRST so the stale reply can never speak.
_cut_all_tts()
try:
with _sessions_lock:
running = [s for s in _sessions.values() if s.get("running")]
for s in running:
agent = s.get("agent")
if agent is not None and hasattr(agent, "interrupt"):
try:
agent.interrupt()
except Exception:
pass
except Exception as e:
logger.debug("voice interjection interrupt failed: %s", e)
_voice_emit("voice.interrupted")
wav_path = full_duplex_listen(
_should_stop, is_playing=is_audio_output_active, on_trigger=_on_trigger,
multiplier=_mult or None, grace_ms=max(0, _grace_ms),
)
if not (wav_path and tripped.is_set()):
return
try:
result = transcribe_recording(wav_path)
text = (result.get("transcript") or "").strip() if result.get("success") else ""
if text:
# Stop-check must never break transcript delivery (stubbed
# voice_mode in tests, partial installs) — treat as not-a-stop.
try:
from tools.voice_mode import is_voice_stop_phrase
_is_stop = is_voice_stop_phrase(text)
except Exception:
_is_stop = False
if _is_stop:
# Turn already interrupted / TTS cut at trip time; now end the chat.
_end_voice_chat(stop_loop=True, stop_tts=False)
_voice_emit("voice.transcript", {"stop_phrase": True, "text": text})
else:
_voice_emit("voice.transcript", {"text": text})
finally:
try:
os.unlink(wav_path)
except OSError:
pass
except Exception as e:
logger.debug("full-duplex listener failed: %s", e)
finally:
with _fd_listener_lock:
_fd_listener_active = False
def _speak_text_with_barge(text: str) -> None:
"""Speak via hermes_cli.voice.speak_text with spoken barge-in: the (stop, done)
pair is registered in ``_fd_speak_pipelines`` so the full-duplex listener can cut
it on a playback trip and keeps listening while it is pending."""
from hermes_cli.voice import speak_text
stop = threading.Event()
done = threading.Event()
with _fd_listener_lock:
_fd_speak_pipelines.add((stop, done))
def _speak():
try:
speak_text(text, stop)
except TypeError:
# Older wrapper without the stop_event parameter.
speak_text(text)
finally:
done.set()
with _fd_listener_lock:
_fd_speak_pipelines.discard((stop, done))
threading.Thread(target=_speak, daemon=True).start()
if _voice_mode_enabled() and _voice_cfg_dict().get("barge_in", True):
_arm_full_duplex_listener()
def _voice_cfg_dict() -> dict:
"""Shape-safe ``voice:`` block. ``_load_cfg()`` doesn't deep-merge defaults, so
root and ``voice`` may be any YAML scalar/list/None; malformed → {}."""
cfg = _load_cfg()
voice_cfg = cfg.get("voice") if isinstance(cfg, dict) else None
return voice_cfg if isinstance(voice_cfg, dict) else {}
def _voice_cfg_number(value, default):
"""Numeric config value, else *default*. bool is excluded explicitly (int
subclass): ``silence_threshold: true`` must not forward as ``1``."""
return value if isinstance(value, (int, float)) and not isinstance(value, bool) else default
def _voice_record_key() -> str:
"""Current ``voice.record_key`` value, documented default on error."""
record_key = _voice_cfg_dict().get("record_key")
return str(record_key) if isinstance(record_key, str) and record_key else "ctrl+b"
# ── Wake word ("Hey Hermes") ──────────────────────────────────────────────
# Process-global detector (one mic). The first eligible transport to call
# wake.start owns it until stop, disconnect, or stream failure. On detection we
# emit wake.detected; the client opens a session and starts its own capture. The
# detector yields the mic to voice.record (pause/resume) and to the desktop's
# browser mic (wake.pause/resume RPCs).
_wake_lock = threading.Lock()
_wake_owner_transport: "Optional[Transport]" = None
_wake_owner_surface = ""
def _wake_owner_snapshot():
with _wake_lock:
return _wake_owner_transport, _wake_owner_surface
def _release_wake_for_transport(transport: "Transport") -> bool:
"""Release the wake lease iff ``transport`` is the current gateway owner."""
global _wake_owner_transport, _wake_owner_surface
with _wake_lock:
if _wake_owner_transport is not transport:
return False
_wake_owner_transport = None
_wake_owner_surface = ""
try:
from tools.wake_word import stop_listening
stop_listening(owner=transport)
except Exception as e:
logger.debug("wake stop failed: %s", e)
return True
def _release_gateway_wake_owner() -> bool:
owner, _surface = _wake_owner_snapshot()
return owner is not None and _release_wake_for_transport(owner)
_wake_resume_retry_lock = threading.Lock()
_wake_resume_retry_active = False
def _wake_resume_if_owner(owner: "Transport", *, retry_seconds: float = 15.0,
retry_interval: float = 1.0) -> bool:
"""Resume the wake detector for ``owner``; self-heal a busy microphone.
Reopening the mic right after a voice turn can fail while the device is still
being released (browser WebRTC tracks release async). On an exception we retry
in a background thread until it sticks, the lease changes hands, or
``retry_seconds`` elapses. ``False`` from ``resume_listening`` (lease gone /
different owner) is final — never retried, so this can't steal another
surface's mic.
"""
from tools.wake_word import resume_listening
try:
return resume_listening(owner=owner)
except Exception as e:
logger.debug("wake resume failed (will retry): %s", e)
global _wake_resume_retry_active
with _wake_resume_retry_lock:
if _wake_resume_retry_active:
return False
_wake_resume_retry_active = True
def _retry() -> None:
global _wake_resume_retry_active
deadline = time.monotonic() + retry_seconds
try:
while time.monotonic() < deadline:
time.sleep(retry_interval)
try:
if resume_listening(owner=owner):
logger.info("wake: detector resumed after retry")
return
except Exception:
continue
# False — detector gone or lease moved: stop, don't fight it.
return
logger.warning(
"wake: could not resume detector after voice turn "
"(microphone still busy?) — toggle the wake word to re-arm"
)
finally:
with _wake_resume_retry_lock:
_wake_resume_retry_active = False
threading.Thread(target=_retry, daemon=True, name="wake-resume-retry").start()
return False
def _persist_wake_enabled(enabled: bool) -> bool:
"""Write ``wake_word.enabled`` to config.yaml. Only for explicit user gestures
(ear toggle, /wake on|off) — never passive auto-arm paths."""
try:
from cli import save_config_value
return bool(save_config_value("wake_word.enabled", enabled))
except Exception as e:
logger.warning("wake: failed to persist wake_word.enabled=%s: %s", enabled, e)
return False
def _wake_prefers_client(params: dict, surface: str) -> bool:
"""Desktop remote (gui) prefers client capture (Mac mic → wake.feed PCM) while
the engine runs on the backend; CLI/TUI stay local."""
return surface in ("gui", "desktop") or bool(params.get("client_capture"))
def _wake_probe(cfg: dict, prefer_client: bool) -> tuple[str, dict]:
"""``(capture_mode, requirements)`` with capture stamped so the probe matches
the mode that would actually arm."""
from tools.wake_word import check_wake_word_requirements, resolve_capture_mode
capture_mode = resolve_capture_mode(cfg, prefer_client=prefer_client)
probe_cfg = dict(cfg)
probe_cfg["capture"] = capture_mode
return capture_mode, check_wake_word_requirements(probe_cfg)
def _wake_detect_handler(transport, sid: str, phrase: str, new_session: bool):
"""Build the on-detect callback: pause, verify ownership, emit ``wake.detected``
on the owner's transport."""
def _on_detect() -> None:
from tools.wake_word import get_last_match, owns_listener, pause_listening
if not pause_listening(owner=transport):
return
if not owns_listener(transport):
return
if _transport_is_dead(transport):
_release_wake_for_transport(transport)
return
# Multi-phrase engines report WHICH phrase fired and its profile, so one
# listener wakes any enrolled profile; single-phrase engines fall back.
matched_phrase, matched_profile = get_last_match() or (phrase, "")
logger.info("wake.detected: emitting to sid=%r (transport=%s, profile=%r)",
sid, type(transport).__name__, matched_profile)
token = bind_transport(transport)
try:
_emit("wake.detected", sid, {
"phrase": matched_phrase or phrase, "profile": matched_profile or None,
"start_new_session": new_session,
})
finally:
reset_transport(token)
return _on_detect
@method("gateway.capabilities")
def _(rid, params: dict) -> dict:
"""Advertise what THIS BUILD enforces. A client can't tell a gateway that fences
concurrent writers from one that doesn't (both accept the same calls), so it
withholds unless the guarantee is advertised. Sourced from the enforcing module,
never config: a believed-but-absent capability is worse than none."""
from hermes_cli.active_sessions import PER_SESSION_EXCLUSIVE_SUBMIT
return _ok(rid, {"per_session_exclusive_submit": bool(PER_SESSION_EXCLUSIVE_SUBMIT)})
@method("ping")
def _(rid, params: dict) -> dict:
"""Cheapest liveness probe, answered on the WS reader thread so it works while
every agent is mid-turn: lets the desktop tell a half-open TCP socket after
sleep/wake from a healthy one and force a reconnect."""
return _ok(rid, {"pong": True})
@method("wake.start")
def _(rid, params: dict) -> dict:
"""Arm the wake-word listener for the calling surface ("tui" | "gui").
Idempotent and gated: ``{started: False, reason}`` when disabled, scoped to
another surface, or deps/mic aren't ready. ``persist: true`` marks an explicit
user gesture: when disabled in config it flips ``wake_word.enabled`` on before
arming; passive auto-arm callers omit it and keep the config-gated refusal.
"""
surface = str(params.get("surface") or "auto").strip().lower()
persist = bool(params.get("persist"))
transport = _caller_transport()
try:
from tools.wake_word import (
WakeWordInUse,
detector_frame_info,
load_wake_word_config,
owns_listener,
start_listening,
wake_phrase,
wake_surface_enabled,
)
except Exception as e:
return _err(rid, 5026, f"wake module unavailable: {e}")
cfg = load_wake_word_config()
capture_mode, reqs = _wake_probe(cfg, _wake_prefers_client(params, surface))
external_audio = capture_mode == "client"
# Requirements first: a gesture on an un-armable setup must refuse WITHOUT
# flipping wake_word.enabled — else config says on while nothing can arm.
if not reqs["available"]:
logger.warning("wake.start(%s): not available — %s", surface, reqs.get("hint"))
return _ok(rid, {
"started": False, "reason": "unavailable", "hint": reqs.get("hint") or "",
"capture": capture_mode,
})
enabled_persisted = False
if persist and not cfg.get("enabled"):
enabled_persisted = _persist_wake_enabled(True)
if enabled_persisted:
cfg = dict(cfg)
cfg["enabled"] = True
if not wake_surface_enabled(surface, cfg):
# "disabled" (a persist:true retry can turn it on) vs "disabled_for_surface"
# (explicit wake_word.surface choice, which persist does NOT override).
reason = "disabled" if not cfg.get("enabled") else "disabled_for_surface"
logger.info("wake.start(%s): %s (enabled=%s, surface=%s)",
surface, reason, cfg.get("enabled"), cfg.get("surface"))
return _ok(rid, {"started": False, "reason": reason})
existing_owner, existing_surface = _wake_owner_snapshot()
if existing_owner is not None and (
_transport_is_dead(existing_owner) or not owns_listener(existing_owner)
):
_release_wake_for_transport(existing_owner)
existing_owner = None
existing_surface = ""
if existing_owner is not None and existing_owner is not transport:
return _ok(rid, {"started": False, "reason": "owned", "owner_surface": existing_surface})
sid = str(params.get("session_id") or "")
try:
start_listening(
_wake_detect_handler(
transport, sid, wake_phrase(cfg), bool(cfg.get("start_new_session", True))
),
owner=transport,
config=cfg,
external_audio=external_audio,
)
except WakeWordInUse:
return _ok(rid, {
"started": False, "reason": "owned", "owner_surface": existing_surface or None,
})
except Exception as e:
logger.warning("wake.start(%s): failed to start listener: %s", surface, e)
return _err(rid, 5026, str(e))
global _wake_owner_transport, _wake_owner_surface
with _wake_lock:
_wake_owner_transport = transport
_wake_owner_surface = surface
frame = detector_frame_info()
logger.info(
"wake.start(%s): listening for %r (%s) capture=%s frame=%s",
surface, reqs["phrase"], reqs["provider"], capture_mode, frame.get("frame_length"),
)
return _ok(rid, {
"started": True, "phrase": reqs["phrase"], "provider": reqs["provider"],
"owner_surface": surface, "enabled_persisted": enabled_persisted, "capture": capture_mode,
"sample_rate": frame.get("sample_rate", 16000),
"frame_length": frame.get("frame_length", 1280),
})
@method("wake.stop")
def _(rid, params: dict) -> dict:
"""Stop this surface's listener. ``persist: true`` also writes
``wake_word.enabled: false`` so auto-arm stays off in future sessions."""
transport = _caller_transport()
stopped = _release_wake_for_transport(transport)
disabled_persisted = False
if bool(params.get("persist")):
try:
from tools.wake_word import load_wake_word_config
currently_enabled = bool(load_wake_word_config().get("enabled"))
except Exception:
currently_enabled = True
if currently_enabled:
disabled_persisted = _persist_wake_enabled(False)
return _ok(rid, {
"stopped": stopped, "reason": None if stopped else "not_owner",
"disabled_persisted": disabled_persisted,
})
@method("wake.pause")
def _(rid, params: dict) -> dict:
"""Release the mic (e.g. while the desktop's browser captures audio)."""
transport = _caller_transport()
try:
from tools.wake_word import pause_listening
paused = pause_listening(owner=transport)
logger.info("wake.pause: detector paused=%s", paused)
except Exception as e:
logger.debug("wake.pause failed: %s", e)
paused = False
return _ok(rid, {"paused": paused, "reason": None if paused else "not_owner"})
@method("wake.resume")
def _(rid, params: dict) -> dict:
"""Reclaim the mic after a pause; no-op if the listener isn't armed."""
resumed = _wake_resume_if_owner(_caller_transport())
logger.info("wake.resume: detector resumed=%s", resumed)
return _ok(rid, {"resumed": resumed, "reason": None if resumed else "not_owner"})
@method("wake.status")
def _(rid, params: dict) -> dict:
try:
from tools.wake_word import (
audio_is_silent,
detector_frame_info,
get_input_device_status,
is_listening,
load_wake_word_config,
owns_listener,
silent_audio_hint,
)
cfg = load_wake_word_config()
probe_capture, reqs = _wake_probe(
cfg, _wake_prefers_client(params, str(params.get("surface") or "").strip().lower())
)
transport = _caller_transport()
owner, owner_surface = _wake_owner_snapshot()
owned_by_caller = owns_listener(transport)
listening = owned_by_caller and is_listening()
silent = listening and audio_is_silent()
input_device = get_input_device_status(cfg)
hint = reqs.get("hint", "")
if input_device.get("error") and not hint:
hint = f"Wake-word input device could not be resolved: {input_device['error']}"
if silent and not hint:
hint = silent_audio_hint(input_device)
# Effective capture: prefer the *armed* detector over config/auto, else
# with capture:auto a bare status probe reports "local" and the desktop
# never reattaches the PCM feeder after wake.detected.
frame = detector_frame_info()
if owned_by_caller and frame.get("external_audio"):
capture = "client"
elif owned_by_caller and listening:
capture = "local"
else:
capture = probe_capture or reqs.get("capture") or str(cfg.get("capture") or "auto")
return _ok(rid, {
"listening": listening,
"owned_by_caller": owned_by_caller,
"owner_surface": owner_surface if owner is not None else None,
"phrase": reqs["phrase"],
"provider": reqs["provider"],
"configured_surface": str(cfg.get("surface") or "auto"),
"input_device": input_device,
"available": reqs["available"],
"hint": hint,
# Config truth: clients re-arm after a voice turn ("permanent on") from this.
"enabled": bool(cfg.get("enabled")),
# Armed but deaf despite an open stream; see platform-specific hint.
"audio_silent": silent,
"capture": capture,
"local_input_available": bool(reqs.get("local_input_available")),
"sample_rate": frame.get("sample_rate", 16000),
"frame_length": frame.get("frame_length", 1280),
})
except Exception as e:
return _err(rid, 5026, str(e))
@method("wake.feed")
def _(rid, params: dict) -> dict:
"""Push client-captured PCM (``pcm``/``pcm_b64``: base64 int16 mono LE, 16 kHz
only) into the armed detector — used when ``wake.start`` returned
``capture: "client"`` so mic-less remote backends can run openWakeWord."""
transport = _caller_transport()
raw_b64 = params.get("pcm") or params.get("pcm_b64") or ""
if not isinstance(raw_b64, str) or not raw_b64.strip():
return _err(rid, 4001, "wake.feed requires base64 pcm")
try:
import base64
pcm = base64.b64decode(raw_b64, validate=False)
except Exception as e:
return _err(rid, 4001, f"invalid base64 pcm: {e}")
if not pcm:
return _ok(rid, {"fed": False, "reason": "empty"})
# Soft size cap: 64000 bytes = 2s of 16 kHz int16 mono
if len(pcm) > 64000:
return _err(rid, 4001, "pcm frame too large")
sr = params.get("sample_rate")
if sr is not None and int(sr) not in (0, 16000):
return _err(rid, 4001, "wake.feed only accepts 16 kHz PCM")
try:
from tools.wake_word import feed_audio
ok = feed_audio(owner=transport, pcm_int16=pcm)
except Exception as e:
logger.debug("wake.feed failed: %s", e)
return _err(rid, 5026, str(e))
return _ok(rid, {"fed": bool(ok), "reason": None if ok else "not_owner"})
def _voice_toggle_status(rid, params: dict) -> dict:
# Mirrors CLI _show_voice_status: STT/TTS availability tells the user WHY
# voice isn't working; record_key lets the TUI bind and display the shortcut.
payload: dict = {
"enabled": _voice_mode_enabled(),
"record_key": _voice_record_key(),
"tts": _voice_tts_enabled(),
}
try:
from tools.voice_mode import check_voice_requirements
reqs = check_voice_requirements()
payload["available"] = bool(reqs.get("available"))
payload["audio_available"] = bool(reqs.get("audio_available"))
payload["stt_available"] = bool(reqs.get("stt_available"))
payload["details"] = reqs.get("details") or ""
except Exception as e:
# Optional transcription deps — /voice status must always answer.
logger.warning("voice.toggle status: requirements probe failed: %s", e)
return _ok(rid, payload)
def _voice_toggle_mode(rid, params: dict) -> dict:
enabled = params.get("action") == "on"
# Runtime-only flag (CLI parity) — never persisted, so the next TUI launch
# starts with voice OFF instead of auto-REC from a stale toggle.
os.environ["HERMES_VOICE"] = "1" if enabled else "0"
stop_hint = ""
if enabled:
# Spoken-stop hint for the client; sourced from voice.stop_phrases,
# empty when the feature is disabled.
try:
from tools.voice_mode import voice_stop_hint
stop_hint = voice_stop_hint()
except Exception:
stop_hint = ""
# Speech output already on → warm the engine now, not on the first reply.
if _voice_tts_enabled():
_tts_lease_async("tui:voice-tts", True)
if not enabled:
# The continuous loop holds the microphone; tear it down with the mode.
try:
from hermes_cli.voice import stop_continuous
stop_continuous()
except ImportError:
pass
except Exception as e:
logger.warning("voice: stop_continuous failed during toggle off: %s", e)
# Clear TTS so it can be toggled independently later; silence live speech.
os.environ["HERMES_VOICE_TTS"] = "0"
_tts_stream_stop(user_barge=False)
_tts_lease_async("tui:voice-tts", False)
return _ok(
rid,
{
"enabled": enabled,
"record_key": _voice_record_key(),
"tts": _voice_tts_enabled(),
"stop_hint": stop_hint,
},
)
def _voice_toggle_tts(rid, params: dict) -> dict:
if not _voice_mode_enabled():
return _err(rid, 4014, "enable voice mode first: /voice on")
new_value = not _voice_tts_enabled()
os.environ["HERMES_VOICE_TTS"] = "1" if new_value else "0"
if not new_value:
_tts_stream_stop(user_barge=False)
# on → pre-load the engine so the first reply starts hot; off → release the
# lease (last holder gone = resident local model freed).
_tts_lease_async("tui:voice-tts", new_value)
# record_key on every branch so a tts toggle never resets a custom binding.
return _ok(rid, {"enabled": True, "record_key": _voice_record_key(), "tts": new_value})
_VOICE_TOGGLE_ACTIONS = {
"status": _voice_toggle_status, "on": _voice_toggle_mode, "off": _voice_toggle_mode,
"tts": _voice_toggle_tts,
}
@method("voice.toggle")
def _(rid, params: dict) -> dict:
"""CLI parity for ``/voice``: ``status``; ``on``/``off`` flip voice *mode* (off
also tears down the continuous loop; recording itself is driven by
``voice.record``/Ctrl+B); ``tts`` toggles speech output (requires mode on)."""
action = params.get("action", "status")
handler = _VOICE_TOGGLE_ACTIONS.get(action) if isinstance(action, str) else None
if handler is None:
return _err(rid, 4013, f"unknown voice action: {action}")
return handler(rid, params)
# voice.record callbacks (module-level: they touch only process-global state).
def _vr_on_transcript(t):
_voice_emit("voice.transcript", {"text": t})
_resume_voice_wake()
def _vr_on_silent():
_voice_emit("voice.transcript", {"no_speech_limit": True})
_resume_voice_wake()
def _vr_on_stop_phrase(t):
# The user SAID a bare stop phrase: end the chat like /voice off and emit a
# distinct signal so clients end the conversation instead of treating it as
# a no-speech timeout. The continuous loop has already halted.
_end_voice_chat(stop_loop=False, stop_tts=True)
_voice_emit("voice.transcript", {"stop_phrase": True, "text": t})
_resume_voice_wake()
def _vr_on_status(state):
_voice_emit("voice.status", {"state": state})
if state == "idle":
_resume_voice_wake()
@method("voice.record")
def _(rid, params: dict) -> dict:
"""VAD-bounded push-to-talk capture, CLI-parity. ``start`` begins one capture
and emits ``voice.transcript`` when silence stops it; ``stop`` forces
transcription of the active buffer. The wrapper retains no-speech counts across
starts, so three silent captures emit ``no_speech_limit=True``."""
action = params.get("action", "start")
wake_paused = False
if action not in {"start", "stop"}:
return _err(rid, 4019, f"unknown voice action: {action}")
transport = _caller_transport()
wake_owner, _surface = _wake_owner_snapshot()
if wake_owner is not None and wake_owner is not transport:
return _ok(rid, {"status": "busy", "reason": "wake_owned"})
try:
global _voice_event_sid, _voice_wake_owner
if action == "start":
if not _voice_mode_enabled():
return _err(rid, 4015, "voice mode is off — enable with /voice on")
with _voice_sid_lock:
_voice_event_sid = params.get("session_id") or _voice_event_sid
from hermes_cli.voice import start_continuous
# Busy probe holds the no-speech counter during long agent turns.
# Safe to re-register every start; older wrappers lack the setter.
try:
from hermes_cli.voice import set_voice_busy_probe
set_voice_busy_probe(_any_session_running)
except Exception:
pass
# Shape-safe: malformed voice YAML falls back to documented defaults.
voice_cfg = _voice_cfg_dict()
safe_threshold = _voice_cfg_number(voice_cfg.get("silence_threshold"), 200)
safe_duration = _voice_cfg_number(voice_cfg.get("silence_duration"), 3.0)
# max_recording_seconds: explicit numeric <= 0 disables the cap (0.0).
max_rec = _voice_cfg_number(voice_cfg.get("max_recording_seconds"), 120.0)
safe_max_rec = max_rec if max_rec > 0 else 0.0
# Hand the mic to STT if the wake detector holds it; resume on a
# terminal capture event so wake-triggered and manual captures coexist.
try:
from tools.wake_word import pause_listening
wake_paused = pause_listening(owner=transport)
except Exception:
wake_paused = False
if wake_paused:
with _voice_sid_lock:
_voice_wake_owner = transport
started = start_continuous(
on_transcript=_vr_on_transcript, on_status=_vr_on_status,
on_silent_limit=_vr_on_silent, silence_threshold=safe_threshold,
silence_duration=safe_duration, auto_restart=False,
max_recording_seconds=safe_max_rec, on_stop_phrase=_vr_on_stop_phrase,
)
if started is False:
_resume_voice_wake()
return _ok(rid, {"status": "busy"})
return _ok(rid, {"status": "recording"})
# action == "stop"
with _voice_sid_lock:
_voice_event_sid = params.get("session_id") or _voice_event_sid
from hermes_cli.voice import stop_continuous
stop_continuous(force_transcribe=True)
_resume_voice_wake()
return _ok(rid, {"status": "stopped"})
except Exception as e:
if wake_paused or action == "stop":
_resume_voice_wake()
if isinstance(e, ImportError):
return _err(rid, 5025, "voice module not available — install audio dependencies")
return _err(rid, 5025, str(e))
@method("voice.tts")
def _(rid, params: dict) -> dict:
text = params.get("text", "")
if not text:
return _err(rid, 4020, "text required")
try:
# Import check up front so a missing voice module returns 5026 instead
# of failing silently in the thread.
import hermes_cli.voice # noqa: F401
threading.Thread(
target=_speak_text_with_barge, args=(text,), daemon=True
).start()
return _ok(rid, {"status": "speaking"})
except ImportError:
return _err(rid, 5026, "voice module not available")
except Exception as e:
return _err(rid, 5026, str(e))
def register(server) -> None:
"""Publish this module's helpers + handlers onto ``server``, rebound to its globals."""
bind_module(globals(), server, skip=("_",))