mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
bc997a36a8
commit
fab4c888ae
6 changed files with 231 additions and 4 deletions
12
cli.py
12
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"):
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
135
tests/tools/test_voice_stop_phrase.py
Normal file
135
tests/tools/test_voice_stop_phrase.py
Normal 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 == []
|
||||
|
|
@ -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
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue