feat(voice): stream any provider in the CLI, with barge-in capture

The streaming gate broadens from ElevenLabs-only to any provider that
passes check_tts_requirements(). In continuous voice mode a mic monitor
runs during playback: talking over the agent cuts TTS at detection
while the monitor keeps recording, then the captured interruption is
transcribed and queued as the next turn (process_loop's auto-restart
stands down while the capture owns the mic). New voice.barge_in config
key, default true.
This commit is contained in:
Brooklyn Nicholson 2026-07-22 17:47:15 -05:00
parent 8ce18d2557
commit b135a8badd
3 changed files with 189 additions and 136 deletions

146
cli.py
View File

@ -4205,6 +4205,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._voice_continuous = False
self._voice_tts_done = threading.Event()
self._voice_tts_done.set()
self._voice_tts_stop = None # active streaming pipeline's stop event
self._voice_barge_capture = threading.Event() # barge monitor is capturing the interruption
# Status bar visibility (toggled via /statusbar)
self._status_bar_visible = True
@ -11099,6 +11101,26 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
time.sleep(0.15)
threading.Thread(target=_refresh_level, daemon=True).start()
def _voice_stt_model(self) -> Optional[str]:
"""STT model override from config, or None for the provider default."""
try:
from hermes_cli.config import load_config
stt_config = load_config().get("stt", {})
return stt_config.get("model") if isinstance(stt_config, dict) else None
except Exception:
return None
def _voice_restart_recording_async(self) -> None:
"""Restart continuous-mode recording off-thread (start() can block)."""
def _restart_recording():
try:
self._voice_start_recording()
if hasattr(self, '_app') and self._app:
self._app.invalidate()
except Exception as e:
_cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}")
threading.Thread(target=_restart_recording, daemon=True).start()
def _voice_stop_and_transcribe(self):
"""Stop recording, transcribe via STT, and queue the transcript as input."""
# Atomic guard: only one thread can enter stop-and-transcribe.
@ -11136,17 +11158,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._app.invalidate()
_cprint(f"{_DIM}Transcribing...{_RST}")
# Get STT model from config
stt_model = None
try:
from hermes_cli.config import load_config
stt_config = load_config().get("stt", {})
stt_model = stt_config.get("model")
except Exception:
pass
from tools.voice_mode import transcribe_recording
result = transcribe_recording(wav_path, model=stt_model)
result = transcribe_recording(wav_path, model=self._voice_stt_model())
if result.get("success") and result.get("transcript", "").strip():
transcript = result["transcript"].strip()
@ -11197,14 +11210,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# (When transcript IS submitted, process_loop handles restart
# after chat() completes.)
if self._voice_continuous and not submitted and not self._voice_recording:
def _restart_recording():
try:
self._voice_start_recording()
if hasattr(self, '_app') and self._app:
self._app.invalidate()
except Exception as e:
_cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}")
threading.Thread(target=_restart_recording, daemon=True).start()
self._voice_restart_recording_async()
def _voice_speak_response_async(self, text: str) -> None:
"""Schedule TTS and mark it pending before continuous recording can restart."""
@ -11270,6 +11276,68 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._voice_tts_done.set()
def _voice_barge_in_monitor(self, stop_event: threading.Event) -> None:
"""VAD barge-in: cut streaming TTS the moment the user starts talking.
Runs for one turn alongside the streaming pipeline (continuous voice
mode only the mic is otherwise idle during playback). On speech,
playback is cut immediately while the monitor KEEPS capturing (with
pre-roll, so the interruption is transcribed from its first syllable
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.
"""
try:
from hermes_cli.config import load_config
voice_cfg = load_config().get("voice") or {}
if not (isinstance(voice_cfg, dict) and voice_cfg.get("barge_in", True)):
return
from tools.voice_mode import listen_for_speech, stop_playback
def _cut_playback():
if not self._voice_tts_done.is_set():
self._voice_barge_capture.set()
stop_event.set()
stop_playback()
wav_path = listen_for_speech(
lambda: stop_event.is_set() or self._voice_tts_done.is_set(),
capture=True,
on_trigger=_cut_playback,
)
if wav_path and self._voice_barge_capture.is_set():
self._voice_submit_barge_utterance(wav_path)
else:
self._voice_barge_capture.clear()
except Exception as e:
self._voice_barge_capture.clear()
logger.debug("Voice barge-in monitor failed: %s", e)
def _voice_submit_barge_utterance(self, wav_path: str) -> None:
"""Transcribe a barge-captured interruption and queue it as the next turn."""
submitted = False
try:
from tools.voice_mode import transcribe_recording
result = transcribe_recording(wav_path, model=self._voice_stt_model())
transcript = (result.get("transcript") or "").strip() if result.get("success") else ""
if transcript:
self._pending_input.put(transcript)
submitted = True
elif not result.get("success"):
_cprint(f"\n{_DIM}Transcription failed: {result.get('error', 'Unknown error')}{_RST}")
except Exception as e:
_cprint(f"\n{_DIM}Voice processing error: {e}{_RST}")
finally:
try:
if os.path.isfile(wav_path):
os.unlink(wav_path)
except OSError:
pass
self._voice_barge_capture.clear()
# No usable transcript: hand the mic back to the normal loop.
if not submitted and self._voice_mode and self._voice_continuous and not self._voice_recording:
self._voice_restart_recording_async()
def _voice_beeps_enabled(self) -> bool:
"""Return whether CLI voice mode should play record start/stop beeps."""
try:
@ -11363,8 +11431,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
threading.Thread(target=_bg_shutdown, daemon=True).start()
self._voice_recorder = None
# Stop any active TTS playback
# Stop any active TTS playback (file player + streaming pipeline)
try:
if self._voice_tts_stop is not None:
self._voice_tts_stop.set()
from tools.voice_mode import stop_playback
stop_playback()
except Exception:
@ -12117,9 +12187,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._reasoning_shown_this_turn = False
# --- Streaming TTS setup ---
# When ElevenLabs is the TTS provider and sounddevice is available,
# we stream audio sentence-by-sentence as the agent generates tokens
# instead of waiting for the full response.
# Any working TTS provider streams sentence-by-sentence as the agent
# generates tokens: PCM-streaming providers (ElevenLabs, OpenAI) play
# chunks as they arrive, everything else synthesizes per sentence.
use_streaming_tts = False
_streaming_box_opened = False
text_queue = None
@ -12130,20 +12200,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
if self._voice_tts:
try:
from tools.tts_tool import (
_load_tts_config as _load_tts_cfg,
_get_provider as _get_prov,
_import_elevenlabs,
_import_sounddevice,
check_tts_requirements,
stream_tts_to_speaker,
)
_tts_cfg = _load_tts_cfg()
if _get_prov(_tts_cfg) == "elevenlabs":
# Verify both ElevenLabs SDK and audio output are available
_import_elevenlabs()
_import_sounddevice()
use_streaming_tts = True
except (ImportError, OSError):
pass
_import_sounddevice()
use_streaming_tts = check_tts_requirements()
except Exception:
pass
@ -12171,6 +12233,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
daemon=True,
)
tts_thread.start()
# Expose the pipeline's stop event so barge-in paths (voice
# key, VAD monitor) can cut playback from outside this turn.
self._voice_tts_stop = stop_event
if self._voice_continuous:
threading.Thread(
target=self._voice_barge_in_monitor, args=(stop_event,), daemon=True
).start()
def stream_callback(delta: str):
if text_queue is not None:
@ -13316,6 +13385,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._voice_continuous = False # Whether to auto-restart after agent responds
self._voice_tts_done = threading.Event() # Signals TTS playback finished
self._voice_tts_done.set() # Initially "done" (no TTS pending)
self._voice_tts_stop = None # active streaming pipeline's stop event
self._voice_barge_capture = threading.Event() # barge monitor is capturing the interruption
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
self._install_tool_callbacks()
@ -14104,9 +14175,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
return
# Interrupt TTS if playing, so user can start talking.
# stop_playback() is fast (just terminates a subprocess).
# stop_playback() is fast (just terminates a subprocess);
# the stop event drains the streaming pipeline if one is live.
if not cli_ref._voice_tts_done.is_set():
try:
if cli_ref._voice_tts_stop is not None:
cli_ref._voice_tts_stop.set()
from tools.voice_mode import stop_playback
stop_playback()
cli_ref._voice_tts_done.set()
@ -15328,6 +15402,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
if self._voice_tts:
self._voice_tts_done.wait(timeout=60)
time.sleep(0.3)
# A barge-in capture already owns the mic and
# will submit the interruption itself.
if self._voice_barge_capture.is_set():
return
self._voice_start_recording()
app.invalidate()
except Exception as e:

