从一段 base64 PCM 到一段合成语音:六步数据流

2026-08-09

接实时语音服务,最容易写错的不是协议字段,而是时机。什么时候该把界面切成「正在思考」,什么时候该把文字打到屏幕上,什么时候一段响应才算真的结束——这些答案都不在事件名里,而在数据流的形状里。

speech-to-speech 是 Hugging Face 的开源语音代理流水线(github.com/huggingface/speech-to-speech,仓库标注 Apache-2.0,2026-08-09 快照 star 11893),架构是 VAD → STT → LLM → TTS 四段级联,每个组件跑在自己的线程里、用队列相连,对外暴露一套兼容 OpenAI Realtime 的接口。这篇把「客户端发出一段 base64 PCM」到「客户端收到一段合成语音」中间的六步走一遍,依据是仓库内 src/speech_to_speech/api/openai_realtime/README.md 这份 Realtime Engine 架构文档。

先看服务端的形状

服务端是一个 FastAPI / uvicorn 应用,同时暴露 WebSocket 与 WebRTC 两种传输。每个会话从池里认领一个由队列驱动的 PipelineUnit,这个单元里装着该会话的 RealtimeService、轮次追踪器和取消状态。local 命令则是把这个服务和自带的麦克风/扬声器客户端组合起来,走环回 WebSocket 端点——所以本地模式和远程模式跑的是同一套东西。

池子大小由模块级参数 --num_pipelines 控制,默认值是 1。这个默认值值得单独记一下:它意味着开箱状态下这个服务是按单会话配的,你要接多路并发之前,先回 speech-to-speech serve -h 确认这个数改了没有。

六步数据流

第一步,入站音频。 客户端发 input_audio_buffer.append,内容是 base64 编码的 PCM。RealtimeService 负责解码、重采样到 16 kHz切成 512 采样点一块,然后放进 recv_audio_chunks_queue 交给 VAD。这里有两个隐含约定:块的粒度是「采样点数」而不是「毫秒数」,以及不管你原始采样率是多少,进流水线之前都会被拉到 16 kHz。

第二步,语音检测。 VAD 检测语音边界,在 text_output_queue 上发出 speech_started / speech_stopped,完整的一段话音频再进 STT。注意 VAD 的判定不是「静音就切」那么简单——它有一组可配置的等待,比如 --speech_pad_ms(默认 500)会把触发点之前的一段音频保留下来前置拼到语音段上,避免把开头的字咬掉。VAD 这一层的十九个参数各有各的脾气,另有专文细讲,这里只需要记住一件事:这些毫秒值是流水线自己引入的、可配置的等待,不是推理耗时,更不能拿来相加去凑一个「端到端延迟」

第三步,转写。 STT 的输出经过 TranscriptionNotifier,发出 transcription.delta / transcription.completedRealtimeService 把当前的修订版提交进会话状态,并据此创建 LLM 请求。「修订版」这个词是有讲究的:一段语音可能因为用户只是停顿而被重开、被更新,转写因此是有版本的,不是一锤定音。

第四步,生成。 LLM 产出文本以及可选的工具调用,然后 LMOutputProcessor 把输出劈成两路——干净文本给 TTS 去合成,assistant_text 连同工具调用字典放进 text_output_queue

这一劈是整套设计里最该先理解的一处。很多人接到一半会困惑:为什么助手说的话,音频是一条流、文字又是另一条流?答案就在这里。它们不是同一份数据的两种表示,而是从同一个 LLM 输出分岔出去的两条独立通路,各自有各自的队列和节奏。你在客户端上把「字幕」和「声音」严格对齐的想法,从架构上就不成立。

第五步,出站音频。 TTS 把 PCM 块写进 send_audio_chunks_queue。路由器的异步 _send_loop 同时抽干两个队列,把 PCM 编码成 response.output_audio.delta 事件,同时把内部消息翻译成协议事件。也就是说第四步分岔出去的两条路,到这里由同一个 send loop 汇合成一条线上事件流。

