跳到主要内容

数据截至 (上游 commit b21e54d6a845)

语音合成:合并、切块与延迟

这一章讲什么: 以默认后端 Qwen3-TTS 为例,讲 TTS handler 干的四件事:决定这段话到底该不该说、把队列里排着的碎片合并、流式产出定长 PCM 块、以及记录延迟指标。


1. 它在流水线里的特殊地位

LMOutputProcessor ──► lm_processed_queue ──► [TTS] ──► send_audio_chunks_queue ──► 发送循环
▲ │
│ ├─ commit(turn, rev) ← 回合在这里彻底关闭
队列里可能排着多条 TTSInput └─ 事件原样透传(BaseHandler 负责)

三个「只有它做」的职责:

  1. 提交投机回合:speculative_turns.commit(...) 唯一的业务调用点在这里(src/speech_to_speech/TTS/qwen3_tts_handler.py:831-832);
  2. 合并排队文本:因为它是唯一知道「后面还排着多少要念的话」的地方;
  3. 产出终结哨兵:收到 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_ENDPIPELINE_ENDEndOfResponse、非 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,否则会削掉像 sf 这种能量低的起始辅音。这个 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 章)。这个字段穿过了 TranscriptionTranscriptionCompletedEventGenerateResponseRequestLLMResponseChunkTTSInput 五个消息类型——为了一个日志行专门打通一条数据通路,可见作者对延迟的重视程度。


7. 三种发声模式与会话覆盖

Qwen3-TTS handler 支持三种(process 里的分支,:846-857):

模式触发条件说明
voice clone提供了 qwen3_tts_ref_audioref_spk用参考音频克隆音色
custom voice模型名是 CustomVoice 系用具名说话人(默认 Aiden)
voice design模型名是 VoiceDesign 系用文字描述音色

Base 模型不给参考音频会直接抛错并给出明确指引(:854-857)。

运行时可以被会话配置覆盖:_apply_session_voice_override(:506-553)读 session.audio.output.voice,所以客户端发 session.update 就能换声音,不用重启。

后端与量化

平台默认后端量化选项
Linux/Windowsggml(faster-qwen3-tts)BF16 / Q8_0 / Q4_K_M / F32
macOSmlx-audiobf16 / 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-82MKokoroTTSHandler有语言→音色映射表;macOS 走 mlx-audio,其余走 kokoro 包
Pocket TTSPocketTTSHandler需要 numpy>=2,与 DeepFilterNet 冲突
ChatTTSChatTTSHandler可选 extra
MMS TTSFacebookMMSTTSHandler通过 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.pyQwen3TTSHandler.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
Kokorosrc/speech_to_speech/TTS/kokoro_handler.pyKokoroTTSHandler, WHISPER_LANGUAGE_TO_KOKORO_LANG
其他后端src/speech_to_speech/TTS/PocketTTSHandler, ChatTTSHandler, FacebookMMSTTSHandler
基准脚本scripts/benchmark_tts.py