6. 语音模块
语音模块、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 项目语音流程
用户说话
↓
前端 VAD 判断人声开始/结束
↓
获取一段音频 Float32Array
↓
转成 16-bit PCM
↓
FormData 上传到 Django ASR 接口
↓
后端通过 WebSocket 调用阿里云 ASR
↓
得到识别文本
↓
前端把识别文本交给聊天输入逻辑
↓
后端调用大模型生成回复
↓
后端同时调用 TTS 合成音频
↓
SSE 返回文本片段和音频片段
↓
前端显示文字并播放语音
三、消息打断机制
3.1 为什么需要打断?
用户可能在 AI 尚未回复完时又发送新消息,或者开始说话。这时旧回复如果继续追加,会造成:
- 新旧回复混在一起;
- 旧语音继续播放;
- 当前聊天上下文混乱。
3.2 processId 设计
let processId = 0
每次发送新消息:
const curId = ++processId
流式回调中判断:
if (curId !== processId) return
含义:
- 每次请求都有自己的版本号
curId; - 如果后续又发送了新消息,
processId自增; - 老请求回来的数据版本号不一致,直接丢弃。
3.3 打断语音播放
function handleStop() {
++processId
stopAudio()
}
触发场景:
- 用户开始新一轮语音输入;
- 用户关闭聊天框;
- 用户切换输入模式;
- 用户发送新消息。
四、前端 VAD 语音检测
4.1 安装依赖
cd frontend
npm install @ricky0123/vad-web
4.2 拷贝静态资源
需要复制到:
frontend/public/vad/
资源来源:
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:
frontend/public/vad/
这些文件体积较大,不建议提交到仓库。
4.3 Microphone.vue 组件结构
<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 音浪动画
.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
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 生命周期释放资源
onMounted(() => {
startRecording()
})
onBeforeUnmount(() => {
if (vadInstance) {
vadInstance.destroy()
vadInstance = null
}
})
五、音频格式转换与上传
5.1 Float32 转 PCM16
VAD 输出的是 Float32Array,ASR 接口需要 PCM 音频。
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 上传到后端
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。
六、InputField 中切换文字 / 语音输入
6.1 状态设计
const showMic = ref(false)
模板:
<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 支持语音文本
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 安装依赖
cd backend
pip install websockets
7.2 环境变量
API_KEY="你的 API Key"
WSS_URL="wss://dashscope.aliyuncs.com/api-ws/v1/inference"
7.3 ASRView
import asyncio
import json
import os
import uuid
import websockets
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
class 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 发送音频
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 接收识别结果
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 并发执行发送和接收
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 路由
path('api/friend/message/asr/asr/', ASRView.as_view()),
八、协程知识补充
8.1 什么是协程?
协程是一种轻量级并发模型。它不是由操作系统抢占调度,而是在遇到 await 时主动让出控制权。
8.2 协程 vs 线程
| 对比 | 协程 | 线程 |
|---|---|---|
| 调度方式 | 用户态事件循环 | 操作系统内核 |
| 切换开销 | 小 | 较大 |
| 适合场景 | 高并发 I/O | CPU 密集或阻塞任务 |
| 并行能力 | 单线程内不并行 | 多核可并行 |
| 编程关键 | async/await |
锁、线程安全 |
8.3 asyncio.gather
await asyncio.gather(task1(), task2())
表示并发等待多个协程完成。在 ASR 中,发送音频和接收结果需要同时进行,因此使用 gather。
九、后端 TTS 语音合成
9.1 为什么更复杂?
文字聊天只需要:
LLM 文本片段 → SSE 文本片段
语音聊天需要:
LLM 文本片段
↓
一边推给前端显示
↓
一边发送给 TTS 服务
↓
TTS 返回二进制音频
↓
转 base64
↓
通过 SSE 推给前端播放
9.2 为什么需要线程 + 队列?
Django 的 StreamingHttpResponse 是同步生成器,而 TTS WebSocket 和 LangGraph astream 是异步任务。
因此设计:
主线程:同步 event_stream,不断从 Queue 取消息并 yield 给前端
子线程:运行 asyncio.run,调用 LLM 和 TTS,把文本/音频塞进 Queue
9.3 线程安全队列
from queue import Queue
mq = Queue()
队列内容示例:
{'content': '你好'}
{'audio': 'base64字符串'}
{'usage': {...}}
None
None 表示结束。
9.4 音频为什么要 base64?
SSE 是文本协议,不能直接传二进制音频,因此要:
audio = base64.b64encode(binary_audio).decode('utf-8')
前端再用 atob 解码。
9.5 TTS 返回 SSE
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 播放音频流
10.1 MediaSource 是什么?
MediaSource Extensions(MSE)允许前端不断向音频 / 视频缓冲区追加二进制片段,实现“边下载边播放”。
10.2 状态变量
let mediaSource = null
let sourceBuffer = null
let audioPlayer = new Audio()
let audioQueue = []
let isUpdating = false
10.3 初始化音频流
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 处理音频片段
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
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 停止音频
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 组件卸载清理
onUnmounted(() => {
audioPlayer.pause()
audioPlayer.src = ''
})
十一、路由汇总
path('api/friend/message/chat/', MessageChatView.as_view()),
path('api/friend/message/asr/asr/', ASRView.as_view()),
十二、开发与部署注意事项
12.1 开发环境 VAD 路径
const baseUrl = 'http://localhost:5173/vad/'
12.2 打包到后端后的 VAD 路径
如果前端已打包到 Django 静态目录,建议改为:
const baseUrl = 'http://127.0.0.1:8000/static/frontend/vad/'
并将 VAD 文件同步到:
backend/static/frontend/vad/
12.3 MIME 类型配置
某些静态文件需要正确 MIME,否则浏览器加载失败。
import mimetypes
mimetypes.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 的作用
异步子线程: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 返回:
{"content": "你好"}
或:
{"audio": "base64音频"}
18.2 前端步骤
- 初始化
MediaSource; - 创建
Audio对象; - 收到
data.content时更新聊天气泡; - 收到
data.audio时 base64 解码; - 放入音频队列;
- 依次写入
SourceBuffer; - 浏览器自动播放。
十九、新手重点:为什么需要 VAD
如果没有 VAD,前端不知道:
- 用户什么时候开始说话;
- 用户什么时候说完;
- 哪些是人声;
- 哪些是键盘声、环境噪声。
VAD 的作用就是判断“这一段是不是人声”。
它可以避免用户需要一直按住按钮,也能自动在说完后上传音频。
二十、新手重点:为什么 ASR / TTS 用 WebSocket
语音数据通常比较长,而且适合一边发送一边接收。
普通 HTTP 像一次性寄包裹:
把全部音频准备好 → 一次性发送 → 等结果
WebSocket 像电话通话:
一边说 → 对方一边听 → 对方一边返回结果
所以实时语音识别和语音合成更适合 WebSocket。
二十一、新手重点:为什么前端播放要用队列
浏览器的 SourceBuffer 不能同时写入多个音频片段。
如果同时 append,可能报错:
SourceBuffer is updating
所以需要队列:
音频片段 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 语音识别接口
POST /api/friend/message/asr/asr/
Authorization: Bearer <access_token>
Content-Type: multipart/form-data
表单字段:
| 字段 | 类型 | 说明 |
|---|---|---|
audio |
file | PCM 或 wav 音频文件 |
sample_rate |
string | 采样率,例如 16000 |
成功响应:
{
"result": "success",
"text": "你好,请介绍一下你自己"
}
失败响应:
{
"result": "语音识别失败"
}
24.2 语音聊天流式接口
POST /api/friend/message/voice/chat/
Authorization: Bearer <access_token>
Content-Type: application/json
Accept: text/event-stream
请求体:
{
"friend_id": 5,
"message": "你好,请介绍一下你自己"
}
SSE 响应示例:
data: {"content": "你好"}
data: {"content": ",我是你的 AI 助手。"}
data: {"audio": "SUQzBAAAAAAA..."}
data: {"audio": "AAAAHGZ0eXBtc..."}
data: [DONE]
说明:
content用于显示文字;audio是 base64 音频片段;[DONE]表示本轮回复结束。
二十五、关键代码逐行解释示例
25.1 Float32 转 PCM16
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 音频队列播放
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 组件数据传递图
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 流程
收到 POST /asr/asr/
↓
检查用户是否登录
↓
从 request.FILES 读取 audio
↓
读取 sample_rate
↓
连接 ASR WebSocket 服务
↓
发送任务开始消息
↓
发送音频二进制数据
↓
发送任务结束消息
↓
等待识别结果
↓
返回 text
27.2 语音聊天流程
收到 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 报错,所以必须排队顺序写入。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)