VoxCPM2 TTS 服务化部署

一、项目概述

1.1 背景

Hermes Agent 支持通过 TTS provider 配置实现语音播报能力。原生 OpenAI-compatible API 方案要求调用外部服务,希望使用本地开源模型实现声音定制能力(Voice Design / Voice Cloning)。VoxCPM2 是 OpenBMB 团队开源的高质量语音合成模型,支持零样本声音克隆和自然语言音色设计。

1.2 选择 VoxCPM2 的理由

维度说明
声音定制支持零样本克隆 + 自然语言描述生成新音色(Voice Design)
开源协议Apache 2.0,商用友好
质量48kHz 高保真输出,中英语言支持良好
本地部署M5 Max 128GB 可完整运行,无需 GPU
集成方式FastAPI HTTP wrapper → OpenAI compatible API → Hermes TTS provider

1.3 项目文件结构

/Users/yuanxj/Documents/openapp/voxcpm2/
├── voxcpm_server.py          # FastAPI 主服务(634行)
├── start_server.sh           # 启动/停止脚本(132行)
├── test_config.py            # 配置测试套件(4 tests,全部通过)
├── test_web_ui.py            # Web UI 测试套件(18 tests,全部通过)
├── test_start_server.sh      # 启动脚本测试套件(10 tests)
├── .venv/                    # Python 3.11 虚拟环境
└── README.md                 # 项目说明

二、安装配置流程

2.1 环境准备

# 创建项目目录
mkdir -p /Users/yuanxj/Documents/openapp/voxcpm2
cd /Users/yuanxj/Documents/openapp/voxcpm2

# 创建 Python 3.11 虚拟环境
python3 -m venv .venv
source .venv/bin/activate

# 升级 pip
pip install --upgrade pip

2.2 安装 VoxCPM2 模型依赖

# 安装 VoxCPM Python 包(含 PyTorch)
pip install voxcpm soundfile fastapi uvicorn

# 注意:voxcpm 包会自动下载 PyTorch(约 2GB)
# M5 Max 建议用 CPU-only PyTorch 以节省内存

2.3 下载 VoxCPM2 模型权重

推荐方式:ModelScope(魔搭)— 国内加速,速度快

# 使用 ModelScope CLI 下载(替代 HuggingFace XET,后者无 token 会卡住)
pip install modelscope

# 下载模型到本地目录
modelscope download --model OpenBMB/VoxCPM2 --local_dir /tmp/voxcpm_ms/OpenBMB/VoxCPM2

模型大小约 4.27GB,下载完成后路径:/tmp/voxcpm_ms/OpenBMB/VoxCPM2

2.4 验证安装

python3 -c "from voxcpm import VoxCPM; print('VoxCPM imported successfully')"

三、服务架构设计

3.1 整体架构图

┌─────────────────────────────────────────────────────┐
│                   Hermes Agent                       │
│  tts.provider = "openai"                             │
│  tts.openai.base_url = "http://127.0.0.1:7000/v1"  │
│  tts.openai.model = "voxcpm2"                        │
└───────────────┬─────────────────────────────────────┘
                │  POST /v1/audio/speech (JSON)
                ▼
┌─────────────────────────────────────────────────────┐
│             VoxCPM2 HTTP Server (FastAPI)            │
│             Port: 7000                               │
│                                                     │
│  ┌─────────────┐   ┌──────────────┐                │
│  │ /v1/audio/  │   │ /api/clone   │                │
│  │ speech      │──▶│              │                │
│  │ (OpenAI     │   │ (Browser UI  │                │
│  │  compatible)│   │  clone API)  │                │
│  └──────┬──────┘   └──────┬───────┘                │
│         │                 │                         │
│  ┌──────▼─────────────────▼──────┐                 │
│  │     _synthesize()              │                 │
│  │   (core inference engine)      │                 │
│  └──────────────┬────────────────┘                 │
│                 │                                   │
│  ┌──────────────▼────────────────┐                 │
│  │     VoxCPM Model (loaded)      │                 │
│  │   _generate() → np.ndarray     │                 │
│  └──────────────┬────────────────┘                 │
└─────────────────┼──────────────────────────────────┘
                  │
         ┌────────▼────────┐
         │  PyTorch CPU    │
         │  + soundfile     │
         └─────────────────┘

