Skip to content
Open
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
52 changes: 44 additions & 8 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -12830,6 +12830,34 @@ def _should_echo_stt_transcripts(self) -> bool:
"""Return whether inbound voice/STT transcripts should be echoed to chat."""
return bool(getattr(self.config, "stt_echo_transcripts", True))

def _discord_voice_reply_max_chars(self) -> int:
"""Return the spoken-text cap for Discord voice-channel replies.

The full assistant reply is still delivered as normal text by the
gateway. This cap only limits the companion TTS clip so a long Discord
voice-channel turn does not monopolize playback or run into the playback
safety timeout. ``0`` disables this extra clamp and falls back to the
provider/input cap applied below.
"""
default = 1200
try:
cfg = _load_gateway_config() or {}
raw = (cfg.get("discord") or {}).get("voice_reply_max_chars", default)
value = int(raw)
except (TypeError, ValueError):
return default
return max(0, value)

@staticmethod
def _clamp_spoken_voice_reply(text: str, max_chars: int) -> str:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Negative values currently become 0, which disables the clamp despite only 0 being documented as the opt-out. Treat negative values as invalid and fall back to 1200; add a regression test for -1.

"""Clamp TTS text with an audible pointer to the full text reply."""
if max_chars <= 0 or len(text) <= max_chars:
return text
notice = " … I’ll continue in text."
if max_chars <= len(notice):
return text[:max_chars].rstrip()
return text[: max_chars - len(notice)].rstrip() + notice

async def _send_voice_reply(self, event: MessageEvent, text: str) -> None:
"""Generate TTS audio and send as a voice message before the text reply."""
import uuid as _uuid
Expand All @@ -12838,7 +12866,21 @@ async def _send_voice_reply(self, event: MessageEvent, text: str) -> None:
try:
from tools.tts_tool import text_to_speech_tool, _strip_markdown_for_tts

adapter = self._adapter_for_source(event.source)
guild_id = self._get_guild_id(event)
playing_in_discord_vc = (
event.source.platform == Platform.DISCORD
and guild_id
and hasattr(adapter, "play_in_voice_channel")
and hasattr(adapter, "is_in_voice_channel")
and adapter.is_in_voice_channel(guild_id)
)

tts_text = _strip_markdown_for_tts(text[:4000])
if playing_in_discord_vc:
tts_text = self._clamp_spoken_voice_reply(
tts_text, self._discord_voice_reply_max_chars()
)
if not tts_text:
return

Expand Down Expand Up @@ -12866,14 +12908,8 @@ async def _send_voice_reply(self, event: MessageEvent, text: str) -> None:
logger.warning("Auto voice reply TTS failed: %s", result.get("error"))
return

adapter = self._adapter_for_source(event.source)

# If connected to a voice channel, play there instead of sending a file
guild_id = self._get_guild_id(event)
if (guild_id
and hasattr(adapter, "play_in_voice_channel")
and hasattr(adapter, "is_in_voice_channel")
and adapter.is_in_voice_channel(guild_id)):
# If connected to a Discord voice channel, play there instead of sending a file.
if playing_in_discord_vc:
await adapter.play_in_voice_channel(guild_id, actual_path)
elif adapter and hasattr(adapter, "send_voice"):
reply_anchor = self._reply_anchor_for_event(event)
Expand Down
6 changes: 6 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2359,6 +2359,12 @@ def _ensure_hermes_home_managed(home: Path):
"history_backfill_limit": 50, # Max number of recent messages to scan when assembling the backfill block
"reactions": True, # Add πŸ‘€/βœ…/❌ reactions to messages during processing
"channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads)
# Discord voice-channel TTS guardrails. The full assistant response is
# still sent as text; voice_reply_max_chars caps only the spoken
# companion clip so long replies do not monopolize VC playback. Set 0
# to disable this extra clamp. playback timeout remains a safety guard.
"voice_reply_max_chars": 1200,
"voice_playback_timeout_seconds": 300,
# Opt-in DM role-based auth (#12136). By default, DISCORD_ALLOWED_ROLES
# authorizes only guild messages in the role's own guild β€” DMs require
# DISCORD_ALLOWED_USERS. Set dm_role_auth_guild to a guild ID to also
Expand Down
42 changes: 35 additions & 7 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,7 @@ def __init__(self, config: PlatformConfig):
self._voice_mixers: Dict[int, Any] = {} # guild_id -> VoiceMixer
self._ambient_pcm_cache: Optional[bytes] = None # decoded ambient bed
self._voice_fx_cfg: Dict[str, Any] = self._load_voice_fx_config()
self._playback_timeout: float = self._load_voice_playback_timeout()
# Track threads where the bot has participated so follow-up messages
# in those threads don't require @mention. Persisted to disk so the
# set survives gateway restarts.
Expand Down Expand Up @@ -2887,8 +2888,33 @@ async def leave_voice_channel(self, guild_id: int) -> None:
self._voice_text_channels.pop(guild_id, None)
self._voice_sources.pop(guild_id, None)

