feat(voice): say 'stop' to end a voice chat hands-free

Saying EXACTLY a configured stop phrase (default: 'stop') and nothing
else now ends the voice conversation instead of being sent to the agent
as a prompt. Match is deliberately strict — whole utterance,
case-insensitive, surrounding punctuation stripped — so 'stop doing
that and try X' still reaches the agent.

- tools/voice_mode.py: is_voice_stop_phrase() + voice.stop_phrases
  config loader (default ['stop'], [] disables, malformed config falls
  back safely).
- hermes_cli/voice.py: shared continuous loop (TUI + desktop) halts on
  a stop phrase exactly like the silent-cycle limit (fires
  on_silent_limit so every UI turns voice off); stop_continuous
  force-transcribe path swallows the phrase without counting a silent
  cycle.
- cli.py: classic CLI push-to-talk/continuous path and barge-in
  utterance path disable voice mode on a stop phrase.
- Config default voice.stop_phrases: ['stop']; docs updated.

Tests: tests/tools/test_voice_stop_phrase.py (27 tests,
sabotage-verified: loop test fails when detection is disabled);
voice suites green (110 + 44 passed).
This commit is contained in:
Teknium 2026-07-27 20:49:23 -07:00
parent bc997a36a8
commit fab4c888ae
6 changed files with 231 additions and 4 deletions

12
cli.py
View file

@ -11852,6 +11852,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
if result.get("success") and result.get("transcript", "").strip():
transcript = result["transcript"].strip()
from tools.voice_mode import is_voice_stop_phrase
if is_voice_stop_phrase(transcript):
# Bare "stop" (or configured phrase) ends the voice chat
# instead of being sent to the agent.
_cprint(f"{_DIM}Stop phrase detected — ending voice chat.{_RST}")
self._disable_voice_mode()
return
self._attached_images.clear()
if hasattr(self, '_app') and self._app:
self._app.invalidate()
@ -12012,6 +12019,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
result = transcribe_recording(wav_path, model=self._voice_stt_model())
transcript = (result.get("transcript") or "").strip() if result.get("success") else ""
if transcript:
from tools.voice_mode import is_voice_stop_phrase
if is_voice_stop_phrase(transcript):
_cprint(f"\n{_DIM}Stop phrase detected — ending voice chat.{_RST}")
self._disable_voice_mode()
return
self._pending_input.put(transcript)
submitted = True
elif not result.get("success"):

View file

