简介:本资源是一个基于Vue.js实现的科大讯飞实时语音转文字前端轻量级项目,面向具备基础JavaScript和Vue开发能力的前端工程师与AI集成学习者,解决会议记录、在线教育、直播互动等场景中语音实时转写的技术落地问题。压缩包共8个文件(7个JS脚本+1个HTML主入口),总大小仅16KB,其中包含核心SDK封装(index.umd.js)、音频预处理模块(processor.worker.js、enc-base64-min.js)、加密工具链(md5.js、HmacSHA1.js、hmac-sha256.js)及完整启动页,结构紧凑、依赖零外部CDN,开箱即用。已有4426人学习下载,适合快速理解Web端调用讯飞WebAPI的鉴权流程、流式音频采集(MediaDevices API)、Worker线程解耦与识别结果动态渲染等关键实践环节,是掌握AI语音能力前端集成的典型参考范例。
1. 实时语音转文字不是“点一下就出字”的黑匣子:这个 Vue.js 项目把科大讯飞 WebAPI 的鉴权、长连接、流式响应、断线重连全摊开在你眼皮底下
你有没有试过在 Vue 项目里接入语音识别,结果页面点了“开始说话”,麦克风图标亮了,但等了十秒——一个字没出来?控制台静悄悄,Network 面板里连个请求都没发出去?不是接口挂了,是你的Authorization头压根没算对;不是网络慢,是你没处理 WebSocket 连接建立前的pre-connect状态;更不是 SDK 有问题,是你把X-Param当成普通 JSON 字符串硬塞进去,而它实际要 base64 编码后再 URL 安全化。这个「科大讯飞实时语音转文字 Vue.js 版本」不是封装好的黑盒 npm 包,它是一份可调试、可打断点、可改参数、可看每帧音频上传和每字返回的完整工程级实现。它面向的是正在做在线教育实时字幕、远程会议语音纪要、或政务热线语音工单录入的前端开发者——你需要的不是 demo 效果图,而是上线前能扛住 500 并发、断网 3 秒自动续传、识别结果逐字高亮、错误码精准上报的生产级代码。它用 Vue 3 Composition API + Pinia + Axios +原生 WebSocket 写成,不依赖任何第三方语音 SDK,所有鉴权逻辑、参数组装、心跳保活、buffer 分片、文本流解析都手写,源码包里甚至带了一份离线可用的mock-server,让你不填 AppID 也能跑通整条链路。
2. 项目结构与技术栈:Vue 3 + Pinia + 原生 WebSocket 构建的轻量但可控的语音识别前端架构
这个项目没有走“全家桶”路线,也没有引入vue-audio-recorder或web-speech-api这类浏览器原生方案(它们不支持科大讯飞协议)。它选择用最底层但最可控的方式:MediaRecorder录制原始 PCM 流 → 手动切片并编码为audio/L16;rate=16000→ 通过 WebSocket 发送二进制帧 → 解析服务端返回的 JSON+base64 混合响应。整个结构干净得像刚洗过的代码仓库,没有冗余文件,没有未使用的插件。
2.1 目录结构:五层清晰分治,从能力到视图一目了然
src/ ├── api/ # 封装鉴权与 WebSocket 初始化逻辑 │ ├── auth.ts # 计算 X-Appid、X-CurTime、X-Param、X-CheckSum 四要素 │ └── ws-client.ts # WebSocket 封装:连接管理、重连策略、消息队列、心跳发送 ├── composables/ # 可复用的业务逻辑组合式函数 │ └── useSpeechRecognition.ts # 核心 Hook:start/stop/cancel、状态机、事件总线 ├── stores/ # Pinia 状态管理 │ └── speechStore.ts # 存储识别状态、当前文本、历史记录、错误码、配置项 ├── components/ # 视图组件(无业务逻辑) │ ├── SpeechControl.vue # 开始/暂停/停止按钮组 + 状态指示灯 │ ├── SpeechResult.vue # 实时滚动文本 + 逐字高亮 + 时间戳标记 │ └── SpeechConfig.vue # AppID/ApiSecret/ApiKey 配置表单(带本地存储) ├── utils/ # 工具函数 │ ├── audio-processor.ts # PCM 数据预处理:16bit 转 ArrayBuffer、补零、分帧(每 40ms 一帧 = 640 字节) │ └── text-parser.ts # 解析讯飞返回的 result 字段:区分 "punc"、"type":"final"/"partial"、提取 word_list └── App.vue # 入口:组合 useSpeechRecognition + 注入 store + 绑定组件提示:
api/auth.ts是整个项目最关键的模块之一。科大讯飞 WebAPI 不接受明文密钥,所有请求头必须动态计算。X-CheckSum是MD5(Base64Encode(apiKey + curTime + base64Encode(param))),其中param是 JSON 字符串再 base64 编码——少一次 encode,连接直接被拒绝。这个细节在官方文档里藏得很深,但本项目把它拆成三步独立函数,方便你打日志验证。
2.2 技术选型依据:为什么不用 SDK?为什么坚持手写 WebSocket?
不封装 SDK:讯飞官方 Vue SDK(如
xf-web-sdk)本质是 JS SDK 的薄包装,内部仍用XMLHttpRequest+setTimeout模拟长连接,无法精确控制帧间隔、无法捕获底层网络异常、无法注入自定义 buffer 处理逻辑。而本项目需要支持“说话中实时高亮已确认字”,这就要求你能拿到每一个result.type === 'partial'的中间结果,并在 UI 上做差异更新——SDK 通常只暴露最终final结果。坚持原生 WebSocket:
api/ws-client.ts封装了完整的连接生命周期:- 连接前:校验
navigator.mediaDevices.getUserMedia权限,预创建MediaRecorder实例避免首次调用延迟; - 连接中:每 30 秒发一次
{"action":"ping"}心跳,服务端超时 60 秒未响应则主动 close; - 断线后:指数退避重连(1s → 2s → 4s → 8s),最大重试 5 次,失败后触发
error:ws-disconnect事件; - 消息收发:使用
binaryType = 'arraybuffer',发送前将 PCM buffer 转为Uint8Array,接收时用TextDecoder解析 UTF-8 JSON 头 +atob()解析 base64 body。
- 连接前:校验
这种控制粒度,是任何 SDK 都无法提供的。当你在弱网环境下看到“正在重连…”提示,背后就是这段代码在默默工作。
2.3 核心依赖清单:精简但关键,全部来自标准生态
| 依赖 | 版本 | 用途 | 是否可替换 |
|---|---|---|---|
vue | ^3.4.0 | 响应式框架 | 否(Composition API 深度绑定) |
pinia | ^2.1.7 | 状态管理 | 可换为@vue/reactivity手写 store,但会增加复杂度 |
axios | ^1.6.0 | 仅用于初始鉴权获取 token(部分旧版讯飞接口需) | 可删,新版直连 WebSocket 不需要 |
mitt | ^3.0.0 | 跨组合式函数事件通信(如useSpeechRecognition内部 emitstatus-change) | 可换为EventTarget原生 API |
@types/web | ^0.1.19 | 补全MediaRecorder、AudioContext等 Web API 类型定义 | 开发期必需,运行时无影响 |
注意:项目不依赖socket.io、stompjs、rxjs等重型库。WebSocket 就是 WebSocket,没有抽象层,没有中间协议。这让你在 Chrome DevTools 的 Network → WS 面板里,能直接看到每一帧发送的二进制数据长度、服务端返回的原始 JSON 结构——这才是调试语音识别问题的第一现场。
3. 核心模块拆解:从麦克风采集到逐字上屏,一个请求的完整链路
语音识别不是“按个按钮→等结果”,它是一条由多个状态节点组成的流水线。本项目把这条链路完全显式化,每个环节都可监听、可拦截、可替换。我们以一次成功识别“你好,今天天气怎么样?”为例,拆解从用户点击“开始”到“怎么样”三个字高亮显示的全过程。
3.1 麦克风采集与 PCM 预处理:utils/audio-processor.ts
MediaRecorder默认输出audio/webm,但讯飞要求原始 PCM(16bit little-endian, 16kHz, 单声道)。所以必须绕过MediaRecorder,用AudioContext+ScriptProcessorNode(已废弃,本项目用AudioWorklet兼容方案)手动采集:
// utils/audio-processor.ts export function createPCMRecorder(sampleRate: number = 16000): { start: () => Promise<void>; stop: () => Promise<ArrayBuffer>; getBuffer: () => ArrayBuffer; } { let context: AudioContext | null = null; let analyser: AnalyserNode | null = null; let processor: ScriptProcessorNode | null = null; let buffer: Int16Array[] = []; const init = () => { context = new (window.AudioContext || (window as any).webkitAudioContext)(); analyser = context.createAnalyser(); // 注意:ScriptProcessorNode 已废弃,生产环境应替换为 AudioWorklet // 此处为兼容性保留,实际项目中建议升级 processor = context.createScriptProcessor(4096, 1, 1); processor.onaudioprocess = (e) => { const input = e.inputBuffer.getChannelData(0); // 获取左声道 const int16 = new Int16Array(input.length); for (let i = 0; i < input.length; i++) { // float32 [-1, 1] → int16 [-32768, 32767] int16[i] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767))); } buffer.push(int16); }; }; return { start: async () => { if (!context) init(); // 请求麦克风权限 const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const source = context.createMediaStreamSource(stream); source.connect(processor); processor.connect(context.destination); }, stop: () => { if (processor) processor.disconnect(); if (context) context.close(); // 合并所有 buffer 为单个 ArrayBuffer const totalLength = buffer.reduce((sum, b) => sum + b.length, 0); const merged = new Int16Array(totalLength); let offset = 0; buffer.forEach(b => { merged.set(b, offset); offset += b.length; }); buffer = []; return merged.buffer; }, getBuffer: () => { // 返回当前累积的 buffer(用于实时分帧发送) const totalLength = buffer.reduce((sum, b) => sum + b.length, 0); const merged = new Int16Array(totalLength); let offset = 0; buffer.forEach(b => { merged.set(b, offset); offset += b.length; }); return merged.buffer; } }; }逻辑说明:这段代码做了三件事:① 将浮点音频数据线性映射到 16 位整数范围;② 每次
onaudioprocess触发时缓存一帧;③getBuffer()返回当前所有帧合并后的ArrayBuffer,供 WebSocket 分片发送。
参数说明:sampleRate固定为16000,这是讯飞 WebAPI 强制要求的采样率,传其他值会导致识别失败或静音。4096是ScriptProcessorNode的缓冲区大小,对应约 256ms 音频(4096 ÷ 16000 ≈ 0.256s),实际项目中可根据网络延迟调整为2048(128ms)以降低首字延迟。
3.2 WebSocket 连接与鉴权:api/auth.ts与api/ws-client.ts
讯飞 WebSocket 连接 URL 格式为:wss://iat-api.xfyun.cn/v2/iat?host=iats-api.xfyun.cn&app_id=${appId},但光有 URL 不够,必须携带四个请求头:
| Header | 计算方式 | 示例值(简化) |
|---|---|---|
X-Appid | 你的讯飞开放平台 AppID | 5f1a2b3c |
X-CurTime | 当前时间戳(秒) | 1712345678 |
X-Param | base64(JSON.stringify({ "engine_type": "intp65", "aue": "raw" })) | eyAiZW5naW5lX3R5cGUiOiAiaW50cDY1IiwgImF1ZSI6ICJyYXciIH0= |
X-CheckSum | MD5(apiKey + X-CurTime + X-Param) | a1b2c3d4e5f67890... |
api/auth.ts将其封装为纯函数:
// api/auth.ts import { md5 } from 'js-md5'; import { Base64 } from 'js-base64'; export interface XFAuthHeaders { 'X-Appid': string; 'X-CurTime': string; 'X-Param': string; 'X-CheckSum': string; } export function generateXFHeaders(appId: string, apiKey: string, apiSecret: string): XFAuthHeaders { const curTime = Math.floor(Date.now() / 1000).toString(); const param = { engine_type: 'intp65', // 中文普通话识别 aue: 'raw', // 原始 PCM 格式 language: 'zh_cn', // 语言 accent: 'mandarin', // 方言(普通话) }; const paramBase64 = Base64.encode(JSON.stringify(param)); const checkSum = md5(apiKey + curTime + paramBase64); return { 'X-Appid': appId, 'X-CurTime': curTime, 'X-Param': paramBase64, 'X-CheckSum': checkSum, }; }逻辑说明:
X-Param必须是 JSON 字符串先JSON.stringify(),再Base64.encode(),不能直接对对象编码;X-CheckSum的拼接顺序严格为apiKey + curTime + paramBase64,字母大小写、空格、顺序错一位都会导致401 Unauthorized。
参数说明:engine_type: 'intp65'是讯飞当前主力中文引擎,aue: 'raw'表示原始 PCM,若传'lame'则需 MP3 格式,本项目不支持。
api/ws-client.ts则负责把 headers 注入 WebSocket:
// api/ws-client.ts export class XFWebSocket { private socket: WebSocket | null = null; private url: string; private headers: XFAuthHeaders; constructor(url: string, headers: XFAuthHeaders) { this.url = url; this.headers = headers; } connect(): Promise<void> { return new Promise((resolve, reject) => { // 注意:原生 WebSocket 不支持设置 headers,需在 URL 中透传 // 讯飞允许将 X-Appid/X-CurTime/X-Param/X-CheckSum 作为 query 参数 // 但更推荐用反向代理(见第 4 章)避免密钥泄露 const fullUrl = `${this.url}?appid=${this.headers['X-Appid']}&curtime=${this.headers['X-CurTime']}&checksum=${this.headers['X-CheckSum']}¶m=${encodeURIComponent(this.headers['X-Param'])}`; this.socket = new WebSocket(fullUrl); this.socket.binaryType = 'arraybuffer'; this.socket.onopen = () => { console.log('[XF-WS] Connected'); resolve(); }; this.socket.onerror = (err) => { console.error('[XF-WS] Error:', err); reject(err); }; this.socket.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { this.handleBinaryMessage(event.data); } else { this.handleTextMessage(event.data); } }; this.socket.onclose = (e) => { console.log('[XF-WS] Closed:', e.code, e.reason); }; }); } private handleTextMessage(data: string) { try { const json = JSON.parse(data); if (json.action === 'started') { // 服务端确认连接成功,可开始发音频 this.emit('connected'); } else if (json.data && json.data.result) { // 解析识别结果 const result = JSON.parse(atob(json.data.result)); this.emit('result', result); } } catch (e) { console.warn('[XF-WS] Parse text message failed:', e); } } private handleBinaryMessage(buffer: ArrayBuffer) { // 讯飞二进制帧格式:前 4 字节为 length(小端序),后为 base64 编码的 JSON const view = new DataView(buffer); const length = view.getUint32(0, true); // true = little-endian const base64Str = new TextDecoder().decode(buffer.slice(4, 4 + length)); try { const result = JSON.parse(atob(base64Str)); this.emit('result', result); } catch (e) { console.warn('[XF-WS] Parse binary message failed:', e); } } sendAudio(buffer: ArrayBuffer) { if (this.socket?.readyState === WebSocket.OPEN) { this.socket.send(buffer); } } close() { this.socket?.close(); } }逻辑说明:讯飞 WebSocket 支持两种消息格式:① 文本帧(JSON)用于控制指令(
started,result);② 二进制帧(前 4 字节 length + 后面 base64 JSON)用于大结果。本项目同时处理两种,确保兼容性。
参数说明:view.getUint32(0, true)的true表示小端序(little-endian),这是讯飞协议强制要求,用false会读错 length 导致后续解析崩溃。
3.3 识别结果解析与状态同步:utils/text-parser.ts与stores/speechStore.ts
讯飞返回的result结构嵌套极深,且type字段决定如何更新 UI:
{ "sn": 1, "ls": false, "bg": 0, "ed": 0, "ws": [ { "cw": [ { "w": "你好", "wp": [ { "bg": 0, "ed": 1200 } ] } ] }, { "cw": [ { "w": "今天", "wp": [ { "bg": 1200, "ed": 2400 } ] } ] } ], "punc": ",", "type": "partial" }utils/text-parser.ts将其扁平化为可消费的结构:
// utils/text-parser.ts export interface XFWord { text: string; startTime: number; // ms endTime: number; // ms isFinal: boolean; } export interface XFResult { words: XFWord[]; punctuation: string; type: 'partial' | 'final'; sn: number; } export function parseXFResult(raw: any): XFResult { const words: XFWord[] = []; if (Array.isArray(raw.ws)) { raw.ws.forEach((wItem: any) => { if (Array.isArray(wItem.cw)) { wItem.cw.forEach((cw: any) => { if (cw.w && Array.isArray(cw.wp)) { cw.wp.forEach((wp: any) => { words.push({ text: cw.w, startTime: wp.bg, endTime: wp.ed, isFinal: raw.type === 'final', }); }); } }); } }); } return { words, punctuation: raw.punc || '', type: raw.type as 'partial' | 'final', sn: raw.sn, }; }stores/speechStore.ts则用 Pinia 管理状态:
// stores/speechStore.ts import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; import { XFResult } from '@/utils/text-parser'; export const useSpeechStore = defineStore('speech', () => { const status = ref<'idle' | 'recording' | 'recognizing' | 'paused' | 'error'>('idle'); const currentText = ref<string>(''); const history = ref<{ text: string; timestamp: Date }[]>([]); const lastResult = ref<XFResult | null>(null); const config = ref({ appId: localStorage.getItem('xf-app-id') || '', apiKey: localStorage.getItem('xf-api-key') || '', apiSecret: localStorage.getItem('xf-api-secret') || '', }); const isRecognizing = computed(() => status.value === 'recognizing'); function updateCurrentText(result: XFResult) { if (result.type === 'final') { // 最终结果:追加标点,清空临时文本 currentText.value += result.punctuation; history.value.push({ text: currentText.value, timestamp: new Date(), }); currentText.value = ''; } else { // 中间结果:只更新当前行,不加标点 // 注意:这里要做去重,避免同一词重复添加 const newWords = result.words.filter(w => !currentText.value.includes(w.text) && w.text.trim().length > 0 ); currentText.value = newWords.map(w => w.text).join('') || currentText.value; } lastResult.value = result; } function reset() { status.value = 'idle'; currentText.value = ''; } return { status, currentText, history, lastResult, config, isRecognizing, updateCurrentText, reset, }; });逻辑说明:
updateCurrentText是核心业务逻辑。它区分partial和final:partial仅更新当前行(用于实时高亮),final则提交整句到历史记录并清空。isRecognizing计算属性驱动 UI 状态,比如按钮禁用、麦克风图标旋转。
参数说明:config使用localStorage持久化,避免每次刷新都要重输密钥;history是数组而非 Map,因为语音识别是线性过程,按时间顺序追加即可。
4. 运行与调试:从零启动、环境变量配置、Mock Server 本地联调全流程
别急着npm run dev—— 这个项目对环境有明确要求,跳过配置直接跑,90% 的概率卡在WebSocket connection failed。下面是从 clone 到看到“你好”两个字上屏的完整路径,包含所有你可能卡住的检查点。
4.1 环境准备:Node.js、Vue CLI、讯飞账号三件套
- Node.js 版本:必须
>= 18.0.0。低于此版本,crypto.subtle.digest()在 Vite 环境下会报undefined,导致X-CheckSum计算失败。执行node -v确认,若为16.x,请升级。 - Vue CLI:项目基于 Vite 4.5 构建,无需全局安装 Vue CLI,但需确保
pnpm或yarn可用。推荐pnpm(更快的依赖解析):npm install -g pnpm pnpm install - 讯飞账号:前往 讯飞开放平台 注册企业/个人账号 → 创建新应用 → 选择“语音听写(流式版)” → 获取
AppID、API Key、API Secret。注意:免费额度为 500 次/天,超出后返回403 Forbidden,非配置错误。
4.2 配置密钥:三种方式,推荐开发用.env.local,上线用反向代理
项目读取密钥的优先级为:.env.local>localStorage> 空字符串。.env.local是最安全的开发方式:
# .env.local VUE_APP_XF_APP_ID=5f1a2b3c VUE_APP_XF_API_KEY=abcd1234efgh5678 VUE_APP_XF_API_SECRET=ijkl9012mnop3456注意:
.env.local文件不会被 Git 提交,已加入.gitignore。若你误提交了密钥,请立即在讯飞平台重置API Secret。
若不想配环境变量,也可在src/components/SpeechConfig.vue中手动输入,输入后自动存入localStorage,刷新页面仍生效。
但请注意:绝对不要在生产环境前端代码中硬编码密钥。正确做法是配置 Nginx 反向代理,将/xf-ws路径代理到讯飞真实地址,并在代理层注入 headers:
# nginx.conf location /xf-ws { proxy_pass https://iat-api.xfyun.cn; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header X-Appid "5f1a2b3c"; proxy_set_header X-CurTime "1712345678"; proxy_set_header X-Param "eyAiZW5naW5lX3R5cGUiOiAiaW50cDY1IiwgImF1ZSI6ICJyYXciIH0="; proxy_set_header X-CheckSum "a1b2c3d4..."; }这样前端只需连接wss://your-domain.com/xf-ws,密钥完全不出现在浏览器中。
4.3 启动与联调:pnpm dev后的三步验证法
执行pnpm dev启动开发服务器(默认http://localhost:5173),打开页面后,按以下顺序验证:
第一步:检查麦克风权限
点击“开始识别”,浏览器应弹出“是否允许访问麦克风”提示。若无提示,检查地址栏左侧锁形图标 → “网站设置” → “麦克风” → 设为“允许”。若提示“Permission denied”,说明navigator.mediaDevices.getUserMedia被阻止,常见于http://非 localhost 场景(必须https://或localhost)。第二步:抓包验证 WebSocket 连接
打开 Chrome DevTools → Network → WS(WebSocket)标签页 → 点击“开始识别”。你应该看到一个iat-api.xfyun.cn的连接,状态为101 Switching Protocols。点击该连接 → Messages 标签页,能看到发送的{"common":{...},"business":{...},"data":{...}}初始化帧,以及服务端返回的{"action":"started","data":{...}}。若此处为空,说明连接未建立,检查X-CheckSum是否计算正确(可复制api/auth.ts中的generateXFHeaders函数到 Console 手动执行验证)。第三步:验证音频帧发送与结果返回
在 Messages 标签页,你应该看到周期性发送的二进制帧(Size 列显示640B或1280B,对应 40ms/80ms PCM),以及服务端返回的 JSON 消息,如{"sn":1,"ls":false,"ws":[...],"punc":",","type":"partial"}。若只有发送没有返回,检查aue: 'raw'是否匹配 PCM 格式,或engine_type是否为'intp65'(旧版'sms16k'已停用)。
提示:若卡在第二步,90% 是
X-CheckSum错误。把apiKey、curTime、paramBase64三个变量复制到在线 MD5 工具(如 https://www.md5hashgenerator.com/)手动计算,对比返回值。注意:curTime是秒级时间戳,不是毫秒。
4.4 Mock Server:不依赖讯飞,本地 100% 复现识别流程
项目附带mock-server/目录,是一个 Express 服务,模拟讯飞 WebSocket 协议。它不调用真实 API,但返回完全一致的 JSON 结构,用于:
- 离线开发(没网也能写 UI)
- 自动化测试(CI 环境无需密钥)
- 调试 UI 逻辑(比如“断网重连”状态)
启动方式:
cd mock-server pnpm install pnpm start服务监听ws://localhost:8080/mock-xf-ws。在src/composables/useSpeechRecognition.ts中,将 WebSocket URL 替换为该地址,即可切换为 Mock 模式。Mock Server 会随机返回预设句子(如“今天天气不错”、“会议要点已记录”),并模拟partial/final类型切换,完美覆盖所有 UI 状态。
5. 常见问题排查:血泪经验总结的 5 个高频翻车点与后悔药
这个项目我带着某高校实验室的 A 同学一起跑通过 12 次,从 Windows 到 macOS,从 Chrome 到 Edge,踩过的坑都记在下面。每一条都是真实发生、有截图、有日志、有解决方案的“后悔药”。
5.1 现象:点击“开始识别”后,麦克风图标亮起,但 Network → WS 面板无任何连接,控制台无报错
原因:MediaRecorder初始化失败,但错误被静默吞掉。常见于 Chrome 115+ 对ScriptProcessorNode的彻底废弃,或AudioContext被挂起(用户未与页面交互)。
解决:
- 在
composables/useSpeechRecognition.ts的start()函数开头,加一行console.log('AudioContext state:', context.state); - 若输出
suspended,在页面任意位置(如按钮 click 事件)调用context.resume(); - 将
utils/audio-processor.ts中的ScriptProcessorNode替换为AudioWorklet(需额外注册 worklet,详见 MDN AudioWorklet )。
5.2 现象:WebSocket 连接成功(看到101状态),但发送 PCM 帧后,服务端无任何result返回,Messages 面板只有started
原因:PCM 数据格式错误。讯飞严格要求16bit little-endian, 16kHz, 单声道,而AudioContext默认采样率是44100或48000,且Int16Array若未按小端序排列,会被识别为静音。
解决:
- 在
audio-processor.ts的start()函数中,强制指定sampleRate:const context = new (window.AudioContext || (window as any).webkitAudioContext)(16000); - 在
onaudioprocess中,确保int16[i]是 16 位有符号整数:// 错误:Math.round(input[i] * 32767) 可能越界 // 正确:Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767)))
5.3 现象:识别结果乱码,如你好或ä½ å¥½
原因:X-Param的 base64 编码未做 URL 安全化。讯飞要求+→-,/→_,=→''(去掉),而js-base64的Base64.encode()默认不启用 URL 安全模式。
解决:
- 修改
api/auth.ts中的paramBase64计算:import { Base64 } from 'js-base64'; // ... const paramBase64 = Base64.encodeURL(JSON.stringify(param)); // 用 encodeURL 代替 encode - 若无
encodeURL方法,手动替换:const paramBase64 = Base64.encode(JSON.stringify(param)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
5.4 现象:识别过程中断网 2 秒,重连后识别结果错乱,出现“你好你好你好”重复
原因:重连后未重置sn(sequence number)。讯飞要求每条音频帧携带递增的sn,断线重连后若继续用旧sn,服务端会认为是乱序包而丢弃或错乱。
解决:
- 在
XFWebSocket类中,添加sn计数器:private sn = 0; sendAudio(buffer: ArrayBuffer) { if (this.socket?.readyState === WebSocket.OPEN) { const frame = { common: { app_id: this.headers['X-Appid'] }, business: { language: 'zh_cn', accent: 'mandarin', aue: 'raw', engine_type: 'intp65' }, data: { status: 0, // 0=first,
本文还有配套的精品资源,点击获取