Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2020,6 +2020,7 @@ def _ensure_hermes_home_managed(home: Path):
"max_recording_seconds": 120,
"auto_tts": False,
"beep_enabled": True, # Play record start/stop beeps in CLI voice mode
"beep_volume": 0.3, # Beep amplitude multiplier (0.0-1.0, default keeps prior hardcoded value)
"silence_threshold": 200, # RMS below this = silence (0-32767)
"silence_duration": 3.0, # Seconds of silence before auto-stop
},
Expand Down
96 changes: 96 additions & 0 deletions tests/tools/test_voice_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -1476,3 +1476,99 @@ def test_cancel_clears_callback_under_lock(self, mock_sd):
recorder.cancel()
with recorder._lock:
assert recorder._on_silence_stop is None


class TestGetBeepVolume:
"""Issue #55908: beep amplitude must come from config.yaml, with safe fallback."""

def _get(self):
from tools.voice_mode import _get_beep_volume
return _get_beep_volume()

def test_default_when_key_missing(self):
with patch("hermes_cli.config.load_config", return_value={"voice": {}}):
assert self._get() == 0.3

def test_default_when_voice_section_missing(self):
with patch("hermes_cli.config.load_config", return_value={}):
assert self._get() == 0.3

def test_custom_value_honored(self):
with patch("hermes_cli.config.load_config",
return_value={"voice": {"beep_volume": 0.6}}):
assert self._get() == 0.6

def test_zero_is_accepted(self):
with patch("hermes_cli.config.load_config",
return_value={"voice": {"beep_volume": 0.0}}):
assert self._get() == 0.0

def test_one_is_accepted(self):
with patch("hermes_cli.config.load_config",
return_value={"voice": {"beep_volume": 1.0}}):
assert self._get() == 1.0

def test_out_of_range_high_clamps_to_default(self):
with patch("hermes_cli.config.load_config",
return_value={"voice": {"beep_volume": 1.5}}):
assert self._get() == 0.3

def test_out_of_range_low_clamps_to_default(self):
with patch("hermes_cli.config.load_config",
return_value={"voice": {"beep_volume": -0.5}}):
assert self._get() == 0.3

def test_string_numeric_is_coerced(self):
with patch("hermes_cli.config.load_config",
return_value={"voice": {"beep_volume": "0.7"}}):
assert self._get() == 0.7

def test_non_numeric_falls_back(self):
with patch("hermes_cli.config.load_config",
return_value={"voice": {"beep_volume": "loud"}}):
assert self._get() == 0.3

def test_bool_value_falls_back(self):
"""Booleans must not silently pass as 0.0/1.0 (same guard as silence_threshold)."""
with patch("hermes_cli.config.load_config",
return_value={"voice": {"beep_volume": True}}):
assert self._get() == 0.3

def test_nan_falls_back(self):
with patch("hermes_cli.config.load_config",
return_value={"voice": {"beep_volume": float("nan")}}):
assert self._get() == 0.3

def test_load_config_exception_falls_back(self):
with patch("hermes_cli.config.load_config",
side_effect=RuntimeError("broken config")):
assert self._get() == 0.3

def test_voice_section_wrong_type_falls_back(self):
with patch("hermes_cli.config.load_config",
return_value={"voice": "not-a-dict"}):
assert self._get() == 0.3


class TestPlayBeepVolumeWiring:
"""Issue #55908: play_beep multiplies by the volume returned by _get_beep_volume.

Static wiring check — the behaviour is covered by TestGetBeepVolume above; this
class guards against regressions that re-introduce a hardcoded ``0.3`` literal
at the amplitude line in play_beep (the original bug class).
"""

def test_play_beep_does_not_use_hardcoded_0_3_literal(self):
import inspect

from tools import voice_mode as vm_mod

source = inspect.getsource(vm_mod.play_beep)
# The fix replaces ``tone * 0.3 * 32767`` with ``tone * beep_volume * 32767``
# where beep_volume is the result of _get_beep_volume().
hardcoded = " * 0.3 * 32767"
assert hardcoded not in source, (
"play_beep still contains a hardcoded 0.3 amplitude; use _get_beep_volume()"
)
assert "beep_volume * 32767" in source
assert "_get_beep_volume()" in source
39 changes: 38 additions & 1 deletion tools/voice_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"""

import logging
import math
import os
import platform
import re
Expand Down Expand Up @@ -288,6 +289,41 @@ def detect_audio_environment() -> dict:
# ============================================================================
# Audio cues (beep tones)
# ============================================================================
_DEFAULT_BEEP_VOLUME = 0.3 # Backward-compatible default (matches prior hardcoded value)


def _get_beep_volume() -> float:
"""Read ``voice.beep_volume`` from config.yaml; clamps to 0.0-1.0.

Defaults to 0.3 when the key is missing, invalid, or when the config
system can't be imported (e.g. broken ~/.hermes/config.yaml during a
partial install). Failures fall back silently so the audio cue never
breaks the voice loop on a degenerate config.
"""
try:
from hermes_cli.config import load_config
voice_cfg = load_config().get("voice", {})
if not isinstance(voice_cfg, dict):
return _DEFAULT_BEEP_VOLUME
raw = voice_cfg.get("beep_volume", _DEFAULT_BEEP_VOLUME)
except Exception:
return _DEFAULT_BEEP_VOLUME
try:
volume = float(raw)
except (TypeError, ValueError):
return _DEFAULT_BEEP_VOLUME
if isinstance(raw, bool) or volume < 0.0 or volume > 1.0 or _is_nan(volume):
return _DEFAULT_BEEP_VOLUME
return volume


def _is_nan(value: float) -> bool:
try:
return math.isnan(value)
except Exception:
return False


def play_beep(frequency: int = 880, duration: float = 0.12, count: int = 1) -> None:
"""Play a short beep tone using numpy + sounddevice.

Expand All @@ -305,6 +341,7 @@ def play_beep(frequency: int = 880, duration: float = 0.12, count: int = 1) -> N
samples_per_beep = int(SAMPLE_RATE * duration)
samples_per_gap = int(SAMPLE_RATE * gap)

beep_volume = _get_beep_volume()
parts = []
for i in range(count):
t = np.linspace(0, duration, samples_per_beep, endpoint=False)
Expand All @@ -313,7 +350,7 @@ def play_beep(frequency: int = 880, duration: float = 0.12, count: int = 1) -> N
fade_len = min(int(SAMPLE_RATE * 0.01), samples_per_beep // 4)
tone[:fade_len] *= np.linspace(0, 1, fade_len)
tone[-fade_len:] *= np.linspace(1, 0, fade_len)
parts.append((tone * 0.3 * 32767).astype(np.int16))
parts.append((tone * beep_volume * 32767).astype(np.int16))
if i < count - 1:
parts.append(np.zeros(samples_per_gap, dtype=np.int16))

Expand Down
1 change: 1 addition & 0 deletions website/docs/user-guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1576,6 +1576,7 @@ voice:
max_recording_seconds: 120 # Hard stop for long recordings
auto_tts: false # Enable spoken replies automatically when /voice on
beep_enabled: true # Play record start/stop beeps in CLI voice mode
beep_volume: 0.3 # Beep amplitude (0.0-1.0); raise it on quiet systems / headphones
silence_threshold: 200 # RMS threshold for speech detection
silence_duration: 3.0 # Seconds of silence before auto-stop
```
Expand Down
Loading