广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何在 Vue 3 中通过 WebSocket 实现实时语音转文字?

实时语音转文字不是简单地把录音文件丢给接口,而是要在用户说话的同时,让文字以极低延迟出现在界面上。Vue 3 项目里通常用 WebSocket 作为传输层,因为 HTTP 请求一次一收一发,无法让服务端持续推送中间识别结果。本文从音频采集、客户端封装、协议对接三个环节展开,给出可在工程中直接参考的实现方式。

如何在 Vue 3 中通过 WebSocket 实现实时语音转文字?

一、用 AudioWorklet 把麦克风音频转成 PCM 流

浏览器获取麦克风权限后,拿到的是一路 MediaStream。要交给语音识别服务,最常见的是把音频转成 PCM 16bit、16kHz 单声道的原始数据。这个转换可以放在主线程里做,但主线程还负责页面渲染和 WebSocket 发送,一旦有大量计算就可能导致掉帧或音频卡顿。Vue 3 项目里更推荐用 AudioWorklet,它运行在独立的音频渲染线程中,不会阻塞 UI。

AudioWorklet 的使用分两步:先通过 navigator.mediaDevices.getUserMedia 拿到麦克风流,然后用 AudioContext 创建音频图,再加载一个自定义的 AudioWorkletProcessor。在 processor 的 process 回调里,每一帧会送来 Float32Array 格式的采样数据。如果识别服务要求 Int16 格式,就要做一次类型转换,同时把浮点范围 -1 到 1 映射到 -32768 到 32767。下面这段初始化代码展示了核心流程:

async function initAudio(onChunk) {
  const stream = await navigator.mediaDevices.getUserMedia({
    audio: {
      sampleRate: 16000,
      channelCount: 1,
      echoCancellation: true,
      noiseSuppression: true
    }
  });
  const audioContext = new AudioContext({ sampleRate: 16000 });
  const source = audioContext.createMediaStreamSource(stream);
  await audioContext.audioWorklet.addModule('pcm-processor.js');
  const workletNode = new AudioWorkletNode(audioContext, 'pcm-processor');
  source.connect(workletNode);
  workletNode.port.onmessage = (event) => {
    onChunk(event.data);
  };
}

audioWorklet.addModule 加载的 pcm-processor.js 是一个独立文件,需要放在 public 目录或可访问的静态路径下。processor 内部使用 this.port.postMessage 把转换后的 ArrayBuffer 发回主线程,然后主线程再交给 WebSocket 发送。process 方法必须返回 true,表示持续处理音频;返回 false 会让节点停止。

class PCMProcessor extends AudioWorkletProcessor {
  process(inputs) {
    const input = inputs[0];
    if (!input || input.length === 0) {
      return true;
    }
    const channelData = input[0];
    const frameLength = channelData.length;
    const int16Buffer = new ArrayBuffer(frameLength * 2);
    const int16View = new Int16Array(int16Buffer);
    for (let i = 0; i < frameLength; i++) {
      const normalized = Math.max(-1, Math.min(1, channelData[i]));
      int16View[i] = normalized < 0 ? normalized * 0x8000 : normalized * 0x7fff;
    }
    this.port.postMessage(int16Buffer, [int16Buffer]);
    return true;
  }
}
registerProcessor('pcm-processor', PCMProcessor);

这里把 ArrayBuffer 作为可转移对象传给主线程,可以避免复制开销。但要注意,一旦转移,这个 ArrayBuffer 在当前线程就不能再使用了。每帧的长度由 AudioContext 的渲染块大小决定,通常是 128 帧。16kHz 采样率下,128 帧约 8 毫秒,发送频率较高。实际项目里可以在主线程中做缓冲合并,攒够 40 毫秒或 80 毫秒再发送,减少 WebSocket 消息数量。

二、封装 Vue 3 组合式 API 管理连接状态

WebSocket 的连接管理不该散落在组件里,否则很容易出现重复连接、断线不重连、页面卸载后连接依然存在等问题。Vue 3 的组合式 API 很适合把这一块逻辑抽成 useSpeechRecognition 这样的函数。

这个函数需要维护几个关键状态:连接状态、最终文本、临时文本、错误信息。连接状态用一个枚举值表示,例如 idle、connecting、open、closed、error。组件里只要根据状态值显示不同 UI 即可。连接建立后先发送鉴权消息,再启动心跳定时器。语音识别服务通常有鉴权要求,可以用 token 或临时密钥,放在 localStorage 中读取。

断线重连是很容易被忽略的细节。WebSocket 的 onclose 事件在服务端主动断开、网络切换时都会触发。一个简单的重连策略是:如果没超过最大重试次数,就按重试次数递增延迟,比如第 1 次 1 秒、第 2 次 2 秒,尽量避免服务端压力过大。下面是一个完整封装:

