- Backend: FastAPI + SQLAlchemy + Celery (Python 3.11+) - Frontend: Vue 3 + TypeScript + Pinia + Tailwind - Admin Frontend: separate Vue 3 app for management - Docker Compose: 9 services orchestration - Specs: design prototypes, memory system PRD, product roadmap Cleanup performed: - Removed temporary debug scripts from backend root - Removed deprecated admin_app.py (embedded UI) - Removed duplicate docs from admin-frontend - Updated .gitignore for Vite cache and egg-info
150 lines
4.6 KiB
Python
150 lines
4.6 KiB
Python
"""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()
|