View File

@ -2320,6 +2320,7 @@ DEFAULT_CONFIG = {
"beep_enabled": True, # Play record start/stop beeps in CLI voice mode
"silence_threshold": 200, # RMS below this = silence (0-32767)
"silence_duration": 3.0, # Seconds of silence before auto-stop
"barge_in": True, # Stop TTS playback when the user starts talking
},
"human_delay": {

View File

@ -29,6 +29,8 @@ def _make_voice_cli(**overrides):
cli._voice_continuous = False
cli._voice_tts_done = threading.Event()
cli._voice_tts_done.set()
cli._voice_tts_stop = None
cli._voice_barge_capture = threading.Event()
cli._pending_input = queue.Queue()
cli._app = None
cli._attached_images = []
@ -176,114 +178,40 @@ class TestVoiceStateLock:
# ============================================================================
class TestStreamingTTSActivation:
"""Verify streaming TTS uses lazy imports to check availability."""
"""The CLI streaming gate: sounddevice + a working provider, ANY provider.
def test_activates_when_elevenlabs_and_sounddevice_available(self):
"""use_streaming_tts should be True when provider is elevenlabs
and both lazy imports succeed."""
use_streaming_tts = False
Mirrors cli.py's gate exactly — streaming engages whenever audio output
exists and check_tts_requirements() passes, regardless of which provider
is configured (non-streamers get the per-sentence sync path downstream).
"""
@staticmethod
def _gate() -> bool:
"""The cli.py streaming-TTS gate, verbatim."""
try:
from tools.tts_tool import (
_load_tts_config as _load_tts_cfg,
_get_provider as _get_prov,
_import_elevenlabs,
_import_sounddevice,
)
assert callable(_import_elevenlabs)
assert callable(_import_sounddevice)
except ImportError:
pytest.skip("tools.tts_tool not available")
from tools.tts_tool import _import_sounddevice, check_tts_requirements
_import_sounddevice()
return check_tts_requirements()
except Exception:
return False
with patch("tools.tts_tool._load_tts_config") as mock_cfg, \
patch("tools.tts_tool._get_provider", return_value="elevenlabs"), \
patch("tools.tts_tool._import_elevenlabs") as mock_el, \
patch("tools.tts_tool._import_sounddevice") as mock_sd:
mock_cfg.return_value = {"provider": "elevenlabs"}
mock_el.return_value = MagicMock()
mock_sd.return_value = MagicMock()
def test_activates_for_any_working_provider(self):
"""Any provider that passes check_tts_requirements engages streaming."""
with patch("tools.tts_tool._import_sounddevice", return_value=MagicMock()), \
patch("tools.tts_tool.check_tts_requirements", return_value=True):
assert self._gate() is True
from tools.tts_tool import (
_load_tts_config as load_cfg,
_get_provider as get_prov,
_import_elevenlabs as import_el,
_import_sounddevice as import_sd,
)
cfg = load_cfg()
if get_prov(cfg) == "elevenlabs":
import_el()
import_sd()
use_streaming_tts = True
assert use_streaming_tts is True
def test_does_not_activate_when_elevenlabs_missing(self):
"""use_streaming_tts stays False when elevenlabs import fails."""
use_streaming_tts = False
with patch("tools.tts_tool._load_tts_config", return_value={"provider": "elevenlabs"}), \
patch("tools.tts_tool._get_provider", return_value="elevenlabs"), \
patch("tools.tts_tool._import_elevenlabs", side_effect=ImportError("no elevenlabs")):
try:
from tools.tts_tool import (
_load_tts_config as load_cfg,
_get_provider as get_prov,
_import_elevenlabs as import_el,
_import_sounddevice as import_sd,
)
cfg = load_cfg()
if get_prov(cfg) == "elevenlabs":
import_el()
import_sd()
use_streaming_tts = True
except (ImportError, OSError):
pass
assert use_streaming_tts is False
def test_does_not_activate_when_provider_unavailable(self):
"""No working TTS provider → no streaming pipeline."""
with patch("tools.tts_tool._import_sounddevice", return_value=MagicMock()), \
patch("tools.tts_tool.check_tts_requirements", return_value=False):
assert self._gate() is False
def test_does_not_activate_when_sounddevice_missing(self):
"""use_streaming_tts stays False when sounddevice import fails."""
use_streaming_tts = False
with patch("tools.tts_tool._load_tts_config", return_value={"provider": "elevenlabs"}), \
patch("tools.tts_tool._get_provider", return_value="elevenlabs"), \
patch("tools.tts_tool._import_elevenlabs", return_value=MagicMock()), \
patch("tools.tts_tool._import_sounddevice", side_effect=OSError("no PortAudio")):
try:
from tools.tts_tool import (
_load_tts_config as load_cfg,
_get_provider as get_prov,
_import_elevenlabs as import_el,
_import_sounddevice as import_sd,
)
cfg = load_cfg()
if get_prov(cfg) == "elevenlabs":
import_el()
import_sd()
use_streaming_tts = True
except (ImportError, OSError):
pass
assert use_streaming_tts is False
def test_does_not_activate_for_non_elevenlabs_provider(self):
"""use_streaming_tts stays False when provider is not elevenlabs."""
use_streaming_tts = False
with patch("tools.tts_tool._load_tts_config", return_value={"provider": "edge"}), \
patch("tools.tts_tool._get_provider", return_value="edge"):
try:
from tools.tts_tool import (
_load_tts_config as load_cfg,
_get_provider as get_prov,
_import_elevenlabs as import_el,
_import_sounddevice as import_sd,
)
cfg = load_cfg()
if get_prov(cfg) == "elevenlabs":
import_el()
import_sd()
use_streaming_tts = True
except (ImportError, OSError):
pass
assert use_streaming_tts is False
"""No audio output device → no streaming pipeline, even with a provider."""
with patch("tools.tts_tool._import_sounddevice", side_effect=OSError("no PortAudio")), \
patch("tools.tts_tool.check_tts_requirements", return_value=True):
assert self._gate() is False
def test_stale_boolean_imports_no_longer_exist(self):
"""Confirm _HAS_ELEVENLABS and _HAS_AUDIO are not in tts_tool module."""
@ -1326,3 +1254,49 @@ class TestRefreshLevelLock:
assert not t.is_alive()
assert not t.is_alive(), "Refresh thread did not stop"
assert iterations > 0, "Refresh thread never ran"
# ---------------------------------------------------------------------------
# Barge-in capture — the interruption is transcribed and queued directly
# ---------------------------------------------------------------------------
class TestVoiceBargeCaptureSubmit:
"""_voice_submit_barge_utterance: the barge monitor's captured WAV becomes
the next turn without a re-record round trip."""
def test_transcript_is_queued_and_wav_removed(self, tmp_path, monkeypatch):
cli = _make_voice_cli()
cli._voice_barge_capture.set()
wav = tmp_path / "barge.wav"
wav.write_bytes(b"RIFF")
monkeypatch.setattr(
"tools.voice_mode.transcribe_recording",
lambda path, model=None: {"success": True, "transcript": "stop, do it differently"},
)
cli._voice_submit_barge_utterance(str(wav))
assert cli._pending_input.get_nowait() == "stop, do it differently"
assert not cli._voice_barge_capture.is_set()
assert not wav.exists()
def test_no_speech_hands_mic_back_without_queueing(self, tmp_path, monkeypatch):
cli = _make_voice_cli(_voice_mode=True, _voice_continuous=True)
cli._voice_barge_capture.set()
wav = tmp_path / "barge.wav"
wav.write_bytes(b"RIFF")
restarted = threading.Event()
cli._voice_start_recording = lambda: restarted.set()
monkeypatch.setattr(
"tools.voice_mode.transcribe_recording",
lambda path, model=None: {"success": True, "transcript": "", "no_speech": True},
)
cli._voice_submit_barge_utterance(str(wav))
assert cli._pending_input.empty()
assert not cli._voice_barge_capture.is_set()
assert restarted.wait(2.0) # continuous mode resumes listening