战

实战中心

TTS 语音合成评测

← 返回实战列表
🔊 TTS 语音合成评测

tts-bench:从零写一个流式 TTS 评测框架并实测

2026-07-30 · 60 条实测(10 文本 × 2 轮 × 3 引擎)· 工具目录 tts-bench/ · 含真实本地引擎

机型 Apple Silicon macOS Python 3.9.6(零第三方依赖) 真实引擎 macOS say(Tingting) 采样率 16kHz 单声道
本页导航: 背景速览 框架设计 环境准备 跑通步骤 验证结果 核心发现 接真实厂商 踩坑记录

一、背景速览:TTS 评测该测什么

调研阶段已经把流式 TTS 的技术栈、厂商方案与延迟预算摸清(见 流式 TTS 全景调研 ↗,28 章 / 54 信源)。 但调研给的是别人的数字,本地没有一套能算自己数的工具——所以这次从零写了 tts-bench。

音质好不好只能靠人耳,但工程可用性有四个可量化的硬指标:

TTFB 首包
请求发出 → 第一个音频字节。用户开始听到声音的时刻,外呼链路唯一的硬门槛。
RTF 实时率
合成耗时 ÷ 音频时长。<1 才可能撑住实时通话,单机并发量约等于 1/RTF。
分片间隔
相邻音频分片到达的最大间隔。超过播放缓冲就会断音,是流式最隐蔽的杀手。
分片数
等于 1 就说明根本不是流式——一次性返回全量音频,首包必然等于全量合成耗时。

二、框架设计:为什么这么切模块

模块职责关键设计
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极端场景,看首包会不会爆

四、跑通步骤

  1. 看已登记的引擎
    $ 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. 三引擎对照跑,每条重复 2 轮
    执行评测
    $ python3 -m tts_bench run \
        --vendors mock,macos_say,macos_say_stream \
        --repeats 2
  3. 观察逐条明细:注意分片数的差别
    # 整段合成:分片=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
  4. 读汇总并试听音频

    音频落盘在 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总耗时 P50RTF 均值分片间隔 P50音频总时长
mock20/20284.2284.84565.00.638124.8187.60s
macos_say(整段)20/201064.11250.41064.10.185—(单分片)194.25s
macos_say_stream(分句)20/20988.61032.32486.40.5141005.3179.30s

单位 ms(音频时长除外)。三个引擎成功率均 100%,共 60 条无失败。

首包延迟对比(P50 / P95,ms)

分句流式把首包从 1064ms 降到 988ms,只降了 7%——远低于"分句就能大幅降首包"的直觉预期。原因见下一节。

首包 vs 总耗时:流式的代价(P50,ms)

整段合成首包 == 总耗时(1064ms,单分片非流式)。分句流式首包略降,但总耗时涨到 2486ms(2.3 倍)——因为每一句都要重新付一次引擎固定开销。mock 的总耗时 4565ms 是模拟理想流式引擎的正常形态(持续吐流直到音频结束,总耗时 ≈ 音频时长 × RTF 0.638),不影响听感。

5.1 验证通过项

验证环节结论依据
指标计算正确性PASSmock 配置首包 220+60=280ms,实测 P50 284.2ms(+4.2ms 为事件循环开销)
分片间隔计算PASSmock 配置 120ms,实测 P50 124.8ms max 131.5ms
非流式识别PASS整段模式分片数=1,间隔正确显示为 “—”
RTF 反推音频时长PASS按 PCM 字节数反推,与 afinfo 读出的时长一致
音频落盘可听PASS60 个 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
结论:固定开销 ≈ 950ms,边际成本 ≈ 1.8ms/字。 1 个字要 0.96 秒,88 个字只要 1.10 秒——多 87 个字只多花 140ms。 这意味着 say 的耗时几乎全是进程启动 + 模型加载 + afconvert 转码的固定开销, 真正的声学计算可以忽略。所以把文本切成 8 句,第一句依然要付满 950ms 的固定开销,首包自然降不下来。

6.1 这个结论怎么推广到真实选型

把它抽象成一条可复用的判据:

流式化的收益 ≈ 边际合成成本 ÷(固定开销 + 边际合成成本)。 固定开销占比越高,流式化越没用;边际成本占比越高,流式化收益越大。
引擎类型固定开销边际成本分句/流式的收益
本机 say(进程级冷启动)≈950ms 主导≈1.8ms/字几乎没有(实测 7%)
云端流式 API(长连接复用)连接已建,近 0主导显著,这才是流式的正确用法
自建服务常驻显存首次加载后近 0主导显著

所以真实链路里降首包的正确姿势不是"在客户端切句",而是:① 复用长连接消除建连与冷启动;② 服务端支持增量文本输入(LLM 出字即塞给 TTS);③ 首句预热/缓存固定开场白。

6.2 顺带发现的两个副作用

副作用 1:分片间隔 1005ms,真上线必然断音。 分句串行合成时,第 N+1 句要等第 N 句合成完才开始,导致分片间隔约等于单句合成耗时(1005ms)。 播放缓冲通常只有几百毫秒,这个间隔会直接听到卡顿。要修必须并行预取下一句,而不是串行。
副作用 2:分句会改变韵律,音频总时长缩短 7.7%。 整段 194.25s vs 分句 179.30s。原因是分句合成丢掉了句间自然停顿与跨句语调连贯。 也就是说分句流式不是"无损优化",切分点选得不好会让语音听起来急促、机械。

七、接真实厂商

三家云厂商适配器骨架已就位,协议要点写在各自 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
选型提醒:MiniMax 只提供 TTS,没有公开的流式 ASR 接口,做全链路一站式选型时不能把它当语音全能厂商。 这一点在调研阶段已经核实过(见调研中心的开源 ASR 报告)。

八、踩坑记录

坑 1:mock 的首包不是 220ms 而是 284ms。 配置写的是建连 60ms + 首片延迟 220ms = 280ms,实测 P50 284.2ms。多出的 ~4ms 是 asyncio.sleep 的调度精度 + 事件循环开销。这告诉我们:亚 10ms 级别的延迟差异在 Python 异步框架里没有意义, 测云厂商时更是被网络抖动淹没,别为 10ms 的差距下选型结论。
坑 2:一轮评测产出 17MB 音频,必须进 .gitignore。 60 条 16kHz/16bit wav 约 17MB,每跑一轮就多一份。已在 .gitignore 里排除 results/*/audio/,只保留 jsonl/json/md 三个文本产物。
坑 3:类没写 docstring 导致 list 命令直接崩。 __main__.py 里用 cls.__doc__.splitlines()[0] 取一行描述, 而 MockAdapter 只在模块级写了文档、类上没写,__doc__ 是 None → AttributeError。修法两处都补:给类加 docstring,同时代码里做 (cls.__doc__ or "") 容错。 教训:任何反射取元数据的地方都要假设它可能是 None。
提醒 4:串行执行是刻意的,不要改成并发。 本地引擎并发会让 CPU 争抢,把 RTF 测虚高;云厂商并发会触发限流,把延迟测虚高。 吞吐测试要单独做,不能和延迟测试混在一轮里。
提醒 5:横向比较时要扣掉本地引擎的转码开销。 macos_say 的耗时里含 afconvert 转码,这部分在云端流式接口里不存在。 拿 1064ms 去和云厂商的 200ms 首包直接比是不公平的——本地引擎在这里的价值是做控制变量实验,不是做性能标杆。
← 返回实战列表 相关调研:流式 TTS 全景调研(28 章 / 54 信源)↗