跳到主要内容

Unmute — 给文本 LLM 装上耳朵和嘴的实时语音层

30 秒导读: Unmute 让一个只会读写文字的 LLM 能听会说。你对着麦克风讲话,它实时转成文字喂给任意文本 LLM,再把 LLM 吐出的文字实时读成语音播回来——而且你随时能打断它。它自己不训练任何模型,真正的活是当一个死磕延迟的编排层:用 websocket 把远端的语音识别、LLM、语音合成三段拼成一条流水线,难点全在"什么时候该开口、怎么让声音和字幕对齐、被打断了怎么干净收场"。

1. 这是什么(零基础也能懂)

一句话定义: Unmute 是一个实时语音对话的后端编排器,把"语音转文字(STT)→ 文本 LLM → 文字转语音(TTS)"三段接成一条低延迟流水线,让纯文本模型获得语音交互能力。

解决什么问题 / 给谁用。 想象你有一个很聪明但只能打字的聊天模型。你想像打电话一样跟它说话——你说话它就听懂,它回答你就直接听见声音,而且你插一句嘴它就闭嘴听你说。要自己从头搭这套东西,难点根本不在"调用模型",而在时序:什么时候算你说完了、字幕怎么跟声音对齐、被打断时那些还在飞的音频怎么办。Unmute 把这些脏活全包了。它给两类人用:

  • 想给自己的文本 LLM 加语音层的开发者(可换任意 OpenAI 兼容的 LLM);
  • 想研究低延迟实时语音管线怎么工程化的人。

它能做什么(功能):

  • 实时流式听写用户说的话(Kyutai STT,边说边出字);
  • 停顿预测判断"用户说完了没",决定何时让 LLM 开口;
  • 把 LLM 的回答逐词喂给 TTS,一边生成一边播,不用等整句;
  • 支持打断:你一开口,机器人立刻停;
  • 多种人格/声音(系统提示 + 声音库),甚至支持上传音频克隆声音;
  • 协议基于 OpenAI Realtime API,前端可替换。

用起来什么样。 最直接的体验是打开 unmute.sh 点 "connect" 对着麦克风说话。要自己跑,一条命令拉起全部服务:

# 需要一块 ≥16GB 显存的 NVIDIA GPU;STT/LLM/TTS 都在本地容器里跑
export HUGGING_FACE_HUB_TOKEN=hf_... # LLM 权重要用
docker compose up --build # 浏览器打开后点 connect 就能说话

想脱离浏览器直接对接后端的,仓库里有个 Python 客户端 unmute/loadtest/loadtest_client.py,它走的就是和真实前端一样的 websocket 协议。

一句话直觉 / 类比。 把 Unmute 想成一个电话总机接线员:它两只耳朵一只贴着你(STT),一只贴着"大脑"(LLM),嘴巴接着扬声器(TTS);它的核心本事不是这三样里的任何一样,而是判断该谁说话、把话准时接过去、有人插话就立刻转接。真正难的从来是这个调度,不是三个部件本身。

本节到此为止,不涉及底层代码——记住"它把 STT/LLM/TTS 编排成一条实时语音流水线"即可。

2. 顶层全景(它大概怎么转)

2.1 部件与数据流

Unmute 后端自己不含任何模型:STT、LLM、TTS 全是独立进程 / 远端服务,后端只通过 websocket 跟它们对话。真正的编排代码集中在一个类 UnmuteHandler(unmute/unmute_handler.py:80)里。

怎么读下面这张图: 从左到右是一次对话的数据流向;中间那个 UnmuteHandler 是唯一的"大脑",左右两侧都是它通过 websocket 连出去的外部服务。用户的音频从左上进,合成语音从左下出。

