mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(discord): make voice timeouts configurable
This commit is contained in:
parent
388f612435
commit
ae8d3e2027
5 changed files with 279 additions and 80 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue