diff --git a/cli.py b/cli.py index d7042309a000..b15a821eb639 100644 --- a/cli.py +++ b/cli.py @@ -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"): diff --git a/hermes_cli/config.py b/hermes_cli/config.py index ae5dc3626349..608866751439 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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": { diff --git a/hermes_cli/voice.py b/hermes_cli/voice.py index a4ee6a0842d3..c0617a168e38 100644 --- a/hermes_cli/voice.py +++ b/hermes_cli/voice.py @@ -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 diff --git a/tests/tools/test_voice_stop_phrase.py b/tests/tools/test_voice_stop_phrase.py new file mode 100644 index 000000000000..b67b1e07b8ab --- /dev/null +++ b/tests/tools/test_voice_stop_phrase.py @@ -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 == [] diff --git a/tools/voice_mode.py b/tools/voice_mode.py index ca35a2b2e7e5..093534c3be52 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -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 # ============================================================================ diff --git a/website/docs/user-guide/features/voice-mode.md b/website/docs/user-guide/features/voice-mode.md index a4f64be3f3a4..a14e63dfd9b0 100644 --- a/website/docs/user-guide/features/voice-mode.md +++ b/website/docs/user-guide/features/voice-mode.md @@ -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: