Skip to content

Commit 28bb0e7

Browse files
teknium10xbyt4
andauthored
fix(voice): enable TTS voice reply when streaming is active (NousResearch#2322)
When streaming is enabled, the base adapter receives None from _handle_message (already_sent=True) and cannot run auto-TTS for voice input. The runner was unconditionally skipping voice input TTS assuming the base adapter would handle it. Now the runner takes over TTS responsibility when streaming has already delivered the text response, so voice channel playback works with both streaming on and off. Streaming off behavior is unchanged (default already_sent=False preserves the original code path exactly). Co-authored-by: 0xbyt4 <35742124+0xbyt4@users.noreply.github.com>
1 parent 06f4df5 commit 28bb0e7

2 files changed

Lines changed: 60 additions & 12 deletions

File tree

gateway/run.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2248,7 +2248,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
22482248
)
22492249

22502250
# Auto voice reply: send TTS audio before the text response
2251-
if self._should_send_voice_reply(event, response, agent_messages):
2251+
_already_sent = bool(agent_result.get("already_sent"))
2252+
if self._should_send_voice_reply(event, response, agent_messages, already_sent=_already_sent):
22522253
await self._send_voice_reply(event, response)
22532254

22542255
# If streaming already delivered the response, return None so
@@ -3054,6 +3055,7 @@ def _should_send_voice_reply(
30543055
event: MessageEvent,
30553056
response: str,
30563057
agent_messages: list,
3058+
already_sent: bool = False,
30573059
) -> bool:
30583060
"""Decide whether the runner should send a TTS voice reply.
30593061
@@ -3062,8 +3064,9 @@ def _should_send_voice_reply(
30623064
- response is empty or an error
30633065
- agent already called text_to_speech tool (dedup)
30643066
- voice input and base adapter auto-TTS already handled it (skip_double)
3065-
Exception: Discord voice channel — base play_tts is a no-op there,
3066-
so the runner must handle VC playback.
3067+
UNLESS streaming already consumed the response (already_sent=True),
3068+
in which case the base adapter won't have text for auto-TTS so the
3069+
runner must handle it.
30673070
"""
30683071
if not response or response.startswith("Error:"):
30693072
return False
@@ -3093,7 +3096,10 @@ def _should_send_voice_reply(
30933096

30943097
# Dedup: base adapter auto-TTS already handles voice input
30953098
# (play_tts plays in VC when connected, so runner can skip).
3096-
if is_voice_input:
3099+
# When streaming already delivered the text (already_sent=True),
3100+
# the base adapter will receive None and can't run auto-TTS,
3101+
# so the runner must take over.
3102+
if is_voice_input and not already_sent:
30973103
return False
30983104

30993105
return True

tests/gateway/test_voice_command.py

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2467,7 +2467,8 @@ def _make_runner():
24672467
runner.adapters = {}
24682468
return runner
24692469

2470-
def _call_should_reply(self, runner, voice_mode, msg_type, response="Hello", agent_msgs=None):
2470+
def _call_should_reply(self, runner, voice_mode, msg_type, response="Hello",
2471+
agent_msgs=None, already_sent=False):
24712472
from gateway.platforms.base import MessageType, MessageEvent, SessionSource
24722473
from gateway.config import Platform
24732474
runner._voice_mode["ch1"] = voice_mode
@@ -2476,28 +2477,32 @@ def _call_should_reply(self, runner, voice_mode, msg_type, response="Hello", age
24762477
user_id="1", user_name="test", chat_type="channel",
24772478
)
24782479
event = MessageEvent(source=source, text="test", message_type=msg_type)
2479-
return runner._should_send_voice_reply(event, response, agent_msgs or [])
2480+
return runner._should_send_voice_reply(
2481+
event, response, agent_msgs or [], already_sent=already_sent,
2482+
)
2483+
2484+
# -- Streaming OFF (existing behavior, must not change) --
24802485

24812486
def test_voice_input_runner_skips(self):
2482-
"""Voice input: runner skips — base adapter handles via play_tts."""
2487+
"""Streaming OFF + voice input: runner skips — base adapter handles."""
24832488
from gateway.platforms.base import MessageType
24842489
runner = self._make_runner()
2485-
assert self._call_should_reply(runner, "all", MessageType.VOICE) is False
2490+
assert self._call_should_reply(runner, "all", MessageType.VOICE, already_sent=False) is False
24862491

24872492
def test_text_input_voice_all_runner_fires(self):
2488-
"""Text input + voice_mode=all: runner generates TTS."""
2493+
"""Streaming OFF + text input + voice_mode=all: runner generates TTS."""
24892494
from gateway.platforms.base import MessageType
24902495
runner = self._make_runner()
2491-
assert self._call_should_reply(runner, "all", MessageType.TEXT) is True
2496+
assert self._call_should_reply(runner, "all", MessageType.TEXT, already_sent=False) is True
24922497

24932498
def test_text_input_voice_off_no_tts(self):
2494-
"""Text input + voice_mode=off: no TTS."""
2499+
"""Streaming OFF + text input + voice_mode=off: no TTS."""
24952500
from gateway.platforms.base import MessageType
24962501
runner = self._make_runner()
24972502
assert self._call_should_reply(runner, "off", MessageType.TEXT) is False
24982503

24992504
def test_text_input_voice_only_no_tts(self):
2500-
"""Text input + voice_mode=voice_only: no TTS for text."""
2505+
"""Streaming OFF + text input + voice_mode=voice_only: no TTS for text."""
25012506
from gateway.platforms.base import MessageType
25022507
runner = self._make_runner()
25032508
assert self._call_should_reply(runner, "voice_only", MessageType.TEXT) is False
@@ -2523,6 +2528,43 @@ def test_agent_tts_tool_dedup(self):
25232528
]}]
25242529
assert self._call_should_reply(runner, "all", MessageType.TEXT, agent_msgs=agent_msgs) is False
25252530

