1. 为什么实时语音对话的链路必须交给WebRTC而不是普通HTTP
先抛一个很多人刚接触语音交互时会踩的坑:想做网页端语音助手,第一反应是麦克风采集完音频,fetch扔给AI接口,拿回文本再播放。这个方案在小规模Demo里能跑通,但一旦涉及"实时对话"四个字,问题就全出来了——你会遇到音频数据怎么切分、怎么应对网络抖动、AI返回的音频流如何边收边播、用户打断时怎么及时停止,这几个问题环环相扣,用HTTP轮询和短连接根本没法优雅解决。
我大概一年半前开始做这个方向的实验项目,目标是做一个纯浏览器端的语音对话助手,用户打开页面就能说话,AI用语音回答,整体体验尽量接近和真人说话的感觉。最初我也尝试过最简单的方案:getUserMedia采集音频,定时用Blob上传,等服务器返回完整音频再播放。结果用户体验非常糟糕,一句话要等两三秒才开始有反应,稍微多一点噪音识别率就掉得厉害。后来把链路彻底换成 WebRTC 承载音频传输,再用标准 Web Audio API 做音频处理和流式发送,延迟从秒级降到毫秒级,整个体验完全不一样了。
为什么 WebRTC 适合这个场景?因为它本来就是为了实时音视频设计的,底层走的是 UDP 优先的传输策略,配合音视频特有的抗丢包、抖动缓冲、回声消除机制,天然适合"持续、双向、低延迟"的语音流。HTTP 那种"请求-响应"模型是离散的,不适合流式数据的长连接场景。虽然也能用 WebSocket 传输音频,但 WebSocket 只解决"双向长连接"问题,不解决"音频质量在网络波动下如何保持稳定"的问题,像自适应码率、丢包补偿、回声消除这些能力,全得你自己实现,工程量瞬间翻好几倍。
代码层面,WebRTC 方案的核心链路分四段:
- 用
getUserMedia从麦克风拿原始音频流。 - 用
AudioContext把采样率统一到 AI 接口要求的规格,比如 16kHz 单声道。 - 创建
MediaStreamAudioSourceNode连接一个ScriptProcessorNode或AudioWorkletNode,按固定时间片抓取 PCM 数据。 - 把 PCM 字节流通过 WebSocket 或 WebRTC DataChannel 实时推给后端,后端转发给 AI 语音接口,再把 AI 返回的音频流传回前端播放。
这一步选型直接决定了后面所有环节的写法,我强烈建议不要在采集和传输层省事,换来换去最后省下的时间都会在调试延迟和卡顿的时候加倍还回去。
2. 前端音频链路:从麦克风权限到PCM字节流的全流程
2.1 麦克风采集与采样率统一,这是第一个隐形地雷
getUserMedia拿到的音频流,采样率是由设备驱动和浏览器决定的,可能是 48kHz、44.1kHz,甚至 16kHz。但国内大部分AI语音接口,尤其是语音识别类接口,普遍要求的输入格式是 16kHz 单声道、16bit PCM。如果不做采样率转换就直接推流,识别结果会惨不忍睹——声音变调、语速异常、识别错误非常多。
这里的标准做法是借助AudioContext的sampleRate属性。具体写法是:
async function initAudio() { const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); // 强制输出采样率为16kHz,浏览器会自动做重采样 const audioContext = new AudioContext({ sampleRate: 16000 }); const source = audioContext.createMediaStreamSource(stream); const processor = audioContext.createScriptProcessor(4096, 1, 1); source.connect(processor); processor.connect(audioContext.destination); processor.onaudioprocess = (event) => { const inputData = event.inputBuffer.getChannelData(0); const pcmData = convertFloat32ToInt16(inputData); // 这里把 pcmData 交给发送模块 }; } function convertFloat32ToInt16(float32Array) { const int16Array = new Int16Array(float32Array.length); for (let i = 0; i < float32Array.length; i++) { let s = Math.max(-1, Math.min(1, float32Array[i])); int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; } return int16Array.buffer; }很多人会忽略AudioContext的sampleRate参数,创建一个默认的上下文,然后发现延迟和音调不对。道理很简单,AudioContext不指定输出采样率时,会跟随系统默认设备,你在 Mac 上测是 48kHz,在 Windows 上又是另一个值,代码换个环境就跑不通。
另外echoCancellation、noiseSuppression、autoGainControl这三个约束默认值在不同浏览器里并不完全一样,Chrome 默认开,Safari 有些版本默认关。做语音对话场景,这三个开关建议显式全开,不然远端回放的声音会串进麦克风里,AI 那边会听到自己在说话,形成回声反馈循环。
2.2 AudioWorklet 取代 ScriptProcessor,别在性能上留隐患
上面代码里用了ScriptProcessorNode,它是老 API,在主线程上处理音频,遇到页面复杂时容易卡顿。Chrome 已经明确标记 deprecated,但兼容性最好,Safari 和 Firefox 都支持。如果追求性能且目标用户主要用 Chrome,建议改成AudioWorklet,它跑在独立线程,不会阻塞 UI,音频回调更稳定。
AudioWorklet 的代码分成两个文件,一个是注册到 worklet 的处理器,一个是主线程的调用逻辑。简化版如下:
// audio-processor.js class PCMProcessor extends AudioWorkletProcessor { process(inputs) { const input = inputs[0]; if (input && input.length > 0) { const channelData = input[0]; const int16Data = new Int16Array(channelData.length); for (let i = 0; i < channelData.length; i++) { let s = Math.max(-1, Math.min(1, channelData[i])); int16Data[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; } this.port.postMessage(int16Data.buffer, [int16Data.buffer]); } return true; } } registerProcessor('pcm-processor', PCMProcessor);主线程这边:
await audioContext.audioWorklet.addModule('/audio-processor.js'); const workletNode = new AudioWorkletNode(audioContext, 'pcm-processor'); workletNode.port.onmessage = (event) => { // event.data 就是 Int16 PCM 字节数组 sendAudioToServer(event.data); }; source.connect(workletNode); workletNode.connect(audioContext.destination);postMessage时把ArrayBuffer的转移列表一起传过去,可以避免拷贝一份数据,降低内存和 GC 压力。
这里有个细节:connect(audioContext.destination)不是为了让用户听到麦克风声音,而是为了让音频图保持活跃。如果节点不连到 destination,有些浏览器会认为没有输出,从而暂停处理回调。这个坑我在 Safari 上踩过,处理器不响,整个链路静默,排查了很久才意识到是图连接的问题。
2.3 数据发送节奏与静音检测
拿到 PCM 字节流之后,不能一股脑全发。一般按 20ms 或 40ms 一个包发送比较合适。16kHz、16bit、单声道,20ms 的数据量是 16000 * 2 * 0.02 = 640 字节。这个大小在 WebSocket 里非常轻量,既不会让网络包太碎,也不会因为包太大增加延迟。
静音检测建议放在发送端做,而不是等 AI 接口去做。连续几帧音量低于阈值时可以直接不发或发一条静音标记,节省流量和接口算力。简单实现就是算每个缓冲区的 RMS(均方根):
function calculateRMS(int16Array) { let sum = 0; for (let i = 0; i < int16Array.length; i++) { sum += int16Array[i] * int16Array[i]; } return Math.sqrt(sum / int16Array.length); }阈值我一般取 500-800 的范围,低于这个值判定为静音。但注意,判定静音不能只看瞬间,需要连续 5-8 帧都低于阈值才切换状态,避免把说话间的自然停顿误判成静音导致断句。
3. AI接口对接方案:流式协议、算力边界与密钥安全
3.1 流式语音接口的两种常见形态
现在的 AI 语音接口大致分两类。一类是"语音识别 + 大模型 + 语音合成"三段式组合,分别调用不同接口;另一类是端到端的语音大模型接口,直接把音频流发过去,返回音频流。第一种更常见,也更容易控制每一段的延迟和效果,适合做工程化产品;第二种体验更自然,但接口选择和参数调节空间小。
三段式方案的典型连接方式如下:
- 语音识别接口(ASR)用 WebSocket 连接,持续上传 PCM 流,返回实时识别文本。
- 文本送入 LLM 接口,比如 Spring AI 2.0 对接 GLM 这类大模型接口,拿到流式回复文本。
- 回复文本送入语音合成接口(TTS),用流式返回的音频直接播放。
这里面每一步都可能成为延迟瓶颈。ASR 端到端的识别延迟一般在 300-800ms,LLM 首个 token 的返回时间取决于模型和算力,TTS 首包延迟在 200-500ms。一个交互周期总延迟 = ASR 尾字延迟 + LLM 首token + TTS 首包 + 网络RTT,算下来很容易超过 1.5 秒。要优化,就必须让三段并行起来,也就是"边说边识别、边识别边生成、边生成边合成",而不是等整句话识别完再去调 LLM。
3.2 后端转发层选型:为什么需要一个薄薄的网关层
前端直接调 AI 接口,短期看最省事,但有两个致命问题。一个是 CORS 限制和密钥安全,API 密钥不能放前端,否则等于公开在浏览器控制台里,任何访客都能看到你的密钥并盗刷你的额度。另一个是协议不好统一,不同 AI 提供商的接口协议千差万别,有的走 WebSocket,有的走 SSE,有的要求特定鉴权头,前端直接适配三五家供应商,代码会变得非常混乱。
实际操作中我一般用一个薄后端做网关层。前端只跟自己的后端建立 WebSocket 或 WebRTC 连接,后端负责调用 AI 接口、缓存、协议转换、密钥管理。Java 后端我现在一般用 Spring Boot,Spring AI 2.0 已经能比较干净地接入 GLM 这类国内模型,配置方式也简单,不用自己封装 HTTP 客户端:
spring: ai: model: api-key: ${AI_API_KEY} base-url: https://open.bigmodel.cn/api/paas/v4这个模式的好处是,如果业务要换模型,只需要改网关层的配置和少量代码,前端完全不需要动。
3.3 密钥权限与算力管理,这个比写代码更容易翻车
热搜词里出现"对AI接口调用、算力、API密钥权限的理解",这点我深有体会。AI 接口的调用费用和速率限制都是真实成本,尤其是语音类接口,音频传输的数据量比纯文本大几个数量级,费用也相应高很多。做实验的时候,我曾经写了个死循环把 ASR 接口连续调用了几十分钟,账单直接爆掉,联系客服才处理。
管理密钥权限有几个经验:
- 密钥永远只放在服务端环境变量或密钥管理服务里,绝对不能提交到 Git 仓库。
- 在前端调用时,由后端做代理鉴权,前端持有的是临时 session 或一次性 token,而不是 AI 平台的 API Key。
- 给不同接口配置独立的密钥,如果一个密钥泄露,可以单独吊销,不影响其他业务。
- 可以设置调用速率上限,比如同一用户每秒最多推 50 帧音频数据、每天最多 200 次对话,防止异常流量消耗预算。
算力这块也值得多说一句。语音识别和合成的算力消耗比纯文本接口高不少,如果你选的模型是自部署而不是用现成的 API,需要考虑 GPU 显存和并发。用 API 服务则可以关注服务方提供的是"按量付费共享算力"还是"独占算力实例",共享实例在高峰期延迟会明显上升,如果对实时性要求高,建议直接买独占实例或用更贵的优先调度通道,这在语音场景里是值得的成本。
4. 实时对话状态机与打断处理,这是体验分水岭
4.1 对话状态机的四个基本状态
语音对话和文字对话最大的区别在于,用户会随时打断,AI 也可能在说话的时候检测到用户有新指令。如果不在逻辑层控制好状态转换,就会出现 AI 一边回答、ASR 一边识别用户的新指令,两条语音流在播放端打架的情况。
我维护的对话状态机是四态模型:
| 状态 | 含义 | 触发条件 |
|---|---|---|
| IDLE | 空闲监听中 | 页面加载完成、一次对话结束 |
| LISTENING | 用户正在说话 | 检测到语音开始(非静音) |
| PROCESSING | 已停止说话,等待AI返回 | 检测到语音结束(持续静音超过阈值) |
| SPEAKING | AI正在播放回答 | TTS音频流开始播放 |
状态流转规则很简单:
- IDLE 检测到语音开始 -> LISTENING
- LISTENING 检测到静音超时 -> PROCESSING,同时停止发送音频
- PROCESSING 收到AI音频流首包 -> SPEAKING
- SPEAKING 播放结束 -> IDLE
- 任意状态下检测到用户再次说话 -> 立即打断当前播放,回到 LISTENING
这最后一条是整个系统的核心体验保障。实现打断时,前端需要做两件事:调用audioElement.pause()或停止播放 AudioBufferSourceNode;同时通知后端"用户重新说话了,请终止当前 TTS 流,并清空 ASR 缓存"。如果不做第二件事,后端还会继续把旧的 TTS 音频推过来,前端就会出现"AI 已经闭嘴了但音频还在播"的灵异现象。
4.2 播放端双缓冲策略,防止干等和卡顿
很多时候 AI 返回的音频不是一次性给完的,而是边合成边返回。播放端如果拿到一块播一块,遇到网络抖动就会中间断一下,体验非常碎。我的做法是做双缓冲:第一个音频块到达后立即开始播放;后续音频块进入第二个缓冲队列,同时监控队列长度。
队尾的饥饿问题需要提前处理。如果一个块播放快结束了,队列里还没有下一个块,我会选择最多等待 300ms,而不是直接停下。超过 300ms 还没数据,就暂停播放并触发重新拉取或提示异常。这个超时时间是根据语音合成的平均速度动态调的,我实测在 300ms 左右用户体验最自然,既不会产生明显停顿感,也不会为了等一个迟迟不来的包而卡住整个对话。
前端播放 PCM 音频用的是AudioContext的decodeAudioData,先把 PCM 编码成 WAV 或直接保留 PCM 交给 AudioBuffer,再通过 BufferSource 播放。如果用 HTMLAudioElement 播 MP3 或 AAC,走系统解码器会更省 CPU,但需要后端把 TTS 输出转成对应格式并做流式分片,多加一层编码逻辑。
4.3 文本没出来,音频却先到:如何对齐 ASR、LLM 和 TTS 的数据边界
三段式接口各自独立,数据边界天然是错位的。ASR 可能把"我想订一张明天去上海的机票"识别成前半句"我想订一张明天去的"就返回了,LLM 拿到这个不完整句子就开始生成回答,TTS 再合成出来,效果就是 AI 答非所问。
解决思路有两种。第一种是"VAD 边界等待",通过语音活动检测判断用户是否真的说完一整句,再触发 LLM。做法是静音持续 500-800ms 才判定结束,而不是收到一个临时识别结果就立刻调用 LLM。第二种是"后缀抑制",把 ASR 的中间结果持续输入 LLM,当出现新的识别文本时,后端重新生成回答,前端丢弃之前未播完的 TTS 音频。第一种实现简单,第二种更激进,但用户体验更接近真人对话。
我最终采用的是混合方案:正常对话用 VAD 边界等待;当用户连续说了超过 15 秒还没停顿,则强制触发一次 LLM 调用,避免长时间无响应。两种机制配合后,系统既不会频繁打断用户,也不会对长句束手无策。
5. 实测链路中的容量、安全与网络卡顿问题排查
5.1 WebRTC 链路容量与码率估算,避免把带宽当无限用
WebRTC 链路容量估计在音视频领域一直是个热门话题。语音对话场景虽然视频那么耗带宽,但码率依然需要心里有数。
按 16kHz 采样率、16bit 量化、单声道裸流来计算,码率是:
16000 × 16 × 1 = 256000 bit/s ≈ 250 kbps这个数值就是每秒音频数据量。加上 WebSocket 帧头、WebRTC SRTP 加密开销和 IP/UDP 头,实际上行带宽大概在 280-320kbps。4G 网络的上行带宽一般有 5-10Mbps,Wi-Fi 更高,所以语音流本身不会把带宽打满,真正的瓶颈在于 RTT 和抖动,而不是总带宽。
但如果同时开视频或者用户处于信号弱的环境,链路容量就会吃紧。为了适应弱网,可以在音频发送端做动态降级:RTT 超过 500ms 时,把发送码率降一半;丢包率超过 5% 时,改用冗余编码发送。WebRTC 的RTCRtpSender提供了setParameters方法可以动态调整编码码率,我用它做过一次自适应降级实验,实测在信号差的场景下,丢包导致的音频卡顿减少了大约 40%。
不过要提醒一句,浏览器里的getUserMedia采集到的本地音频已经有 Opus 编码(如果走 WebRTC peer connection 的话),上面 250kbps 是裸 PCM 的估算。如果用 WebSocket 传裸 PCM 而非 WebRTC 传 Opus,带宽占用会高不少,但省去了编码解码的 CPU。这个取舍要看你的后端链路:如果后端直接对接 AI 接口需要 PCM,那传 PCM 反而省事;如果后端是转发的 WebRTC 网关,那编成 Opus 再传是更优解。
5.2 浏览器内的 IP 泄露陷阱与权限边界
"WebRTC 泄露"是个被讨论很多的安全话题。WebRTC 在建立 P2P 连接时会进行 ICE 协商,过程中可能通过 STUN 请求暴露本地 IP 地址。虽然语音对话系统通常是浏览器到服务器的中继模式,不存在实际 P2P,但在浏览器里注入的 WebRTC 组件如果配置不当,依然可能在调试页面或日志里暴露用户内网 IP。
我处理这个问题的经验是:
- 页面只使用单向媒体流,不建立真正的 RTCPeerConnection,而是走 WebSocket,从根源上避免 STUN 类请求。
- 如果必须用 WebRTC 传输,可以将 ICE 策略设为
all,但配合RTCIceServer只配置 TURN,不配置 STUN,防止地址暴露。 - 日志输出时注意过滤掉
candidate中的srflx和host类型,避免把内网 IP 打到日志里。 - 会话身份鉴权放在后端,前端只持有时效性 token,并设置短过期时间(比如 10 分钟自动失效)。
这些细节平时不太起眼,但一旦涉及用户数据隐私或企业内网场景,审查的人会非常在意。
5.3 卡顿、断流、杂音:我在这套系统里遇到的三个典型问题
第一个是播放杂音。症状是 AI 返回的语音有明显的"沙沙"声。排查了一圈,发现是前端把 ASR 缓冲区的 PCM 数据直接拿来喂给播放器,但这个 PCM 是 16kHz 的,播放设备的采样率却是 48kHz,没有重采样导致频谱变形。解决方案是在播放端也创建AudioContext({ sampleRate: 16000 })或使用AudioBuffer时手动指定采样率。
第二个是断流。表现是对话到一半,后端 WebSocket 连接突然断开,前端报1006错误码。打日志发现是云服务器上的 Nginx 空闲超时设置得太短,默认 60 秒没有数据传输就切断连接。而语音对话里如果用户思考了 30 秒,ASR 静音期间确实可能没有上行数据。解决办法是前端每 15 秒发送一个 WebSocket 心跳帧,后端收到后重置连接超时计时器。
第三个是重叠说话。表现为 AI 回答还没讲完,用户一开口,前端立刻开始采集新语音,但旧 TTS 音频还在播放,两边声音叠在一起。根因是打断逻辑只处理了播放态,没有处理"TTS 流还未传输完"的后端状态。最终方案是前端在打断时发送一条数字信号帧{"type": "interrupt", "session_id": "xxx"},后端收到后立即终止该 session 的 TTS 拉流,前端同时清空播放缓冲队列。这么一改,打断响应时间从原来的 800ms 降到 100ms 以内,体验提升非常明显。
6. 从 Demo 到可用产品:还需要补上的六个工程细节
如果你照着上面的思路已经跑通了一个本地 Demo,那恭喜你,核心链路已经没问题了。但距离一个真正能给别人用的语音对话系统,还差几个工程细节。
6.1 会话管理与用户状态隔离
多人同时使用时,必须让每个 WebSocket 连接对应一个独立 session,后端用一个sessionId把所有中间状态串起来:ASR 的上下文、LLM 的对话历史、TTS 的合成缓存。我在 Redis 里存会话数据,设 10 分钟过期,过期后自动清空上下文,避免内存泄露和跨会话串号。
6.2 音频格式兼容与转码兜底
不是所有浏览器都支持直接输出 16kHz PCM 的AudioContext。比如某些 Android 内置浏览器创建的AudioContext强制 48kHz,重采样逻辑会走一遍但耗时上浮。前端统一加一层 Probe 逻辑,创建 AudioContext 后读取实际sampleRate,如果不是 16kHz,就在 onaudioprocess 里手动做一次线性插值重采样,保证发到后端的永远是 16kHz PCM。
6.3 异常恢复与重连机制
语音对话是长连接型交互,网络闪断不可避免。前端要做的不是"永不掉线",而是"掉线了能静默恢复"。我实现的是指数退避重连:第一次断开等 1 秒重连,第二次 2 秒,第三次 4 秒,最多等 10 秒。重连成功后,后端根据 sessionId 恢复 ASR 上下文,前端自动补发最近 200ms 的音频缓存,让 AI 不至于断片。
6.4 音频日志与回放调试
AI 语音接口的 bug 特别难复现,因为音频问题靠肉耳听很难定位。我会在本地把所有上行 PCM 保存成 WAV 文件,同时给每帧音频加一个序号,WebSocket 日志里记录每一帧的发送时间和大小。排查问题时,只需要打开 WAV 听一下前端采集的音频是否有问题,再看序号有没有跳跃,就能快速确认是采集端、传输端还是 AI 接口端的锅。
6.5 并发与成本控制
对话系统的成本大头在 ASR 和 TTS 调用。我在网关上加了一层"结果缓存",完全相同的用户问题在 5 分钟内的重复请求,直接返回缓存音频,不再调用 TTS。另外 ASR 静音检测调灵敏一些,减少无效音频的推流时长,也能省 10%-20% 的接口费用。
6.6 浏览器兼容矩阵
实测下来,Chrome 桌面端是体验最好的环境,Safari 和 Firefox 的AudioWorklet支持已经跟进,但getUserMedia的约束行为在 Safari 上不一致,尤其老版本对autoGainControl支持不完整,需要降级处理。移动端的坑更多,iOS Safari 对 AudioContext 有"需要用户手势才能恢复播放"的限制,必须在用户点击开始按钮之后再创建 AudioContext,不能像桌面端那样页面加载就建。这些兼容性问题没有捷径,只能建立一个覆盖桌面四大浏览器加 iOS/Android 端的真机测试列表,每个关键版本回归一遍。
7. 一段可以直接跑通的最小实现代码
最后放一段我认为可以"抄作业"的完整最小实现,去掉业务冗余,保留核心链路。前端负责采集音频并发送,后端用 WebSocket 转发到 AI 接口并回传音频。这里我以 Node.js 为例,方便快速验证。
前端index.html:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8" /> <title>Voice Chat Demo</title> </head> <body> <button id="startBtn">开始对话</button> <p id="status">空闲</p> <script> const startBtn = document.getElementById('startBtn'); const statusEl = document.getElementById('status'); let ws; let audioContext; let workletNode; let mediaStream; startBtn.onclick = async () => { if (!ws || ws.readyState !== WebSocket.OPEN) { audioContext = new AudioContext({ sampleRate: 16000 }); await audioContext.audioWorklet.addModule('/audio-processor.js'); mediaStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); const source = audioContext.createMediaStreamSource(mediaStream); workletNode = new AudioWorkletNode(audioContext, 'pcm-processor'); workletNode.port.onmessage = (e) => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(e.data); } }; source.connect(workletNode); workletNode.connect(audioContext.destination); ws = new WebSocket(`ws://${location.host}/ws`); ws.onmessage = (event) => { // 收到后端返回的音频块或状态帧 const data = event.data; if (typeof data === 'string') { const msg = JSON.parse(data); statusEl.textContent = msg.text || ''; } else { // 这里假设后端返回的是 Blob 音频数据 playAudioBlob(data); } }; } audioContext.resume(); statusEl.textContent = '对话中'; }; async function playAudioBlob(blob) { const arrayBuffer = await blob.arrayBuffer(); const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); const source = audioContext.createBufferSource(); source.buffer = audioBuffer; source.connect(audioContext.destination); source.start(); } </script> </body> </html>audio-processor.js就是上面提到的 PCMProcessor 文件。
后端server.js(Node.js + ws 库):
const WebSocket = require('ws'); const http = require('http'); const fs = require('fs'); const path = require('path'); const server = http.createServer((req, res) => { if (req.url === '/') { fs.createReadStream(path.join(__dirname, 'index.html')).pipe(res); } else if (req.url === '/audio-processor.js') { fs.createReadStream(path.join(__dirname, 'audio-processor.js')).pipe(res); } else { res.writeHead(404); res.end(); } }); const wss = new WebSocket.Server({ server }); wss.on('connection', (ws) => { // 这里接入AI上行接口,把ws收到的音频块推给AI ws.on('message', (data) => { // 示例:打印收到的字节长度,实际对接时转发给 ASR 接口 console.log('received audio chunk, size:', data.byteLength); // 然后可以把AI返回的音频通过 ws.send 回传 }); }); server.listen(8080, () => { console.log('listening on 8080'); });这个最小实现没有接真实 AI 接口,但完整展示了采集、发送、回放、状态管理的最简骨架。把ws.on('message')里替换成调用你的 ASR/TTS 服务,再加上用户状态机和缓存,就是一套基本可用的系统了。
我在实际项目里用的方案比这个复杂得多,但万变不离其宗,核心永远是那三件事:音频采集链路稳不稳定、AI接口调用快不快、状态转换准不准。把这三件事做扎实,语音对话系统的体验自然就上来了。