Vue3 实时音频录制与转写 Composable 技术实现
前言
本文介绍如何基于 Vue3 Composition API 实现一个实时音频录制与转写的 Composable,涉及 Web Audio API、WebSocket 实时通信、音频格式转换等技术。
技术栈
- Vue3 Composition API: 组合式函数封装
- MediaRecorder API: 浏览器音频录制
- Web Audio API: 音频流处理与格式转换
- WebSocket: 实时双向通信
- TypeScript: 类型安全
核心功能
- 实时音频录制(支持暂停/继续/停止)
- 音频流实时处理与传输
- WebSocket 实时通信接收转写结果
- 实时字幕逐句显示
- 字幕定时保存机制
技术架构
text
┌─────────────────────────────────────────┐
│ useAudioRecorder Composable │
├─────────────────────────────────────────┤
│ 1. 音频录制层 (MediaRecorder) │
│ 2. 音频处理层 (Web Audio API) │
│ 3. 实时通信层 (WebSocket) │
│ 4. 字幕处理层 (文本处理) │
│ 5. 数据持久化层 (定时保存) │
└─────────────────────────────────────────┘
一、音频录制实现
1.1 获取麦克风权限
typescript
// 获取用户麦克风权限
audioStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true, // 回声消除
noiseSuppression: true, // 降噪
autoGainControl: true, // 自动增益控制
},
});
技术要点:
getUserMedia返回MediaStream对象- 音频约束配置优化录音质量
- 需要用户授权,需要处理权限拒绝情况
1.2 创建 MediaRecorder
typescript
// 检测浏览器支持的音频格式
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus' // 优先使用 Opus 编码
: 'audio/webm'; // 降级方案
// 创建 MediaRecorder 实例
mediaRecorder = new MediaRecorder(audioStream, { mimeType });
// 监听数据可用事件
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data); // 收集音频块
}
};
// 开始录制(每3秒触发一次 ondataavailable)
mediaRecorder.start(3000);
技术要点:
MediaRecorder用于录制音频流start(timeslice)参数控制数据分片间隔- 音频数据以
Blob格式存储
二、音频处理与格式转换
2.1 双音频流架构
为了实现录音保存和实时转写并行,需要创建两个独立的音频上下文:
typescript
// 音频流1:用于录音保存(原始采样率)
audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256; // 用于音频可视化
audioContext.createMediaStreamSource(audioStream).connect(analyser);
// 音频流2:用于实时转写(16000Hz采样率)
asrAudioContext = new AudioContext({ sampleRate: 16000 });
为什么需要双流?
- 录音保存需要高质量(原始采样率)
- 实时转写需要标准采样率(16000Hz,ASR 标准)
- 两个流互不干扰,独立处理
2.2 音频格式转换
Web Audio API 返回的是 Float32Array(范围 -1.0 到 1.0),而 ASR 服务通常需要 16 位 PCM 格式(范围 -32768 到 32767)。
typescript
/**
* 将 Float32 音频转换为 16 位 PCM
* @param input Float32Array 音频数据
* @returns Uint8Array 16位PCM数据
*/
const convertTo16BitPCM = (input: Float32Array): Uint8Array => {
const buffer = new ArrayBuffer(input.length * 2); // 16位 = 2字节
const view = new DataView(buffer);
input.forEach((sample, i) => {
// 限制范围到 [-1, 1]
const value = Math.max(-1, Math.min(1, sample));
// 转换为16位整数
// 负数: value * 0x8000 = -32768
// 正数: value * 0x7FFF = 32767
view.setInt16(
i * 2,
value < 0 ? value * 0x8000 : value * 0x7FFF,
true // little-endian 字节序
);
});
return new Uint8Array(buffer);
};
转换原理:
- Float32: 32位浮点数,范围 [-1.0, 1.0]
- 16位PCM: 16位整数,范围 [-32768, 32767]
- 使用线性映射:
PCM = Float32 × 32767(正数)或Float32 × 32768(负数)
2.3 音频分块处理
使用 ScriptProcessorNode 处理音频流,并按固定大小分块发送:
typescript
// 常量定义
const PCM_CHUNK_SIZE = 6400; // 200ms音频数据
// 计算:16000Hz × 0.2s × 2字节 = 6400字节
const ASR_SAMPLE_RATE = 16000; // ASR标准采样率
// 设置音频处理器
const setupAudioProcessor = () => {
if (!wsClient?.isConnected() || !asrAudioContext || !audioStream) return;
// 清空缓冲区
audioBuffer = [];
// 创建音频源
const asrSource = asrAudioContext.createMediaStreamSource(audioStream);
// 创建脚本处理器(已废弃但兼容性好)
// 参数:bufferSize, numberOfInputChannels, numberOfOutputChannels
audioProcessor = asrAudioContext.createScriptProcessor(4096, 1, 1);
// 连接音频节点
asrSource.connect(audioProcessor);
audioProcessor.connect(asrAudioContext.destination);
// 处理音频数据
audioProcessor.onaudioprocess = (e) => {
if (!wsClient?.isConnected()) return;
// 1. 获取输入音频数据(Float32格式)
const inputData = e.inputBuffer.getChannelData(0);
// 2. 转换为16位PCM
const pcmData = convertTo16BitPCM(inputData);
// 3. 累积到缓冲区
audioBuffer.push(...pcmData);
// 4. 达到200ms数据量时发送
if (audioBuffer.length >= PCM_CHUNK_SIZE) {
const chunk = new Uint8Array(
audioBuffer.slice(0, PCM_CHUNK_SIZE)
);
// 5. 通过WebSocket发送(二进制数据)
wsClient.send(chunk, false); // false: 不加入消息队列
// 6. 移除已发送的数据
audioBuffer = audioBuffer.slice(PCM_CHUNK_SIZE);
}
};
};
分块策略:
- 块大小: 6400字节 = 200ms音频
- 为什么200ms : 平衡实时性和网络效率
- 太小:网络请求频繁,增加延迟
- 太大:实时性差,用户体验不佳
- 缓冲区管理: 使用数组累积,达到阈值后发送并清空
三、WebSocket 实时通信
3.1 连接建立
typescript
/**
* 连接WebSocket接收实时转写结果
*/
const connectWebSocket = async (recordId: string) => {
try {
const wsUrl = `wss://your-server.com/api/asr/realtime`;
const token = getAuthToken(); // 获取认证token
// 使用封装的WebSocket客户端 你自己可以封装一个WebSocket的工具类
wsClient = createWebSocket({
url: wsUrl,
binaryType: 'arraybuffer', // 支持二进制数据
heartbeatInterval: 30000, // 30秒心跳
reconnectInterval: 3000, // 3秒重连间隔
maxReconnectAttempts: 10, // 最多重连10次
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
}, {
onOpen: () => {
console.log('WebSocket连接成功');
},
onMessage: (message) => {
handleTranscriptionMessage(message);
},
onError: (event) => {
console.error('WebSocket错误:', event);
},
onClose: () => {
console.log('WebSocket连接关闭');
}
});
await wsClient.connect();
} catch (error) {
console.error('WebSocket连接失败:', error);
throw error;
}
};
技术要点:
- 使用 WSS(WebSocket Secure)保证传输安全
- 配置心跳机制保持连接活跃
- 自动重连机制处理网络波动
- Token 认证通过 URL 参数传递(WebSocket 不支持自定义请求头)
3.2 消息处理
typescript
/**
* 处理转写消息
*/
const handleTranscriptionMessage = (message: WebSocketMessage) => {
try {
let result = message.data;
// 1. 解析JSON消息
if (typeof result === 'string') {
// 错误检查
if (result.includes('error') || result.includes('Error') || result.includes('timeout')) {
console.warn('收到错误消息:', result);
return;
}
try {
result = JSON.parse(result);
} catch {
return; // 解析失败,忽略
}
}
// 2. 检查错误字段
if (result?.error) {
console.warn('WebSocket返回错误:', result.error);
return;
}
// 3. 提取转写文本
const text = result?.result?.text || result?.text;
if (text) {
// 更新完整字幕
subtitles.value = text;
accumulatedText.value = text;
// 更新当前显示的字幕(逐句显示)
updateCurrentSubtitle(text, currentSubtitle, lastDisplayedSentenceEnd);
}
} catch (error) {
console.error('处理转写消息失败:', error);
}
};
四、实时字幕显示
4.1 逐句显示逻辑
实现智能的字幕逐句显示,基于标点符号识别完整句子:
typescript
/**
* 更新当前显示的字幕(逐句显示)
* @param fullText 完整字幕文本
* @param currentSubtitle 当前显示的字幕(响应式引用)
* @param lastDisplayedSentenceEnd 已显示的最后一句的结束位置
*/
const updateCurrentSubtitle = (
fullText: string,
currentSubtitle: Ref<string>,
lastDisplayedSentenceEnd: Ref<number>
) => {
if (!fullText) {
currentSubtitle.value = '';
return;
}
// 如果文本长度没有增加,无需更新
if (fullText.length <= lastDisplayedSentenceEnd.value) {
return;
}
// 获取新增的文本部分
const newText = fullText.substring(lastDisplayedSentenceEnd.value);
// 尝试匹配完整句子(以标点符号结尾)
const sentenceMatch = newText.match(/^[^。!?\n\.]+[。!?\n\.]+/);
if (sentenceMatch) {
// 找到完整句子
const completeSentence = sentenceMatch[0].trim();
if (completeSentence) {
// 移除末尾多余的标点符号(保留最后一个)
const displayText = completeSentence.replace(
/[。!?\n\.]+$/,
(match) => match[match.length - 1]
);
currentSubtitle.value = displayText;
// 更新已显示位置
lastDisplayedSentenceEnd.value =
lastDisplayedSentenceEnd.value + sentenceMatch[0].length;
}
} else {
// 没有完整句子,查找最后一个标点符号
const lastPunctuationIndex = Math.max(
fullText.lastIndexOf('。'),
fullText.lastIndexOf('!'),
fullText.lastIndexOf('?'),
fullText.lastIndexOf('\n'),
fullText.lastIndexOf('.')
);
if (lastPunctuationIndex >= lastDisplayedSentenceEnd.value) {
// 有新的标点符号,显示标点符号之后的内容
const afterLastPunctuation = fullText.substring(lastPunctuationIndex + 1).trim();
if (afterLastPunctuation) {
currentSubtitle.value = afterLastPunctuation;
}
} else {
// 没有标点符号,显示新文本部分
const displayText = newText.trim();
if (displayText) {
currentSubtitle.value = displayText;
}
}
}
};
显示策略:
- 完整句子优先: 识别以标点符号结尾的完整句子
- 标点符号处理: 保留最后一个标点,移除多余的
- 增量更新: 只显示新增部分,避免重复显示
- 容错处理: 没有标点符号时显示新文本部分
4.2 字幕状态管理
typescript
// 响应式状态
const subtitles = ref(''); // 完整字幕文本
const currentSubtitle = ref(''); // 当前显示的字幕
const accumulatedText = ref(''); // 累积文本
const lastDisplayedSentenceEnd = ref(0); // 已显示的最后一句的结束位置
// 重置字幕状态
const resetSubtitleState = () => {
subtitles.value = '';
currentSubtitle.value = '';
accumulatedText.value = '';
lastDisplayedSentenceEnd.value = 0;
};
六、完整生命周期管理
6.1 开始录音
typescript
const startRecording = async () => {
try {
// 1. 初始化状态
audioChunks = [];
resetSubtitleState();
subtitleIdentification.value = uuidv4();
// 2. 获取麦克风权限
audioStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
// 3. 创建音频上下文(用于可视化)
audioContext = new AudioContext();
const newAnalyser = audioContext.createAnalyser();
newAnalyser.fftSize = 256;
audioContext.createMediaStreamSource(audioStream).connect(newAnalyser);
analyser.value = newAnalyser;
// 4. 创建 MediaRecorder
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
mediaRecorder = new MediaRecorder(audioStream, { mimeType });
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
// 5. 连接 WebSocket
try {
await connectWebSocket(currentRecordId.value);
startSubtitleSaveTimer();
// 6. 创建用于ASR的音频上下文
if (wsClient?.isConnected()) {
asrAudioContext = new AudioContext({ sampleRate: 16000 });
setupAudioProcessor();
}
} catch (error) {
console.error('WebSocket连接失败,继续录音但不显示实时字幕:', error);
}
// 7. 开始录音
mediaRecorder.start(3000);
isRecording.value = true;
isPaused.value = false;
recordingTime.value = 0;
// 8. 开始计时
const startTime = Date.now();
recordingTimer = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 100);
recordingTime.value = elapsed;
}, 100);
} catch (error) {
console.error('开始录音失败:', error);
throw error;
}
};
6.2 暂停/继续录音
typescript
/**
* 暂停录音
*/
const pauseRecording = async () => {
if (mediaRecorder?.state !== 'recording') return;
mediaRecorder.pause();
isPaused.value = true;
isRecording.value = false;
// 停止计时器
if (recordingTimer) {
clearInterval(recordingTimer);
recordingTimer = null;
}
// 停止音频处理器
if (audioProcessor) {
audioProcessor.disconnect();
audioProcessor = null;
}
// 保存字幕并断开WebSocket
if (subtitles.value && subtitleIdentification.value) {
await saveCurrentSubtitles();
stopSubtitleSaveTimer();
}
if (wsClient) {
wsClient.disconnect();
wsClient = null;
}
};
/**
* 继续录音
*/
const resumeRecording = async () => {
if (!isPaused.value || !mediaRecorder) return;
mediaRecorder.resume();
isPaused.value = false;
isRecording.value = true;
// 重新连接WebSocket
if (currentRecordId.value) {
try {
await connectWebSocket(currentRecordId.value);
startSubtitleSaveTimer();
// 重新设置音频处理器
if (!asrAudioContext && audioStream) {
asrAudioContext = new AudioContext({ sampleRate: 16000 });
}
setupAudioProcessor();
} catch (error) {
console.error('WebSocket重连失败,继续录音但不显示实时字幕:', error);
}
}
// 恢复计时
const startTime = Date.now() - recordingTime.value * 100;
recordingTimer = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 100);
recordingTime.value = elapsed;
}, 100);
};
6.3 停止录音与资源清理
typescript
/**
* 停止录音
*/
const stopRecording = async (
onStop?: (audioChunks: Blob[], recordId: string) => Promise<void>
) => {
if (!mediaRecorder) return;
isRecording.value = false;
isPaused.value = false;
// 停止计时器
if (recordingTimer) {
clearInterval(recordingTimer);
recordingTimer = null;
}
// 设置停止事件处理
mediaRecorder.onstop = async () => {
try {
if (audioChunks.length > 0 && currentRecordId.value && onStop) {
await onStop(audioChunks, currentRecordId.value);
}
} finally {
cleanup();
}
};
// 停止录音并断开WebSocket
mediaRecorder.stop();
if (wsClient) {
wsClient.send('stop', false);
// 延迟断开,确保最后的消息能收到
setTimeout(() => {
if (wsClient) {
wsClient.disconnect();
wsClient = null;
}
}, 500);
}
// 确保显示最后的内容
if (subtitles.value) {
// 处理最后未完成的句子显示逻辑
// ...
}
// 延迟重置状态,确保用户能看到最后的内容
setTimeout(() => {
resetSubtitleState();
}, 2000);
};
/**
* 清理资源
*/
const cleanup = () => {
// 停止音频流
if (audioStream) {
audioStream.getTracks().forEach((track) => track.stop());
audioStream = null;
}
// 关闭音频上下文
if (audioContext) {
audioContext.close();
audioContext = null;
}
if (asrAudioContext) {
asrAudioContext.close();
asrAudioContext = null;
}
// 断开音频处理器
if (audioProcessor) {
audioProcessor.disconnect();
audioProcessor = null;
}
analyser.value = null;
mediaRecorder = null;
// 清理WebSocket
if (wsClient) {
wsClient.disconnect();
wsClient = null;
}
// 停止字幕保存定时器
stopSubtitleSaveTimer();
// 清空音频块和缓冲区
audioChunks = [];
audioBuffer = [];
};