tts-bench:从零写一个流式 TTS 评测框架并实测
一、背景速览:TTS 评测该测什么
调研阶段已经把流式 TTS 的技术栈、厂商方案与延迟预算摸清(见 流式 TTS 全景调研 ↗,28 章 / 54 信源)。 但调研给的是别人的数字,本地没有一套能算自己数的工具——所以这次从零写了 tts-bench。
音质好不好只能靠人耳,但工程可用性有四个可量化的硬指标:
二、框架设计:为什么这么切模块
| 模块 | 职责 | 关键设计 |
|---|---|---|
metrics.py | 打点时间线与指标聚合 | 用 perf_counter 单调时钟(系统改时间不影响);分片到达时刻全记,才能算最大间隔 |
adapters/base.py | 统一适配器接口 | 只要求实现 synthesize(),每收到分片调一次 mark_chunk();凭证支持 env:VAR 占位 |
adapters/mock.py | 纯模拟引擎 | 节奏全部可配,用于验证指标算得对不对 |
adapters/macos_say.py | 真实本地引擎 | 同一引擎两种编排:整段合成 vs 按标点分句流式,音质完全相同,差异纯粹来自工程 |
adapters/cloud.py | 云厂商骨架 | 火山/阿里/MiniMax 的协议要点写在 docstring 里,凭证缺失时抛 NotConfiguredError 不中断整轮 |
runner.py | 执行与落盘 | 串行执行——并发会让本地 CPU 争抢把 RTF 测虚高;单条失败不影响其它样本 |
macos_say 做成"整段"和"分句流式"两个 vendor,是为了做一个
控制变量实验——同一个引擎、同一段文本、同一份音质,只改工程编排,看首包能降多少。
这个实验的结论后面会推翻一个常见直觉。
三、环境准备
零第三方依赖,Python 3.7+ 标准库即可(asyncio + wave + subprocess),不用建 venv。
tts-bench/
├── tts_bench/
│ ├── __main__.py # CLI 入口:run / list
│ ├── metrics.py # Timeline 打点 + 分位聚合
│ ├── runner.py # 串行执行、落盘
│ ├── report.py # 控制台表 + Markdown
│ └── adapters/
│ ├── base.py # 适配器基类
│ ├── mock.py # 纯模拟
│ ├── macos_say.py # 真实本地引擎(两种模式)
│ └── cloud.py # 火山/阿里/MiniMax 骨架
├── data/texts.jsonl # 10 条中文客服话术测试集
├── vendors.example.json # 配置模板(env: 占位)
└── README.md测试集设计
10 条中文客服话术,刻意覆盖会打破 TTS 表现的六类文本特征:
| id | 类型 | 字数 | 为什么要这条 |
|---|---|---|---|
greet_01 | 开场白 | 21 | 外呼首句,首包延迟最敏感 |
short_02 | 短确认 | 10 | 验证固定开销占比(短文本里开销最显眼) |
num_03 | 数字金额 | 30 | 金额读法容易出错 |
num_04 | 数字串 | 18 | 订单号连读,节奏易崩 |
policy_05 | 政策长句 | 62 | 长句是首包的天敌 |
multi_06 | 多标点长句 | 56 | 标点密度决定分句流式能切几段 |
en_08 | 中英混读 | 32 | 「三十 GB」混读切换 |
long_09 | 超长段落 | 141 | 极端场景,看首包会不会爆 |
四、跑通步骤
-
看已登记的引擎
$ cd tts-bench && python3 -m tts_bench list 已登记 vendor: mock 纯模拟流式合成,节奏由配置决定,只用于验证框架与指标计算。 macos_say (mode=whole) macos_say_stream (mode=chunked) volcengine 火山引擎 豆包语音合成(双向流式 WebSocket)。 aliyun 阿里云 智能语音交互 流式语音合成(CosyVoice / 通用模型)。 minimax MiniMax T2A 流式语音合成。
-
三引擎对照跑,每条重复 2 轮执行评测
$ python3 -m tts_bench run \ --vendors mock,macos_say,macos_say_stream \ --repeats 2 -
观察逐条明细:注意分片数的差别
# 整段合成:分片=1,首包 == 总耗时(非流式特征) [macos_say] long_09 ok 首包=1249.6ms 总耗时=1249.6ms 音频=30.27s RTF=0.0413 分片=1 [macos_say] bye_10 ok 首包=1096.5ms 总耗时=1096.5ms 音频=3.923s RTF=0.2795 分片=1 # 分句流式:分片>1,首包只等第一小句 [macos_say_stream] long_09 ok 首包=997.2ms 总耗时=8038.7ms 音频=28.32s RTF=0.2839 分片=8 [macos_say_stream] policy_05 ok 首包=1040.5ms 总耗时=5060.3ms 音频=12.835s RTF=0.3943 分片=5
-
读汇总并试听音频
音频落盘在
results/<时间戳>/audio/,16kHz 单声道 wav,可直接播放核对音质与断句。$ ls results/20260730-080959/ audio/ raw_results.jsonl summary.json report.md $ afinfo results/20260730-080959/audio/macos_say_greet_01_r1.wav Data format: 1 ch, 16000 Hz, Int16
五、本机验证结果
| 引擎 | 样本 | 首包 P50 | 首包 P95 | 总耗时 P50 | RTF 均值 | 分片间隔 P50 | 音频总时长 |
|---|---|---|---|---|---|---|---|
mock | 20/20 | 284.2 | 284.8 | 4565.0 | 0.638 | 124.8 | 187.60s |
macos_say(整段) | 20/20 | 1064.1 | 1250.4 | 1064.1 | 0.185 | —(单分片) | 194.25s |
macos_say_stream(分句) | 20/20 | 988.6 | 1032.3 | 2486.4 | 0.514 | 1005.3 | 179.30s |
单位 ms(音频时长除外)。三个引擎成功率均 100%,共 60 条无失败。
首包延迟对比(P50 / P95,ms)
分句流式把首包从 1064ms 降到 988ms,只降了 7%——远低于"分句就能大幅降首包"的直觉预期。原因见下一节。
首包 vs 总耗时:流式的代价(P50,ms)
整段合成首包 == 总耗时(1064ms,单分片非流式)。分句流式首包略降,但总耗时涨到 2486ms(2.3 倍)——因为每一句都要重新付一次引擎固定开销。mock 的总耗时 4565ms 是模拟理想流式引擎的正常形态(持续吐流直到音频结束,总耗时 ≈ 音频时长 × RTF 0.638),不影响听感。
5.1 验证通过项
| 验证环节 | 结论 | 依据 |
|---|---|---|
| 指标计算正确性 | PASS | mock 配置首包 220+60=280ms,实测 P50 284.2ms(+4.2ms 为事件循环开销) |
| 分片间隔计算 | PASS | mock 配置 120ms,实测 P50 124.8ms max 131.5ms |
| 非流式识别 | PASS | 整段模式分片数=1,间隔正确显示为 “—” |
| RTF 反推音频时长 | PASS | 按 PCM 字节数反推,与 afinfo 读出的时长一致 |
| 音频落盘可听 | PASS | 60 个 wav 全部 16kHz/Int16/单声道,可正常播放 |
| 凭证缺失优雅降级 | PASS | 云厂商无凭证时抛 NotConfiguredError,记为该条失败但不中断整轮 |
六、核心发现:为什么分句流式几乎没用
分句流式只降 7% 首包,这不符合直觉,所以单独做了一个佐证实验——测极短文本与超长文本的合成耗时差:
# 合成 1 个字「好」,跑 3 次 $ time (say -v Tingting -o t.aiff "好" && afconvert -f WAVE -d LEI16@16000 -c 1 t.aiff t.wav) real 1.01 real 0.94 real 0.96 # 合成 88 个字的长段落,跑 2 次 $ time (say -v Tingting -o t2.aiff "非常感谢您的耐心等待。我这边已经为您查询到了完整的账单明细……" && afconvert ...) real 1.10 real 1.11
say 的耗时几乎全是进程启动 + 模型加载 + afconvert 转码的固定开销,
真正的声学计算可以忽略。所以把文本切成 8 句,第一句依然要付满 950ms 的固定开销,首包自然降不下来。
6.1 这个结论怎么推广到真实选型
把它抽象成一条可复用的判据:
| 引擎类型 | 固定开销 | 边际成本 | 分句/流式的收益 |
|---|---|---|---|
本机 say(进程级冷启动) | ≈950ms 主导 | ≈1.8ms/字 | 几乎没有(实测 7%) |
| 云端流式 API(长连接复用) | 连接已建,近 0 | 主导 | 显著,这才是流式的正确用法 |
| 自建服务常驻显存 | 首次加载后近 0 | 主导 | 显著 |
所以真实链路里降首包的正确姿势不是"在客户端切句",而是:① 复用长连接消除建连与冷启动;② 服务端支持增量文本输入(LLM 出字即塞给 TTS);③ 首句预热/缓存固定开场白。
6.2 顺带发现的两个副作用
七、接真实厂商
三家云厂商适配器骨架已就位,协议要点写在各自 docstring 里,补完网络调用即可:
| vendor | 协议要点 | 环境变量 |
|---|---|---|
volcengine |
wss 端点 + 4 字节 header 二进制协议,音频在 type=0x0b 帧;关键参数 operation=submit(流式)而非 query |
VOLC_TTS_APPID VOLC_TTS_TOKEN |
aliyun |
NLS 网关,先用 AK 换 token;StartSynthesis → RunSynthesis(增量文本) → StopSynthesis,支持边生成边合成 |
ALIYUN_NLS_APPKEY ALIYUN_AK_ID ALIYUN_AK_SECRET |
minimax |
POST /v1/t2a_v2 带 stream=true,SSE 返回 hex 编码音频分片 |
MINIMAX_API_KEY MINIMAX_GROUP_ID |
$ cp vendors.example.json vendors.json $ export VOLC_TTS_APPID=xxx VOLC_TTS_TOKEN=xxx $ python3 -m tts_bench run --vendors volcengine,aliyun --config vendors.json --repeats 3
八、踩坑记录
asyncio.sleep
的调度精度 + 事件循环开销。这告诉我们:亚 10ms 级别的延迟差异在 Python 异步框架里没有意义,
测云厂商时更是被网络抖动淹没,别为 10ms 的差距下选型结论。
.gitignore 里排除
results/*/audio/,只保留 jsonl/json/md 三个文本产物。
list 命令直接崩。
__main__.py 里用 cls.__doc__.splitlines()[0] 取一行描述,
而 MockAdapter 只在模块级写了文档、类上没写,__doc__ 是 None →
AttributeError。修法两处都补:给类加 docstring,同时代码里做 (cls.__doc__ or "") 容错。
教训:任何反射取元数据的地方都要假设它可能是 None。
macos_say 的耗时里含 afconvert 转码,这部分在云端流式接口里不存在。
拿 1064ms 去和云厂商的 200ms 首包直接比是不公平的——本地引擎在这里的价值是做控制变量实验,不是做性能标杆。