fix(discord): make voice timeouts configurable

This commit is contained in:
Jeffrey Cox 2026-06-21 22:15:53 -04:00 committed by Teknium
parent 388f612435
commit ae8d3e2027
5 changed files with 279 additions and 80 deletions

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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."""

View file

@ -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.