Home Manual Reference Source

src/audio/wave-packer.js

/**
 * Packer class for audio packing
 *
 * @private
 */
export default class WavePacker {
  /**
   * Stop recording audio.
   *
   * @param {number} recordingSampleRate - Sample rate of recording. Must be either 48000 or 44100.
   * @param {number} sampleRate - Sample rate. Must be half or a quarter of the recording sample rate.
   * @param {number} channels - Amount of audio channels. 1 or 2.
   */
  init(recordingSampleRate, sampleRate, channels) {
    this.recordingSampleRate = recordingSampleRate;
    if ([48000, 44100].indexOf(this.recordingSampleRate) === -1) {
      throw new Error(
        '48000 or 44100 are the only supported recordingSampleRates');
    }

    this.sampleRate = sampleRate;
    if ([
      this.recordingSampleRate,
      this.recordingSampleRate / 2,
      this.recordingSampleRate / 4
    ].indexOf(this.sampleRate) === -1) {
      throw new Error(
        'sampleRate must be equal, half or a quarter of the ' +
        'recording sample rate');
    }

    this.channels = channels;
    this.recording = false;
  }

  clear() {
    this.recLength = 0;
    this.recBuffersL = [];
    this.recBuffersR = [];
  }

  record(left, right) {
    this.recBuffersL.push(left);
    this.recBuffersR.push(right);
    this.recLength += left.length;
  }

  recordStreaming(left, right, callback) {
    function convertFloat32ToInt16(buffer) {
      let l = buffer.length;
      const buf = new Int16Array(l);
      while (l--) {
        buf[l] = Math.min(1, buffer[l]) * 0x7FFF;
      }
      return buf.buffer;
    }
    // Both the left and right channel's data is a view (Float32Array)
    // on top of the buffer (ArrayBuffer). Each buffer's element should
    // have _value between -1 and 1.
    // The audio to export are 16 bit PCM samples that are wrapped in
    // a WAVE file at the server. Therefore convert from float here.
    const converted = convertFloat32ToInt16(left);
    callback(converted);
  }

  exportWAV(callback) {
    const bufferL = WavePacker.mergeBuffers(this.recBuffersL, this.recLength);
    const bufferR = WavePacker.mergeBuffers(this.recBuffersR, this.recLength);
    const interleaved = this.interleave(bufferL, bufferR);
    const dataview = this.encodeWAV(interleaved);
    const audioBlob = new Blob([dataview], {
      type: 'audio/wav'
    });
    callback(audioBlob);
  }


  exportMonoWAV(callback) {
    const bufferL = WavePacker.mergeBuffers(this.recBuffersL, this.recLength);
    const dataview = this.encodeWAV(bufferL, true);
    const audioBlob = new Blob([dataview], {
      type: 'audio/wav'
    });
    callback(audioBlob);
  }

  /**
   * Wrap the raw audio in a header to make it a WAVE format.
   *
   * Specs: {@link https://ccrma.stanford.edu/courses/422/projects/WaveFormat/}.
   *
   * @param {[]} interleaved - Array of interleaved audio.
   */
  encodeWAV(interleaved) {
    const buffer = new ArrayBuffer(44 + interleaved.length * 2);
    const view = new DataView(buffer);

    // RIFF chunk descriptor
    WavePacker.writeUTFBytes(view, 0, 'RIFF');
    // file length
    view.setUint32(4, 44 + interleaved.length * 2, true);
    // RIFF type
    WavePacker.writeUTFBytes(view, 8, 'WAVE');
    // FMT sub-chunk
    WavePacker.writeUTFBytes(view, 12, 'fmt ');
    // format chunk length
    view.setUint32(16, 16, true);
    // sample format (raw)
    view.setUint16(20, 1, true);
    // channel count. mono=1, stereo=2
    view.setUint16(22, this.channels, true);
    // sample rate
    view.setUint32(24, this.sampleRate, true);
    // byte rate (sample rate * block align)
    view.setUint32(28, this.sampleRate * 2 * this.channels, true);
    // block align (channel count * bytes per sample)
    view.setUint16(32, this.channels * 2, true);
    // bits per sample
    view.setUint16(34, 16, true);
    // data sub-chunk
    WavePacker.writeUTFBytes(view, 36, 'data');
    view.setUint32(40, interleaved.length * 2, true);

    // write the PCM samples
    const lng = interleaved.length;
    let index = 44;
    const volume = 1;
    for (let i = 0; i < lng; i++) {
      view.setInt16(index, interleaved[i] * (0x7FFF * volume), true);
      index += 2;
    }

    // Wrap in HTML5 Blob for transport
    const blob = new Blob([view], {
      type: 'audio/wav'
    });
    console.log('Recorded audio/wav Blob size: ' + blob.size);
    return blob;
  }

  interleave(leftChannel, rightChannel) {
    let result = null;
    let length = null;
    let i = null;
    let inputIndex = null;
    if (this.channels === 1) {
      // Keep both right and left input channels, but "pan" them both
      // in the center (to the single mono channel)
      length = leftChannel.length;
      result = new Float32Array(length);
      for (i = 0; i < leftChannel.length; ++i) {
        result[i] = 0.5 * (leftChannel[i] + rightChannel[i]);
      }
    } else {
      length = leftChannel.length + rightChannel.length;
      result = new Float32Array(length);

      inputIndex = 0;
      for (i = 0; i < length;) {
        result[i++] = leftChannel[inputIndex];
        result[i++] = rightChannel[inputIndex];
        inputIndex++;
      }
    }

    // Also downsample if needed.
    if (this.recordingSampleRate !== this.sampleRate) {
      // E.g. 44100/11025 = 4
      const reduceBy = this.recordingSampleRate / this.sampleRate;
      const resampledResult = new Float32Array(length / reduceBy);

      inputIndex = 0;
      for (i = 0; i < length;) {
        let value = 0;
        for (let j = 0; j < reduceBy; j++) {
          value += result[inputIndex++];
        }
        resampledResult[i++] = 1 / reduceBy * value;
      }
      return resampledResult;
    }
    return result;
  }

  static mergeBuffers(channelBuffer, recordingLength) {
    const result = new Float32Array(recordingLength);
    let offset = 0;
    const lng = channelBuffer.length;
    for (let i = 0; i < lng; i++) {
      const buffer = channelBuffer[i];
      result.set(buffer, offset);
      offset += buffer.length;
    }
    return result;
  }

  static writeUTFBytes(view, offset, string) {
    const lng = string.length;
    for (let i = 0; i < lng; i++) {
      view.setUint8(offset + i, string.charCodeAt(i));
    }
  }
}