import { reactive, readonly } from 'vue';
export function useSpeechRecognition(url) {
  const state = reactive({
    status: 'idle',
    finalText: '',
    interimText: '',
    errorMsg: ''
  });
  let ws = null;
  let heartbeatTimer = null;
  let reconnectAttempts = 0;
  let audioQueue = [];
  const maxReconnectAttempts = 5;
  const heartbeatInterval = 15000;
  function connect() {
    state.status = 'connecting';
    ws = new WebSocket(url);
    ws.binaryType = 'arraybuffer';
    ws.onopen = () => {
      state.status = 'open';
      reconnectAttempts = 0;
      startHeartbeat();
      sendAuth();
      flushAudioQueue();
    };
    ws.onmessage = (event) => {
      handleMessage(event.data);
    };
    ws.onerror = () => {
      state.status = 'error';
      state.errorMsg = '连接发生错误,请检查网络或服务地址';
    };
    ws.onclose = () => {
      state.status = 'closed';
      stopHeartbeat();
      if (reconnectAttempts < maxReconnectAttempts) {
        reconnectAttempts += 1;
        setTimeout(connect, 1000 * reconnectAttempts);
      }
    };
  }
  function startHeartbeat() {
    heartbeatTimer = setInterval(() => {
      if (ws && ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify({ type: 'heartbeat' }));
      }
    }, heartbeatInterval);
  }
  function stopHeartbeat() {
    if (heartbeatTimer) {
      clearInterval(heartbeatTimer);
      heartbeatTimer = null;
    }
  }
  function sendAuth() {
    const token = localStorage.getItem('asr_token') || '';
    ws.send(JSON.stringify({ type: 'auth', token }));
  }
  function sendAudioChunk(arrayBuffer) {
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(arrayBuffer);
    } else if (state.status === 'connecting') {
      audioQueue.push(arrayBuffer);
      if (audioQueue.length > 200) {
        audioQueue.shift();
      }
    }
  }
  function flushAudioQueue() {
    while (audioQueue.length > 0 && ws.readyState === WebSocket.OPEN) {
      ws.send(audioQueue.shift());
    }
  }
  function handleMessage(rawData) {
    if (typeof rawData !== 'string') {
      return;
    }
    const msg = JSON.parse(rawData);
    if (msg.type === 'result') {
      state.finalText = msg.final || '';
      state.interimText = msg.interim || '';
    } else if (msg.type === 'error') {
      state.errorMsg = msg.message || '识别失败,请重试';
    }
  }
  function close() {
    stopHeartbeat();
    if (ws) ws.close();
  }
  return { state: readonly(state), connect, sendAudioChunk, close };
}

代码里加了一个简单的音频队列:当连接还在建立时,麦克风已经在产出数据,直接丢弃这些数据会让开头几个字识别不到。把 ArrayBuffer 暂存到数组,等 onopen 后统一发送,能减少丢字。队列限制在 200 帧左右,防止极端情况下内存暴涨。

组件中使用这个组合式函数时,通常在 onMounted 里调用 connect,在 onBeforeUnmount 里调用 close。页面不会因为路由跳转留下僵尸连接。临时文本可以实时展示,最终文本拼接到完整结果区域,形成一个类似字幕的体验。

三、设计适合流式识别的消息协议

语音识别服务可能在云端,也可能是公司内部自建的 WebSocket 服务。无论哪一种,消息协议都要区分控制消息和音频数据。控制消息用 JSON 文本,音频数据用二进制帧,这样服务端不需要猜测消息类型,解析成本低。

连接建立后,客户端先发送鉴权和配置消息。配置消息里告诉服务端音频采样率、编码格式、语言等信息。识别过程中,二进制帧按顺序发送;说话结束可以发送一个 end 消息,服务端收到后返回最终结果。一个简化协议如下:

{"type":"auth","token":"your_token_here"}
{"type":"config","sample_rate":16000,"encoding":"pcm_s16le","language":"zh-CN"}
{"type":"start"}
{"type":"end"}

服务端返回的消息也建议用 JSON。实时识别通常会返回 partial 中间结果和 final 最终结果。partial 用于展示用户正在说的内容,final 表示前面一段话已经确认,可以拼到最终文本里。不同服务字段可能不同,客户端可以做一层适配,把字段映射成统一的结构。

{"type":"partial","text":"今天天气"}
{"type":"final","text":"今天天气不错","start_time":0.05,"end_time":1.28}

分片大小需要结合采样率和延迟要求。16kHz、16bit、单声道下,每毫秒约 32 字节。40 毫秒是 1280 字节,100 毫秒是 3200 字节。太小会导致 WebSocket 消息数过多,太大又增加首字延迟。实践上 40 到 100 毫秒是一个比较舒服的区间。AudioWorklet 每帧只有 8 毫秒左右,所以主线程端可以维护一个缓冲区,攒到阈值再发送。发送时直接取 ArrayBuffer,不用做 base64 编码,WebSocket 天然支持二进制传输。

还有一个容易被忽视的问题:麦克风长时间开启会一直产生数据,即使用户没有说话,也会持续上传静音帧。如果服务端按流量计费或需要做语音活动检测,可以在客户端做简单的 VAD,判断当前帧的能量是否低于阈值,低于阈值时停止发送,等有声音再继续。浏览器端没有内置 VAD 接口,但可以通过计算短时能量或过零率实现一个粗略的检测。对多数场景来说,配合 echoCancellation 和 noiseSuppression 已经能减少不少环境噪声。

最后,WebSocket 连接需要处理服务端主动关闭、网络切换、浏览器标签后台暂停等边界。标签页切到后台后,浏览器可能会降低定时器优先级,但 AudioWorklet 通常继续运行。如果音频继续产生而 WebSocket 又断了,队列会快速积压。可以在 visibilitychange 事件里暂停采集或清空队列,避免页面恢复后出现大段延迟文本。以上这些细节处理到位后,Vue 3 里的实时语音转文字功能就能稳定运行在 Chrome、Edge、Firefox 等现代浏览器中。

广告位 · 文章底部(后台-模块管理替换此区域)