Recording microphone audio with AudioWorklet problem: data spills over between recordings (Chrome only)

1.5k Views Asked by At

I'm developing a web app that uses AudioWorklet to record audio. See example here: https://engagelab.uio.no/oslospell/#/debug

I believe I clear all buffers (this.audioData in the main thread) between recordings, but somehow: On Chrome/PC, a new recording snippet always starts with about 0.5 s of audio from the end of the previous recording. Moreover, the last 0.5 s is not recorded but instead inserted at the start of the next recording, as mentioned. This does not happen on Safari on an iPad; there it works as intended.

To reproduce:

  1. Go to https://engagelab.uio.no/oslospell/#/debug
  2. Click "Start audio"
  3. Start making a uniform sound, click "Start recording", click "Stop recording".
  4. Click "Play test". You should hear the sound.
  5. Do a new recording ("Start recording, then "Stop recording"). Now when you click "Play test", you should hear the uniform sound from the first recording in the first 0.5 seconds of the new recording. The recording will also stop a bit too early.

Why does this happen, and how can I fix the issue?

FYI. I experienced big issues with delays/unsync in recordings on Safari/iPad, but after I implemented disconnecting/reconnecting the audio graph between recordings the issue was completely fixed:

//On every stop recording:
this.micSource.connect(this.recorderNode);
this.recorderNode.connect(this.audioContext.destination)

//On every start recording:
this.micSource.connect(this.recorderNode);
this.recorderNode.connect(this.audioContext.destination);

AudioWorkletProcessor code:

/*
recorderWorkletProcessor2.js
Based on https://gist.github.com/theroman/155d07f9616f5b9a28c028376a247d24
*/

class RecorderWorkletProcessor extends AudioWorkletProcessor {
  constructor() {
    super();
    this._stopTime = 999999999;
    this._startTime = 0;
    this._stopRecording = true;
    this.port.onmessage = (e) => {
      const data = e.data;
      if (data.eventType == "stopRecording") {
        this._stopTime = data.stopTime;
        this._stopRecording = true;
      }

      if (data.eventType == "startRecording") {
        this._startTime = data.startTime;
        this._stopRecording = false;
      }
    };
    this._bufferSize = 2048;
    this._buffers = null;
    this._initBuffer();
    this._initBuffers(1); //numberOfChannels
  }

  _initBuffers(numberOfChannels) {
    this._buffers = [];
    for (let channel = 0; channel < numberOfChannels; channel++) {
      this._buffers.push(new Float32Array(this._bufferSize));
    }
  }

  _initBuffer() {
    this._bytesWritten = 0;
  }

  _isBufferEmpty() {
    return this._bytesWritten === 0;
  }

  _isBufferFull() {
    return this._bytesWritten === this._bufferSize;
  }

  _pushToBuffers(audioRawData, numberOfChannels) {
    if (this._isBufferFull()) {
      this._flush();
    }

    let dataLength = audioRawData[0].length;

    for (let idx = 0; idx < dataLength; idx++) {
      for (let channel = 0; channel < numberOfChannels; channel++) {
        let value = audioRawData[channel][idx];
        this._buffers[channel][this._bytesWritten] = value;
      }
      this._bytesWritten += 1;
    }
  }

  _flush() {
    let buffers = [];
    this._buffers.forEach((buffer, channel) => {
      if (this._bytesWritten < this._bufferSize) {
        buffer = buffer.slice(0, this._bytesWritten);
      }
      buffers[channel] = buffer;
    });
    this.port.postMessage({
      eventType: "data",
      audioBuffer: buffers,
      bufferSize: this._bufferSize,
    });
    this._initBuffer();
    this._initBuffers(1);
  }

  _recordingStopped() {
    this.port.postMessage({
      eventType: "stop",
    });
  }

  process(inputs, outputs, parameters) {
    if (inputs[0] == null) {
      console.log("FROM WORKLET: input is null");
      return;
    }
    if (this._buffers === null) {
      this._initBuffers(1);
      this._initBuffer();
    }

    if (
      this._stopRecording &&
      !this._isBufferEmpty() &&
      currentTime > this._stopTime
    ) {
      this._flush();
      this._recordingStopped();
    } else if (!this._stopRecording && currentTime > this._startTime) {
      this._pushToBuffers(inputs[0], 1); //data, numberOfChannels
    }
    return true;
  }
}

registerProcessor("recorder-worklet-2", RecorderWorkletProcessor);

Relevant main thread code (it's a Vue app):

    //Global variables (this.)
      audioContext: null as any,
      recorderNode: null as any,
      micSource: null as any,
      audioData: [] as any,
      sampleRate: 0,
      numChannels: 1,


    //Methods:

    stopRecorderWorklet() {
      this.recorderNode.port.postMessage({
        eventType: "stopRecording",
      });
    },

    startRecorderWorklet() {
      this.micSource.connect(this.recorderNode);
      this.recorderNode.connect(this.audioContext.destination);
      console.log("start Recorder Worklet()");
      this.recorderNode.port.postMessage({
        eventType: "startRecording",
      });
    },

    async startAudio() {
      console.log("Initializing audio");
      //Find sample rate
      const deviceDetector = new DeviceDetector();
      const device = deviceDetector.parse(navigator.userAgent);
      let sampleRate;
      if (device.os?.name == "Mac") {
        sampleRate = 44100;
      } else {
        sampleRate = 48000;
      }
      this.sampleRate = sampleRate;
      //Get mic
      const constraints = { audio: true };
      const micStream = await navigator.mediaDevices.getUserMedia(constraints);
      this.handleSuccess(micStream);
    },

     async handleSuccess(micStream) {
      // Default || Safari and old versions of Chrome
      const AudioContext =
        window.AudioContext || (window as any).webkitAudioContext; //eslint-disable-line
      const audioContext = new AudioContext();
      this.audioContext = audioContext;
      this.micSource = audioContext.createMediaStreamSource(micStream);

      //Register the worklet
      try {
        await audioContext.audioWorklet.addModule(
          "worklet/recorderWorkletProcessor2.js"
        );
      } catch (error) {
        console.error("Error register the worklet", error);
        alert("Error register the worklet: " + error);
        return;
      }

      // Create worklet
      const recorderNode = new window.AudioWorkletNode(
        this.audioContext,
        "recorder-worklet-2",
        {
          channelCount: 1,
          channelCountMode: "explicit",
          channelInterpretation: "discrete",
        }
      );
      this.recorderNode = recorderNode;

      // Connect your source
      this.micSource.connect(recorderNode);
      recorderNode.connect(this.audioContext.destination);

      // Register worklet events
      recorderNode.port.onmessage = (e) => {
        const data = e.data;
        if (data.eventType == "startedRecording") {
          this.recStart = data.ts;
          this.debug = "startedRecording";
        } else if (data.eventType == "stop") {
          console.log("RECORDING STOPPED");
          this.recorderNode.disconnect(this.audioContext.destination);
          this.micSource.disconnect(this.recorderNode);

          // recording has stopped
          // process pcm data; encode etc

          //FLATTEN ARRAY
          const float32Flatten = (chunks) => {
            //get the total number of frames on the new float32array
            const nFrames = chunks.reduce((acc, elem) => acc + elem.length, 0);

            //create a new float32 with the correct number of frames
            const result = new Float32Array(nFrames);

            //insert each chunk into the new float32array
            let currentFrame = 0;
            chunks.forEach((chunk) => {
              result.set(chunk, currentFrame);
              currentFrame += chunk.length;
            });
            return result;
          };

          const audioBuffer = float32Flatten(this.audioData);

          //Make object for Wavencoder
          const wavObj = {
            sampleRate: this.sampleRate,
            channelData: [audioBuffer],
          };

          //Run WavEncoder
          const wavOutput = WavEncoder.encode.sync(wavObj);
          console.log("wavOutput", wavOutput);

          //Reset buffer
          this.audioData = [];

          //Prepare for playback
          const blob = new Blob([wavOutput], { type: "audio/wav" });
          const url = window.URL.createObjectURL(blob);
          this.testAudio = url;
        } else if (data.eventType == "data") {
          console.log("DATA RECIEVED");
          this.audioData.push(data.audioBuffer[0]); //channel 0
        }
      };


0

There are 0 best solutions below