从 0 到 1:Vue3 + 科大讯飞实现实时语音转文字的工程实践
本文记录了一个完整的语音输入功能从调研到落地的全过程,包括踩过的坑、数据结构设计的演进,以及最终的大厂级解决方案。
一、背景
1.1 为什么要做语音输入?
在 AI 对话场景中,语音输入不是锦上添花,而是刚需:
- 场景1:用户边走边想,或者开会记录
- 场景2:长句输入打断思路,语音更连贯
- 场景3:部分用户(如长辈)更习惯语音交互
1.2 我们的现状
项目技术栈:
- 前端:Vue3 + Element Plus
- 后端:Django + Django Channels(WebSocket)
- 目标:接入科大讯飞实时语音识别
1.3 核心挑战
- 实时性:边说话边出字,延迟 < 200ms
- 流式传输:音频数据量大,不能等录完再传
- 多句话处理:用户停顿后继续说,不能丢内容
- 修正处理:识别过程中可能出现"你号"→"你好"的修正 (核心难点)
二、方案设计
2.1 整体架构
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ 浏览器 │ ──▶ │ Django │ ──▶ │ 科大讯飞 │
│ (Vue3) │ PCM │ (WebSocket) │ │ (实时识别) │
│ │ ◀── │ │ │ │
└─────────────┘ └──────────────┘ └─────────────┘
职责划分:
- 前端:音频采集、编码、传输、UI反馈
- 后端:WebSocket 中转、鉴权、协议转换
- 讯飞:实时识别、返回流式结果
2.2 音频传输方案
为什么选择 PCM?
| 格式 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| WebM/Opus | 压缩率高 | 需要解码,时延大 | ❌ |
| WAV | 通用 | 有文件头,流式不友好 | ❌ |
| PCM | 原始、无压缩、时延低 | 数据量大 | ✅ |
讯飞要求:
- 采样率:16000 Hz
- 位深:16 bit
- 声道:单声道
- 编码:小端 (little-endian)
2.3 数据结构演进(核心)
第一阶段:简单追加(踩坑)
// ❌ 错误方案:直接追加
textRef.value += data.text
问题:科大每次返回的都是完整文本,直接追加会导致疯狂重复。(重点注意)
第二阶段:Delta 计算(复杂化)
// ❌ 过度设计:后端计算增量 主要是讯飞本身就不是按一个字一个字传过来的 讯飞本身就是后覆盖+周期性记录 用下面的数据结构明显不合适
{
delta_type: "append" | "replace" | "final",
text: "增量文本",
replaced_len: 2 // 需要回退的字符数
}
问题:
- 后端逻辑复杂
- 多句话场景处理困难
- 状态管理混乱
第三阶段:双数组结构(✅ 最终方案)
// ✅ 最简洁的方案
let completedTexts = [] // 已完成的句子数组
let currentText = '' // 当前未完成句子(用后覆盖前) 得益于讯飞优秀的模型,后一个句子会对前一个句子进行符号补充,所以我们直接字符串累加就行不必考虑字符串
// 显示逻辑
const displayText = completedTexts.join('') + currentText
处理逻辑:
function handleSpeechResult(data) {
const { text, is_final } = data
if (is_final) {
// 句子完成,推入数组
completedTexts.push(text)
currentText = ''
} else {
// 未完成,用后覆盖前
currentText = text
}
updateInputDisplay()
}
为什么这个方案好?
| 优势 | 说明 |
|---|---|
| 天然去重 | 未完成文本始终被覆盖,不会重复 |
| 修正自动处理 | "你号"→"你好"只是 currentText 被新值覆盖 |
| 多句话清晰 | completedTexts 数组天然支持多句 |
| 逻辑极简 | 没有 delta 计算,没有复杂状态机 |
对比大厂方案:
// 科大讯飞官方示例(和我们结构一致)
if (result.ls) { // 一句话结束
finalResult += current + '。'
currentResult = ''
} else {
currentResult = current // 用后覆盖
}
我们的设计和大厂方案完全一致,甚至更简单。
三、实施与踩坑
3.1 如何传输音频
采集流程
// 1. 获取麦克风流
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000, // 讯飞要求
channelCount: 1, // 单声道
echoCancellation: true,
noiseSuppression: true,
},
})
// 2. 创建 AudioContext
const audioContext = new AudioContext({ sampleRate: 16000 })
const source = audioContext.createMediaStreamSource(stream)
// 3. 创建 ScriptProcessor(4096 = 256ms 缓冲)
const processor = audioContext.createScriptProcessor(4096, 1, 1)
// 4. 处理音频数据
processor.onaudioprocess = (e) => {
const input = e.inputBuffer.getChannelData(0) // Float32Array
const pcm = float32ToPcm16(input) // 转 Int16
const frames = splitIntoChunks(pcm, 640) // 640 samples = 40ms
frames.forEach(frame => {
const base64 = arrayBufferToBase64(frame.buffer)
ws.send(JSON.stringify({
type: 'speech_audio',
data: base64,
}))
})
}
关键转换函数
// Float32 -> Int16 PCM
function float32ToPcm16(float32Array) {
const pcm = new Int16Array(float32Array.length)
for (let i = 0; i < float32Array.length; i++) {
const s = Math.max(-1, Math.min(1, float32Array[i]))
pcm[i] = s < 0 ? s * 0x8000 : s * 0x7fff
}
return pcm
}
// 分片发送:每帧 1280 字节 = 640 samples = 40ms
// 这是讯飞 WebSocket 协议的要求
const PCM_FRAME_SIZE = 1280 // bytes
3.2 如何接收识别结果
WebSocket 协议设计
前端 -> 后端:
// 开始识别
{ type: 'speech_start', format: 'pcm' }
// 发送音频
{ type: 'speech_audio', data: 'base64encoded...' }
// 结束识别
{ type: 'speech_end' }
后端 -> 前端:
// 识别结果(最简设计)
{
type: 'speech_result',
text: '你好,我叫蔡徐坤',
is_final: false // true = 一句话结束
}
// 错误
{ type: 'speech_error', message: '识别失败' }
// 识别结束(科大自动超时或手动结束)
{ type: 'speech_end' }
为什么这样设计?
- 后端只做转发,不计算 delta
- 前端用后覆盖前,逻辑最简单
- 协议清晰,易于调试
3.3 踩过的坑
坑1:文本重复
现象:输入框显示 "你你你你好好好好"
根因:直接追加完整文本
// ❌ 错误
textRef.value += data.text // 科大每次返回完整文本
解决:用后覆盖前
// ✅ 正确
currentText = data.text // 未完成时覆盖
坑2:多句话内容丢失
现象:用户说两句话,只显示第一句
根因:没有区分已完成和未完成
// ❌ 错误:没有数组存储已完成句子
let finalText = ''
finalText += data.text // 第二句覆盖了第一句
解决:数组存储已完成句子
// ✅ 正确
let completedTexts = [] // 已完成句子数组
if (is_final) {
completedTexts.push(text) // 推入数组
}
坑3:修正时文本错乱
现象:"你号"→"你好"变成 "你号好"
根因:追加逻辑处理不了回退
// ❌ 错误
// 你 -> 你号 -> 你好
// 追加结果:你 + 号 + 好 = "你号好"
解决:覆盖天然支持修正
// ✅ 正确
// currentText = "你号"
// 收到 "你好",直接覆盖
// currentText = "你好"
坑4:后端过度设计
现象:后端写了 200 行 delta 计算代码
根因:想在前端偷懒,让后端算增量
解决:后端只转发,前端自己处理
# 后端极简代码
def on_speech_result(text, is_final):
websocket.send({
"type": "speech_result",
"text": text,
"is_final": is_final
})
四、成果
4.1 最终代码结构
// src/composables/useVoiceRecorder.js
export function useVoiceRecorder({ textRef, onError, onStart, onEnd } = {}) {
// ========== 状态 ==========
let completedTexts = [] // 已完成句子数组
let currentText = '' // 当前未完成句子
let ws = null
let audioContext = null
// ... 其他资源
// ========== 核心处理 ==========
function handleSpeechResult(data) {
const { text, is_final } = data
if (is_final) {
completedTexts.push(text)
currentText = ''
} else {
currentText = text // 用后覆盖前
}
// 更新显示
textRef.value = completedTexts.join('') + currentText
}
// ========== 音频采集与发送 ==========
async function start() {
// 获取麦克风流
const stream = await navigator.mediaDevices.getUserMedia({
audio: { sampleRate: 16000, channelCount: 1 }
})
// WebSocket 连接
ws = new WebSocket(getWsUrl())
ws.send(JSON.stringify({ type: 'speech_start', format: 'pcm' }))
// 音频处理
const audioContext = new AudioContext({ sampleRate: 16000 })
// ... 创建 ScriptProcessor,转换 PCM,分片发送
}
// ========== 清理 ==========
function stop() {
ws.send(JSON.stringify({ type: 'speech_end' }))
cleanup()
}
return { isListening, isSupported, start, stop }
}
4.2 使用效果
| 指标 | 结果 |
|---|---|
| 首字延迟 | < 200ms |
| 实时性 | 边说边出字 |
| 断句准确性 | 科大 LS 参数自动检测 |
| 多句话支持 | ✅ 自动追加 |
| 修正处理 | ✅ 天然支持 |
4.3 关键设计原则
- 数据分离:已完成 vs 未完成必须分开存储
- 用后覆盖:未完成文本始终覆盖,避免重复
- 后端极简:只做转发,不计算 delta
- 前端自治:自己管理拼接逻辑,响应更快
五、总结
5.1 核心数据结构(最重要)
// 双数组结构:大厂标准方案
completedTexts = [] // 已完成的句子(is_final=true 时 push)
currentText = '' // 当前未完成(用后覆盖前)
displayText = completedTexts.join('') + currentText
5.2 传输与接收
| 环节 | 要点 |
|---|---|
| 采集 | AudioContext + getUserMedia |
| 编码 | Float32 -> Int16 PCM (16kHz/单声道) |
| 分片 | 1280 bytes/帧 = 40ms |
| 传输 | WebSocket base64 |
| 接收 | 最简 JSON: {text, is_final} |
| 拼接 | 数组 + 覆盖 |
5.3 最佳实践
- 不要让后端计算 delta,前端自己管理更简单
- 不要直接追加完整文本,用后覆盖
- 要区分已完成和未完成,用数组存储已完成
- 要保持后端极简,只做协议转发
后续待优化。。。。
- 音频流压缩(减少带宽)
- 本地VAD(减少无效传输)
- 防抖渲染(避免频繁DOM更新)
- 容错重传(丢包处理)
- 多语言切换