草庐IT

第五十四周总结——WebRTC录制音频

抓住鼹鼠不撒手 2023-04-19 原文

云文档开发笔记-录制音频

我首先想到的就是使用WebRTC,如果使用WebRTC我们首先要请求麦克风权限。

window.navigator.mediaDevices.getUserMedia({
  audio: true
}).then(mediaStream => {
  beginRecord(mediaStream);
}).catch(err => {
    console.log(err)
});

这里输出的mediaStream其实就可以直接传递给audio标签的src属性使用。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <audio id="local-audio" autoplay controls>播放麦克风捕获的声音</audio>
  <button id="playAudio">打开麦克风</button>
  <script>
    document.getElementById('playAudio').addEventListener('click',()=>{
      window.navigator.mediaDevices.getUserMedia({
        audio: true
      }).then(mediaStream => {
        document.getElementById('local-audio').srcObject = mediaStream;
      }).catch(err => {
        console.log(err);
      });
    })
  </script>
</body>
</html>

我们看上面的代码,当我们点击打开麦克风的时候,就可以使audio标签一直捕获我们的声音播放,但是问题就是,如果没戴耳机的情况下会有回音,而且,我们要做的肯定也不是直接捕获播放,而是要收集起来,点击播放的时候再播放。

当我们获取得到mediaStream数据后我们也可以采取下面的方法直接播放
audioContext.createMediaStreamSource需要传入一个媒体流,然后对音频执行播放的操作。

let audioContext = new (window.AudioContext || window.webkitAudioContext);
let mediaNode = audioContext.createMediaStreamSource(mediaStream);
mediaNode.connect(audioContext.destination);

保存音频信息的话,我采用了PCM格式,音频的信息就相当于一连串的电信号变化,有许多[-1,1]之间的数字组成的波。如果需要播放就要转成PCM格式。

gerUserMedia传递mediaStream
AudioContext
使用onaudioprocess来监听音频信息
加头部文件
麦克风
webAudio解析数据
采集数据
采样PCM缓存
转换成WAV格式

我们利用audioContext.createScriptProcessor来创建缓存节点,

//创建AudioContext,将获取麦克风权限的stream传递给AudioContext
//并创建jsNode用来收集信息,将jsNode连接
//开始录音,调用该函数,将recorder函数返回的msg传递进去
beginRecord(mediaStream:MediaStream){
  let audioContext = new window.AudioContext;
  let mediaNode = audioContext.createMediaStreamSource(mediaStream);
  // 创建一个jsNode
  let jsNode = this.createJSNode(audioContext);
  this.jsNodes = jsNode;
  // 需要连到扬声器消费掉outputBuffer,process回调才能触发
  // 并且由于不给outputBuffer设置内容,所以扬声器不会播放出声音
  jsNode.connect(audioContext.destination);
  jsNode.onaudioprocess = this.onAudioProcess;
  // 把mediaNode连接到jsNode
  mediaNode.connect(jsNode);
}

//创建jsNode
createJSNode (audioContext:AudioContext) {
  const BUFFER_SIZE = 4096;
  const INPUT_CHANNEL_COUNT = 2;
  const OUTPUT_CHANNEL_COUNT = 2;
  // createJavaScriptNode已被废弃
  //@ts-ignore
  let creator = audioContext.createScriptProcessor || audioContext.createJavaScriptNode;
  creator = creator.bind(audioContext);
  return creator(BUFFER_SIZE,INPUT_CHANNEL_COUNT, OUTPUT_CHANNEL_COUNT);
}

我们主要使用onaudioprocess来监听音频信息。

//收集录音信息,大概0.09s调用一次
onAudioProcess (event:any) {
  let audioBuffer = event.inputBuffer;
  //左声道
  let leftChannelData = audioBuffer.getChannelData(0);
  //右声道
  let rightChannelData = audioBuffer.getChannelData(1);
  leftDataList.push([...leftChannelData]);
  rightDataList.push([...rightChannelData]);
}

这里在全局定义leftChannelDatarightChannelData两个数组,来缓存音频信息,大概每0.09s调用一下onAudioProcess函数。

因为我们在上面向leftChannelDatarightChannelDatapush的是数组,所以我们要先将leftChannelDatarightChannelData扁平化,合并成一个Float32Array数组。

//停止录音
stopRecord () {
  //合并左右声道
  let leftData = this.mergeArray(leftDataList),
      rightData = this.mergeArray(rightDataList);
  //交叉合并左右声道
  let allData = this.interleaveLeftAndRight(leftData, rightData);
  let wavBuffer = this.createWavFile(allData);
  return this.playRecord(wavBuffer);
}
//合并左声道和右声道
mergeArray (list:any[]) {
  let length = list.length * list[0].length;
  let data = new Float32Array(length),
      offset = 0;
  for (let i = 0; i < list.length; i++) {
      data.set(list[i], offset);
      offset += list[i].length;
  }
  return data;
}

我们将还要将左右声道交叉合并。

//交叉合并左右声道
interleaveLeftAndRight (left:Float32Array, right:Float32Array) {
  let totalLength = left.length + right.length;
  let data = new Float32Array(totalLength);
  for (let i = 0; i < left.length; i++) {
      let k = i * 2;
      data[k] = left[i];
      data[k + 1] = right[i];
  }
  return data;
}

此时我们就可以创建一个WAV文件了。

我们先写入WAV文件固定的头部。

createWavFile (audioData:Float32Array) {
  const WAV_HEAD_SIZE = 44;
  let buffer = new ArrayBuffer(audioData.length * 2 + WAV_HEAD_SIZE),
      view = new DataView(buffer);
  this.writeUTFBytes(view, 0, 'RIFF');
  view.setUint32(4, 44 + audioData.length * 2, true);
  this.writeUTFBytes(view, 8, 'WAVE');
  this.writeUTFBytes(view, 12, 'fmt ');
  view.setUint32(16, 16, true);
  view.setUint16(20, 1, true);
  view.setUint16(22, 2, true);
  view.setUint32(24, 44100, true);
  view.setUint32(28, 44100 * 2, true);
  view.setUint16(32, 2 * 2, true);
  view.setUint16(34, 16, true);
  this.writeUTFBytes(view, 36, 'data');
  view.setUint32(40, audioData.length * 2, true);

  // 写入PCM数据
  let length = audioData.length;
  let index = 44;
  let volume = 1;
  for (let i = 0; i < length; i++) {
      view.setInt16(index, audioData[i] * (0x7FFF * volume), true);
      index += 2;
  }
  return buffer;
}

writeUTFBytes (view:DataView, offset:number, string:string) {
  var lng = string.length;
  for (var i = 0; i < lng; i++) { 
      view.setUint8(offset + i, string.charCodeAt(i));
  }
}

最后写入刚刚录制的音频数据,我们采用16位二进制来表示声音的强弱,16位表示的范围是[-32768, +32767],最大值是32767即0x7FFF,录音数据的取值范围是[-1, 1]。

function createWavFile (audioData) {
    // 写入wav头部,代码同上
    // 写入PCM数据
    let length = audioData.length;
    let index = 44;
    let volume = 1;
    for (let i = 0; i < length; i++) {
        view.setInt16(index, audioData[i] * (0x7FFF * volume), true);
        index += 2;
    }
    return buffer;
}

最后生成本地的blob url返回。

//返回src
playRecord (arrayBuffer:ArrayBuffer) {
  let blob = new Blob([new Uint8Array(arrayBuffer)]);
  let blobUrl = URL.createObjectURL(blob);
  return blobUrl;
}

完整代码

enum status {
  success = 200,
  error = 500
}

let leftDataList:any[] = [];
let rightDataList:any[] = [];

//录音
export class Audio{
  static instance:any;
  public mediaStreams:MediaStream | undefined;
  public jsNodes:ScriptProcessorNode | undefined;

  constructor(){}

  //初始化,单例模式
  static init():Audio{
    if(!this.instance){
      this.instance = new Audio();
    }
    return this.instance;
  }

  //获取麦克风权限
  recorder () {
    return new Promise<{code: status,msg: MediaStream}>((resolve,reject)=>{
      window.navigator.mediaDevices.getUserMedia({
        audio: true
      }).then(mediaStream => {
        this.mediaStreams = mediaStream;
        resolve({
          code: status.success,
          msg: mediaStream
        });
      }).catch(err => {
          reject({
            code: status.error,
            msg: err
          })
      });
    })
  }

