web如何实现录制音频,满满干货(下篇)

上篇中讲了,web如何实现录制音频,这一篇中,介绍如何播放录制好的音频,以及如何下载和上传音频。

上篇的文章链接: juejin.cn/spost/73115...

播放

播放,其实就有很多种方法了,可以先上传到云服务器,然后生成链接,使用audio标签进行播放;当然录制完成之后,没有上传之前,也是可以播放的。

获取录制数据

录制中的时候,数据全部存储为this.lBuffer和this.rBuffer,现在就可以使用,不过,当初存储一个怎样的数据呢?先来回顾一下

js 复制代码
// 左声道数据
      // getChannelData返回Float32Array类型的pcm数据
      let lData = e.inputBuffer.getChannelData(0),
        rData = null,
        vol = 0; // 音量百分比
      // console.log(lData)
      this.lBuffer.push(new Float32Array(lData));

      this.size += lData.length;

      // 判断是否有右声道数据
      if (this.config.numChannels === 2) {
        rData = e.inputBuffer.getChannelData(1);
        this.rBuffer.push(new Float32Array(rData));

        this.size += rData.length;
      }

Float32Array,是使用数组来存一个一个Float32Array数组的,所以,现在获取所有的Float32Array数据,需要先把二维数组,转换为一维数组。

js 复制代码
/**
   * 将二维数组转一维
   *
   * @private
   * @returns  {float32array}     音频pcm二进制数据
   * @memberof Recorder
   */
  flat() {
    let lData = null,
      rData = new Float32Array(0); // 右声道默认为0

    // 创建存放数据的容器
    if (this.config.numChannels === 1) {
      lData = new Float32Array(this.size);
    } else {
      lData = new Float32Array(this.size / 2);
      rData = new Float32Array(this.size / 2);
    }
    // 合并
    let offset = 0; // 偏移量计算
    // 将二维数据,转成一维数据
    // 左声道
    this.lBuffer.forEach(buffer => {
      lData.set(buffer, offset);
      offset += buffer.length;
    });

    // 右声道
    offset = 0;
    this.rBuffer.forEach(buffer => {
      rData.set(buffer, offset);
      offset += buffer.length;
    });

    return {
      left: lData,
      right: rData
    };
  }
js 复制代码
// 获取录音数据
  getData() {
    return this.flat();
  }

数据合并压缩

根据输入和输出的采样率压缩数据,比如输入的采样率是48k的,我们需要的是(输出)的是16k的,由于48k与16k是3倍关系,所以输入数据中每隔3取1位

js 复制代码
/**
 * 数据合并压缩
 * 根据输入和输出的采样率压缩数据,
 * 比如输入的采样率是48k的,我们需要的是(输出)的是16k的,由于48k与16k是3倍关系,
 * 所以输入数据中每隔3取1位
 *
 * @param {float32array} data       [-1, 1]的pcm数据
 * @param {number} inputSampleRate  输入采样率
 * @param {number} outputSampleRate 输出采样率
 * @returns  {float32array}         压缩处理后的二进制数据
 */
export function compress(data, inputSampleRate, outputSampleRate) {
  // 压缩,根据采样率进行压缩
  let rate = inputSampleRate / outputSampleRate,
    compression = Math.max(rate, 1),
    lData = data.left,
    rData = data.right,
    length = Math.floor((lData.length + rData.length) / rate),
    result = new Float32Array(length),
    index = 0,
    j = 0;

  // 循环间隔 compression 位取一位数据
  while (index < length) {
    // 取整是因为存在比例compression不是整数的情况
    let temp = Math.floor(j);

    result[index] = lData[temp];
    index++;

    if (rData.length) {
      /*
       * 双声道处理
       * e.inputBuffer.getChannelData(0)得到了左声道4096个样本数据,1是右声道的数据,
       * 此处需要组和成LRLRLR这种格式,才能正常播放,所以要处理下
       */
      result[index] = rData[temp];
      index++;
    }

    j += compression;
  }
  // 返回压缩后的一维数据
  return result;
}

如果是双声道,那就需要特殊处理,e.inputBuffer.getChannelData(0)得到了左声道4096个样本数据,1是右声道的数据,此处需要组和成LRLRLR这种格式,才能正常播放。

我的电脑上,输入和输出的采样率是一样的,所以都是1

转换对应格式编码

按采样位数重新编码

js 复制代码
/**
 * 转换到我们需要的对应格式的编码
 *
 * @param {Float32Array} bytes      pcm二进制数据
 * @param {number}  sampleBits      采样位数
 * @param {boolean} littleEdian     是否是小端字节序
 * @returns {dataview}              pcm二进制数据
 */
export function encodePCM(bytes, sampleBits, littleEdian = true) {
  let offset = 0,
    dataLength = bytes.length * (sampleBits / 8),
    buffer = new ArrayBuffer(dataLength),
    data = new DataView(buffer);

  // 写入采样数据
  if (sampleBits === 8) {
    for (let i = 0; i < bytes.length; i++, offset++) {
      // 范围[-1, 1]
      let s = Math.max(-1, Math.min(1, bytes[i]));
      // 8位采样位划分成2^8=256份,它的范围是0-255;
      // 对于8位的话,负数*128,正数*127,然后整体向上平移128(+128),即可得到[0,255]范围的数据。
      let val = s < 0 ? s * 128 : s * 127;
      val = +val + 128;
      data.setInt8(offset, val);
    }
  } else {
    for (let i = 0; i < bytes.length; i++, offset += 2) {
      let s = Math.max(-1, Math.min(1, bytes[i]));
      // 16位的划分的是2^16=65536份,范围是-32768到32767
      // 因为我们收集的数据范围在[-1,1],那么你想转换成16位的话,只需要对负数*32768,对正数*32767,即可得到范围在[-32768,32767]的数据。
      data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, littleEdian);
    }
  }

  return data;
}

这里有一个判断是否小端字节序

那什么是字节序,简单来说,就是超过一个字节的数据类型在内存中的存储顺序。目前有两种字节序,大端字节序和小端字节序。详细介绍可以看下面的文章:

blog.csdn.net/damanchen/a...

阮一峰老师的:

www.ruanyifeng.com/blog/2016/1...

