Search code examples
javascriptweb-audio-api

Save PCM raw bytes into a DataView object


I'm acquiring PCM raw bytes with new AudioContext({ sampleRate: 16000 }) (note, I'm using Chrome, which supports the sampleRate option), and I'd like to convert the resulting array into a DataView object.

My current code is the following, in the stop method I read the left channel and store it as an array of Float32Arrays.

function mergeBuffers(channelBuffer, recordingLength) {
  let result = new Float32Array(recordingLength);
  let offset = 0;

  for (let i = 0; i < channelBuffer.length; i++) {
    result.set(channelBuffer[i], offset);
    offset += channelBuffer[i].length;
  }

  return Array.prototype.slice.call(result);
}

class AudioRecorder {
  constructor(audioStream, config) {
    this.audioStream = audioStream;

    // creates the an instance of audioContext
    this.audioContext = new AudioContext({ sampleRate: 16000 });

    // retrieve the current sample rate of microphone the browser is using
    this.sampleRate = this.audioContext.sampleRate;

    // creates a gain node
    this.volume = this.audioContext.createGain();

    // creates an audio node from the microphone incoming stream
    this.audioInput = this.audioContext.createMediaStreamSource(audioStream);

    this.leftChannel = [];
    this.recordingLength = 0;

    /*
     * From the spec: This value controls how frequently the audioprocess event is
     * dispatched and how many sample-frames need to be processed each call.
     * Lower values for buffer size will result in a lower (better) latency.
     * Higher values will be necessary to avoid audio breakup and glitches
     */
    const bufferSize = config?.bufferSize ?? 2048;
    this.recorder = (
      this.audioContext.createScriptProcessor ||
      this.audioContext.createJavaScriptNode
    ).call(this.audioContext, bufferSize, 1, 1);

    // connect the stream to the gain node
    this.audioInput.connect(this.volume);

    this.recorder.onaudioprocess = (event) => {
      const samples = event.inputBuffer.getChannelData(0);

      // we clone the samples
      this.leftChannel.push(new Float32Array(samples));

      this.recordingLength += bufferSize;
    };
    
    this.ondataavailable = config?.ondataavailable;
  }

  start() {
    // we connect the recorder
    this.volume.connect(this.recorder);

    // start recording
    this.recorder.connect(this.audioContext.destination);
    
    
  }

  stop() {
    this.recorder.disconnect();
    const PCM32fSamples = mergeBuffers(this.leftChannel, this.recordingLength);
    const PCM16iSamples = [];

    for (let i = 0; i < PCM32fSamples.length; i++) {
      let val = Math.floor(32767 * PCM32fSamples[i]);
      val = Math.min(32767, val);
      val = Math.max(-32768, val);

      PCM16iSamples.push(val);
    }
    
    return PCM16iSamples;
  }
}

(async () => {
  if (!navigator.getUserMedia) {
    alert("getUserMedia not supported in this browser.");
  }

  let audioStream;
  try {
    audioStream = await navigator.mediaDevices.getUserMedia({
      audio: true
    });
  } catch (err) {
    alert("Error capturing audio.");
  }

  const recorder = new AudioRecorder(audioStream);
  
  document.querySelector('#start').addEventListener('click', () => recorder.start());
  document.querySelector('#stop').addEventListener('click', () => console.log(recorder.stop()));
})();
<button id="start">Start</button>
<button id="stop">Stop</button>

In order to send this recording to my backend I need to convert the array into a DataView, so I tried to do the following:

stop() {
    this.recorder.disconnect();
    const PCM32fSamples = mergeBuffers(this.leftChannel, this.recordingLength);
    const buffer = new ArrayBuffer(PCM32fSamples.length + 1);
    const PCM16iSamples = new DataView(buffer);

    for (let i = 0; i < PCM32fSamples.length; i++) {
      let val = Math.floor(32767 * PCM32fSamples[i]);
      val = Math.min(32767, val);
      val = Math.max(-32768, val);

      PCM16iSamples.setInt16(i, val);
    }
    
    return PCM16iSamples;
  }

But the problem is that the resulting audio is inaudible.

From my understanding, the AudioContext is returning a list of Float32Array no matter what sampleRate I set it to use, so I don't understand what am I supposed to do to convert the values so that they fit into a Int16 buffer...


Solution

  • From your comment I will assume you know how to handle s16le raw audio.

    Also notice that you are creating your ArrayBuffer with length equals the number of samples in the PCM32fSamples, should be the size in bytes, also the call to setInt16 should pass the offset in bytes.

    An alternative to set an array buffer is to construct an Int16Array. The motivation for using a DataView is to be able to write mixed type data. This will make your code more readable.

      const buffer = new ArrayBuffer(this.recordingLength * 2);
      const PCM16iSamples = new Int16Array(buffer);
      let offset = 0;
      for(const chunk of this.leftChannel){
        for(const sample of chunk){
          let val = Math.floor(32767 * sample);
          val = Math.min(32767, val);
          val = Math.max(-32768, val);
          PCM16iSamples[offset++] = val;
        }
      }
      
    

    In the end the data will be on the PCM16iSamples and in the buffer, you can construct the dataview from the buffer as you did in your example

    PS I didn't test, the snipped does not work here.

    To populate using DataView

      const buffer = new ArrayBuffer(this.recordingLength * 2);
      const data = new DataView(buffer);
      let offset = 0;
      for(const chunk of this.leftChannel){
        for(const sample of chunk){
          let val = Math.floor(32767 * sample);
          val = Math.min(32767, val);
          val = Math.max(-32768, val);
          data.setInt16(offset, val) = val;
          offset += 2;
        }
      }