Files
auto_agent/llm_server/streaming_tts_server.py
T
2026-08-01 11:15:19 +08:00

326 lines
11 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_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 _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 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 音频块。"""
try:
body = await request.json()
except Exception:
form = await request.form()
body = dict(form)
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 模型目录")
args = parser.parse_args()
_model_args["model_path"] = args.model_path
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=args.port)