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

一、用 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 等现代浏览器中。

