服务定位

sherpa-tts-server 是一个 C++ HTTP TTS 服务,基于 sherpa-onnx 离线 TTS。模型采用
activeTtsModel + ttsModels profile 表结构,可同时声明多个模型 profile(每个
profile 的 backend 才是 sherpa-onnx 后端类型 matcha / vits / kokoro),用
activeTtsModel 指定当前加载哪一个。仓库默认 activeTtsModelvits-zh-ll
(5 说话人中文 VITS),默认配置同时声明 matcha-icefall-zh-envits-aishell3
vits-zh-llkokoro-multi-lang-v1_1(103 说话人中英双语多音色)四个 profile,切换
模型只改 activeTtsModel。服务直接调用仓库内 sherpa-onnx 预编译库,合成 wav 后落盘
并通过 HTTP 暴露下载 URL。

设计目标:

  • 固定大小 HTTP worker 线程池
  • 多实例 OfflineTts 池并行合成(单实例推理线程数由 ttsModelNumThreads 控制)
  • text + speakerId + speed 的请求去重:in-flight 共享 + 文件级缓存
  • GET /wav/... 流式发送,避免大文件占内存
  • POST /tts-stream + GET /tts-stream/<id>.mp3 支持边合成边播放
  • 流式稳定性兜底:ffmpeg 退出超时回收、流式总时长 watchdog、maxActiveStreams 并发限流、socket 读写超时
  • HEAD /wav/... 支持,便于 FreeSWITCH playback 拉取前预检
  • 启动时可选扫描 public/wav/ 重建文件索引

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

构建与运行

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

构建目录保留在 build\win_x64\,**可运行的发布产物安装到 target\win_x64\**(仅含可执行文件、
config.json 与运行时 DLL,不含编译中间产物)。bun run dev / bun run dev:tts 即从这里启动它。

启动:

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

模型目录解析顺序:

  1. 当前工作目录的上三级 ../../../models(典型 target\win_x64\ 启动场景)
  2. 当前工作目录的上一级 ../models(源码目录启动场景)
  3. 否则回退到当前工作目录下的 ./models

默认只要找到 models/vits-zh-ll/ 即可启动(仓库默认 activeTtsModel)。

默认 vits-zh-ll 依赖文件:

  • vits-zh-ll.onnx
  • tokens.txt
  • lexicon.txt
  • phone.fst / number.fst(中文数字 / 日期归一化,依赖 dict_dir
  • dict/dictDir,Bert-VITS2 中文分词字典,头文件注释称 unused 是错的,实测必需)

vits-zh-ll 有 5 个说话人(ll_0..ll_4),音色名通过 profile 的 speakerNames 配置。

切到 Kokoro(kokoro-multi-lang-v1_1,103 说话人中英双语)时需要 model.onnx
voices.bintokens.txtlexicon-us-en.txtlexicon-zh.txtdict/
espeak-ng-data/;切到 Matcha(matcha-icefall-zh-en)时需要 model-steps-3.onnx
vocos-16khz-univ.onnxtokens.txtlexicon.txtespeak-ng-data/;切到
vits-aishell3 时需要 vits-aishell3.onnxtokens.txtlexicon.txt
各 profile 的具体文件名见仓库 onnx-platform/sherpa-tts-server/README.md

HTTP 端点

Method Path 用途
GET /health 健康检查 + 当前已加载模型能力(model.numSpeakers / supportsMultipleSpeakers / speakers[] 等)
POST /tts 提交合成请求,等待整段 wav 合成完成后返回 wavUrl
POST /tts-stream 创建流式 TTS 播放链接(不等待完整合成)
GET /tts-stream/<id>.mp3 在同一个连接中以 Transfer-Encoding: chunked 持续推 MP3
HEAD /tts-stream/<id>.mp3 检查流式链接是否存在
GET /wav/<file> 下载已合成的 wav
HEAD /wav/<file> 预检 wav 是否存在

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

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

GET /health

返回服务基础信息、并发运行态与当前已加载 TTS 模型能力。前端(如管理控制台的音色试听)用
model.numSpeakers > 1model.supportsMultipleSpeakers=true 判断是否展示多个 speakerId 试听项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
"status": "ok",
"server": "sherpa-tts-server",
"listenHost": "0.0.0.0",
"listenPort": 9080,
"publicBaseUrl": "http://127.0.0.1:9080",
"sampleRate": 24000,
"workerThreads": 8,
"ttsPoolSize": 4,
"activeStreams": 0,
"maxActiveStreams": 24,
"queuedRequests": 0,
"maxQueuedRequests": 0,
"socketReadTimeoutMs": 5000,
"socketSendTimeoutMs": 15000,
"ffmpegExitTimeoutMs": 5000,
"streamMaxDurationMs": 60000,
"model": {
"id": "vits-zh-ll",
"backend": "vits",
"dir": "vits-zh-ll",
"activeFiles": "vits-zh-ll.onnx,tokens.txt,lexicon.txt",
"numSpeakers": 5,
"supportsMultipleSpeakers": true,
"speakers": [ { "id": 0, "name": "ll_0" }, { "id": 1, "name": "ll_1" } ]
}
}

