2026-04-04 23:56:18 +08:00
|
|
|
|
import asyncio
|
|
|
|
|
|
import base64
|
|
|
|
|
|
import logging
|
2026-04-11 09:24:14 +08:00
|
|
|
|
import os
|
2026-04-07 23:38:23 +08:00
|
|
|
|
from io import BytesIO
|
|
|
|
|
|
from typing import Optional
|
2026-04-04 23:56:18 +08:00
|
|
|
|
|
2026-04-11 09:24:14 +08:00
|
|
|
|
# 设置 Hugging Face 镜像源为国内镜像
|
|
|
|
|
|
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
|
|
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
|
import torch
|
|
|
|
|
|
from fastapi import APIRouter, HTTPException
|
2026-04-04 23:56:18 +08:00
|
|
|
|
from pydantic import BaseModel
|
2026-04-06 11:14:09 +08:00
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
|
logger = logging.getLogger(__name__)
|
2026-04-06 11:14:09 +08:00
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
|
# New TTS model import
|
|
|
|
|
|
try:
|
|
|
|
|
|
from qwen_tts import Qwen3TTSModel # type: ignore
|
|
|
|
|
|
except Exception: # pragma: no cover
|
|
|
|
|
|
Qwen3TTSModel = None # type: ignore
|
2026-04-06 11:14:09 +08:00
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
|
router = APIRouter()
|
2026-04-06 11:14:09 +08:00
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
|
# Global TTS model instance
|
|
|
|
|
|
_tts_model: Optional["Qwen3TTSModel"] = None
|
2026-04-06 11:14:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
|
def _get_device_map() -> str:
|
|
|
|
|
|
"""设备检测逻辑:优先 CUDA,其次 MPS,最后 CPU"""
|
|
|
|
|
|
if torch.cuda.is_available():
|
|
|
|
|
|
return "cuda:0"
|
2026-04-05 13:42:29 +08:00
|
|
|
|
try:
|
2026-04-06 11:14:09 +08:00
|
|
|
|
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
|
|
|
|
|
return "mps"
|
2026-04-11 09:24:14 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.debug("MPS check failed: %s", e)
|
2026-04-07 23:38:23 +08:00
|
|
|
|
return "cpu"
|
2026-04-05 13:42:29 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
|
async def _warmup_tts():
|
|
|
|
|
|
"""预热 TTS 模型"""
|
|
|
|
|
|
await asyncio.to_thread(_load_tts_model_with_retry)
|
2026-04-05 13:42:29 +08:00
|
|
|
|
|
2026-04-06 11:14:09 +08:00
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
|
async def _warmup_all():
|
|
|
|
|
|
"""预热所有模型(TTS 和 ASR)"""
|
|
|
|
|
|
logger.info("[Warmup] 开始预热 TTS 模型...")
|
|
|
|
|
|
await _warmup_tts()
|
|
|
|
|
|
logger.info("[Warmup] TTS 模型预热完成")
|
2026-04-04 23:56:18 +08:00
|
|
|
|
|
2026-04-05 13:42:29 +08:00
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
|
def _load_tts_model_with_retry(max_retries: int = 3) -> "Qwen3TTSModel":
|
|
|
|
|
|
"""加载 TTS 模型,支持多个镜像源"""
|
|
|
|
|
|
global _tts_model
|
|
|
|
|
|
if _tts_model is not None:
|
|
|
|
|
|
return _tts_model
|
|
|
|
|
|
if Qwen3TTSModel is None:
|
|
|
|
|
|
raise RuntimeError("qwen_tts 库未安装,无法加载 TTS 模型")
|
2026-04-05 13:42:29 +08:00
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
|
candidates = [
|
|
|
|
|
|
"Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign",
|
|
|
|
|
|
"ModelScope/Qwen3-TTS-12Hz-1.7B-VoiceDesign",
|
|
|
|
|
|
]
|
|
|
|
|
|
device_map = _get_device_map()
|
|
|
|
|
|
last_err = None
|
|
|
|
|
|
for i, model_id in enumerate(candidates, start=1):
|
2026-04-06 11:14:09 +08:00
|
|
|
|
try:
|
2026-04-07 23:38:23 +08:00
|
|
|
|
_tts_model = Qwen3TTSModel.from_pretrained(
|
|
|
|
|
|
model_id,
|
|
|
|
|
|
device_map=device_map,
|
|
|
|
|
|
dtype=torch.float16,
|
|
|
|
|
|
attn_implementation="flash_attention_2",
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info("Loaded TTS model from %s", model_id)
|
|
|
|
|
|
return _tts_model
|
2026-04-06 11:14:09 +08:00
|
|
|
|
except Exception as e:
|
2026-04-07 23:38:23 +08:00
|
|
|
|
logger.warning("Failed to load TTS model from %s: %s", model_id, e)
|
|
|
|
|
|
last_err = e
|
|
|
|
|
|
if i >= max_retries:
|
|
|
|
|
|
break
|
|
|
|
|
|
raise RuntimeError(f"Unable to load TTS model from sources: {candidates}") from last_err
|
2026-04-05 13:42:29 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-04 23:56:18 +08:00
|
|
|
|
class TTSRequest(BaseModel):
|
|
|
|
|
|
text: str
|
2026-04-07 23:38:23 +08:00
|
|
|
|
instruct: str = ""
|
|
|
|
|
|
speaker: str = "Vivian"
|
2026-04-04 23:56:18 +08:00
|
|
|
|
format: str = "wav"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TTSResponse(BaseModel):
|
|
|
|
|
|
audio_base64: str
|
|
|
|
|
|
format: str
|
|
|
|
|
|
duration_ms: int
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-05 13:42:29 +08:00
|
|
|
|
class ModelStatus(BaseModel):
|
|
|
|
|
|
tts_loaded: bool
|
2026-04-07 23:38:23 +08:00
|
|
|
|
asr_loaded: bool = False
|
2026-04-05 13:42:29 +08:00
|
|
|
|
device: str
|
|
|
|
|
|
tts_last_used: Optional[float] = None
|
|
|
|
|
|
asr_last_used: Optional[float] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
|
def _ensure_model() -> "Qwen3TTSModel":
|
|
|
|
|
|
"""确保模型已加载"""
|
|
|
|
|
|
global _tts_model
|
|
|
|
|
|
if _tts_model is None:
|
|
|
|
|
|
_tts_model = _load_tts_model_with_retry()
|
|
|
|
|
|
return _tts_model
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/status", response_model=ModelStatus)
|
|
|
|
|
|
async def get_status():
|
|
|
|
|
|
"""获取模型状态"""
|
|
|
|
|
|
return ModelStatus(
|
|
|
|
|
|
tts_loaded=_tts_model is not None,
|
|
|
|
|
|
asr_loaded=False,
|
|
|
|
|
|
device=_get_device_map(),
|
|
|
|
|
|
)
|
2026-04-04 23:56:18 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-06 11:14:09 +08:00
|
|
|
|
@router.get("/config")
|
2026-04-07 23:38:23 +08:00
|
|
|
|
async def get_config():
|
|
|
|
|
|
"""获取配置信息"""
|
2026-04-06 11:14:09 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"model": {
|
2026-04-07 23:38:23 +08:00
|
|
|
|
"tts": "Qwen3-TTS-12Hz-1.7B-VoiceDesign",
|
|
|
|
|
|
"asr": None,
|
2026-04-06 11:14:09 +08:00
|
|
|
|
},
|
2026-04-07 23:38:23 +08:00
|
|
|
|
"device": _get_device_map(),
|
2026-04-06 11:14:09 +08:00
|
|
|
|
"status": {
|
2026-04-07 23:38:23 +08:00
|
|
|
|
"tts_loaded": _tts_model is not None,
|
|
|
|
|
|
"asr_loaded": False,
|
2026-04-06 11:14:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-05 13:42:29 +08:00
|
|
|
|
@router.post("/warmup")
|
2026-04-07 23:38:23 +08:00
|
|
|
|
async def warmup_models():
|
|
|
|
|
|
"""手动触发模型预热"""
|
|
|
|
|
|
await _warmup_tts()
|
2026-04-05 13:42:29 +08:00
|
|
|
|
return {
|
2026-04-07 23:38:23 +08:00
|
|
|
|
"tts_warmup": _tts_model is not None,
|
|
|
|
|
|
"device": _get_device_map(),
|
2026-04-05 13:42:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
|
@router.post("/tts", response_model=TTSResponse)
|
|
|
|
|
|
async def tts_endpoint(req: TTSRequest):
|
|
|
|
|
|
"""TTS 文字转语音端点"""
|
2026-04-04 23:56:18 +08:00
|
|
|
|
try:
|
2026-04-07 23:38:23 +08:00
|
|
|
|
model = _ensure_model()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
2026-04-04 23:56:18 +08:00
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
|
text = req.text
|
|
|
|
|
|
instruct = req.instruct or ""
|
|
|
|
|
|
speaker = req.speaker or "Vivian"
|
2026-04-05 13:42:29 +08:00
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
|
try:
|
|
|
|
|
|
wavs_sr = model.generate_custom_voice(
|
|
|
|
|
|
text=text,
|
|
|
|
|
|
language="Chinese",
|
|
|
|
|
|
speaker=speaker,
|
|
|
|
|
|
instruct=instruct,
|
2026-04-05 13:42:29 +08:00
|
|
|
|
)
|
2026-04-04 23:56:18 +08:00
|
|
|
|
except Exception as e:
|
2026-04-07 23:38:23 +08:00
|
|
|
|
logger.exception("TTS 推理失败")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"TTS 推理失败: {e}")
|
2026-04-04 23:56:18 +08:00
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
|
# Normalize output
|
|
|
|
|
|
if isinstance(wavs_sr, tuple) and len(wavs_sr) == 2:
|
|
|
|
|
|
wav_data, sr = wavs_sr
|
|
|
|
|
|
else:
|
|
|
|
|
|
wav_data, sr = wavs_sr[0], wavs_sr[1] # type: ignore
|
2026-04-04 23:56:18 +08:00
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
|
# 编码 WAV 到内存
|
2026-04-04 23:56:18 +08:00
|
|
|
|
try:
|
2026-04-07 23:38:23 +08:00
|
|
|
|
import soundfile as sf
|
|
|
|
|
|
bio = BytesIO()
|
|
|
|
|
|
sf.write(bio, wav_data, sr, format="WAV")
|
|
|
|
|
|
audio_bytes = bio.getvalue()
|
2026-04-04 23:56:18 +08:00
|
|
|
|
except Exception as e:
|
2026-04-07 23:38:23 +08:00
|
|
|
|
logger.exception("音频编码失败")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"音频编码失败: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# 计算时长(毫秒)
|
|
|
|
|
|
duration_ms = int(len(wav_data) / sr * 1000) if sr > 0 else 0
|
|
|
|
|
|
|
|
|
|
|
|
# 返回 JSON 格式,包含 base64 编码的音频
|
|
|
|
|
|
audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
|
|
|
|
|
|
return TTSResponse(
|
|
|
|
|
|
audio_base64=audio_base64,
|
|
|
|
|
|
format="wav",
|
|
|
|
|
|
duration_ms=duration_ms,
|
|
|
|
|
|
)
|
2026-04-04 23:56:18 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def register_tts_asr_routes(app):
|
2026-04-07 23:38:23 +08:00
|
|
|
|
"""注册 TTS/ASR 路由到 FastAPI 应用"""
|
2026-04-04 23:56:18 +08:00
|
|
|
|
app.include_router(router, prefix="/v1/tts-asr")
|