news 2026/5/13 3:07:45

Vue3 实时音频录制与转写 Composable 技术实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue3 实时音频录制与转写 Composable 技术实现

Vue3 实时音频录制与转写 Composable 技术实现

前言

本文介绍如何基于 Vue3 Composition API 实现一个实时音频录制与转写的 Composable,涉及 Web Audio API、WebSocket 实时通信、音频格式转换等技术。

技术栈

  • Vue3 Composition API: 组合式函数封装
  • MediaRecorder API: 浏览器音频录制
  • Web Audio API: 音频流处理与格式转换
  • WebSocket: 实时双向通信
  • TypeScript: 类型安全

核心功能

  1. 实时音频录制(支持暂停/继续/停止)
  2. 音频流实时处理与传输
  3. WebSocket 实时通信接收转写结果
  4. 实时字幕逐句显示
  5. 字幕定时保存机制

技术架构

┌─────────────────────────────────────────┐ │ useAudioRecorder Composable │ ├─────────────────────────────────────────┤ │ 1. 音频录制层 (MediaRecorder) │ │ 2. 音频处理层 (Web Audio API) │ │ 3. 实时通信层 (WebSocket) │ │ 4. 字幕处理层 (文本处理) │ │ 5. 数据持久化层 (定时保存) │ └─────────────────────────────────────────┘

一、音频录制实现

1.1 获取麦克风权限

// 获取用户麦克风权限audioStream=awaitnavigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,// 回声消除noiseSuppression:true,// 降噪autoGainControl:true,// 自动增益控制},});

技术要点

  • getUserMedia返回MediaStream对象
  • 音频约束配置优化录音质量
  • 需要用户授权,需要处理权限拒绝情况

1.2 创建 MediaRecorder

// 检测浏览器支持的音频格式constmimeType=MediaRecorder.isTypeSupported('audio/webm;codecs=opus')?'audio/webm;codecs=opus'// 优先使用 Opus 编码:'audio/webm';// 降级方案// 创建 MediaRecorder 实例mediaRecorder=newMediaRecorder(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 双音频流架构

为了实现录音保存和实时转写并行,需要创建两个独立的音频上下文:

// 音频流1:用于录音保存(原始采样率)audioContext=newAudioContext();constanalyser=audioContext.createAnalyser();analyser.fftSize=256;// 用于音频可视化audioContext.createMediaStreamSource(audioStream).connect(analyser);// 音频流2:用于实时转写(16000Hz采样率)asrAudioContext=newAudioContext({sampleRate:16000});

为什么需要双流?

  • 录音保存需要高质量(原始采样率)
  • 实时转写需要标准采样率(16000Hz,ASR 标准)
  • 两个流互不干扰,独立处理

2.2 音频格式转换

Web Audio API 返回的是Float32Array(范围 -1.0 到 1.0),而 ASR 服务通常需要 16 位 PCM 格式(范围 -32768 到 32767)。

/** * 将 Float32 音频转换为 16 位 PCM * @param input Float32Array 音频数据 * @returns Uint8Array 16位PCM数据 */constconvertTo16BitPCM=(input:Float32Array):Uint8Array=>{constbuffer=newArrayBuffer(input.length*2);// 16位 = 2字节constview=newDataView(buffer);input.forEach((sample,i)=>{// 限制范围到 [-1, 1]constvalue=Math.max(-1,Math.min(1,sample));// 转换为16位整数// 负数: value * 0x8000 = -32768// 正数: value * 0x7FFF = 32767view.setInt16(i*2,value<0?value*0x8000:value*0x7FFF,true// little-endian 字节序);});returnnewUint8Array(buffer);};

转换原理

  • Float32: 32位浮点数,范围 [-1.0, 1.0]
  • 16位PCM: 16位整数,范围 [-32768, 32767]
  • 使用线性映射:PCM = Float32 × 32767(正数)或Float32 × 32768(负数)

2.3 音频分块处理

使用ScriptProcessorNode处理音频流,并按固定大小分块发送:

// 常量定义constPCM_CHUNK_SIZE=6400;// 200ms音频数据// 计算:16000Hz × 0.2s × 2字节 = 6400字节constASR_SAMPLE_RATE=16000;// ASR标准采样率// 设置音频处理器constsetupAudioProcessor=()=>{if(!wsClient?.isConnected()||!asrAudioContext||!audioStream)return;// 清空缓冲区audioBuffer=[];// 创建音频源constasrSource=asrAudioContext.createMediaStreamSource(audioStream);// 创建脚本处理器(已废弃但兼容性好)// 参数:bufferSize, numberOfInputChannels, numberOfOutputChannelsaudioProcessor=asrAudioContext.createScriptProcessor(4096,1,1);// 连接音频节点asrSource.connect(audioProcessor);audioProcessor.connect(asrAudioContext.destination);// 处理音频数据audioProcessor.onaudioprocess=(e)=>{if(!wsClient?.isConnected())return;// 1. 获取输入音频数据(Float32格式)constinputData=e.inputBuffer.getChannelData(0);// 2. 转换为16位PCMconstpcmData=convertTo16BitPCM(inputData);// 3. 累积到缓冲区audioBuffer.push(...pcmData);// 4. 达到200ms数据量时发送if(audioBuffer.length>=PCM_CHUNK_SIZE){constchunk=newUint8Array(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 连接建立

/** * 连接WebSocket接收实时转写结果 */constconnectWebSocket=async(recordId:string)=>{try{constwsUrl=`wss://your-server.com/api/asr/realtime`;consttoken=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连接关闭');}});awaitwsClient.connect();}catch(error){console.error('WebSocket连接失败:',error);throwerror;}};

技术要点

  • 使用 WSS(WebSocket Secure)保证传输安全
  • 配置心跳机制保持连接活跃
  • 自动重连机制处理网络波动
  • Token 认证通过 URL 参数传递(WebSocket 不支持自定义请求头)

3.2 消息处理

/** * 处理转写消息 */consthandleTranscriptionMessage=(message:WebSocketMessage)=>{try{letresult=message.data;// 1. 解析JSON消息if(typeofresult==='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. 提取转写文本consttext=result?.result?.text||result?.text;if(text){// 更新完整字幕subtitles.value=text;accumulatedText.value=text;// 更新当前显示的字幕(逐句显示)updateCurrentSubtitle(text,currentSubtitle,lastDisplayedSentenceEnd);}}catch(error){console.error('处理转写消息失败:',error);}};

四、实时字幕显示

4.1 逐句显示逻辑

实现智能的字幕逐句显示,基于标点符号识别完整句子:

/** * 更新当前显示的字幕(逐句显示) * @param fullText 完整字幕文本 * @param currentSubtitle 当前显示的字幕(响应式引用) * @param lastDisplayedSentenceEnd 已显示的最后一句的结束位置 */constupdateCurrentSubtitle=(fullText:string,currentSubtitle:Ref<string>,lastDisplayedSentenceEnd:Ref<number>)=>{if(!fullText){currentSubtitle.value='';return;}// 如果文本长度没有增加,无需更新if(fullText.length<=lastDisplayedSentenceEnd.value){return;}// 获取新增的文本部分constnewText=fullText.substring(lastDisplayedSentenceEnd.value);// 尝试匹配完整句子(以标点符号结尾)constsentenceMatch=newText.match(/^[^。!?\n\.]+[。!?\n\.]+/);if(sentenceMatch){// 找到完整句子constcompleteSentence=sentenceMatch[0].trim();if(completeSentence){// 移除末尾多余的标点符号(保留最后一个)constdisplayText=completeSentence.replace(/[。!?\n\.]+$/,(match)=>match[match.length-1]);currentSubtitle.value=displayText;// 更新已显示位置lastDisplayedSentenceEnd.value=lastDisplayedSentenceEnd.value+sentenceMatch[0].length;}}else{// 没有完整句子,查找最后一个标点符号constlastPunctuationIndex=Math.max(fullText.lastIndexOf('。'),fullText.lastIndexOf('!'),fullText.lastIndexOf('?'),fullText.lastIndexOf('\n'),fullText.lastIndexOf('.'));if(lastPunctuationIndex>=lastDisplayedSentenceEnd.value){// 有新的标点符号,显示标点符号之后的内容constafterLastPunctuation=fullText.substring(lastPunctuationIndex+1).trim();if(afterLastPunctuation){currentSubtitle.value=afterLastPunctuation;}}else{// 没有标点符号,显示新文本部分constdisplayText=newText.trim();if(displayText){currentSubtitle.value=displayText;}}}};

显示策略

  1. 完整句子优先: 识别以标点符号结尾的完整句子
  2. 标点符号处理: 保留最后一个标点,移除多余的
  3. 增量更新: 只显示新增部分,避免重复显示
  4. 容错处理: 没有标点符号时显示新文本部分

4.2 字幕状态管理

// 响应式状态constsubtitles=ref('');// 完整字幕文本constcurrentSubtitle=ref('');// 当前显示的字幕constaccumulatedText=ref('');// 累积文本constlastDisplayedSentenceEnd=ref(0);// 已显示的最后一句的结束位置// 重置字幕状态constresetSubtitleState=()=>{subtitles.value='';currentSubtitle.value='';accumulatedText.value='';lastDisplayedSentenceEnd.value=0;};

六、完整生命周期管理

6.1 开始录音

conststartRecording=async()=>{try{// 1. 初始化状态audioChunks=[];resetSubtitleState();subtitleIdentification.value=uuidv4();// 2. 获取麦克风权限audioStream=awaitnavigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true,},});// 3. 创建音频上下文(用于可视化)audioContext=newAudioContext();constnewAnalyser=audioContext.createAnalyser();newAnalyser.fftSize=256;audioContext.createMediaStreamSource(audioStream).connect(newAnalyser);analyser.value=newAnalyser;// 4. 创建 MediaRecorderconstmimeType=MediaRecorder.isTypeSupported('audio/webm;codecs=opus')?'audio/webm;codecs=opus':'audio/webm';mediaRecorder=newMediaRecorder(audioStream,{mimeType});mediaRecorder.ondataavailable=(event)=>{if(event.data.size>0){audioChunks.push(event.data);}};// 5. 连接 WebSockettry{awaitconnectWebSocket(currentRecordId.value);startSubtitleSaveTimer();// 6. 创建用于ASR的音频上下文if(wsClient?.isConnected()){asrAudioContext=newAudioContext({sampleRate:16000});setupAudioProcessor();}}catch(error){console.error('WebSocket连接失败,继续录音但不显示实时字幕:',error);}// 7. 开始录音mediaRecorder.start(3000);isRecording.value=true;isPaused.value=false;recordingTime.value=0;// 8. 开始计时conststartTime=Date.now();recordingTimer=setInterval(()=>{constelapsed=Math.floor((Date.now()-startTime)/100);recordingTime.value=elapsed;},100);}catch(error){console.error('开始录音失败:',error);throwerror;}};

6.2 暂停/继续录音

/** * 暂停录音 */constpauseRecording=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;}// 保存字幕并断开WebSocketif(subtitles.value&&subtitleIdentification.value){awaitsaveCurrentSubtitles();stopSubtitleSaveTimer();}if(wsClient){wsClient.disconnect();wsClient=null;}};/** * 继续录音 */constresumeRecording=async()=>{if(!isPaused.value||!mediaRecorder)return;mediaRecorder.resume();isPaused.value=false;isRecording.value=true;// 重新连接WebSocketif(currentRecordId.value){try{awaitconnectWebSocket(currentRecordId.value);startSubtitleSaveTimer();// 重新设置音频处理器if(!asrAudioContext&&audioStream){asrAudioContext=newAudioContext({sampleRate:16000});}setupAudioProcessor();}catch(error){console.error('WebSocket重连失败,继续录音但不显示实时字幕:',error);}}// 恢复计时conststartTime=Date.now()-recordingTime.value*100;recordingTimer=setInterval(()=>{constelapsed=Math.floor((Date.now()-startTime)/100);recordingTime.value=elapsed;},100);};

6.3 停止录音与资源清理

/** * 停止录音 */conststopRecording=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){awaitonStop(audioChunks,currentRecordId.value);}}finally{cleanup();}};// 停止录音并断开WebSocketmediaRecorder.stop();if(wsClient){wsClient.send('stop',false);// 延迟断开,确保最后的消息能收到setTimeout(()=>{if(wsClient){wsClient.disconnect();wsClient=null;}},500);}// 确保显示最后的内容if(subtitles.value){// 处理最后未完成的句子显示逻辑// ...}// 延迟重置状态,确保用户能看到最后的内容setTimeout(()=>{resetSubtitleState();},2000);};/** * 清理资源 */constcleanup=()=>{// 停止音频流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;// 清理WebSocketif(wsClient){wsClient.disconnect();wsClient=null;}// 停止字幕保存定时器stopSubtitleSaveTimer();// 清空音频块和缓冲区audioChunks=[];audioBuffer=[];};
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/6 3:29:05

远程控制复现

一、漏洞测试 打开easy file sharing web server进入后修改端口点击go可以看到之后打开kali用searchsploit easy file sharing扫描漏洞利用对应的Python脚本攻击攻击完成&#xff0c;说明无法阻挡本身漏洞 二、kali生成被控端和启动主控端 先ifconfig查询kali的ip地址然后生成p…

作者头像 李华
网站建设 2026/4/30 23:19:04

android开发compose系列之Icon

文章目录 前言一、使用二、官方Icon图库的引入 前言 Icon是compose中专门用来展示小图标的组件&#xff0c;传统的View体系中没有对应的控件&#xff0c;该组件支持三种不同类型的图片设置&#xff1a;imageVector矢量图(可显示SVG格式的图标)、ImageBitmap位图(可显示JPG、PN…

作者头像 李华
网站建设 2026/5/11 18:12:01

重构智慧书-第13条:先知他人别有所图的心思,再伺机行事

一、原文呈现先知他人别有所图的心思&#xff0c;再伺机行事尘世人生本是一场除邪斗恶的战争。狡诈者的武器无非是玩弄种种心计&#xff0c;它常玩的把戏是声东击西。假装瞄准一个目标&#xff0c;煞有介事地佯攻一番,其实心底里却在暗自瞅准别人不留心的靶子&#xff0c;然后伺…

作者头像 李华
网站建设 2026/5/6 23:48:11

函数介绍及使用

函数 一、为什么需要函数&#xff1f; function&#xff0c;是被设计为执行特定任务的代码块 说明&#xff1a; 函数可以把具有相同或相似逻辑的代码“包裹”起来&#xff0c;通过函数调用执行这些被“包裹”的代码逻辑&#xff0c;这么做的优势是有利于精简代码方便复用。 比如…

作者头像 李华
网站建设 2026/5/12 17:54:25

Unity ReferenceFinder插件 多选资源查找bug解决

GitHub地址 当选中多个资源 查找引用时&#xff0c;有的资源引用不显示&#xff0c;解决方法&#xff1a; 把ReferenceFinderWindow脚本原来的 while(stack.Count > 0) { … if (!memo.ContainsKey(current[0])) { … } } 替换为下面这段。 // 替换原来的 while(stack.Cou…

作者头像 李华