Files
2026-08-21 16:00:13 +08:00

326 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""MiMo TTS adapter — ``POST /v1/chat/completions`` with ``audio`` parameter.
MiMo 的 TTS 不走独立音频端点,而是通过聊天补全接口扩展实现:
POST {base_url}/chat/completions
Content-Type: application/json
Authorization: Bearer {api_key}
{
"model": "mimo-v2.5-tts",
"messages": [
{"role": "user", "content": "(可选)音色风格描述,voicedesign 必填"},
{"role": "assistant", "content": "要朗读的文本"}
],
"audio": {
"format": "wav",
"voice": "mimo_default"
}
}
响应::
{
"choices": [{
"message": {
"audio": {
"data": "<base64 编码的音频数据>",
"id": "...",
"expires_at": null,
"transcript": null
}
}
}]
}
模型差异:
- ``mimo-v2.5-tts`` — ``voice`` 可选,默认 ``mimo_default``,仅支持预置音色
- ``mimo-v2.5-tts-voiceclone`` — ``voice`` 必填,传入音频样本的 base64(mp3/wav)
- ``mimo-v2.5-tts-voicedesign`` — 不支持 ``voice``,\n ``user`` 消息必填(风格描述),支持 ``optimize_text_preview``
"""
from __future__ import annotations
import base64
import hashlib
import time
import httpx
from app.ai_provider.audio.base import (
AudioAdapterBase,
AudioServiceError,
TTSProtocol,
TTSRequest,
TTSResponse,
)
from app.core.logging import logger
# voicedesign 模型的默认风格描述(用户未提供时使用)
_DEFAULT_VOICE_DESIGN_PROMPT = "自然、清晰的朗读风格,语气平和,语速适中,适合日常对话场景。"
# TTS 音频缓存:默认最多 256 条,TTL 1 小时
_TTS_CACHE_MAX_SIZE = 256
_TTS_CACHE_TTL_SECONDS = 3600
class _TTSCacheEntry:
__slots__ = ("data", "format", "stored_at")
def __init__(self, data: bytes, format: str) -> None:
self.data = data
self.format = format
self.stored_at = time.monotonic()
_tts_cache: dict[str, _TTSCacheEntry] = {}
def _tts_cache_key(model: str, voice: str, fmt: str, text: str, extra_json: str) -> str:
"""生成 TTS 缓存键:SHA256(model + voice + format + text + extra)。"""
raw = f"{model}\0{voice}\0{fmt}\0{text}\0{extra_json}"
return hashlib.sha256(raw.encode()).hexdigest()
def _tts_cache_get(key: str) -> _TTSCacheEntry | None:
entry = _tts_cache.get(key)
if entry is None:
return None
if time.monotonic() - entry.stored_at > _TTS_CACHE_TTL_SECONDS:
del _tts_cache[key]
return None
return entry
def _tts_cache_set(key: str, entry: _TTSCacheEntry) -> None:
if len(_tts_cache) >= _TTS_CACHE_MAX_SIZE:
# 淘汰最旧的一半
sorted_keys = sorted(_tts_cache.keys(), key=lambda k: _tts_cache[k].stored_at)
for old_key in sorted_keys[: _TTS_CACHE_MAX_SIZE // 2]:
del _tts_cache[old_key]
_tts_cache[key] = entry
class HunjingTTSOpenAI(AudioAdapterBase, TTSProtocol):
"""MiMo TTS 适配器,通过 ``POST /chat/completions`` 携带 ``audio`` 参数实现。
调用兼容 OpenAI 格式的 chat completions 端点,在请求体中添加 ``audio`` 字段,
然后从响应中提取并解码 base64 音频数据。
"""
_adapter_type = "tts"
_default_timeout = 120
def __init__(
self,
*,
base_url: str,
api_key: str,
model: str = "mimo-v2.5-tts",
request_timeout_seconds: int = 120,
) -> None:
self._base_url = base_url.rstrip("/")
self._api_key = api_key
self._model = model
self._timeout = request_timeout_seconds
self._client: httpx.AsyncClient | None = None
# ── 客户端 ───────────────────────────────────────────────────────────
@property
def _http(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
return self._client
async def close(self) -> None:
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
# ── TTSProtocol ──────────────────────────────────────────────────────
async def synthesize(self, request: TTSRequest) -> TTSResponse:
"""通过 ``POST /chat/completions`` 将文本合成为音频。
构造 chat 消息和 ``audio`` 参数,匹配 MiMo 兼容 OpenAI 格式的 TTS,
然后从响应中解码 base64 音频。
结果按 ``(model, voice, format, text)`` 在进程内缓存,避免重复 API 调用。
"""
# ── 缓存查找 ──────────────────────────────────────────────────────
cache_key = _tts_cache_key(
model=self._model,
voice=request.voice,
fmt=request.response_format,
text=request.text,
extra_json=str(sorted((request.extra or {}).items())),
)
cached = _tts_cache_get(cache_key)
if cached is not None:
logger.info("audio_tts.cache_hit", model=self._model, text_length=len(request.text))
return TTSResponse(audio_data=cached.data, format=cached.format)
# ── API 调用 ──────────────────────────────────────────────────────
url = f"{self._base_url}/chat/completions"
# 构造 messages
messages = self._build_messages(request)
# 构造 audio 参数
audio_params = self._build_audio_params(request)
payload: dict = {
"model": self._model,
"messages": messages,
"audio": audio_params,
"max_completion_tokens": 8192,
}
# 合并 extra(如 frequency_penalty, temperature 等)
if request.extra:
payload.update(request.extra)
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
logger.info(
"audio_tts.request",
model=self._model,
voice=request.voice,
audio_format=request.response_format,
text_length=len(request.text),
url=url,
)
try:
response = await self._http.post(url, json=payload, headers=headers)
except httpx.TimeoutException as exc:
raise AudioServiceError(
status_code=504,
code="audio_tts_timeout",
message="TTS request timed out calling chat completions.",
) from exc
except httpx.RequestError as exc:
raise AudioServiceError(
status_code=503,
code="audio_tts_connection_error",
message=f"TTS connection error: {exc}",
) from exc
if response.status_code != 200:
body = response.text[:500]
raise AudioServiceError(
status_code=response.status_code,
code="audio_tts_api_error",
message=f"TTS API returned {response.status_code}: {body}",
)
# 解析响应,提取 base64 音频
audio_data = self._extract_audio(response.json())
# ── 写入缓存 ──────────────────────────────────────────────────────
_tts_cache_set(cache_key, _TTSCacheEntry(audio_data, request.response_format))
logger.info(
"audio_tts.success",
model=self._model,
audio_bytes=len(audio_data),
cache_size=len(_tts_cache),
)
return TTSResponse(audio_data=audio_data, format=request.response_format)
# ── 辅助方法 ──────────────────────────────────────────────────────────
def _build_messages(self, request: TTSRequest) -> list[dict]:
"""构造 chat messages 数组。
- voicedesign 模型:user 消息必填(风格描述)
- 其他模型:user 消息可选,assistant 消息存放待朗读文本
"""
is_voicedesign = "voicedesign" in self._model
if is_voicedesign:
# voicedesign:user 消息为风格提示,assistant 消息为朗读文本
style_prompt = (request.extra or {}).pop("style_prompt", _DEFAULT_VOICE_DESIGN_PROMPT)
return [
{"role": "user", "content": style_prompt},
{"role": "assistant", "content": request.text},
]
# 基础 TTS / voiceclone:只传 assistant 消息
return [
{"role": "assistant", "content": request.text},
]
def _build_audio_params(self, request: TTSRequest) -> dict:
"""构造 ``audio`` 参数字段,按模型处理 voice/format/optimize_text_preview 等。"""
params: dict = {
"format": _resolve_api_format(request.response_format),
}
is_voicedesign = "voicedesign" in self._model
if is_voicedesign:
# voicedesign 不支持 voice 字段
optimize = (request.extra or {}).pop("optimize_text_preview", None)
if optimize is not None:
params["optimize_text_preview"] = optimize
else:
# 基础 TTS / voiceclone:按需传 voice
if request.voice and request.voice != "default":
params["voice"] = request.voice
else:
params["voice"] = "mimo_default"
return params
@staticmethod
def _extract_audio(body: dict) -> bytes:
"""从 chat completion 响应中提取并解码 base64 音频数据。"""
try:
choices = body.get("choices", [])
if not choices:
raise AudioServiceError(
status_code=500,
code="audio_tts_no_choices",
message="TTS response has no choices.",
)
message = choices[0].get("message", {})
audio_info = message.get("audio")
if not audio_info:
raise AudioServiceError(
status_code=500,
code="audio_tts_no_audio_field",
message="TTS response missing audio field in message.",
)
raw = audio_info.get("data")
if not raw:
raise AudioServiceError(
status_code=500,
code="audio_tts_empty_audio_data",
message="TTS response has empty audio.data.",
)
return base64.b64decode(raw)
except AudioServiceError:
raise
except Exception as exc:
raise AudioServiceError(
status_code=500,
code="audio_tts_response_parse_error",
message=f"Failed to parse TTS response: {exc}",
) from exc
def _resolve_api_format(user_format: str) -> str:
"""将用户层面的格式名映射为 API 期望的格式。
MiMo TTS API 接受:``wav``、``mp3``、``pcm``、``pcm16``。
"""
fmt = user_format.lower()
if fmt in ("pcm", "pcm16"):
return "pcm16"
if fmt in ("wav", "mp3"):
return fmt
return "wav"