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

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

前言

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

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

讯飞AIUI官网入口

环境准备

首先,你需要安装 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;

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

相关推荐
葡萄城技术团队1 小时前
基于前端技术的QR码API开发实战:从原理到部署
前端
八了个戒3 小时前
「数据可视化 D3系列」入门第三章:深入理解 Update-Enter-Exit 模式
开发语言·前端·javascript·数据可视化
noravinsc3 小时前
html页面打开后中文乱码
前端·html
小满zs4 小时前
React-router v7 第四章(路由传参)
前端·react.js
小陈同学呦4 小时前
聊聊双列瀑布流
前端·javascript·面试
来自星星的坤4 小时前
SpringBoot 与 Vue3 实现前后端互联全解析
后端·ajax·前端框架·vue·springboot
键指江湖4 小时前
React 在组件间共享状态
前端·javascript·react.js
诸葛亮的芭蕉扇5 小时前
D3路网图技术文档
前端·javascript·vue.js·microsoft
小离a_a5 小时前
小程序css实现容器内 数据滚动 无缝衔接 点击暂停
前端·css·小程序
徐小夕5 小时前
花了2个月时间研究了市面上的4款开源表格组件,崩溃了,决定自己写一款
前端·javascript·react.js