AI Chat 集成语音输入的最佳实践

3 阅读7分钟

从 0 到 1:Vue3 + 科大讯飞实现实时语音转文字的工程实践

本文记录了一个完整的语音输入功能从调研到落地的全过程,包括踩过的坑、数据结构设计的演进,以及最终的大厂级解决方案。


一、背景

1.1 为什么要做语音输入?

在 AI 对话场景中,语音输入不是锦上添花,而是刚需

  • 场景1:用户边走边想,或者开会记录
  • 场景2:长句输入打断思路,语音更连贯
  • 场景3:部分用户(如长辈)更习惯语音交互

1.2 我们的现状

项目技术栈:

  • 前端:Vue3 + Element Plus
  • 后端:Django + Django Channels(WebSocket)
  • 目标:接入科大讯飞实时语音识别

1.3 核心挑战

  1. 实时性:边说话边出字,延迟 < 200ms
  2. 流式传输:音频数据量大,不能等录完再传
  3. 多句话处理:用户停顿后继续说,不能丢内容
  4. 修正处理:识别过程中可能出现"你号"→"你好"的修正 (核心难点)

二、方案设计

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 关键设计原则

  1. 数据分离:已完成 vs 未完成必须分开存储
  2. 用后覆盖:未完成文本始终覆盖,避免重复
  3. 后端极简:只做转发,不计算 delta
  4. 前端自治:自己管理拼接逻辑,响应更快

五、总结

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,前端自己管理更简单
  • 不要直接追加完整文本,用后覆盖
  • 区分已完成和未完成,用数组存储已完成
  • 保持后端极简,只做协议转发

后续待优化。。。。

  1. 音频流压缩(减少带宽)
  2. 本地VAD(减少无效传输)
  3. 防抖渲染(避免频繁DOM更新)
  4. 容错重传(丢包处理)
  5. 多语言切换