简介:基于Vue.js集成科大讯飞WebAPI的实时语音转文字示例工程,面向需要在会议记录、在线教育、直播互动等场景落地语音识别能力的前端开发者。工程实现了从麦克风权限获取、音频流采集到调用讯飞语音识别接口并动态回显文本的完整链路,可作为组件化开发与AI服务集成的实用参考。资源包为16KB的RAR压缩包,共8个文件,以7个JavaScript文件和1个HTML页面为主,涵盖SDK封装、Worker音频处理、加密签名工具及演示页面,文件不大但分工明确,便于直接阅读和改造。目前已有4430人学习下载,适合具备一定Vue.js基础、希望快速理解WebAPI调用流程和前端录音实现的开发者。通过该资源,读者可掌握MediaDevices录音、流式音频上传、识别结果异步处理及错误恢复等关键环节,并能借助清晰的模块组织复用其功能,缩短自研语音输入模块的调试时间。
1. 实时语音转写选型:为什么是讯飞 WebAPI + Vue.js
vue.js 接科大讯飞实时语音转写 WebAPI,最直观的场景是直播字幕、会议纪要、客服质检——麦克风说完,页面就要跟着出字。很多人第一次接语音能力,默认按「录音文件转写」来设计:录完一整段,上传,等结果。离线场景没问题,但线上会议开到一半,谁也没耐心等那几十秒。这套资源走的是实时语音转写通道,前端拿到麦克风数据后按帧上传,服务端增量返回识别文本,Vue 组件再把文本逐句渲染出来。
适合谁?一类是 Vue 前端,项目里临时要加语音转写,不想自研声学模型;另一类是用过讯飞录音文件转写、嫌轮询太慢的从业者。整个链路不长,但鉴权签名、音频采样率、分片大小、尾包处理这四个点最容易翻车,而且报错信息往往不带具体堆栈,像个黑匣子。下面按落地顺序拆开讲,从握手到出字。
2. WebSocket 鉴权与连接:手写 HMAC-SHA256 签名,别用后端中转
2.1 实时转写 vs 录音文件转写,先分清场景
| 对比项 | 实时语音转写(WebSocket) | 录音文件转写(HTTP 轮询) |
|---|---|---|
| 连接方式 | 一条 WebSocket 长连接 | HTTP 上传 + 轮询结果 |
| 出字时机 | 边说边返,增量返回 | 整段音频处理完一次性返回 |
| 典型场景 | 直播字幕、现场会议纪要、客服实时质检 | 历史录音归档、离线音频转写 |
| 前端接入成本 | 要处理鉴权、音频分片、增量拼接 | 上传简单,但要等结果 |
| 延迟 | 低,秒级 | 取决于音频长度 |
这套资源用的是实时转写,所以代码里不存在「上传文件后等回调」的逻辑,核心是一条 ws 连接从开始说话到结束一直保持。判断自己在做哪个场景很简单:如果需求是「说完一句话马上要看到字」,实时;如果是「下班后批量转一批录音」,录音文件转写更划算。
2.2 鉴权 URL 生成:拼接、加密、编码三次易错点
讯飞 WebSocket 接口不像 HTTP 接口把 token 放在请求头里,而是把签名参数拼进 URL 的 query。签名链路是固定的三步:把 host、date、request-line 拼成 signature_origin,用 HMAC-SHA256 加密后 base64 得到 signature,再把 authorization 拼接串整体 base64 后放进 URL。每一步都有对应的坑,最常见是 date 在拼接时被重新获取导致签名失效。
先看代码:
// 鉴权 URL 生成:纯前端实现,适合本地调试和资源演示 import CryptoJS from 'crypto-js'; function buildAuthUrl() { // host 与 path 以你实际拿到的接口地址为准,不要硬编码到前端仓库 const host = '你的WebAPI域名'; const path = '/v2/iat'; const apiKey = '你的APIKey'; const apiSecret = '你的APISecret'; const date = new Date().toUTCString(); // 必须是 RFC1123 格式 const signatureOrigin = `host: ${host}\ndate: ${date}\nGET ${path} HTTP/1.1`; // 第一步:HMAC-SHA256 签名 const signature = CryptoJS.enc.Base64.stringify( CryptoJS.HmacSHA256(signatureOrigin, apiSecret) ); // 第二步:拼 authorization 原始串 const authorizationOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", ` + `headers="host date request-line", signature="${signature}"`; // 第三步:对 authorization 整体再做一次 base64 const authorization = CryptoJS.enc.Base64.stringify( CryptoJS.enc.Utf8.parse(authorizationOrigin) ); return `wss://${host}${path}?authorization=${authorization}` + `&date=${encodeURIComponent(date)}&host=${host}`; }逻辑说明:signatureOrigin 里的\n是字符串里的真实换行,顺序必须是 host、date、request-line,任何一个字段多加一个空格都会导致 10105。date 用toUTCString()得到的是 UTC 字符串,这一步在拼接过程中只要重新new Date()一次,签名就废了。authorization 和 signature 是两次 base64,对象不同,第一次对二进制签名串做 base64,第二次对 UTF-8 字符串做 base64。
参数说明:apiKey 和 apiSecret 在讯飞开放平台控制台的语音服务里能看到,apiKey 不是 app_id,别混用。host 字段要和 URL 里的域名完全一致,不带https://前缀。这段逻辑放在前端,apiSecret 会暴露给浏览器,正式项目建议挪到 Node 中间层生成 URL 后返回给前端,资源里保留了纯前端版本是为了方便你单机调试。
2.3 WebSocket 建连与首帧配置
鉴权 URL 生成后,建连本身反而简单,难点在首帧参数。讯飞实时转写协议里,客户端连接后发的第一帧不携带音频数据,只带配置;服务端收到后才会进入收流状态。
const ws = new WebSocket(authUrl); ws.onopen = () => { ws.send(JSON.stringify({ common: { app_id: '你的APPID' }, business: { language: 'zh_cn', domain: 'iat', accent: 'mandarin', vad_eos: 5000, dwa: 'wpgs' // 开启动态修正,识别结果会增量更新 }, data: { status: 0, // 0 表示首帧 format: 'audio/L16;rate=16000', encoding: 'raw', audio: '' // 首帧不传音频 } })); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.code !== 0) { console.error(msg.code, msg.message); return; } // 到这里说明鉴权通过、参数被接受 };参数说明:business.domain填iat表示实时听写;vad_eos表示语音结尾静默多久判定一句结束,单位毫秒,一般 3000~5000;dwa: 'wpgs'开启字词级动态修正,不开启的话识别结果会更保守,但很多场景下首次出字会慢一点。data.format里的audio/L16;rate=16000表示 16kHz、16bit 线性 PCM,这是实时接口的默认输入格式,也是后面录音采集要围绕的核心参数。
还有一个细节:onopen 后立刻发首帧,不要等用户说话,因为讯飞侧是从首帧到达开始计时的。onmessage 里如果收到 code 非 0,先别急着调 UI,拿错误码对照第 5 章的速查表去排查。首帧通过后,ws 连接进入收流状态,下一章开始处理麦克风。
3. 录音采集与音频分片:16kHz PCM 是实时转写的隐形门槛
3.1 getUserMedia + AudioContext 采集麦克风
浏览器里拿麦克风只有一条路:navigator.mediaDevices.getUserMedia。拿到 MediaStream 之后,用 AudioContext 创建音频处理链,这一步大家都能跑通,真正拉开差距的是采样率。系统麦克风默认 48000Hz,但 2.3 节的接口参数写的是 16000Hz,两者不一致时识别结果会变成「语速快一倍」的乱码。
async function startRecording() { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); // 期望 16000Hz,但很多浏览器不保证生效,所以后面要有重采样兜底 const audioCtx = new AudioContext({ sampleRate: 16000 }); const source = audioCtx.createMediaStreamSource(stream); const processor = audioCtx.createScriptProcessor(4096, 1, 1); processor.onaudioprocess = (event) => { const input = event.inputBuffer.getChannelData(0); const pcm = convertTo16BitPCM(resample(input, audioCtx.sampleRate, 16000)); // 把 pcm 交给分片发送模块,见 3.3 节 }; source.connect(processor); // 不接 destination 也能触发 onaudioprocess,部分浏览器需要接一下才不会静音 processor.connect(audioCtx.destination); }逻辑说明:createScriptProcessor(4096, 1, 1)的 4096 是缓冲区大小,即每次回调约 4096 个采样点;在 48000Hz 下大约 85ms 回调一次,在 16000Hz 下大约 256ms 回调一次。把 processor 连接到 destination 看起来会发出声音,但如果不接,部分浏览器的音频处理链不会运转,这是录不上数据的血泪经验。实际使用时可以接一个 zero-gain 的 GainNode 来避免外放。
参数说明:ScriptProcessor 已经被标准标记为废弃,但兼容性仍然最好,尤其在 Android WebView 里 AudioWorklet 经常加载不了独立脚本。AudioWorklet 是更长远的方案,调试成本高一些;这份资源用的 ScriptProcessor,换成 AudioWorklet 时只需要替换采集回调部分,后面的分片和发送逻辑不变。
3.2 Float32 转 16Bit PCM 与降采样
采集回调拿到的是 Float32Array,取值范围 -1 到 1,而接口要求的是 16bit 线性 PCM。转换时最容易翻车的是没有做 clamp,直接float * 0x7fff,一旦输入超过 1,转换结果会溢出变成刺耳的爆音,识别率直接崩掉。降采样则是为了让 48000Hz 数据匹配接口要求的 16000Hz。
// 线性插值重采样,输入输出都是 Float32Array function resample(input, fromRate, toRate) { if (fromRate === toRate) return input; const ratio = fromRate / toRate; // 48000 / 16000 = 3 const outputLength = Math.round(input.length / ratio); const output = new Float32Array(outputLength); for (let i = 0; i < outputLength; i++) { const position = i * ratio; const index0 = Math.floor(position); const index1 = Math.min(index0 + 1, input.length - 1); const fraction = position - index0; output[i] = input[index0] + (input[index1] - input[index0]) * fraction; } return output; } // Float32 转 16bit PCM,注意 clamp function floatTo16BitPCM(float32) { const int16 = new Int16Array(float32.length); for (let i = 0; i < float32.length; i++) { const sample = Math.max(-1, Math.min(1, float32[i])); int16[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff; } return int16; }逻辑说明:resample的原理是线性插值,对语音信号来说够用;更复杂的重采样算法是 FIR 滤波,在浏览器端 CPU 开销大,收益不明显。floatTo16BitPCM的 clamp 已经把值约束在 -1~1 之间,负数走 0x8000 段,正数走 0x7fff 段,这样生成的 PCM 数据不会溢出。
参数说明:ratio = fromRate / toRate,48000 转 16000 时等于 3,也就是每 3 个采样点映射为 1 个输出点,输出长度约为输入的 1/3。如果麦克风实际采样率是 44100,ratio 就是 2.75625,代码里的插值逻辑同样成立。发送前可以把floatTo16BitPCM的结果转成 ArrayBuffer 再 base64,讯飞接口 audio 字段要求 base64 字符串。
3.3 分片发送与 VAD 静音控制
采集到 PCM 只是第一步,还要按帧发给 WebSocket。分片的大小直接影响延迟:一包太大,服务端要等缓冲区填满才识别;一包太小,网络往返开销变大,还容易触发频率限制。我一般按 300ms 一包,也就是 16000Hz 下约 4800 个采样点、9600 字节左右的 PCM 数据。
let pcmBuffer = []; let silenceFrames = 0; const SILENCE_THRESHOLD = 0.01; // RMS 能量阈值,安静办公室够用 const MAX_SILENCE_FRAMES = 50; // 约 5 秒静音后强制断句 // 计算一帧数据的 RMS 能量,用于 VAD 判断 // bytes 是 Int16Array 对应的 ArrayBuffer function computeRMS(bytes) { const samples = new Int16Array(bytes); let sum = 0; for (let i = 0; i < samples.length; i++) { sum += (samples[i] / 32768) * (samples[i] / 32768); } return Math.sqrt(sum / samples.length); } // 把 ArrayBuffer 编码成 base64,btoa 对超大 Buffer 会栈溢出 // 实际工程里超过 1MB 建议用 FileReader 的 readAsDataURL 分片编码 function toBase64(buffer) { const bytes = new Uint8Array(buffer); let binary = ''; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); } function sendChunk(bytes) { ws.send(JSON.stringify({ data: { status: 1, // 中间帧 format: 'audio/L16;rate=16000', encoding: 'raw', audio: toBase64(bytes) } })); }逻辑说明:每帧采集完成后先算 RMS。RMS 低于阈值就认为是静音,静音帧累计超过MAX_SILENCE_FRAMES时,即使没有说话也强制把缓冲区的数据发出去,防止长时间静音时连接空转被服务端断开。有语音时累积到 8 帧左右(约 0.7 秒)发一包,既能保证低延迟,又不会让网络请求太密集。
参数说明:SILENCE_THRESHOLD的取值和麦克风增益强相关,安静办公室 0.01 可用;有空调声或风扇声时提到 0.03 更稳。MAX_SILENCE_FRAMES乘以每帧时长就是断句间隔,50 帧约 5 秒,和 2.3 节vad_eos: 5000对应。注意 status 1 是中间帧,status 2 是尾帧,VAD 只是前端辅助,最终断句由服务端vad_eos决定。
4. 渐进式结果解析:把 ws 返回帧变成 Vue 响应式字幕
4.1 返回帧结构:status、sn、pgs 三个关键字段
服务端返回的每一帧都是 JSON,核心字段不多,但结构嵌套有点绕。先看一个实际返回帧长什么样:
{ "code": 0, "message": "success", "sid": "x0001", "status": 1, "data": { "result": { "sn": 1, "pgs": "rpl", "ws": [ { "bg": 0, "cw": [{ "w": "你好" }] } ] } } }字段说明:
| 字段 | 含义 | 注意点 |
|---|---|---|
| code | 返回码 | 非 0 表示出错,message 会带原因 |
| status | 帧状态 | 0 首帧、1 中间帧、2 尾帧 |
| data.result.sn | 分片序号 | 同一句话的识别结果可能有多帧,按 sn 归属 |
| data.result.pgs | 本次返回是否为修正 | rpl 表示替换之前结果,apd 表示追加 |
| data.result.ws | 词数组 | 每项里 cw[0].w 是识别到的词 |
pgs这个字段最容易忽略。开启dwa: 'wpgs'动态修正后,同一个 sn 的结果会被服务端反复修正:先说「你好世界」,后改成「你好,世界」。如果不管 pgs,每次都 push 新文本,字幕会越积越长,这是很多第一次接讯飞的人会踩的坑。apd表示追加,这时应该 push 新文本而不是替换。
4.2 把消息解析成响应式字幕
解析逻辑分成两层:第一层把 ws 消息解成可读文本,第二层判断是新增还是替换。
function parseIatMessage(msg) { if (msg.code !== 0) throw new Error(`${msg.code}: ${msg.message}`); if (!msg.data || !msg.data.result) return null; const words = msg.data.result.ws || []; const text = words .map(item => (item.cw || []).map(c => c.w).join('')) .join(''); return { sn: msg.data.result.sn, text, isCorrection: msg.data.result.pgs === 'rpl' }; }在 Vue 组件里的处理:
const subtitles = ref([]); let currentSn = -1; ws.onmessage = (event) => { const parsed = parseIatMessage(JSON.parse(event.data)); if (!parsed) return; if (parsed.isCorrection && parsed.sn === currentSn) { // 同一句的修正结果:替换最后一条字幕,而不是追加 const last = subtitles.value[subtitles.value.length - 1]; if (last) last.text = parsed.text; } else { subtitles.value.push({ sn: parsed.sn, text: parsed.text }); currentSn = parsed.sn; } };逻辑说明:isCorrection和sn === currentSn同时成立时,说明服务端在对当前句做修正,应该替换数组最后一项;否则就 push 一条新字幕。这里有个边界:不同 sn 之间可能交叉返回,严格场景应该按 sn 做映射而不是只改最后一条,但对大多数字幕场景,按最后一条替换已经够用。
参数说明:parseIatMessage里ws.map(...).join('')把一组词拼成句子。注意ws字段名不是 WebSocket 的 ws,而是「Word Sequence」的意思,别和连接对象混淆。组件里展示时,subtitles数组用v-for渲染成字幕列表,每一行对应一个 sn。
4.3 停止录音与尾包处理
停止录音不是简单ws.close()。实时转写协议要求客户端先发一个 status 2 的尾帧,通知服务端音频流结束;服务端收到尾帧后会把最后一句话的识别结果返回,再回一个 status 2 的帧。顺序反过来,最后一个词大概率丢。
function stop() { ws.send(JSON.stringify({ data: { status: 2, // 尾帧:音频流结束 format: 'audio/L16;rate=16000', encoding: 'raw', audio: '' } })); // 等服务端回 status 2 再关连接,同时设超时兜底 const timer = setTimeout(() => ws.close(), 3000); ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.status === 2 && msg.code === 0) { clearTimeout(timer); ws.close(); } }; }逻辑说明:尾帧发送后,服务端会继续返回当前句的最终结果,然后返回 status 2。此时ws.onmessage里设置的临时处理函数接管,收到 status 2 后主动关闭连接。3 秒超时是为了防止服务端异常时连接一直挂着。注意audioCtx.close()要在发尾帧之后做,否则音频链断了但 ws 还在等回包。
参数说明:尾帧的audio字段和首帧一样留空,status改成 2。setTimeout的 3000 不是固定值,网络差时可以放宽到 5000,但超过 5 秒还没回包,基本可以认定这次转写已经异常,强制关闭比一直等更合理。
5. 避坑排查:实时转写最常见的 5 个坑
5.1 10105 鉴权失败:八成是签名串问题
现象:WebSocket 连上去后立即收到 code 10105,或者建立失败。
原因:signature_origin 拼错、date 不是 RFC1123 格式、authorization 二次 base64 前没按 UTF-8 编码,还有 host 字段带了多余的https://前缀。
解决:先把 buildAuthUrl 打印到控制台,用在线工具解一下 authorization,确认三个 query 参数齐全;date 在函数内只取一次,放在一个变量里复用,不要在任何异步操作之后再重新生成。这一步排查下来,10105 里十有八九是 date 漂移。
5.2 连接建立后秒断:首帧参数压缩到最少再放开
现象:onopen 正常,首帧发出去 1 秒内连接被服务端关闭,错误码多为 1005 或 4xxx。
原因:business 里加了服务端不支持的枚举,或者 app_id 与 APIKey 不匹配。
解决:把首帧先压缩到language、domain、format、encoding、status五个字段,能跑通再加accent、vad_eos、dwa。这一步排错能省下大量翻文档时间。
5.3 识别结果乱码:检查采样率与截断
现象:识别出的文本语速明显变快,或者全是错别字。
原因:48000Hz 数据没降采样到 16000Hz,或者 Float32 转 16bit 时没做 clamp,信号削顶产生爆音。
解决:发送前打一行日志,记录当前帧的采样率估算值;把 3.2 节的resample和floatTo16BitPCM串起来,先重采样再截断,顺序不能反。
5.4 结果延迟高:分片太大或断句太晚
现象:说完话要等 2~3 秒才出字。
原因:前端分片压到 1 秒一包,或者vad_eos设成了 10000,服务端一直在等静音超时才断句。
解决:分片控制在 200~500ms,vad_eos调到 3000~5000ms,同时确认开了dwa: 'wpgs'。动态修正虽然会多几次返回,但首次出字时间反而更快。
5.5 10114 并发超限:连接没在页面离开时关闭
现象:多人同时打开页面转写时,有人报 10114。
原因:实时转写的并发连接数有限,组件销毁时 ws 没关,连接泄漏。
解决:在 Vue 组件的 unmounted 里调 stop() 并 close();页面级应用还要在 vue-router 的 afterEach 里做兜底。也可以在入口维护一个全局连接计数器,超出配额时主动提示而不是硬发。
错误码速查表(具体编号以你拿到的接口文档为准):
| 返回码 | 常见含义 | 排查方向 |
|---|---|---|
| 10105 | 鉴权失败 | 签名串、date、authorization 编码 |
| 10110 | 无权限 | app_id 与 APIKey 是否匹配 |
| 10114 | 并发超限 | 连接是否正常关闭 |
| 11100 | 音频格式错误 | format、encoding、采样率 |
6. 封装进阶:把整套逻辑收进 useSpeechRecognition
6.1 组合式函数接口设计
把鉴权、录音、分片、解析四段逻辑收进一个组合式函数,组件里只留 start/stop 和结果回调。
export function useSpeechRecognition({ onResult, onError }) { const status = ref('idle'); const error = ref(''); async function start() { // 1. 生成鉴权 URL // 2. 建连并发送首帧 // 3. 启动麦克风采集和分片发送 // 4. 把解析后的文本交给 onResult } function stop() { // 发尾帧 -> 等服务端 status 2 -> 关闭连接 } return { status, error, start, stop }; }6.2 组件内只留两行调用
const { status, start, stop } = useSpeechRecognition({ onResult: (text) => caption.value = text, onError: (msg) => errorToast.value = msg });组件只负责按钮状态和字幕展示,不碰 WebSocket,不碰 AudioContext。后续换 AudioWorklet 或改接口域名,都不影响组件代码。
6.3 一条自检流程
每次拿到新环境,我会按这 5 步排:先打印鉴权 URL 确认 date 不漂移;连上后先发 3 秒静音,确认无报错;说一句「你好,测试」,看是否出字;连续说 10 秒,看同一 sn 是否被替换而不是追加;停止后等服务端回 status 2 再关 ws。这套流程走完,基本能覆盖 80% 的现场故障。这套 Vue 工程源码我整理成了下载包,包含鉴权、录音、分片、解析四个模块,可以直接对照着改。从那以后,我每次接到语音转写需求,都会强制把鉴权连通性、静音段、尾包回包这三步走完再碰 UI。希望帮到你。
本文还有配套的精品资源,点击获取