From ae8d3e20278be497f606d1298839b7214e6f38e4 Mon Sep 17 00:00:00 2001 From: Jeffrey Cox Date: Sun, 21 Jun 2026 22:15:53 -0400 Subject: [PATCH] fix(discord): make voice timeouts configurable --- hermes_cli/config.py | 7 + plugins/platforms/discord/adapter.py | 255 +++++++++++++------ tests/gateway/test_discord_voice_mixer.py | 2 + tests/gateway/test_voice_command.py | 91 ++++++- website/docs/user-guide/messaging/discord.md | 4 + 5 files changed, 279 insertions(+), 80 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 72f8a55a812..4f72ba6ff35 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2716,6 +2716,13 @@ DEFAULT_CONFIG = { # override: DISCORD_APPROVAL_MENTIONS. Default false avoids surprise # pings. "approval_mentions": False, + # Discord voice-channel inactivity timeout, in seconds. Set to 0 to + # keep the bot in VC until an explicit `/voice leave` / disconnect. + "voice_channel_inactivity_timeout_seconds": 300, + # Minimum seconds to wait for a VC playback before force-stopping it. + # The adapter also probes clip duration and extends this floor by a + # padding window, so long TTS readbacks are not cut at exactly 120s. + "voice_playback_timeout_seconds": 120, # Voice-channel audio effects (the continuous mixer). OFF by default. # When enabled, the bot installs a software mixer on the outgoing voice # stream so a low ambient "thinking" bed, verbal acknowledgements, and diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index e5d75d793f9..0e613408527 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -898,8 +898,14 @@ class DiscordAdapter(BasePlatformAdapter): supports_code_blocks = True # Discord markdown renders fenced code blocks natively splits_long_messages = True # send() chunks via truncate_message(MAX_MESSAGE_LENGTH) - # Auto-disconnect from voice channel after this many seconds of inactivity + # Auto-disconnect from voice channel after this many seconds of inactivity. + # Config key: discord.voice_channel_inactivity_timeout_seconds (0 disables) VOICE_TIMEOUT = 300 + # Minimum seconds to wait for a single voice playback. The effective limit + # scales with the probed clip duration so long readbacks are not cut off at + # a hard two-minute ceiling. + PLAYBACK_TIMEOUT = 120 + PLAYBACK_TIMEOUT_PADDING = 30 def __init__(self, config: PlatformConfig): super().__init__(config, Platform.DISCORD) @@ -919,6 +925,8 @@ class DiscordAdapter(BasePlatformAdapter): self._voice_text_channels: Dict[int, int] = {} # guild_id -> text_channel_id self._voice_sources: Dict[int, Dict[str, Any]] = {} # guild_id -> linked text channel source metadata self._voice_timeout_tasks: Dict[int, asyncio.Task] = {} # guild_id -> timeout task + self._voice_timeout_seconds = self._load_voice_timeout() + self._playback_timeout_seconds = self._load_playback_timeout() # Phase 2: voice listening self._voice_receivers: Dict[int, VoiceReceiver] = {} # guild_id -> VoiceReceiver self._voice_listen_tasks: Dict[int, asyncio.Task] = {} # guild_id -> listen loop @@ -3759,6 +3767,83 @@ class DiscordAdapter(BasePlatformAdapter): logger.debug("Could not load discord.voice_fx config: %s", e) return defaults + def _load_discord_int_config(self, key: str, default: int, *, minimum: int = 0) -> int: + """Read a non-secret integer from the top-level ``discord`` config.""" + try: + from hermes_cli.config import read_raw_config + cfg = read_raw_config() or {} + raw = (cfg.get("discord") or {}).get(key, default) + value = int(raw) + return max(minimum, value) + except Exception as e: + logger.debug("Could not load discord.%s config: %s", key, e) + return default + + def _load_voice_timeout(self) -> int: + """Return voice-channel inactivity timeout seconds; 0 disables it.""" + return self._load_discord_int_config( + "voice_channel_inactivity_timeout_seconds", + self.VOICE_TIMEOUT, + minimum=0, + ) + + def _load_playback_timeout(self) -> int: + """Return minimum playback wait seconds for Discord VC audio.""" + return self._load_discord_int_config( + "voice_playback_timeout_seconds", + self.PLAYBACK_TIMEOUT, + minimum=1, + ) + + def _voice_timeout_limit(self) -> int: + return int(getattr(self, "_voice_timeout_seconds", self.VOICE_TIMEOUT)) + + def _playback_timeout_limit(self) -> int: + return int(getattr(self, "_playback_timeout_seconds", self.PLAYBACK_TIMEOUT)) + + def _probe_audio_duration_seconds(self, audio_path: str) -> Optional[float]: + """Best-effort audio duration probe used to size playback timeouts.""" + try: + import importlib + mutagen = importlib.import_module("mutagen") + audio = mutagen.File(audio_path) + length = getattr(getattr(audio, "info", None), "length", None) + if length: + return float(length) + except Exception: + pass + + try: + proc = subprocess.run( + [ + "ffprobe", + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + audio_path, + ], + check=False, + capture_output=True, + text=True, + timeout=5, + stdin=subprocess.DEVNULL, + ) + if proc.returncode == 0: + raw = (proc.stdout or "").strip() + if raw: + return float(raw) + except Exception: + pass + return None + + async def _playback_timeout_for_audio(self, audio_path: str) -> float: + """Return timeout for this clip: configured floor or duration+padding.""" + floor = float(self._playback_timeout_limit()) + duration = await asyncio.to_thread(self._probe_audio_duration_seconds, audio_path) + if not duration or duration <= 0: + return floor + return max(floor, duration + float(self.PLAYBACK_TIMEOUT_PADDING)) + def _get_ambient_pcm(self) -> Optional[bytes]: """Return decoded 48k/stereo/s16le PCM for the ambient idle bed. @@ -3991,9 +4076,6 @@ class DiscordAdapter(BasePlatformAdapter): 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 - async def play_in_voice_channel(self, guild_id: int, audio_path: str) -> bool: """Play an audio file in the connected voice channel. @@ -4006,78 +4088,85 @@ class DiscordAdapter(BasePlatformAdapter): if not vc or not vc.is_connected(): return False - # ── Mixer path (overlap + ducking) ────────────────────────────── - mixer = getattr(self, "_voice_mixers", {}).get(guild_id) if getattr(self, "_voice_mixers", None) else None - if mixer is not None: - try: - from voice_mixer import decode_to_pcm - except ImportError: - from .voice_mixer import decode_to_pcm - pcm = await asyncio.to_thread(decode_to_pcm, audio_path) - if pcm: - speech_gain = float(self._voice_fx_cfg.get("speech_gain", 1.0)) - mixer.play_speech(self._lead_silence_bytes() + pcm, gain=speech_gain) - # Block until the speech child drains so callers serialise - # replies (mirrors legacy semantics) but the ambient keeps - # playing underneath the whole time. - wait_start = time.monotonic() - while mixer.speech_active: - if time.monotonic() - wait_start > self.PLAYBACK_TIMEOUT: - logger.warning("Mixer speech playback timed out after %ds", self.PLAYBACK_TIMEOUT) - mixer.stop_speech() - break - await asyncio.sleep(0.05) - self._reset_voice_timeout(guild_id) - return True - logger.warning("Mixer decode failed for %s; falling back to legacy playback", audio_path) - - # ── Legacy one-shot path (no mixer) ───────────────────────────── - # Pause voice receiver while playing (echo prevention) - receiver = self._voice_receivers.get(guild_id) - if receiver: - receiver.pause() - + # Playback is activity. Do not let the inactivity timer disconnect the + # bot while duration probing, decoding, or speaking; re-arm it when this + # attempt finishes, even if decoding/playback raises. + self._cancel_voice_timeout(guild_id) try: - # Wait for current playback to finish (with timeout) - wait_start = time.monotonic() - while vc.is_playing(): - if time.monotonic() - wait_start > self.PLAYBACK_TIMEOUT: - logger.warning("Timed out waiting for previous playback to finish") - vc.stop() - break - await asyncio.sleep(0.1) + playback_timeout = await self._playback_timeout_for_audio(audio_path) - done = asyncio.Event() - loop = asyncio.get_running_loop() + # ── Mixer path (overlap + ducking) ────────────────────────────── + mixer = getattr(self, "_voice_mixers", {}).get(guild_id) if getattr(self, "_voice_mixers", None) else None + if mixer is not None: + try: + from voice_mixer import decode_to_pcm + except ImportError: + from .voice_mixer import decode_to_pcm + pcm = await asyncio.to_thread(decode_to_pcm, audio_path) + if pcm: + speech_gain = float(self._voice_fx_cfg.get("speech_gain", 1.0)) + mixer.play_speech(self._lead_silence_bytes() + pcm, gain=speech_gain) + # Block until the speech child drains so callers serialise + # replies (mirrors legacy semantics) but the ambient keeps + # playing underneath the whole time. + wait_start = time.monotonic() + while mixer.speech_active: + if time.monotonic() - wait_start > playback_timeout: + logger.warning("Mixer speech playback timed out after %.1fs", playback_timeout) + mixer.stop_speech() + break + await asyncio.sleep(0.05) + return True + logger.warning("Mixer decode failed for %s; falling back to legacy playback", audio_path) - def _after(error): - if error: - logger.error("Voice playback error: %s", error) - loop.call_soon_threadsafe(done.set) - - # Prepend a short lead of silence so the voice socket's warm-up - # doesn't clip the first word (mirrors the mixer path above). - ffmpeg_opts: Dict[str, Any] = {} - _fx_cfg = getattr(self, "_voice_fx_cfg", None) or {} - try: - lead_ms = int(_fx_cfg.get("lead_silence_ms", 0) or 0) - except (TypeError, ValueError): - lead_ms = 0 - if lead_ms > 0: - ffmpeg_opts["options"] = f"-af adelay={lead_ms}:all=1" - source = discord.FFmpegPCMAudio(audio_path, **ffmpeg_opts) - source = discord.PCMVolumeTransformer(source, volume=1.0) - vc.play(source, after=_after) - try: - await asyncio.wait_for(done.wait(), timeout=self.PLAYBACK_TIMEOUT) - except asyncio.TimeoutError: - logger.warning("Voice playback timed out after %ds", self.PLAYBACK_TIMEOUT) - vc.stop() - self._reset_voice_timeout(guild_id) - return True - finally: + # ── Legacy one-shot path (no mixer) ───────────────────────── + # Pause voice receiver while playing (echo prevention) + receiver = self._voice_receivers.get(guild_id) if receiver: - receiver.resume() + receiver.pause() + + try: + # Wait for current playback to finish (with timeout) + wait_start = time.monotonic() + while vc.is_playing(): + if time.monotonic() - wait_start > playback_timeout: + logger.warning("Timed out waiting for previous playback to finish") + vc.stop() + break + await asyncio.sleep(0.1) + + done = asyncio.Event() + loop = asyncio.get_running_loop() + + def _after(error): + if error: + logger.error("Voice playback error: %s", error) + loop.call_soon_threadsafe(done.set) + + # Prepend a short lead of silence so the voice socket's warm-up + # doesn't clip the first word (mirrors the mixer path above). + ffmpeg_opts: Dict[str, Any] = {} + _fx_cfg = getattr(self, "_voice_fx_cfg", None) or {} + try: + lead_ms = int(_fx_cfg.get("lead_silence_ms", 0) or 0) + except (TypeError, ValueError): + lead_ms = 0 + if lead_ms > 0: + ffmpeg_opts["options"] = f"-af adelay={lead_ms}:all=1" + source = discord.FFmpegPCMAudio(audio_path, **ffmpeg_opts) + source = discord.PCMVolumeTransformer(source, volume=1.0) + vc.play(source, after=_after) + try: + await asyncio.wait_for(done.wait(), timeout=playback_timeout) + except asyncio.TimeoutError: + logger.warning("Voice playback timed out after %.1fs", playback_timeout) + vc.stop() + return True + finally: + if receiver: + receiver.resume() + finally: + self._reset_voice_timeout(guild_id) async def get_user_voice_channel(self, guild_id: int, user_id: str): """Return the voice channel the user is currently in, or None.""" @@ -4091,19 +4180,29 @@ class DiscordAdapter(BasePlatformAdapter): return None return member.voice.channel - def _reset_voice_timeout(self, guild_id: int) -> None: - """Reset the auto-disconnect inactivity timer.""" + def _cancel_voice_timeout(self, guild_id: int) -> None: task = self._voice_timeout_tasks.pop(guild_id, None) if task: task.cancel() + + def _reset_voice_timeout(self, guild_id: int) -> None: + """Reset the auto-disconnect inactivity timer.""" + self._cancel_voice_timeout(guild_id) + timeout = self._voice_timeout_limit() + if timeout <= 0: + logger.debug("Voice inactivity timeout disabled (guild=%d)", guild_id) + return self._voice_timeout_tasks[guild_id] = asyncio.ensure_future( - self._voice_timeout_handler(guild_id) + self._voice_timeout_handler(guild_id, timeout) ) - async def _voice_timeout_handler(self, guild_id: int) -> None: - """Auto-disconnect after VOICE_TIMEOUT seconds of inactivity.""" + async def _voice_timeout_handler(self, guild_id: int, timeout: Optional[int] = None) -> None: + """Auto-disconnect after the configured inactivity timeout.""" + timeout = self._voice_timeout_limit() if timeout is None else int(timeout) + if timeout <= 0: + return try: - await asyncio.sleep(self.VOICE_TIMEOUT) + await asyncio.sleep(timeout) except asyncio.CancelledError: return text_ch_id = self._voice_text_channels.get(guild_id) diff --git a/tests/gateway/test_discord_voice_mixer.py b/tests/gateway/test_discord_voice_mixer.py index a5592391148..9991785a9a3 100644 --- a/tests/gateway/test_discord_voice_mixer.py +++ b/tests/gateway/test_discord_voice_mixer.py @@ -203,6 +203,7 @@ class TestPlayInVoiceChannelMixerPath: ok = await adapter.play_in_voice_channel(111, "/tmp/x.mp3") assert ok is True mixer.play_speech.assert_called_once() + adapter._reset_voice_timeout.assert_called_once_with(111) # Legacy path must NOT have been used. vc.play.assert_not_called() @@ -232,6 +233,7 @@ class TestPlayInVoiceChannelMixerPath: ok = await adapter.play_in_voice_channel(111, "/tmp/x.mp3") # Fell through to legacy path -> vc.play called. assert vc.play.called + adapter._reset_voice_timeout.assert_called_once_with(111) class TestLeadSilence: diff --git a/tests/gateway/test_voice_command.py b/tests/gateway/test_voice_command.py index 8952a4d55ef..ef49c811926 100644 --- a/tests/gateway/test_voice_command.py +++ b/tests/gateway/test_voice_command.py @@ -1252,6 +1252,93 @@ class TestDiscordVoiceChannelMethods: result = await adapter.play_in_voice_channel(111, "/tmp/test.ogg") assert result is False + def test_voice_timeout_zero_disables_auto_leave(self): + adapter = self._make_adapter() + adapter._voice_timeout_seconds = 0 + existing_task = MagicMock() + adapter._voice_timeout_tasks[111] = existing_task + + adapter._reset_voice_timeout(111) + + existing_task.cancel.assert_called_once() + assert adapter._voice_timeout_tasks == {} + + def test_discord_voice_timeout_config_loaded(self): + from plugins.platforms.discord.adapter import DiscordAdapter + from gateway.config import PlatformConfig + + with patch("hermes_cli.config.read_raw_config", return_value={ + "discord": { + "voice_channel_inactivity_timeout_seconds": 0, + "voice_playback_timeout_seconds": 240, + } + }): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="x")) + + assert adapter._voice_timeout_seconds == 0 + assert adapter._playback_timeout_seconds == 240 + + @pytest.mark.asyncio + async def test_playback_timeout_scales_with_audio_duration(self): + adapter = self._make_adapter() + adapter._playback_timeout_seconds = 120 + adapter._probe_audio_duration_seconds = MagicMock(return_value=180.5) + + timeout = await adapter._playback_timeout_for_audio("/tmp/long.mp3") + + assert timeout == pytest.approx(210.5) + + @pytest.mark.asyncio + async def test_playback_timeout_uses_floor_when_duration_unknown(self): + adapter = self._make_adapter() + adapter._playback_timeout_seconds = 240 + adapter._probe_audio_duration_seconds = MagicMock(return_value=None) + + timeout = await adapter._playback_timeout_for_audio("/tmp/unknown.mp3") + + assert timeout == pytest.approx(240.0) + + @pytest.mark.asyncio + async def test_play_in_voice_channel_uses_duration_aware_timeout(self): + adapter = self._make_adapter() + mock_vc = MagicMock() + mock_vc.is_connected.return_value = True + mock_vc.is_playing.return_value = False + adapter._voice_clients[111] = mock_vc + adapter._playback_timeout_for_audio = AsyncMock(return_value=211.0) + adapter._cancel_voice_timeout = MagicMock() + adapter._reset_voice_timeout = MagicMock() + + def _play(_source, after): + after(None) + mock_vc.play.side_effect = _play + + with patch("plugins.platforms.discord.adapter.discord") as mock_discord: + mock_discord.FFmpegPCMAudio.return_value = MagicMock() + mock_discord.PCMVolumeTransformer.return_value = MagicMock() + result = await adapter.play_in_voice_channel(111, "/tmp/long.mp3") + + assert result is True + adapter._playback_timeout_for_audio.assert_awaited_once_with("/tmp/long.mp3") + adapter._cancel_voice_timeout.assert_called_once_with(111) + adapter._reset_voice_timeout.assert_called_once_with(111) + + @pytest.mark.asyncio + async def test_play_in_voice_channel_rearms_timeout_when_probe_fails(self): + adapter = self._make_adapter() + mock_vc = MagicMock() + mock_vc.is_connected.return_value = True + adapter._voice_clients[111] = mock_vc + adapter._playback_timeout_for_audio = AsyncMock(side_effect=RuntimeError("probe failed")) + adapter._cancel_voice_timeout = MagicMock() + adapter._reset_voice_timeout = MagicMock() + + with pytest.raises(RuntimeError, match="probe failed"): + await adapter.play_in_voice_channel(111, "/tmp/bad.mp3") + + adapter._cancel_voice_timeout.assert_called_once_with(111) + adapter._reset_voice_timeout.assert_called_once_with(111) + def test_is_allowed_user_empty_list(self): adapter = self._make_adapter() assert adapter._is_allowed_user("42") is False @@ -2114,8 +2201,8 @@ class TestPlaybackTimeout: 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 "_playback_timeout_for_audio" in source, \ + "play_in_voice_channel must use duration-aware playback timeout helper" def test_playback_timeout_constant_exists(self): """PLAYBACK_TIMEOUT constant is defined on DiscordAdapter.""" diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 3414e14414e..80bf88b716e 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -348,6 +348,8 @@ discord: limit: 100 # Global scan cap per reconnect max_dispatches: 10 # Recovery dispatch cap per reconnect channel_prompts: {} # Per-channel ephemeral system prompts + voice_channel_inactivity_timeout_seconds: 300 # Set 0 to stay in VC until explicit /voice leave + voice_playback_timeout_seconds: 120 # Minimum playback watchdog; long clips get duration+padding allow_mentions: # What the bot is allowed to ping (safe defaults) everyone: false # @everyone / @here pings (default: false) roles: false # @role pings (default: false) @@ -759,6 +761,8 @@ discord: ``` Notes: +- Set `voice_channel_inactivity_timeout_seconds: 0` if you want the bot to remain in the voice channel until an explicit `/voice leave` or manual disconnect. The default preserves the historical 300-second idle auto-leave. +- `voice_playback_timeout_seconds` is a floor, not a hard cap for long TTS. Hermes probes the generated audio duration and waits for `duration + 30s` when that is longer than the configured floor. - The acknowledgement fires at most once per turn, only when the bot is in a voice channel and the mixer is active. It uses your configured TTS provider. - `ambient_path` accepts any file `ffmpeg` can decode; it's looped seamlessly. Leave it empty to use the built-in synthesised pad (no asset needed). - All settings live in `config.yaml` (not `.env`) — they're behavioral, not secrets.