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
76 changes: 62 additions & 14 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11500,6 +11500,11 @@ def _voice_barge_in_monitor(self, stop_event: threading.Event) -> None:
— restarting the recorder after detection would lose the opening
words). ``_voice_barge_capture`` suppresses process_loop's auto-
restart until the captured utterance has been submitted.

A short startup grace period delays VAD activation so TTS playback
has time to establish before the mic starts listening. Without
this, speaker bleed during the first few hundred milliseconds can
falsely trigger barge-in and cut the response short.
"""
try:
from hermes_cli.config import load_config
Expand All @@ -11508,8 +11513,25 @@ def _voice_barge_in_monitor(self, stop_event: threading.Event) -> None:
return
from tools.voice_mode import listen_for_speech, stop_playback

# Grace period: wait briefly before opening the mic so the
# first TTS sentence is already playing and the VAD calibration
# samples the actual playback level (not silence). This
# prevents speaker bleed from falsely triggering barge-in
# at the start of playback.
_grace_s = float(voice_cfg.get("barge_in_grace_seconds", 2.0))
if _grace_s > 0:
stop_event.wait(timeout=_grace_s)
if stop_event.is_set() or self._voice_tts_done.is_set():
return

def _cut_playback():
if not self._voice_tts_done.is_set():
import traceback as _tb
logger.debug(
"TTS CUT: barge-in _cut_playback fired (VAD trip) — "
"stop_event.set() + stop_playback()\n%s",
"".join(_tb.format_stack()),
)
from tools.tts_streaming import mark_speech_interrupted
mark_speech_interrupted()
self._voice_barge_capture.set()
Expand All @@ -11520,6 +11542,8 @@ def _cut_playback():
lambda: stop_event.is_set() or self._voice_tts_done.is_set(),
capture=True,
on_trigger=_cut_playback,
sustained_ms=1000,
calibration_ms=800,
)
if wav_path and self._voice_barge_capture.is_set():
self._voice_submit_barge_utterance(wav_path)
Expand Down Expand Up @@ -11650,6 +11674,7 @@ def _bg_shutdown(rec=recorder):
# Stop any active TTS playback (file player + streaming pipeline)
try:
if self._voice_tts_stop is not None:
logger.info("TTS CUT: _disable_voice_mode setting stop event")
self._voice_tts_stop.set()
from tools.voice_mode import stop_playback
stop_playback()
Expand Down Expand Up @@ -12419,6 +12444,7 @@ def _stage_user_message() -> None:
tts_thread = None
stream_callback = None
stop_event = None
_tts_normal_exit = False

if self._voice_tts:
try:
Expand All @@ -12436,23 +12462,32 @@ def _stage_user_message() -> None:
text_queue = queue.Queue()
stop_event = threading.Event()

def display_callback(sentence: str):
"""Called by TTS consumer when a sentence is ready to display + speak."""
nonlocal _streaming_box_opened
if not _streaming_box_opened:
_streaming_box_opened = True
w = self._scrollback_box_width(getattr(self.console, "width", 80))
label = " ⚕ Hermes "
if self.show_timestamps:
label = f"{label}{datetime.now().strftime(getattr(self, 'timestamp_format', '%H:%M'))} "
fill = w - 2 - HermesCLI._status_bar_display_width(label)
_cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}")
_cprint(f"{_STREAM_PAD}{sentence.rstrip()}")
# When token streaming is enabled (the common case), the
# CLI's _stream_delta already renders text token-by-token as
# the model generates it. Passing a display_callback here too
# would render every sentence a second time. Only attach the
# callback when streaming is disabled, so the TTS consumer
# becomes the sole display path.
_tts_display_cb = None
if not self.streaming_enabled:
def display_callback(sentence: str):
"""Called by TTS consumer when a sentence is ready to display + speak."""
nonlocal _streaming_box_opened
if not _streaming_box_opened:
_streaming_box_opened = True
w = self._scrollback_box_width(getattr(self.console, "width", 80))
label = " ⚕ Hermes "
if self.show_timestamps:
label = f"{label}{datetime.now().strftime(getattr(self, 'timestamp_format', '%H:%M'))} "
fill = w - 2 - HermesCLI._status_bar_display_width(label)
_cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}")
_cprint(f"{_STREAM_PAD}{sentence.rstrip()}")
_tts_display_cb = display_callback

tts_thread = threading.Thread(
target=stream_tts_to_speaker,
args=(text_queue, stop_event, self._voice_tts_done),
kwargs={"display_callback": display_callback},
kwargs={"display_callback": _tts_display_cb},
daemon=True,
)
tts_thread.start()
Expand Down Expand Up @@ -12722,6 +12757,12 @@ def run_agent():
text_queue.put(None) # sentinel
if tts_thread is not None:
tts_thread.join(timeout=120)
# Mark normal completion only if the thread actually
# finished. If join() timed out and the thread is still
# alive, leave _tts_normal_exit False so the finally block
# sets stop_event to kill the runaway worker.
if tts_thread is not None and not tts_thread.is_alive():
_tts_normal_exit = True

# Drain any remaining agent output still in the StdoutProxy
# buffer so tool/status lines render ABOVE our response box.
Expand Down Expand Up @@ -13004,12 +13045,18 @@ def run_agent():
# Normal path sends the sentinel at line ~3568; this is a safety
# net for exception paths that skip it. Duplicate sentinels are
# harmless — stream_tts_to_speaker exits on the first None.
#
# Only set stop_event on the exception path. On normal exit
# (_tts_normal_exit is True) the pipeline has already drained —
# setting stop_event here would race the playback worker and
# could cut the final sentence mid-audio.
if text_queue is not None:
try:
text_queue.put_nowait(None)
except Exception:
pass
if stop_event is not None:
if stop_event is not None and not _tts_normal_exit:
logger.info("TTS CUT: exception finally block setting stop_event")
stop_event.set()
if tts_thread is not None and tts_thread.is_alive():
tts_thread.join(timeout=5)
Expand Down Expand Up @@ -14443,6 +14490,7 @@ def handle_voice_record(event):
# the stop event drains the streaming pipeline if one is live.
if not cli_ref._voice_tts_done.is_set():
try:
logger.info("TTS CUT: record key handler cutting TTS")
from tools.tts_streaming import mark_speech_interrupted
mark_speech_interrupted()
if cli_ref._voice_tts_stop is not None:
Expand Down
4 changes: 3 additions & 1 deletion tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13056,6 +13056,8 @@ def default_listen(should_stop, capture=False, on_trigger=None, **_kw):
types.SimpleNamespace(
check_tts_requirements=lambda: requirements,
stream_tts_to_speaker=fake_stream,
_get_provider=lambda cfg: "edge",
get_env_value=lambda key, default="": default,
),
)
monkeypatch.setitem(
Expand Down Expand Up @@ -13187,7 +13189,7 @@ def fake_listen(should_stop, capture=False, on_trigger=None, **_kw):
with server._tts_stream_lock:
state = server._tts_stream_state
assert state is not None
assert state["stop"].wait(2.0)
assert state["stop"].wait(5.0) # grace period (2s) + fake_listen + margin
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline and wav.exists():
time.sleep(0.01) # unlink (finally) runs after the transcript emit
Expand Down
71 changes: 71 additions & 0 deletions tests/tools/test_voice_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -1578,6 +1578,77 @@ def test_loud_floor_raises_trigger(self, mock_sd):
heard, _ = self._run(mock_sd, levels)
assert heard is False

def test_quiet_then_loud_playback_does_not_trip(self, mock_sd):
"""TTS that starts quiet and gets louder must NOT trip barge-in.