3.2 API 端点设计

端点方法协议格式用途
/v1/audio/speechPOSTJSON body (OpenAI compatible)Hermes Agent 标准调用接口
/api/clonePOSTmultipart/form-dataWeb UI 声音克隆接口(返回原始 WAV bytes)
/healthGETJSON健康检查
/uiGETHTMLWeb UI 页面
/api/configGETJSON获取可用参数配置

3.3 Hermes Agent 集成配置

# ~/.hermes/profiles/opc-builder/config.yaml
tts:
  provider: "openai"
  openai:
    base_url: "http://127.0.0.1:7000/v1"
    model: "voxcpm2"
    voice: "default"

这样 Hermes Agent 调用 TTS 时,会自动转发到本地 VoxCPM2 服务。


四、实现思路与核心逻辑

4.1 服务启动流程

start_server.sh start
    │
    ├── 检测 Python(优先 .venv/bin/python)
    ├── 安装依赖(pip install fastapi uvicorn soundfile voxcpm)
    │
    ├── 导出 VOXCPM_MODEL 环境变量(供 worker 进程使用)
    │
    └── nohup python voxcpm_server.py --port 7000 --model /path/to/model
            │
            ├── parse_args() → 解析命令行参数
            ├── load_model() → 预加载模型到全局变量(关键!)
            └── uvicorn.run() → 启动 HTTP 服务

4.2 核心推理流程 _synthesize()

def _synthesize(text, reference_wav_path=None):
    model = load_model(_MODEL_NAME)  # 获取全局模型实例

    wav_data = next(model._generate(
        text=text,
        reference_wav_path=reference_wav_path,  # None = 默认语音
        cfg_value=2.0,                          # CFG 引导值
        inference_timesteps=10,                 # 推理步数
        min_len=2,                              # 最短时长(秒)
        max_len=4096,                           # 最长 token 数
        normalize=False,                        # 文本规范化
        denoise=False,                          # 音频去噪
        streaming=False,                        # 非流式
    ))

    # _generate() 返回 np.ndarray,直接写入 WAV bytes
    buf = io.BytesIO()
    sf.write(buf, wav_data, 48000, format="WAV")
    return buf.getvalue()

4.3 数据流:从文本到音频

用户输入文本 → FastAPI 接收请求
    │
    ├── /v1/audio/speech: JSON body → SpeechRequest Pydantic model
    │   └── voice='default' → 标准合成
    │   └── voice=<path>   → 声音克隆(读取本地 wav)
    │
    ├── /api/clone: multipart/form → text + optional reference_audio file
    │   └── 上传文件写入临时 .wav,传给 _synthesize()
    │       finally: os.unlink(临时文件) 清理
    │
    ▼
_synthesize() → model._generate() → np.ndarray (float32 audio)
    │
    ▼
soundfile.write() → WAV bytes (48kHz, 16-bit PCM, mono)
    │
    ▼
Response(content=wav_bytes, media_type="audio/wav")

4.4 Web UI 前端流程

用户输入文本 + 可选参考音频 → FormData 构建请求
    │
    ▼
fetch('/api/clone', { method: 'POST', body: formData })
    │
    ▼
resp.blob() → 拿到 audio/wav blob(Content-Type: audio/wav)
    │
    ├── currentAudioBlob = blob  (内存保存)
    ├── URL.createObjectURL(blob) → audioPlayer.src
    └── audioPlayer.play()       (自动播放)

五、关键实现细节与踩坑记录

5.1 Uvicorn Worker 进程模型 — 全局状态丢失 Bug ⚠️

问题:Uvicorn 默认启动多个 worker,每个 worker 是独立进程。全局变量 _model 在 main() 中设置的值在 worker 进程中丢失,导致首次请求时模型未加载。

修复方案:预加载 + 单 worker

def main():
    # 在 uvicorn.run() 之前预加载模型到全局变量
    load_model(_MODEL_NAME)

    # 限制为单 worker(多 worker 时全局状态仍可能丢失)
    uvicorn.run(app, host=args.host, port=args.port, workers=1)

5.2 from_pretrained 必须加 local_files_only=True ⚠️

