上个月给自家产品加语音对话功能,原本以为接个SDK半小时搞定,结果硬是折腾了十几天。过程中发现网上关于uniapp处理流式音频的资料又散又旧,索性把这次踩坑经历完整写下来,给后面要做类似功能的朋友铺条路。
语音对话到底难在哪
文字对话大家都熟了,用户发一段文本,后端返回一大段文本,前端慢慢渲染就行。可语音对话完全是另一回事:用户对着手机说话,这坨音频数据要实时送到后端,后端识别出文字后再喂给大模型,大模型给出的回复还得转成语音,最后把语音流推回前端播放。这中间每个环节都在跟「实时性」较劲,任何一步卡一下,用户体感上就是反应慢半拍。
更头疼的是平台差异。H5、小程序、App 的录音接口长得完全不一样,甚至同一个平台安卓和 iOS 的表现都有差异。uniapp 虽然抹平了大部分 UI 层面的问题,但在多媒体能力上还是留下了不少需要手动填补的沟壑。
整体交互链路先理清
动手之前先画了张简图,把整个数据流向捋明白:
用户说话 ➜ 前端录音,分片发送 ➜ WebSocket ➜ 后端实时语音识别
➜ 识别文本送入大模型
➜ 大模型回复文本
➜ 后端实时语音合成,分片返回 ➜ 前端接收PCM流,连续播放
这里有两个关键决定:第一,用 WebSocket 做全双工通道,一条连接同时上传音频和接收合成语音,省去多次握手开销;第二,前端不做本地识别也不做本地合成,全部交给后端,这样模型升级时用户端不用更新,而且跨端的一致性更好。
接下来分模块细说,重点讲前端怎么在 uniapp 里把这些能力串起来。
录音分片:不一样的操作系统,一样的心累
先看 H5 端。H5 的录音靠的是 MediaRecorder API,这玩意儿用起来挺简单,但坑藏在细节里。下面是我封装的一段核心代码:
// utils/audio/h5-recorder.js
export class H5Recorder {
constructor(options) {
this.onDataAvailable = options.onDataAvailable; // 分片回调
this.onStop = options.onStop;
this.mediaRecorder = null;
this.stream = null;
}
async start() {
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.mediaRecorder = new MediaRecorder(this.stream, {
mimeType: 'audio/webm;codecs=opus'
});
this.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0 && this.onDataAvailable) {
// 把Blob转成ArrayBuffer再传给上层,方便后续统一处理
const reader = new FileReader();
reader.onloadend = () => {
this.onDataAvailable(reader.result);
};
reader.readAsArrayBuffer(event.data);
}
};
// 每300毫秒收集一个分片,这个间隔可以根据网络情况调整
this.mediaRecorder.start(300);
} catch (e) {
throw new Error('麦克风权限被拒绝或设备不支持');
}
}
stop() {
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
this.mediaRecorder.stop();
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
}
}
}
这里选了 opus 编码,体积小、延迟低,特别适合实时传输。但要注意,iOS 上的 Safari 对 opus 支持有点玄学,有时候会回退到 PCM,所以如果兼容性要求苛刻,可以多准备一套 PCM 分支。
再看小程序端,情况就复杂多了。uni 提供了 RecorderManager,API 长这样:
// utils/audio/mp-recorder.js
export class MpRecorder {
constructor(options) {
this.onDataAvailable = options.onDataAvailable;
this.recorderManager = uni.getRecorderManager();
}
start() {
this.recorderManager.onFrameRecorded((res) => {
// res.frameBuffer 是 ArrayBuffer,可以直接用
if (this.onDataAvailable) {
this.onDataAvailable(res.frameBuffer);
}
});
this.recorderManager.onError((err) => {
console.error('录音错误:', err);
});
this.recorderManager.start({
duration: 60000,
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 48000,
format: 'pcm', // 重要:后端语音识别通常需要PCM
frameSize: 10 // 每10KB回调一次,对应约320ms的16k单声道音频
});
}
stop() {
this.recorderManager.stop();
}
}
小程序录音回调的 frameSize 单位是 KB,不是毫秒,这跟 H5 的逻辑完全不同。一开始我没注意,设了个 300,结果回调频率低得离谱,说话半天后端都没反应。后来查文档才发现是 KB,改成 10 之后延迟才降到可接受范围。
还有个容易忽略的细节:小程序录音期间,如果页面切到后台,录音会被系统中断。所以最好在页面 onHide 时主动停止录音,避免出现半截音频。
WebSocket 双向通道:一根管子同时跑上传和下载
分片拿到手之后,得赶紧发出去。这里我封装了一个通用的 WebSocket 管理器,同时处理上行音频数据和下行的合成语音数据。uniapp 的 WebSocket API 跟浏览器基本一致,直接上代码:
// utils/ws/speech-socket.js
export class SpeechSocket {
constructor(url) {
this.url = url;
this.socketTask = null;
this.isConnected = false;
this.callbacks = {
onAudioChunk: null, // 收到合成语音分片
onTextResult: null, // 收到识别或对话文本(可选)
onDone: null,
onError: null
};
}
connect(callbacks) {
this.callbacks = { ...this.callbacks, ...callbacks };
this.socketTask = uni.connectSocket({
url: this.url,
complete: () => {}
});
this.socketTask.onOpen(() => {
this.isConnected = true;
// 可以发送初始握手信息,比如告诉后端音频格式
this.socketTask.send({
data: JSON.stringify({
type: 'start',
audioFormat: 'pcm',
sampleRate: 16000,
channels: 1
})
});
});
this.socketTask.onMessage((res) => {
// 后端返回的数据统一用JSON包装,区分类型
try {
const msg = JSON.parse(res.data);
if (msg.type === 'audio') {
// base64编码的PCM数据,需要在收到后解码
const audioBuffer = this._base64ToArrayBuffer(msg.data);
this.callbacks.onAudioChunk && this.callbacks.onAudioChunk(audioBuffer);
} else if (msg.type === 'text') {
this.callbacks.onTextResult && this.callbacks.onTextResult(msg.data);
} else if (msg.type === 'done') {
this.callbacks.onDone && this.callbacks.onDone();
}
} catch (e) {
// 非JSON数据暂不处理
}
});
this.socketTask.onError((err) => {
this.callbacks.onError && this.callbacks.onError(err);
});
this.socketTask.onClose(() => {
this.isConnected = false;
});
}
sendAudio(arrayBuffer) {
if (!this.isConnected || !this.socketTask) return;
// 将音频数据转成base64发送,保证二进制数据完整
const base64 = this._arrayBufferToBase64(arrayBuffer);
this.socketTask.send({
data: JSON.stringify({
type: 'audio_data',
data: base64
})
});
}
sendStop() {
if (!this.isConnected || !this.socketTask) return;
this.socketTask.send({
data: JSON.stringify({ type: 'stop' })
});
}
close() {
if (this.socketTask) {
this.socketTask.close();
}
}
// 工具方法:ArrayBuffer转Base64
_arrayBufferToBase64(buffer) {
let binary = '';
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
// 工具方法:Base64转ArrayBuffer
_base64ToArrayBuffer(base64) {
const binary = atob(base64);
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
}
这里把音频数据统一转成 base64 在 WebSocket 的 JSON 帧里传输,虽然比直接传二进制稍大一点,但避免了小程序端对二进制帧支持不统一的问题。实测下来,16k 采样率的 PCM 音频,每 300 毫秒一片的数据量大约是 9.6KB,base64 之后 12.8KB 左右,在 4G 网络下延迟基本无感。
播放合成语音:PCM 流怎么让扬声器连续出声
后端把大模型的回复转成语音后,同样分片推送回来。前端收到这些 PCM 裸数据,要想办法连续播放。H5 端可以用 AudioContext 的 decodeAudioData,但那个方法对分片流不友好,每次解码都有间隙。我后来改用 AudioWorklet 直接往缓冲区里灌 PCM,平滑度好很多。小程序端则需要用 InnerAudioContext,但它不支持流式播放,这里我换了种思路:把陆续收到的分片先缓存,拼成一定长度的完整音频段后,转成临时文件再播放,通过连续切换来模拟流式效果。
先看 H5 的播放器实现:
// utils/audio/h5-player.js
export class H5StreamPlayer {
constructor() {
this.audioContext = null;
this.workletNode = null;
this.bufferQueue = [];
this.isPlaying = false;
}
async init() {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
// 加载AudioWorklet处理器(需要单独的processor.js文件,这里略去)
await this.audioContext.audioWorklet.addModule('/static/audio/pcm-processor.js');
this.workletNode = new AudioWorkletNode(this.audioContext, 'pcm-processor', {
outputChannelCount: [1]
});
this.workletNode.connect(this.audioContext.destination);
// 接收来自处理器的消息,通知我们它需要更多数据
this.workletNode.port.onmessage = (event) => {
if (event.data === 'need-data' && this.bufferQueue.length > 0) {
const chunk = this.bufferQueue.shift();
this.workletNode.port.postMessage({ type: 'audio-data', buffer: chunk });
}
};
}
addPCMChunk(arrayBuffer) {
// 将收到的PCM转成Float32Array(16bit PCM)
const int16View = new Int16Array(arrayBuffer);
const float32Array = new Float32Array(int16View.length);
for (let i = 0; i 0) {
const chunk = this.bufferQueue.shift();
this.workletNode.port.postMessage({ type: 'audio-data', buffer: chunk });
} else {
this.isPlaying = false;
}
}
stop() {
if (this.workletNode) {
this.workletNode.port.postMessage({ type: 'stop' });
this.workletNode.disconnect();
}
if (this.audioContext) {
this.audioContext.close();
}
this.bufferQueue = [];
this.isPlaying = false;
}
}
AudioWorklet 的配套处理器代码不算长,核心就是一个 while 循环从消息队列取数据写入输出缓冲区,这里不贴了,网上样例很多。关键要记得把输出采样率设成跟后端合成的一致,否则声音会变调。
小程序端的播放没那么优雅,因为没有流式音频 API,只能走「分段拼接、顺序播放」的野路子。我把收到的连续分片攒到一定数量(比如 800 毫秒的音频量),用 FileSystemManager 写成本地临时文件,然后用 InnerAudioContext 顺序播放这些文件。相邻文件之间会有几十毫秒的静默中断,体验打个折扣,但目前在小程序里没有更好的替代方案。
组装对话页面:把零散的模块捏到一起
有了录音器和播放器,对话页面就是把它们跟 WebSocket 串起来。核心流程用一段精简后的代码表示:
// pages/voice-chat/index.vue 的 script 部分简化
import { H5Recorder } from '@/utils/audio/h5-recorder.js';
import { MpRecorder } from '@/utils/audio/mp-recorder.js';
import { SpeechSocket } from '@/utils/ws/speech-socket.js';
import { H5StreamPlayer } from '@/utils/audio/h5-player.js';
export default {
data() {
return {
isRecording: false,
statusText: '点击按钮开始说话',
player: null,
recorder: null,
wsClient: null
}
},
methods: {
async startTalk() {
this.isRecording = true;
this.statusText = '正在聆听...';
// 初始化WebSocket连接
this.wsClient = new SpeechSocket('wss://your-backend.com/speech');
this.wsClient.connect({
onAudioChunk: (buffer) => {
// 收到合成语音分片,送给播放器
if (this.player) {
this.player.addPCMChunk(buffer);
}
},
onTextResult: (text) => {
this.statusText = text;
},
onDone: () => {
this.statusText = '播放完毕';
this.stopTalk();
},
onError: (err) => {
console.error('WebSocket错误', err);
this.statusText = '连接异常,请重试';
}
});
// 初始化播放器
// #ifdef H5
this.player = new H5StreamPlayer();
await this.player.init();
// #endif
// 初始化录音器
// #ifdef H5
this.recorder = new H5Recorder({
onDataAvailable: (buffer) => {
this.wsClient && this.wsClient.sendAudio(buffer);
}
});
// #endif
// #ifdef MP-WEIXIN
this.recorder = new MpRecorder({
onDataAvailable: (buffer) => {
this.wsClient && this.wsClient.sendAudio(buffer);
}
});
// #endif
this.recorder.start();
},
stopTalk() {
this.isRecording = false;
if (this.recorder) {
this.recorder.stop();
this.recorder = null;
}
if (this.wsClient) {
this.wsClient.sendStop(); // 通知后端音频结束
setTimeout(() => {
this.wsClient.close();
this.wsClient = null;
}, 500);
}
if (this.player) {
this.player.stop();
this.player = null;
}
}
}
}
页面上放了两个按钮:「开始说话」和「打断」,后者会直接调用 stopTalk() 清掉所有状态,同时告诉后端丢弃当前没有处理完的音频。这个打断功能在真实对话里特别重要,不然用户说一半想纠正,还得等机器把错误的话念完,非常反人类。
几个优化点,让体验更顺滑
基础流程跑通后,我又加了几项优化:
- 音量指示器:录音时实时显示音量大小,给用户一个正在收音的反馈。小程序可以通过 recorderManager.onFrameRecorded 里的 frameBuffer 简单计算能量值,H5 用 AudioContext 的 AnalyserNode 拿频域数据。
- 静音检测:用户在说完一句话后会有短暂停顿,这时候自动停止录音可以减少等待感。后端做 VAD(语音活动检测)最准,前端也可以根据连续几个音频分片的能量低于阈值来触发自动停止。
- 网络自适应:在弱网环境下,可以动态调整录音分片的大小。网络差时把分片调大一些,减少发送频率,避免 WebSocket 缓冲区堆积。
- 降级策略:如果 H5 环境不支持 AudioWorklet,可以降级为 ScriptProcessorNode(虽然已废弃但还能用),再不行就回退到分段播放的笨办法。
后端需要配合做的事
虽然本文侧重前端,但后端方案直接决定了整体的延迟和效果。简单提一下我们后端的技术选型:语音识别用的阿里云的一句话识别和实时流识别,大模型就是前面提到的 DeepSeek,语音合成用了微软 Azure 的流式 TTS。WebSocket 服务用 Node.js 搭的,把几个服务的流对接起来,没做什么重逻辑。
有一点容易遗漏:后端返回的合成语音格式必须和前端播放器匹配。我们约定的是 16kHz、单声道、16bit PCM。一旦格式对不上,前端要么没声,要么全是噪音。调试时可以先用一段固定音频替代后端合成,确认播放链路没问题再联调。
踩坑清单,帮你省点时间
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 微信小程序录音回调不及时,延迟2秒以上 | frameSize 单位误解为毫秒,设成了300 | 改为10(单位KB),约320ms回调一次 |
| iOS Safari 录音无声音或沙沙声 | opus编码不支持,自动回退失败 | 强制指定 mimeType 为 ‘audio/wav’ 或捕获异常后用 PCM |
| 播放合成语音时有“哒哒”爆音 | PCM 数据字节序错误或位深度不匹配 | 确认前后端统一16bit little-endian,采样率一致 |
| H5 端 AudioWorklet 加载失败 | 未使用 HTTPS 或本地调试环境不支持 | 部署到 HTTPS 环境测试,本地开发用 localhost 例外 |
| 通话一段时间后小程序闪退 | 录音产生的临时文件过多,内存泄漏 | 定期清理无用音频缓存,控制队列长度 |
效果与后续计划
整套方案在 iPhone 12 和红米 K50 上实测,从说完最后一个字到扬声器开始出声,端到端延迟大概在 1.2 到 1.8 秒之间,在可接受范围内。小程序端因为播放分段拼接的问题会再多 200 毫秒左右。自己用了几天,查天气、设闹钟、闲聊几句都挺自然,就是偶尔网络抖动的时候会有半句话重复。
接下来准备把前端代码打包成一个 uni_modules 插件,把各平台的差异都封装好,对外暴露统一的 start、stop、onAudio 接口,方便其他项目复用。另外还想试试 WebRTC 的方案,理论上延迟能压到 500ms 以内,不过那又是另一个大坑了。
语音交互这个方向大概率会是移动端AI应用的核心入口之一,早点趟一遍坑,后面再碰类似需求就有底气了。希望这篇记录能给同样在这条路上的朋友一点参考。