第六步,会话配置。 session.update 事件会被深合并RuntimeConfig——一个共享的 Pydantic 模型,由 VAD(回合检测阈值)、LLM(instructions、tools)、TTS(voice)在处理的时候读取

这一条同样容易想当然。它不是「改了配置要重连才生效」,而是处理时现读:你在会话中途改 instructions 或换 voice,下一次处理动作就会读到新值。好处是可以做运行中调参,代价是你得自己想清楚,一次生成正跑到一半时改配置会落在哪个边界上。而且它是深合并不是整体替换,只传你要改的那一层就行,没传的部分不会被清空。

三个把客户端写错的时机

流程走通之后,真正咬人的是下面三处。

其一,response.created 不是在请求发起时发的,是在第一个出站音频块出来时才发(响应此时进入 in_progress)。如果你按直觉把「正在思考」的转圈动画绑在 response.created 上,那这个动画会在声音已经开始播的那一刻才亮起来,等于白做。判断依据很清楚:要标识「我收到了、正在处理」,你得用自己发出 response.create 的那一刻,或者用 input_audio_buffer.speech_stoppedresponse.created 只能用来标识「音频开始了」。

其二,助手转写要改到 delta 上渲染。 助手侧的转写块以 response.output_audio_transcript.delta 发出,把这些 delta 拼起来,能复现终态的 response.output_audio_transcript.done.transcript。一个产生了转写文本的响应,恰好发一次转写 done,位置在 response.output_audio.done 之后、response.done 之前,取消关闭了未完成助手项的情况也照发(此时它带的是已累积的部分转写)。

架构文档专门为此写了迁移指引:以前消费每个块级 done 事件的客户端,必须把实时渲染改到 delta 上,把 done 只当定稿。仓库自带的音频客户端也接受遗留的 done-only 流,但它不会在显示完 delta 之后再重新渲染一遍完整终态转写。所以如果你手上有一份老客户端代码,这是个会静默改变行为的破坏性变更,别等到界面上文字重复或缺失了才回头找。

其三,conversation.item.create 不触发生成。 用它注入 input_textfunction_call_output,只会把内容送进 LLM 上下文并回一个 conversation.item.created,想让助手开口,得再发一次 response.create。这条在工具调用回流时尤其要记牢:工具结果需要说给用户听的(搜索、数据一类),补 response.create;「发射后不管」的动作类工具(架构文档举的是 Reachy Mini 一类机器人的跳舞、转头、待机),客户端收到 conversation.item.created 就可以停了。

WebSocket 还是 WebRTC

两种传输跑的是同一套协议、从同一个池里认领 pipeline unit,选哪个看部署形态。三处差异是硬的:

差异点WebRTC 下的行为
input_audio_buffer.append被拒绝,返回 invalid_event_for_transport——音频改走媒体轨
output_audio_buffer.clear仅 WebRTC 支持:未播放音频在服务端缓冲,抢话与取消要在服务端冲掉它;服务端在 response.cancel 与 VAD 打断时也会自动冲
session.created数据通道打开时发送,而不是连接时

WebRTC 侧的音频走 RTP 媒体轨(Opus,48 kHz,用有状态重采样器与流水线的 16 kHz 互转),所有 JSON 事件走 oai-events 数据通道;每单元的 send loop 依然是流水线输出队列的唯一消费者,把 PCM 交给传输层后由传输层按 20 ms RTP 帧节奏发出,空闲时发静音。