2531+
# -- Streaming ON (already_sent=True) --
2532+
2533+
def test_streaming_on_voice_input_runner_fires(self):
2534+
"""Streaming ON + voice input: runner handles TTS (base adapter has no text)."""
2535+
from gateway.platforms.base import MessageType
2536+
runner = self._make_runner()
2537+
assert self._call_should_reply(runner, "all", MessageType.VOICE, already_sent=True) is True
2538+
2539+
def test_streaming_on_text_input_runner_fires(self):
2540+
"""Streaming ON + text input: runner handles TTS (same as before)."""
2541+
from gateway.platforms.base import MessageType
2542+
runner = self._make_runner()
2543+
assert self._call_should_reply(runner, "all", MessageType.TEXT, already_sent=True) is True
2544+
2545+
def test_streaming_on_voice_off_no_tts(self):
2546+
"""Streaming ON + voice_mode=off: no TTS regardless of streaming."""
2547+
from gateway.platforms.base import MessageType
2548+
runner = self._make_runner()
2549+
assert self._call_should_reply(runner, "off", MessageType.VOICE, already_sent=True) is False
2550+
2551+
def test_streaming_on_empty_response_no_tts(self):
2552+
"""Streaming ON + empty response: no TTS."""
2553+
from gateway.platforms.base import MessageType
2554+
runner = self._make_runner()
2555+
assert self._call_should_reply(runner, "all", MessageType.VOICE, response="", already_sent=True) is False
2556+
2557+
def test_streaming_on_agent_tts_dedup(self):
2558+
"""Streaming ON + agent called TTS: runner skips (dedup still works)."""
2559+
from gateway.platforms.base import MessageType
2560+
runner = self._make_runner()
2561+
agent_msgs = [{"role": "assistant", "tool_calls": [
2562+
{"id": "1", "type": "function", "function": {"name": "text_to_speech", "arguments": "{}"}}
2563+
]}]
2564+
assert self._call_should_reply(
2565+
runner, "all", MessageType.VOICE, agent_msgs=agent_msgs, already_sent=True,
2566+
) is False
2567+
25262568

25272569
class TestUDPKeepalive:
25282570
"""UDP keepalive prevents Discord from dropping the voice session."""

0 commit comments

Comments
 (0)