Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Whitestone
de6f0d174c fix: add CLI voice beep toggle
All checks were successful
Lint / lint (pull_request) Successful in 9s
Closes #952
2026-04-22 11:32:33 -04:00
3 changed files with 112 additions and 11 deletions

33
cli.py
View File

@@ -6852,11 +6852,12 @@ class HermesCLI:
self._voice_stop_and_transcribe()
# Audio cue: single beep BEFORE starting stream (avoid CoreAudio conflict)
try:
from tools.voice_mode import play_beep
play_beep(frequency=880, count=1)
except Exception:
pass
if self._voice_beeps_enabled():
try:
from tools.voice_mode import play_beep
play_beep(frequency=880, count=1)
except Exception:
pass
try:
self._voice_recorder.start(on_silence_stop=_on_silence)
@@ -6904,11 +6905,12 @@ class HermesCLI:
wav_path = self._voice_recorder.stop()
# Audio cue: double beep after stream stopped (no CoreAudio conflict)
try:
from tools.voice_mode import play_beep
play_beep(frequency=660, count=2)
except Exception:
pass
if self._voice_beeps_enabled():
try:
from tools.voice_mode import play_beep
play_beep(frequency=660, count=2)
except Exception:
pass
if wav_path is None:
_cprint(f"{_DIM}No speech detected.{_RST}")
@@ -7059,6 +7061,17 @@ class HermesCLI:
_cprint(f"Unknown voice subcommand: {subcommand}")
_cprint("Usage: /voice [on|off|tts|status]")
def _voice_beeps_enabled(self) -> bool:
"""Return whether CLI voice mode should play record start/stop beeps."""
try:
from hermes_cli.config import load_config
voice_cfg = load_config().get("voice", {})
if isinstance(voice_cfg, dict):
return bool(voice_cfg.get("beep_enabled", True))
except Exception:
pass
return True
def _enable_voice_mode(self):
"""Enable voice mode after checking requirements."""
if self._voice_mode:

View File

@@ -4,13 +4,31 @@ state management, streaming TTS activation, voice message prefix, _vprint."""
import ast
import os
import queue
import sys
import threading
import types
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
def _ensure_cli_import_shims():
sys.modules.setdefault(
"agent.auxiliary_client",
types.SimpleNamespace(
call_llm=lambda *args, **kwargs: "",
async_call_llm=lambda *args, **kwargs: "",
extract_content_or_reasoning=lambda *args, **kwargs: "",
resolve_provider_client=lambda *args, **kwargs: (None, None, None, None),
get_async_text_auxiliary_client=lambda *args, **kwargs: None,
),
)
_ensure_cli_import_shims()
def _make_voice_cli(**overrides):
"""Create a minimal HermesCLI with only voice-related attrs initialized.
@@ -18,6 +36,7 @@ def _make_voice_cli(**overrides):
needed. Only the voice state attributes (from __init__ lines 3749-3758)
are populated.
"""
_ensure_cli_import_shims()
from cli import HermesCLI
cli = HermesCLI.__new__(HermesCLI)
@@ -933,6 +952,58 @@ class TestEnableVoiceModeReal:
assert cli._voice_mode is True
class TestVoiceBeepConfigReal:
"""Tests the CLI voice beep toggle."""
@patch("hermes_cli.config.load_config", return_value={"voice": {}})
def test_beeps_enabled_by_default(self, _cfg):
cli = _make_voice_cli()
assert cli._voice_beeps_enabled() is True
@patch("hermes_cli.config.load_config", return_value={"voice": {"beep_enabled": False}})
def test_beeps_can_be_disabled(self, _cfg):
cli = _make_voice_cli()
assert cli._voice_beeps_enabled() is False
@patch("cli._cprint")
@patch("cli.threading.Thread")
@patch("tools.voice_mode.play_beep")
@patch("tools.voice_mode.create_audio_recorder")
@patch(
"tools.voice_mode.check_voice_requirements",
return_value={
"available": True,
"audio_available": True,
"stt_available": True,
"details": "OK",
"missing_packages": [],
},
)
@patch(
"hermes_cli.config.load_config",
return_value={
"voice": {
"beep_enabled": False,
"silence_threshold": 200,
"silence_duration": 3.0,
}
},
)
def test_start_recording_skips_beep_when_disabled(
self, _cfg, _req, mock_create, mock_beep, mock_thread, _cp
):
recorder = MagicMock()
recorder.supports_silence_autostop = True
mock_create.return_value = recorder
mock_thread.return_value = MagicMock(start=MagicMock())
cli = _make_voice_cli()
cli._voice_start_recording()
recorder.start.assert_called_once()
mock_beep.assert_not_called()
class TestDisableVoiceModeReal:
"""Tests _disable_voice_mode with real CLI instance."""
@@ -1087,6 +1158,16 @@ class TestVoiceStopAndTranscribeReal:
cli._voice_stop_and_transcribe()
assert cli._pending_input.empty()
@patch("cli._cprint")
@patch("hermes_cli.config.load_config", return_value={"voice": {"beep_enabled": False}})
@patch("tools.voice_mode.play_beep")
def test_no_speech_detected_skips_beep_when_disabled(self, mock_beep, _cfg, _cp):
recorder = MagicMock()
recorder.stop.return_value = None
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)
cli._voice_stop_and_transcribe()
mock_beep.assert_not_called()
@patch("cli._cprint")
@patch("cli.os.unlink")
@patch("cli.os.path.isfile", return_value=True)
@@ -1156,12 +1237,18 @@ class TestVoiceStopAndTranscribeReal:
@patch("cli._cprint")
@patch("tools.voice_mode.play_beep")
def test_continuous_restarts_on_no_speech(self, _beep, _cp):
import time
recorder = MagicMock()
recorder.stop.return_value = None
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder,
_voice_continuous=True)
cli._voice_start_recording = MagicMock()
cli._voice_stop_and_transcribe()
for _ in range(50):
if cli._voice_start_recording.call_count:
break
time.sleep(0.01)
cli._voice_start_recording.assert_called_once()
@patch("cli._cprint")

View File

@@ -149,7 +149,7 @@ Two-stage algorithm detects when you've finished speaking:
If no speech is detected at all for 15 seconds, recording stops automatically.
Both `silence_threshold` and `silence_duration` are configurable in `config.yaml`.
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`.
### Streaming TTS
@@ -383,6 +383,7 @@ voice:
record_key: "ctrl+b" # Key to start/stop recording
max_recording_seconds: 120 # Maximum recording length
auto_tts: false # Auto-enable TTS when voice mode starts
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