跳到主要内容

HuggingFace speech-to-speech — 架构与原理

30 秒导读: 这是 HuggingFace 开源的本地语音 agent 框架——你对着麦克风说话,它听懂、想好、再用合成语音答你。它没有用某个端到端的大语音模型,而是把「听声音在不在(VAD)→ 转文字(STT)→ 大模型生成(LLM)→ 合成语音(TTS)」四段级联成一条多线程流水线,每一段都能换成不同实现。它的难点不在「接四个模型」,而在做成真正能对话的全双工:你插嘴要能立刻打断它、你话没说完补一句它要能改口重答,而且对外还兼容 OpenAI Realtime API,现成客户端直接连。


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

一句话定义: 一个把「你说的话」变成「AI 说的话」的开源本地流水线——语音进,语音出。

解决什么问题 / 给谁用。 假设你想做一个能打电话、能语音聊天的 AI 助手,但不想把用户的声音发给第三方云,也想自由挑选每一环用哪个模型(哪个识别引擎、哪个大模型、哪个音色)。这个项目就是给你这条完整流水线:全部用 HuggingFace 生态里的开源模型,能跑在你自己的 GPU 或 Apple Silicon 上。

它能做什么。

  • 实时听音、判断你什么时候开始/停止说话(不用按按钮)。
  • 把语音转文字、喂给大模型、把回答合成语音播回来。
  • 可打断:AI 正在说话时你出声,它立刻停嘴听你的(barge-in)。
  • 边说边改:你说完一句它开始答,你紧接着又补一句,它能把两句合起来重答,而不是当成两轮(speculative turns)。
  • 每一段(VAD / STT / LLM / TTS)都能独立换件:Whisper、Parakeet、Qwen3、Kokoro、OpenAI API……
  • 对外暴露 OpenAI Realtime 兼容的 WebSocket,已有的 Realtime 客户端不改代码就能连。

用起来什么样。 装好后一条命令起服务,默认就是 realtime 模式:

# 示意:启动一个 OpenAI Realtime 兼容的语音服务器
python -m speech_to_speech.s2s_pipeline --mode realtime
# → ws://0.0.0.0:8765/v1/realtime 用任意 Realtime 客户端连上说话即可

入口是 src/speech_to_speech/s2s_pipeline.py:984(main),--modelocal(本机麦克风)/ socket / websocket / realtime(见 arguments_classes/module_arguments.py:11)。

一句话直觉/类比。 把它想成一条餐厅传菜流水线:VAD 是门口迎宾(判断客人来没来、说完没),STT 是把口头点单写成小票,LLM 是后厨出菜,TTS 是摆盘上桌。每个工位是一个独立的线程,工位之间用**传送带(队列)**连接——谁也不等谁,菜一份份往下传。难的是「客人中途改口」:得让整条线知道「刚才那张单作废/要改」,这就是本项目最精巧的部分。

本节不涉及任何底层代码细节。


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

2.1 一条流水线,六个工位

核心抽象只有一个:BaseHandler(baseHandler.py:23)。每个工位都是它的子类,有一个输入队列、一个输出队列,跑在自己的线程里,run() 循环地「从入队列取一项 → process() 处理 → 结果放出队列」。ThreadManager(utils/thread_manager.py:9)负责把所有工位各起一个线程。

怎么读下面这张图: 从左到右是一次对话的数据流向;每个方框是一个独立线程,方框之间的 是一个线程安全队列。

麦克风/客户端音频
│ (recv_audio_chunks_queue)

┌─────────┐ spoken_prompt ┌─────────┐ stt_output ┌────────────────────┐
│ VAD │ ───────────────► │ STT │ ────────────► │ TranscriptionNotifier│
│ 听声音 │ 一段语音 │ 转文字 │ 文字 │ 转文字→触发LLM │
└─────────┘ └─────────┘ └────────────────────┘
│ text_prompt

┌─────────┐ send_audio ┌──────────────────┐ lm_processed ┌─────────┐
│ TTS │ ◄────────── │ LMOutputProcessor │ ◄──────────── │ LLM │
│ 合成语音│ 音频块 │ 拆工具调用/转发文本│ 一句句文本 │ 生成 │
└─────────┘ └──────────────────┘ └─────────┘
│ (send_audio_chunks_queue)

播放/回传给客户端

链条在 s2s_pipeline.py:380(_build_pipeline_handlers)里被组装成固定顺序:VAD → STT → TranscriptionNotifier → LM → LMOutputProcessor → TTS

2.2 部件一句话职责

