语音模块、ASR、TTS 与流式播放 一、目标 本节在文字聊天基础上加入完整语音交互能力:
前端麦克风语音输入;
VAD 人声检测;
录音数据转 PCM;
后端 ASR 语音识别;
识别文本继续走聊天流程;
后端 TTS 语音合成;
文本与音频通过 SSE 同时流式返回;
前端 MediaSource 实现边下边播;
新消息打断旧回复与旧语音播放。
本节把项目从“文字聊天”升级为“语音对话”,核心难点是异步 I/O、WebSocket、SSE、多线程和浏览器音频播放的组合。
二、语音交互整体流程 2.1 三个核心概念
缩写
全称
作用
ASR
Automatic Speech Recognition
语音转文字
NLP / LLM
Natural Language Processing / Large Language Model
理解文本并生成回复
TTS
Text To Speech
文字转语音
2.2 项目语音流程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 用户说话 ↓ 前端 VAD 判断人声开始/结束 ↓ 获取一段音频 Float32Array ↓ 转成 16-bit PCM ↓ FormData 上传到 Django ASR 接口 ↓ 后端通过 WebSocket 调用阿里云 ASR ↓ 得到识别文本 ↓ 前端把识别文本交给聊天输入逻辑 ↓ 后端调用大模型生成回复 ↓ 后端同时调用 TTS 合成音频 ↓ SSE 返回文本片段和音频片段 ↓ 前端显示文字并播放语音
三、消息打断机制 3.1 为什么需要打断? 用户可能在 AI 尚未回复完时又发送新消息,或者开始说话。这时旧回复如果继续追加,会造成:
新旧回复混在一起;
旧语音继续播放;
当前聊天上下文混乱。
3.2 processId 设计
每次发送新消息:
1 const curId = ++processId
流式回调中判断:
1 if (curId !== processId) return
含义:
每次请求都有自己的版本号 curId;
如果后续又发送了新消息,processId 自增;
老请求回来的数据版本号不一致,直接丢弃。
3.3 打断语音播放 1 2 3 4 function handleStop ( ) { ++processId stopAudio () }
触发场景:
用户开始新一轮语音输入;
用户关闭聊天框;
用户切换输入模式;
用户发送新消息。
四、前端 VAD 语音检测 4.1 安装依赖 1 2 cd frontend npm install @ricky0123/vad-web
4.2 拷贝静态资源 需要复制到:
资源来源:
1 2 3 4 5 6 7 frontend/node_modules/@ricky0123/vad-web/dist/ ├── silero_vad_legacy.onnx └── vad.worklet.bundle.min.js frontend/node_modules/onnxruntime-web/dist/ ├── *.wasm └── ort-wasm-simd-threaded.mjs
添加 .gitignore:
这些文件体积较大,不建议提交到仓库。
4.3 Microphone.vue 组件结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 <script setup> import { onBeforeUnmount, onMounted, ref } from 'vue' import { MicVAD } from '@ricky0123/vad-web' import KeyboardIcon from '@/components/character/icons/KeyboardIcon.vue' import api from '@/js/http/api.js' const emit = defineEmits(['close', 'send', 'stop']) const isSpeaking = ref(false) let vadInstance = null </script> <template> <div class="absolute bottom-4 left-2 h-12 w-86 flex items-center bg-black/30 backdrop-blur-sm text-white rounded-2xl"> <div v-if="isSpeaking" class="flex items-center justify-center gap-1 h-6 flex-1"> <div v-for="i in 32" :key="i" class="w-0.5 bg-blue-400 rounded-full animate-wave" :style="{ animationDelay: `${i * 0.1}s` }" ></div> </div> <div v-else class="text-white/50 text-base w-full text-center"> 语音输入 </div> <div @click="emit('close')" class="absolute right-2 w-8 h-8 flex justify-center items-center cursor-pointer"> <KeyboardIcon /> </div> </div> </template>
4.4 音浪动画 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 .animate-wave { height : 4px ; animation : wave-animation 0.6s ease-in-out infinite alternate; } @keyframes wave-animation { 0% { height : 4px ; opacity : 0.3 ; } 100% { height : 20px ; opacity : 1 ; } }
4.5 初始化 VAD 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 const startRecording = async ( ) => { const baseUrl = 'http://localhost:5173/vad/' try { vadInstance = await MicVAD .new ({ baseAssetPath : baseUrl, onSpeechStart : () => { isSpeaking.value = true emit ('stop' ) }, onSpeechEnd : audio => { isSpeaking.value = false const pcm16 = float32ToInt16 (audio) sendToBackend (pcm16) }, ortConfig : ort => { ort.env .wasm .wasmPaths = baseUrl ort.env .logLevel = 'error' }, positiveSpeechThreshold : 0.8 , negativeSpeechThreshold : 0.65 , minSpeechFrames : 5 , redemptionFrames : 5 , }) await vadInstance.start () } catch (e) { console .error ('VAD 初始化失败:' , e) } }
4.6 生命周期释放资源 1 2 3 4 5 6 7 8 9 10 onMounted (() => { startRecording () }) onBeforeUnmount (() => { if (vadInstance) { vadInstance.destroy () vadInstance = null } })
五、音频格式转换与上传 5.1 Float32 转 PCM16 VAD 输出的是 Float32Array,ASR 接口需要 PCM 音频。
1 2 3 4 5 6 7 8 const float32ToInt16 = float32Array => { const buffer = new Int16Array (float32Array.length ) for (let i = 0 ; i < float32Array.length ; i++) { let s = Math .max (-1 , Math .min (1 , float32Array[i])) buffer[i] = s < 0 ? s * 0x8000 : s * 0x7fff } return buffer.buffer }
5.2 上传到后端 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const sendToBackend = async arrayBuffer => { const blob = new Blob ([arrayBuffer], { type : 'audio/pcm' }) const formData = new FormData () formData.append ('audio' , blob, 'voice.pcm' ) try { const res = await api.post ('/api/friend/message/asr/asr/' , formData) const data = res.data if (data.result === 'success' ) { emit ('send' , null , data.text ) } } catch (err) { console .error (err) } }
emit('send', null, data.text) 会把识别出的文本交给父组件 InputField.vue 的 handleSend。
6.1 状态设计 1 const showMic = ref (false )
模板:
1 2 3 4 5 6 7 8 9 10 11 12 <form v-if="!showMic" @submit.prevent="handleSend" class="absolute bottom-4 left-2 h-12 w-86 flex items-center"> <input v-model="message" ref="inputRef" class="input bg-black/30 text-white w-full h-full rounded-2xl" /> <div @click="handleSend">发送</div> <div @click="showMic = true">麦克风</div> </form> <Microphone v-else @close="showMic = false" @send="handleSend" @stop="handleStop" />
6.2 handleSend 支持语音文本 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 async function handleSend (event, audio_msg ) { let content if (audio_msg) { content = audio_msg.trim () } else { content = message.value .trim () } if (!content) return const curId = ++processId message.value = '' emit ('pushBackMessage' , { role : 'user' , content, id : crypto.randomUUID () }) emit ('pushBackMessage' , { role : 'ai' , content : '' , id : crypto.randomUUID () }) await streamApi ('/api/friend/message/chat/' , { body : { friend_id : props.friendID , message : content, }, onmessage (data, isDone ) { if (curId !== processId) return if (data.content ) { emit ('addToLastMessage' , data.content ) } if (data.audio ) { handleAudioChunk (data.audio ) } }, }) }
七、后端 ASR 语音识别 7.1 安装依赖 1 2 cd backend pip install websockets
7.2 环境变量 1 2 API_KEY="你的 API Key" WSS_URL="wss://dashscope.aliyuncs.com/api-ws/v1/inference"
7.3 ASRView 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import asyncioimport jsonimport osimport uuidimport websocketsfrom rest_framework.views import APIViewfrom rest_framework.response import Responsefrom rest_framework.permissions import IsAuthenticatedclass ASRView (APIView ): permission_classes = [IsAuthenticated] def post (self, request ): audio = request.FILES.get('audio' ) if not audio: return Response({'result' : '音频不存在' }) pcm_data = audio.read() text = asyncio.run(self .run_asr_tasks(pcm_data)) return Response({'result' : 'success' , 'text' : text})
7.4 发送音频 1 2 3 4 5 6 7 8 9 10 11 12 13 14 async def asr_sender (self, pcm_data, ws, task_id ): chunk = 3200 for i in range (0 , len (pcm_data), chunk): await ws.send(pcm_data[i:i + chunk]) await asyncio.sleep(0.01 ) await ws.send(json.dumps({ 'header' : { 'action' : 'finish-task' , 'task_id' : task_id, 'streaming' : 'duplex' , }, 'payload' : {'input' : {}}, }))
7.5 接收识别结果 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 async def asr_receiver (self, ws ): text = '' async for msg in ws: data = json.loads(msg) event = data['header' ]['event' ] if event == 'result-generated' : output = data['payload' ]['output' ] transcription = output.get('transcription' ) if transcription and transcription.get('sentence_end' ): text += transcription['text' ] elif event in ['task-finished' , 'task-failed' ]: break return text
7.6 并发执行发送和接收 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 async def run_asr_tasks (self, pcm_data ): task_id = uuid.uuid4().hex api_key = os.getenv('API_KEY' ) wss_url = os.getenv('WSS_URL' ) headers = {'Authorization' : f'Bearer {api_key} ' } async with websockets.connect(wss_url, additional_headers=headers) as ws: await ws.send(json.dumps({ 'header' : { 'streaming' : 'duplex' , 'task_id' : task_id, 'action' : 'run-task' , }, 'payload' : { 'model' : 'gummy-realtime-v1' , 'parameters' : { 'sample_rate' : 16000 , 'format' : 'pcm' , 'transcription_enabled' : True , }, 'input' : {}, 'task' : 'asr' , 'task_group' : 'audio' , 'function' : 'recognition' , }, })) async for msg in ws: if json.loads(msg)['header' ]['event' ] == 'task-started' : break _, text = await asyncio.gather( self .asr_sender(pcm_data, ws, task_id), self .asr_receiver(ws), ) return text
7.7 路由 1 path('api/friend/message/asr/asr/' , ASRView.as_view()),
八、协程知识补充 8.1 什么是协程? 协程是一种轻量级并发模型。它不是由操作系统抢占调度,而是在遇到 await 时主动让出控制权。
8.2 协程 vs 线程
对比
协程
线程
调度方式
用户态事件循环
操作系统内核
切换开销
小
较大
适合场景
高并发 I/O
CPU 密集或阻塞任务
并行能力
单线程内不并行
多核可并行
编程关键
async/await
锁、线程安全
8.3 asyncio.gather 1 await asyncio.gather(task1(), task2())
表示并发等待多个协程完成。在 ASR 中,发送音频和接收结果需要同时进行,因此使用 gather。
九、后端 TTS 语音合成 9.1 为什么更复杂? 文字聊天只需要:
语音聊天需要:
1 2 3 4 5 6 7 8 9 10 11 LLM 文本片段 ↓ 一边推给前端显示 ↓ 一边发送给 TTS 服务 ↓ TTS 返回二进制音频 ↓ 转 base64 ↓ 通过 SSE 推给前端播放
9.2 为什么需要线程 + 队列? Django 的 StreamingHttpResponse 是同步生成器,而 TTS WebSocket 和 LangGraph astream 是异步任务。
因此设计:
1 2 主线程:同步 event_stream,不断从 Queue 取消息并 yield 给前端 子线程:运行 asyncio.run,调用 LLM 和 TTS,把文本/音频塞进 Queue
9.3 线程安全队列 1 2 3 from queue import Queuemq = Queue()
队列内容示例:
1 2 3 4 {'content' : '你好' } {'audio' : 'base64字符串' } {'usage' : {...}} None
None 表示结束。
9.4 音频为什么要 base64? SSE 是文本协议,不能直接传二进制音频,因此要:
1 audio = base64.b64encode(binary_audio).decode('utf-8' )
前端再用 atob 解码。
9.5 TTS 返回 SSE 1 2 3 4 5 if msg.get('content' ): yield f"data: {json.dumps({'content' : msg['content' ]} , ensure_ascii=False)}\n\n" if msg.get('audio' ): yield f"data: {json.dumps({'audio' : msg['audio' ]} , ensure_ascii=False)}\n\n"
前端同一个 SSE 流中可以同时收到:
content:显示文字;
audio:播放语音。
MediaSource Extensions(MSE)允许前端不断向音频 / 视频缓冲区追加二进制片段,实现“边下载边播放”。
10.2 状态变量 1 2 3 4 5 let mediaSource = null let sourceBuffer = null let audioPlayer = new Audio ()let audioQueue = []let isUpdating = false
10.3 初始化音频流 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 const initAudioStream = ( ) => { audioPlayer.pause () audioQueue = [] isUpdating = false mediaSource = new MediaSource () audioPlayer.src = URL .createObjectURL (mediaSource) mediaSource.addEventListener ('sourceopen' , () => { sourceBuffer = mediaSource.addSourceBuffer ('audio/mpeg' ) sourceBuffer.addEventListener ('updateend' , () => { isUpdating = false processQueue () }) }) audioPlayer.play ().catch (() => { console .error ('等待用户交互以播放音频' ) }) }
10.4 处理音频片段 1 2 3 4 5 6 7 8 9 10 const handleAudioChunk = base64Data => { const binaryString = atob (base64Data) const len = binaryString.length const bytes = new Uint8Array (len) for (let i = 0 ; i < len; i++) { bytes[i] = binaryString.charCodeAt (i) } audioQueue.push (bytes) processQueue () }
10.5 写入 SourceBuffer 1 2 3 4 5 6 7 8 9 10 11 12 13 14 const processQueue = ( ) => { if (isUpdating || audioQueue.length === 0 || !sourceBuffer || sourceBuffer.updating ) { return } isUpdating = true const chunk = audioQueue.shift () try { sourceBuffer.appendBuffer (chunk) } catch (e) { console .error ('SourceBuffer Append Error:' , e) isUpdating = false } }
10.6 停止音频 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const stopAudio = ( ) => { audioPlayer.pause () audioQueue = [] isUpdating = false if (mediaSource) { if (mediaSource.readyState === 'open' ) { try { mediaSource.endOfStream () } catch (e) {} } mediaSource = null } if (audioPlayer.src ) { URL .revokeObjectURL (audioPlayer.src ) audioPlayer.src = '' } }
10.7 组件卸载清理 1 2 3 4 onUnmounted (() => { audioPlayer.pause () audioPlayer.src = '' })
十一、路由汇总 1 2 path('api/friend/message/chat/' , MessageChatView.as_view()), path('api/friend/message/asr/asr/' , ASRView.as_view()),
十二、开发与部署注意事项 12.1 开发环境 VAD 路径 1 const baseUrl = 'http://localhost:5173/vad/'
12.2 打包到后端后的 VAD 路径 如果前端已打包到 Django 静态目录,建议改为:
1 const baseUrl = 'http://127.0.0.1:8000/static/frontend/vad/'
并将 VAD 文件同步到:
1 backend/static/frontend/vad/
12.3 MIME 类型配置 某些静态文件需要正确 MIME,否则浏览器加载失败。
1 2 3 4 5 6 import mimetypesmimetypes.add_type('application/javascript' , '.js' , True ) mimetypes.add_type('application/javascript' , '.mjs' , True ) mimetypes.add_type('application/wasm' , '.wasm' , True ) mimetypes.add_type('application/octet-stream' , '.onnx' , True )
12.4 代理与网络问题 ASR / TTS 使用 wss:// WebSocket,测试时如果一直卡住:
检查 API Key;
检查 WSS_URL;
检查网络代理;
关闭可能拦截 WebSocket 的软件;
查看后端终端异常。
十三、易错点总结
空消息要先判断再设置处理中状态,否则会导致后续无法发送;
processId 必须在每次新消息或停止时自增;
VAD 模型文件必须能被浏览器访问;
baseAssetPath 和 ort.env.wasm.wasmPaths 都要配置;
Float32Array 要转 PCM16;
ASR 上传要用 FormData;
后端 WebSocket 任务必须等待 task-started 后再发送音频;
task-failed 不要拼错;
SSE 不能直接传二进制音频,必须 base64;
SourceBuffer.appendBuffer 不能并发写入,要用队列;
关闭弹窗时要停止音频并清理 Object URL;
Nginx 部署时要关闭缓冲:X-Accel-Buffering: no。
十四、知识补充:线程、协程与队列 14.1 为什么同时用线程和协程?
WebSocket ASR / TTS 是 I/O 密集型,适合协程;
Django SSE 生成器是同步接口;
需要用线程隔离异步事件循环;
用队列在线程之间传输数据。
14.2 Queue 的作用 1 2 异步子线程:LLM/TTS 产生数据 → mq.put(...) 同步主线程:event_stream 从 mq.get() → yield 给前端
queue.Queue 是线程安全的,适合线程间通信。
14.3 asyncio.Queue 与 queue.Queue 区别
队列
适合场景
queue.Queue
多线程之间通信
asyncio.Queue
同一事件循环中的协程之间通信
十五、小结 本节实现了完整语音链路:
前端用 VAD 检测用户说话;
音频转换为 PCM 并上传;
后端通过 WebSocket 调用 ASR;
识别文本复用文字聊天流程;
后端通过 TTS 将大模型回复合成为音频;
文本和音频通过同一个 SSE 流返回;
前端用 MediaSource 进行流式播放;
使用 processId 和 stopAudio 实现打断。
十六、新手版:用生活例子理解语音模块 可以把语音聊天想象成一个同声传译助手:
你开口说话;
助手判断你是不是正在说话;
你说完后,助手把语音听写成文字;
文字交给 AI 思考;
AI 生成文字回复;
另一个助手把文字读出来;
浏览器边收到声音边播放。
对应到技术:
生活步骤
技术
判断你有没有说话
VAD
把语音听写成文字
ASR
AI 思考和回答
LLM
把文字读出来
TTS
浏览器播放声音
MediaSource
十七、跟做步骤:语音输入 17.1 前端步骤
安装 @ricky0123/vad-web;
复制 VAD 所需模型文件到 frontend/public/vad/;
创建 Microphone.vue;
在组件中初始化 MicVAD;
在 onSpeechStart 中显示音浪并打断旧回复;
在 onSpeechEnd 中获取音频;
把 Float32 音频转为 PCM16;
用 FormData 上传到 ASR 接口。
17.2 后端步骤
安装 websockets;
在 .env 中配置 API_KEY 和 WSS_URL;
创建 ASRView;
从 request.FILES 获取音频;
使用 WebSocket 连接 ASR 服务;
发送 PCM 数据;
接收识别文本;
返回给前端。
17.3 验证方法
打开聊天框;
点击麦克风;
浏览器弹出麦克风权限时点击允许;
说一句话;
观察是否出现音浪;
Network 中是否请求 /api/friend/message/asr/asr/;
响应中是否有识别文本;
识别文本是否自动发送给聊天接口。
十八、跟做步骤:语音回复播放 18.1 后端步骤
保留原来的文字聊天 SSE;
新增 TTS WebSocket 调用;
大模型生成文字片段时,同时发给 TTS;
TTS 返回二进制音频;
后端把音频 base64 编码;
通过 SSE 返回:
或:
18.2 前端步骤
初始化 MediaSource;
创建 Audio 对象;
收到 data.content 时更新聊天气泡;
收到 data.audio 时 base64 解码;
放入音频队列;
依次写入 SourceBuffer;
浏览器自动播放。
十九、新手重点:为什么需要 VAD 如果没有 VAD,前端不知道:
用户什么时候开始说话;
用户什么时候说完;
哪些是人声;
哪些是键盘声、环境噪声。
VAD 的作用就是判断“这一段是不是人声”。
它可以避免用户需要一直按住按钮,也能自动在说完后上传音频。
二十、新手重点:为什么 ASR / TTS 用 WebSocket 语音数据通常比较长,而且适合一边发送一边接收。
普通 HTTP 像一次性寄包裹:
WebSocket 像电话通话:
所以实时语音识别和语音合成更适合 WebSocket。
二十一、新手重点:为什么前端播放要用队列 浏览器的 SourceBuffer 不能同时写入多个音频片段。
如果同时 append,可能报错:
1 SourceBuffer is updating
所以需要队列:
1 2 3 4 5 6 7 音频片段 1 → 队列 音频片段 2 → 队列 音频片段 3 → 队列 SourceBuffer 空闲 → 写入片段 1 写完 → 写入片段 2 写完 → 写入片段 3
这和排队打饭类似:窗口一次只能服务一个人。
二十二、常见报错排查 22.1 浏览器提示没有麦克风权限 检查:
是否使用 localhost 或 HTTPS;
浏览器是否允许当前网站使用麦克风;
系统隐私设置是否允许浏览器访问麦克风。
22.2 VAD 模型加载失败 检查:
frontend/public/vad/ 是否有 .onnx、.wasm、.mjs 文件;
baseAssetPath 是否正确;
浏览器 Network 是否能访问这些文件;
打包部署后路径是否改成后端静态路径。
22.3 ASR 一直卡住 检查:
.env 中 API_KEY 是否正确;
WSS_URL 是否正确;
是否能访问 wss://;
是否等待到了 task-started;
代理或防火墙是否拦截 WebSocket。
22.4 识别结果为空 检查:
麦克风是否真的录到声音;
PCM 格式是否正确;
sample_rate 是否与接口要求一致;
是否在 sentence_end 时才追加文本;
说话时间是否太短。
22.5 有文字但没有声音 检查:
后端 SSE 是否返回了 audio 字段;
base64 是否能被前端解码;
MediaSource 是否成功 sourceopen;
addSourceBuffer('audio/mpeg') 是否成功;
浏览器是否阻止自动播放。
22.6 新回复和旧回复混在一起 检查:
每次发送时是否 ++processId;
SSE 回调里是否判断 curId !== processId;
开始语音输入时是否触发 emit('stop');
handleStop 是否调用 stopAudio()。
二十三、本节复习问题
VAD、ASR、TTS 分别是什么意思?
为什么需要把 Float32Array 转成 PCM16?
为什么上传语音要用 FormData?
WebSocket 和 SSE 在本项目中分别用在哪里?
为什么 TTS 音频要 base64 编码后通过 SSE 发送?
MediaSource 解决了什么问题?
SourceBuffer 为什么需要队列?
processId 如何实现消息打断?
为什么要在组件卸载时释放 VAD 和音频资源?
部署时 VAD 静态资源路径为什么要特别注意?
二十四、核心接口完整请求 / 响应示例 24.1 ASR 语音识别接口 1 2 3 POST /api/friend/message/asr/asr/ Authorization : Bearer <access_token>Content-Type : multipart/form-data
表单字段:
字段
类型
说明
audio
file
PCM 或 wav 音频文件
sample_rate
string
采样率,例如 16000
成功响应:
1 2 3 4 { "result" : "success" , "text" : "你好,请介绍一下你自己" }
失败响应:
24.2 语音聊天流式接口 1 2 3 4 POST /api/friend/message/voice/chat/ Authorization : Bearer <access_token>Content-Type : application/jsonAccept : text/event-stream
请求体:
1 2 3 4 { "friend_id" : 5 , "message" : "你好,请介绍一下你自己" }
SSE 响应示例:
1 2 3 4 5 6 7 8 9 data: {"content": "你好"} data: {"content": ",我是你的 AI 助手。"} data: {"audio": "SUQzBAAAAAAA..."} data: {"audio": "AAAAHGZ0eXBtc..."} data: [DONE]
说明:
content 用于显示文字;
audio 是 base64 音频片段;
[DONE] 表示本轮回复结束。
二十五、关键代码逐行解释示例 25.1 Float32 转 PCM16 1 2 3 4 5 6 7 8 9 function floatTo16BitPCM (float32Array ) { const buffer = new ArrayBuffer (float32Array.length * 2 ) const view = new DataView (buffer) for (let i = 0 ; i < float32Array.length ; i++) { let sample = Math .max (-1 , Math .min (1 , float32Array[i])) view.setInt16 (i * 2 , sample < 0 ? sample * 0x8000 : sample * 0x7fff , true ) } return buffer }
逐行解释:
代码
新手解释
float32Array.length * 2
PCM16 每个采样点占 2 字节
new ArrayBuffer(...)
创建一块二进制内存
new DataView(buffer)
用 DataView 按字节写入数据
for (...)
遍历每一个音频采样点
Math.max(-1, Math.min(1, ...))
把音频值限制在 -1 到 1 之间,防止溢出
setInt16(...)
写入 16 位整数格式的音频采样
true
使用小端字节序,很多音频接口要求这种格式
return buffer
返回可上传的二进制音频数据
25.2 音频队列播放 1 2 3 4 5 function appendNextChunk ( ) { if (!sourceBuffer || sourceBuffer.updating || audioQueue.length === 0 ) return const chunk = audioQueue.shift () sourceBuffer.appendBuffer (chunk) }
逐行解释:
代码
新手解释
!sourceBuffer
播放器还没准备好就不能写入
sourceBuffer.updating
上一个音频片段还没写完
audioQueue.length === 0
队列里没有待播放音频
return
不满足条件就先退出,避免报错
audioQueue.shift()
取出队列中最早进入的音频片段
appendBuffer(chunk)
把音频片段追加给浏览器播放
二十六、Vue 组件数据传递图 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 Microphone.vue │ VAD 监听用户说话 ├── onSpeechStart │ ↓ │ emit stop │ ↓ │ ChatField.vue 停止旧回复和旧音频 │ └── onSpeechEnd ↓ PCM 音频文件 ↓ ASR API ↓ 得到识别文本 ↓ emit send-message ↓ ChatField.vue ↓ voice stream API ↓ content 更新文字气泡 audio 放入播放队列
二十七、后端语音视图处理流程 27.1 ASRView 流程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 收到 POST /asr/asr/ ↓ 检查用户是否登录 ↓ 从 request.FILES 读取 audio ↓ 读取 sample_rate ↓ 连接 ASR WebSocket 服务 ↓ 发送任务开始消息 ↓ 发送音频二进制数据 ↓ 发送任务结束消息 ↓ 等待识别结果 ↓ 返回 text
27.2 语音聊天流程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 收到 POST /voice/chat/ ↓ 复用文字聊天上下文构造 ↓ 大模型生成文本片段 ↓ 文本片段通过 SSE 返回 content ↓ 同时把文本送入 TTS 队列 ↓ TTS WebSocket 返回音频二进制 ↓ 后端 base64 编码 ↓ 通过 SSE 返回 audio ↓ 生成完成后返回 [DONE]
二十八、小练习与自测答案 练习 1 问题:为什么 VAD 检测到用户开始说话时要停止旧回复?
答案:语音对话需要支持打断。如果 AI 还在说,用户又开始说话,应该停止旧音频和旧流,避免新旧对话混在一起。
练习 2 问题:为什么 TTS 音频不能直接当 JSON 字符串发送?
答案:音频是二进制数据,不适合直接放进 JSON。通常需要 base64 编码成文本,再放进 SSE 的 JSON 字段中发送。
练习 3 问题:为什么 SourceBuffer 需要队列?
答案:SourceBuffer 同一时间只能写入一个片段。如果多个音频片段同时写入,会出现 SourceBuffer is updating 报错,所以必须排队顺序写入。