  //创建AudioContext,将获取麦克风权限的stream传递给AudioContext
  //并创建jsNode用来收集信息,将jsNode连接
  //开始录音,调用该函数,将recorder函数返回的msg传递进去
  beginRecord(mediaStream:MediaStream){
    let audioContext = new window.AudioContext;
    let mediaNode = audioContext.createMediaStreamSource(mediaStream);
    // 创建一个jsNode
    let jsNode = this.createJSNode(audioContext);
    this.jsNodes = jsNode;
    // 需要连到扬声器消费掉outputBuffer,process回调才能触发
    // 并且由于不给outputBuffer设置内容,所以扬声器不会播放出声音
    jsNode.connect(audioContext.destination);
    jsNode.onaudioprocess = this.onAudioProcess;
    // 把mediaNode连接到jsNode
    mediaNode.connect(jsNode);
  }

  //创建jsNode
  createJSNode (audioContext:AudioContext) {
    const BUFFER_SIZE = 4096;
    const INPUT_CHANNEL_COUNT = 2;
    const OUTPUT_CHANNEL_COUNT = 2;
    // createJavaScriptNode已被废弃
    //@ts-ignore
    let creator = audioContext.createScriptProcessor || audioContext.createJavaScriptNode;
    creator = creator.bind(audioContext);
    return creator(BUFFER_SIZE,INPUT_CHANNEL_COUNT, OUTPUT_CHANNEL_COUNT);
  }

  //收集录音信息,大概0.09s调用一次
  onAudioProcess (event:any) {
    let audioBuffer = event.inputBuffer;
    //左声道
    let leftChannelData = audioBuffer.getChannelData(0);
    //右声道
    let rightChannelData = audioBuffer.getChannelData(1);
    leftDataList.push([...leftChannelData]);
    rightDataList.push([...rightChannelData]);
  }

  //停止录音
  stopRecord () {
    //合并左右声道
    let leftData = this.mergeArray(leftDataList),
        rightData = this.mergeArray(rightDataList);
    //交叉合并左右声道
    let allData = this.interleaveLeftAndRight(leftData, rightData);
    let wavBuffer = this.createWavFile(allData);
    return this.playRecord(wavBuffer);
  }

  //返回src
  playRecord (arrayBuffer:ArrayBuffer) {
    let blob = new Blob([new Uint8Array(arrayBuffer)]);
    let blobUrl = URL.createObjectURL(blob);
    return blobUrl;
  }

  //合并左声道和右声道
  mergeArray (list:any[]) {
    let length = list.length * list[0].length;
    let data = new Float32Array(length),
        offset = 0;
    for (let i = 0; i < list.length; i++) {
        data.set(list[i], offset);
        offset += list[i].length;
    }
    return data;
  }

  //交叉合并左右声道
  interleaveLeftAndRight (left:Float32Array, right:Float32Array) {
    let totalLength = left.length + right.length;
    let data = new Float32Array(totalLength);
    for (let i = 0; i < left.length; i++) {
        let k = i * 2;
        data[k] = left[i];
        data[k + 1] = right[i];
    }
    return data;
  }

  //将PCM数据转换成wav
  createWavFile (audioData:Float32Array) {
    const WAV_HEAD_SIZE = 44;
    let buffer = new ArrayBuffer(audioData.length * 2 + WAV_HEAD_SIZE),
        view = new DataView(buffer);
    this.writeUTFBytes(view, 0, 'RIFF');
    view.setUint32(4, 44 + audioData.length * 2, true);
    this.writeUTFBytes(view, 8, 'WAVE');
    this.writeUTFBytes(view, 12, 'fmt ');
    view.setUint32(16, 16, true);
    view.setUint16(20, 1, true);
    view.setUint16(22, 2, true);
    view.setUint32(24, 44100, true);
    view.setUint32(28, 44100 * 2, true);
    view.setUint16(32, 2 * 2, true);
    view.setUint16(34, 16, true);
    this.writeUTFBytes(view, 36, 'data');
    view.setUint32(40, audioData.length * 2, true);
  
    // 写入PCM数据
    let length = audioData.length;
    let index = 44;
    let volume = 1;
    for (let i = 0; i < length; i++) {
        view.setInt16(index, audioData[i] * (0x7FFF * volume), true);
        index += 2;
    }
    return buffer;
  }

  writeUTFBytes (view:DataView, offset:number, string:string) {
    var lng = string.length;
    for (var i = 0; i < lng; i++) { 
        view.setUint8(offset + i, string.charCodeAt(i));
    }
  }

}

在React中调用

import { useEffect, useRef } from 'react';
//Audio就是上面的完整代码
import { Audio } from '../../../utils/audio';

