数据截至 (上游 commit c49982eb3aea)
编排核心:一次对话回合怎么从音频走到音频
30 秒导读: 这是 Unmute 后端的主线。用户对着麦克风说话,几百毫秒后音箱里传出机器人的回答——中间这条流水线由谁在驱动?答案不是一个
while True的大状态机,而是 FastRTC 给的两个回调receive()(音频进)和emit()(音频出),外加一个从聊天记录末条消息实时推断出来的会话状态。本章讲清这条从音频到音频的回路怎么转,以及"什么时候该让机器人开口"这个决定是在哪里、怎么做出的。
本章是全书主线。它只讲中央控制回路:双回调、会话状态、响应触发时机、唯一出口、以及对外的事件协议。至于"怎么判断用户说完了"(停顿检测)留给 听:STT/VAD/轮次,"怎么逐词喂 TTS、按真实时间放音"留给 说:TTS 实时队列,"Quest 这个异步 RAII 怎么管生命周期与打断"留给 生命周期与打断。
1. 这是什么(零基础也能懂)
一句话定义: Unmute 后端是一个 WebSocket 服务,一端连着浏览器(收发 Opus 压缩音频),另一端连着三个远程模型服务(STT 转写、LLM 生成文本、TTS 合成语音),它负责把这几样编排成一场自然的语音对话。
它要解决的问题: 文本大模型只会读写字符串,没有耳朵也没有嘴。要让你能"打电话"跟它聊,得有人在中间盯着:实时听音频、判断你说完没有、把你的话喂给 LLM、把 LLM 吐的字逐个送去合成语音、再把语音流回你耳朵——而且你随时插嘴它得立刻闭嘴。这个"盯着"的角色,就是本章讲的编排回路。
一个直觉类比: 把后端想成一个同声传译的调度员。他不自己翻译(那是三个模型的事),他只做三件事:决定"现在轮到谁说"、把话从一个人的嘴传到下一个人的耳、以及在有人插话时喊停。Unmute 的巧妙在于:这个"轮到谁说"的判断,它不用一个专门的变量记着,而是每次现查——看看对话记录最后一句是谁说的。
用起来什么样(协议层面): 客户端连上 wss://.../v1/realtime,不断发 input_audio_buffer.append(base64 的 Opus 音频块),服务端不断回 response.audio.delta(base64 的 Opus 音频)以及一堆状态事件(response.created、转写增量、speech_started 等)。这套事件名字故意抄的 OpenAI Realtime API,所以 OpenAI 的客户端能几乎无改动地连过来。
本节不出现代码细节。记住一件事就够:输入是音频流,输出是音频流,中间隔着三个模型,后端是那个把它们串起来的人。
2. 顶层全景(它大概怎么转)
2.1 两层回路,别混淆
后端其实有两层回路,初学最容易把它们搅在一起。第一层是 WebSocket 的收发,第二层是音频处理逻辑。它们靠一个 asyncio.Queue(叫 output_queue)解耦。
- 外层(协议层):
main_websocket.py里的receive_loop和emit_loop,只管 WebSocket 上的字节:解 JSON、解 Opus、编 Opus、发 JSON。 - 内层(逻辑层):
UnmuteHandler的receive()和emit(),只管音频语义:喂给 STT、决定何时生成、从队列取要发的东西。
外层拿到一帧解码后的 PCM 就调内层的 receive();外层想发东西就调内层的 emit() 取一件。两层各跑各的 async 任务,互不阻塞。
2.2 一张图:从音频到音频
怎么读这张图:上半是"进"的方向(左→右),下半是"出"的方向(右→左),正中间的 output_queue 是唯一汇合点。 所有要发给客户端的东西——不管是音频、文本、还是状态事件——都先进这个队列。
┌─────────── ──────────────────────────────────┐
浏览器 │ UnmuteHandler(内层逻辑) │
(Opus 音频) │ │
│ append │ receive(pcm) │
▼ │ │ 喂音频 ──────────► STT 转写(远程) │
┌──────────┐ PCM 帧 │ │ 推断会话状态 │ ┌─────────┐
│receive_ ├────────────►│ │ 该说话了? ──► _generate_response │─────►│ LLM(远程)│
│loop │ 解 Opus │ │ │◄─────┤ 逐词流 │
│(外层) │ │ ▼ 逐词喂 │ └─────────┘
└──────────┘ │ ┌─────────┐ 文本delta │ ┌─────────┐
│ │ TTS 循环 │◄──────────────────────│─────►│ TTS(远程)│
┌──────────┐ Opus 帧 │ └────┬────┘ 音频/文本回来 │◄─────┤ 逐帧音频 │
│emit_loop ├◄────────────│ emit() │ │ └─────────┘
│(外层) │ 编 Opus │ ▲ ▼ │
└────┬─────┘ 取一件 │ └── output_queue(唯一出口)◄─────────┘
│ └─────────────────────────────────────────────┘
▼
浏览器(听到回答)
2.3 部件一句话职责
| 部件 | 干什么 | 在哪(文件:符号) |
|---|---|---|
websocket_route | 接受 /v1/realtime 连接,协商 subprotocol,建 handler | unmute/main_websocket.py:291 websocket_route |
_run_route | 用一个 TaskGroup 并发拉起收/发/生命周期四个任务 | unmute/main_websocket.py:380 _run_route |
receive_loop | 从 WS 收 JSON、解 Opus 成 PCM、调 handler.receive() | unmute/main_websocket.py:406 receive_loop |
emit_loop | 调 handler.emit() 取一件、必要时编 Opus、发 JSON | unmute/main_websocket.py:512 emit_loop |
UnmuteHandler.receive | 每来一帧音频:喂 STT、看状态、决定是否生成 | unmute/unmute_handler.py:280 receive |
UnmuteHandler.emit | 从 output_queue 取一件东西交给外层 | unmute/unmute_handler.py:393 emit |
_generate_response | 触发一次机器人回合(拉起 LLM+TTS) | unmute/unmute_handler.py:177 _generate_response |
Chatbot.conversation_state | 从 chat_history 末条消息现算会话状态 | unmute/llm/chatbot.py:21 conversation_state |
output_queue | 所有对外输出的唯一汇合队列 | unmute/unmute_handler.py:89(self.output_queue) |