@ -2368,6 +2368,10 @@ DEFAULT_CONFIG = {
"silence_threshold": 200, # RMS below this = silence (0-32767)
"silence_duration": 3.0, # Seconds of silence before auto-stop
"barge_in": True, # Stop TTS playback when the user starts talking
# Saying EXACTLY one of these phrases (and nothing else) ends the
# voice chat instead of being sent to the agent. Case-insensitive,
# surrounding punctuation ignored. Set [] to disable.
"stop_phrases": ["stop"],
},
"human_delay": {

View file

@ -215,6 +215,7 @@ def format_voice_record_key_for_status(raw: Any) -> str:
from tools.voice_mode import (
create_audio_recorder,
is_voice_stop_phrase,
is_whisper_hallucination,
play_audio_file,
transcribe_recording,
@ -509,6 +510,12 @@ def stop_continuous(force_transcribe: bool = False) -> None:
except Exception as e:
logger.warning("failed to stop/transcribe recorder: %s", e)
finally:
stop_phrase = bool(transcript and is_voice_stop_phrase(transcript))
if stop_phrase:
# Bare stop phrase — the loop is already stopping;
# swallow it instead of sending "stop" to the agent.
_debug("stop_continuous: stop phrase — transcript discarded")
transcript = None
if transcript:
try:
on_transcript(transcript)
@ -517,7 +524,7 @@ def stop_continuous(force_transcribe: bool = False) -> None:
if track_no_speech:
with _continuous_lock:
if transcript:
if transcript or stop_phrase:
_continuous_no_speech_count = 0
else:
_continuous_no_speech_count += 1
@ -643,6 +650,14 @@ def _continuous_on_silence() -> None:
except Exception:
pass
stop_phrase = bool(transcript and is_voice_stop_phrase(transcript))
if stop_phrase:
# User said a bare stop phrase ("stop") — end the voice chat.
# Not delivered to the agent; the loop halts exactly like the
# silent-cycle limit so every UI (TUI, desktop) turns voice off.
_debug(f"_continuous_on_silence: stop phrase {transcript!r} — ending loop")
transcript = None
with _continuous_lock:
if not _continuous_active:
# User stopped us while we were transcribing — discard.
@ -650,9 +665,11 @@ def _continuous_on_silence() -> None:
return
if transcript:
_continuous_no_speech_count = 0
else:
elif not stop_phrase:
_continuous_no_speech_count += 1
should_halt = _continuous_no_speech_count >= _CONTINUOUS_NO_SPEECH_LIMIT
should_halt = stop_phrase or (
_continuous_no_speech_count >= _CONTINUOUS_NO_SPEECH_LIMIT
)
no_speech = _continuous_no_speech_count
if transcript and on_transcript:
@ -662,7 +679,10 @@ def _continuous_on_silence() -> None:
logger.warning("on_transcript callback raised: %s", e)
if should_halt:
_debug(f"_continuous_on_silence: {no_speech} silent cycles — halting")
_debug(
"_continuous_on_silence: halting "
f"({'stop phrase' if stop_phrase else f'{no_speech} silent cycles'})"
)
with _continuous_lock:
_continuous_active = False
_continuous_no_speech_count = 0

View file

@ -0,0 +1,135 @@
"""Tests for the voice-chat stop phrase (say "stop" and nothing else to end).
Contract:
- `is_voice_stop_phrase` matches ONLY when the whole utterance equals a
configured phrase (case-insensitive, surrounding punctuation stripped).
- Default phrase list is ("stop",); `voice.stop_phrases` in config.yaml
customizes it; `[]` disables the feature.
- In the shared continuous loop, a stop phrase halts the loop (like the
silent-cycle limit) and is NEVER delivered to the agent.
"""
from unittest.mock import patch
import pytest
from tools.voice_mode import (
DEFAULT_VOICE_STOP_PHRASES,
_load_voice_stop_phrases,
is_voice_stop_phrase,
)
class TestIsVoiceStopPhrase:
@pytest.mark.parametrize("utterance", [
"stop", "Stop", "STOP", "stop.", "Stop!", " stop ", '"Stop."', "stop?",
])
def test_bare_stop_matches(self, utterance):
assert is_voice_stop_phrase(utterance, ("stop",)) is True
@pytest.mark.parametrize("utterance", [
"stop doing that",
"please stop",
"stop the build and rerun tests",
"don't stop",
"stopwatch",
"",
" ",
"ok",
])
def test_longer_utterances_pass_through(self, utterance):
assert is_voice_stop_phrase(utterance, ("stop",)) is False
def test_custom_phrases(self):
phrases = ("stop", "goodbye hermes")
assert is_voice_stop_phrase("Goodbye Hermes!", phrases) is True
assert is_voice_stop_phrase("goodbye hermes, one more thing", phrases) is False
def test_empty_phrase_list_disables(self):
assert is_voice_stop_phrase("stop", ()) is False
def test_uses_config_when_phrases_omitted(self):
with patch("tools.voice_mode._load_voice_stop_phrases", return_value=("halt",)):
assert is_voice_stop_phrase("halt") is True
assert is_voice_stop_phrase("stop") is False
class TestLoadVoiceStopPhrases:
def _with_cfg(self, voice_cfg):
return patch(
"hermes_cli.config.load_config",
return_value={"voice": voice_cfg},
)
def test_default(self):
with self._with_cfg({}):
assert _load_voice_stop_phrases() == DEFAULT_VOICE_STOP_PHRASES
def test_custom_list(self):
with self._with_cfg({"stop_phrases": ["Stop", " Goodbye Hermes "]}):
assert _load_voice_stop_phrases() == ("stop", "goodbye hermes")
def test_empty_list_disables(self):
with self._with_cfg({"stop_phrases": []}):
assert _load_voice_stop_phrases() == ()
def test_bare_string_coerced(self):
with self._with_cfg({"stop_phrases": "halt"}):
assert _load_voice_stop_phrases() == ("halt",)
def test_malformed_falls_back(self):
with self._with_cfg({"stop_phrases": {"bad": "shape"}}):
assert _load_voice_stop_phrases() == DEFAULT_VOICE_STOP_PHRASES
def test_config_error_falls_back(self):
with patch("hermes_cli.config.load_config", side_effect=RuntimeError):
assert _load_voice_stop_phrases() == DEFAULT_VOICE_STOP_PHRASES
class TestContinuousLoopStopPhrase:
"""The shared continuous-loop silence callback ends the loop on a stop
phrase and never forwards it to on_transcript."""
def _run_silence_cycle(self, transcript_text):
import hermes_cli.voice as v
delivered = []
silent_limit_fired = []
class _FakeRecorder:
_peak_rms = 500
def stop(self):
return "/tmp/fake.wav"
def cancel(self):
pass
def start(self, on_silence_stop=None):
pass
fake_result = {"success": True, "transcript": transcript_text}
with patch.object(v, "_continuous_active", True), \
patch.object(v, "_continuous_recorder", _FakeRecorder()), \
patch.object(v, "_continuous_on_transcript", delivered.append), \
patch.object(v, "_continuous_on_status", None), \
patch.object(v, "_continuous_on_silent_limit",
lambda: silent_limit_fired.append(True)), \
patch.object(v, "_continuous_no_speech_count", 0), \
patch.object(v, "transcribe_recording", return_value=fake_result), \
patch.object(v, "_play_beep", lambda **kw: None), \
patch.object(v.os.path, "isfile", return_value=False):
v._continuous_on_silence()
still_active = v._continuous_active
return delivered, silent_limit_fired, still_active
def test_stop_phrase_halts_loop_and_is_not_delivered(self):
delivered, silent_limit, still_active = self._run_silence_cycle("Stop.")
assert delivered == []
assert silent_limit == [True]
assert still_active is False
def test_normal_transcript_is_delivered(self):
delivered, silent_limit, _ = self._run_silence_cycle("stop the build and rerun")
assert delivered == ["stop the build and rerun"]
assert silent_limit == []

View file

@ -872,6 +872,57 @@ def is_whisper_hallucination(transcript: str) -> bool:
return False
# ============================================================================
# Voice-chat stop phrases
# ============================================================================
DEFAULT_VOICE_STOP_PHRASES = ("stop",)
def _load_voice_stop_phrases() -> tuple:
"""Return the configured ``voice.stop_phrases`` list (default: ("stop",)).
Malformed config (scalar, dict, list of non-strings) falls back to the
default rather than crashing the voice loop.
"""
try:
from hermes_cli.config import load_config
voice_cfg = load_config().get("voice", {})
if isinstance(voice_cfg, dict):
raw = voice_cfg.get("stop_phrases", DEFAULT_VOICE_STOP_PHRASES)
if isinstance(raw, str):
raw = [raw]
if isinstance(raw, (list, tuple)):
phrases = tuple(
str(p).strip().lower() for p in raw
if isinstance(p, (str, int, float)) and str(p).strip()
)
return phrases # empty tuple = feature disabled
except Exception:
pass
return DEFAULT_VOICE_STOP_PHRASES
def is_voice_stop_phrase(transcript: str, stop_phrases: Optional[tuple] = None) -> bool:
"""Return True when *transcript* is EXACTLY a configured stop phrase.
Ends the voice conversation when the user says "stop" (or another
configured phrase) and nothing else. Deliberately strict: the whole
utterance after lowercasing and stripping surrounding punctuation
must equal a phrase, so "stop doing that and try again" still reaches
the agent. Configure via ``voice.stop_phrases`` in config.yaml
(set ``[]`` to disable).
"""
if not transcript:
return False
cleaned = transcript.strip().lower().strip(".,!?;: \t\n\"'")
if not cleaned:
return False
if stop_phrases is None:
stop_phrases = _load_voice_stop_phrases()
return cleaned in stop_phrases
# ============================================================================
# STT dispatch
# ============================================================================

View file

@ -157,6 +157,10 @@ If no speech is detected at all for 15 seconds, recording stops automatically.
Both `silence_threshold` and `silence_duration` are configurable in `config.yaml`. You can also disable the record start/stop beeps with `voice.beep_enabled: false`.
### Ending a voice chat by voice
Say **"stop"** — and nothing else — to end the voice conversation hands-free. The match is deliberately strict: the whole utterance (case-insensitive, surrounding punctuation ignored) must equal a configured phrase, so "stop doing that and try X instead" still reaches the agent normally. Customize the phrase list with `voice.stop_phrases` in `config.yaml` (e.g. `["stop", "goodbye hermes"]`), or set it to `[]` to disable. A voice chat also ends on its own after three consecutive silent cycles (no speech detected).
### Streaming TTS
When TTS is enabled, the agent speaks its reply **sentence-by-sentence** as it generates text — you don't wait for the full response. This works with **every TTS provider**:
@ -403,6 +407,7 @@ voice:
beep_enabled: true # Play record start/stop beeps
silence_threshold: 200 # RMS level (0-32767) below which counts as silence
silence_duration: 3.0 # Seconds of silence before auto-stop
stop_phrases: ["stop"] # Saying exactly one of these ends the voice chat; [] disables
# Speech-to-Text
stt: