Skip to content
Closed
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
32 changes: 26 additions & 6 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -14482,20 +14482,27 @@ 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
generated_path = None
actual_path = None
try:
from tools.tts_tool import text_to_speech_tool, _strip_markdown_for_tts
from tools.tts_tool import (
_convert_to_opus,
_strip_markdown_for_tts,
text_to_speech_tool,
)

tts_text = _strip_markdown_for_tts(text[:4000])
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"
# Matrix and Telegram native voice renderers expect Ogg/Opus.
# Generate MP3 first for broad provider compatibility, then
# transcode below. Passing a .ogg path directly is unsafe for Edge
# TTS: it writes MP3 bytes to whatever path it is given.
needs_opus_voice = event.source.platform in {Platform.MATRIX, Platform.TELEGRAM}
audio_path = os.path.join(
tempfile.gettempdir(), "hermes_voice",
f"tts_reply_{_uuid.uuid4().hex[:12]}.{audio_ext}",
f"tts_reply_{_uuid.uuid4().hex[:12]}.mp3",
)
os.makedirs(os.path.dirname(audio_path), exist_ok=True)

Expand All @@ -14510,10 +14517,23 @@ async def _send_voice_reply(self, event: MessageEvent, text: str) -> None:

# Use the actual file path from result (may differ after opus conversion)
actual_path = result.get("file_path", audio_path)
generated_path = actual_path
if not result.get("success") or not os.path.isfile(actual_path):
logger.warning("Auto voice reply TTS failed: %s", result.get("error"))
return

if needs_opus_voice and not str(actual_path).lower().endswith(".ogg"):
opus_path = await asyncio.to_thread(_convert_to_opus, actual_path)
if opus_path and os.path.isfile(opus_path):
actual_path = opus_path
else:
logger.warning(
"Auto voice reply could not convert %s to Ogg/Opus for %s; "
"sending original audio",
actual_path,
event.source.platform.value,
)

adapter = self._adapter_for_source(event.source)

# If connected to a voice channel, play there instead of sending a file
Expand Down Expand Up @@ -14548,7 +14568,7 @@ async def _send_voice_reply(self, event: MessageEvent, text: str) -> None:
except Exception as e:
logger.warning("Auto voice reply failed: %s", e, exc_info=True)
finally:
for p in {audio_path, actual_path} - {None}:
for p in {audio_path, generated_path, actual_path} - {None}:
try:
os.unlink(p)
except OSError:
Expand Down
213 changes: 202 additions & 11 deletions plugins/platforms/matrix/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,15 @@
from __future__ import annotations

import asyncio
import array
import inspect
import logging
import mimetypes
import os
import re
import shutil
import subprocess
import sys
import time
from urllib.parse import urljoin, urlsplit, urlunsplit
from dataclasses import dataclass, field
Expand Down Expand Up @@ -134,6 +138,134 @@ class _TrustStateStub: # type: ignore[no-redef]

logger = logging.getLogger(__name__)

_MATRIX_VOICE_WAVEFORM_BINS = 30


def _matrix_voice_metadata_for_file(path: Path) -> Dict[str, Any]:
"""Return best-effort Matrix voice metadata for an audio file.

Matrix clients such as Element render ``m.audio`` events with
``org.matrix.msc3245.voice`` as voice bubbles. They are more reliable when
the event also includes duration and MSC1767 waveform metadata. Metadata
extraction is deliberately best-effort: media delivery must still work on
systems without ffprobe/ffmpeg.
"""
metadata: Dict[str, Any] = {}

