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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,11 @@ BROWSER_INACTIVITY_TIMEOUT=120
# Named VOICE_TOOLS_OPENAI_KEY to avoid interference with OpenRouter.
# Get at: https://platform.openai.com/api-keys
# VOICE_TOOLS_OPENAI_KEY=
#
# Discord live voice transcripts are posted to the linked side text channel
# directly by the gateway by default. Set true only if every transcript snippet
# should also become an agent turn.
# HERMES_DISCORD_VOICE_TRANSCRIPT_AGENT_TURNS=false

# =============================================================================
# SLACK INTEGRATION
Expand Down
1 change: 1 addition & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,7 @@ platform_toolsets:
# reactions: true # Show processing reactions (default: true)
# history_backfill: true # Recover missed channel messages on mention (default: true)
# history_backfill_limit: 50 # Max messages to scan backwards (default: 50)
# voice_transcript_agent_turns: false # Post live voice transcripts directly; true also invokes agent per snippet

# ─────────────────────────────────────────────────────────────────────────────
# Available toolsets (use these names in platform_toolsets or the toolsets list)
Expand Down
2 changes: 2 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,8 @@ def _merge_platform_map(source_platforms: Any) -> None:
bridged["channel_prompts"] = channel_prompts
if "gateway_restart_notification" in platform_cfg:
bridged["gateway_restart_notification"] = platform_cfg["gateway_restart_notification"]
if plat == Platform.DISCORD and "voice_transcript_agent_turns" in platform_cfg:
bridged["voice_transcript_agent_turns"] = platform_cfg["voice_transcript_agent_turns"]
enabled_was_explicit = _cfg_toplevel and "enabled" in platform_cfg
if not bridged and not enabled_was_explicit:
continue
Expand Down
20 changes: 20 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -12198,6 +12198,17 @@ def _is_duplicate_voice_transcript(self, guild_id: int, user_id: int, transcript
recent_store[key] = recent[-5:]
return False

def _discord_voice_transcript_agent_turns_enabled(self) -> bool:
"""Whether Discord voice transcript snippets should invoke the agent."""
env_value = os.getenv("HERMES_DISCORD_VOICE_TRANSCRIPT_AGENT_TURNS")
if env_value is not None:
return is_truthy_value(env_value, default=False)

config = getattr(self, "config", None)
platform_cfg = getattr(config, "platforms", {}).get(Platform.DISCORD) if config else None
extra = getattr(platform_cfg, "extra", {}) if platform_cfg else {}
return is_truthy_value(extra.get("voice_transcript_agent_turns"), default=False)

async def _handle_voice_channel_input(
self, guild_id: int, user_id: int, transcript: str
):
Expand Down Expand Up @@ -12244,6 +12255,10 @@ async def _handle_voice_channel_input(
)
return

reset_timeout = getattr(adapter, "_reset_voice_timeout", None)
if callable(reset_timeout):
reset_timeout(guild_id)

# Show transcript in text channel (after auth, with mention sanitization)
try:
channel = adapter._client.get_channel(text_ch_id)
Expand All @@ -12253,6 +12268,11 @@ async def _handle_voice_channel_input(
except Exception:
pass

# LLMs/agents orchestrate reasoning-heavy work; the deterministic
# gateway runtime executes repeatable transcript posting by default.
if not self._discord_voice_transcript_agent_turns_enabled():
return

# Build a synthetic MessageEvent and feed through the normal pipeline
# Use SimpleNamespace as raw_message so _get_guild_id() can extract
# guild_id and _send_voice_reply() plays audio in the voice channel.
Expand Down
89 changes: 87 additions & 2 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ class VoiceReceiver:

SILENCE_THRESHOLD = 1.5 # seconds of silence → end of utterance
MIN_SPEECH_DURATION = 0.5 # minimum seconds to process (skip noise)
MAX_UTTERANCE_DURATION = 30.0 # bound hot-path audio buffers during long monologues
SAMPLE_RATE = 48000 # Discord native rate
CHANNELS = 2 # Discord sends stereo

Expand Down Expand Up @@ -478,7 +479,11 @@ def check_silence(self) -> list:
# 48kHz, 16-bit, stereo = 192000 bytes/sec
buf_duration = len(buf) / (self.SAMPLE_RATE * self.CHANNELS * 2)

if silence_duration >= self.SILENCE_THRESHOLD and buf_duration >= self.MIN_SPEECH_DURATION:
utterance_complete = (
silence_duration >= self.SILENCE_THRESHOLD
or buf_duration >= self.MAX_UTTERANCE_DURATION
)
if utterance_complete and buf_duration >= self.MIN_SPEECH_DURATION:
user_id = ssrc_user_map.get(ssrc, 0)
if not user_id:
# SSRC not mapped (SPEAKING event missing after bot rejoin).
Expand Down Expand Up @@ -576,6 +581,7 @@ class DiscordAdapter(BasePlatformAdapter):

# Auto-disconnect from voice channel after this many seconds of inactivity
VOICE_TIMEOUT = 300
VOICE_ACTIVITY_TIMEOUT_REFRESH = 30.0

def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.DISCORD)
Expand All @@ -595,9 +601,14 @@ 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_activity_timeout_resets: Dict[int, float] = {} # guild_id -> monotonic timestamp
# 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
self._voice_input_tasks: Dict[int, set[asyncio.Task]] = {} # guild_id -> in-flight STT/callback work
self._voice_input_semaphore: Optional[asyncio.Semaphore] = None
self._voice_input_concurrency = max(1, int(os.getenv("HERMES_DISCORD_VOICE_STT_CONCURRENCY", "3")))

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.

This creates a user-facing non-secret HERMES_* behavior knob and raises ValueError for a malformed value during adapter construction. Please source this from validated discord config.yaml instead; AGENTS.md:102-106 reserves .env for secrets.

self._voice_input_max_pending = max(1, int(os.getenv("HERMES_DISCORD_VOICE_STT_MAX_PENDING", "12")))
self._voice_input_callback: Optional[Callable] = None # set by run.py
self._on_voice_disconnect: Optional[Callable] = None # set by run.py
# Phase 3: continuous voice mixer (ambient idle bed + ducked speech).
Expand Down Expand Up @@ -2138,6 +2149,8 @@ async def leave_voice_channel(self, guild_id: int) -> None:
listen_task = self._voice_listen_tasks.pop(guild_id, None)
if listen_task:
listen_task.cancel()
for task in list(getattr(self, "_voice_input_tasks", {}).pop(guild_id, set())):
task.cancel()

# Tear down the mixer (stops the continuous outgoing stream).
if getattr(self, "_voice_mixers", None) is not None:
Expand All @@ -2154,6 +2167,9 @@ async def leave_voice_channel(self, guild_id: int) -> None:
task = self._voice_timeout_tasks.pop(guild_id, None)
if task:
task.cancel()
resets = getattr(self, "_voice_activity_timeout_resets", None)
if resets is not None:
resets.pop(guild_id, None)
self._voice_text_channels.pop(guild_id, None)
self._voice_sources.pop(guild_id, None)

Expand Down Expand Up @@ -2252,10 +2268,25 @@ def _reset_voice_timeout(self, guild_id: int) -> None:
task = self._voice_timeout_tasks.pop(guild_id, None)
if task:
task.cancel()
resets = getattr(self, "_voice_activity_timeout_resets", None)
if resets is None:
resets = {}
self._voice_activity_timeout_resets = resets
resets[guild_id] = time.monotonic()
self._voice_timeout_tasks[guild_id] = asyncio.ensure_future(
self._voice_timeout_handler(guild_id)
)

def _note_voice_activity(self, guild_id: int) -> None:
"""Refresh the inactivity timer for inbound audio without timer churn."""
resets = getattr(self, "_voice_activity_timeout_resets", None)
if resets is None:
resets = {}
self._voice_activity_timeout_resets = resets
now = time.monotonic()
if now - resets.get(guild_id, 0.0) >= self.VOICE_ACTIVITY_TIMEOUT_REFRESH:
self._reset_voice_timeout(guild_id)

async def _voice_timeout_handler(self, guild_id: int) -> None:
"""Auto-disconnect after VOICE_TIMEOUT seconds of inactivity."""
try:
Expand Down Expand Up @@ -2382,6 +2413,8 @@ async def _voice_listen_loop(self, guild_id: int):
pass

completed = receiver.check_silence()
if receiver._last_packet_time:
self._note_voice_activity(guild_id)
# Voice inputs always originate from a specific guild
# (guild_id is in scope). Pass it so role checks are
# guild-scoped and not cross-guild.
Expand All @@ -2393,12 +2426,64 @@ async def _voice_listen_loop(self, guild_id: int):
is_dm=False,
):
continue
await self._process_voice_input(guild_id, user_id, pcm_data)
self._note_voice_activity(guild_id)
self._schedule_voice_input_processing(guild_id, user_id, pcm_data)
except asyncio.CancelledError:
pass
except Exception as e:
logger.error("Voice listen loop error: %s", e, exc_info=True)

