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;
如果有任何问题,欢迎在评论区留言