# Maximum seconds to wait for voice playback before giving up
PLAYBACK_TIMEOUT = 120
# Maximum seconds to wait for voice playback before giving up. Configurable
# via discord.voice_playback_timeout_seconds; the class attr is the fallback
# for object.__new__ test doubles and old subclasses.
PLAYBACK_TIMEOUT = 300

def _load_voice_playback_timeout(self) -> float:
"""Read Discord VC playback timeout from config.yaml.

This is a behavioural setting, not a secret, so it belongs under
``discord.voice_playback_timeout_seconds``. Invalid values fall back to
the safe default instead of disabling the timeout guard.
"""
try:
from hermes_cli.config import read_raw_config
cfg = read_raw_config() or {}
raw = (cfg.get("discord") or {}).get("voice_playback_timeout_seconds")
if raw is None:
return float(self.PLAYBACK_TIMEOUT)
timeout = float(raw)
if timeout > 0:
return timeout
except Exception as e:
logger.debug("Could not load discord.voice_playback_timeout_seconds: %s", e)
return float(self.PLAYBACK_TIMEOUT)

def _voice_playback_timeout(self) -> float:
return float(getattr(self, "_playback_timeout", self.PLAYBACK_TIMEOUT))

async def play_in_voice_channel(self, guild_id: int, audio_path: str) -> bool:
"""Play an audio file in the connected voice channel.
Expand Down Expand Up @@ -2917,9 +2943,10 @@ async def play_in_voice_channel(self, guild_id: int, audio_path: str) -> bool:
# replies (mirrors legacy semantics) but the ambient keeps
# playing underneath the whole time.
wait_start = time.monotonic()
playback_timeout = self._voice_playback_timeout()
while mixer.speech_active:
if time.monotonic() - wait_start > self.PLAYBACK_TIMEOUT:
logger.warning("Mixer speech playback timed out after %ds", self.PLAYBACK_TIMEOUT)
if time.monotonic() - wait_start > playback_timeout:
logger.warning("Mixer speech playback timed out after %ds", playback_timeout)
mixer.stop_speech()
break
await asyncio.sleep(0.05)
Expand All @@ -2936,8 +2963,9 @@ async def play_in_voice_channel(self, guild_id: int, audio_path: str) -> bool:
try:
# Wait for current playback to finish (with timeout)
wait_start = time.monotonic()
playback_timeout = self._voice_playback_timeout()
while vc.is_playing():
if time.monotonic() - wait_start > self.PLAYBACK_TIMEOUT:
if time.monotonic() - wait_start > playback_timeout:
logger.warning("Timed out waiting for previous playback to finish")
vc.stop()
break
Expand All @@ -2955,9 +2983,9 @@ def _after(error):
source = discord.PCMVolumeTransformer(source, volume=1.0)
vc.play(source, after=_after)
try:
await asyncio.wait_for(done.wait(), timeout=self.PLAYBACK_TIMEOUT)
await asyncio.wait_for(done.wait(), timeout=playback_timeout)
except asyncio.TimeoutError:
logger.warning("Voice playback timed out after %ds", self.PLAYBACK_TIMEOUT)
logger.warning("Voice playback timed out after %ds", playback_timeout)
vc.stop()
self._reset_voice_timeout(guild_id)
return True
Expand Down
50 changes: 50 additions & 0 deletions tests/gateway/test_discord_voice_mixer.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ def _make_adapter(fx_cfg=None):
"ambient_gain": 0.18, "duck_gain": 0.06, "speech_gain": 1.0,
"ack_enabled": True, "ack_phrases": ["One moment."],
}
adapter._playback_timeout = 300
return adapter


Expand Down Expand Up @@ -262,3 +263,52 @@ async def test_plays_speech_when_armed(self, tmp_path):
ok = await adapter.play_ack_in_voice(111, phrase="Testing one two.")
assert ok is True
mixer.play_speech.assert_called_once()


class TestVoicePlaybackTimeoutConfig:
def test_loads_positive_timeout_from_config(self):
from plugins.platforms.discord.adapter import DiscordAdapter

adapter = object.__new__(DiscordAdapter)
with patch("hermes_cli.config.read_raw_config", return_value={
"discord": {"voice_playback_timeout_seconds": 240}
}):
assert adapter._load_voice_playback_timeout() == 240

def test_invalid_timeout_uses_safe_default(self):
from plugins.platforms.discord.adapter import DiscordAdapter

adapter = object.__new__(DiscordAdapter)
with patch("hermes_cli.config.read_raw_config", return_value={
"discord": {"voice_playback_timeout_seconds": 0}
}):
assert adapter._load_voice_playback_timeout() == DiscordAdapter.PLAYBACK_TIMEOUT

@pytest.mark.asyncio
async def test_legacy_playback_timeout_uses_configured_value_and_stops(self):
adapter = _make_adapter()
adapter._playback_timeout = 7
vc = MagicMock()
vc.is_connected.return_value = True
vc.is_playing.return_value = False
adapter._voice_clients[111] = vc
adapter._reset_voice_timeout = MagicMock()

seen = {}

async def _timeout(coro, *, timeout):
seen["timeout"] = timeout
if hasattr(coro, "close"):
coro.close()
raise asyncio.TimeoutError

import asyncio
with patch("plugins.platforms.discord.adapter.discord") as mock_discord, \
patch("asyncio.wait_for", _timeout):
mock_discord.FFmpegPCMAudio.return_value = MagicMock()
mock_discord.PCMVolumeTransformer.return_value = MagicMock()
ok = await adapter.play_in_voice_channel(111, "/tmp/x.mp3")

assert ok is True
assert seen["timeout"] == 7
vc.stop.assert_called_once()
60 changes: 58 additions & 2 deletions tests/gateway/test_voice_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,62 @@ async def test_auto_voice_reply_uses_thread_metadata_helper(self, runner):
"notify": True,
}


@pytest.mark.asyncio
async def test_discord_voice_channel_reply_clamps_spoken_tts_text(self, runner):
from gateway.config import Platform

mock_adapter = AsyncMock()
mock_adapter.is_in_voice_channel = MagicMock(return_value=True)
mock_adapter.play_in_voice_channel = AsyncMock()
event = _make_event()
event.source.platform = Platform.DISCORD
event.raw_message = SimpleNamespace(guild_id=999)
runner.adapters[event.source.platform] = mock_adapter

tts_result = json.dumps({"success": True, "file_path": "/tmp/test.mp3"})
long_text = "x" * 200

with patch("gateway.run._load_gateway_config", return_value={"discord": {"voice_reply_max_chars": 40}}), \
patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result) as mock_tts, \
patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \
patch("os.path.isfile", return_value=True), \
patch("os.unlink"), \
patch("os.makedirs"):
await runner._send_voice_reply(event, long_text)

spoken = mock_tts.call_args.kwargs["text"]
assert len(spoken) <= 40
assert spoken.endswith("text.")
mock_adapter.play_in_voice_channel.assert_called_once_with(999, "/tmp/test.mp3")
mock_adapter.send_voice.assert_not_called()

@pytest.mark.asyncio
async def test_discord_non_vc_reply_preserves_existing_tts_cap(self, runner):
from gateway.config import Platform

mock_adapter = AsyncMock()
mock_adapter.is_in_voice_channel = MagicMock(return_value=False)
mock_adapter.send_voice = AsyncMock()
event = _make_event()
event.source.platform = Platform.DISCORD
event.raw_message = SimpleNamespace(guild_id=999)
runner.adapters[event.source.platform] = mock_adapter

tts_result = json.dumps({"success": True, "file_path": "/tmp/test.mp3"})
long_text = "x" * 200

with patch("gateway.run._load_gateway_config", return_value={"discord": {"voice_reply_max_chars": 40}}), \
patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result) as mock_tts, \
patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \
patch("os.path.isfile", return_value=True), \
patch("os.unlink"), \
patch("os.makedirs"):
await runner._send_voice_reply(event, long_text)

assert mock_tts.call_args.kwargs["text"] == long_text
mock_adapter.send_voice.assert_called_once()

@pytest.mark.asyncio
async def test_empty_text_after_strip_skips(self, runner):
event = _make_event()
Expand Down Expand Up @@ -2062,8 +2118,8 @@ def test_source_has_wait_for_timeout(self):
source = inspect.getsource(DiscordAdapter.play_in_voice_channel)
assert "wait_for" in source, \
"play_in_voice_channel must use asyncio.wait_for for timeout"
assert "PLAYBACK_TIMEOUT" in source, \
"play_in_voice_channel must reference PLAYBACK_TIMEOUT constant"
assert "_voice_playback_timeout" in source, \
"play_in_voice_channel must use the configurable playback timeout helper"

def test_playback_timeout_constant_exists(self):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please replace this source-text assertion with behavioral coverage. AGENTS.md:1358 prohibits tests that read source code because they couple to implementation spelling rather than the playback-timeout contract.

"""PLAYBACK_TIMEOUT constant is defined on DiscordAdapter."""
Expand Down
12 changes: 12 additions & 0 deletions website/docs/guides/use-voice-mode-with-hermes.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,18 @@ In a Discord text channel where the bot is present:
- use a dedicated bot/testing channel at first
- verify STT and TTS work in ordinary text-chat voice mode before trying VC mode

### Long spoken replies

Discord VC replies always preserve the full assistant response in the linked text channel. The spoken companion clip is intentionally capped by default so long answers do not monopolize the voice channel or hit the playback safety timeout.

Tune these in `config.yaml` if your TTS voice is unusually fast/slow:

```yaml
discord:
voice_reply_max_chars: 1200 # spoken VC text only; 0 disables this extra clamp
voice_playback_timeout_seconds: 300 # safety timeout for one playback item
```

## Voice quality recommendations

### Best quality setup
Expand Down