语音模块、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.vuehandleSend


六、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 的软件;
  • 查看后端终端异常。

十三、易错点总结

  1. 空消息要先判断再设置处理中状态,否则会导致后续无法发送;
  2. processId 必须在每次新消息或停止时自增;
  3. VAD 模型文件必须能被浏览器访问;
  4. baseAssetPathort.env.wasm.wasmPaths 都要配置;
  5. Float32Array 要转 PCM16;
  6. ASR 上传要用 FormData
  7. 后端 WebSocket 任务必须等待 task-started 后再发送音频;
  8. task-failed 不要拼错;
  9. SSE 不能直接传二进制音频,必须 base64;
  10. SourceBuffer.appendBuffer 不能并发写入,要用队列;
  11. 关闭弹窗时要停止音频并清理 Object URL;
  12. 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 进行流式播放;
  • 使用 processIdstopAudio 实现打断。

十六、新手版:用生活例子理解语音模块

可以把语音聊天想象成一个同声传译助手:

  1. 你开口说话;
  2. 助手判断你是不是正在说话;
  3. 你说完后,助手把语音听写成文字;
  4. 文字交给 AI 思考;
  5. AI 生成文字回复;
  6. 另一个助手把文字读出来;
  7. 浏览器边收到声音边播放。

对应到技术:

生活步骤 技术
判断你有没有说话 VAD
把语音听写成文字 ASR
AI 思考和回答 LLM
把文字读出来 TTS
浏览器播放声音 MediaSource

十七、跟做步骤:语音输入

17.1 前端步骤

  1. 安装 @ricky0123/vad-web
  2. 复制 VAD 所需模型文件到 frontend/public/vad/
  3. 创建 Microphone.vue
  4. 在组件中初始化 MicVAD
  5. onSpeechStart 中显示音浪并打断旧回复;
  6. onSpeechEnd 中获取音频;
  7. 把 Float32 音频转为 PCM16;
  8. FormData 上传到 ASR 接口。

17.2 后端步骤

  1. 安装 websockets
  2. .env 中配置 API_KEYWSS_URL
  3. 创建 ASRView
  4. request.FILES 获取音频;
  5. 使用 WebSocket 连接 ASR 服务;
  6. 发送 PCM 数据;
  7. 接收识别文本;
  8. 返回给前端。

17.3 验证方法

  1. 打开聊天框;
  2. 点击麦克风;
  3. 浏览器弹出麦克风权限时点击允许;
  4. 说一句话;
  5. 观察是否出现音浪;
  6. Network 中是否请求 /api/friend/message/asr/asr/
  7. 响应中是否有识别文本;
  8. 识别文本是否自动发送给聊天接口。

十八、跟做步骤:语音回复播放

18.1 后端步骤

  1. 保留原来的文字聊天 SSE;
  2. 新增 TTS WebSocket 调用;
  3. 大模型生成文字片段时,同时发给 TTS;
  4. TTS 返回二进制音频;
  5. 后端把音频 base64 编码;
  6. 通过 SSE 返回:
{"content": "你好"}

或:

{"audio": "base64音频"}

18.2 前端步骤

  1. 初始化 MediaSource
  2. 创建 Audio 对象;
  3. 收到 data.content 时更新聊天气泡;
  4. 收到 data.audio 时 base64 解码;
  5. 放入音频队列;
  6. 依次写入 SourceBuffer
  7. 浏览器自动播放。

十九、新手重点:为什么需要 VAD

如果没有 VAD,前端不知道:

  • 用户什么时候开始说话;
  • 用户什么时候说完;
  • 哪些是人声;
  • 哪些是键盘声、环境噪声。

VAD 的作用就是判断“这一段是不是人声”。

它可以避免用户需要一直按住按钮,也能自动在说完后上传音频。


二十、新手重点:为什么 ASR / TTS 用 WebSocket

语音数据通常比较长,而且适合一边发送一边接收。

普通 HTTP 像一次性寄包裹:

把全部音频准备好 → 一次性发送 → 等结果

WebSocket 像电话通话:

一边说 → 对方一边听 → 对方一边返回结果

所以实时语音识别和语音合成更适合 WebSocket。


二十一、新手重点:为什么前端播放要用队列

浏览器的 SourceBuffer 不能同时写入多个音频片段。

如果同时 append,可能报错:

SourceBuffer is updating

所以需要队列:

音频片段 1 → 队列
音频片段 2 → 队列
音频片段 3 → 队列

SourceBuffer 空闲 → 写入片段 1
写完 → 写入片段 2
写完 → 写入片段 3

这和排队打饭类似:窗口一次只能服务一个人。


二十二、常见报错排查

22.1 浏览器提示没有麦克风权限

检查:

  1. 是否使用 localhost 或 HTTPS;
  2. 浏览器是否允许当前网站使用麦克风;
  3. 系统隐私设置是否允许浏览器访问麦克风。

22.2 VAD 模型加载失败

检查:

  1. frontend/public/vad/ 是否有 .onnx.wasm.mjs 文件;
  2. baseAssetPath 是否正确;
  3. 浏览器 Network 是否能访问这些文件;
  4. 打包部署后路径是否改成后端静态路径。

22.3 ASR 一直卡住

检查:

  1. .envAPI_KEY 是否正确;
  2. WSS_URL 是否正确;
  3. 是否能访问 wss://
  4. 是否等待到了 task-started
  5. 代理或防火墙是否拦截 WebSocket。

22.4 识别结果为空

检查:

  1. 麦克风是否真的录到声音;
  2. PCM 格式是否正确;
  3. sample_rate 是否与接口要求一致;
  4. 是否在 sentence_end 时才追加文本;
  5. 说话时间是否太短。

22.5 有文字但没有声音

检查:

  1. 后端 SSE 是否返回了 audio 字段;
  2. base64 是否能被前端解码;
  3. MediaSource 是否成功 sourceopen
  4. addSourceBuffer('audio/mpeg') 是否成功;
  5. 浏览器是否阻止自动播放。

22.6 新回复和旧回复混在一起

检查:

  1. 每次发送时是否 ++processId
  2. SSE 回调里是否判断 curId !== processId
  3. 开始语音输入时是否触发 emit('stop')
  4. handleStop 是否调用 stopAudio()

二十三、本节复习问题

  1. VAD、ASR、TTS 分别是什么意思?
  2. 为什么需要把 Float32Array 转成 PCM16?
  3. 为什么上传语音要用 FormData
  4. WebSocket 和 SSE 在本项目中分别用在哪里?
  5. 为什么 TTS 音频要 base64 编码后通过 SSE 发送?
  6. MediaSource 解决了什么问题?
  7. SourceBuffer 为什么需要队列?
  8. processId 如何实现消息打断?
  9. 为什么要在组件卸载时释放 VAD 和音频资源?
  10. 部署时 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 报错,所以必须排队顺序写入。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