fix(voice): rolling-window VAD to prevent false barge-in during TTS - #71083
fix(voice): rolling-window VAD to prevent false barge-in during TTS#71083beardedeagle wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
Improves voice-mode TTS barge-in behavior by making the VAD floor adaptive during playback, adding a short grace period before the mic starts listening, and preventing duplicate text rendering when token streaming is enabled.
Changes:
- Replaces one-shot VAD calibration with a rolling-window (90th percentile) floor and adds diagnostic logging for VAD trips.
- Adds a configurable barge-in grace period and tunes barge-in VAD parameters from the CLI monitor.
- Suppresses duplicate sentence rendering during streaming TTS and adds a “normal exit” flag to avoid cutting audio on clean shutdown.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| tools/voice_mode.py | Implements rolling-window VAD floor + trigger ceiling and adds diagnostics. |
| tests/tools/test_voice_mode.py | Adds regression coverage for new VAD behavior (needs one test adjustment to truly cover the multiplier). |
| cli.py | Adds barge-in grace period + TTS cut diagnostics; avoids duplicate rendering; refines streaming-TTS shutdown behavior (normal-exit flag logic needs a small fix). |
Comments suppressed due to low confidence (1)
tools/voice_mode.py:1226
- The comment says the multiplier "was 5.0", but the removed code used a different calibration approach (median * 3.5). Since it's not a direct like-for-like multiplier change, the "was 5.0" parenthetical is misleading and should be removed.
# 8.0x multiplier (was 5.0): TTS speaker bleed has wide
# volume variation between sentences and within sentences.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
b2b308f to
70d7785
Compare
…eway mirror Replace one-shot VAD calibration with a rolling deque window that continuously recalibrates the noise floor throughout TTS playback, preventing false barge-in triggers from stale calibration. Add a grace period before VAD activates so TTS playback establishes first. Suppress duplicate text rendering when token streaming is enabled. Mirror the barge-in and TTS stream stop logic to the TUI gateway path so both CLI and gateway use the same VAD semantics. Rolling-window VAD: - 90th percentile of rolling window (~3s) for noise floor - 8x multiplier (was 5x) for TTS volume variation headroom - 4000 RMS trigger ceiling so genuine speech can still trip - min_floor clamped to SILENCE_RMS_THRESHOLD * 2 - sustained_ms=1000, calibration_ms=800 Barge-in grace period (barge_in_grace_seconds, default 2.0s): Delays VAD activation so TTS playback establishes before the mic opens. Duplicate render suppression: When streaming_enabled, pass display_callback=None to stream_tts_to_speaker so the token stream is the sole display path. TUI gateway mirror: Mirror _tts_stream_stop and _tts_stream_barge_in_monitor changes to tui_gateway/server.py so both code paths use the same VAD parameters, grace period, and TTS CUT diagnostic logging. Profile-scoped session DB and MoA progress events in tui_gateway/server.py were necessitated by the TTS pipeline changes affecting session state and event routing. Normal-exit flag and TTS CUT diagnostic logging at all cut paths. Regression tests: - test_quiet_then_loud_playback_does_not_trip - test_8x_multiplier_absorbs_tts_volume_spikes - test_trigger_ceiling_lets_genuine_speech_trip - test_silence_calibration_does_not_false_trip_on_tts - test_tts_stream_stop_latches_interruption_for_next_turn - test_tts_stream_stop_after_natural_finish_does_not_latch - Profile-scoped session DB tests (10 tests)
70d7785 to
070370f
Compare
|
Note on suppressed Copilot comment about 'was 5.0' parenthetical in voice_mode.py: True positive — the old code used median * 3.5, not 5.0. Removed the misleading '(was 5.0)' from the comment. Fixed in 070370f. |
…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).
…ateway/desktop backends) Premise check on live main: barge-in machinery EXISTS for the per-turn STREAMING pipeline only — cli.py chat() arms _voice_barge_in_monitor and tui_gateway _tts_stream_begin arms _tts_stream_barge_in_monitor. What was actually broken for spoken interruptions: 1. CLI whole-file fallback (_voice_speak_response_async — used whenever streaming TTS cannot start: sounddevice missing, requirement probe fails): NO monitor was ever armed, so talking over the reply did nothing. Now arms _voice_barge_in_monitor in continuous voice mode. 2. Gateway fallback speak (tts_queue None → speak_text thread) and the voice.tts RPC (desktop-triggered speech): speak_text ran bare, and its internal streaming dispatch created a PRIVATE stop event nothing could reach — uninterruptible even by stop_playback(). New _speak_text_with_barge() runs the same barge monitor beside the speak thread; hermes_cli.voice.speak_text/_speak_text_streaming accept an external stop_event so a barge cuts the streaming pipeline too. Stop-phrase handling and voice.transcript submission are inherited from the shared monitor (merged #73933 behavior preserved). 3. False barge during TTS (the reason interruption "worked" then self-cancelled or fired randomly): salvaged PR #71083 by @beardedeagle (previous commit, kept authorship) — rolling-window VAD floor, 8x multiplier, 4000-RMS trigger ceiling, barge_in_grace_seconds (2s) before the mic opens, min-floor clamp. barge_in_grace_seconds is now documented in DEFAULT_CONFIG. Desktop spoken barge (renderer mic via voice-barge-in.ts) already covers both its live-stream and fallback speech paths — verified, no change.
|
Salvaged into PR #74000 (merged, b6729ba) with your commit authorship preserved in git history. Your rolling-window VAD floor, multiplier tuning, RMS ceiling, and barge grace window were exactly the missing piece that made spoken barge-in reliable — they shipped alongside the fix that armed the barge monitor on the whole-file playback paths, and |
…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 NousResearch#71083 cherry-pick: the fake tools.tts_tool module needs _load_tts_config (main's tts_streaming imports it).
…ateway/desktop backends) Premise check on live main: barge-in machinery EXISTS for the per-turn STREAMING pipeline only — cli.py chat() arms _voice_barge_in_monitor and tui_gateway _tts_stream_begin arms _tts_stream_barge_in_monitor. What was actually broken for spoken interruptions: 1. CLI whole-file fallback (_voice_speak_response_async — used whenever streaming TTS cannot start: sounddevice missing, requirement probe fails): NO monitor was ever armed, so talking over the reply did nothing. Now arms _voice_barge_in_monitor in continuous voice mode. 2. Gateway fallback speak (tts_queue None → speak_text thread) and the voice.tts RPC (desktop-triggered speech): speak_text ran bare, and its internal streaming dispatch created a PRIVATE stop event nothing could reach — uninterruptible even by stop_playback(). New _speak_text_with_barge() runs the same barge monitor beside the speak thread; hermes_cli.voice.speak_text/_speak_text_streaming accept an external stop_event so a barge cuts the streaming pipeline too. Stop-phrase handling and voice.transcript submission are inherited from the shared monitor (merged NousResearch#73933 behavior preserved). 3. False barge during TTS (the reason interruption "worked" then self-cancelled or fired randomly): salvaged PR NousResearch#71083 by @beardedeagle (previous commit, kept authorship) — rolling-window VAD floor, 8x multiplier, 4000-RMS trigger ceiling, barge_in_grace_seconds (2s) before the mic opens, min-floor clamp. barge_in_grace_seconds is now documented in DEFAULT_CONFIG. Desktop spoken barge (renderer mic via voice-barge-in.ts) already covers both its live-stream and fallback speech paths — verified, no change.
…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 NousResearch#71083 cherry-pick: the fake tools.tts_tool module needs _load_tts_config (main's tts_streaming imports it).
…ateway/desktop backends) Premise check on live main: barge-in machinery EXISTS for the per-turn STREAMING pipeline only — cli.py chat() arms _voice_barge_in_monitor and tui_gateway _tts_stream_begin arms _tts_stream_barge_in_monitor. What was actually broken for spoken interruptions: 1. CLI whole-file fallback (_voice_speak_response_async — used whenever streaming TTS cannot start: sounddevice missing, requirement probe fails): NO monitor was ever armed, so talking over the reply did nothing. Now arms _voice_barge_in_monitor in continuous voice mode. 2. Gateway fallback speak (tts_queue None → speak_text thread) and the voice.tts RPC (desktop-triggered speech): speak_text ran bare, and its internal streaming dispatch created a PRIVATE stop event nothing could reach — uninterruptible even by stop_playback(). New _speak_text_with_barge() runs the same barge monitor beside the speak thread; hermes_cli.voice.speak_text/_speak_text_streaming accept an external stop_event so a barge cuts the streaming pipeline too. Stop-phrase handling and voice.transcript submission are inherited from the shared monitor (merged NousResearch#73933 behavior preserved). 3. False barge during TTS (the reason interruption "worked" then self-cancelled or fired randomly): salvaged PR NousResearch#71083 by @beardedeagle (previous commit, kept authorship) — rolling-window VAD floor, 8x multiplier, 4000-RMS trigger ceiling, barge_in_grace_seconds (2s) before the mic opens, min-floor clamp. barge_in_grace_seconds is now documented in DEFAULT_CONFIG. Desktop spoken barge (renderer mic via voice-barge-in.ts) already covers both its live-stream and fallback speech paths — verified, no change.
Problem
The barge-in VAD used one-shot calibration that froze the noise floor from the first 800ms of TTS playback. Later louder passages exceeded the stale floor and false-triggered barge-in, cutting TTS mid-sentence. The barge-in monitor also opened the mic immediately when TTS started, causing speaker bleed to falsely trigger VAD. Additionally, when token streaming was enabled, TTS sentences were rendered twice — once by the token stream and once by the TTS display callback.
Solution
Rolling-window VAD — 90th percentile of a rolling deque window (~3s), 8x multiplier, 4000 RMS trigger ceiling, min_floor clamped to SILENCE_RMS_THRESHOLD * 2, sustained_ms=1000, calibration_ms=800.
Barge-in grace period — barge_in_grace_seconds (default 2.0s) delays VAD activation so TTS playback establishes first.
Duplicate render suppression — pass display_callback=None to stream_tts_to_speaker when streaming_enabled, making the token stream the sole display path.
Normal-exit flag — _tts_normal_exit prevents the exception finally block from setting stop_event on clean exit.
TTS CUT diagnostic logging at all barge-in cut paths.
Regression tests