数据截至 (上游 commit c49982eb3aea)
说:逐词喂 TTS 与按真实时间释放的 RealtimeQueue
30 秒导读: LLM 一个 token 一个 token 地吐文本,TTS 想要的是"整词"、还得读得跟真人语速一样自然。这一章讲 Unmute 怎么把"文本流"变成"同步的语音流":先把碎 token 重新拼成整词逐个喂给 TTS,再用一个按时间戳排序的小堆(
RealtimeQueue),把 TTS 抢跑生成出来的音频和文字,掐着它们真该出现的那一刻才放出去。
本章只讲"说"这一侧的时序工程。打断时怎么取消留给 04-quest-lifecycle-interruption,声音克隆/人格留给 05-voices-prompt-personality。上游怎么"听"、怎么决定该说话,见 02-stt-vad-turn-taking;整个回合的编排见 01-orchestration-loop。
1. 这是什么(零基础也能懂)
一句话定义: 把文本大模型输出的文字流,实时转成一段和它自己节奏对齐的语音流。
它要解决的两个直觉难题:
- 难题 A:LLM 吐的是碎片,不是词。 流式接口按 token 给你
"Hel"、"lo wor"、"ld"这种碎块。TTS(文字转语音)如果拿到半个词,它不知道词到哪结束,就会读错音。所以喂给 TTS 之前,必须先把碎块重新拼成整词 。 - 难题 B:机器比真人快。 TTS 生成语音往往快于实时——它能在 1 秒内算完 3 秒的语音。如果算出来就立刻播,音频会挤成一坨,而且屏幕上的字幕会和声音对不上。所以要有个"节流阀":掐着每段语音/每个字真该出现的时刻再放出去。
用起来什么样: 从调用方看,喂词只是一个循环——LLM 每吐出一个整词,就 await tts.send(词);词发完了发一个 Eos(end of stream)。剩下的同步全由 TTS 客户端内部搞定:
# 示意,非源码:喂词循环的骨架
async for word in rechunk_to_words(llm.chat_completion(messages)):
await tts.send(word) # 逐个整词发给 TTS 服务器
await tts.send(TTSClientEosMessage()) # 告诉 TTS:说完了
# 另一侧:`async for msg in tts` 会按真实时间吐出 音频帧 和 文字
一句话类比: 把它想成同声传译的字幕组 + 配音。翻译(LLM)一个词一个词地报;配音演员(TTS)提前把整句都录好了,但不能一股脑全放——得掐着影片的真实时间轴,让声音和字幕卡在正确的一帧出现。那个"时间轴"就是 RealtimeQueue。
2. 顶层全景(它大概怎么转)
这条"说"的流水线横跨三个文件:LLM 侧做整词切分,unmute_handler 做喂词与取音频,TTS 客户端做协议编解码 + 时序释放。
怎么读下面这张图: 从左到右是数据流。上半条是"文本进 TTS",下半条是"音频/文字出 TTS";中间那个 RealtimeQueue 是全章的心脏——两个方向的输出都要过它这道"按时间释放"的闸。
LLM 碎 token 流
│
▼
┌──────────────────┐ 整词 ┌──────────────────────────┐
│ rechunk_to_words │ ──────▶ │ _generate_response_task │ 逐词
│ (按空白切整词) │ │ 的喂词循环 │ ─────┐
└──────────────────┘ └──────────────────────────┘ │
▼
┌────────────────────┐
│ tts.send(word) │
│ msgpack ─▶ WS ─▶TTS │
└────────────────────┘
│ (TTS 服务器抢跑生成)
┌──────────────────────────────────────┘
▼
┌─────────────────────────┐
│ TextToSpeech.__aiter__ │ 收到 Audio / Text 消息
│ │
│ ┌───────────────────┐ │ put(音频, 收到样本数/采样率 − 4帧)
│ │ RealtimeQueue │ │ put(文字, message.start_s)
│ │ (堆:按时间戳排序)│ │
│ └───────────────────┘ │ get_nowait(): 只放"到点了"的
└─────────────────────────┘
│ 按真实时间 yield
▼
┌─────────────────────────┐
│ _tts_loop: │
│ 音频→output_queue(播) │
│ 文字→chat_history(字幕)│
└─────────────────────────┘
各部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
rechunk_to_words | 把碎 token 流重切成整词流(空格归到下一个词) | unmute/llm/llm_utils.py:65 |
preprocess_messages_for_llm | 喂 LLM 前清洗历史:合并同角色、去打断符、给 Gemma 塞假 user 消息 | unmute/llm/llm_utils.py:16 |
VLLMStream.chat_completion | 调 OpenAI 兼容接口拿流式补全,过滤空/keep-alive 块 | unmute/llm/llm_utils.py:140 |
_generate_response_task | 喂词循环:逐词发给 TTS,发完发 Eos | unmute/unmute_handler.py:184 |
TextToSpeech.send | 把一个词(或 Voice/Eos)msgpack 打包发给 TTS 服务器 | unmute/tts/text_to_speech.py:181 |
TextToSpeech.__aiter__ | 收 TTS 消息,塞进 RealtimeQueue,按时释放 | unmute/tts/text_to_speech.py:267 |
prepare_text_for_tts | 去掉不可发音字符(`*_`` 等)、规范引号 | unmute/tts/text_to_speech.py:97 |
RealtimeQueue | 按时间戳排序的堆,到点才放 | unmute/tts/realtime_queue.py:18 |
_tts_loop | 消费 TTS 输出:音频进播放队列,文字进字幕 | unmute/unmute_handler.py:508 |
主线走一遍(高层): LLM 流 → rechunk_to_words 攒成整词 → 喂词循环逐个 tts.send → TTS 服务器返回音频帧和带时间戳的文字 → RealtimeQueue 按时间戳掐点释放 → _tts_loop 把音频送去播、把文字送去当字幕。
3. 核心原理(逐个机制,由浅入深)
3.1 整词切分:为什么必须按整词喂
它要解决的小问题: LLM 流式返回的是任意切分的 token,一个词可能被劈成两半("wonder" → "won" + "der")。TTS 逐块拿到就无法定位词边界,会读错音。
思路: 在文本进 TTS 前加一道"重切分"——把流拼进一个 buffer,只在遇到空白时才切出一个完整的词;buffer 尾部那截"可能还没写完的词"留着,等下一块补齐。
一个关键细节:空格归到下一个词。 "foo bar baz" 被切成 "foo"、" bar"、" baz"——前导空格跟着后一个词走。这样拼回去时不用另外补空格,TTS 也能正确处理词间停顿。多个连续空白会被合并成一个空格。
原理演示:
# 示意,非源码:整词切分的核心想法
buffer = ""
prefix = "" # 除第一个词外,每个词前面带一个空格
async for delta in llm_stream:
buffer += delta
while (m := re.search(r"\s+", buffer)): # 找到一个空白 = 一个词写完了
word = buffer[:m.start()]
buffer = buffer[m.end():] # 空白连同后面留在 buffer
if word:
yield prefix + word # 吐出:前导空格 + 整词
prefix = " "
# 流结束后,buffer 里剩的最后一个词也要吐出来
if buffer:
yield prefix + buffer
真实实现: rechunk_to_words 在 unmute/llm/llm_utils.py:65-91。函数 docstring 直接点破动机:"Otherwise the TTS doesn't know where word boundaries are and will mispronounce split words."。注意 llm_utils.py:90-91 那个收尾——流结束后 buffer 里的最后一个词(它后面没有空白触发切分)必须补 yield,否则会丢词。
3.2 喂 LLM 前的消息清洗:三个坑
它要解决的小问题: 对话历史里有三种"脏东西"会让 LLM 犯迷糊,得在发出去前擦干净。preprocess_messages_for_llm(unmute/llm/llm_utils.py:16-62)一次处理这三件事:
| 坑 | 现象 | 处理 | 源码行 |
|---|---|---|---|
| 空打断消息 | 一次打断发生在 LLM 还没说出任何话之前,留下一条只含打断符 — 的消息 | 整条丢 弃 | llm_utils.py:27-28 |
| 打断符入上下文 | 若把结尾的 —(INTERRUPTION_CHAR, em-dash)留在历史里,LLM 可能有样学样,自己也去复读它 | .removesuffix(INTERRUPTION_CHAR) 去掉结尾打断符 | llm_utils.py:30-32 |
| 同角色相邻消息 | 打断/续话会产生两条相邻的同角色消息 | 合并成一条,中间加空格 | llm_utils.py:34-37 |
第四件事——Gemma 需要一条假 user 消息。 有些模型(如 Gemma)如果系统消息后面直接跟 assistant 消息(没有 user 消息在中间)会犯迷糊。所以当第 0 条是 system、第 1 条是 assistant 或不存在时,硬塞一条 {"role": "user", "content": "Hello."} 进去(llm_utils.py:44-47)。
注意有两处在处理"Gemma 需要 user 消息":
Chatbot.preprocessed_messages(unmute/llm/chatbot.py:77-92)在历史还很短(≤2 条,只有系统提示)时先补一条"Hello!";preprocess_messages_for_llm再针对"system 后面紧跟 assistant"的情况补一条"Hello."。前者管"对话刚开始",后者管"打断后 assistant 消息被顶到最前"。
第五件事——去静默标记。 用户长时间不说话时,系统会插一条以 ...(USER_SILENCE_MARKER)开头的 user 消息。如果用户在标记插入后、LLM 回应前又开口了,这条消息就变成 "...真正说的话"。系统提示里对 ... 有专门指令,所以要把开头的标记剥掉、只留真正内容,免得混淆 LLM(llm_utils.py:49-60)。
3.3 TTS 客户端协议:发什么、收什么
它要解决的小问题: Unmute 的 TTS 是一个独立的 WebSocket 服务(moshi-server),两边用 msgpack(二进制序列化,比 JSON 小/快)通信。得约定好发什么消息、收什么消息。
发给 TTS(client → server) 有三种消息,靠 type 字段区分(unmute/tts/text_to_speech.py:30-53):
| 消息 | 作用 | 关键字段 |
|---|---|---|
TTSClientTextMessage | "把这段文字读出来" | text |
TTSClientVoiceMessage | 传自定义声音的 embedding(声音克隆,见第 05 章) | embeddings, shape |
TTSClientEosMessage | "我发完了"(end of stream) | 无 |
从 TTS 收(server → client) 有四种(text_to_speech.py:56-81):TTSAudioMessage(一帧 PCM 音频,pcm 是浮点样本列表)、TTSTextMessage(一个词,带 start_s/stop_s 时间戳——这个时间戳是同步的关键)、TTSErrorMessage、TTSReadyMessage。
连接参数:cfg_alpha=1.5。 建连时用 TtsStreamingQuery(text_to_speech.py:111-129)把参数拼进 URL,其中 format="PcmMessagePack" 指定音频用 msgpack 打包的 PCM,cfg_alpha=1.5(在 TextToSpeech.__init__ 里硬编码,text_to_speech.py:154-161)是 classifier-free guidance 的强度——控制生成语音多大程度贴合给定声音/文本条件。
发送时的两点讲究(TextToSpeech.send, text_to_speech.py:181-204):
- 原始字符串会被预处理,
TTSClientTextMessage不会。 传进来的裸str会走prepare_text_for_tts清洗;但如果你已经构造好一个TTSClientTextMessage,它会原样发送(见 docstringtext_to_speech.py:182-185)。 - 空文本直接丢弃(
text_to_speech.py:198-199),并在发第一段文字时启动"首字延迟"计时器,用于统计 TTS 的 time-to-first-token。
prepare_text_for_tts 去掉不可发音字符(text_to_speech.py:97-108):删掉 markdown 残留的 *、_、反引号(否则 TTS 会试图"读"出这些符号),把花引号 “”‘’ 规范成直引号,并把 " : " 压成空格。
3.4 RealtimeQueue:按真实时间释放的堆(全章心脏)
它要解决的小问题: TTS 服务器抢跑——它比实时快,一股脑把音频和文字都算出来发过来了。如果收到就立刻放,声音会挤成一坨、字幕会跑到声音前头。需要一个东西:攒着这些带时间戳的项,到了它们该出现的真实时刻再放。
思路: 一个按时间戳排序的最小堆。每个项带一个"它该在第几秒被释放"的时间戳;释放时看"从启动到现在过了多久",只放那些"到点了"的项。
为什么用堆而不是队列? 因为项不一定按时间戳顺序到达(FIFO 不够用)。堆保证每次都能 O(log n) 取到"时间戳最小"的那个。看 RealtimeQueue 的 docstring:"Implemented as a heap, so it doesn't have to be FIFO."(unmute/tts/realtime_queue.py:18-22)。数据项是 TimedItem,用 @dataclass(order=True) + item 字段标 compare=False(realtime_queue.py:9-15)——这样堆只按 time 比较,不会因为 payload 不可比较而报错。
原理演示:
# 示意,非源码:RealtimeQueue 的核心
import heapq
queue = [] # 最小堆,元素是 (时间戳, 项)
start_time = now()
def put(item, t):
heapq.heappush(queue, (t, item))
def get_nowait(): # 只放"到点了"的,不阻塞
elapsed = now() - start_time
while queue and queue[0][0] <= elapsed:
yield heapq.heappop(queue)
真实实现: RealtimeQueue.put(realtime_queue.py:39-40)只管压堆;get_nowait(realtime_queue.py:59-66)吐出所有"时间戳 ≤ 已过时间"的项、不阻塞;get 和 __aiter__(realtime_queue.py:42-79)则会 await asyncio.sleep(delta) 等到下一个项到点——用于连接关闭后把剩余项按真实时间排空。start_if_not_started(realtime_queue.py:35-37)在第一帧到来时才锁定 start_time,即"时间轴的零点"。
外部时间函数是个巧思。 构造时可传 get_time(realtime_queue.py:24-33),默认用事件循环时间。Unmute 传的是 handler.audio_received_sec(unmute_handler.py:475)——即"已收到多少秒音频",而不是墙钟时间。这样整套时序绑定到音频流的进度,不受真实流速快慢影响(见 audio_received_sec 的说明 unmute_handler.py:273-278)。