Files
llm-in-text/backend/tts_asr.py
T
“ydy0615” 5a26dfde2a refactor: 全栈架构升级 - 风险控制、会话管理、审计日志和验证码功能
后端变更:
- 新增 risk_config.py: 风险配置数据类,支持环境变量驱动
- 新增 risk_control.py: 风险控制控制器,管理并发和预算
- 新增 session_store.py: 匿名会话存储,基于 cookie 的 session ID
- 新增 audit_store.py: API 审计日志存储,记录请求和 LLM 调用
- 新增 captcha_api.py: 验证码 API,用于验证用户操作真实性
- 新增 llm_policy.py: LLM 策略配置,管理 completion/pro/vision 模型
- main.py: 集成 middleware、risk/audit/session 模块 (+467/-7)
- job_handlers.py: LLM 执行流程重构,新增 risk/audit 集成 (+207/-4)
- llm.py: 异步客户端封装,新增 max_output_tokens 参数 (+78/-1)
- job_system.py: stream_events 逻辑优化,支持心跳检测 (+12/-4)
- pro_completions.py: SSE heartbeat 机制,防止连接超时 (+14/-4)
- prompt.py: _normalize_preferences 支持 Mapping 类型 (+13/-0)
- tts_asr.py: asyncio loop 初始化,router export (+10/-0)

前端变更:
- src/components/CaptchaComponent.vue: 新增验证码组件 (NEW)
- src/utils/cookie_policy.js: Cookie 策略工具 (NEW)
- SettingsPanel.vue: 集成验证码组件,新增安全设置部分 (+59/-0)
- MilkdownEditor.vue: 移除硬编码 API_KEY,新增 credentials (+32/-10)
- ProBlockCrepe.vue: 样式简化,移除渐变动画 (+18/-4)
- proBlockPlugin.ts: 重构 schema/serializer 引用方式,通过 Ctx 管理 (+40/-10)
- api.js: 新增 credentials,重构 headers 条件逻辑 (+50/-14)
- config.js: API 基址改为 https://api.imageteach.tech:8002 (+8/-4)
- convert.js, docsApi.js, i18n.js: 新增 credentials 和验证码 i18n (+54/-12)
- proAccept.js: 重构正则和转义处理,修复捕获组索引 (+14/-4)

配置和基础设施:
- docker-compose.yml: 新增端口映射 8001:8001 (+2/-0)
- docker/nginx.conf: 改为 307 redirect,优化代理配置 (+8/-6)
- vite.config.js: 移除 proxy 配置,直接调用远程 API (+8/-4)
- .env.example: 新增 VITE_API_BASE_URL, VITE_API_KEY (+3/-1)
- backend/.env.example: 大量 RISK_*, SESSION_*, CORS_* 配置 (+54/-0)
- pytest.ini: 扩展 coverage 范围到整个 backend,移除 fail_under (+3/-2)
- .coveragerc: 移除 fail_under = 90 (+0/-1)
- .gitignore: 新增 docker-data/ (+3/-0)
- package.json: 新增 vue3-captcha 依赖 (+3/-1)
- AGENTS.md, README.md: 更新 Docker 部署和前端网络约定 (+20/-5)
- public/sw.js: Service Worker cache 版本从 v1 升级到 v2 (+0/-1)

测试变更:
- test_main_endpoints.py: 新增 session/risk/audit reset,新增测试用例 (+63/-4)
- test_main_cancel.py: 新增 reset 调用 (+6/-0)
- test_pro_completions.py: 新增 preferences 序列化和测试 (+23/-0)

总计: 45 个文件变更,+1009/-280 行
2026-06-08 11:51:39 +08:00