部件干什么在哪个文件
BaseHandler所有工位的基类:队列循环、停机哨兵、stale 过滤钩子baseHandler.py:23
VADHandler判断语音起止、切出一段完整话、管理话轮 id/revisionVAD/vad_handler.py:53
BaseSTTHandler 及子类语音转文字;丢弃过期话轮的输入/输出STT/base_stt_handler.py:16
TranscriptionNotifier把最终转录变成协议事件并触发 LLM 生成STT/transcription_notifier.py
BaseOpenAICompatibleHandler / LanguageModelHandler调大模型、流式分句、工具调用、写回历史LLM/base_openai_compatible_language_model.py:109
Chat有界会话历史;超限时驱逐或后台压缩LLM/chat.py:77
LMOutputProcessor从 LLM 输出里抽出工具调用,把干净文本转给 TTSLLM/lm_output_processor.py:26
TTS handlers(Qwen3/Kokoro/…)文字合成音频块TTS/*_handler.py
CancelScope一个 generation 计数器,实现打断(barge-in)pipeline/cancel_scope.py:1
SpeculativeTurnTracker话轮 revision 状态机,实现「边说边改」pipeline/speculative_turns.py:24
RealtimeServer / websocket_routerOpenAI Realtime 协议翻译、会话池、音频回传api/openai_realtime/server.py:13

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

  1. 客户端把 PCM 音频块通过 WebSocket 传进来,AudioHandler 解码、重采样成 16kHz、切成 512 样本一块,丢进 VAD 的入队列(handlers/audio.py:48)。
  2. VAD 用 Silero 模型逐块判断有没有人声。累积到「一段有效语音」且检测到停顿,就吐出一整段音频,并给它盖上一个话轮 id(turn_1)和 revision(0)。
  3. STT 把这段音频转成文字。
  4. TranscriptionNotifier / RealtimeService 把转录写进 Chat 历史,并往 LLM 的入队列塞一个「请生成回复」的请求。
  5. LLM 流式生成,边生成边按句子切,一批句子一批句子地往下吐(这样 TTS 能尽早开口,不用等整段生成完)。
  6. LMOutputProcessor 把文本转给 TTS、把工具调用抽出来走文本旁路发给客户端。
  7. TTS 把每句文本合成音频块,经 send_audio_chunks_queue 交给 WebSocket 送回客户端播放。
  8. 全程 CancelScopeSpeculativeTurnTracker 在旁边盯着:你一插嘴,前者让整条线上的「旧 generation」全部作废;你一补话,后者把当前话轮升一个 revision、让旧回复停下、拿更长的音频重来。

3. 阅读地图

这是一个大型多子系统项目(流水线运行时、话轮检测、投机话轮、语言模型、Realtime 服务器五大块),所以拆成多文件。建议按下面顺序读——由浅入深、后一章依赖前一章:

如果你只想抓精华:读完本篇 §4,再直接跳 03-speculative-turns.md


4. 巧妙之处(可借鉴的技术)

这是读者最该带走的部分。每条先说「妙在哪」,再给源码锚点。

4.1 用「generation 计数」实现打断,而不是靠时序脉冲

妙在哪: 打断一个正在生成的回复,天真做法是发个「停」信号,但流水线上 LLM、TTS 分别在不同线程、不同进度,信号很难同步到位。这里换了个思路:CancelScope 只维护一个整数 generation。每个线程在开始处理一个回复时记下当时的 generation,之后每前进一步就比一次;cancel() 只是把计数加一(cancel_scope.py:24)。于是所有「记着旧号」的线程,下一步一比就发现自己 stale、自动丢弃产物——无需锁、无需广播,靠 GIL 保证 int 读写原子(cancel_scope.py:8)。

  • is_stale(gen) 就是一句 gen != self._gen(cancel_scope.py:52)。
  • BaseHandler.should_process_input 用它在队列层面直接丢掉 stale 输入(baseHandler.py:54)。

4.2 话轮 revision 状态机:让「补一句」不变成「新一轮」

妙在哪: 你说「明天天气」,VAD 判定说完了、STT 转录、LLM 开答;可你紧接着补「北京的」。如果当成新一轮,上下文就割裂了。SpeculativeTurnTracker(speculative_turns.py:24)把它建模成同一个 turn 的多个 revision:VAD 在一个 grace 窗口内允许「重开」当前话轮,拿更长的音频前缀重新转录,把旧回复标记为 stale、用新 revision 重答。整套东西是线程安全的 revision 账本:observe/is_latest/commit/begin_reopen_candidate/confirm_reopen_candidate(speculative_turns.py:38-316),配合 Chat.replace_user_message_text(chat.py:257)原地替换用户消息文本,而不是追加一条。

4.3 流式分句:LLM 边生成,TTS 边开口

妙在哪: 若等 LLM 整段生成完再合成,首字延迟高得离谱。这里在消费 LLM 流时用 nltk.sent_tokenize 实时切句,凑够 stream_batch_sentences(默认 3)句就 flush 一批给 TTS(base_openai_compatible_language_model.py:365-375)。文本旁路(给客户端显示)保留原文,音频路则先过 remove_unspeechable 去掉不适合朗读的符号(utils.py:24),两条路各取所需。

4.4 每连接一条独立流水线:池化而非共享

妙在哪: 多个客户端同时连,如果共享一条流水线,状态会互相污染。这里 --num_pipelines N 预先构造 N 条互相隔离的 PipelineUnit(各自的队列、事件、CancelScopeSpeculativeTurnTracker、handler 实例,见 s2s_pipeline.py:448),放进 RealtimeServer 的池里;WebSocket 每次连接认领一个空闲 unit,断开时释放(websocket_router.py:282)。池满就直接拒绝新连接。深拷贝参数(s2s_pipeline.py:476)确保各 unit 的配置互不串味。

4.5 会话释放要「排空」而非「立即回收」

妙在哪: 客户端断开后立刻把 unit 还给池,可能有上一会话的残留输出还在管线里飘,被下一个客户端认领后就串了会话。这里用一个软控制消息 SESSION_END 从入队列穿过每一个 handler 一路回到输出队列;send loop 观察到它才置位 drained,_release_unit_after_drain 才真正释放(websocket_router.py:227)。宁可暂时少一个可用槽位,也不容忍跨会话泄漏——甚至故意不设超时兜底释放。

4.6 有界历史 + 后台 LLM 压缩,单飞不阻塞

妙在哪: 长对话会撑爆上下文。Chat 默认超限就同步驱逐最老一轮;但开启 compact_history 后,它会在后台线程用一次 LLM 调用把老对话摘要成一对 user/assistant 消息再拼回去(chat.py:512 _maybe_trigger_compaction),而且是单飞的——压缩进行中再触发直接跳过,主对话路径一秒都不卡。压缩线程是 daemon,会话关闭时用 _gen_counter 令其结果作废(chat.py:450)。

4.7 VAD 侧丢弃被取代的音频块

妙在哪: 话轮重开后,队列里可能还排着旧 revision 的「渐进式」音频块,再转录纯属浪费。VAD 在入队新块前,直接进队列的 mutex就地剔除已被取代的项(vad_handler.py:448 _drop_superseded_vad_audio);STT 侧也有对称的 _drop_stale_queued_inputs(base_stt_handler.py:103)。stale 数据越早丢越省算力


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

一张跳转表。行号 as-of sourceCommit;行号会漂,符号名更稳,agent 可用符号名 grep 定位。

主题文件路径符号名
进程入口、参数解析、模式选择src/speech_to_speech/s2s_pipeline.py:984main
组装流水线(按模式建 handler 链)src/speech_to_speech/s2s_pipeline.py:581build_pipeline
共享 handler 链(VAD→…→TTS)src/speech_to_speech/s2s_pipeline.py:351_build_pipeline_handlers
单条隔离 realtime 流水线src/speech_to_speech/s2s_pipeline.py:448_build_realtime_pipeline_unit
工位基类:队列循环 + stale 钩子src/speech_to_speech/baseHandler.py:23BaseHandler
队列主循环、哨兵、SESSION_ENDsrc/speech_to_speech/baseHandler.py:84BaseHandler.run
线程编排src/speech_to_speech/utils/thread_manager.py:9ThreadManager
typed 消息定义 + 二进制哨兵src/speech_to_speech/pipeline/messages.py:24PipelineMessage / PIPELINE_END
打断:generation 计数器src/speech_to_speech/pipeline/cancel_scope.py:1CancelScope
投机话轮:revision 状态机src/speech_to_speech/pipeline/speculative_turns.py:24SpeculativeTurnTracker
VAD:语音起止 + 话轮管理src/speech_to_speech/VAD/vad_handler.py:53VADHandler
VAD 流式迭代器(Silero 封装)src/speech_to_speech/VAD/vad_iterator.py:6VADIterator
STT 基类:stale 过滤src/speech_to_speech/STT/base_stt_handler.py:16BaseSTTHandler
LLM 共享编排:分句/工具/历史src/speech_to_speech/LLM/base_openai_compatible_language_model.py:109BaseOpenAICompatibleHandler
流式分句消费src/speech_to_speech/LLM/base_openai_compatible_language_model.py:310_consume_streaming
有界会话历史src/speech_to_speech/LLM/chat.py:77Chat
后台压缩触发src/speech_to_speech/LLM/chat.py:512Chat._maybe_trigger_compaction
抽工具调用 + 转发 TTSsrc/speech_to_speech/LLM/lm_output_processor.py:26LMOutputProcessor
Realtime 服务器 + 会话池src/speech_to_speech/api/openai_realtime/server.py:13RealtimeServer
一条隔离流水线单元src/speech_to_speech/api/openai_realtime/pipeline_unit.py:38PipelineUnit
WebSocket 路由 + 认领/释放 + send loopsrc/speech_to_speech/api/openai_realtime/websocket_router.py:258create_app
协议翻译服务(事件↔消息)src/speech_to_speech/api/openai_realtime/service.py:179RealtimeService
入站音频解码/切块、出站编码src/speech_to_speech/api/openai_realtime/handlers/audio.py:31AudioHandler