時(shí)語音轉(zhuǎn)文字實(shí)戰(zhàn):科大訊飛WebSocket流式識(shí)別全鏈路解析)
簡(jiǎn)介本資源是一個(gè)基于Vue.js實(shí)現(xiàn)的科大訊飛實(shí)時(shí)語音轉(zhuǎn)文字前端輕量級(jí)Demo面向Web前端開發(fā)者及AI集成初學(xué)者解決會(huì)議記錄、在線教育、直播互動(dòng)等場(chǎng)景中語音實(shí)時(shí)轉(zhuǎn)寫的技術(shù)落地問題。項(xiàng)目完整封裝了科大訊飛WebAPI調(diào)用邏輯涵蓋音頻采集MediaDevices、流式數(shù)據(jù)處理、鑒權(quán)簽名HmacSHA1/hmac-sha256/md5、Worker多線程解碼與結(jié)果動(dòng)態(tài)渲染等核心環(huán)節(jié)具備即開即用、結(jié)構(gòu)清晰、易于二次開發(fā)的特點(diǎn)。壓縮包共8個(gè)文件含7個(gè)JS腳本如index.umd.js主入口、processor.worker.js后臺(tái)處理、enc-base64-min.js編碼工具等和1個(gè)HTML啟動(dòng)頁(yè)總大小僅16KB輕量緊湊適合快速學(xué)習(xí)與嵌入現(xiàn)有Vue項(xiàng)目。目前已有4431人學(xué)習(xí)下載讀者可直接獲取可運(yùn)行的完整代碼結(jié)構(gòu)、分層清晰的SDK封裝思路、Web Workers避阻塞實(shí)踐方案以及針對(duì)網(wǎng)絡(luò)波動(dòng)與API異常的輕量級(jí)錯(cuò)誤處理邏輯。1. 實(shí)時(shí)語音轉(zhuǎn)文字不是“點(diǎn)一下就出字”的黑匣子這個(gè) Vue 項(xiàng)目把科大訊飛 WebAPI 的鑒權(quán)、長(zhǎng)連接、流式響應(yīng)、斷線重連全攤開給你看適合正在做會(huì)議記錄、在線教育實(shí)時(shí)字幕、客服語音質(zhì)檢的前端同學(xué)上手改造你肯定試過直接在 Vue 里fetch一個(gè)語音識(shí)別接口——上傳 WAV 文件等幾秒返回一整段 JSON。但真實(shí)業(yè)務(wù)場(chǎng)景根本不是這樣老師講課要邊說邊出字幕客服通話要毫秒級(jí)響應(yīng)網(wǎng)絡(luò)抖動(dòng)時(shí)不能整個(gè)流程崩掉。這個(gè)「科大訊飛實(shí)時(shí)語音轉(zhuǎn)文字 Vue.js 版本」項(xiàng)目不封裝成黑盒 SDK而是用原生 WebSocket Vue Composition API 把整個(gè)鏈路拆得明明白白從X-Appid和X-Checksum怎么算到onmessage收到分片結(jié)果后怎么合并語句、過濾靜音段、處理標(biāo)點(diǎn)回填再到斷網(wǎng) 3 秒自動(dòng)重連、重連后續(xù)傳未發(fā)完音頻幀——它不是 demo是能塞進(jìn)你生產(chǎn)項(xiàng)目的最小可運(yùn)行骨架。如果你正卡在「為什么我的語音識(shí)別總延遲高」「為什么斷網(wǎng)后收不到后續(xù)結(jié)果」「為什么標(biāo)點(diǎn)老是錯(cuò)位」這個(gè)項(xiàng)目就是你該 clone 下來跑通的第一份參考實(shí)現(xiàn)。2. 項(xiàng)目結(jié)構(gòu)與核心依賴Vue 3 Vite 構(gòu)建關(guān)鍵不在框架而在如何把訊飛 WebAPI 的「流式雙工通信」映射成響應(yīng)式狀態(tài)2.1 目錄結(jié)構(gòu)直擊重點(diǎn)沒有冗余頁(yè)面src/voice/是唯一主戰(zhàn)場(chǎng)項(xiàng)目采用極簡(jiǎn)結(jié)構(gòu)所有語音能力收斂在src/voice/下避免新手被無關(guān)路由、Mock 數(shù)據(jù)、UI 組件分散注意力src/ ├── voice/ │ ├── api/ # 訊飛認(rèn)證與 WebSocket 連接邏輯非 fetch │ │ ├── auth.ts # 生成 X-Appid/X-CurTime/X-CheckSum/X-Param │ │ └── ws-client.ts # 封裝 WebSocket含重連、心跳、錯(cuò)誤降級(jí) │ ├── composables/ # Vue 組合式函數(shù)狀態(tài)與行為分離 │ │ ├── useSpeechRecog.ts # 核心控制啟停、接收結(jié)果、暴露 loading/error/status │ │ └── useAudioCapture.ts # 麥克風(fēng)采集采樣率 16k、PCM 編碼、分幀發(fā)送 │ ├── types/ # 強(qiáng)類型定義直接對(duì)應(yīng)訊飛文檔字段 │ │ ├── iflytek.ts # RecognitionResult, PartialResult, FinalResult 等 │ │ └── audio.ts # AudioFrame, AudioConfig (bitrate, sampleRate) │ └── utils/ # 工具函數(shù)非業(yè)務(wù)邏輯 │ ├── pcm-encoder.ts # 瀏覽器 AudioContext → 16-bit PCM ArrayBuffer │ └── text-merger.ts # 合并多幀 partial result智能處理標(biāo)點(diǎn)回填與重復(fù)詞 ├── App.vue # 極簡(jiǎn) UI僅含 start/stop 按鈕、實(shí)時(shí)文本區(qū)、狀態(tài)提示 └── main.ts # Vite 入口無額外插件干凈啟動(dòng)提示項(xiàng)目刻意不引入axios或pinia—— 鑒權(quán)頭生成、WebSocket 生命周期、音頻幀緩沖都必須親手控制封裝過深反而掩蓋訊飛協(xié)議細(xì)節(jié)。2.2 關(guān)鍵依賴只有 3 個(gè)精簡(jiǎn)到只留不可替代項(xiàng)package.json中真正參與語音鏈路的依賴僅以下三項(xiàng)其余均為構(gòu)建或開發(fā)輔助{ dependencies: { vue: ^3.4.0, vue-demi: ^0.14.0, // 兼容 Vue 2/3 的組合式 API polyfill若需降級(jí) web-audio-beat-detector: ^1.0.2 // 可選用于靜音檢測(cè)避免空幀發(fā)送 }, devDependencies: { vitejs/plugin-vue: ^4.5.0, vite: ^4.5.0 } }注意沒有iflytek-sdk-js官方包。該項(xiàng)目繞過所有第三方 SDK直接對(duì)接訊飛 WebAPI 文檔中定義的 WebSocket 地址wss://iat-api.xfyun.cn/v2/iat原因很實(shí)在——官方 SDK 對(duì)X-Param加密、斷線重傳、幀序號(hào)管理做了黑盒處理而線上問題如某次重連后識(shí)別結(jié)果亂序必須能定位到具體哪一幀沒發(fā)成功。2024 年訊飛 WebAPI 接入要點(diǎn)更新避坑章節(jié)現(xiàn)象WebSocket 連接立即關(guān)閉控制臺(tái)報(bào)403 Forbidden原因訊飛于 2024 年 Q2 起強(qiáng)制要求X-Param字段必須為 Base64 編碼的 JSON 字符串且其中engine_type必須顯式指定為intp65而非舊版默認(rèn)的sms16k同時(shí)aue音頻編碼必須為raw表示 PCM。舊項(xiàng)目若沿用sms16k或未 Base64 編碼X-Param服務(wù)端直接拒收。解決嚴(yán)格按訊飛最新文檔構(gòu)造X-Param// src/voice/api/auth.ts export const buildXParam () { const param { engine_type: intp65, // ?? 強(qiáng)制指定不可省略 domain: iat, language: zh_cn, accent: mandarin, aue: raw, // ?? 必須為 raw表示原始 PCM 數(shù)據(jù) vad_eos: 3000, // 靜音超時(shí) ms影響斷句靈敏度 }; return btoa(JSON.stringify(param)); // ?? 必須 Base64 編碼 };現(xiàn)象識(shí)別結(jié)果中大量出現(xiàn)[噪音]、[笑聲]且標(biāo)點(diǎn)完全丟失原因訊飛 WebAPI 默認(rèn)返回result字段為純文本但實(shí)際協(xié)議支持兩種模式semantic帶語義標(biāo)簽和normal純文本。項(xiàng)目默認(rèn)使用normal而semantic模式會(huì)返回punc標(biāo)簽及typelaugh等事件。若未在X-Param中開啟semantic服務(wù)端不會(huì)發(fā)送這些結(jié)構(gòu)化信息。解決修改X-Param開啟語義分析const param { // ...其他字段 semantic: true, // ?? 新增此字段 };并在onmessage中解析punc標(biāo)簽// src/voice/composables/useSpeechRecog.ts const handlePartialResult (data: PartialResult) { const text data.payload.result.text; // 匹配 punc typecomma替換為中文逗號(hào) const formatted text.replace(/punc typecomma/g, ); // 同理處理句號(hào)、問號(hào)... mergedText.value formatted; };現(xiàn)象Chrome 下麥克風(fēng)采集正常Safari 無法啟動(dòng)MediaRecorder原因Safari 17 對(duì)MediaRecorder的audio/webmMIME 類型支持不穩(wěn)定且對(duì)采樣率敏感訊飛要求 PCM 16k而 Safari 的AudioContext默認(rèn)采樣率可能為 44.1k直接 downsample 易失真。解決放棄MediaRecorder改用AudioContextScriptProcessorNode已廢棄但 Safari 仍支持或AudioWorklet推薦手動(dòng)采集 PCM// src/voice/composables/useAudioCapture.ts const initAudioContext async () { const AudioContext window.AudioContext || (window as any).webkitAudioContext; audioContext new AudioContext({ sampleRate: 16000 // ?? 強(qiáng)制設(shè)為 16k避免 Safari 自動(dòng) resample }); // ...后續(xù)創(chuàng)建 analyser、script processor 等 };3. 核心模塊拆解useSpeechRecog.ts如何把 WebSocket 流式響應(yīng)轉(zhuǎn)化為 Vue 響應(yīng)式狀態(tài)3.1useSpeechRecog的 4 大職責(zé)與狀態(tài)映射該組合式函數(shù)是整個(gè)語音識(shí)別的中樞它不處理 UI只暴露 4 類響應(yīng)式狀態(tài)和 3 個(gè)控制方法讓組件層保持極簡(jiǎn)Vue 狀態(tài)變量類型用途更新時(shí)機(jī)isListeningRefboolean是否處于「正在識(shí)別」態(tài)start()時(shí)置truestop()或 WebSocketclose時(shí)置falserecognitionTextRefstring最終確認(rèn)的完整文本含標(biāo)點(diǎn)收到type final_result時(shí)追加partialTextRefstring當(dāng)前正在修正的實(shí)時(shí)文本無標(biāo)點(diǎn)收到type partial_result時(shí)覆蓋statusMessageRefstring連接狀態(tài)提示已連接 / 重連中... / 網(wǎng)絡(luò)異常WebSocketopen/error/close事件觸發(fā)控制方法start(): 初始化麥克風(fēng)、建立 WebSocket、發(fā)送{common:{...},business:{...},data:{...}}初始化幀stop(): 發(fā)送{data:{status:2}}結(jié)束幀關(guān)閉 WebSocketpause(): 發(fā)送{data:{status:1}}暫停幀用于會(huì)議中臨時(shí)靜音3.2 WebSocket 連接與鑒權(quán)頭生成auth.ts的 3 行關(guān)鍵邏輯訊飛 WebAPI 要求 WebSocket 握手時(shí)攜帶 4 個(gè)自定義 Header其中X-Checksum是base64(md5(apiKey curTime X-Param))極易出錯(cuò)// src/voice/api/auth.ts export const generateAuthHeaders (apiKey: string, appid: string): Recordstring, string { const curTime Math.floor(Date.now() / 1000).toString(); // 秒級(jí)時(shí)間戳 const xParam buildXParam(); // 上節(jié)已定義 const checksum md5(${apiKey}${curTime}${xParam}); // 使用標(biāo)準(zhǔn) md5 庫(kù) return { X-Appid: appid, X-CurTime: curTime, X-CheckSum: btoa(checksum), // ?? 注意checksum 是 hex 字符串需再 base64 X-Param: xParam, }; };注意X-CheckSum不是md5(...)的原始二進(jìn)制而是md5(...)計(jì)算出的 32 位 hex 字符串如a1b2c3...再經(jīng)btoa()編碼。曾有開發(fā)者直接對(duì)二進(jìn)制ArrayBuffer做btoa導(dǎo)致 403。3.3 流式響應(yīng)解析onmessage中的三類 result 處理訊飛 WebSocket 返回的data是字符串需JSON.parse后按action字段分流。useSpeechRecog.ts中的核心解析邏輯如下// src/voice/composables/useSpeechRecog.ts ws.onmessage (event: MessageEvent) { try { const msg JSON.parse(event.data) as RecognitionResult; if (msg.action started) { // 服務(wù)端已準(zhǔn)備好接收音頻幀可開始發(fā)送 isListening.value true; statusMessage.value 已連接開始說話; return; } if (msg.action result) { const result msg.data.result; if (result.type partial_result) { // 實(shí)時(shí)片段覆蓋 partialText不觸發(fā)最終渲染 partialText.value result.text; } else if (result.type final_result) { // 最終結(jié)果追加到 recognitionText并清空 partialText recognitionText.value result.text; partialText.value ; } // ?? 注意訊飛可能在同一 connection 中混發(fā) partial/final需按 type 分離處理 } if (msg.action error) { // 服務(wù)端報(bào)錯(cuò)如音頻格式錯(cuò)誤、鑒權(quán)失敗 statusMessage.value 服務(wù)錯(cuò)誤${msg.data.desc}; stop(); // 主動(dòng)斷開 } } catch (e) { console.error(解析訊飛消息失敗, e); } };關(guān)鍵點(diǎn)在于partial_result用于 UI 實(shí)時(shí)反饋如輸入框閃爍final_result才是存入數(shù)據(jù)庫(kù)或發(fā)送給后端的權(quán)威文本。二者不可混淆——曾有項(xiàng)目把partial_text直接當(dāng)最終結(jié)果保存導(dǎo)致會(huì)議記錄中出現(xiàn)大量「今天天氣真好…今天天氣真好啊…今天天氣真好啊」的重復(fù)。4. 麥克風(fēng)采集與音頻幀發(fā)送useAudioCapture.ts如何確保 PCM 數(shù)據(jù) 16k/16bit 符合訊飛要求4.1 瀏覽器音頻采集的底層選擇AudioContext而非MediaRecorderMediaRecorder輸出webm或mp3需額外解碼為 PCM性能損耗大且兼容性差尤其 Safari。本項(xiàng)目采用AudioContext直接獲取 PCM 原始數(shù)據(jù)流程為User MediaStream → AudioContext → ScriptProcessorNode (or AudioWorklet) → PCM ArrayBuffer → 分幀發(fā)送ScriptProcessorNode雖已廢棄但 Safari 17 仍支持若需長(zhǎng)期維護(hù)應(yīng)升級(jí)為AudioWorklet但本項(xiàng)目為降低復(fù)雜度暫用前者。4.2 PCM 編碼與分幀邏輯每 40ms 一幀16-bit signed integer訊飛要求音頻幀為16-bit little-endian PCM采樣率16000Hz即每秒 16000 個(gè)樣本。每幀時(shí)長(zhǎng) 40ms → 每幀樣本數(shù) 16000 * 0.04 640個(gè)Int16值// src/voice/composables/useAudioCapture.ts const processAudio (e: AudioProcessingEvent) { const inputBuffer e.inputBuffer; const channelData inputBuffer.getChannelData(0); // 單聲道 const pcmData new Int16Array(640); // 每幀 640 個(gè) int16 // 將 float32 [-1, 1] 轉(zhuǎn)為 int16 [-32768, 32767] for (let i 0; i 640; i) { const floatVal channelData[i] || 0; pcmData[i] Math.max(-32768, Math.min(32767, Math.round(floatVal * 32767))); } // 發(fā)送幀base64 編碼后的 PCM ArrayBuffer const audioFrame { status: 0, // 0首幀1中間幀2末幀 format: audio/L16;rate16000, // ?? 必須與 X-Param.aue 匹配 audio: btoa(String.fromCharCode(...new Uint8Array(pcmData.buffer))), encoding: raw, }; if (ws ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ data: audioFrame })); } };注意btoa(String.fromCharCode(...))是將ArrayBuffer轉(zhuǎn)為 base64 的安全方式。直接對(duì)ArrayBuffer調(diào)用btoa會(huì)因字符集問題損壞二進(jìn)制數(shù)據(jù)。4.3 靜音檢測(cè)與幀序號(hào)管理避免無效幀沖擊服務(wù)端連續(xù)發(fā)送靜音幀不僅浪費(fèi)帶寬還可能觸發(fā)訊飛服務(wù)端的防刷策略。項(xiàng)目集成簡(jiǎn)易 VADVoice Activity Detection// src/voice/utils/vad.ts export const isSilent (pcmData: Int16Array, threshold 500): boolean { let sum 0; for (let i 0; i pcmData.length; i) { sum Math.abs(pcmData[i]); } const avgAbs sum / pcmData.length; return avgAbs threshold; // threshold 經(jīng)實(shí)測(cè)設(shè)為 500 較穩(wěn)定 }; // 在 processAudio 中調(diào)用 if (!isSilent(pcmData)) { // 僅當(dāng)非靜音時(shí)發(fā)送 ws.send(JSON.stringify({ data: audioFrame })); }同時(shí)為防止網(wǎng)絡(luò)亂序每幀增加seq字段雖訊飛未強(qiáng)制要求但便于調(diào)試const audioFrame { seq: frameSeq, // 全局遞增序號(hào) status: isFirstFrame ? 0 : 1, // ... };5. 本地運(yùn)行與環(huán)境配置5 分鐘跑通關(guān)鍵在.env里的 3 個(gè)字段5.1 獲取訊飛憑證免費(fèi)額度足夠測(cè)試無需付費(fèi)前往 訊飛開放平臺(tái) 注冊(cè)賬號(hào) → 創(chuàng)建「語音聽寫流式版」應(yīng)用 → 獲取APPID控制臺(tái)「我的應(yīng)用」列表中顯示API_KEY應(yīng)用詳情頁(yè)「接口秘鑰」下的API KeyAPI_SECRET同頁(yè)「接口秘鑰」下的API Secret?? 僅首次顯示務(wù)必保存提示新注冊(cè)賬號(hào)贈(zèng)送 500 小時(shí)/月免費(fèi)額度夠個(gè)人開發(fā)者反復(fù)調(diào)試 2 個(gè)月。5.2 配置.env文件Vite 會(huì)自動(dòng)注入到import.meta.env在項(xiàng)目根目錄創(chuàng)建.envVUE_APP_IFLYTEK_APPIDyour_appid_here VUE_APP_IFLYTEK_API_KEYyour_api_key_here VUE_APP_IFLYTEK_API_SECRETyour_api_secret_here然后在main.ts中驗(yàn)證console.log(訊飛憑證加載:, { appid: import.meta.env.VUE_APP_IFLYTEK_APPID?.slice(0, 6) ..., key: import.meta.env.VUE_APP_IFLYTEK_API_KEY?.slice(0, 6) ..., });5.3 啟動(dòng)與調(diào)試Chrome DevTools 的 3 個(gè)必查位置npm install npm run dev服務(wù)啟動(dòng)后打開http://localhost:5173點(diǎn)擊「開始識(shí)別」。此時(shí)務(wù)必檢查Network → WS → Frames查看 WebSocket 連接是否101 Switching Protocols發(fā)送的common幀是否含正確appid接收的started幀是否及時(shí)Console是否有Failed to execute btoa on Window說明 PCM 編碼出錯(cuò)或WebSocket is already in CLOSING or CLOSED state說明重連邏輯未清除舊連接Application → Storage → Cookies確認(rèn)無訊飛相關(guān) cookie本項(xiàng)目純前端不依賴服務(wù)端 session。常見失敗場(chǎng)景控制臺(tái)報(bào)WebSocket connection to wss://... failed→ 檢查.env中APPID是否復(fù)制完整常漏掉末尾 1-2 位點(diǎn)擊「開始」無反應(yīng) → 檢查瀏覽器地址欄左上角麥克風(fēng)圖標(biāo)是否被禁用或調(diào)用navigator.mediaDevices.getUserMedia被跨域阻止務(wù)必用http://localhost:5173而非file://協(xié)議打開。6. 二次開發(fā)實(shí)戰(zhàn)從「能跑」到「能用」的 3 個(gè)關(guān)鍵改造點(diǎn)6.1 支持多語種切換動(dòng)態(tài)修改X-Param.language與accent訊飛 WebAPI 支持中英文混合識(shí)別只需在X-Param中指定language和accent。項(xiàng)目默認(rèn)為中文擴(kuò)展為多語言需兩步第一步在useSpeechRecog.ts中暴露語言配置// src/voice/composables/useSpeechRecog.ts export const useSpeechRecog (options: { language?: zh_cn | en_us | ja_jp; // 新增選項(xiàng) accent?: mandarin | cantonese | english_us | japanese; } {}) { const { language zh_cn, accent mandarin } options; const buildXParam () { const param { engine_type: intp65, domain: iat, language, // ?? 動(dòng)態(tài)注入 accent, // ?? 動(dòng)態(tài)注入 aue: raw, vad_eos: 3000, }; return btoa(JSON.stringify(param)); }; };第二步在App.vue中添加語言選擇器!-- src/App.vue -- select v-modelcurrentLang option valuezh_cn中文/option option valueen_usEnglish/option option valueja_jp日本語/option /select script setup import { ref, onMounted } from vue; import { useSpeechRecog } from ./voice/composables/useSpeechRecog; const currentLang ref(zh_cn); const { start, stop, recognitionText } useSpeechRecog({ language: currentLang.value, }); // 語言變更時(shí)重啟識(shí)別因 X-Param 已變 const handleChangeLang () { if (isListening.value) { stop(); setTimeout(() start(), 100); // 等待 WebSocket 徹底關(guān)閉 } }; /script血淚經(jīng)驗(yàn)切語言必須stop()再start()不能復(fù)用舊 WebSocket 連接——X-Param是握手時(shí)一次性傳遞的連接建立后無法動(dòng)態(tài)修改。6.2 識(shí)別結(jié)果結(jié)構(gòu)化提取人名、地名、時(shí)間等實(shí)體為 NLP 后處理鋪路訊飛 WebAPI 的final_result僅返回純文本但實(shí)際業(yè)務(wù)常需抽取關(guān)鍵信息。項(xiàng)目預(yù)留了text-merger.ts的擴(kuò)展點(diǎn)// src/voice/utils/text-merger.ts export const extractEntities (text: string): Entity[] { const entities: Entity[] []; // 簡(jiǎn)單正則匹配中文姓名2-3字常見姓氏 const nameRegex /(?:王|李|張|劉|陳|楊|趙|黃|周|吳|徐|孫|胡|朱|高|林|何|郭|馬|羅)[\u4e00-\u9fa5]{1,2}/g; let match; while ((match nameRegex.exec(text)) ! null) { entities.push({ type: PERSON, value: match[0], start: match.index }); } // 匹配 YYYY年MM月DD日 const dateRegex /(\d{4})年(\d{1,2})月(\d{1,2})日/g; while ((match dateRegex.exec(text)) ! null) { entities.push({ type: DATE, value: ${match[1]}-${match[2].padStart(2,0)}-${match[3].padStart(2,0)}, start: match.index }); } return entities; }; // 在 useSpeechRecog.ts 中調(diào)用 if (result.type final_result) { recognitionText.value result.text; const entities extractEntities(result.text); console.log(識(shí)別出的實(shí)體:, entities); // 可 emit 到父組件或存入 store }此方案不依賴外部 NLP 服務(wù)輕量可靠。若需更高精度可在此處接入開源模型如LTP的 Web 版本但需注意跨域和性能。6.3 斷網(wǎng)重連的「后悔藥」機(jī)制緩存未發(fā)送音頻幀重連后續(xù)傳默認(rèn)重連邏輯是stop()→start()丟失當(dāng)前語音片段。對(duì)會(huì)議記錄場(chǎng)景這會(huì)導(dǎo)致「老師剛說半句話網(wǎng)絡(luò)一抖后半句就沒了」。項(xiàng)目提供可選的「斷網(wǎng)緩存」模式// src/voice/composables/useSpeechRecog.ts const audioFrameQueue: AudioFrame[] []; // 緩存隊(duì)列 const MAX_QUEUE_SIZE 30; // 最多緩存 30 幀約 1.2 秒 // 發(fā)送幀前先入隊(duì) const sendAudioFrame (frame: AudioFrame) { if (ws?.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ data: frame })); } else { audioFrameQueue.push(frame); if (audioFrameQueue.length MAX_QUEUE_SIZE) { audioFrameQueue.shift(); // FIFO丟棄最舊幀 } } }; // 重連成功后發(fā)送緩存幀 ws.onopen () { statusMessage.value 已重連發(fā)送緩存音頻...; while (audioFrameQueue.length 0) { const frame audioFrameQueue.shift()!; // 設(shè)置 status首幀為 0其余為 1 frame.status audioFrameQueue.length 0 ? 2 : 1; ws.send(JSON.stringify({ data: frame })); } };從那以后我每次做實(shí)時(shí)語音項(xiàng)目都強(qiáng)制走一遍「拔網(wǎng)線→說話→插回→檢查是否續(xù)上」的測(cè)試。真正的穩(wěn)定性不在代碼行數(shù)而在你敢不敢在演示現(xiàn)場(chǎng)關(guān)掉 WiFi。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取