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
47 changes: 43 additions & 4 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@
_ADAPTER_DISCONNECT_TIMEOUT_SECS_DEFAULT = 5.0
_TELEGRAM_COMMAND_MENTION_RE = re.compile(r"(?<![\w:/])/([A-Za-z0-9][A-Za-z0-9_-]*)")

def _env_flag(name: str, default: bool = False) -> bool:
raw = os.getenv(name, "").strip().lower()
if not raw:
return default
return raw in {"1", "true", "yes", "on"}


_TELEGRAM_NOISY_STATUS_RE = re.compile(
r"(" # transient/auxiliary status that should stay in logs, not Telegram chat
r"auxiliary\s+.+\s+failed"
Expand Down Expand Up @@ -12113,12 +12120,25 @@ async def _handle_voice_channel_join(self, event: MessageEvent) -> str:
adapter._voice_text_channels[guild_id] = int(event.source.chat_id)
if hasattr(adapter, "_voice_sources"):
adapter._voice_sources[guild_id] = event.source.to_dict()
# Meeting/listen mode should not make every transcript trigger an
# agent+TTS turn. Keep text-channel replies speakable via voice_mode
# but disable adapter auto-TTS unless transcript agent turns are
# explicitly enabled.
self._voice_mode[self._voice_key(event.source.platform, event.source.chat_id)] = "all"
self._save_voice_modes()
self._set_adapter_auto_tts_enabled(adapter, event.source.chat_id, enabled=True)
transcript_agent_turns = self._voice_transcripts_trigger_agent_turns()
if transcript_agent_turns:
self._set_adapter_auto_tts_enabled(adapter, event.source.chat_id, enabled=True)
else:
self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=True)
mode_note = (
"I'll speak my replies and listen to you."
if transcript_agent_turns
else "I'll transcribe voice to this side chat without answering every utterance."
)
return (
f"Joined voice channel **{voice_channel.name}**.\n"
f"I'll speak my replies and listen to you. Use /voice leave to disconnect."
f"{mode_note} Use /voice leave to disconnect."
)
# Join failed — clear callback
adapter._voice_input_callback = None
Expand Down Expand Up @@ -12198,13 +12218,24 @@ def _is_duplicate_voice_transcript(self, guild_id: int, user_id: int, transcript
recent_store[key] = recent[-5:]
return False

def _voice_transcripts_trigger_agent_turns(self) -> bool:
"""Whether raw Discord VC transcripts should run the full agent loop.

Meeting mode should be cheap and durable: publish transcripts to the
linked side chat without replaying a growing conversation into the LLM
for every utterance. Operators can opt back into the old behavior with
HERMES_DISCORD_VOICE_TRANSCRIPT_AGENT_TURNS=1.
"""
return _env_flag("HERMES_DISCORD_VOICE_TRANSCRIPT_AGENT_TURNS", False)

async def _handle_voice_channel_input(
self, guild_id: int, user_id: int, transcript: str
):
"""Handle transcribed voice from a user in a voice channel.

Creates a synthetic MessageEvent and processes it through the
adapter's full message pipeline (session, typing, agent, TTS reply).
Always posts the transcript to the linked side chat. By default it does
not turn every utterance into a full agent request, because that makes
long meetings slower as Discord/session history grows.
"""
adapter = self.adapters.get(Platform.DISCORD)
if not adapter:
Expand Down Expand Up @@ -12253,6 +12284,14 @@ async def _handle_voice_channel_input(
except Exception:
pass

if not self._voice_transcripts_trigger_agent_turns():
logger.debug(
"Posted Discord voice transcript without agent turn for guild=%s user=%s",
guild_id,
user_id,
)
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
103 changes: 101 additions & 2 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,13 @@ def check_discord_requirements() -> bool:
return True


def _env_flag(name: str, default: bool = False) -> bool:
raw = os.getenv(name, "").strip().lower()
if not raw:
return default
return raw in {"1", "true", "yes", "on"}


def _build_allowed_mentions():
"""Build Discord ``AllowedMentions`` with safe defaults, overridable via env.

Expand Down Expand Up @@ -595,10 +602,12 @@ 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_last_reset: Dict[int, float] = {} # guild_id -> last inbound activity timer reset
# 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_callback: Optional[Callable] = None # set by run.py
self._recent_voice_transcripts: Dict[Tuple[int, int], List[Tuple[float, str]]] = {}
self._on_voice_disconnect: Optional[Callable] = None # set by run.py
# Phase 3: continuous voice mixer (ambient idle bed + ducked speech).
# Installed once per guild on join; lets acks / TTS / the "thinking"
Expand Down Expand Up @@ -2154,6 +2163,7 @@ async def leave_voice_channel(self, guild_id: int) -> None:
task = self._voice_timeout_tasks.pop(guild_id, None)
if task:
task.cancel()
self._voice_activity_last_reset.pop(guild_id, None)
self._voice_text_channels.pop(guild_id, None)
self._voice_sources.pop(guild_id, None)

Expand Down Expand Up @@ -2256,13 +2266,30 @@ def _reset_voice_timeout(self, guild_id: int) -> None:
self._voice_timeout_handler(guild_id)
)

def _note_voice_activity(self, guild_id: int, *, min_interval: float = 30.0) -> None:
"""Record inbound voice activity and keep the voice session alive.

The inactivity timeout exists to leave empty/forgotten voice channels,
but inbound speech is activity too. Previously the timer was reset on
join/playback only, so a listen-only meeting could be disconnected
after VOICE_TIMEOUT even while users were actively talking. Throttle
resets to avoid churning timeout tasks on every 200ms listen-loop tick.
"""
now = time.monotonic()
last = self._voice_activity_last_reset.get(guild_id, 0.0)
if now - last < min_interval:
return
self._voice_activity_last_reset[guild_id] = now
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:
await asyncio.sleep(self.VOICE_TIMEOUT)
except asyncio.CancelledError:
return
text_ch_id = self._voice_text_channels.get(guild_id)
self._voice_activity_last_reset.pop(guild_id, None)
await self.leave_voice_channel(guild_id)
# Notify the runner so it can clean up voice_mode state
if self._on_voice_disconnect and text_ch_id:
Expand Down Expand Up @@ -2381,6 +2408,20 @@ async def _voice_listen_loop(self, guild_id: int):
except Exception:
pass

# Any recent inbound packet means the meeting is active, even
# before an utterance reaches silence/STT. This prevents
# continuous or listen-only meetings from timing out mid-call.
try:
with receiver._lock:
recent_audio = any(
now - last_t < self._KEEPALIVE_INTERVAL
for last_t in receiver._last_packet_time.values()
)
if recent_audio:
self._note_voice_activity(guild_id)
except Exception:
pass

completed = receiver.check_silence()
# Voice inputs always originate from a specific guild
# (guild_id is in scope). Pass it so role checks are
Expand All @@ -2393,14 +2434,61 @@ async def _voice_listen_loop(self, guild_id: int):
is_dm=False,
):
continue
self._note_voice_activity(guild_id)
await self._process_voice_input(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 _voice_transcripts_trigger_agent_turns(self) -> bool:
"""Whether completed VC utterances should enter the agent pipeline."""
return _env_flag("HERMES_DISCORD_VOICE_TRANSCRIPT_AGENT_TURNS", False)

def _is_duplicate_voice_transcript(self, guild_id: int, user_id: int, transcript: str) -> bool:
"""Suppress repeated STT outputs before side-chat posting/callback."""
from difflib import SequenceMatcher

normalized = re.sub(r"\s+", " ", transcript).strip().lower()
normalized = re.sub(r"[^\w\s]", "", normalized)
if not normalized:
return False

now = time.monotonic()
window_seconds = 12.0
key = (guild_id, user_id)
recent = [
(ts, txt)
for ts, txt in self._recent_voice_transcripts.get(key, [])
if now - ts <= window_seconds
]
for _, prior in recent:
if prior == normalized:
self._recent_voice_transcripts[key] = recent
return True
if len(prior) >= 16 and len(normalized) >= 16:
if SequenceMatcher(None, prior, normalized).ratio() >= 0.95:
self._recent_voice_transcripts[key] = recent
return True
recent.append((now, normalized))
self._recent_voice_transcripts[key] = recent[-5:]
return False

async def _post_voice_transcript_to_side_chat(self, guild_id: int, user_id: int, transcript: str) -> None:
"""Post a completed voice transcript directly from the Discord adapter."""
text_ch_id = self._voice_text_channels.get(guild_id)
if not text_ch_id or not self._client:
return
try:
channel = self._client.get_channel(text_ch_id)
if channel:
safe_text = transcript[:2000].replace("@everyone", "@\u200beveryone").replace("@here", "@\u200bhere")
await channel.send(f"**[Voice]** <@{user_id}>: {safe_text}")
except Exception:
logger.debug("Failed to post Discord voice transcript", exc_info=True)

async def _process_voice_input(self, guild_id: int, user_id: int, pcm_data: bytes):
"""Convert PCM -> WAV -> STT -> callback."""
"""Convert PCM -> WAV -> STT, post transcript, and optionally callback."""
from tools.voice_mode import is_whisper_hallucination

tmp_f = tempfile.NamedTemporaryFile(suffix=".wav", prefix="vc_listen_", delete=False)
Expand All @@ -2420,7 +2508,18 @@ async def _process_voice_input(self, guild_id: int, user_id: int, pcm_data: byte

logger.info("Voice input from user %d: %s", user_id, transcript[:100])

if self._voice_input_callback:
if self._is_duplicate_voice_transcript(guild_id, user_id, transcript):
logger.info(
"Suppressing duplicate voice transcript for guild=%s user=%s: %s",
guild_id,
user_id,
transcript[:100],
)
return

await self._post_voice_transcript_to_side_chat(guild_id, user_id, transcript)

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.

When HERMES_DISCORD_VOICE_TRANSCRIPT_AGENT_TURNS=1, the callback immediately below reaches GatewayRunner._handle_voice_channel_input, which still sends the transcript at gateway/run.py:13061-13066. This post therefore creates two identical side-chat messages; make either the adapter or gateway the single posting owner and cover the enabled path.


if self._voice_input_callback and self._voice_transcripts_trigger_agent_turns():
await self._voice_input_callback(
guild_id=guild_id,
user_id=user_id,
Expand Down
Loading