497 lines
16 KiB
Python
Raw 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.
import asyncio
import base64
import io
import logging
import os
import tempfile
import wave
from typing import Optional
# 设置 Hugging Face / ModelScope 镜像源为国内镜像
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
import numpy as np
import torch
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
logger = logging.getLogger(__name__)
try:
asyncio.get_running_loop()
except RuntimeError:
asyncio.set_event_loop(asyncio.new_event_loop())
# New TTS model import
try:
from qwen_tts import Qwen3TTSModel # type: ignore
except Exception as e: # pragma: no cover
logger.debug("qwen_tts import failed (optional): %s", e)
Qwen3TTSModel = None # type: ignore
# ASR model import (MLX-based, Apple Silicon only)
try:
from mlx_audio.stt.models.qwen3_asr import ( # type: ignore
ForcedAlignerModel,
Qwen3ASRModel,
)
except Exception as e: # pragma: no cover
logger.debug("mlx_audio import failed (optional): %s", e)
Qwen3ASRModel = None # type: ignore
ForcedAlignerModel = None # type: ignore
try:
from modelscope import snapshot_download # type: ignore
except Exception as e: # pragma: no cover
logger.debug("modelscope import failed (optional): %s", e)
meta_router = APIRouter()
generation_router = APIRouter()
# Global model instances
_tts_model: Optional["Qwen3TTSModel"] = None
_asr_model: Optional[object] = None # Qwen3ASRModel or ForcedAlignerModel
_align_model: Optional[object] = None # Qwen3-ForcedAlignerModel
# Model paths for loading
MODEL_ID_HF = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
MODEL_ID_MS = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
# ModelScope ASR/ForcedAligner models (MLX 4-bit format)
ASR_MODEL_ID_MS = "aufklarer/Qwen3-ASR-0.6B-MLX-4bit"
ALIGN_MODEL_ID_MS = "aufklarer/Qwen3-ForcedAligner-0.6B-MLX"
def _get_device_map() -> str:
"""设备检测逻辑:优先 CUDA,其次 MPS,最后 CPU"""
if torch.cuda.is_available():
return "cuda:0"
try:
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
except Exception as e: # noqa: ANN001
logger.debug("MPS check failed: %s", e)
return "cpu"
def _download_model_from_modelscope() -> Optional[str]:
"""从 ModelScope 下载模型到本地缓存目录"""
try:
cache_dir = os.path.join(os.path.dirname(__file__), "models")
os.makedirs(cache_dir, exist_ok=True)
model_dir = snapshot_download(
MODEL_ID_MS,
cache_dir=cache_dir,
revision="master"
)
logger.info("ModelScope 模型下载完成: %s", model_dir)
return model_dir
except Exception as e: # noqa: ANN001
logger.warning("ModelScope 下载失败: %s", e)
return None
async def _warmup_tts():
"""预热 TTS 模型"""
await asyncio.to_thread(_load_tts_model_with_retry)
async def _warmup_asr():
"""预热 ASR 模型(从 ModelScope 下载并加载)"""
await asyncio.to_thread(_load_asr_models)
async def _warmup_all():
"""预热所有模型(TTS 和 ASR"""
logger.info("[Warmup] 开始预热 TTS 模型...")
await _warmup_tts()
logger.info("[Warmup] TTS 模型预热完成")
if Qwen3ASRModel is not None:
logger.info("[Warmup] 开始预热 ASR 模型...")
await _warmup_asr()
logger.info("[Warmup] ASR 模型预热完成")
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 模型")
device_map = _get_device_map()
last_err = None
# 策略1: 尝试从 ModelScope 下载后加载
for attempt in range(max_retries):
try:
logger.info("尝试从 ModelScope 下载 TTS 模型...")
model_path = _download_model_from_modelscope()
if model_path and os.path.isdir(model_path):
_tts_model = Qwen3TTSModel.from_pretrained( # type: ignore
model_path,
device_map=device_map,
dtype=torch.float16,
)
logger.info("ModelScope TTS 模型加载成功: %s", model_path)
return _tts_model
except Exception as e: # noqa: ANN001
logger.warning("ModelScope TTS 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e)
last_err = e
# 策略2: 尝试从 HuggingFace 镜像加载
for attempt in range(max_retries):
try:
logger.info("尝试从 HuggingFace 镜像加载 TTS...")
_tts_model = Qwen3TTSModel.from_pretrained( # type: ignore
MODEL_ID_HF,
device_map=device_map,
dtype=torch.float16,
)
logger.info("HuggingFace TTS 模型加载成功")
return _tts_model
except Exception as e: # noqa: ANN001
logger.warning("HuggingFace TTS 加载失败 (尝试 %d/%d): %s", attempt + 1, max_retries, e)
last_err = e
raise RuntimeError(f"无法加载 TTS 模型: {last_err}") from last_err
def _load_asr_models() -> None:
"""从 ModelScope 下载并加载 ASR/ForcedAligner MLX 模型"""
global _asr_model, _align_model
if snapshot_download is None:
logger.warning("modelscope 未安装,跳过 ASR 模型加载")
return
if Qwen3ASRModel is None:
logger.warning("mlx_audio 未安装,跳过 ASR 模型加载")
return
# Download and load ASR model from ModelScope
try:
logger.info("从 ModelScope 下载 ASR 模型...")
asr_cache_dir = os.path.join(os.path.dirname(__file__), "models", "asr")
asr_model_dir = snapshot_download(ASR_MODEL_ID_MS, cache_dir=asr_cache_dir)
_load_asr_from_path(asr_model_dir)
except Exception as e: # noqa: ANN001
logger.warning("ASR ModelScope 下载失败,尝试 hf-mirror: %s", e)
try:
_load_asr_from_hf_mirror()
except Exception as e2: # noqa: ANN001
logger.warning("ASR hf-mirror 加载失败,跳过 ASR: %s", e2)
# Download and load ForcedAligner model from ModelScope
try:
logger.info("从 ModelScope 下载 ForcedAligner 模型...")
align_cache_dir = os.path.join(os.path.dirname(__file__), "models", "aligner")
align_model_dir = snapshot_download(ALIGN_MODEL_ID_MS, cache_dir=align_cache_dir)
_load_align_from_path(align_model_dir)
except Exception as e: # noqa: ANN001
logger.warning("ForcedAligner ModelScope 下载失败,尝试 hf-mirror: %s", e)
try:
_load_align_from_hf_mirror()
except Exception as e2: # noqa: ANN001
logger.warning("ForcedAligner hf-mirror 加载失败,跳过: %s", e2)
def _load_asr_from_path(model_dir: str) -> None:
"""从本地路径加载 ASR MLX 模型"""
global _asr_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
model = stt_load(model_dir)
_asr_model = model
logger.info("ASR 模型加载成功 (路径: %s)", model_dir)
except Exception as e: # noqa: ANN001
logger.warning("ASR MLX 加载失败,尝试直接构建: %s", e)
try:
from mlx.core import load as mx_load # type: ignore
weights = mx_load(os.path.join(model_dir, "model.safetensors"))
from mlx_lm import load as lm_load # type: ignore
model = lm_load(model_dir, model_cls=Qwen3ASRModel)
_asr_model = model
except Exception as e2: # noqa: ANN001
raise RuntimeError(f"无法加载 ASR MLX 模型: {e2}") from e
def _load_asr_from_hf_mirror() -> None:
"""从 hf-mirror 加载 ASR MLX 模型"""
global _asr_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
model = stt_load("mlx-community/Qwen3-ASR-0.6B-4bit")
_asr_model = model
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法从 hf-mirror 加载 ASR MLX: {e}") from e
def _load_align_from_path(model_dir: str) -> None:
"""从本地路径加载 ForcedAligner MLX 模型"""
global _align_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
model = stt_load(model_dir)
_align_model = model
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法加载 ForcedAligner MLX 模型 (路径: {model_dir}): {e}") from e
def _load_align_from_hf_mirror() -> None:
"""从 hf-mirror 加载 ForcedAligner MLX 模型"""
global _align_model
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
model = stt_load("mlx-community/Qwen3-ForcedAligner-0.6B-4bit")
_align_model = model
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法从 hf-mirror 加载 ForcedAligner MLX: {e}") from e
class TTSRequest(BaseModel):
text: str
instruct: str = ""
speaker: str = "Vivian"
format: str = "wav"
class TTSResponse(BaseModel):
audio_base64: str
format: str
duration_ms: int
class ASRRequest(BaseModel):
audio_base64: str
language: Optional[str] = "zh-CN"
class ASRResponse(BaseModel):
text: str
language: Optional[str] = None
class ModelStatus(BaseModel):
tts_loaded: bool
asr_loaded: bool = False
device: str
def _ensure_tts_model() -> "Qwen3TTSModel":
"""确保 TTS 模型已加载"""
global _tts_model
if _tts_model is None:
_tts_model = _load_tts_model_with_retry()
return _tts_model
def _ensure_asr_model():
"""确保 ASR 模型已加载(懒加载)"""
global _asr_model
if _asr_model is None:
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
_asr_model = stt_load(ASR_MODEL_ID_MS)
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法加载 ASR MLX 模型 (路径: {ASR_MODEL_ID_MS}): {e}") from e
return _asr_model
def _ensure_align_model():
"""确保 ForcedAligner 模型已加载(懒加载)"""
global _align_model
if _align_model is None:
try:
from mlx_audio.stt.utils import load as stt_load # type: ignore
_align_model = stt_load(ALIGN_MODEL_ID_MS)
except Exception as e: # noqa: ANN001
raise RuntimeError(f"无法加载 ForcedAligner MLX 模型 (路径: {ALIGN_MODEL_ID_MS}): {e}") from e
return _align_model
@meta_router.get("/status", response_model=ModelStatus)
async def get_status():
"""获取模型状态"""
return ModelStatus(
tts_loaded=_tts_model is not None,
asr_loaded=_asr_model is not None,
device=_get_device_map(),
)
@meta_router.get("/config")
async def get_config():
"""获取配置信息"""
return {
"model": {
"tts": MODEL_ID_MS,
"asr": ASR_MODEL_ID_MS if Qwen3ASRModel is not None else None,
},
"device": _get_device_map(),
"status": {
"tts_loaded": _tts_model is not None,
"asr_loaded": _asr_model is not None,
}
}
@meta_router.post("/warmup")
async def warmup_models():
"""手动触发模型预热"""
await _warmup_tts()
if Qwen3ASRModel is not None:
await _warmup_asr()
return {
"tts_warmup": _tts_model is not None,
"asr_warmup": _asr_model is not None if Qwen3ASRModel else False,
"device": _get_device_map(),
}
async def generate_tts_response(
text: str,
instruct: str = "",
speaker: str = "Vivian",
output_format: str = "wav",
) -> TTSResponse:
del speaker # current model path does not expose multi-speaker routing
del output_format # current implementation always returns wav
try:
model = _ensure_tts_model()
except Exception as e: # noqa: ANN001
raise HTTPException(status_code=500, detail=str(e))
try:
wavs, sr = model.generate_voice_design( # type: ignore
text=text,
language="Chinese",
instruct=instruct or "",
)
except Exception as e: # noqa: ANN001
logger.exception("TTS 推理失败")
raise HTTPException(status_code=500, detail=f"TTS 推理失败: {e}")
wav_data = wavs[0] if isinstance(wavs, (list, tuple)) else wavs
if hasattr(wav_data, 'numpy'): # type: ignore
wav_data = wav_data.cpu().numpy() # type: ignore
wav_data = np.asarray(wav_data, dtype=np.float32)
tmp_path = None
try:
import soundfile as sf # type: ignore
fd, tmp_path = tempfile.mkstemp(suffix=".wav")
os.close(fd) # type: ignore
sf.write(tmp_path, wav_data, sr)
with open(tmp_path, "rb") as f: # noqa: SIM115
audio_bytes = f.read()
except Exception as e: # noqa: ANN001
logger.exception("音频编码失败")
raise HTTPException(status_code=500, detail=f"音频编码失败: {e}")
finally:
if tmp_path and os.path.exists(tmp_path): # noqa: SIM201
try:
os.unlink(tmp_path)
except Exception:
pass
duration_ms = int(len(wav_data) / sr * 1000) if sr > 0 else 0
audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
return TTSResponse(audio_base64=audio_base64, format="wav", duration_ms=duration_ms)
async def generate_asr_response(audio_bytes: bytes, language: Optional[str] = "zh-CN") -> ASRResponse:
if Qwen3ASRModel is None:
raise HTTPException(status_code=501, detail="mlx_audio 未安装,ASR 功能不可用")
try:
model = _ensure_asr_model()
except Exception as e: # noqa: ANN001
raise HTTPException(status_code=500, detail=f"ASR 模型加载失败: {e}")
try:
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf: # noqa: SIM115
n_channels = wf.getnchannels()
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
n_frames = wf.getnframes()
raw_data = wf.readframes(n_frames)
audio_array = np.frombuffer(raw_data, dtype=np.int16 if sampwidth == 2 else np.float32)
if n_channels > 1:
audio_array = np.mean(audio_array.reshape(-1, n_channels), axis=1)
if framerate != 16000:
try:
import scipy.signal as signal # type: ignore
n_samples = int(len(audio_array) * 16000 / framerate)
audio_array = signal.resample(audio_array, n_samples) # type: ignore
except Exception as e2: # noqa: ANN001
logger.warning("重采样失败,使用原始音频: %s", e2)
if audio_array.dtype == np.int16:
audio_array = audio_array.astype(np.float32) / 32768.0
result = model.generate( # type: ignore
audio_array,
language=language if language else None,
)
recognized_text = getattr(result, 'text', str(result)) if hasattr(result, 'text') else str(result)
detected_lang = getattr(result, 'language', language or "zh-CN")
if isinstance(detected_lang, list) and len(detected_lang) > 0:
detected_lang = detected_lang[0]
return ASRResponse(text=recognized_text, language=str(detected_lang))
except HTTPException:
raise
except Exception as e: # noqa: ANN001
logger.exception("ASR 推理失败")
raise HTTPException(status_code=500, detail=f"ASR 推理失败: {e}")
@generation_router.post("/tts", response_model=TTSResponse)
async def tts_endpoint(req: TTSRequest):
"""TTS 文字转语音端点"""
return await generate_tts_response(
text=req.text,
instruct=req.instruct or "",
speaker=req.speaker,
output_format=req.format,
)
@generation_router.post("/asr", response_model=ASRResponse)
async def asr_endpoint(req: ASRRequest):
"""语音识别端点(非流式)"""
audio_bytes = base64.b64decode(req.audio_base64)
return await generate_asr_response(audio_bytes, req.language if req.language else None)
def register_tts_asr_routes(app, include_generation_routes: bool = True):
"""注册 TTS/ASR 路由到 FastAPI 应用"""
app.include_router(meta_router, prefix="/v1/tts-asr")
if include_generation_routes:
app.include_router(generation_router, prefix="/v1/tts-asr")
router = APIRouter()
router.include_router(meta_router)
router.include_router(generation_router)