Skip to content
Closed
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
7 changes: 7 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2120,6 +2120,13 @@ def _ensure_hermes_home_managed(home: Path):
# real memory cost. Default 32 MiB matches the historical hardcoded
# cap. Set to 0 for no cap. Env override: DISCORD_MAX_ATTACHMENT_BYTES.
"max_attachment_bytes": 33554432,
# 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
Expand Down
229 changes: 164 additions & 65 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -733,8 +733,14 @@ class DiscordAdapter(BasePlatformAdapter):
_SPLIT_THRESHOLD = 1900 # near the 2000-char split point
supports_code_blocks = True # Discord markdown renders fenced code blocks natively

# 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)
Expand All @@ -754,6 +760,8 @@ def __init__(self, config: PlatformConfig):
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
Expand Down Expand Up @@ -2234,6 +2242,83 @@ def _load_voice_fx_config(self) -> Dict[str, Any]:
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.

Expand Down Expand Up @@ -2423,9 +2508,6 @@ 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

async def play_in_voice_channel(self, guild_id: int, audio_path: str) -> bool:
"""Play an audio file in the connected voice channel.

Expand All @@ -2438,68 +2520,75 @@ async def play_in_voice_channel(self, guild_id: int, audio_path: str) -> bool:
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:
# 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:
playback_timeout = await self._playback_timeout_for_audio(audio_path)

# ── 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(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)

# ── Legacy one-shot path (no mixer) ─────────────────────────────
# Pause voice receiver while playing (echo prevention)
receiver = self._voice_receivers.get(guild_id)
if receiver:
receiver.pause()

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(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 for current playback to finish (with timeout)
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()
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.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()

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)
await asyncio.sleep(0.1)

done = asyncio.Event()
loop = asyncio.get_running_loop()
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)
def _after(error):
if error:
logger.error("Voice playback error: %s", error)
loop.call_soon_threadsafe(done.set)

source = discord.FFmpegPCMAudio(audio_path)
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
source = discord.FFmpegPCMAudio(audio_path)
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:
if receiver:
receiver.resume()
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."""
Expand All @@ -2513,19 +2602,29 @@ async def get_user_voice_channel(self, guild_id: int, user_id: str):
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)
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
"sommerhoff@gmail.com": "andressommerhoff",
"pwnda.zhang@dbappsecurity.com.cn": "x7peeps",
"palkin.dominik@gmail.com": "skyc1e",
"namredips@gmail.com": "namredips",
"namredips@users.noreply.github.com": "namredips",
"mihabubnjevic@gmail.com": "whoislikemiha",
"m24927605@gmail.com": "m24927605",
Expand Down
2 changes: 2 additions & 0 deletions tests/gateway/test_discord_voice_mixer.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ def speech_active(self):
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()

Expand Down Expand Up @@ -232,6 +233,7 @@ async def _fast(coro, *a, **k):
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 TestPlayAckInVoice:
Expand Down
Loading
Loading