问题:当 model path 是本地路径时,HuggingFace from_pretrained 会尝试从 HF Hub 下载。没有 token 时 XET 格式下载会卡住不动。

修复方案:

is_local = os.path.isdir(model_name)
_model = VoxCPM.from_pretrained(
    hf_model_id=model_name,
    local_files_only=is_local,  # 本地路径必须加此参数
)

5.3 _generate() 返回值类型 ⚠️

问题:model._generate() 返回 np.ndarray,不是 TensorFlow Tensor。调用 .numpy() 会报错 AttributeError。

修复方案:

wav_data = next(model._generate(...))  # wav_data is np.ndarray directly
# 不需要 .numpy()!直接传给 soundfile.write()

5.4 /api/clone 响应格式 — JSON vs Raw WAV ⚠️

问题:最初 /api/clone 返回 {"status":"ok","audio_data":"hex_string..."} JSON。浏览器 resp.blob() 拿到的是 JSON blob(application/json),无法被 <audio> 元素解码。

修复方案:直接返回原始 WAV bytes

return Response(
    content=wav_data,       # 原始 WAV bytes
    media_type="audio/wav", # 正确的 Content-Type
)

5.5 start_server.sh 中 VOXCPM_MODEL 环境变量 ⚠️

问题:nohup 启动的子进程不会继承 shell 变量。如果模型路径只在脚本中赋值但没有 export,worker 进程的 load_model() 无法获取正确的 model path。

修复方案:

export VOXCPM_MODEL="$MODEL"  # export 确保子进程可见
nohup $PYTHON "$SCRIPT_DIR/voxcpm_server.py" \
    --port "$PORT" \
    --model "$MODEL" \
    > "$LOG_FILE" 2>&1 &

5.6 HTMLResponse 导入路径 ⚠️

问题:from fastapi import HTMLResponse 在某些版本中不存在。

修复方案:

from starlette.responses import HTMLResponse  # ✅ 正确路径

5.7 Response vs uvicorn.datastructures.Response ⚠️

问题:不存在 uvicorn.datastructures.Response。

修复方案:

from fastapi import Response  # ✅ 正确导入
# 或使用 starlette: from starlette.responses import Response

5.8 Bash 脚本中 Python docstring 导致执行错误 ⚠️

问题:.sh 文件顶部放 Python docstring(如 """..."""),bash 会尝试执行它并报错。