决策路径这么走:如果你的客户端是服务端到服务端、或者在你自己可控的内网里,WebSocket 的路径最短,append 直接发就行;如果客户端是浏览器或移动端、跨公网、需要靠 UDP 拿到抗抖动能力,那就是 WebRTC,代价是要装 speech-to-speech[webrtc] 这个 extra,客户端 POST 一个 SDP offer 到 POST /v1/realtime/callsContent-Type: application/sdp),拿回 201 与 Location: /v1/realtime/calls/{call_id} 头。ICE 服务器用环境变量 SPEECH_TO_SPEECH_ICE_SERVERS 配一个 JSON 列表,不设就用 aiortc 默认(host candidates 加 Google STUN)。架构文档明确写了:客户端无法直连服务端的部署(对称 NAT、没暴露 UDP 的容器)需要 TURN 服务器——这一句往往决定了你的项目要不要多养一台机器,值得在选型阶段就算进去。

另外,抢话相关的行为在两种传输下不一样:WebRTC 因为音频缓冲在服务端,取消时必须冲缓冲,所以才有那个 WebSocket 侧根本不存在的 output_audio_buffer.clear

一段能照抄的客户端骨架

架构文档给的 WebSocket 示例:

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": {
                "type": "realtime",
                "instructions": "You are a helpful assistant.",
                "audio": {
                    "input": {
                        "turn_detection": {
                            "type": "server_vad",
                            "interrupt_response": True,
                        }
                    }
                },
            },
        }
    )

    for event in conn:
        print(event.type)

端口 8765 是实时服务的端口(仓库 docker compose up 还会同时起一个跑 llama.cpp 的服务,那个是 8080,两个别记反)。注意那个 api_key="not-needed"——服务端自身不做认证,这不是示例图省事,是这个服务的真实边界。

同一条边界在 --enable_llm_proxy 上更要紧。开启后,实时服务会把它配置的那个远端 LLM 也作为普通 OpenAI 兼容端点暴露出来(POST /v1/chat/completionsPOST /v1/responses,取决于 --llm_backend 用的是 chat-completions 还是 responses-api),让客户端能跑摘要、起标题一类的侧边任务,与语音对话并发且不会被新的说话打断。它默认关闭,要求远端后端,否则返回 501 并附原因;请求是无状态的,代理时用服务端持有的 key(该 key 永不到达客户端),客户端传的 model 字段总是被覆写成服务端配置的 --model_name,客户端传的 API key 则被忽略。

官方在这里的声明必须原样带出:服务端自己不做任何认证,也不做任何限流。只应在受信网络上启用该代理,或者把服务部署在一个由你自己掌管访问控制的网关后面。 文档举的例子是 s2s-endpoint 的计算副本充当这样的网关,只对用 HF token 创建了会话的客户端开放这些路径、按 token 校验 API key 并按用户限流。同理,--host 0.0.0.0 也不是随手能加的——这些都不构成「这样配就安全了」的结论,具体方案得结合你自己的环境评估。另外,密钥一律走环境变量(例如 $OPENAI_API_KEY$HF_TOKEN)而不是写进命令行历史——这一条是通用运维做法,不是该项目官方文档里的内容。

把六步记成三次交接

如果只记一件事:这条流水线的每一级都在自己的线程里跑,级与级之间靠队列交接。入站是 recv_audio_chunks_queue,中段是 LLM 输出被劈成音频与文本两路,出站是 send_audio_chunks_queuetext_output_queue 被同一个 _send_loop 同时抽干(_send_loop 处理时文本事件优先于音频)。你在客户端看到的所有时序怪相——转写先于声音、response.created 姗姗来迟、取消后还收到一个转写 done——都能从这三次交接推出来。协议表可以查,数据流的形状得记在脑子里。

延伸阅读


本文依据 speech-to-speech 官方仓库(github.com/huggingface/speech-to-speech)的 README、 src/speech_to_speech/arguments_classes/ 下的参数定义与 Realtime Engine 架构文档整理,核对日 2026-08-09。 本文内容为仓库源码与文档口径,我们没有安装、部署或调用过该服务,文中毫秒值均为参数默认值而非实测延迟。 参数与默认值随版本变动,请以 speech-to-speech serve -h 的实际输出为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。