Files
auto_agent/llm_server/streaming_tts_server.py
T

375 lines
13 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.
#!/usr/bin/env python3
"""
VoxCPM 流式 TTS 服务 —— 按句分块合成,SSE 流式推送 PCM 音频。
使用 OpenBMB VoxCPM 模型(Whispera 同款),无需 Genie TTS。
配合前端 Web Audio API 实现边合成边播放,无需保存音频文件。
启动方式:
python streaming_tts_server.py --port 8902 --model-path ./models/VoxCPM
"""
import os
import sys
import json
import base64
import time
import threading
import argparse
import warnings
from contextlib import asynccontextmanager
from pathlib import Path
# 抑制 PyTorch weight_norm 弃用警告(来自 voxcpm 内部)
warnings.filterwarnings("ignore", message=".*weight_norm.*deprecated.*")
# ======================== 配置区域 ========================
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# 启用 TF32 加速(Ampere+ GPU)
import torch
torch.set_float32_matmul_precision("high")
# 确保 torchaudio 已安装(VoxCPM 参考音频需要)
try:
import torchaudio # noqa: F401
except ImportError:
pass # 无参考音频时可选
# 默认参数(可通过命令行覆盖)
DEFAULT_MODEL_PATH = os.path.join(BASE_DIR, "models", "VoxCPM1__5")
DEFAULT_PORT = 8902
DEFAULT_MODEL_NAME = "VoxCPM1.5" # 默认服务模型名(校验请求 body.model 用)
DEFAULT_API_KEY = "" # 默认空:不校验 API Key(任意值放行)
# 参考音频(用于统一音色,同一人物说的一段话 + 对应文本)
# 如果配置了这对参数,所有合成都会克隆该音色,各句音色保持一致
DEFAULT_PROMPT_WAV = os.path.join(BASE_DIR, "reference_audio.wav")
DEFAULT_PROMPT_TEXT = "日子缓缓前行,不必急于追赶,照顾好自己的心情,认真过好当下的每一刻就足够了。"
# 音频质量参数
DEFAULT_CFG_VALUE = 1.5 # 音色贴合度 (1.0~3.0),越高越贴近参考音频但可能不自然
DEFAULT_INFERENCE_STEPS = 15 # 推理步数 (4~30),越高音质越好但越慢
# =========================================================
# 请求级取消标志
_cancel_flags: dict[str, threading.Event] = {}
_cancel_lock = threading.Lock()
# 当前活跃请求 ID(新请求到达时自动取消旧请求)
_current_request_id: str | None = None
_current_req_lock = threading.Lock()
# 模型锁(VoxCPM 只允许单线程使用,新请求取消旧请求后等待锁释放)
_model_lock = threading.Lock()
def set_cancel(request_id: str) -> None:
with _cancel_lock:
evt = _cancel_flags.get(request_id)
if evt is None:
evt = threading.Event()
_cancel_flags[request_id] = evt
evt.set()
def is_cancelled(request_id: str) -> bool:
with _cancel_lock:
evt = _cancel_flags.get(request_id)
return evt is not None and evt.is_set()
def cancel_current_and_set(new_request_id: str) -> str | None:
"""取消当前活跃请求并注册新请求 ID,返回被取消的旧请求 ID。"""
global _current_request_id
with _current_req_lock:
old = _current_request_id
_current_request_id = new_request_id
if old and old != new_request_id:
set_cancel(old)
# 若旧请求持有模型锁,强制释放以让新请求立即获取
try:
if _model_lock.locked():
_model_lock.release()
except RuntimeError:
pass
print(f"[VoxCPM TTS] 打断旧请求 {old},开始新请求 {new_request_id}")
return old
# Lazy-loaded model singleton
_model = None
_singleton_lock = threading.Lock()
_model_sample_rate = 24000
def _get_model(model_path: str):
"""延迟加载 VoxCPM 模型(线程安全)。"""
global _model, _model_sample_rate
if _model is not None:
return _model, _model_sample_rate
with _singleton_lock:
if _model is not None:
return _model, _model_sample_rate
if "voxcpm" not in sys.modules:
try:
from voxcpm import VoxCPM # type: ignore
except ImportError:
raise ImportError(
"无法导入 voxcpm 模块。请确保 Whispera-main 项目的 "
"voxcpm-tts-streaming-module/src 在 sys.path 中,"
"或 pip install 了 voxcpm 包。"
)
from voxcpm import VoxCPM # type: ignore
mp = _resolve_model_path(model_path)
print(f"[VoxCPM TTS] 加载模型: {mp}")
_model = VoxCPM(
voxcpm_model_path=mp,
enable_denoiser=False,
optimize=True,
)
_model_sample_rate = int(_model.tts_model.sample_rate)
print(f"[VoxCPM TTS] 模型加载成功,采样率: {_model_sample_rate} Hz")
return _model, _model_sample_rate
def _resolve_model_path(model_path: str) -> str:
"""解析模型路径:支持多种命名格式。"""
base = Path(model_path).expanduser().resolve()
if not base.exists():
raise FileNotFoundError(f"VoxCPM 模型路径不存在: {base}")
# 1. 直接就是模型目录(有 config.json)
if (base / "config.json").is_file():
return str(base)
# 2. 检查常见子目录名
for sub in ["openbmb__VoxCPM2", "openbmb__VoxCPM1.5",
"VoxCPM2", "VoxCPM1.5", "VoxCPM1__5"]:
candidate = base / sub
if candidate.is_dir() and (candidate / "config.json").is_file():
return str(candidate)
raise FileNotFoundError(
f"在 {base} 下未找到 VoxCPM 模型(需要包含 config.json 的子目录,"
f"如 openbmb__VoxCPM1.5/、VoxCPM1__5/ 等)"
)
def _build_sse(data: dict) -> str:
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
def _check_api_key(request: Request) -> str | None:
"""校验请求 API Key:服务未配置 key(空)时放行任意值;否则校验 Authorization / X-API-Key。
返回 None 表示通过,否则返回错误提示文本。
"""
api_key = _model_args.get("api_key", "")
if not api_key:
return None
header = request.headers.get("Authorization", "")
if header.startswith("Bearer "):
provided = header[len("Bearer "):].strip()
else:
provided = request.headers.get("X-API-Key", "").strip()
if provided == api_key:
return None
return "API Key 无效或缺失"
def _check_model_name(body: dict) -> str | None:
"""校验请求 modelname:服务未配置模型名(空)时放行任意值;否则要求 body.model 匹配。
返回 None 表示通过,否则返回错误提示文本。
"""
model_name = _model_args.get("model_name", "")
if not model_name:
return None
if str(body.get("model") or "").strip() == model_name:
return None
return f"模型名不匹配: 期望 '{model_name}',收到 '{body.get('model') or ''}'"
def _stream_audio_events(text: str, request_id: str, model_path: str):
"""生成 SSE 音频流事件(同步,在独立线程中运行)。"""
import numpy as np
try:
model, sample_rate = _get_model(model_path)
except Exception as e:
yield _build_sse({"type": "audio.error", "request_id": request_id, "message": str(e)})
return
# 开始
yield _build_sse({
"type": "audio.start",
"request_id": request_id,
"sample_rate": sample_rate,
"audio_format": "pcm_f32le",
})
chunk_index = 0
total_samples = 0
# 获取模型锁(前一请求已被 cancel_current_and_set 取消,锁应已释放)
# 短超时 + 重试,避免用户等待
for attempt in range(3):
if _model_lock.acquire(timeout=3):
break
if attempt < 2:
print(f"[VoxCPM TTS] 等待模型锁释放... (尝试 {attempt + 1}/3)")
else:
yield _build_sse({"type": "audio.error", "request_id": request_id,
"message": "等待模型超时,请重试"})
return
try:
gen = model.generate_streaming(
text=text,
prompt_wav_path=DEFAULT_PROMPT_WAV or None,
prompt_text=DEFAULT_PROMPT_TEXT or None,
cfg_value=DEFAULT_CFG_VALUE,
inference_timesteps=DEFAULT_INFERENCE_STEPS,
retry_badcase=False,
)
for audio_chunk in gen:
if is_cancelled(request_id):
yield _build_sse({"type": "audio.cancelled", "request_id": request_id})
return
arr = np.asarray(audio_chunk, dtype=np.float32).ravel()
pcm_bytes = np.ascontiguousarray(arr, dtype=np.float32).tobytes()
num_samples = arr.size
total_samples += num_samples
b64_data = base64.b64encode(pcm_bytes).decode("ascii")
yield _build_sse({
"type": "audio.chunk",
"request_id": request_id,
"index": chunk_index,
"num_samples": num_samples,
"sample_rate": sample_rate,
"data": b64_data,
})
chunk_index += 1
finally:
_model_lock.release()
yield _build_sse({
"type": "audio.done",
"request_id": request_id,
"total_samples": total_samples,
"interrupted": False,
})
# ======================== FastAPI ========================
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, StreamingResponse
@asynccontextmanager
async def lifespan(app: FastAPI):
mp = _model_args.get("model_path", DEFAULT_MODEL_PATH)
print(f"[VoxCPM TTS] 服务启动中,模型路径: {mp}")
print(f"[VoxCPM TTS] 调用方式: POST /stream_tts body: {{'text': '...', 'request_id': '...'}}")
# 启动时预加载模型(避免首次请求等待)
try:
_get_model(mp)
except Exception as e:
print(f"[VoxCPM TTS] 模型预加载失败: {e}")
yield
print("[VoxCPM TTS] 服务关闭")
app = FastAPI(title="VoxCPM Streaming TTS", lifespan=lifespan)
@app.post("/stream_tts")
async def stream_tts(request: Request):
"""流式 TTS 端点:接收文本,SSE 流式返回 PCM f32le 音频块。"""
# 鉴权校验(服务配置了 API Key 时)
auth_error = _check_api_key(request)
if auth_error:
return JSONResponse(status_code=401, content={"detail": auth_error})
try:
body = await request.json()
except Exception:
form = await request.form()
body = dict(form)
# 模型名校验(服务配置了模型名时)
model_error = _check_model_name(body)
if model_error:
return JSONResponse(status_code=400, content={"detail": model_error})
text = (body.get("text") or "").strip()
request_id = body.get("request_id") or f"tts-{int(time.time() * 1000)}"
if not text:
return StreamingResponse(
iter([_build_sse({"type": "audio.error", "request_id": request_id, "message": "文本为空"})]),
media_type="text/event-stream",
)
# 新请求到达:自动取消当前活跃的旧请求
cancel_current_and_set(request_id)
return StreamingResponse(
_stream_audio_events(text, request_id, _model_args.get("model_path", DEFAULT_MODEL_PATH)),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@app.post("/cancel_tts")
async def cancel_tts(request: Request):
"""取消指定 request_id 的 TTS 合成。"""
try:
body = await request.json()
except Exception:
form = await request.form()
body = dict(form)
request_id = body.get("request_id", "")
if request_id:
set_cancel(request_id)
return {"status": "cancelled", "request_id": request_id}
return {"status": "error", "message": "缺少 request_id"}
@app.get("/health")
async def health():
return {"status": "ok"}
# ======================== 启动入口 ========================
_model_args: dict = {}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="VoxCPM Streaming TTS Server")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"服务端口(默认 {DEFAULT_PORT})")
parser.add_argument("--model-path", type=str, default=DEFAULT_MODEL_PATH, help="VoxCPM 模型目录")
parser.add_argument("--model-name", type=str, default=DEFAULT_MODEL_NAME,
help=f"服务模型名(校验请求 body.model;默认 {DEFAULT_MODEL_NAME},留空则不校验)")
parser.add_argument("--api-key", type=str, default=DEFAULT_API_KEY,
help="API Key(校验 Authorization: Bearer <key> 或 X-API-Key;默认空=任意值放行)")
args = parser.parse_args()
_model_args["model_path"] = args.model_path
_model_args["model_name"] = args.model_name
_model_args["api_key"] = args.api_key
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=args.port)