activeStreams / maxActiveStreams 反映当前流式 GET /tts-stream/<id>.mp3 连接数与上限;
queuedRequests / maxQueuedRequests 反映等待 worker 的连接队列。speakerId 直接对应
/health 返回的 model.speakers[].id

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": 24000,
"cached": false
}
字段 说明
wavUrl publicBaseUrl + /wav/ + fileName
fileName 格式 tts-<FNV-1a 16hex>.wav
sampleRate 模型采样率
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
<activeTtsModel>:<生效模型目录> | 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 检查,文件不存在直接放弃,避免下载半截。

POST /tts-stream(流式 TTS 入口)

请求体与 POST /tts 完全相同:

1
2
3
curl -s -X POST http://127.0.0.1:9080/tts-stream \
-H "Content-Type: application/json" \
-d '{"text":"欢迎进入会议","speakerId":0,"speed":1.0}'

立即返回(无需等待整段合成):

1
2
3
4
5
6
7
{
"streamUrl": "http://127.0.0.1:9080/tts-stream/3a1c7b9eaf0c1d72.mp3",
"streamPath": "/tts-stream/3a1c7b9eaf0c1d72.mp3",
"sampleRate": 24000,
"estimatedDurationMs": null,
"cached": false
}
字段 说明
streamUrl publicBaseUrl + streamPath
streamPath 流式拉流路径
sampleRate 模型采样率
estimatedDurationMs 命中缓存时给出估算时长(毫秒),未命中为 null;同时通过响应头 X-TTS-Estimated-Duration-Ms 返回
cached true 表示命中已有 wav

GET /tts-stream/<id>.mp3 在同一个 HTTP 连接里通过 Transfer-Encoding: chunked 输出 audio/mpeg:服务端调用 sherpa TTS 的增量回调,把新生成的 float PCM 样本送入 ffmpeg 实时编码为 MP3 并写入 HTTP 响应;完整生成后仍会把 wav 结果落盘到 public/wav/,供普通 /tts 链路缓存命中。

HEAD /tts-stream/<id>.mp3 只校验链接是否存在,不触发合成。

注意

流式链路依赖外部 ffmpeg 进程做 PCM → MP3 实时编码,部署时务必保证 mp3EncoderPath(默认 "ffmpeg")对应的二进制可执行。FreeSWITCH 侧通常用 shout://... 前缀通过 mod_shout 拉取,参考 apps/callflow-esl/config.jsontts.profiles.shout.playbackPrefix

关键配置

模型采用 activeTtsModel + ttsModels profile 表结构,与 ASR 一致。

字段 默认 说明
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 实例数
ttsModelNumThreads 2 单个 OfflineTts 实例内部 ONNX Runtime 推理线程数;调低偏吞吐,调高偏单请求低 RTF
mp3EncoderPath ffmpeg 流式 TTS 把 PCM 编码为 MP3 时使用的 ffmpeg 路径;mod_shout 拉流必需
mp3BitrateKbps 192 流式 TTS 转 MP3 的 CBR 码率,范围 64..320
mp3VolumeGainDb 3.0 流式 TTS 转 MP3 时追加的响度增益(dB),范围 -20..20;正增益会同时启用 limiter 降低削波
maxActiveStreams 24 同时处理的 GET /tts-stream/<id>.mp3 上限;超出在发音频头前返回 503。若 >= workerThreads 会被自动 clamp,预留 worker 给 link 创建与 /health
streamMaxDurationMs 60000 单条流式连接允许占用的最长时间;到点 watchdog 主动断开并释放 worker
socketReadTimeoutMs 5000 HTTP socket 读超时;0 = 系统默认
socketSendTimeoutMs 15000 HTTP socket 写超时;慢读 / 断连客户端超过该时间本次请求失败
ffmpegExitTimeoutMs 5000 等待 ffmpeg 退出的超时;超时 kill 子进程并记失败,避免僵尸 ffmpeg 占满 worker
wavSendChunkBytes 65536 GET /wav/... 流式发送块大小
maxRequestBodyBytes 1048576 单请求体最大字节数
maxQueuedRequests 0(不限) 等待队列上限,超出直接 503
logDir logs 日志目录;相对路径按运行目录解析,按天滚动
startupScanCache true 启动时扫描 public/wav/ 建文件索引
activeTtsModel 必填 当前加载的模型 profile id,必须存在于 ttsModels(仓库默认 vits-zh-ll
ttsModels 必填 模型 profile 表;每项含 backendmatcha/vits/kokoro)、modelDirspeakerNames?、lengthScale? 及后端专属字段

maxActiveStreams(流式 GET 并发上限)与 maxQueuedRequests(连接队列上限)是
两层独立保护。调优时注意 ttsModelNumThreads × ttsPoolSize 的乘积不应明显超过
CPU 核心数。

缓存 wav 与实时合成两条流式路径共用同一套 mp3BitrateKbps / mp3VolumeGainDb
避免首次播放与缓存播放的 MP3 听感不一致。完整字段表与各模型 profile 的文件清单见仓库
onnx-platform/sherpa-tts-server/README.md