修复方案: .sh 文件顶部只放 bash comments(#),不放 Python docstring。

5.9 PID 文件管理 ⚠️

问题:服务异常退出后留下 stale PID 文件,导致 ./start_server.sh start 误判为”已运行”。

修复方案:

if [ -f "$PID_FILE" ]; then
    OLD_PID=$(cat "$PID_FILE")
    if kill -0 "$OLD_PID" 2>/dev/null; then
        echo "Server already running (PID: $OLD_PID)."
    else
        rm -f "$PID_FILE"  # stale PID, clean up
    fi
fi

六、测试体系

6.1 TDD 工作流

所有功能均遵循 RED → GREEN → REFACTOR 三阶段:

测试文件测试数覆盖范围
test_config.py4 testsCLI 参数解析、默认端口、健康检查
test_web_ui.py18 testsHTML 页面、表单字段、参数滑块、下载按钮、中文界面、/api/clone 行为
test_start_server.sh10 tests启动/停止/重启/状态检测、PID 管理、端口冲突处理

6.2 关键测试用例示例

# Test: /api/clone 返回原始 WAV bytes(不是 JSON)
def run_test_clone_returns_raw_wav():
    resp = urllib.request.urlopen(req, timeout=120)
    raw_bytes = resp.read()

    # Content-Type 必须是 audio/wav
    assert_true('audio/wav' in resp.headers.get('Content-Type', ''))

    # 必须以 RIFF WAV header 开头
    assert_true(raw_bytes[:4] == b'RIFF')

    # 不能是 JSON
    assert_true(not raw_bytes.lstrip().startswith(b'{'))

    # 必须包含 data chunk
    assert_true(b'data' in raw_bytes)

七、运维与使用

7.1 服务管理

cd /Users/yuanxj/Documents/openapp/voxcpm2

# 启动服务
./start_server.sh start

# 停止服务
./start_server.sh stop

# 重启服务(先停后启)
./start_server.sh restart

# 查看状态
./start_server.sh status

7.2 环境变量配置

变量默认值说明
VOXCPM_PORT7000HTTP 服务端口
VOXCPM_MODEL/tmp/voxcpm_ms/OpenBMB/VoxCPM2模型路径(本地目录或 HF model ID)
VOXCPM_PYTHON自动检测 .venv/bin/pythonPython 解释器路径

7.3 健康检查

curl http://127.0.0.1:7000/health
# {"status":"healthy","model":"loaded","sampling_rate":48000,"version":"1.0.0"}

7.4 Web UI

浏览器访问 http://127.0.0.1:7000/ui,提供:

  • 文本输入框
  • 参考音频上传(声音克隆)
  • CFG 引导值、推理步数等参数调节滑块
  • 实时音频播放 + WAV 下载

八、整体复盘

8.1 技术决策回顾

决策选择理由
封装方式HTTP API wrapper (FastAPI)与 Hermes TTS provider 天然兼容,无需修改 Agent 核心代码
模型选择VoxCPM2 (ModelScope)国内下载速度快,支持 Voice Design + 声音克隆
响应格式Raw WAV bytes (audio/wav)浏览器 <audio> 元素和 Blob API 原生支持,无需前端解码
测试策略TDD (RED-GREEN-REFACTOR)每个 bug 修复都有测试覆盖,防止回归
模型加载预加载 + 单 workerUvicorn 多 worker 导致全局状态丢失的规避方案

8.2 Bug 修复历程

#Bug根因修复
1from_pretrained 卡住不加载本地路径未设置 local_files_only=True,HF XET 下载无 token 卡死添加 is_local 判断 + local_files_only=is_local
2_generate() 返回 .numpy() 报错返回值是 np.ndarray 不是 Tensor直接使用,去掉 .numpy() 调用
3Worker 进程模型为 NoneUvicorn fork 后全局变量丢失预加载 + workers=1
4/api/clone 返回 JSON 导致浏览器无法播放响应格式为 {"audio_data":"hex"}改为 Response(content=wav, media_type="audio/wav")
5start_server.sh worker 进程找不到模型路径变量未 exportexport VOXCPM_MODEL="$MODEL"
6HTMLResponse import errorFastAPI 不直接导出 HTMLResponse从 starlette.responses 导入
7Bash docstring 导致脚本执行失败Python docstring 被 bash 解析.sh 文件只使用 # 注释
8Stale PID 导致重复启动误判未检测进程是否存活kill -0 检查 + stale PID 清理

8.3 经验总结

  1. Uvicorn worker 模型是最大坑:全局状态(加载的模型)在 fork 后丢失。解决思路有两种:预加载 + 单 worker(当前方案),或用 --reload 模式。生产环境建议用 workers=1 + 预加载。
  2. 响应格式决定前端兼容性:JSON hex-encoded audio → 浏览器无法识别;Raw WAV bytes → 原生支持。API 设计时就要考虑最终消费方(浏览器/客户端)的能力边界。
  3. TDD 的价值在回归保护:每次 bug 修复都先写测试,确保后续重构不会引入同样的问题。test_web_ui.py 从最初的 9 tests 增长到 18 tests,覆盖了所有已知场景。
  4. ModelScope vs HuggingFace:国内环境 ModelScope 下载速度快且稳定。HF XET 格式需要 token,否则大量小文件下载极易卡住。
  5. 声音克隆 vs 标准合成:/v1/audio/speech(OpenAI 兼容)通过 voice 参数区分,但需要本地文件路径;/api/clone(专用)支持直接上传参考音频。两者互补,满足不同使用场景。

8.4 后续优化方向

  • 流式输出:当前 _generate() 返回完整 WAV,长文本合成等待时间长。可探索 streaming mode
  • 并发处理:单 worker 限制吞吐量,后续可考虑 async queue + 预加载模型到共享内存
  • 声音管理:当前参考音频每次上传,可持久化存储声音库,支持选择已有音色
  • 多模型切换:支持加载多个 VoxCPM2 变体或不同 TTS 模型,动态切换

发表回复