音频帧 ┌──────────── Kyutai STT ────────┐
用户浏览器 ──────────────►┌───────────────────┐ │ 流式转写 + 每帧吐"停顿概率" │
(WebRTC/WS) │ │──►└────────────────────────────────┘
▲ │ UnmuteHandler │ (边说边出字 + VAD 信号)
│ 合成语音 + 字幕 │ 「编排大脑」 │
└─────────────────────│ │──►┌──── 任意文本 LLM (OpenAI 兼容) ─┐
│ · 判断该谁说话 │ │ 逐 token 流式生成回答 │
│ · 逐词转发 │ └────────────────────────────────┘
│ · 打断处理 │
│ │──►┌──────── Kyutai TTS ────────────┐
└───────────────────┘ │ 逐词吃文字,流式吐 PCM 音频 │
└────────────────────────────────┘

各部件一句话职责:

部件干什么在哪(文件 / 符号)
WebSocket 入口建连、鉴权、把每个连接绑一个 handlerunmute/main_websocket.py:290 websocket_route
编排大脑全流程调度:听→判断→说→打断unmute/unmute_handler.py:80 UnmuteHandler
STT 客户端连 Kyutai STT,流式收词 + 停顿概率unmute/stt/speech_to_text.py:72 SpeechToText
对话状态机维护 chat_history,判断当前是谁在说unmute/llm/chatbot.py:12 Chatbot
LLM 客户端连 OpenAI 兼容 LLM,流式收 tokenunmute/llm/llm_utils.py:126 VLLMStream
TTS 客户端连 Kyutai TTS,逐词送、按时释放音频unmute/tts/text_to_speech.py:132 TextToSpeech
实时队列按时间戳释放音频/字幕,做音画同步unmute/tts/realtime_queue.py:18 RealtimeQueue
生命周期管理async 版 RAII,统一起停 STT/LLM/TTSunmute/quest_manager.py:24 Quest / :101 QuestManager

2.2 主线走一遍(高层,不进代码)

一次完整的"你说一句、它答一句"是这样流动的:

  1. 建连即开听。 连上后 handler 立刻拉起 STT(start_up_stt,unmute/unmute_handler.py:422),开始把麦克风音频一帧帧转发过去。
  2. 边说边转写。 STT 流式返回识别到的词,同时每一帧返回一个"停顿概率";handler 把词追加进 chat_history,状态变成 user_speaking
  3. 判断你说完了。 当停顿概率超过阈值(determine_pause,unmute/unmute_handler.py:372),handler 认为一句话结束,进入生成回合。
  4. LLM 逐词吐、TTS 逐词吃。 _generate_response_task(unmute/unmute_handler.py:184)一边从 LLM 流式收词,一边立刻把每个词送给 TTS,不等整句。
  5. 按真实时间播出去。 TTS 流式吐 PCM 音频,RealtimeQueue 按时间戳节流释放,保证声音和字幕对齐后送回浏览器。
  6. 随时能打断。 只要 STT 在机器人说话时听到你开口,interrupt_bot(unmute/unmute_handler.py:583)立刻清空音频队列、拆掉 TTS/LLM,把话筒交还给你。

这条主线的每一步都在后面各章展开。

3. 阅读地图(建议顺序)

这个子库拆成 5 章,由浅入深。建议从 §01 建立全局主线,再按兴趣钻具体机制:

如果你只想读一章: 读 §01。它是把所有部件缝在一起的地方,读懂它就懂了 Unmute 的骨架。

4. 巧妙之处(值得带走的精华)

