fix(gateway): platform-aware auto-TTS output path for native voice bubbles

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
This commit is contained in:
Gilad Bauman 2026-07-28 09:38:45 -07:00 committed by Teknium
parent 28adb86891
commit 1753369f7c
2 changed files with 58 additions and 12 deletions

View file

@ -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

View file

@ -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