fix(voice): silent cycles never end the chat while the agent is busy or TTS is playing

The continuous-voice no-speech counter (3 strikes -> voice off) counted
every silent capture cycle unconditionally. During a long agent turn
(thinking/tool-calling for minutes) or while TTS is speaking, the user
is CORRECTLY silent — those cycles ended the voice chat under them.

- hermes_cli/voice.py: new set_voice_busy_probe() seam + _voice_activity_held()
  (TTS-playing via the existing _tts_playing Event, agent-busy via the
  registered probe). Both the continuous-loop strike path and the
  force-transcribe single-shot strike path skip counting while held.
  Fail-open: a broken probe counts cycles as before.
- tui_gateway/server.py: registers _any_session_running() as the probe
  on voice.record start (voice is process-global; any running session holds).
- cli.py: classic CLI strike path skips counting while _agent_running
  or TTS playback is in flight.

Stop phrase and barge-in still work during the hold (own paths).
Includes a fixture fix for the #71083 cherry-pick: the fake tools.tts_tool
module needs _load_tts_config (main's tts_streaming imports it).
This commit is contained in:
Teknium 2026-07-29 00:34:32 -07:00
parent be424703c4
commit 9e5f1b619c
5 changed files with 265 additions and 6 deletions

24
cli.py
View file

@ -12000,14 +12000,26 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
pass
# Track consecutive no-speech cycles to avoid infinite restart loops.
# While the agent is mid-turn or TTS is speaking, the user is
# CORRECTLY silent (waiting/listening) — those cycles must not
# count, or a multi-minute tool run ends the voice chat under
# the user. The stop phrase and barge-in still work during the
# hold (they run on their own paths above).
stop_continuous_restart = False
_activity_hold = bool(
getattr(self, "_agent_running", False)
or not self._voice_tts_done.is_set()
)
if not submitted:
self._no_speech_count = getattr(self, '_no_speech_count', 0) + 1
if self._no_speech_count >= 3:
self._voice_continuous = False
self._no_speech_count = 0
_cprint(f"{_DIM}No speech detected 3 times, continuous mode stopped.{_RST}")
stop_continuous_restart = True
if _activity_hold:
pass # held: keep listening without counting the cycle
else:
self._no_speech_count = getattr(self, '_no_speech_count', 0) + 1
if self._no_speech_count >= 3:
self._voice_continuous = False
self._no_speech_count = 0
_cprint(f"{_DIM}No speech detected 3 times, continuous mode stopped.{_RST}")
stop_continuous_restart = True
else:
self._no_speech_count = 0

View file

@ -301,6 +301,47 @@ _continuous_recorder: Any = None
# leak into the mic.
_tts_playing = threading.Event()
_tts_playing.set() # initially "not playing"
# ── Silence-count hold (agent busy) ──────────────────────────────────
# While the agent is mid-turn (thinking / tool-calling, possibly for
# minutes) or TTS is playing, the user is CORRECTLY silent — those cycles
# must not count toward the no-speech limit or a long tool run ends the
# voice chat under the user (#silence-must-not-end-the-chat). The host
# surface (tui_gateway) registers a probe that reports "agent busy";
# TTS-playing is already tracked via _tts_playing above.
_voice_busy_probe: Optional[Callable[[], bool]] = None
def set_voice_busy_probe(probe: Optional[Callable[[], bool]]) -> None:
"""Register a callable that returns True while the agent is mid-turn.
Called by the hosting surface (tui_gateway registers one that checks
every session's ``running`` flag). ``None`` clears it. The probe must
be cheap and thread-safe it runs on the silence-callback thread.
"""
global _voice_busy_probe
_voice_busy_probe = probe
def _voice_activity_held() -> bool:
"""True while silent cycles must NOT count toward the no-speech limit.
Held when TTS is playing (the user is listening) or when the
registered busy probe reports the agent mid-turn (the user is
waiting). Fail-open to "not held" so a broken probe can never make
the voice chat immortal.
"""
if not _tts_playing.is_set():
return True
probe = _voice_busy_probe
if probe is None:
return False
try:
return bool(probe())
except Exception:
return False
_continuous_on_transcript: Optional[Callable[[str], None]] = None
_continuous_on_status: Optional[Callable[[str], None]] = None
_continuous_on_silent_limit: Optional[Callable[[], None]] = None
@ -574,9 +615,17 @@ def stop_continuous(force_transcribe: bool = False) -> None:
logger.warning("on_transcript callback raised: %s", e)
if track_no_speech:
held = _voice_activity_held()
with _continuous_lock:
if transcript or stop_phrase:
_continuous_no_speech_count = 0
elif held:
# Agent busy / TTS playing — the user is
# correctly silent; don't count the cycle.
_debug(
"stop_continuous: silent cycle ignored "
"(agent busy or TTS playing)"
)
else:
_continuous_no_speech_count += 1
should_halt = (
@ -712,6 +761,14 @@ def _continuous_on_silence() -> None:
_debug(f"_continuous_on_silence: stop phrase {transcript!r} — ending loop")
transcript = None
# Silent cycle while the agent is mid-turn or TTS is playing: the user
# is CORRECTLY quiet (waiting/listening), so the cycle must not count
# toward the no-speech limit — a multi-minute tool run would otherwise
# end the voice chat under the user. Checked outside the lock (probe
# may call into the host surface).
_silence_held = (transcript is None and not stop_phrase
and _voice_activity_held())
with _continuous_lock:
if not _continuous_active:
# User stopped us while we were transcribing — discard.
@ -719,6 +776,11 @@ def _continuous_on_silence() -> None:
return
if transcript:
_continuous_no_speech_count = 0
elif _silence_held:
_debug(
"_continuous_on_silence: silent cycle ignored "
"(agent busy or TTS playing)"
)
elif not stop_phrase:
_continuous_no_speech_count += 1
should_halt = stop_phrase or (

View file

@ -424,6 +424,7 @@ class TestContinuousLoopSimulation:
monkeypatch.setattr(voice, "_continuous_on_status", None)
monkeypatch.setattr(voice, "_continuous_on_silent_limit", None)
monkeypatch.setattr(voice, "_continuous_auto_restart", True, raising=False)
monkeypatch.setattr(voice, "_voice_busy_probe", None, raising=False)
monkeypatch.setattr(voice, "_play_beep", lambda *_, **__: None)
class FakeRecorder:
@ -724,6 +725,162 @@ class TestContinuousLoopSimulation:
assert voice.is_continuous_active() is False
assert fake_recorder.cancelled >= 1
def test_silent_cycles_do_not_count_while_agent_busy(self, fake_recorder, monkeypatch):
"""Agent mid-turn: silent cycles must NOT count toward the no-speech
limit a multi-minute tool run would otherwise end the voice chat
while the user is correctly waiting quietly."""
import hermes_cli.voice as voice
monkeypatch.setattr(
voice,
"transcribe_recording",
lambda _p: {"success": True, "transcript": ""},
)
monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False)
monkeypatch.setattr(voice, "_voice_busy_probe", lambda: True)
silent_limit_fired = []
voice.start_continuous(
on_transcript=lambda _t: None,
on_silent_limit=lambda: silent_limit_fired.append(True),
)
# Way past the 3-strike limit while the agent is busy.
for _ in range(6):
fake_recorder.last_callback()
assert silent_limit_fired == []
assert voice.is_continuous_active() is True
assert voice._continuous_no_speech_count == 0
# Agent finishes → strikes count again, limit fires as before.
monkeypatch.setattr(voice, "_voice_busy_probe", lambda: False)
for _ in range(3):
fake_recorder.last_callback()
assert silent_limit_fired == [True]
assert voice.is_continuous_active() is False
def test_silent_cycles_do_not_count_while_tts_playing(self, fake_recorder, monkeypatch):
"""TTS speaking: the user is listening, not ignoring the mic."""
import hermes_cli.voice as voice
monkeypatch.setattr(
voice,
"transcribe_recording",
lambda _p: {"success": True, "transcript": ""},
)
monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False)
# Keep the TTS-wait re-arm path from blocking: _tts_playing cleared
# means "playing"; use a tiny wait timeout via a fake event-like shim.
monkeypatch.setattr(voice, "_voice_busy_probe", None)
class _FakePlaying:
def is_set(self):
return False
def wait(self, timeout=None):
return True
monkeypatch.setattr(voice, "_tts_playing", _FakePlaying())
silent_limit_fired = []
voice.start_continuous(
on_transcript=lambda _t: None,
on_silent_limit=lambda: silent_limit_fired.append(True),
)
for _ in range(4):
fake_recorder.last_callback()
assert silent_limit_fired == []
assert voice._continuous_no_speech_count == 0
voice.stop_continuous()
def test_stop_phrase_still_ends_chat_during_busy_hold(self, fake_recorder, monkeypatch):
"""The hold suppresses the silence counter only — a spoken stop
phrase must still end the voice chat instantly."""
import hermes_cli.voice as voice
monkeypatch.setattr(
voice,
"transcribe_recording",
lambda _p: {"success": True, "transcript": "stop"},
)
monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False)
monkeypatch.setattr(voice, "is_voice_stop_phrase", lambda _t: True)
monkeypatch.setattr(voice, "_voice_busy_probe", lambda: True)
stop_fired = []
voice.start_continuous(
on_transcript=lambda _t: None,
on_stop_phrase=lambda t: stop_fired.append(t),
)
fake_recorder.last_callback()
assert stop_fired == ["stop"]
assert voice.is_continuous_active() is False
def test_broken_busy_probe_fails_open(self, fake_recorder, monkeypatch):
"""A raising probe must not make the voice chat immortal — silent
cycles count as if no probe were registered."""
import hermes_cli.voice as voice
monkeypatch.setattr(
voice,
"transcribe_recording",
lambda _p: {"success": True, "transcript": ""},
)
monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False)
def _boom():
raise RuntimeError("probe broken")
monkeypatch.setattr(voice, "_voice_busy_probe", _boom)
silent_limit_fired = []
voice.start_continuous(
on_transcript=lambda _t: None,
on_silent_limit=lambda: silent_limit_fired.append(True),
)
for _ in range(3):
fake_recorder.last_callback()
assert silent_limit_fired == [True]
assert voice.is_continuous_active() is False
def test_force_transcribe_silent_cycle_held_while_busy(self, fake_recorder, monkeypatch):
"""The single-shot (auto_restart=False, force_transcribe) strike path
honors the busy hold too desktop/TUI clients drive that loop."""
import hermes_cli.voice as voice
class ImmediateThread:
def __init__(self, target, daemon=False):
self.target = target
def start(self):
self.target()
monkeypatch.setattr(voice.threading, "Thread", ImmediateThread)
monkeypatch.setattr(
voice,
"transcribe_recording",
lambda _p: {"success": True, "transcript": ""},
)
monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False)
monkeypatch.setattr(voice, "_voice_busy_probe", lambda: True)
silent_limit_fired = []
for _ in range(4):
voice.start_continuous(
on_transcript=lambda _t: None,
on_silent_limit=lambda: silent_limit_fired.append(True),
auto_restart=False,
)
voice.stop_continuous(force_transcribe=True)
assert silent_limit_fired == []
assert voice._continuous_no_speech_count == 0
def test_stop_during_transcription_discards_restart(self, fake_recorder, monkeypatch):
"""User hits Ctrl+B mid-transcription: the in-flight transcript must
still fire (it's a real utterance), but the loop must NOT restart."""