def _schedule_voice_input_processing(self, guild_id: int, user_id: int, pcm_data: bytes) -> bool:
"""Schedule PCM->WAV->STT->callback work without blocking voice polling.

Live meetings can produce completed utterances from several speakers in
the same silence-check tick. Running each STT path inline makes later
speakers wait behind earlier speakers and pauses silence detection.
Keep the listener cheap: fan out completed chunks, cap concurrency, and
bound pending work per guild so STT backlog cannot grow forever.
"""
tasks_by_guild = getattr(self, "_voice_input_tasks", None)
if tasks_by_guild is None:
tasks_by_guild = {}
self._voice_input_tasks = tasks_by_guild

tasks = tasks_by_guild.setdefault(guild_id, set())
for task in list(tasks):
if task.done():
tasks.discard(task)

max_pending = max(1, int(getattr(self, "_voice_input_max_pending", 12)))
if len(tasks) >= max_pending:
logger.warning(
"Dropping Discord voice utterance for guild=%d user=%d: STT backlog full (%d)",
guild_id, user_id, len(tasks),
)
return False

async def _runner():
semaphore = getattr(self, "_voice_input_semaphore", None)
if semaphore is None:
concurrency = max(1, int(getattr(self, "_voice_input_concurrency", 3)))
semaphore = asyncio.Semaphore(concurrency)
self._voice_input_semaphore = semaphore
async with semaphore:
await self._process_voice_input(guild_id, user_id, pcm_data)

