From 1753369f7c3c4797e26ec446d5892619149c9728 Mon Sep 17 00:00:00 2001 From: Gilad Bauman Date: Tue, 28 Jul 2026 09:38:45 -0700 Subject: [PATCH] fix(gateway): platform-aware auto-TTS output path for native voice bubbles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvaged from PR #62040 (@giladbau), simplified per post-#73072 main: the central _repair_ogg_container transcode makes an explicit .ogg output path sufficient — no target_platform plumbing through the TTS tool needed. Root cause (class-level): both gateway auto-TTS delivery call sites relied on the TTS tool reading HERMES_SESSION_PLATFORM to pick Ogg/Opus vs MP3, but that contextvar is cleared by _clear_session_env before the base adapter's post-handler auto-TTS block runs, so want_opus was always False on that path → MP3 → Telegram sent an audio attachment instead of a native voice bubble (#57049, #36685). The runner's _send_voice_reply had the sibling bug: it hardcoded .ogg for Telegram only, leaving Matrix and Feishu runner voice replies as MP3 (#14841, #45557). Fix: new build_auto_tts_output_path(platform) in gateway/platforms/base.py hands an explicit .ogg temp path when the platform is in the TTS tool's OPUS_VOICE_PLATFORMS set (single source of truth — telegram/matrix/feishu/ whatsapp/signal), .mp3 otherwise. Used by BOTH delivery call sites: - BasePlatformAdapter auto-TTS block (also honors the tool's success flag and cleans up requested + returned paths) - GatewayRunner._send_voice_reply (replaces the telegram-only ternary) Fixes #57049 Fixes #36685 Refs #14841 #45557 --- gateway/platforms/base.py | 54 ++++++++++++++++++++++++++++++++++++--- gateway/run.py | 16 +++++------- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 8e1b2c63848..95fba722245 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -15,6 +15,7 @@ import re import socket as _socket import subprocess import sys +import tempfile import time import uuid import weakref @@ -160,6 +161,32 @@ def should_send_media_as_audio(platform, ext: str, is_voice: bool = False) -> bo return True +def build_auto_tts_output_path(platform) -> str: + """Return a unique temp output path for gateway auto-TTS synthesis. + + Platform-awareness lives HERE (the caller knows its platform), not in the + TTS tool's ``HERMES_SESSION_PLATFORM`` contextvar — that contextvar is + cleared by ``_clear_session_env`` before the post-handler auto-TTS block + in ``BasePlatformAdapter`` runs, so relying on it always produced MP3 + (#57049, #36685). Platforms whose native voice bubbles require Ogg/Opus + (``tools.tts_tool.OPUS_VOICE_PLATFORMS`` — the single source of truth) + get an explicit ``.ogg`` path; the tool's central container repair + (``_repair_ogg_container``) then guarantees real Ogg/Opus bytes for every + provider, including MP3-only backends like Edge TTS. Everything else + keeps the MP3 default. + """ + from tools.tts_tool import OPUS_VOICE_PLATFORMS + + ext = "ogg" if _platform_name(platform) in OPUS_VOICE_PLATFORMS else "mp3" + audio_path = os.path.join( + tempfile.gettempdir(), + "hermes_voice", + f"tts_reply_{uuid.uuid4().hex[:12]}.{ext}", + ) + os.makedirs(os.path.dirname(audio_path), exist_ok=True) + return audio_path + + def utf16_len(s: str) -> int: """Count UTF-16 code units in *s*. @@ -5547,6 +5574,7 @@ class BasePlatformAdapter(ABC): # an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is # True globally and no ``/voice off`` has been issued. _tts_path = None + _tts_requested_path = None if (self._should_auto_tts_for_chat(event.source.chat_id) and event.message_type == MessageType.VOICE and text_content @@ -5558,16 +5586,29 @@ class BasePlatformAdapter(ABC): speech_text = self.prepare_tts_text(text_content) if not speech_text: raise ValueError("Empty text after markdown cleanup") + # Pass an explicit platform-aware output path: the + # HERMES_SESSION_PLATFORM contextvar the tool would + # otherwise consult is already cleared by the time + # this post-handler block runs, which silently + # produced MP3 (audio attachment, not a native + # voice bubble) on Opus platforms (#57049, #36685). + _tts_requested_path = build_auto_tts_output_path( + self.platform + ) tts_result_str = await asyncio.to_thread( - text_to_speech_tool, text=speech_text + text_to_speech_tool, + text=speech_text, + output_path=_tts_requested_path, ) tts_data = _json.loads(tts_result_str) - _tts_path = tts_data.get("file_path") + if tts_data.get("success", True): + _tts_path = tts_data.get("file_path") or _tts_requested_path except Exception as tts_err: logger.warning("[%s] Auto-TTS failed: %s", self.name, tts_err) # Play TTS audio before text (voice-first experience) _tts_caption_delivered = False + _tts_cleanup_paths = {_tts_requested_path, _tts_path} - {None} if _tts_path and Path(_tts_path).exists(): try: # Caption eligibility and payload stay on the ORIGINAL @@ -5593,8 +5634,15 @@ class BasePlatformAdapter(ABC): telegram_tts_caption and getattr(tts_result, "success", False) ) finally: + for _cleanup_path in _tts_cleanup_paths: + try: + os.remove(_cleanup_path) + except OSError: + pass + elif _tts_cleanup_paths: + for _cleanup_path in _tts_cleanup_paths: try: - os.remove(_tts_path) + os.remove(_cleanup_path) except OSError: pass diff --git a/gateway/run.py b/gateway/run.py index 8dc97579577..5628554c3b5 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2207,6 +2207,7 @@ from gateway.platforms.base import ( MessageType, _prefix_within_utf16_limit, _reply_anchor_for_event, + build_auto_tts_output_path, merge_pending_message_event, utf16_len, ) @@ -15835,7 +15836,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew async def _send_voice_reply(self, event: MessageEvent, text: str) -> None: """Generate TTS audio and send as a voice message before the text reply.""" - import uuid as _uuid audio_path = None actual_path = None try: @@ -15845,14 +15845,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if not tts_text: return - # Telegram's adapter only sends native voice bubbles for OGG/Opus. - # Other platforms keep the existing MP3 default. - audio_ext = "ogg" if event.source.platform == Platform.TELEGRAM else "mp3" - audio_path = os.path.join( - tempfile.gettempdir(), "hermes_voice", - f"tts_reply_{_uuid.uuid4().hex[:12]}.{audio_ext}", - ) - os.makedirs(os.path.dirname(audio_path), exist_ok=True) + # Platform-aware output path: platforms whose native voice + # bubbles require Ogg/Opus (OPUS_VOICE_PLATFORMS — Telegram, + # Matrix, Feishu, WhatsApp, Signal) get an explicit .ogg path; + # the TTS tool's central container repair guarantees real + # Ogg/Opus bytes for every provider. Others keep MP3. + audio_path = build_auto_tts_output_path(event.source.platform) result_json = await asyncio.to_thread( text_to_speech_tool, text=tts_text, output_path=audio_path