View file

@ -14521,6 +14521,7 @@ def _fake_tts_modules(monkeypatch, *, requirements=True, playback_stops=None, li
check_tts_requirements=lambda: requirements,
stream_tts_to_speaker=fake_stream,
_get_provider=lambda cfg: "edge",
_load_tts_config=lambda: {},
get_env_value=lambda key, default="": default,
),
)

View file

@ -17701,6 +17701,21 @@ def _voice_tts_enabled() -> bool:
return os.environ.get("HERMES_VOICE_TTS", "").strip() == "1"
def _any_session_running() -> bool:
"""True while any session's agent turn is in flight.
Registered as the voice busy-probe (``hermes_cli.voice.set_voice_busy_probe``)
so silent capture cycles during a long agent turn don't count toward the
no-speech limit the user is correctly quiet while the agent works.
Voice is process-global (one microphone), so any running session holds.
"""
try:
with _sessions_lock:
return any(s.get("running") for s in _sessions.values())
except Exception:
return False
# ── Streaming TTS (one active pipeline per process — one speaker) ──────────
# Token deltas from the running turn feed a sentence-buffering consumer
# (tools.tts_tool.stream_tts_to_speaker) so speech starts on the first
@ -18354,6 +18369,18 @@ def _(rid, params: dict) -> dict:
from hermes_cli.voice import start_continuous
# Register the agent-busy probe so the shared voice wrapper can
# hold the no-speech counter during long agent turns (item:
# silence must not end the chat while the agent works). Safe to
# re-register on every start; older wrappers without the setter
# are tolerated.
try:
from hermes_cli.voice import set_voice_busy_probe
set_voice_busy_probe(_any_session_running)
except Exception:
pass
# Shape-safe lookups: malformed ``voice:`` YAML (bool/scalar/list)
# must not crash /voice with a 5025 — fall back to VAD defaults.
#