export default function ContentAudio() {

  const audioRef = useRef(null);

  useEffect(() => {
    (async function fn(){
      let audio = Audio.init();
      //获取麦克风权限
      let recorder = await audio.recorder();
      //开始录音
      audio.beginRecord(recorder.msg);
      setTimeout(()=>{
        console.log('停止录音');
        let url = audio.stopRecord();
        //@ts-ignore
        audioRef.current.src = url
      },1000)
    })()
  }, [])
  return (
    <div>
      <audio ref={audioRef} src="" id="audio" controls autoPlay></audio>
    </div>
  )
}

参考资料

[1]前端webrtc基础 —— 录音篇
[2]如何实现前端录音功能

有关第五十四周总结——WebRTC录制音频的更多相关文章

  1. 屏幕录制为什么没声音?检查这2项,轻松解决 - 2

    相信很多人在录制视频的时候都会遇到各种各样的问题,比如录制的视频没有声音。屏幕录制为什么没声音?今天小编就和大家分享一下如何录制音画同步视频的具体操作方法。如果你有录制的视频没有声音,你可以试试这个方法。 一、检查是否打开电脑系统声音相信很多小伙伴在录制视频后会发现录制的视频没有声音,屏幕录制为什么没声音?如果当时没有打开音频录制,则录制好的视频是没有声音的。因此,建议在录制前进行检查。屏幕上没有声音,很可能是因为你的电脑系统的声音被禁止了。您只需打开电脑系统的声音,即可录制音频和图画同步视频。操作方法:步骤1:点击电脑屏幕右下侧的“小喇叭”图案,在上方的选项中,选择“声音”。 步骤2:在“声

  2. SPI接收数据异常问题总结 - 2

    SPI接收数据左移一位问题目录SPI接收数据左移一位问题一、问题描述二、问题分析三、探究原理四、经验总结最近在工作在学习调试SPI的过程中遇到一个问题——接收数据整体向左移了一位(1bit)。SPI数据收发是数据交换,因此接收数据时从第二个字节开始才是有效数据,也就是数据整体向右移一个字节(1byte)。请教前辈之后也没有得到解决,通过在网上查阅前人经验终于解决问题,所以写一个避坑经验总结。实际背景:MCU与一款芯片使用spi通信,MCU作为主机,芯片作为从机。这款芯片采用的是它规定的六线SPI,多了两根线:RDY和INT,这样从机就可以主动请求主机给主机发送数据了。一、问题描述根据从机芯片手

  3. Simulink方法总结和避坑指南(一)——Simulink入门与基本调试方法 - 2

    文章目录一、项目场景二、基本模块原理与调试方法分析——信源部分:三、信号处理部分和显示部分:四、基本的通信链路搭建:四、特殊模块:interpretedMATLABfunction:五、总结和坑点提醒一、项目场景  最近一个任务是使用simulink搭建一个MIMO串扰消除的链路,并用实际收到的数据进行测试,在搭建的过程中也遇到了不少的问题(当然这比vivado里面的debug好不知道多少倍)。准备趁着这个机会,先以一个很基本的通信链路对simulink基础和相关的debug方法进行总结。  在本篇中,主要记录simulink的基本原理和基本的SISO通信传输链路(QPSK方式),计划在下篇记

  4. micropython复现经典单片机项目(二)可视化音频 频谱解析(基本搞定) - 2

    本人是音乐爱好者,从小就特别喜欢那个随着音乐跳动的方框效果,就是这个:arduino上一大把对,我忍你很久了,我就想用mpy做,全网没有,行我自己研究。果然兴趣是最好的老师,我之前有篇博客专门讲音频,有兴趣的可以回顾一下。提到可视化频谱,必然绕不开fft,大学学过这玩意,当时一心玩,老师讲的一个字都么听进去,网上教程简略扫了一下,大该就是把时域转频域的工具,我大mpy居然没有fft函数,奶奶的,先放着。音频信息如何收集?第一种傻瓜式的ADC,模拟转数字,原始粗暴,第二种,I2S库,我之前博客有讲过,数据是PCM编码。然后又去学PCM编码,一学豁然开朗,舒服,以代码为例:audio_in=I2S

  5. ruby-on-rails - 如何从服务器目录制作 Paperclip 进程文件? - 2

    我想对服务器目录中的所有文件运行Paperclip。基本上,我想允许用户将一些文件通过FTP传输到我的网络服务器,然后我可以手动运行rake任务让Paperclip处理所有文件(调整图像大小、更新数据库等)。我该怎么做? 最佳答案 我不确定我是否理解您的问题-您是在询问远程运行rake任务还是如何导入图像?在后一种情况下有一个答案。首先你需要一些模型来保存图像和一些其他数据,像这样:classPicture{:thumb=>"100x100>",:big=>"500x500>"}end您可以在lib/tasks文件夹中创建简单的ra

  6. 【动态规划】背包问题(详细总结,很全) - 2

    【动态规划】一、背包问题1.背包问题总结1)动规四部曲:2)递推公式总结:3)遍历顺序总结:2.01背包1)二维dp数组代码实现2)一维dp数组代码实现3.完全背包代码实现4.多重背包代码实现一、背包问题1.背包问题总结暴力的解法是指数级别的时间复杂度。进而才需要动态规划的解法来进行优化!背包问题是动态规划(DynamicPlanning)里的非常重要的一部分,关于几种常见的背包,其关系如下:在解决背包问题的时候,我们通常都是按照如下五部来逐步分析,把这五部都搞透了,算是对动规来理解深入了。1)动规四部曲:(1)确定dp数组及其下标的含义(2)确定递推公式(3)dp数组的初始化(4)确定遍历顺

  7. 解决台式机麦克风不可用问题,只有音频输出,无音频输入 - 2

    解决台式机麦克风不可用问题戴尔灵越3880最近因为需要开线上会议,发现戴尔台式机音频只有输出没有输入,也就是只能听见声音,无法输入声音。先后尝试了各种驱动安装更新之类的调试,无果。之后通过戴尔支持解决~这里多说一句,专业的就是专业,问题描述过去,直接给了解决方案,可能是他们遇到的相似问题比较多了,但也告诉我们,有些时候是可以通过这些官方服务解决问题的,比起自己折腾效率要高很多。那就记录一下吧~问题描述:电脑只能输出声音,不能输入声音。1、前提需要准备一只带麦克风的耳机,将耳机插入面板。2、先确定是否可以听到声音,可以通过播放歌曲或者视频。3、然后确认麦克风是否可用,可以通过调用win自带麦克风

  8. ruby - 我将如何以编程方式与 VST(i) 插件交互以合成音频? - 2

    以VSTiTriforce为例,由Tweakbench提供。当加载到市场上的任何VST主机时,它允许主机向VSTi发送(大概是MIDI)信号。然后VSTi将处理该信号并输出​​由VSTi内的软件乐器创建的合成音频。例如,将A4(我相信是MIDI音符)发送到VSTi会导致它合成高于中央C的A。它将音频数据发送回VST主机,然后它可以在我的扬声器上播放或将其保存为.wav或其他一些音频文件格式。假设我有Triforce,我正在尝试用我选择的语言编写一个程序,它可以通过发送要合成的A4纸条与VSTi交互,并自动将其保存到系统上的文件?最终,我希望能够解析整个单轨MIDI文件(使用已经可用于此

  9. 相机面试问题总结 - 2

    1,Camera基本工作原理答案:光线通过镜头Lens进入摄像头内部,然后经过IRFilter过滤红外光,最后到达sensor(传感器),senor分为按照材质可以分为CMOS和CCD两种,可以将光学信号转换为电信号,再通过内部的ADC电路转换为数字信号,然后传输给DSP(如果有的话,如果没有则以DVP的方式传送数据到基带芯片baseband,此时的数据格式RawData,后面有讲进行加工)加工处理,转换成RGB、YUV等格式输出。数据流是如何从sensor到APP的?上述描述结束后,在ISP处理后面的阶段,数据会进行分流,分为capture,preview,video等以供后续动作使用。例如

  10. ruby - 在 ruby​​ 中使用 PortAudio 包装器将声音录制到 .wav - 2

    我最近一直在研究ruby​​,我决定开始一个简单的项目来编写一个ruby​​脚本,将线路输入声音记录到.wav文件中。我发现ruby​​不能很好地访问硬件设备(它可能不应该),但是PortAudio可以,而且我发现了一个很棒的PA包装器here(它不是gem,我认为是因为它使用ruby​​的ffi附加到PortAudio,而且PA库可能在很多地方)。我一直在摸索PortAudio的文档和示例以了解PA的工作原理。我已经很多年没有写过或读过C了。我在创建过程中应该将哪些参数传递给流以及在创建过程中传递给缓冲区时遇到了困难。例如,frame到底是什么,它与channel和samplerat

随机推荐