在windows平台上是小端字节序(Windos(x86,x64)和Linux(x86,x64)都是Little Endian操作系统,所以默认小端字节序为true。

PCM数据

获取到PCM数据,就是要经历上面的步骤,合并压缩,格式编码

js 复制代码
getPCM() {
    // 先停止
    this.stop();
    // 获取pcm数据
    let data = this.getData();
    // 根据输入输出比例 压缩或扩展
    data = compress(data, this.inputSampleRate, this.outputSampleRate);
    // 按采样位数重新编码
    return encodePCM(data, this.oututSampleBits, this.littleEdian);
  }

WAV编码

编码wav,一般wav格式是在pcm文件前增加44个字节的文件头,所以,此处只需要在pcm数据前增加下就行了。

js 复制代码
/**
 * 编码wav,一般wav格式是在pcm文件前增加44个字节的文件头,
 * 所以,此处只需要在pcm数据前增加下就行了。
 *
 * @param {DataView} bytes           pcm二进制数据
 * @param {number}  inputSampleRate  输入采样率
 * @param {number}  outputSampleRate 输出采样率
 * @param {number}  numChannels      声道数
 * @param {number}  oututSampleBits  输出采样位数
 * @param {boolean} littleEdian      是否是小端字节序
 * @returns {DataView}               wav二进制数据
 */
export function encodeWAV(bytes, inputSampleRate, outputSampleRate, numChannels, oututSampleBits, littleEdian = true) {
  let sampleRate = outputSampleRate > inputSampleRate ? inputSampleRate : outputSampleRate, // 输出采样率较大时,仍使用输入的值,
    sampleBits = oututSampleBits,
    buffer = new ArrayBuffer(44 + bytes.byteLength),
    data = new DataView(buffer),
    channelCount = numChannels, // 声道
    offset = 0;

  // 资源交换文件标识符
  writeString(data, offset, 'RIFF');
  offset += 4;
  // 下个地址开始到文件尾总字节数,即文件大小-8
  data.setUint32(offset, 36 + bytes.byteLength, littleEdian);
  offset += 4;
  // WAV文件标志
  writeString(data, offset, 'WAVE');
  offset += 4;
  // 波形格式标志
  writeString(data, offset, 'fmt ');
  offset += 4;
  // 过滤字节,一般为 0x10 = 16
  data.setUint32(offset, 16, littleEdian);
  offset += 4;
  // 格式类别 (PCM形式采样数据)
  data.setUint16(offset, 1, littleEdian);
  offset += 2;
  // 声道数
  data.setUint16(offset, channelCount, littleEdian);
  offset += 2;
  // 采样率,每秒样本数,表示每个通道的播放速度
  data.setUint32(offset, sampleRate, littleEdian);
  offset += 4;
  // 波形数据传输率 (每秒平均字节数) 声道数 × 采样频率 × 采样位数 / 8
  data.setUint32(offset, channelCount * sampleRate * (sampleBits / 8), littleEdian);
  offset += 4;
  // 快数据调整数 采样一次占用字节数 声道数 × 采样位数 / 8
  data.setUint16(offset, channelCount * (sampleBits / 8), littleEdian);
  offset += 2;
  // 采样位数
  data.setUint16(offset, sampleBits, littleEdian);
  offset += 2;
  // 数据标识符
  writeString(data, offset, 'data');
  offset += 4;
  // 采样数据总数,即数据总大小-44
  data.setUint32(offset, bytes.byteLength, littleEdian);
  offset += 4;

  // 给wav头增加pcm体
  for (let i = 0; i < bytes.byteLength;) {
    data.setUint8(offset, bytes.getUint8(i));
    offset++;
    i++;
  }

  return data;
}
/**
   * 获取WAV编码的二进制数据(dataview)
   *
   * @returns {dataview}  WAV编码的二进制数据
   * @memberof Recorder
   */
  getWAV() {
    let pcmTemp = this.getPCM();

    // PCM增加44字节的头就是WAV格式了
    return encodeWAV(pcmTemp, this.inputSampleRate,
      this.outputSampleRate, this.config.numChannels, this.oututSampleBits, this.littleEdian);;
  }

开始播放录音

上面拿到WAV数据之后,就可以进行播放了,播放使用window.AudioContext对象。

developer.mozilla.org/zh-CN/docs/...

js 复制代码
let audioData = this.getWAV();
let context = null;
let analyser = null;


/**
 * 初始化
 */
function init() {
  context = new(window.AudioContext || window.webkitAudioContext)();
  analyser = context.createAnalyser();
  analyser.fftSize = 2048; // 表示存储频域的大小
}

/**
 * play
 * @returns {Promise<{}>}
 */
function playAudio() {
  isPaused = false;

  return context.decodeAudioData(audioData.slice(0), buffer => {
    source = context.createBufferSource();

    // 播放结束的事件绑定
    source.onended = () => {
      if (!isPaused) { // 暂停的时候也会触发该事件
        // 计算音频总时长
        totalTime = context.currentTime - playStamp + playTime;
        endplayFn();
      }

    }

    // 设置数据
    source.buffer = buffer;
    // connect到分析器,还是用录音的,因为播放时不能录音的
    source.connect(analyser);
    analyser.connect(context.destination);
    source.start(0, playTime); // 开始播放

    // 记录当前的时间戳,以备暂停时使用
    playStamp = context.currentTime;
  }, function (e) {
    throwError(e);
  });
}

AudioContext接口的 decodeAudioData() 方法可用于异步解码音频文件中的 ArrayBuffer。ArrayBuffer 数据可以通过 XMLHttpRequestFileReader 来获取。AudioBuffer 是通过 AudioContext 采样率进行解码的,然后通过回调返回结果。

暂停播放

点击暂停之后,又触发暂停,所以需要获取到最新一次暂停的时间戳

js 复制代码
/**
   * 暂停播放录音
   * @memberof Player
   */
  function pausePlay() {
    destroySource();
    // 多次暂停需要累加
    playTime += context.currentTime - playStamp;
    isPaused = true;
  }

恢复播放

播放的时候,记录了播放的时间戳,就是为了恢复播放的时候使用

js 复制代码
/**
   * 暂停播放录音
   * @memberof Player
   */
  function pausePlay() {
    destroySource();
    // 多次暂停需要累加
    playTime += context.currentTime - playStamp;
    isPaused = true;
  }

结束播放

js 复制代码
/**
   * 停止播放
   * @memberof Player
   */
  function stopPlay() {
    playTime = 0;
    audioData = null;

    destroySource();
  }

// 销毁source, 由于 decodeAudioData 产生的source每次停止后就不能使用,所以暂停也意味着销毁,下次需重新启动。
function destroySource() {
  if (source) {
    source.stop();
    source = null;
  }
}

下载

其实上面已经拿到WAV数据了,就很好实现下载了。

下载就是创建一个a标签,实现下载功能,拿到Blob数据之后,就可以直接调用下面方法

通用下载方法

js 复制代码
/**
 * 下载录音文件
 * @private
 * @param {*} blob      blob数据
 * @param {string} name 下载的文件名
 * @param {string} type 下载的文件后缀
 */
function _download(blob, name, type) {
  let oA = document.createElement('a');

  oA.href = window.URL.createObjectURL(blob);
  oA.download = `${ name }.${ type }`;
  oA.click();
}

mav&pcm下载

下载格式,可以是wav或者pcm

一般wav格式是在pcm文件前增加44个字节的文件头

js 复制代码
/**
 * 下载录音的wav数据
 *
 * @param {blob}   需要下载的blob数据类型
 * @param {string} [name='recorder']    重命名的名字
 */
export function downloadWAV(wavblob, name = 'recorder') {
  _download(wavblob, name, 'wav');
}

/**
 * 下载录音pcm数据
 *
 * @param {blob}   需要下载的blob数据类型
 * @param {string} [name='recorder']    重命名的名字
 * @memberof Recorder
 */
export function downloadPCM(pcmBlob, name = 'recorder') {
  _download(pcmBlob, name, 'pcm');
}

mp3下载

如果需要下载mp3

在不使用第三方库的情况下,将PCM数据转换为MP3是一个复杂的任务,因为MP3是一种有损压缩音频格式,涉及到信号处理和编码技术,比如傅立叶变换、量化、哈夫曼编码等。一种方法是使用lamejs的纯JavaScript MP3编码器,它是LAME MP3编码器的JavaScript移植版本。

js 复制代码
// 首先引入lamejs库
import { Mp3Encoder } from 'lamejs';


function convertToMp3 (wavDataView) {
  // 获取wav头信息
  const wav = lamejs.WavHeader.readHeader(wavDataView); // 此处其实可以不用去读wav头信息,毕竟有对应的config配置
  const { channels, sampleRate } = wav;
  // 设置一些音频参数
  let mp3Encoder = new Mp3Encoder(channels, sampleRate, 128); // 2表示立体声, 44100表示采样率, 128表示比特率
  
  // 获取左右通道数据
  const result = recorder.getChannelData()
  const buffer = [];

  const leftData = result.left && new Int16Array(result.left.buffer, 0, result.left.byteLength / 2);
  const rightData = result.right && new Int16Array(result.right.buffer, 0, result.right.byteLength / 2);
  const remaining = leftData.length + (rightData ? rightData.length : 0);

  const maxSamples = 1152;
  for (let i = 0; i < remaining; i += maxSamples) {
      const left = leftData.subarray(i, i + maxSamples);
      let right = null;
      let mp3buf = null;

      if (channels === 2) {
          right = rightData.subarray(i, i + maxSamples);
          mp3buf = mp3Encoder.encodeBuffer(left, right);
      } else {
          mp3buf = mp3Encoder.encodeBuffer(left);
      }

      if (mp3buf.length > 0) {
          buffer.push(mp3buf);
      }
  }

  const enc = mp3Encoder.flush();

  if (enc.length > 0) {
      buffer.push(enc);
  }

  return new Blob(buffer, { type: 'audio/mp3' });
}

上传

得到Blob数据,对于上传到云服务器,就是很简单的事情了

具体可以看腾讯云文档:

cloud.tencent.com/document/pr...

js 复制代码
async uploadRecorder(blobData) {
  const fileName = `recorder.wav`
  const ossDirPath = ''
  const cutImgFile = new File([blobData], fileName, {
    type: 'audio/wav',
  })
  const res = await uploadFileToCos(cutImgFile, ossDirPath)
  return res
}

录制的全部流程如下:

总结

好了,这就是录制+播放+下载+上传音频的正确方式,其实上面这些功能,就是第三方库js-audio-recorder的全部源码了

仓库:github.com/2fps/record...

引入方式

  • npm方式:

安装:

cmd 复制代码
npm i js-audio-recorder

调用:

js 复制代码
import Recorder from 'js-audio-recorder';

let recorder = new Recorder();
  • script标签方式
html 复制代码
<script type="text/javascript" src="./dist/recorder.js"></script>

let recorder = new Recorder();

具体的效果就是这样

好了,本次分享到这里就结束了~

相关推荐
腾讯TNTWeb前端团队7 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰10 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪10 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪10 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy11 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom11 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom12 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom12 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom12 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom12 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试