ForcePilot/backend/package/yuxi/channels/adapters/bluebubbles/voice.py
Kris 6aa1d52a8b feat(bluebubbles): 实现完整的BlueBubbles适配器基础组件
新增BlueBubbles适配器全套核心工具类与服务,包含会话管理、消息去重、Webhook验证、缓存系统、账号配置解析、聊天消息处理等完整功能模块,支持iMessage消息收发、群管理、反应特效、语音合成等能力,提供完善的健康检查与配置校验流程。
2026-05-12 00:42:57 +08:00

147 lines
4.4 KiB
Python

from __future__ import annotations
import os
import subprocess
import tempfile
from typing import Any, Literal
from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient
from yuxi.channels.adapters.bluebubbles.send import send_attachment_bytes
from yuxi.utils.logging_config import logger
AudioFormat = Literal["ogg", "caf", "mp3"]
AUDIO_FORMAT_MIME_MAP: dict[str, str] = {
"ogg": "audio/ogg",
"caf": "audio/x-caf",
"mp3": "audio/mpeg",
}
SYNTHESIS_TARGET = "audio-file"
PREFERRED_AUDIO_FORMAT: AudioFormat = "caf"
SUPPORTED_AUDIO_FILE_FORMATS: list[str] = ["mp3", "caf", "audio/mpeg", "audio/x-caf"]
async def send_tts_voice(
client: BlueBubblesClient,
chat_guid: str,
text: str,
reply_to_guid: str | None = None,
fmt: AudioFormat = "caf",
) -> dict[str, Any]:
audio_data = await _generate_tts_audio(text, fmt)
if not audio_data:
return {"success": False, "error": "TTS generation failed"}
extension_map: dict[str, str] = {"ogg": "voice_message.ogg", "caf": "voice_message.caf", "mp3": "voice_message.mp3"}
filename = extension_map.get(fmt, "voice_message.caf")
return await send_attachment_bytes(
client,
chat_guid,
audio_data,
filename,
caption="",
reply_to_guid=reply_to_guid,
)
async def _generate_tts_audio(text: str, fmt: AudioFormat = "caf") -> bytes:
try:
with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as tmp:
tmp_path = tmp.name
try:
result = subprocess.run(
["espeak", "-w", tmp_path, text],
capture_output=True,
timeout=30,
)
if result.returncode != 0:
raise RuntimeError(f"espeak failed: {result.stderr.decode()}")
if fmt == "ogg":
with open(tmp_path, "rb") as f:
return f.read()
return await _transcode_to_format(tmp_path, fmt)
finally:
os.unlink(tmp_path)
except FileNotFoundError:
logger.warning("[BlueBubbles] espeak not found, TTS requires espeak to be installed")
return b""
except Exception as e:
logger.error(f"[BlueBubbles] TTS generation failed: {e}")
return b""
async def _transcode_to_format(input_path: str, fmt: AudioFormat) -> bytes:
import asyncio
import importlib.util
if importlib.util.find_spec("pydub") is not None:
from pydub import AudioSegment
return await asyncio.to_thread(_transcode_pydub, input_path, fmt, AudioSegment)
logger.warning("[BlueBubbles] pydub not available, falling back to ffmpeg")
return await _transcode_ffmpeg(input_path, fmt)
def _transcode_pydub(input_path: str, fmt: AudioFormat, AudioSegment) -> bytes:
audio = AudioSegment.from_file(input_path, format="ogg")
fmt_map: dict[str, str] = {"caf": "caf", "mp3": "mp3"}
out_fmt = fmt_map.get(fmt, "caf")
with tempfile.NamedTemporaryFile(suffix=f".{out_fmt}", delete=False) as out_tmp:
out_path = out_tmp.name
try:
audio.export(out_path, format=out_fmt)
with open(out_path, "rb") as f:
return f.read()
finally:
try:
os.unlink(out_path)
except OSError:
pass
async def _transcode_ffmpeg(input_path: str, fmt: AudioFormat) -> bytes:
import asyncio
fmt_map: dict[str, str] = {
"caf": "caf",
"mp3": "mp3",
}
out_fmt = fmt_map.get(fmt, "caf")
with tempfile.NamedTemporaryFile(suffix=f".{out_fmt}", delete=False) as out_tmp:
out_path = out_tmp.name
try:
proc = await asyncio.create_subprocess_exec(
"ffmpeg",
"-y",
"-i",
input_path,
"-acodec",
"pcm_s16le" if out_fmt == "caf" else "libmp3lame",
"-ar",
"44100" if out_fmt == "caf" else "24000",
out_path,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
if proc.returncode != 0:
raise RuntimeError("ffmpeg transcoding failed")
with open(out_path, "rb") as f:
return f.read()
except FileNotFoundError:
logger.warning("[BlueBubbles] ffmpeg not found, cannot transcode")
with open(input_path, "rb") as f:
return f.read()
finally:
try:
os.unlink(out_path)
except OSError:
pass