This is the core regression: a one-shot calibration freezes the
floor from the quiet opening, then louder TTS exceeds the stale
floor and false-triggers. The rolling window keeps the floor
current so the louder passage is absorbed into the floor.
"""
levels = [100] * self.CALIB_BLOCKS + [200] * 30 + [500] * 30 + [1000] * 30
heard, _ = self._run(mock_sd, levels)
assert heard is False

def test_8x_multiplier_absorbs_tts_volume_spikes(self, mock_sd):
"""TTS volume spikes that would exceed a 5x floor must NOT trip.

At 5x multiplier, a quiet TTS passage (RMS 200) sets a floor of
~180 and a trigger of 900. A subsequent louder passage at RMS
1000 exceeds the trigger, is excluded from the floor window, and
after sustained_ms of consecutive above-trigger blocks the VAD
false-trips and cuts playback mid-sentence. The 8x multiplier
raises the trigger to 1440 so the 1000-RMS passage stays below
it and gets absorbed into the rolling floor.
"""
# Calib at 200 RMS → floor ~180 → 8x trigger = 1440
# Then 1000 RMS TTS: below 3200 (400*8), absorbed into floor, no trip.
# With old 5x: trigger=2000 (400*5), 1000 < 2000, would NOT trip either.
# To actually test the 8x multiplier, use levels where 5x would trip
# but 8x would not: calib at 200 → floor=180 → 5x trigger=900,
# 8x trigger=1440. Feed 1200 RMS: above 900 (5x trips) but below
# 1440 (8x absorbs). With min_floor=400 the trigger is max(400,180*8)=1440,
# so 1200 < 1440 → no trip at 8x, but 1200 > 900 → would trip at 5x.
levels = [200] * self.CALIB_BLOCKS + [1200] * 50
heard, _ = self._run(mock_sd, levels)
assert heard is False

def test_trigger_ceiling_lets_genuine_speech_trip(self, mock_sd):
"""Even with a loud TTS floor, genuine speech must still trip.

