feat(voice): make silence timeout and continuous recognition configurable via localStorage

The voice-mode loop hardcoded SILENCE_MS=1800 and
_recognition.continuous=false, giving users no way to tune the
auto-send timing or keep the mic open across natural pauses.

Changes:
- SILENCE_MS now reads from localStorage key 'hermes-voice-silence-ms'
  with 1800ms fallback — users set it from the dev console or a future
  settings toggle
- _recognition.continuous reads from 'hermes-voice-continuous' (default
  false) — set to 'true' to keep the mic open across pauses within an
  utterance, with the silence timer remaining the sole send decision

New test file (tests/test_issue4761_voice_mode_config.py, 8 tests)
pins both localStorage reads and verifies the surrounding voice-mode
infrastructure is intact.

Refs #4761
This commit is contained in:
Sean authored and nesquena-hermes committed 2026-06-29 20:19:40 +00:00
1 parent ca507138f7
commit 5bfa9a8ec0
2 files changed
+90 -2

No files matched your search

+5 -2
View File
@@ -1026,7 +1026,10 @@ window._hermesTtsSynth=function(id, text, opts){
let _browserTtsKeepAlive=null;
let _browserTtsWatchdog=null;
let _browserTtsSuppressNextErrorRearm=false;
const SILENCE_MS=1800; // auto-send after 1.8s silence
// Configurable via localStorage keys (set from dev console or a future settings panel).
// hermes-voice-silence-ms — pause duration before auto-send (ms, default 1800)
// hermes-voice-continuous — keep mic open across natural pauses ("true"/"false", default false)
const SILENCE_MS=parseInt(localStorage.getItem('hermes-voice-silence-ms'))||1800;
function _clearBrowserTtsRecovery(){
if(_browserTtsKeepAlive){
@@ -1086,7 +1089,7 @@ window._hermesTtsSynth=function(id, text, opts){
_setState('listening');
_recognition=new SpeechRecognition();
_recognition.continuous=false;
_recognition.continuous=localStorage.getItem('hermes-voice-continuous')==='true';
_recognition.interimResults=true;
_recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
+85
View File
@@ -0,0 +1,85 @@
"""Tests for #4761 — configurable voice-mode silence timeout and continuous recognition.
The voice-mode loop currently hardcodes:
- SILENCE_MS = 1800 (1.8s pause before auto-send)
- _recognition.continuous = false (mic closes after each utterance)
This module pins the fix: both values are now configurable via localStorage keys
(hermes-voice-silence-ms, hermes-voice-continuous) with sensible defaults.
"""
import re
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
def _boot_src() -> str:
return (REPO / "static" / "boot.js").read_text(encoding="utf-8")
class TestVoiceModeSilenceMsConfig:
"""SILENCE_MS must read from localStorage with 1800 fallback."""
def test_silence_ms_reads_local_storage_with_fallback(self):
src = _boot_src()
m = re.search(
r"const\s+SILENCE_MS\s*=\s*parseInt\s*\(\s*localStorage\.getItem\s*\(\s*'hermes-voice-silence-ms'\s*\)\s*\)\s*\|\|\s*1800",
src,
)
assert m, (
"SILENCE_MS must be defined as `parseInt(localStorage.getItem('hermes-voice-silence-ms')) || 1800` "
"so users can tune it via dev console or a future settings toggle."
)
def test_silence_ms_used_in_timeout(self):
src = _boot_src()
assert "SILENCE_MS" in src, "SILENCE_MS must still be referenced in the timeout call."
class TestVoiceModeContinuousConfig:
"""_recognition.continuous must read from localStorage."""
def test_continuous_reads_local_storage(self):
src = _boot_src()
assert (
"_recognition.continuous=localStorage.getItem('hermes-voice-continuous')==='true'"
in src
), (
"_recognition.continuous must read from localStorage key "
"'hermes-voice-continuous' with default false. "
"Without this, users with natural mid-sentence pauses get cut off."
)
def test_continuous_true_behavior(self):
"""When hermes-voice-continuous is 'true', the recognition stays open
across pauses, so the silence timer is the sole arbiter of send timing."""
src = _boot_src()
# The continuous flag must not replace or disable the silence timer logic.
assert (
"_silenceTimer=setTimeout" in src
), "The silence timer must still exist for continuous mode send decision."
class TestBootJsVoiceSectionIntegrity:
"""Smoke checks — the surrounding voice-mode infrastructure is intact."""
def test_voice_mode_declares_silence_ms(self):
src = _boot_src()
assert "SILENCE_MS" in src, "SILENCE_MS constant must exist in boot.js"
def test_voice_mode_declares_recognition(self):
src = _boot_src()
assert "_recognition=new SpeechRecognition()" in src
def test_voice_mode_state_machine_present(self):
src = _boot_src()
for state in ("idle", "listening", "thinking", "speaking"):
assert f"'{state}'" in src, f"Voice mode state '{state}' must be referenced."
def test_voice_mode_patches_auto_read(self):
src = _boot_src()
assert "autoReadLastAssistant" in src, (
"voice mode must still override autoReadLastAssistant to pipe "
"response completion into _speakResponse."
)