示例成品 · 平台演示,按左边这组点选真跑出来的
# 语音助手“边说边答”架构:直接给骨架
先给结论:你大概率卡在三个地方:
1. ASR 等整句 final 才给大模型。
2. 大模型生成完一整段才给 TTS。
3. TTS 不是流式,首包延迟高。
正确链路是:
> 20ms 音频流 → 流式 ASR 出 interim → 提前判断“能答了” → OpenAI 流式 LLM → 句子级流式 TTS → 浏览器边收边播。
> 用户一说话,就打断当前 LLM/TTS。
---
## 1. 两条落地路线
### 路线 A:先用 OpenAI Realtime API 跑通,最省事
如果你不强制自己拆 ASR / LLM / TTS,OpenAI Realtime API 是最快的:
- 浏览器采集音频。
- 后端只做 OpenAI Realtime API 的转发和鉴权。
- 音频进出都由 OpenAI 完成。
- 天然支持边听边说、打断、流式。
适合先把产品跑起来验证体验。
### 路线 B:自己串 ASR → LLM → TTS
如果要做自建串行链路,下面这套骨架可直接拿过去改。
---
## 2. 目标架构
```
React 浏览器
- 麦克风 PCM16/16k,每 20ms 一包
- WebSocket 发二进制音频
- 收到后端 PCM 后 AudioContext 播放
- 本地检测用户说话,触发 interrupt
│
│ WebSocket binary + JSON
▼
Python FastAPI
- 音频队列
- 流式 ASR:interim / final
- Turn 控制器:提前触发 LLM
- OpenAI 流式 LLM
- 句子缓冲 → 流式 TTS
- 上下文摘要 / 滑动窗口
```
核心原则:
- 音频用二进制 WebSocket,不要用 JSON base64。
- ASR 必须流式,能吐 interim。
- LLM 必须流式。
- TTS 必须流式或至少按小句子合成。
- 打断不是单纯前端停止播放,后端必须取消当前任务。
---
## 3. 前后端通信协议
### 前端 → 后端
```text
binary: PCM16/16k 音频分片,20ms~40ms
JSON:
{ "type": "interrupt", "reason": "user_speech" }
```
### 后端 → 前端
```json
{ "type": "session", "session_id": "abc", "sample_rate": 16000, "tts_sample_rate": 24000 }
{ "type": "transcript", "status": "interim", "text": "我想问" }
{ "type": "transcript", "status": "final", "text": "我想问一下今天天气" }
{ "type": "llm_start", "epoch": 12 }
{ "type": "llm_delta", "text": "好的" }
{ "type": "tts_end", "epoch": 12 }
{ "type": "interrupt_ack" }
```
后端还会发:
```text
binary: TTS 音频 PCM16/24k
```
---
## 4. React 前端关键骨架
下面代码是核心逻辑,生产建议把 `ScriptProcessor` 换成 `AudioWorklet`,但 ScriptProcessor 能跑。
```ts
// useVoiceChat.ts
import { useRef, useState } from "react";
const MIC_SAMPLE_RATE = 16000;
const TTS_SAMPLE_RATE = 24000;
export function useVoiceChat(sessionId: string) {
const wsRef = useRef<WebSocket | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const playQueueRef = useRef<AudioBufferSourceNode[]>([]);
const nextPlayTimeRef = useRef<number>(0);
const playingRef = useRef(false);
const playStartedAtRef = useRef(0);
const [liveText, setLiveText] = useState("");
async function start() {
const ws = new WebSocket(
`ws://localhost:8000/ws/voice?session_id=${sessionId}`
);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
ws.onmessage = async (ev) => {
if (typeof ev.data === "string") {
const msg = JSON.parse(ev.data);
if (msg.type === "llm_delta") {
setLiveText((prev) => prev + msg.text);
}
if (msg.type === "tts_end") {
playingRef.current = false;
}
return;
}
// 二进制音频
if (ev.data instanceof ArrayBuffer) {
playPCM(ev.data);
}
};
const ctx = new AudioContext();
audioCtxRef.current = ctx;
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
const source = ctx.createMediaStreamSource(stream);
const processor = ctx.createScriptProcessor(1024, 1, 1);
source.connect(processor);
processor.connect(ctx.destination);
processor.onaudioprocess = (e) => {
const input = e.inputBuffer.getChannelData(0);
const inputRate = e.inputBuffer.sampleRate;
// 重采样到 16k,转 Int16
const pcm16 = downsampleTo16k(input, inputRate);
if (ws.readyState === WebSocket.OPEN) {
ws.send(pcm16.
点左边「开工 · 直接出成品」,出一份你自己的版本(文字免费)