关于录音 Recorder.js

看,学,做

关于目录

  • 关于关键 Api
  • 关于关键代码实现
  • 关于 audio 标签的细节

关于实现所需 API

  • navigator.mediaDevices.getUserMedia() 获取客户端录音流
  • AudioContext 音频处理对象
    • AudioContext.createMediaStreamSource(stream) 关联录音流
    • AudioContext.createScriptProcessor(option) 获取音频流

关于关键代码实现

首要必须是获取客户端录音权限,并给予一些错误提示

/*
  @type           constraints = { audio: true, video: true }
  @description    一个 MediaStreamConstraints 对象,
                  指定了请求的媒体类型和相对应的参数。
*/
navigator.mediaDevices.getUserMedia([constraints])
.(function(stream) {
  /* 使用这个 stream stream */
  const context = new AudioContext({ sampleRate: 48000 });
  const audioInput = context.createMediaStreamSource(stream);
  const recorder = context.createScriptProcessor(4096, 1, 1);
})
.catch(function(err) {
  /*
    处理error
    ------------
    可能有的错误
    AbortError              [中止错误]
    NotAllowedError         [拒绝错误]
    NotFoundError           [找不到错误]
    NotReadableError        [无法读取错误]
    OverConstrainedError    [无法满足要求错误]
    SecurityError           [安全错误]
    TypeError               [类型错误]
  */
  switch(err.message || err.name){
    case 'PERMISSION_DENIED':
    case 'PermissionDeniedError':
      alert('用户拒绝提供信息。');
      break;
    case 'NOT_SUPPORTED_ERROR':
    case 'NotSupportedError':
      alert('浏览器不支持硬件设备。');
      break;
    case 'MANDATORY_UNSATISFIED_ERROR':
    case 'MandatoryUnsatisfiedError':
      alert('无法发现指定的硬件设备。');
      break;
    default:
      alert('无法打开麦克风。异常信息:' + (err.code || err.name));
      break;
  }
});

有权限录音流后,目前所见的常用做法,是放入一个对象内,
对象有些根据所传入的配置要求初始化,并初始化缓存数据,
最后暴露出简单的方法

对象初始化


class Recorder{
  // 初始化 config 与需要的数据流
  constructor(stream, config, callback){
    // 配置初始化
    this.callback   = callback
    this.sampleBits = config.sampleBits || 16
    this.sampleRate = config.sampleRate || 16000
    this.context    = new AudioContext({ sampleRate: 48000 })
    this.audioInput = this.context.createMediaStreamSource(stream)
    this.recorder   = this.context.createScriptProcessor(4096,1,1)

    // Audio Source 数据缓存
    this.size             = 0
    this.buffer           = []
    this.inputSampleRate  = 48000
    this.inputSampleBits  = 16
    this.outputSampleRate = this.sampleRate
    this.outputSampleBits = this.sampleBits
    this.objUrl           = null
  }
}

Tip:如果 AudioContext 对象无配置采样率,则会根据系统变化,已知 Mac 默认是 44100, Window 默认是 48000

编写内置工具函数

内置需要一些压缩与输出的算法工具函数

  • clear() 清空缓存数据 or 上一次数据
  • input() 存储过程数据
  • compress() 压缩算法
  • encode() 输出格式,可自行百度获取
class Recorder{
  /* ... */
  /* 清空数据 */
  claer(){
    this.buffer = [];
    this.size   = 0;
  }
  /* 数据缓存 */
  input(inputBuffer){
    this.buffer.push(new Float32Array(inputBuffer));
    this.size += inputBuffer.length;
  }
  /* 数据合并压缩 */
  compress(){
    // 合并
    const data = new Float32Array(this.size);
    for (let i = 0, offset = 0; i < this.buffer.length; i++) {
      data.set(this.buffer[i], offset);
      offset += this.buffer[i].length;
    }
    //压缩
    const compression = this.inputSampleRate / this.outputSampleRate;
    const length = data.length / compression;
    const result = new Float32Array(length);
    for (let i = 0; i < length; i += compression) {
      result.push(this.buffer[i]);
    }
    return result
  }
}

encode() 方法有几种,主要是为了输出不同的格式,以满足不同的需求,
这里只描绘一下 Wav

这里也是网上拿下来的