Loud TTS at 3000 RMS → floor ~2700 → 8x trigger = 21600, but
the ceiling caps it at 4000. Speech at 5000 RMS exceeds the
capped trigger and trips after sustained_ms blocks.
"""
levels = [3000] * self.CALIB_BLOCKS + [5000] * 50
heard, _ = self._run(mock_sd, levels)
assert heard is True

def test_silence_calibration_does_not_false_trip_on_tts(self, mock_sd):
"""Calibration during an inter-sentence gap must NOT false-trip.

If the grace period ends during a pause between TTS sentences, the
calibration window samples near-silence. Without the min_floor clamp,
min_floor locks near zero, the trigger drops to 400 RMS (SILENCE_RMS_THRESHOLD
* 2), and the next TTS sentence at 800 RMS exceeds it — those blocks are
excluded from the rolling window (rms >= trigger), the floor freezes, and
after sustained_ms the VAD false-triggers and cuts playback mid-sentence.

With the clamp, min_floor stays at SILENCE_RMS_THRESHOLD * 2 = 400, the
trigger is max(400, 400 * 8.0) = 3200, and 800-RMS TTS stays below it and
feeds the rolling floor. No false trip.
"""
# calibration_ms=800 → CALIB_BLOCKS = 800/30 ≈ 26 blocks of silence
# Then TTS resumes at 800 RMS — must NOT trip (below 3200 trigger).
calib = 800 // 30
levels = [0] * calib + [800] * 100
heard, _ = self._run(
mock_sd, levels,
sustained_ms=1000,
calibration_ms=800,
)
assert heard is False


class TestListenForSpeechCapture:
"""capture=True: the barge monitor records the interruption with pre-roll,
Expand Down
81 changes: 77 additions & 4 deletions tools/voice_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -1167,9 +1167,18 @@ def listen_for_speech(
trip_blocks = max(1, sustained_ms // 30)
endpoint_blocks = max(1, endpoint_silence_ms // 30)
max_blocks = max(1, max_utterance_ms // 30)
floor_samples: List[float] = []

# Rolling floor window: continuously tracks TTS speaker-bleed volume
# throughout playback, not just the first calibration_ms. This is the
# key fix for false barge-in — a one-shot calibration freezes a floor
# from the opening TTS passage, but later louder passages exceed the
# stale floor and false-trigger. The rolling window keeps the floor
# current so only genuinely louder-than-playback speech trips the VAD.
floor_window: "deque[float]" = deque(maxlen=max(calib_blocks, 100)) # ~3s rolling
pre_roll: deque = deque(maxlen=max(1, pre_roll_ms // 30))
consecutive = 0
min_floor = 0.0 # baseline from initial calibration; floor never drops below this
block_idx = 0 # block counter for diagnostic logging

try:
with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="int16", blocksize=block) as stream:
Expand All @@ -1178,15 +1187,79 @@ def listen_for_speech(
rms = float(np.sqrt(np.mean(data.astype(np.float64) ** 2)))
if capture:
pre_roll.append(data.copy())
if len(floor_samples) < calib_blocks:
floor_samples.append(rms)
block_idx += 1

# Wait for at least calib_blocks before evaluating. During
# the initial warmup we always feed the window so calibration
# has data to work with.
if len(floor_window) < calib_blocks:
floor_window.append(rms)
continue
trigger = max(float(threshold or SILENCE_RMS_THRESHOLD * 2), float(np.median(floor_samples)) * 3.5)

# Lock a minimum floor from the initial calibration samples.
# During inter-sentence pauses the rolling window can flush
# with near-silence, collapsing the 90th-percentile floor
# toward zero and false-triggering on the next rising
# sentence. min_floor keeps the trigger from ever dropping
# below the baseline TTS playback level established during
# the initial calibration_ms window.
#
# If the grace period ended during an inter-sentence gap the
# calibration samples near-silence. Locking a near-zero
# floor sets the trigger so low that TTS blocks exceed it,
# are excluded from the rolling window (rms >= trigger), and
# the floor freezes — guaranteeing a false trigger the moment
# TTS resumes. Clamp min_floor to SILENCE_RMS_THRESHOLD * 2
# (400 RMS) so the 8x multiplier yields a trigger of at least
# (500-2000 RMS) stays below it and feeds the rolling window,
# while genuine speech (3000-8000 RMS) can still trip it.
if min_floor == 0.0 and len(floor_window) >= calib_blocks:
_pct90 = float(np.percentile(list(floor_window), 90))
min_floor = max(_pct90, SILENCE_RMS_THRESHOLD * 2)
else:
_pct90 = float(np.percentile(list(floor_window), 90))

# Use the 90th percentile of the ROLLING window for the
# noise floor so the trigger reflects the loudest parts of
# recent playback — not a frozen snapshot from TTS onset.
_floor = max(_pct90, min_floor)
# 8.0x multiplier: TTS speaker bleed has wide
# volume variation between sentences and within sentences.
# At 5x, louder TTS passages exceed the trigger, get
# excluded from the floor window, and create a low-stale
# floor that false-triggers on the next loud passage.
# 8x gives enough headroom for TTS dynamics to stay below
# the trigger and get absorbed into the rolling floor.
trigger = max(float(threshold or SILENCE_RMS_THRESHOLD * 2), _floor * 8.0)
# Ceiling: never let the trigger exceed 4000 RMS, otherwise
# a very loud TTS passage would push the trigger so high
# that genuine speech (which is typically 3000–8000 RMS)
# couldn't trip it.
trigger = min(trigger, 4000.0)

# Only feed the floor window with blocks that are NOT above
# the current trigger — speech blocks would inflate the floor
# and make the trigger unreachable.
if rms < trigger:
floor_window.append(rms)

consecutive = consecutive + 1 if rms >= trigger else 0
if consecutive > 0:
logger.debug(
"VAD above-trigger: block=%d rms=%.0f floor=%.0f trigger=%.0f "
"consec=%d/%d min_floor=%.0f window_len=%d",
block_idx, rms, _floor, trigger, consecutive,
trip_blocks, min_floor, len(floor_window),
)
if consecutive < trip_blocks:
continue

# Tripped — the user is talking over playback.
logger.info(
"VAD TRIPPED: block=%d rms=%.0f floor=%.0f trigger=%.0f "
"consec=%d min_floor=%.0f — cutting TTS playback",
block_idx, rms, _floor, trigger, consecutive, min_floor,
)
if on_trigger:
try:
on_trigger()
Expand Down
Loading