mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
fix(matrix): add MSC3245 duration + MSC1767 waveform metadata to voice sends
Salvaged from PR #68063 (base commit, runner-path hunks superseded by the platform-aware OPUS_VOICE_PLATFORMS fix in this branch). Element and other Matrix clients render voice bubbles more reliably when m.audio events carry duration and waveform metadata; probe both best-effort via ffprobe/ffmpeg.
This commit is contained in:
parent
4aac89b429
commit
34ee3bbf8e
2 changed files with 122 additions and 6 deletions
|
|
@ -52,11 +52,15 @@ Environment variables:
|
|||
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
|
||||
|
|
@ -135,6 +139,87 @@ from gateway.platforms.helpers import ThreadParticipationTracker
|
|||
|
||||
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
|
||||
|
||||
_MATRIX_BANG_COMMAND_RE = re.compile(
|
||||
r"^!([A-Za-z][A-Za-z0-9_-]*)(?=$|\s)(.*)$",
|
||||
re.DOTALL,
|
||||
|
|
@ -2293,6 +2378,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||
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:
|
||||
|
|
@ -2349,6 +2435,17 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||
# 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)
|
||||
|
||||
|
|
@ -2397,9 +2494,19 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||
fname = file_name or p.name
|
||||
ct = mimetypes.guess_type(fname)[0] or "application/octet-stream"
|
||||
data = p.read_bytes()
|
||||
voice_metadata = _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,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -323,17 +323,26 @@ class TestMatrixSendVoiceMSC3245:
|
|||
|
||||
self.adapter._client.send_message_event = mock_send_message_event
|
||||
|
||||
await self.adapter.send_voice(
|
||||
chat_id="!room:example.org",
|
||||
audio_path=temp_path,
|
||||
caption="Test voice",
|
||||
)
|
||||
with patch(
|
||||
"plugins.platforms.matrix.adapter._matrix_voice_metadata_for_file",
|
||||
return_value={"duration": 1234, "waveform": [0, 512, 1024]},
|
||||
):
|
||||
await self.adapter.send_voice(
|
||||
chat_id="!room:example.org",
|
||||
audio_path=temp_path,
|
||||
caption="Test voice",
|
||||
)
|
||||
|
||||
assert sent_content is not None, "No message was sent"
|
||||
assert "org.matrix.msc3245.voice" in sent_content, \
|
||||
f"MSC3245 voice field missing from content: {sent_content.keys()}"
|
||||
assert sent_content["msgtype"] == "m.audio"
|
||||
assert sent_content["info"]["mimetype"] == "audio/ogg"
|
||||
assert sent_content["info"]["duration"] == 1234
|
||||
assert sent_content["org.matrix.msc1767.audio"] == {
|
||||
"duration": 1234,
|
||||
"waveform": [0, 512, 1024],
|
||||
}
|
||||
assert self.upload_call is not None, "Expected upload_media() to be called"
|
||||
assert isinstance(self.upload_call["data"], bytes)
|
||||
assert self.upload_call["mime_type"] == "audio/ogg"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue