React使用科大讯飞AIUI通过websocket进行pcm语音转文字

前言

在工作中,我遇到了一项需求:在我的 React 项目中实现实时语音识别功能。由于项目涉及到讯飞的 AIUI 平台,我需要将音频数据实时发送给讯飞的服务器进行处理。然而,翻阅了官方文档后,我发现并没有现成的示例可以直接参考。这让我意识到,要实现这个功能,我不仅要处理音频录制,还需要处理如何将数据与服务器交互。那么,下面就是一个基于 recorder-core 和讯飞 AIUI 的音频识别方案。

虽然 recorder-core 提供了强大的录音功能,但在与讯飞平台的 WebSocket 对接、音频数据格式转换、以及保证数据实时上传的过程中,我遇到了不少坑。不过,通过一番摸索和调试,我终于完成了这一功能,并在此分享给大家,希望能帮到遇到类似问题的你。

环境准备

首先,你需要安装 recorder-core,这个库能轻松地在浏览器中实现音频录制,而且它的功能非常强大,支持多种音频格式的处理,包括 WAV、MP3 等。当然,我们还需要借助讯飞的AIUI平台,这样才能把音频内容转换成文字,让我们的应用不再是个聋子。


bash

代码解读

复制代码

npm install recorder-core or yarn add recorder-core

recorder-core 中录音和语音识别的工作其实并不复杂。我们只需要几步简单的配置,就能迅速完成录音并将音频传给讯飞的服务器进行识别。这里,我会逐步讲解每个步骤。

1. 初始化 WebSocket 与讯飞 AIUI

首先,语音识别是基于 WebSocket 连接的,因此我们需要与讯飞的服务器建立连接。连接成功后,就可以实时向讯飞发送音频数据,获得识别结果。


ini

代码解读

复制代码

const BASE_URL = "wss://wsapi.xfyun.cn/v1/aiui"; const APPID = "9d40e"; const APIKEY = "b2e408c556ef7750dcca"; const params = JSON.stringify({ auth_id: "f8948af1d2d6547eaf09bc2f20ebfcc6", data_type: "audio", scene: "main_box", sample_rate: "16000", aue: "raw", result_level: "plain", context: '{"sdk_support":["tts"]}' }); const url = BASE_URL + getHandshakeParams(params, APIKEY, APPID); wsRef.current = new WebSocket(url); wsRef.current.onopen = () => { console.log("WebSocket连接成功"); }; wsRef.current.onerror = (err) => { console.error("WebSocket错误:", err); }; wsRef.current.onmessage = (event) => { console.log("语音识别结果:", event.data); };

在这个步骤里,getHandshakeParams 函数会为我们生成 WebSocket 连接所需的认证信息和签名,这些内容可以在讯飞的开发者平台上找到。

2. 请求录音权限与开始录音

为了能够录音,我们需要首先请求用户授权。这是一个非常关键的步骤,因为如果用户拒绝授权,就无法进行后续的操作。


javascript

代码解读

复制代码

js 复制编辑 const recReq = function (success) { RecordApp.RequestPermission( function () { success && success(); }, function (msg, isUserNotAllow) { console.log( (isUserNotAllow ? "UserNotAllow," : "") + "无法录音:" + msg ); } ); }; const recStart = function (success) { recReq(success); setIsRecording(true); RecordApp.Start( { type: "wav", sampleRate: 16000, bitRate: 16, onProcess: function (buffers, powerLevel, bufferDuration, bufferSampleRate, newBufferIdx, asyncEnd) { RealTimeSendTry(buffers, bufferSampleRate, false); } }, function () { console.log("开始录音成功"); }, function (msg) { console.log("开始录音失败:" + msg); } ); };

通过调用 RecordApp.RequestPermission 来请求录音权限。注意,RecordApp.Start 是启动录音的关键函数,它的 onProcess 回调可以帮助我们实时获取录音数据并处理。

3. 实时处理音频数据并发送到讯飞服务器

在录音过程中,我们需要不断地将音频数据发送到讯飞服务器进行识别。这里是处理音频数据的核心函数——RealTimeSendTry


ini

代码解读

复制代码

js 复制编辑 const RealTimeSendTry = function (buffers, bufferSampleRate, isClose) { var pcm = new Int16Array(0); if (buffers.length > 0) { var chunk = Recorder.SampleData(buffers, bufferSampleRate, testSampleRate, send_chunk); send_chunk = chunk; pcm = chunk.data; send_pcmSampleRate = chunk.sampleRate; } if (!SendFrameSize) { TransferUpload(pcm, isClose); return; } var pcmBuffer = send_pcmBuffer; var tmp = new Int16Array(pcmBuffer.length + pcm.length); tmp.set(pcmBuffer, 0); tmp.set(pcm, pcmBuffer.length); pcmBuffer = tmp; var chunkSize = SendFrameSize / (testBitRate / 8); while (true) { if (pcmBuffer.length >= chunkSize) { var frame = new Int16Array(pcmBuffer.subarray(0, chunkSize)); pcmBuffer = new Int16Array(pcmBuffer.subarray(chunkSize)); var closeVal = false; if (isClose && pcmBuffer.length == 0) { closeVal = true; } TransferUpload(frame, closeVal); if (!closeVal) continue; } else if (isClose) { var frame = new Int16Array(chunkSize); frame.set(pcmBuffer); pcmBuffer = new Int16Array(0); TransferUpload(frame, true); } break; } send_pcmBuffer = pcmBuffer; };

这段代码通过 Recorder.SampleData 对音频数据进行处理,并将其分帧发送到讯飞的服务器。每一帧数据都是 PCM 格式的音频,讯飞的服务器会对这些数据进行实时识别。

4. 停止录音并处理音频

当用户停止录音时,我们会将最后一帧音频数据发送到服务器,并结束 WebSocket 连接。


javascript

代码解读

复制代码

js 复制编辑 const recStop = function () { wsRef.current.send("--end--"); RecordApp.Stop( function (arrayBuffer, duration, mime) { console.log(arrayBuffer, mime, "时长:" + duration + "ms"); if (typeof Blob != "undefined" && typeof window == "object") { var blob = new Blob([arrayBuffer], { type: mime }); console.log(blob, (window.URL || webkitURL).createObjectURL(blob)); } }, function (msg) { console.log("录音失败:" + msg); } ); };

通过 RecordApp.Stop 停止录音,并将音频数据转换为二进制格式,最后通过 WebSocket 发送出去。

总结

通过 recorder-core 和讯飞的 AIUI 平台,结合 WebSocket 的实时传输,我们就可以实现一个语音录音、实时识别的应用。实现的过程中,不仅仅是简单地录音,还包括了权限请求、音频数据的实时处理与分帧传输,甚至是 WebSocket 的建立与关闭。

最后贴出完整代码


ini

代码解读

复制代码

import React, { useState, useEffect, useRef } from "react"; import Recorder from "recorder-core"; // 你可以从npm安装这个库 import { getHandshakeParams } from "@/utils/authenticationUrl"; import "recorder-core/src/engine/mp3"; import "recorder-core/src/engine/mp3-engine"; //如果此格式有额外的编码引擎(*-engine.js)的话,必须要加上 import "recorder-core/src/extensions/waveview"; import RecordApp from "recorder-core/src/app-support/app"; import socket from "@/utils/socket"; const AudioRecorder = () => { const [isRecording, setIsRecording] = useState(false); const recorderRef = useRef(null); const wsRef = useRef(null); var testSampleRate = 16000; var testBitRate = 16; //本例子只支持16位pcm,不支持其他值 let SendFrameSize = 0; var BASE_URL = "wss://wsapi.xfyun.cn/v1/aiui"; var ORIGIN = "http://wsapi.xfyun.cn"; // 应用ID,在AIUI开放平台创建并设置 var APPID = "fe8e"; // 接口密钥,在AIUI开放平台查看 var APIKEY = "56ef7750dce2ca"; const receiveMessage = (msg) => { console.log(msg, "msg"); console.log(JSON.parse(msg.data), "msg"); }; var RealTimeSendReset = function () { send_pcmBuffer = new Int16Array(0); send_pcmSampleRate = testSampleRate; send_chunk = null; send_lastFrame = null; send_logNumber = 0; }; var send_pcmBuffer; //将pcm数据缓冲起来按固定大小切分发送 var send_pcmSampleRate; //pcm缓冲的采样率,等于testSampleRate,但取值过大时可能低于配置值 var send_chunk; //SampleData需要的上次转换结果,用于连续转换采样率 var send_lastFrame; //最后发送的一帧数据 var send_logNumber; useEffect(() => { let params = { auth_id: "f8948af1d2d6547eaf09bc2f20ebfcc6", data_type: "audio", scene: "main_box", sample_rate: "16000", aue: "raw", result_level: "plain", context: '{"sdk_support":["tts"]}', }; // 初始化 WebSocket 连接到讯飞的服务器 let url = BASE_URL + getHandshakeParams(params, APIKEY, APPID); // socket.init(url, receiveMessage); wsRef.current = new WebSocket(url); wsRef.current.onopen = () => { console.log("WebSocket连接成功"); }; wsRef.current.onerror = (err) => { console.error("WebSocket错误:", err); }; wsRef.current.onmessage = (event) => { console.log("语音识别结果:", event.data); }; // wsRef.current.send = (event) => { // console.log("语音识别结果:send", event.data); // socket.websocket.send(event); // }; // wsRef.current.sendBytes = (event) => { // console.log("语音识别结果:sendBytes", event.data); // socket.websocket.send(event); // }; // 清理 WebSocket 连接 return () => { if (wsRef.current) { wsRef.current.close(); } }; }, []); const startRecording = () => { if (recorderRef.current) { console.dir(recorderRef.current, "recorderRef.current"); recorderRef.current.start().then(() => { setIsRecording(true); }); } }; /**请求录音权限,Start调用前至少要调用一次RequestPermission**/ var recReq = function (success) { //RecordApp.RequestPermission_H5OpenSet={ audioTrackSet:{ noiseSuppression:true,echoCancellation:true,autoGainControl:true } }; //这个是Start中的audioTrackSet配置,在h5中必须提前配置,因为h5中RequestPermission会直接打开录音 RecordApp.RequestPermission( function () { //注意:有使用到H5录音时,为了获得最佳兼容性,建议RequestPermission、Start至少有一个应当在用户操作(触摸、点击等)下进行调用 success && success(); }, function (msg, isUserNotAllow) { //用户拒绝未授权或不支持 console.log( (isUserNotAllow ? "UserNotAllow," : "") + "无法录音:" + msg ); } ); }; /**开始录音**/ var recStart = function (success) { var processTime = 0; var clearBufferIdx = 0; RealTimeSendReset(); recReq(); setIsRecording(true); //开始录音的参数和Recorder的初始化参数大部分相同 RecordApp.Start( { type: "mp3", sampleRate: 16000, bitRate: 16, //mp3格式,指定采样率hz、比特率kbps,其他参数使用默认配置;注意:是数字的参数必须提供数字,不要用字符串;需要使用的type类型,需提前把格式支持文件加载进来,比如使用wav格式需要提前加载wav.js编码引擎 /*,audioTrackSet:{ //可选,如果需要同时播放声音(比如语音通话),需要打开回声消除(打开后声音可能会从听筒播放,部分环境下(如小程序、uni-app原生接口)可调用接口切换成扬声器外放) //注意:H5中需要在请求录音权限前进行相同配置RecordApp.RequestPermission_H5OpenSet后此配置才会生效 echoCancellation:true,noiseSuppression:true,autoGainControl:true} */ onProcess: function ( buffers, powerLevel, bufferDuration, bufferSampleRate, newBufferIdx, asyncEnd ) { //录音实时回调,大约1秒调用12次本回调,buffers为开始到现在的所有录音pcm数据块(16位小端LE) //可实时上传(发送)数据,可实时绘制波形,ASR语音识别,使用可参考Recorder processTime = Date.now(); for (var i = clearBufferIdx; i < newBufferIdx; i++) { buffers[i] = null; } clearBufferIdx = newBufferIdx; RealTimeSendTry(buffers, bufferSampleRate, false); }, //... 不同环境的专有配置,根据文档按需配置 }, function () { //开始录音成功 success && console.log("开始录音成功"); //【稳如老狗WDT】可选的,监控是否在正常录音有onProcess回调,如果长时间没有回调就代表录音不正常 var this_ = RecordApp; //有this就用this,没有就用一个全局对象 if (RecordApp.Current.CanProcess()) { var wdt = (this_.watchDogTimer = setInterval(function () { if (wdt != this_.watchDogTimer) { clearInterval(wdt); return; } //sync if (Date.now() < this_.wdtPauseT) return; //如果暂停录音了就不检测:puase时赋值this_.wdtPauseT=Date.now()*2(永不监控),resume时赋值this_.wdtPauseT=Date.now()+1000(1秒后再监控) if (Date.now() - (processTime || startTime) > 1500) { clearInterval(wdt); console.error(processTime ? "录音被中断" : "录音未能正常开始"); // ... 错误处理,关闭录音,提醒用户 } }, 1000)); } else { console.warn("当前环境不支持onProcess回调,不启用watchDogTimer"); //目前都支持回调 } var startTime = Date.now(); this_.wdtPauseT = 0; }, function (msg) { console.log("开始录音失败:" + msg); } ); }; //=====实时处理核心函数========== var RealTimeSendTry = function (buffers, bufferSampleRate, isClose) { //提取出新的pcm数据 var pcm = new Int16Array(0); if (buffers.length > 0) { //【关键代码】借用SampleData函数进行数据的连续处理,采样率转换是顺带的,得到新的pcm数据 var chunk = Recorder.SampleData( buffers, bufferSampleRate, testSampleRate, send_chunk ); send_chunk = chunk; pcm = chunk.data; //此时的pcm就是原始的音频16位pcm数据(小端LE),直接保存即为16位pcm文件、加个wav头即为wav文件、丢给mp3编码器转一下码即为mp3文件 send_pcmSampleRate = chunk.sampleRate; //实际转换后的采样率,如果testSampleRate值比录音数据的采样率大,将会使用录音数据的采样率 } //没有指定固定的帧大小,直接把pcm发送出去即可 if (!SendFrameSize) { TransferUpload(pcm, isClose); return; } //先将新的pcm写入缓冲,再按固定大小切分后发送 var pcmBuffer = send_pcmBuffer; var tmp = new Int16Array(pcmBuffer.length + pcm.length); tmp.set(pcmBuffer, 0); tmp.set(pcm, pcmBuffer.length); pcmBuffer = tmp; //循环切分出固定长度的数据帧 var chunkSize = SendFrameSize / (testBitRate / 8); while (true) { //切分出固定长度的一帧数据 if (pcmBuffer.length >= chunkSize) { var frame = new Int16Array(pcmBuffer.subarray(0, chunkSize)); pcmBuffer = new Int16Array(pcmBuffer.subarray(chunkSize)); var closeVal = false; if (isClose && pcmBuffer.length == 0) { closeVal = true; //已关闭录音,且没有剩余要发送的数据了 } TransferUpload(frame, closeVal); if (!closeVal) continue; //循环切分剩余数据 } else if (isClose) { //已关闭录音,但此时结尾剩余的数据不够一帧长度,结尾补0凑够一帧即可,或者直接丢弃结尾的这点数据 var frame = new Int16Array(chunkSize); frame.set(pcmBuffer); pcmBuffer = new Int16Array(0); TransferUpload(frame, true); } break; } //剩余数据存回去,留给下次发送 send_pcmBuffer = pcmBuffer; }; //=====数据传输函数========== var TransferUpload = function (pcmFrame, isClose) { if (isClose && pcmFrame.length == 0) { //最后一帧数据,在没有指定固定的帧大小时,因为不是从onProcess调用的,pcmFrame的长度为0没有数据。可以修改成复杂一点的逻辑:停止录音时不做任何处理,等待下一次onProcess回调时再调用实际的停止录音,这样pcm就一直数据了;或者延迟一帧的发送,isClose时取延迟的这帧作为最后一帧 //这里使用简单的逻辑:直接生成一帧静默的pcm(全0),使用上一帧的长度或50ms长度 //return; //如果不需要处理最后一帧数据,直接return不做任何处理 var len = send_lastFrame ? send_lastFrame.length : Math.round((send_pcmSampleRate / 1000) * 50); pcmFrame = new Int16Array(len); } send_lastFrame = pcmFrame; //*********发送方式一:Base64文本发送*************** var str = "", bytes = new Uint8Array(pcmFrame.buffer); for (var i = 0, L = bytes.length; i < L; i++) str += String.fromCharCode(bytes[i]); var base64 = btoa(str); console.log("发送pcm数据:", wsRef.current); wsRef.current.send(base64); wsRef.current.send("--end--"); //可以实现 //WebSocket send(base64) ... //WebRTC send(base64) ... //XMLHttpRequest send(base64) ... //*********发送方式二:直接ArrayBuffer二进制发送*************** var arrayBuffer = pcmFrame.buffer; //可以实现 //WebSocket send(arrayBuffer) ... //WebRTC send(arrayBuffer) ... //XMLHttpRequest send(arrayBuffer) ... //****这里仅显示一个日志 意思意思**** //最后一次调用发送,此时的pcmFrame可以认为是最后一帧 if (isClose) { console.log("已停止传输"); } }; /**停止录音,清理资源**/ var recStop = function () { var this_ = RecordApp; this_.watchDogTimer = 0; //停止监控onProcess超时 RecordApp.Stop( function (arrayBuffer, duration, mime) { //arrayBuffer就是录音文件的二进制数据,不同平台环境下均可进行播放、上传 console.log(arrayBuffer, mime, "时长:" + duration + "ms"); // console.dir(arrayBuffer,'arrayBuffer.buffer') //如果当前环境支持Blob,也可以直接构造成Blob文件对象,和Recorder使用一致 if (typeof Blob != "undefined" && typeof window == "object") { var blob = new Blob([arrayBuffer], { type: mime }); console.log(blob, (window.URL || webkitURL).createObjectURL(blob)); } }, function (msg) { console.log("录音失败:" + msg); } ); }; const stopRecording = () => { if (recorderRef.current) { recorderRef.current.stop().then((audioBuffer) => { setIsRecording(false); // 将音频数据通过 WebSocket 发送给讯飞服务器 sendAudioData(audioBuffer); }); } }; const sendAudioData = (audioBuffer) => { // 将音频数据转换为需要的格式(例如 PCM 编码) const pcmData = audioBuffer.getChannelData(0); // 获取左声道的数据 const pcmBuffer = new ArrayBuffer(pcmData.length * 2); const pcmView = new DataView(pcmBuffer); for (let i = 0; i < pcmData.length; i++) { pcmView.setInt16(i * 2, pcmData[i] * 32767, true); } // 通过 WebSocket 发送数据 if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.send(pcmBuffer); } }; const initRecorder = () => { recorderRef.current = new Recorder({ sampleRate: 16000, // 设置采样率为16kHz bitRate: 16, // 16位采样深度 numberOfChannels: 1, // 单声道 }); console.log(window, "Windows"); }; useEffect(() => { initRecorder(); }, []); return ( <div> <h2>语音录音</h2> <button onClick={isRecording ? recStop : recStart}> {isRecording ? "停止录音" : "开始录音"} </button> <div style={{width:'350px',height:'350px'}}> </div> </div> ); }; export default AudioRecorder;

如果有任何问题,欢迎在评论区留言

原文:https://juejin.cn/post/7491116054372188169

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