服务定位

sherpa-tts-server 是一个 C++ HTTP TTS 服务,基于 sherpa-onnx 离线 VITS 模型(vits-zh-aishell3)。直接调用仓库内 sherpa-onnx 预编译库,合成 wav 后落盘并通过 HTTP 暴露下载 URL。

设计目标:

  • 固定大小 HTTP worker 线程池
  • 多实例 OfflineTts 池并行合成
  • text + speakerId + speed 的请求去重:in-flight 共享 + 文件级缓存
  • GET /wav/... 流式发送,避免大文件占内存
  • HEAD /wav/... 支持,便于 FreeSWITCH playback 拉取前预检
  • 启动时可选扫描 public/wav/ 重建文件索引

面向 callflow-eslctx.speak({ kind: "tts" }) 场景,但接口本身是通用 HTTP。

构建与运行

1
2
3
4
5
cd sherpa-tts-server
.\build.ps1
# 等价于:
# cmake -S . -B build -G "Visual Studio 17 2022" -A x64
# cmake --build build --config Release --target sherpa_tts_server

启动:

1
2
3
4
cd .\build\Release
.\sherpa_tts_server.exe
# 或显式指定配置:
# .\sherpa_tts_server.exe C:\path\to\config.json

模型目录解析顺序:

  1. 当前工作目录的上三级 ../../../models(典型 build/Release 启动场景)
  2. 否则回退到当前工作目录下的 ./models

只要找到 models/vits-zh-aishell3/ 即可启动。

依赖文件:

  • vits-aishell3.onnx
  • tokens.txt
  • lexicon.txt

三个 HTTP 端点

Method Path 用途
POST /tts 提交合成请求
GET /wav/<file> 下载已合成的 wav
HEAD /wav/<file> 预检 wav 是否存在

所有响应带 Connection: close,错误统一 JSON:

1
{ "error": "<message>" }

POST /tts

请求:

1
2
3
curl -s -X POST http://127.0.0.1:9080/tts \
-H "Content-Type: application/json" \
-d '{"text":"欢迎进入会议","speakerId":0,"speed":1.0}'
字段 类型 必填 默认 说明
text string - 要合成的文本,trim 后不能为空
speakerId int 0 多说话人 ID;超过 NumSpeakers() 自动回退 0
speed float 1.0 合成语速倍率,必须 > 0

成功响应(HTTP 200):

1
2
3
4
5
6
{
"wavUrl": "http://127.0.0.1:9080/wav/tts-3a1c7b9eaf0c1d72.wav",
"fileName": "tts-3a1c7b9eaf0c1d72.wav",
"sampleRate": 16000,
"cached": false
}
字段 说明
wavUrl publicBaseUrl + /wav/ + fileName
fileName 格式 tts-<FNV-1a 16hex>.wav
sampleRate 模型采样率(VITS aishell3 通常 16000 或 22050)
cached true 表示命中已有 wav,未触发新合成

错误响应:

HTTP 触发 示例 body
400 非 JSON、缺 text、text 为空、speed≤0、Content-Length 超限 {"error":"Missing text"}
404 路径不是 /tts/wav/* {"error":"Not found"}
500 TTS 生成失败、wav 写盘失败 {"error":"TTS generated empty audio"}
503 等待队列已满(受 maxQueuedRequests {"error":"Server busy"}

缓存与去重

服务按以下 key 做 FNV-1a 64bit 哈希得到 tts-<16hex>.wav

1
vits-zh-aishell3 | sid=<speakerId> | speed=<speed.fixed3> | text=<text>

同样的 {text, speakerId, speed} 永远命中同一文件

  1. 进程内已知 → 直接返回 cached=true
  2. in-flight 合成中 → 共享同一个 future,等待其他请求完成后一起返回
  3. 否则 → 当前请求成为 owner,调用 OfflineTtsWorker 实际生成

注意

当前没有 LRU / TTL 清理逻辑,wav 会持续保留在 public\wav\。长期运行需要外部任务定期清理;删除文件不会破坏服务,下一次相同请求会重新生成。

GET /wav/<fileName>

1
2
3
4
5
6
HTTP/1.1 200 OK
Content-Type: audio/wav
Content-Length: <bytes>
Connection: close

<wav binary stream,按 wavSendChunkBytes 分块发送>

约束:

  • fileName 只取 path::filename,禁止 ..,否则 400
  • 不在缓存索引中返回 404
  • 仅返回 Content-Length 头,不支持 Range / 多段断点续传

HEAD /wav/<fileName>

与 GET 完全一致的状态码和头部,但不返回 body。FreeSWITCH playback 拉取 wav URL 时通常会先做 HEAD 检查,文件不存在直接放弃,避免下载半截。

关键配置

字段 默认 说明
listenHost / listenPort 127.0.0.1 / 9080 实际监听地址
publicBaseUrl http://127.0.0.1:9080 返回给客户端的 wavUrl 前缀;可与 listenHost 不同(反代后)
workerThreads CPU 核心数 HTTP 请求处理线程数
ttsPoolSize max(CPU/2, 1) 预热的 OfflineTts 实例数
wavSendChunkBytes 65536 GET /wav/... 流式发送块大小
maxRequestBodyBytes 1048576 单请求体最大字节数
maxQueuedRequests 0(不限) 等待队列上限,超出直接 503
startupScanCache true 启动时扫描 public/wav/ 建文件索引

并发模型

1
2
3
4
5
6
7
8
9
10
11
12
13
accept thread (单线程)


queue_ ────► worker[0..workerThreads-1]
│ 解析 HTTP、路由

TtsService::Synthesize

▼ Acquire / Release
OfflineTtsPool (ttsPoolSize 实例)


Generate + WriteWave

并发吞吐主要取决于:

  • ttsPoolSize:实际并行合成度
  • 模型本身的 RTF(输出 rtf= 日志可对比)
  • workerThreads:HTTP 处理线程数,通常 >= ttsPoolSize 即可

缓存命中 / in-flight 复用时不占 TTS 实例,只做 I/O。

与 FreeSWITCH 集成

callflow-esl 配置:

1
2
3
4
5
6
7
8
{
"tts": {
"sherpaHttpEndpoint": "http://127.0.0.1:9081/tts",
"playbackTarget": "wav-url",
"speakerId": 0,
"speed": 1
}
}

业务调用 ctx.speak({ kind: "tts", text: "..." }) 时,runtime:

  1. POST /tts 拿到 wavUrl
  2. 通过 ESL 下发 playback shout://<wavUrl>(或本地路径,见下面 playbackTarget

playbackTarget 两种模式

取值 行为
"wav-url"(默认) wavUrl 直接交给 FS playback,FS 自己拉
"file-path" tts.fsPlaybackBaseDir + fileName 拼成 FS 可访问的本地路径(要求 callflow-esl 与 FS 同机 / 共享卷)

publicBaseUrl 在远端 FS 场景下的注意点

注意

如果 callflow-esl 部署在机器 A、FS 部署在机器 B、TTS 服务部署在机器 A:

  • listenHost = 0.0.0.0(接受外部连接)
  • publicBaseUrl = http://A 的可访问 IP:9080不能填 127.0.0.1,否则 FS 在 B 上拉不到)

FS 必须能从自己网络通到 publicBaseUrl

调用示例

curl

1
2
3
curl -s -X POST http://127.0.0.1:9080/tts \
-H "Content-Type: application/json" \
-d '{"text":"测试语音合成","speakerId":0,"speed":1.0}'

Node / Bun

仓库内 request-tts.js

1
2
3
bun run .\request-tts.js
bun run .\request-tts.js "测试语音合成"
bun run .\request-tts.js "测试语音合成" http://127.0.0.1:9080/tts

等价:

1
2
3
4
5
6
7
const res = await fetch("http://127.0.0.1:9080/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "测试语音合成" }),
});
const result = await res.json();
console.log(result.wavUrl);

输出位置

1
build\Release\public\wav\tts-<hash>.wav

publicBaseUrl 对外暴露的 /wav/... 实际就读这个目录。

日志

所有日志以 [sherpa_tts_server] 前缀输出到 stdout。关键事件:

日志 含义
loaded TTS worker in <ms> ms 单个 TTS 实例加载完成
listening on http://<host>:<port> 监听就绪
generated <path> in <ms> ms (rtf=<n>) 本次合成耗时与实时倍率
request textLength=<n> cached=<bool> elapsedMs=<n> 一次 /tts 请求摘要
startup cache scanned <n> wav files 启动扫描结果

局限

注意

  • 仅支持最小 JSON 解析,不支持复杂嵌套结构和数组
  • 缺少任何模型文件会在启动时抛 missing TTS model file: ... 并退出
  • 启动扫描无法可靠恢复原始 cache_key,扫描出的文件只能通过 GET/HEAD 按文件名访问;新的 /tts 请求若文本相同仍会命中合成路径(hash 一致),生成结果与扫描到的旧文件同名并覆盖
  • 服务无 graceful shutdown:Ctrl+C 直接终止 accept 与 worker
  • 无鉴权、无限流(除 maxQueuedRequests),仅建议内网 / 可信网络运行
  • 无 LRU / TTL;长期运行需要外部清理 public/wav/

参考

  • 详细字段说明:仓库 sherpa-tts-server/README.md
  • callflow-esl 集成:callflow-esl