uniapp流式语音实战:把AI对话升级为「边说边听」,跨端实时音频处理全解

2026-07-02 0 527

上个月给自家产品加语音对话功能,原本以为接个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应用的核心入口之一,早点趟一遍坑,后面再碰类似需求就有底气了。希望这篇记录能给同样在这条路上的朋友一点参考。

uniapp流式语音实战:把AI对话升级为「边说边听」,跨端实时音频处理全解
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uniapp流式语音实战:把AI对话升级为「边说边听」,跨端实时音频处理全解 https://www.taomawang.com/web/uniapp/2306.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务