Unmute 的价值几乎全在时序工程的细节里。这里先点几处最妙的,细节见对应章节:

  • 停顿预测,不是简单的"静音检测"。 STT 服务器每一帧都吐一个"用户是不是说完了"的概率,后端用指数移动平均平滑它,超阈值才判定一句结束(unmute/stt/speech_to_text.py:87pause_prediction:212update;determine_pauseunmute/unmute_handler.py:387> 0.6)。这比"停了 X 秒就算说完"自然得多。→ §02

  • 逐词流水,不等整句。 LLM 每吐一个词,_generate_response_task 立刻转给 TTS(unmute/unmute_handler.py:225-251),TTS 也逐词吐音频。整条链是流式的,首字延迟被压到最低。→ §01/§03

  • 用堆队列做音画同步。 TTS 的音频/字幕带着"未来的时间戳"更快于实时地到达,RealtimeQueue(unmute/tts/realtime_queue.py:18)是个最小堆,严格按时间戳 await asyncio.sleep(delta) 才释放,让声音和字幕都在"该出现的真实时刻"出现。→ §03

  • 打断时换掉整个 output_queue。 打断不是简单地"停一下":interrupt_bot(unmute/unmute_handler.py:596)直接把 self.output_queue 换成一个新的空队列,这样即使旧的 TTS worker 还在跑,它推的音频也只会进那个被废弃的旧队列,绝无可能污染新回合。→ §04

  • Quest:给 Python 补一个 async 版 RAII。 作者自嘲这是"a desperate attempt at having some kind of RAII in Python"(unmute/quest_manager.py:1)。QuestManager.add 的规则是"加一个同名 Quest 就自动取消旧的"(:115),于是"重启 TTS""换 LLM"这类操作变成一行,资源清理有保证。→ §04

  • 靠 "bye!" 收尾,不用 function calling。 判断"该结束通话了"时,它直接看 LLM 最后一句是不是以 bye! 结尾(check_for_bot_goodbye,unmute/unmute_handler.py:621),并特意注释说这样是为了不绑定特定 LLM(function calling 会让换模型变难)。→ §01/§05

5. 代码地图(导航索引)

给要跳进源码的人 / agent。行号 as-of commit 84aa28d;优先用符号名 grep,行号可能随上游漂移。

主题文件路径符号
WebSocket 入口 / 路由unmute/main_websocket.py:290websocket_route
FastAPI app + 健康检查 + 声音接口unmute/main_websocket.py:60app / get_health / voices
编排大脑(核心类)unmute/unmute_handler.py:80UnmuteHandler
收音频主循环unmute/unmute_handler.py:280UnmuteHandler.receive
吐输出主循环unmute/unmute_handler.py:393UnmuteHandler.emit
一次生成回合unmute/unmute_handler.py:184_generate_response_task
停顿判定unmute/unmute_handler.py:372determine_pause
打断机器人unmute/unmute_handler.py:583interrupt_bot
长时间沉默处理unmute/unmute_handler.py:626detect_long_silence
STT 客户端unmute/stt/speech_to_text.py:72SpeechToText
停顿概率(EMA 平滑)unmute/stt/speech_to_text.py:87pause_prediction
EMA 实现unmute/stt/exponential_moving_average.pyExponentialMovingAverage
对话状态机unmute/llm/chatbot.py:12Chatbot / conversation_state
LLM 流式客户端unmute/llm/llm_utils.py:126VLLMStream
逐词重切分unmute/llm/llm_utils.py:65rechunk_to_words
打断符 / 沉默标记unmute/llm/llm_utils.py:12-13INTERRUPTION_CHAR / USER_SILENCE_MARKER
TTS 客户端unmute/tts/text_to_speech.py:132TextToSpeech
音画同步节流unmute/tts/text_to_speech.py:94AUDIO_BUFFER_SEC
实时队列(堆)unmute/tts/realtime_queue.py:18RealtimeQueue
async RAIIunmute/quest_manager.py:24Quest
Quest 管理器(同名即取消)unmute/quest_manager.py:101QuestManager
服务发现 / 启动unmute/service_discovery.pyfind_instance / ServiceWithStartup
系统提示(可含动态元素)unmute/llm/system_prompt.pyConstantInstructions / Instructions
声音库 / 定义voices.yaml · unmute/tts/voices.py:176VoiceList
声音克隆(嵌入缓存)unmute/tts/voice_cloning.pyvoice_embeddings_cache
协议事件定义(ORA 变体)unmute/openai_realtime_api_events.py