数据截至 (上游 commit b21e54d6a845)
语音合成:合并、切块与延迟
这一章讲什么: 以默认后端 Qwen3-TTS 为例,讲 TTS handler 干的四件事:决定这段话到底该不该说、把队列里排着的碎片合并、流式产出定长 PCM 块、以及记录延迟指标。
1. 它在流水线里的特殊地位
LMOutputProcessor ──► lm_processed_queue ──► [TTS] ──► send_audio_chunks_queue ──► 发送循环
▲ │
│ ├─ commit(turn, rev) ← 回合在这里彻底关闭
队列里可能排着多条 TTSInput └─ 事件原样透传(BaseHandler 负责)
三个「只有它做」的职责:
- 提交投机回合:
speculative_turns.commit(...)唯一的业务调用点在这里(src/speech_to_speech/TTS/qwen3_tts_handler.py:831-832); - 合并排队文本:因为它是唯一知道「后面还排着多少要念的话」的地方;
- 产出终结哨兵:收到
EndOfResponse时吐AUDIO_RESPONSE_DONE(qwen3_tts_handler.py:814-823),这条哨兵驱动协议层关闭响应。
2. 进门先做两道判定
Qwen3TTSHandler.process(qwen3_tts_handler.py:812-865)开头:
# 真实源码节选,qwen3_tts_handler.py:814-832
if isinstance(tts_input, EndOfResponse):
if speculative_turns and not speculative_turns.is_latest_after_reopen_grace(...):
if tts_input.response_key is None:
return
tts_input.cleanup_only = True
yield AUDIO_RESPONSE_DONE
return
if speculative_turns and not speculative_turns.is_latest_after_reopen_grace(...):
logger.debug("Dropping stale TTS input for turn=%s rev=%s", ...)
return
if speculative_turns:
speculative_turns.commit(tts_input.turn_id, tts_input.turn_revision)
读法:
- 用的是最强的那种查询
is_latest_after_reopen_grace(会一直等到宽限期结束),因为下一步就是真的发声; - 过期的
EndOfResponse不能直接丢——响应必须有人关闭。它被改标成cleanup_only=True后照常发出,由发送循环走「只清理不广播」的分支(见 06 章); - 只有确认要念,才
commit。这是「最晚提交」原则的落点。
3. 排队文本合并:为什么必须做
问题
LLM 那边为了低延迟,攒够 3 句就发一批(见 04 章)。但如果 TTS 正忙,队列里会堆好几批。逐批合成有两个坏处:每次合成都有固定开销;而且分开合成的短句,韵律衔接会很生硬。
做法
_coalesce_pending_tts_input(qwen3_tts_handler.py:751-810)在开始合成前,持锁把输入队列前面属于同一响应的 TTSInput 全部吸走合并:
队列头 ─┬─ TTSInput("你好。") ← 当前正在处理的
├─ AssistantOutputEvent(文本) ← 收集起来,合成前先转发出去
├─ TTSInput("今天天气不错。") ← 吸走,拼进来
├─ TTSInput("要出门吗?") ← 吸走,拼进来
├─ EndOfResponse ← 停(不能跨响应边界)
└─ ...
停止条件写得很密(:773-795):碰到 SESSION_END、PIPELINE_END、EndOfResponse、非 TTSInput 类型、不同响应(四元组 turn_id/turn_revision/cancel_generation/response_key 全等才算同一响应)、不同语言码,任一条就停。
一个容易被忽略的顺序问题
被吸走的那些 TTSInput,前面往往跟着对应的 AssistantOutputEvent(字幕事件)。如果直接跳过它们,客户端就会先听到声音后看到字。所以合并函数把这些事件收集起来,在开始合成之前先 queue_out.put 出去(:806-807),保持「文本 → 音频」的协议顺序。
4. 流式产出:_stream 干的五件事
_stream(qwen3_tts_handler.py:695-749)是所有合成路径的公共出口:
for item in 模型生成器:
① 取消检查:cancel_scope.is_stale(捕获的世代) → 直接 return
② 首块时打印 TTFA(首音频延迟)
③ 重采样到 16 kHz + 转 int16
④ 掐头:找到第一个超过阈值的采样,往前留 40 ms 预卷
⑤ 定长切块:按 self.blocksize 切,不足的留到下一轮
收尾:剩余部分补零成一块;打印 RTF(实时因子)
第 ④ 步值得单独说。原始代码:
# 真实源码,qwen3_tts_handler.py:722-729
if not found_speech:
threshold = int(32768 * 0.01)
above = np.abs(audio_chunk) > threshold
if not np.any(above):
continue
start_idx = max(0, int(np.argmax(above)) - int(PIPELINE_SR * 0.040))
audio_chunk = audio_chunk[start_idx:]
found_speech = True
神经 TTS 常在开头产生一小段几乎无声的爬升,直接播出去就是「延迟感」。这段把它切掉——但特意往前留 40 ms,否则会削掉像 s、f 这种能量低的起始辅音。这个 40 ms 是典型的「实测调出来的常数」。
定长切块(第 ⑤ 步) 保证下游拿到的永远是整块,便于发送循环做批量合并。
5. token 预算估计:一个有趣的启发式
问题
Qwen3-TTS 是自回归 codec 模型,要给 max_new_tokens。给小了句子被截断,给大了浪费。
做法
_estimate_max_new_tokens(qwen3_tts_handler.py:615-658)从文本估算预计语音秒数,再换算成 token:
预计秒数 = max(按词数估, 按字符数估, 按 CJK 字数估)
+ 标点数 × 0.5 秒
+ 1.0 秒基础开销
token 数 = ceil(秒数 × 12.5 tokens/秒 × 1.35 安全系数)
向上对齐到 streaming_chunk_size 的整数倍
下限 360,上限 max_new_tokens 配置值
三种速率常数并列取最大值(:58-60):2.6 词/秒、14 字符/秒、5.5 CJK 字/秒。取最大值而不是按语言分支,是一种务实的做法:中英混排的句子会自动被 CJK 那项主导。
标点加 0.5 秒是为逗号句号处的停顿留空间(QWEN3_PUNCTUATION_PAUSE_SECONDS,:63)。
估算结果被配置上限截断时会打 warning 提醒「输出可能仍被截断」(:642-649)——不静默失败。
6. 延迟度量:两个数
| 指标 | 含义 | 打印位置 |
|---|---|---|
| TTFA | 从调用合成到第一块音频的时间 | _stream,:713-715 |
| RTF | 生成的音频时长 ÷ 生成耗时(>1 才跟得上实时) | _stream,:744-749 |
| 端到端 | 「最后一次检测到说话」到「第一声输出」 | _log_first_audio_latency,:867-878 |
第三个是真正的用户体感指标。它靠 speech_stopped_at_s 一路从 STT 传下来(STT 里赋的是 vad_audio.created_at_s,见 03 章)。这个字段穿过了 Transcription → TranscriptionCompletedEvent → GenerateResponseRequest → LLMResponseChunk → TTSInput 五个消息类型——为了一个日志行专门打通一条数据通路,可见作者对延迟的重视程度。
7. 三种发声模式与会话覆盖
Qwen3-TTS handler 支持三种(process 里的分支,:846-857):
| 模式 | 触发条件 | 说明 |
|---|---|---|
| voice clone | 提供了 qwen3_tts_ref_audio 或 ref_spk | 用参考音频克隆音色 |
| custom voice | 模型名是 CustomVoice 系 | 用具名说话人(默认 Aiden) |
| voice design | 模型名是 VoiceDesign 系 | 用文字描述音色 |
Base 模型不给参考音频会直接抛错并给出明确指引(:854-857)。
运行时可以被会话配置覆盖:_apply_session_voice_override(:506-553)读 session.audio.output.voice,所以客户端发 session.update 就能换声音,不用重启。
后端与量化
| 平台 | 默认后端 | 量化选项 |
|---|---|---|
| Linux/Windows | ggml(faster-qwen3-tts) | BF16 / Q8_0 / Q4_K_M / F32 |
| macOS | mlx-audio | bf16 / 4bit / 6bit(默认) / 8bit |
(依据 qwen3_tts_handler.py:53-55 的合法值常量与 README 的 CLI 默认值段。)
MLX 路径的流式间隔由 _mlx_streaming_interval(:880-881)算出:streaming_chunk_size / 12.5,即「多少个 codec token 算一次输出」。默认 MLX 是 4(≈320 ms),ggml 是 8(:49-50)。
8. 其他 TTS 后端
| 后端 | 类 | 特点 |
|---|---|---|
| Kokoro-82M | KokoroTTSHandler | 有语言→音色映射表;macOS 走 mlx-audio,其余走 kokoro 包 |
| Pocket TTS | PocketTTSHandler | 需要 numpy>=2,与 DeepFilterNet 冲突 |
| ChatTTS | ChatTTSHandler | 可选 extra |
| MMS TTS | FacebookMMSTTSHandler | 通过 transformers,语言参数需特殊归一化 |
Kokoro 的语言映射表 WHISPER_LANGUAGE_TO_KOKORO_LANG 和默认音色表 KOKORO_LANG_DEFAULT_VOICES(src/speech_to_speech/TTS/kokoro_handler.py:32-75)是「STT 检测到什么语言就用什么音色说」这条链路的落点。
MMS 的注册表条目用了自定义归一化函数 _normalize_facebook_mms_config(backend_registry.py:145-148),把 tts_language 改名成 language——注册表的 normalize_config 钩子就是为这种一次性适配留的。
9. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 主流程与回合提交 | src/speech_to_speech/TTS/qwen3_tts_handler.py | Qwen3TTSHandler.process |
| 排队文本合并 | 同上 | _coalesce_pending_tts_input |
| 流式切块与掐头 | 同上 | _stream, _resample_to_pipeline_sr, _to_int16 |
| token 预算估计 | 同上 | _estimate_max_new_tokens, ESTIMATED_QWEN3_WORDS_PER_SECOND, QWEN3_TOKEN_SAFETY_MARGIN |
| 延迟指标 | 同上 | _log_first_audio_latency, _mlx_streaming_interval |
| 三种发声模式 | 同上 | _process_voice_clone, _process_custom_voice, _process_voice_design, _apply_session_voice_override |
| 后端/量化校验 | 同上 | _normalize_faster_backend, _normalize_ggml_quantization, _normalize_mlx_quantization |
| Kokoro | src/speech_to_speech/TTS/kokoro_handler.py | KokoroTTSHandler, WHISPER_LANGUAGE_TO_KOKORO_LANG |
| 其他后端 | src/speech_to_speech/TTS/ | PocketTTSHandler, ChatTTSHandler, FacebookMMSTTSHandler |
| 基准脚本 | scripts/benchmark_tts.py |