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

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

播放

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

获取录制数据

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

// 左声道数据
      // 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数据,需要先把二维数组,转换为一维数组。

/**
   * 将二维数组转一维
   *
   * @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
    };
  }
// 获取录音数据
  getData() {
    return this.flat();
  }

数据合并压缩

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

/**
 * 数据合并压缩
 * 根据输入和输出的采样率压缩数据,
 * 比如输入的采样率是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

转换对应格式编码

按采样位数重新编码

/**
 * 转换到我们需要的对应格式的编码
 *
 * @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;
}

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

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

https://blog.csdn.net/damanchen/article/details/112424874

阮一峰老师的:

https://www.ruanyifeng.com/blog/2016/11/byte-order.html

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

PCM数据

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

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数据前增加下就行了。

/**
 * 编码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对象。

https://developer.mozilla.org/zh-CN/docs/Web/API/AudioContext

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 采样率进行解码的,然后通过回调返回结果。

暂停播放

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

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

恢复播放

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

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

结束播放

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

    destroySource();
  }

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

下载

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

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

通用下载方法

/**
 * 下载录音文件
 * @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个字节的文件头

/**
 * 下载录音的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移植版本。

// 首先引入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数据,对于上传到云服务器,就是很简单的事情了

具体可以看腾讯云文档:

https://cloud.tencent.com/document/product/436/64960

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
}

录制的全部流程如下:

无标题-2023-12-12-2040.png

总结

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

仓库:https://github.com/2fps/recorder

引入方式
  • npm方式:

安装:

npm i js-audio-recorder

调用:

import Recorder from 'js-audio-recorder';

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

let recorder = new Recorder();

具体的效果就是这样

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

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Java 数组是存储相同类型数据的集合,它们具有固定大小并且在创建后大小不可更改。在Java中,数组通过声明和初始化来创建。声明数组的语法形式如下: ```java int[] arr; // 声明了一个 int 类型的数组 ``` 在声明数组之后,需要通过初始化该数组,也就是为数组分配内存和赋初值。初始化数组的方式有两种:静态初始化和动态初始化。静态初始化是在声明数组的同时给数组元素赋初值的方法,语法形式如下: ```java int[] arr = {1, 2, 3, 4, 5}; // 静态初始化数组 ``` 动态初始化是在声明数组后通过循环或用户输入等方式给数组元素赋值的方法,语法形式如下: ```java int[] arr = new int[5]; // 动态初始化数组 for (int i = 0; i < arr.length; i++) { arr[i] = i + 1; } ``` Java 数组还具有一些常用的属性和方法,如`length`属性用来获取数组的长度,`clone()`方法用来复制数组,`toString()`方法用来将数组转换为字符串等。 除了一维数组外,Java 还支持多维数组,如二维数组、三维数组等。多维数组的声明和初始化方式与一维数组类似,只是需要使用多个`[]`来表示维度。 值得注意的是,Java 中的数组是引用类型,因此在传递数组参数时,实际上传递的是数组的引用,而不是数组的副本。这意味着在方法中对数组的修改会影响到原数组。 总的来说,了解和掌握 Java 数组的声明、初始化、属性和方法,并能灵活运用,对于 Java 编程是非常重要的。希望本文能够为大家提供关于 Java 数组的全面解析和干货知识。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值