class Recorder {
  encodeWAV(){
    const sampleRate = Math.min(this.inputSampleRate, this.outputSampleRate);
    const sampleBits = Math.min(this.inputSampleBits, this.outputSampleBits);
    const bytes = this.compress();
    const dataLength = bytes.length * (sampleBits / 8);
    const buffer = new ArrayBuffer(44 + dataLength);
    const data = new DataView(buffer);
    const channelCount = 1; //单声道
    let offset = 0;
    const writeString = function (str: string) {
      for (let i = 0; i < str.length; i++) {
        data.setUint8(offset + i, str.charCodeAt(i));
      }
    };
    // 资源交换文件标识符
    writeString('RIFF');
    offset += 4;
    // 下个地址开始到文件尾总字节数,即文件大小-8
    data.setUint32(offset, 36 + dataLength, true);
    offset += 4;
    // WAV文件标志
    writeString('WAVE');
    offset += 4;
    // 波形格式标志
    writeString('fmt ');
    offset += 4;
    // 过滤字节,一般为 0x10 = 16
    data.setUint32(offset, 16, true);
    offset += 4;
    // 格式类别 (PCM形式采样数据)
    data.setUint16(offset, 1, true);
    offset += 2;
    // 通道数
    data.setUint16(offset, channelCount, true);
    offset += 2;
    // 采样率,每秒样本数,表示每个通道的播放速度
    data.setUint32(offset, sampleRate, true);
    offset += 4;
    // 波形数据传输率 (每秒平均字节数) 单声道×每秒数据位数×每样本数据位/8
    data.setUint32(offset, channelCount * sampleRate * (sampleBits / 8), true);
    offset += 4;
    // 快数据调整数 采样一次占用字节数 单声道×每样本的数据位数/8
    data.setUint16(offset, channelCount * (sampleBits / 8), true);
    offset += 2;
    // 每样本数据位数
    data.setUint16(offset, sampleBits, true);
    offset += 2;
    // 数据标识符
    writeString('data');
    offset += 4;
    // 采样数据总数,即数据总大小-44
    data.setUint32(offset, dataLength, true);
    offset += 4;
    // 写入采样数据
    if (sampleBits === 8) {
      for (let i = 0; i < bytes.length; i++, offset++) {
        const s = Math.max(-1, Math.min(1, Number(bytes[i])));
        const val = s < 0 ? s * 0x8000 : s * 0x7fff;
        val = 255 / (65535 / (val + 32768));
        data.setInt8(offset, val);
      }
    } else {
      for (let i = 0; i < bytes.length; i++, offset += 2) {
        const s = Math.max(-1, Math.min(1, Number(bytes[i])));
        data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
      }
    }
    return new Blob([data], { type: 'audio/wav' });
  }
}

编写可暴露的数据

  • start() 开始记录录音数据
  • stop() 停止记录录音数据
  • getAudioUrl() 获取录音数据
class Recorder {
  /* ... */
  // 开始
  start(){
    console.log('recorder start');
    this.audioInput.connect(this.recorder);
    this.recorder.connect(this.context.destination);
    this.recorder.onaudioprocess = e => {
      const inputBuffer = e.inputBuffer.getChannelData(0);
      this.input(inputBuffer);
    };
  }
  // 停止
  stop(){
    this.recorder.disconnect();
  }
  // 获取可播放 url
  getAudioUrl(){
    const url = this.encodeWAV();
    if (this.objUrl) {
      URL.revokeObjectURL(this.objUrl);
    }
    this.objUrl = URL.createObjectURL(wav);
    return this.objUrl
  }
}

实时传送录音数据流

  • 需要进行一次压缩
  • 需要一个回调函数
class Recorder {
  /* ... */
  sendData(){
    const compression = this.inputSampleRate / this.outputSampleRate;
    const length = inputBuffer.length / compression;
    const result = new Float32Array(length);
    let index = 0,
      j = 0;
    while (index < length) {
      result[index] = inputBuffer[j];
      j += compression;
      index++;
    }
    const dataLength = result.length * (16 / 8);
    const buffer = new ArrayBuffer(dataLength);
    const blockData = new Uint8Array(buffer);
    blockData && this.callback  && this.callback(blockData)
  }
}

关于小结

尚未解决的问题

createScriptProcessor 已经不推荐使用,然在大部分浏览器内尚可运行,
且新的录音 Api 尚未有新鲜可循的 blog 文教授

本文大致才是参照,网上诸多老旧 blog 以及已有的插件组成的,大致可以看作是一片缝合水文,
本文目的在于记录自己开发前置需求中的一些思考,以及找寻到的 blog

感谢阅读


参照文章