task = asyncio.create_task(_runner())
tasks.add(task)

def _done(done_task: asyncio.Task) -> None:
tasks.discard(done_task)
try:
done_task.result()
except asyncio.CancelledError:
pass
except Exception as exc:
logger.warning("Discord voice input task failed: %s", exc, exc_info=True)

task.add_done_callback(_done)
return True

async def _process_voice_input(self, guild_id: int, user_id: int, pcm_data: bytes):
"""Convert PCM -> WAV -> STT -> callback."""
from tools.voice_mode import is_whisper_hallucination
Expand Down
16 changes: 16 additions & 0 deletions tests/gateway/test_stt_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,22 @@ def test_load_gateway_config_bridges_stt_enabled_from_config_yaml(tmp_path, monk
assert config.stt_enabled is False


def test_load_gateway_config_bridges_discord_voice_transcript_agent_turns(tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
yaml.dump({"discord": {"voice_transcript_agent_turns": True}}),
encoding="utf-8",
)

monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)

config = load_gateway_config()

assert config.platforms[Platform.DISCORD].extra["voice_transcript_agent_turns"] is True


@pytest.mark.asyncio
async def test_enrich_message_with_transcription_surfaces_path_when_stt_disabled():
from gateway.run import GatewayRunner
Expand Down
Loading