旧版扁平字段 ttsModelType / ttsKokoro* / ttsMatcha* / ttsVits* / ttsModelDir
已移除,不再做兼容转换,迁移时必须改写为 activeTtsModel + ttsModels profile 表。

并发模型

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:实际并行合成度(实例数)
  • ttsModelNumThreads:单实例 ONNX 推理线程数(ttsModelNumThreads × ttsPoolSize 不应明显超过 CPU 核心数)
  • 模型本身的 RTF(输出 rtf= 日志可对比)
  • workerThreads:HTTP 处理线程数,通常 >= ttsPoolSize 即可
  • maxActiveStreams:流式 GET /tts-stream/<id>.mp3 的独立并发上限(与 maxQueuedRequests 连接队列是两层不同限制)

缓存命中 / in-flight 复用时不占 TTS 实例,只做 I/O。流式链路对每条 GET /tts-stream/<id>.mp3
起 watchdog 线程,streamMaxDurationMs 到点主动 kill ffmpeg 并断开连接,避免慢客户端长期占用
worker;ffmpeg 退出另有 ffmpegExitTimeoutMs 兜底回收,避免僵尸进程。

与 FreeSWITCH 集成

callflow-esl 配置(tts 段采用 defaults + use + profiles 三段结构,完整字段见 配置参考):

1
2
3
4
5
6
7
8
9
10
{
"tts": {
"defaults": { "speakerId": 0, "speed": 1, "requestTimeoutMs": 10000 },
"use": { "call": "shout", "conference": "wav" },
"profiles": {
"wav": { "endpoint": "http://127.0.0.1:9080/tts", "playbackTarget": "wav-url", "fsPlaybackBaseDir": "" },
"shout": { "endpoint": "http://127.0.0.1:9080/tts-stream", "playbackPrefix": "shout://" }
}
}
}

业务调用 ctx.speak({ kind: "tts", text: "..." }) 时,runtime 按 tts.use.call(或会议场景
tts.use.conference)选择 profile:

  1. "wav" 时,向本服务 POST /tts 拿到 wavUrl,再让 FreeSWITCH playback <wavUrl>
  2. "shout" 时,向本服务 POST /tts-stream 拿到 streamUrl,把 profiles.shout.playbackPrefix 拼到链接前(默认 shout://)交给 FreeSWITCH 边拉边播
  3. 完整 wav 仍会落盘到 public/wav/,下一次相同 {text, speakerId, speed} 会直接命中缓存

旧版扁平字段 sherpaHttpEndpoint / sherpaStreamEndpoint / streamingEnabled /
streamPlaybackPrefix / playbackTarget 已移除,流式与否改由 use.call / use.conference
选 profile 决定。

profiles.wav.playbackTarget 两种模式

取值 行为
"wav-url"(默认) wavUrl 直接交给 FS playback,FS 自己拉
"file-path" profiles.wav.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
target\win_x64\public\wav\tts-<hash>.wav

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

日志

所有日志以 [sherpa_tts_server] 前缀输出到 stdout(按天滚动到 logDir)。关键事件:

日志 含义
loaded TTS worker in <ms> ms 单个 TTS 实例加载完成
listening on http://<host>:<port> 监听就绪
request start ... / request end ... 一次 /tts 请求的开始 / 结束摘要
generated <path> in <ms> ms (rtf=<n>) 本次合成耗时与实时倍率(带 modelId/modelBackend/modelDir/modelNumThreads/modelFile
request textLength=<n> cached=<bool> elapsedMs=<n> 一次 /tts 请求摘要
streamed <path> ... 一次流式 GET /tts-stream/<id>.mp3 推流摘要
stream busy 流式连接数超过 maxActiveStreams,返回 503
MP3 streaming encoder watchdog closed stream after streamMaxDurationMs=<n> 流式连接超过 streamMaxDurationMs,watchdog 主动断开
ffmpeg MP3 encoder exit timed out ffmpeg 退出超过 ffmpegExitTimeoutMs,已 kill 回收
startup cache scanned <n> wav files 启动扫描结果

局限

注意

  • 配置文件使用完整 JSON parser(支持嵌套对象与数组);HTTP 请求 / 响应仍是轻量解析
  • 缺少任何模型文件会在启动时抛 missing TTS model file: ...missing TTS model directory: ... 并退出
  • speakerId 直接对应 /health 返回的 model.speakers[].id;vits-zh-ll 默认 5 说话人,Kokoro 103 说话人,Matcha 单说话人
  • 启动扫描无法可靠恢复原始 cache_key,扫描出的文件只能通过 GET/HEAD 按文件名访问;新的 /tts 请求若文本相同仍会命中合成路径(hash 一致),生成结果与扫描到的旧文件同名并覆盖
  • 服务无 graceful shutdown:Ctrl+C 直接终止 accept 与 worker
  • 无鉴权;限流为 maxQueuedRequests(连接队列)+ maxActiveStreams(流式并发)两层,仅建议内网 / 可信网络运行
  • 无 LRU / TTL;长期运行需要外部清理 public/wav/

参考

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