一、项目概述
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/speech | POST | JSON body (OpenAI compatible) | Hermes Agent 标准调用接口 |
/api/clone | POST | multipart/form-data | Web UI 声音克隆接口(返回原始 WAV bytes) |
/health | GET | JSON | 健康检查 |
/ui | GET | HTML | Web UI 页面 |
/api/config | GET | JSON | 获取可用参数配置 |
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.py | 4 tests | CLI 参数解析、默认端口、健康检查 |
test_web_ui.py | 18 tests | HTML 页面、表单字段、参数滑块、下载按钮、中文界面、/api/clone 行为 |
test_start_server.sh | 10 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_PORT | 7000 | HTTP 服务端口 |
VOXCPM_MODEL | /tmp/voxcpm_ms/OpenBMB/VoxCPM2 | 模型路径(本地目录或 HF model ID) |
VOXCPM_PYTHON | 自动检测 .venv/bin/python | Python 解释器路径 |
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 修复都有测试覆盖,防止回归 |
| 模型加载 | 预加载 + 单 worker | Uvicorn 多 worker 导致全局状态丢失的规避方案 |
8.2 Bug 修复历程
| # | Bug | 根因 | 修复 |
|---|---|---|---|
| 1 | from_pretrained 卡住不加载 | 本地路径未设置 local_files_only=True,HF XET 下载无 token 卡死 | 添加 is_local 判断 + local_files_only=is_local |
| 2 | _generate() 返回 .numpy() 报错 | 返回值是 np.ndarray 不是 Tensor | 直接使用,去掉 .numpy() 调用 |
| 3 | Worker 进程模型为 None | Uvicorn fork 后全局变量丢失 | 预加载 + workers=1 |
| 4 | /api/clone 返回 JSON 导致浏览器无法播放 | 响应格式为 {"audio_data":"hex"} | 改为 Response(content=wav, media_type="audio/wav") |
| 5 | start_server.sh worker 进程找不到模型路径 | 变量未 export | export VOXCPM_MODEL="$MODEL" |
| 6 | HTMLResponse import error | FastAPI 不直接导出 HTMLResponse | 从 starlette.responses 导入 |
| 7 | Bash docstring 导致脚本执行失败 | Python docstring 被 bash 解析 | .sh 文件只使用 # 注释 |
| 8 | Stale PID 导致重复启动误判 | 未检测进程是否存活 | kill -0 检查 + stale PID 清理 |
8.3 经验总结
- Uvicorn worker 模型是最大坑:全局状态(加载的模型)在 fork 后丢失。解决思路有两种:预加载 + 单 worker(当前方案),或用
--reload模式。生产环境建议用workers=1+ 预加载。 - 响应格式决定前端兼容性:JSON hex-encoded audio → 浏览器无法识别;Raw WAV bytes → 原生支持。API 设计时就要考虑最终消费方(浏览器/客户端)的能力边界。
- TDD 的价值在回归保护:每次 bug 修复都先写测试,确保后续重构不会引入同样的问题。
test_web_ui.py从最初的 9 tests 增长到 18 tests,覆盖了所有已知场景。 - ModelScope vs HuggingFace:国内环境 ModelScope 下载速度快且稳定。HF XET 格式需要 token,否则大量小文件下载极易卡住。
- 声音克隆 vs 标准合成:
/v1/audio/speech(OpenAI 兼容)通过voice参数区分,但需要本地文件路径;/api/clone(专用)支持直接上传参考音频。两者互补,满足不同使用场景。
8.4 后续优化方向
- 流式输出:当前
_generate()返回完整 WAV,长文本合成等待时间长。可探索 streaming mode - 并发处理:单 worker 限制吞吐量,后续可考虑 async queue + 预加载模型到共享内存
- 声音管理:当前参考音频每次上传,可持久化存储声音库,支持选择已有音色
- 多模型切换:支持加载多个 VoxCPM2 变体或不同 TTS 模型,动态切换