ffprobe = shutil.which("ffprobe")
if ffprobe:
try:
result = subprocess.run(
[
ffprobe,
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(path),
],
capture_output=True,
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
if result.returncode == 0:
duration = float((result.stdout or "").strip() or 0)
if duration > 0:
metadata["duration"] = int(duration * 1000)
except Exception:
logger.debug("Matrix: failed to probe voice duration for %s", path, exc_info=True)

ffmpeg = shutil.which("ffmpeg")
if ffmpeg:
try:
result = subprocess.run(
[
ffmpeg,
"-v",
"error",
"-i",
str(path),
"-ac",
"1",
"-ar",
"8000",
"-f",
"s16le",
"-",
],
capture_output=True,
timeout=15,
stdin=subprocess.DEVNULL,
)
if result.returncode == 0 and result.stdout:
samples = array.array("h")
samples.frombytes(result.stdout)
if sys.byteorder != "little":
samples.byteswap()
if samples:
count = len(samples)
waveform = []
for idx in range(_MATRIX_VOICE_WAVEFORM_BINS):
start = idx * count // _MATRIX_VOICE_WAVEFORM_BINS
end = max(start + 1, (idx + 1) * count // _MATRIX_VOICE_WAVEFORM_BINS)
peak = max(abs(value) for value in samples[start:end])
waveform.append(min(1024, int(peak / 32767 * 1024)))
metadata["waveform"] = waveform
except Exception:
logger.debug("Matrix: failed to build voice waveform for %s", path, exc_info=True)

return metadata

def _matrix_transcode_voice_to_ogg(path: str) -> Optional[str]:
"""Best-effort transcode of an audio file to Ogg/Opus for MSC3245 delivery.

Returns the path of a NEW temporary ``.ogg`` file (caller owns cleanup), or
``None`` when ffmpeg is unavailable or fails — callers then send the
original file, matching the adapter's previous behaviour. Runs blocking
subprocess work; call via ``asyncio.to_thread`` from async code.
"""
ffmpeg = shutil.which("ffmpeg")
if not ffmpeg:
return None
import tempfile

fd, ogg_path = tempfile.mkstemp(prefix="matrix_voice_", suffix=".ogg")
os.close(fd)
try:
result = subprocess.run(
[
ffmpeg,
"-v",
"error",
"-y",
"-i",
str(path),
"-acodec",
"libopus",
"-ac",
"1",
"-b:a",
"64k",
ogg_path,
],
capture_output=True,
timeout=30,
stdin=subprocess.DEVNULL,
)
if result.returncode == 0 and os.path.getsize(ogg_path) > 0:
return ogg_path
except Exception:
logger.debug("Matrix: voice transcode to Ogg/Opus failed for %s", path, exc_info=True)
try:
os.unlink(ogg_path)
except OSError:
pass
return None


_MATRIX_BANG_COMMAND_RE = re.compile(
r"^!([A-Za-z][A-Za-z0-9_-]*)(?=$|\s)(.*)$",
re.DOTALL,
Expand Down Expand Up @@ -1993,16 +2125,47 @@ async def send_voice(
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Upload an audio file as a voice message (MSC3245 native voice)."""
return await self._send_local_file(
chat_id,
audio_path,
"m.audio",
caption,
reply_to,
metadata=metadata,
is_voice=True,
)
"""Upload an audio file as a voice message (MSC3245 native voice).

Matrix voice bubbles require Opus in an Ogg container (MSC3245), but
callers can reach this with any audio format — e.g. a model-invoked
``text_to_speech`` result routed through gateway media delivery, not
just ``_send_voice_reply``. Enforce the codec at this boundary:
transcode non-Ogg input to Ogg/Opus (best-effort — if ffmpeg is
unavailable the original file is sent unchanged, preserving the
previous behaviour).
"""
converted_path: Optional[str] = None
send_path = audio_path
if not str(audio_path).lower().endswith((".ogg", ".oga", ".opus")):
converted_path = await asyncio.to_thread(
_matrix_transcode_voice_to_ogg, audio_path
)
if converted_path:
send_path = converted_path
try:
return await self._send_local_file(
chat_id,
send_path,
"m.audio",
caption,
reply_to,
# keep the caller's basename (the temp transcode file has a
# generated name) so the event body stays meaningful
file_name=(
Path(audio_path).with_suffix(".ogg").name
if converted_path
else None
),
metadata=metadata,
is_voice=True,
)
finally:
if converted_path:
try:
os.unlink(converted_path)
except OSError:
pass

async def send_video(
self,
Expand Down Expand Up @@ -2241,6 +2404,7 @@ async def _upload_and_send(
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
is_voice: bool = False,
voice_metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Upload bytes to Matrix and send as a media message."""
if len(data) > self._max_media_bytes:
Expand Down Expand Up @@ -2297,6 +2461,17 @@ async def _upload_and_send(
# Add MSC3245 voice flag for native voice messages.
if is_voice:
msg_content["org.matrix.msc3245.voice"] = {}
duration = (voice_metadata or {}).get("duration")
waveform = (voice_metadata or {}).get("waveform")
if duration is not None:
msg_content["info"]["duration"] = duration
if duration is not None or waveform is not None:
audio_metadata: Dict[str, Any] = {}
if duration is not None:
audio_metadata["duration"] = duration
if waveform is not None:
audio_metadata["waveform"] = waveform
msg_content["org.matrix.msc1767.audio"] = audio_metadata

self._apply_relation_metadata(msg_content, reply_to=reply_to, metadata=metadata)

Expand Down Expand Up @@ -2345,9 +2520,25 @@ async def _send_local_file(
fname = file_name or p.name
ct = mimetypes.guess_type(fname)[0] or "application/octet-stream"
data = p.read_bytes()
# ffprobe/ffmpeg probing is blocking (subprocess timeouts up to 15s) —
# run it off the event loop so voice uploads never stall the adapter.
voice_metadata = (
await asyncio.to_thread(_matrix_voice_metadata_for_file, p)
if is_voice
else None
)

return await self._upload_and_send(
room_id, data, fname, ct, msgtype, caption, reply_to, metadata, is_voice
room_id,
data,
fname,
ct,
msgtype,
caption,
reply_to,
metadata,
is_voice,
voice_metadata,
)

# ------------------------------------------------------------------
Expand Down
Loading
Loading