"""MiniMax 语音生成适配器 (T2A V2)。""" import time import httpx from tenacity import ( retry, retry_if_exception_type, stop_after_attempt, wait_exponential, ) from app.core.config import settings from app.core.logging import get_logger from app.services.adapters.base import AdapterConfig, BaseAdapter from app.services.adapters.registry import AdapterRegistry logger = get_logger(__name__) # MiniMax API 配置 DEFAULT_API_URL = "https://api.minimaxi.com/v1/t2a_v2" DEFAULT_MODEL = "speech-2.6-turbo" @AdapterRegistry.register("tts", "minimax") class MiniMaxTTSAdapter(BaseAdapter[bytes]): """MiniMax 语音生成适配器。 需要配置: - api_key: MiniMax API Key - minimax_group_id: 可选 (取决于使用的模型/账户类型) """ adapter_type = "tts" adapter_name = "minimax" def __init__(self, config: AdapterConfig): super().__init__(config) self.api_url = DEFAULT_API_URL async def execute( self, text: str, voice_id: str | None = None, model: str | None = None, speed: float | None = None, vol: float | None = None, pitch: int | None = None, emotion: str | None = None, **kwargs, ) -> bytes: """生成语音。""" # 1. 优先使用传入参数 # 2. 其次使用 Adapter 配置里的 default # 3. 最后使用系统默认值 model = model or self.config.model or DEFAULT_MODEL cfg = self.config.extra_config or {} voice_id = voice_id or cfg.get("voice_id") or "male-qn-qingse" speed = speed if speed is not None else (cfg.get("speed") or 1.0) vol = vol if vol is not None else (cfg.get("vol") or 1.0) pitch = pitch if pitch is not None else (cfg.get("pitch") or 0) emotion = emotion or cfg.get("emotion") group_id = kwargs.get("group_id") or settings.minimax_group_id url = self.api_url if group_id: url = f"{self.api_url}?GroupId={group_id}" payload = { "model": model, "text": text, "stream": False, "voice_setting": { "voice_id": voice_id, "speed": speed, "vol": vol, "pitch": pitch, }, "audio_setting": { "sample_rate": 32000, "bitrate": 128000, "format": "mp3", "channel": 1 } } if emotion: payload["voice_setting"]["emotion"] = emotion start_time = time.time() logger.info("minimax_generate_start", text_length=len(text), model=model) result = await self._call_api(url, payload) # 错误处理 if result.get("base_resp", {}).get("status_code") != 0: error_msg = result.get("base_resp", {}).get("status_msg", "未知错误") raise ValueError(f"MiniMax API 错误: {error_msg}") # Hex 解码 (关键逻辑,从 primary.py 迁移) hex_audio = result.get("data", {}).get("audio") if not hex_audio: raise ValueError("API 响应中未找到音频数据 (data.audio)") try: audio_bytes = bytes.fromhex(hex_audio) except ValueError: raise ValueError("MiniMax 返回的音频数据不是有效的 Hex 字符串") elapsed = time.time() - start_time logger.info( "minimax_generate_success", elapsed_seconds=round(elapsed, 2), audio_size_bytes=len(audio_bytes), ) return audio_bytes async def health_check(self) -> bool: """检查 Minimax API 是否可用。""" try: # 尝试生成极短文本 await self.execute("Hi") return True except Exception: return False @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10), retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)), reraise=True, ) async def _call_api(self, url: str, payload: dict) -> dict: """调用 API,带重试机制。""" timeout = self.config.timeout_ms / 1000 async with httpx.AsyncClient(timeout=timeout) as client: response = await client.post( url, json=payload, headers={ "Authorization": f"Bearer {self.config.api_key}", "Content-Type": "application/json", }, ) response.raise_for_status() return response.json()