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();

具体的效果就是这样

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

相关推荐
前端小小王21 分钟前
React Hooks
前端·javascript·react.js
迷途小码农零零发31 分钟前
react中使用ResizeObserver来观察元素的size变化
前端·javascript·react.js
娃哈哈哈哈呀1 小时前
vue中的css深度选择器v-deep 配合!important
前端·css·vue.js
旭东怪1 小时前
EasyPoi 使用$fe:模板语法生成Word动态行
java·前端·word
ekskef_sef3 小时前
32岁前端干了8年,是继续做前端开发,还是转其它工作
前端
sunshine6413 小时前
【CSS】实现tag选中对钩样式
前端·css·css3
真滴book理喻4 小时前
Vue(四)
前端·javascript·vue.js
蜜獾云4 小时前
npm淘宝镜像
前端·npm·node.js
dz88i84 小时前
修改npm镜像源
前端·npm·node.js
Jiaberrr4 小时前
解锁 GitBook 的奥秘:从入门到精通之旅
前端·gitbook