feat(voice): stream turn deltas through the TUI gateway, with barge-in

Turn deltas feed a per-turn TTS pipeline; the post-complete speak_text
call survives only as a fallback. session.interrupt, /voice toggles,
and new turns cut in-flight speech. VAD barge-in emits
voice.interrupted at detection, then the captured interruption goes out
as voice.transcript — the same event the TUI already submits as a
spoken turn.
This commit is contained in:
Brooklyn Nicholson
2026-07-22 17:47:15 -05:00
parent b135a8badd
commit 68e1fedd2d
2 changed files with 251 additions and 6 deletions
+128 -1
View File
@@ -1209,7 +1209,10 @@ def test_voice_toggle_tts_branch_also_carries_record_key(monkeypatch):
),
)
monkeypatch.setenv("HERMES_VOICE", "1")
monkeypatch.delenv("HERMES_VOICE_TTS", raising=False)
# setenv (not delenv) — the handler writes HERMES_VOICE_TTS directly, and
# delenv on an absent var registers no teardown, leaking TTS=1 into every
# later test in the file (which now spins up the streaming TTS pipeline).
monkeypatch.setenv("HERMES_VOICE_TTS", "0")
tts_resp = server.dispatch(
{"id": "voice-tts", "method": "voice.toggle", "params": {"action": "tts"}}
@@ -11733,3 +11736,127 @@ def test_get_usage_clamps_post_compression_sentinel():
usage = server._get_usage(agent)
assert "context_used" not in usage
assert "context_percent" not in usage
# ---------------------------------------------------------------------------
# Streaming TTS — per-turn pipeline + barge-in
# ---------------------------------------------------------------------------
def _fake_tts_modules(monkeypatch, *, requirements=True, playback_stops=None, listen=None, transcribe=None):
"""Install lightweight tools.tts_tool / tools.voice_mode fakes."""
started = {}
def fake_stream(text_queue, stop, done, **_kw):
started["queue"] = text_queue
stop.wait(5)
done.set()
def default_listen(should_stop, capture=False, on_trigger=None, **_kw):
return None if capture else False
monkeypatch.setitem(
sys.modules,
"tools.tts_tool",
types.SimpleNamespace(
check_tts_requirements=lambda: requirements,
stream_tts_to_speaker=fake_stream,
),
)
monkeypatch.setitem(
sys.modules,
"tools.voice_mode",
types.SimpleNamespace(
stop_playback=lambda: (playback_stops.append(True) if playback_stops is not None else None),
listen_for_speech=listen or default_listen,
transcribe_recording=transcribe or (lambda path, model=None: {"success": True, "transcript": ""}),
),
)
return started
def test_tts_stream_begin_requires_voice_tts(monkeypatch):
monkeypatch.setenv("HERMES_VOICE_TTS", "0")
assert server._tts_stream_begin() is None
def test_tts_stream_begin_requires_working_provider(monkeypatch):
monkeypatch.setenv("HERMES_VOICE_TTS", "1")
_fake_tts_modules(monkeypatch, requirements=False)
assert server._tts_stream_begin() is None
def test_tts_stream_begin_and_stop_lifecycle(monkeypatch):
"""begin() spawns the consumer; stop() cuts it and clears the slot."""
monkeypatch.setenv("HERMES_VOICE_TTS", "1")
monkeypatch.setenv("HERMES_VOICE", "0") # no barge-in monitor (no mic)
playback_stops: list = []
started = _fake_tts_modules(monkeypatch, playback_stops=playback_stops)
text_queue = server._tts_stream_begin()
assert text_queue is not None
assert started["queue"] is text_queue
with server._tts_stream_lock:
state = server._tts_stream_state
assert state is not None and not state["stop"].is_set()
server._tts_stream_stop()
assert state["stop"].is_set()
assert playback_stops == [True]
with server._tts_stream_lock:
assert server._tts_stream_state is None
def test_tts_stream_begin_barges_in_on_previous_pipeline(monkeypatch):
"""A new turn's pipeline stops the previous turn's speech (one speaker)."""
monkeypatch.setenv("HERMES_VOICE_TTS", "1")
monkeypatch.setenv("HERMES_VOICE", "0")
_fake_tts_modules(monkeypatch)
server._tts_stream_begin()
with server._tts_stream_lock:
first = server._tts_stream_state
server._tts_stream_begin()
assert first is not None and first["stop"].is_set()
server._tts_stream_stop()
def test_tts_stream_vad_barge_in_cuts_pipeline_and_submits_capture(monkeypatch, tmp_path):
"""User speech during playback cuts TTS at the moment of detection
(voice.interrupted), then the captured interruption is transcribed and
emitted as voice.transcript so the TUI submits it — complete from its
first syllable, no re-record round trip."""
monkeypatch.setenv("HERMES_VOICE_TTS", "1")
monkeypatch.setenv("HERMES_VOICE", "1")
monkeypatch.setattr(server, "_load_cfg", lambda: {"voice": {"barge_in": True}})
events: list = []
monkeypatch.setattr(
server, "_voice_emit", lambda event, payload=None: events.append((event, payload))
)
wav = tmp_path / "barge.wav"
wav.write_bytes(b"RIFF")
def fake_listen(should_stop, capture=False, on_trigger=None, **_kw):
assert capture is True
on_trigger() # playback cut happens at detection, not after endpointing
return str(wav)
_fake_tts_modules(
monkeypatch,
listen=fake_listen,
transcribe=lambda path, model=None: {"success": True, "transcript": "stop, actually—"},
)
server._tts_stream_begin()
with server._tts_stream_lock:
state = server._tts_stream_state
assert state is not None
assert state["stop"].wait(2.0)
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline and wav.exists():
time.sleep(0.01) # unlink (finally) runs after the transcript emit
assert ("voice.interrupted", None) in events
assert ("voice.transcript", {"text": "stop, actually—"}) in events
assert not wav.exists() # capture temp file cleaned up
server._tts_stream_stop()
+123 -5
View File
@@ -9372,6 +9372,9 @@ def _(rid, params: dict) -> dict:
@method("session.interrupt")
def _(rid, params: dict) -> dict:
# Keypress barge-in: stopping the turn also silences its streaming TTS
# (voice is process-global, so no per-session scoping is needed).
_tts_stream_stop()
session, err = _sess_nowait(params, rid)
if err:
return err
@@ -10302,6 +10305,7 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
session_tokens = []
home_token = None # per-turn HERMES_HOME override for a resumed remote profile
goal_followup = None # set by the post-turn goal hook below
tts_queue = None # streaming-TTS feed for this turn (voice mode)
one_turn_restore = session.pop("one_turn_model_restore", None)
try:
from tools.approval import (
@@ -10424,12 +10428,18 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
else:
run_message = _enrich_with_attached_images(prompt, images)
# Streaming TTS: voice-mode replies are spoken sentence-by-sentence
# as tokens arrive (CLI parity) instead of after the full turn.
tts_queue = _tts_stream_begin()
def _stream(delta):
with session["history_lock"]:
_append_inflight_delta(session, delta)
payload = {"text": delta}
if streamer and (r := streamer.feed(delta)) is not None:
payload["rendered"] = r
if tts_queue is not None and isinstance(delta, str):
tts_queue.put(delta)
_emit("message.delta", sid, payload)
# Surface interim assistant text (commentary emitted alongside
@@ -10698,12 +10708,13 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
except Exception:
pass
# CLI parity: when voice-mode TTS is on, speak the agent reply
# (cli.py:_voice_speak_response). Only the final text — tool
# calls / reasoning already stream separately and would be
# noisy to read aloud.
# Voice TTS fallback: when the streaming pipeline couldn't start
# (no provider / missing deps probed at turn start), speak the
# final text whole (cli.py:_voice_speak_response parity). The
# streaming path already spoke everything via tts_queue.
if (
status == "complete"
and tts_queue is None
and isinstance(raw, str)
and raw.strip()
and _voice_tts_enabled()
@@ -10738,6 +10749,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
)
_emit("error", sid, {"message": str(e)})
finally:
if tts_queue is not None:
tts_queue.put(None) # end-of-text sentinel — flush + finish speaking
if one_turn_restore:
try:
_restore_agent_model_runtime(agent, one_turn_restore)
@@ -15512,6 +15525,107 @@ def _voice_tts_enabled() -> bool:
return os.environ.get("HERMES_VOICE_TTS", "").strip() == "1"
# ── 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
# sentence instead of after the full reply. Voice is process-global, so a
# single slot suffices; starting a new turn's pipeline barges in on the
# previous one.
_tts_stream_lock = threading.Lock()
_tts_stream_state: Optional[dict] = None
def _tts_stream_begin() -> Optional[queue.Queue]:
"""Start a per-turn streaming TTS consumer; None when TTS can't stream."""
if not _voice_tts_enabled():
return None
try:
from tools.tts_tool import check_tts_requirements, stream_tts_to_speaker
if not check_tts_requirements():
return None
except Exception:
return None
_tts_stream_stop()
text_queue: queue.Queue = queue.Queue()
stop = threading.Event()
done = threading.Event()
threading.Thread(
target=stream_tts_to_speaker, args=(text_queue, stop, done), daemon=True
).start()
global _tts_stream_state
with _tts_stream_lock:
_tts_stream_state = {"stop": stop, "done": done}
if _voice_mode_enabled() and _voice_cfg_dict().get("barge_in", True):
threading.Thread(
target=_tts_stream_barge_in_monitor, args=(stop, done), daemon=True
).start()
return text_queue
def _tts_stream_stop() -> None:
"""Barge-in: cut any in-flight streaming TTS (new turn, interrupt, /voice off)."""
global _tts_stream_state
with _tts_stream_lock:
state, _tts_stream_state = _tts_stream_state, None
if state is None:
return
state["stop"].set()
try:
from tools.voice_mode import stop_playback
stop_playback()
except Exception:
pass
def _tts_stream_barge_in_monitor(stop: threading.Event, done: threading.Event) -> None:
"""VAD barge-in: cut streaming TTS when the user starts talking.
Playback is cut at the moment of detection while the monitor keeps
capturing (with pre-roll) until the user goes quiet — the interruption is
then transcribed and emitted as ``voice.transcript``, which the TUI
submits like any spoken turn. Without capture the opening words would be
lost between detection and the next recording start.
"""
try:
from tools.voice_mode import listen_for_speech, stop_playback, transcribe_recording
barged = threading.Event()
def _cut_playback():
if not done.is_set():
barged.set()
stop.set()
stop_playback()
_voice_emit("voice.interrupted")
wav_path = listen_for_speech(
lambda: stop.is_set() or done.is_set(),
capture=True,
on_trigger=_cut_playback,
)
if not (wav_path and barged.is_set()):
return
try:
result = transcribe_recording(wav_path)
text = (result.get("transcript") or "").strip() if result.get("success") else ""
if text:
_voice_emit("voice.transcript", {"text": text})
finally:
try:
os.unlink(wav_path)
except OSError:
pass
except Exception as e:
logger.debug("TTS barge-in monitor failed: %s", e)
def _voice_cfg_dict() -> dict:
"""Shape-safe accessor for the ``voice:`` block in config.yaml.
@@ -15599,8 +15713,10 @@ def _(rid, params: dict) -> dict:
except Exception as e:
logger.warning("voice: stop_continuous failed during toggle off: %s", e)
# Clear TTS so it can be toggled independently after voice is off.
# Clear TTS so it can be toggled independently after voice is off,
# and silence any in-flight streaming speech.
os.environ["HERMES_VOICE_TTS"] = "0"
_tts_stream_stop()
return _ok(
rid,
@@ -15617,6 +15733,8 @@ def _(rid, params: dict) -> dict:
new_value = not _voice_tts_enabled()
# Runtime-only flag (CLI parity) — see voice.toggle on/off above.
os.environ["HERMES_VOICE_TTS"] = "1" if new_value else "0"
if not new_value:
_tts_stream_stop()
# Include ``record_key`` on every branch so a /voice tts toggle
# doesn't reset the TUI's cached shortcut to the default when a
# user has a custom binding configured (Copilot review, round 2