数据截至 (上游 commit b21e54d6a845)
HuggingFace speech-to-speech — 架构与原理
30 秒导读: 这是一个开源的语音对话后端。你对着麦克风说话,它检测你说完了、转成文字、送给大模型、把回答合成成语音流回来——全程用 OpenAI Realtime 协议,所以任何写好的 Realtime 客户端把 URL 一改就能连过来。四个环节的模型全部可换,LLM 可以是 OpenAI,也可以是你本机的 llama.cpp。
1. 这是什么(零基础也能懂)
一句话定义
一个把「听 → 懂 → 说」串起来的低延迟语音代理服务器,对外长得跟 OpenAI 的 Realtime API 一模一样。
解决谁的什么问题
假设你要做一个会说话的机器人玩具,或者一个电话客服 bot。你需要的东西是:
- 判断用户什么时候说完了(不能他一停顿就抢话)
- 把语音转成文字
- 让大模型生成回答
- 把回答变成声音,边生成边播(不能等整段生成完)
- 用户中途插话时,立刻闭嘴
这五件事每一件都有现成的模型/库,但把它们拼起来并且低延迟、不串台是脏活。这个项目就是那份脏活的开源实现。README 称它已经作为 Reachy Mini 机器人的对话后端在生产环境运行(此为 README 陈述,代码内无从验证)。
它能做什么
| 能力 | 说明 |
|---|---|
| Realtime 兼容服务 | WebSocket /v1/realtime + WebRTC POST /v1/realtime/calls |
| 四段全部可换 | VAD / STT / LLM / TTS 各有多个后端,CLI 一个参数切换 |
| 打断(barge-in) | 用户开口即取消正在播的回答 |
| 实时字幕 | 说话过程 中就流式吐出转写增量 |
| 工具调用 | 云端 API 原生 tools,或本地模型用 <code>...</code> 提示词模拟 |
| 纯本地栈 | Parakeet(STT)+ llama.cpp / mlx-lm(LLM)+ Qwen3-TTS(TTS) |
| LLM 反向代理 | 把配置好的远端 LLM 再暴露成普通 OpenAI 端点给客户端做副业任务 |
用起来什么样
最小的一次跑通(依据:README.md Quickstart 段、pyproject.toml:105-106 的 [project.scripts]):
pip install speech-to-speech
export OPENAI_API_KEY=...
# 终 端 1:起服务器(默认 Parakeet STT + OpenAI Responses API + Qwen3-TTS)
speech-to-speech serve
# 终端 2:用自带的麦克风/扬声器客户端连上去
speech-to-speech talk --url ws://127.0.0.1:8765/v1/realtime
程序化连接就是标准 OpenAI SDK,只是把 base_url 指向本机:
# 示意,非源码(改编自 README 的 Realtime API 段)
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8765/v1",
websocket_base_url="ws://localhost:8765/v1",
api_key="not-needed", # 服务端本身不校验
)
with client.realtime.connect(model="local") as conn:
conn.send({"type": "session.update", "session": {...}})
for event in conn: # speech_started / transcription / audio delta ...
print(event.type)
一句话直觉
把它想成一条工厂流水线: 每个工位(VAD、STT、LLM、TTS)是一个独立线程,工位之间用传送带(队列)连接,每件半成品上贴着一张标签(turn_id + turn_revision + cancel_generation)。用户改口时,不需要去停机器——只要把标签作废,下游工位看到过期标签就直接把半成品扔进垃圾桶。
这条「标签作废」的暗线,是理解整个项目的钥匙。
2. 顶层全景(它大概怎么转)
主线数据流
怎么读这张图:从上到下是一次问答的时间顺序;注意 STT 不直接连 LLM,中间要绕一次协议层。
客户端(浏览器 / 打包客户端 / 任意 Realtime SDK)
│ 上行 PCM ▲ 下行 JSON 事件 + 音频
▼ │
┌──────────┐ ┌──────────────┐
│ VAD │ │ 发送循环 │
│切出语音段│ │ _send_loop │
└────┬─────┘ └──────▲───────┘
│ 语音段 │ PCM + 有序事件
▼ │
┌──────────┐ ┌───────────┐
│ STT │ │ TTS │
└────┬─────┘ └─────▲─────┘
│ 转写完成事件 │ 要念的句子
▼ │
┌──────────────────────┐ ┌───────────────┐
│ RealtimeService │ 生成请求 │ 输出处理器 │
│ 协议状态机+会话历史 │ ────────► │ LMOutputProc │
└──────────────────────┘ └───────▲───────┘
│ 文本片段 / 工具调用
┌──────┴──────┐
│ LLM │
└─────────────┘
为什么 STT 不直连 LLM? 因为「转写完了」和「该生成回答了」是两件事:协议层要先把转写落进会话历史、发出 input_audio_transcription.completed、判断这个回合是否已经作废,才决定要不要下单。这一跳发生在 RealtimeService._on_transcription_completed(src/speech_to_speech/api/openai_realtime/service.py:615-668),它构造 GenerateResponseRequest 丢进 text_prompt_queue。TranscriptionNotifier.process 本身从不产出任何东西给下游队列(结尾是 yield from (),src/speech_to_speech/STT/transcription_notifier.py:105)。
部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
VADHandler | 用 Silero 判定说话起止,切出语音段,发 speech_started/stopped | src/speech_to_speech/VAD/vad_handler.py |
SmartTurnAnalyzer | 语音段结束后再判一次「这句话说完了吗」,决定等多久 | src/speech_to_speech/VAD/smart_turn.py |
| STT handler | 语音段 → 文本(渐进 + 最终两种) | src/speech_to_speech/STT/parakeet_tdt_handler.py 等 |
TranscriptionNotifier | 把转写结果翻译成协议中立事件 | src/speech_to_speech/STT/transcription_notifier.py |
| LLM handler | 调 provider,把流式响应归一化成文本/工具事件 | src/speech_to_speech/LLM/base_openai_compatible_language_model.py |
LMOutputProcessor | 保序:把文本、工具、TTS 输入放同一条队列 | src/speech_to_speech/LLM/lm_output_processor.py |
| TTS handler | 文本 → PCM 流 | src/speech_to_speech/TTS/qwen3_tts_handler.py 等 |
RealtimeService | 协议状态机:事件解析、会话状态、历史、用量 | src/speech_to_speech/api/openai_realtime/service.py |
_send_loop_for | 出口总闸:决定哪些输出真正发给客户端 | src/speech_to_speech/api/openai_realtime/websocket_router.py:806-1091 |
PipelineUnit | 一条完整独立流水线(队列 + 状态 + handler 实例) | src/speech_to_speech/api/openai_realtime/pipeline_unit.py |
主线走一遍(高层)
- 客户端发
input_audio_buffer.append(base64 PCM)。AudioHandler.append_pcm重采样到 16 kHz,切成 512 采样一块喂给 VAD(src/speech_to_speech/api/openai_realtime/handlers/audio.py:122-149)。 - VAD 累积到「有效语音」时发
speech_started;检测到静音收尾时把整段音频作为VADAudio(mode="final")推给 STT。 - STT 出
Transcription;TranscriptionNotifier转成TranscriptionCompletedEvent放到旁路队列text_output_queue。 - 发送循环把该事件交给
RealtimeService,后者写历史 + 下GenerateResponseRequest。 - LLM 边流边按句子成批吐
LLMResponseChunk;LMOutputProcessor把每一段拆成「事件 + TTS 输入」放同一队列(保序的关键)。 - TTS 合成 PCM,连同事件一起走
send_audio_chunks_queue;发送循环编码成response.output_audio.delta发出去。 - 用户中途开口 → 发送循环调
cancel_scope.cancel(),世代号 +1,所有在途产物瞬间过期。
3. 阅读地图
建议顺序(由浅入深):
| 章节 | 讲什么 | 什么时候读 |
|---|---|---|
| 01 骨架 | handler/队列/线程模型、后端注册表、进程启动 | 必读,后面所有章的基础 |
| 02 断句与投机回合 | VAD、Smart Turn、turn_id/revision、打断 | 必读,这是本项目最核心的原创机制 |
| 03 语音转文字 | 渐进转写、过期过滤、MLX 全局锁 | 想理解「实时字幕怎么做」时读 |
| 04 语言模型与历史 | provider 事件归一化、按句批量、Chat 事务 | 想接自己的 LLM 时读 |
| 05 工具与预取 | 两种工具调用路径、投机预取事务 | 想做 function calling 时读 |
| 06 协议层 | 状态机、发送循环闸门、WebRTC、LLM 代理 | 想改协议行为或做客户端时读 |
| 07 语音合成 | 文本合并、流式切块、延迟指标 | 想优化「开口延迟」时读 |
| 08 精华与边界 | 可借鉴技术、局限、对比、总代码地图 | 收尾必读 |
4. 代码地图(总入口)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| CLI 命令分发 | src/speech_to_speech/cli.py | parse_command, main |
| 参数解析与流水线组装 | src/speech_to_speech/s2s_pipeline.py | parse_arguments, _build_handlers, build_pipeline |
| handler 基类(线程主循环) | src/speech_to_speech/baseHandler.py | BaseHandler.run |
| 后端注册表 | src/speech_to_speech/backend_registry.py | STT_BACKENDS, LLM_BACKENDS, TTS_BACKENDS |
| 投机回合跟踪器 | src/speech_to_speech/pipeline/speculative_turns.py | SpeculativeTurnTracker |
| 取消世代 | src/speech_to_speech/pipeline/cancel_scope.py | CancelScope |
| 队列消息类型 | src/speech_to_speech/pipeline/messages.py | VADAudio, LLMResponseChunk, TTSInput, EndOfResponse |
| 协议中立事件 | src/speech_to_speech/pipeline/events.py | SpeechStartedEvent, AssistantOutputEvent |
| 协议状态机 | src/speech_to_speech/api/openai_realtime/service.py | RealtimeService, ConnState |
| 出口发送循环 | src/speech_to_speech/api/openai_realtime/websocket_router.py | _send_loop_for, create_app |
| 官方设计说明(英文,项目自带) | src/speech_to_speech/api/openai_realtime/README.md | — |