3

I have implemented a simple audio recorder that uses AudioWorkletAPI. The playback works great on Chrome, but on Safari or iPhone devices (including Chrome on iPhone) half of the audio is missing. For example: only the first 20% and last 30% of the audio is heard.

You can find the deployed example here: https://super-queijadas-d93541.netlify.app/

The audio worklet code:

class RecorderProcessor extends AudioWorkletProcessor {
  bufferSize = 4096;
  // 1. Track the current buffer fill level
  _bytesWritten = 0;

  // 2. Create a buffer of fixed size
  _buffer = new Float32Array(this.bufferSize);

  constructor() {
    super();
    this.initBuffer();
  }

  initBuffer() {
    this._bytesWritten = 0;
  }

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

  isBufferFull() {
    return this._bytesWritten === this.bufferSize;
  }

  process(inputs) {
    // Grabbing the 1st channel similar to ScriptProcessorNode
    this.append(inputs[0][0]);

    return true;
  }

  append(channelData) {
    if (this.isBufferFull()) {
      this.flush();
    }

    if (!channelData) return;

    for (let i = 0; i < channelData.length; i++) {
      this._buffer[this._bytesWritten++] = channelData[i];
    }
  }

  flush() {
    // trim the buffer if ended prematurely
    this.port.postMessage(
      this._bytesWritten < this.bufferSize
        ? this._buffer.slice(0, this._bytesWritten)
        : this._buffer
    );
    this.initBuffer();
  }
}

registerProcessor('recorder.worklet', RecorderProcessor);

Here's the code that starts/stops the recording and encodes it to WAV:

let sampleRate = 3000;

let microphone;
let context;
let data = [];
let audioSource = null;

const start = async () => {
  document.getElementById('startBtn').disabled = true;
  document.getElementById('stopBtn').disabled = false;
  window.URL.revokeObjectURL(audioSource);

  try {
    context = new AudioContext({ sampleRate });
    microphone = await navigator.mediaDevices.getUserMedia({
      audio: true,
    });

    const source = context.createMediaStreamSource(microphone);

    await context.audioWorklet.addModule('recorder.worklet.js');
    // Create the recorder worklet
    const recorder = new AudioWorkletNode(context, 'recorder.worklet');
    source.connect(recorder).connect(context.destination);
    recorder.port.onmessage = (e) => {
      // `data` is a Float32Array array containing our audio samples
      data.push(e.data);
    };
  } catch (error) {
    console.log(error);
    alert('Error capturing audio.');
  }
};

const stop = async () => {
  document.getElementById('stopBtn').disabled = true;
  document.getElementById('startBtn').disabled = false;

  try {
    await context.close();
    console.log(data.length);
    console.log(float32Flatten(data).length, float32Flatten(data).byteLength);
    const blob = new Blob([encodeWAV(float32Flatten(data), sampleRate)], {
      type: 'audio/wav',
    });
    data = [];
    audioSource = window.URL.createObjectURL(blob);

    document.getElementById('audioPlayback').src = audioSource;

    microphone.getTracks().forEach((track) => {
      track.stop();
    });
  } catch (error) {
    console.log(error);
  }
};

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;
};

function encodeWAV(samples, sampleRate, format = 1, numChannels = 1, bitDepth = 16) {
  var bytesPerSample = bitDepth / 8;
  var blockAlign = numChannels * bytesPerSample;

  var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
  var view = new DataView(buffer);

  /* RIFF identifier */
  writeString(view, 0, 'RIFF');
  /* RIFF chunk length */
  view.setUint32(4, 36 + samples.length * bytesPerSample, true);
  /* RIFF type */
  writeString(view, 8, 'WAVE');
  /* format chunk identifier */
  writeString(view, 12, 'fmt ');
  /* format chunk length */
  view.setUint32(16, 16, true);
  /* sample format (raw) */
  view.setUint16(20, format, true);
  /* channel count */
  view.setUint16(22, numChannels, true);
  /* sample rate */
  view.setUint32(24, sampleRate, true);
  /* byte rate (sample rate * block align) */
  view.setUint32(28, sampleRate * blockAlign, true);
  /* block align (channel count * bytes per sample) */
  view.setUint16(32, blockAlign, true);
  /* bits per sample */
  view.setUint16(34, bitDepth, true);
  /* data chunk identifier */
  writeString(view, 36, 'data');
  /* data chunk length */
  view.setUint32(40, samples.length * bytesPerSample, true);
  if (format === 1) {
    // Raw PCM
    floatTo16BitPCM(view, 44, samples);
  } else {
    writeFloat32(view, 44, samples);
  }

  return buffer;
}

function floatTo16BitPCM(output, offset, input) {
  for (var i = 0; i < input.length; i++, offset += 2) {
    var s = Math.max(-1, Math.min(1, input[i]));
    output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
  }
}

function writeString(view, offset, string) {
  for (var i = 0; i < string.length; i++) {
    view.setUint8(offset + i, string.charCodeAt(i));
  }
}

function handleSampleRateChange(select) {
  sampleRate = parseInt(select.options[select.selectedIndex].value);
}
slaninero
  • 31
  • 1

2 Answers2

1

I can confirm that it is currently broken on MacOS 12.6 where Safari completely scrambles the recording, skips frames and messes up the chunk order. On iOS 16.0.3 (iPhone XS) it is a bit less troubling since recording is ok for about 4s then skips a few frames and continues.
I've tested the latest Safari Developer Preview on Mac OS as well (2022.10.13), exact same issues.
The deprecated "scriptProcessor" works without frame skipping and scrambling, but for some reason creates a very bad recording for me.
All tests I've done use the SEPIA web-audio framework: https://github.com/SEPIA-Framework/sepia-web-audio.

If there is no WebKit Bug-Report yet we should create one.

Flow
  • 29
  • 4
  • This does not really answer the question. If you have a different question, you can ask it by clicking [Ask Question](https://stackoverflow.com/questions/ask). To get notified when this question gets new answers, you can [follow this question](https://meta.stackexchange.com/q/345661). Once you have enough [reputation](https://stackoverflow.com/help/whats-reputation), you can also [add a bounty](https://stackoverflow.com/help/privileges/set-bounties) to draw more attention to this question. - [From Review](/review/late-answers/32934464) – Mohamed Elkassas Oct 17 '22 at 13:45
  • Well if the question is "Is this a bug?" then this is the answer: yes, it is a bug and only Apple can fix it. – Flow Oct 25 '22 at 16:00
0

This is the exact behavior I experienced with nearly the same code. Right now I could not find any solution besides switching to mediaRecorder based encoded recording and do the conversion to pcm/wav on the server.

stv0l
  • 1
  • I have decided to use the deprecated `createScriptProcessor` for recording from iOS devices. After this, there were no issues with recording on iOS devices. – slaninero Sep 16 '22 at 08:06