DEV Community

orca_forge
orca_forge

Posted on • Originally published at forge.workstyle.tech

Recording Spec-Compliant WAV Files (16-bit/Mono/Uncompressed) Using Only Web Audio

📝 Originally published (in Japanese) at forge.workstyle.tech.

Introduction to Clean Audio Recording

When it comes to recording audio in the browser, the first thing that comes to mind is likely the MediaRecorder API. It's easy to use and only requires a few lines of code to start recording. However, when it comes to using the recorded audio for machine learning preprocessing, such as voice conversion or feature extraction, MediaRecorder becomes inconvenient.

MediaRecorder typically outputs webm/opus, which is a non-reversible compression format. While it's sufficient for human ears, the fine details of the audio are already lost during the preprocessing stage. Moreover, many downstream pipelines require a strictly specified WAV format, which is 16-bit PCM, mono, and uncompressed.

In this article, we'll introduce an implementation that uses Web Audio to record raw PCM and encodes it into a WAV file manually. Although it's a tedious task to write binary data one byte at a time, once it's done, you'll have a high-quality recording with zero degradation.

Background: Why Raw PCM?

The Web Audio API allows us to redirect the microphone input to an AudioContext graph and extract the raw samples (Float32, -1 to 1) at any point. Since we're not passing the audio through any compression codec, we can capture the original signal from the microphone. By encoding these Float32 samples into a 16-bit WAV file, we can create a file that meets the specifications without any degradation.

Our approach consists of two stages:

  1. Recording: Collecting Float32 raw samples from the microphone (using Web Audio)
  2. Encoding: Converting the collected Float32 samples into a 16-bit PCM WAV byte array

Step 1: Opening the Microphone with Clean Settings

We use getUserMedia to access the microphone, but it's essential to disable all browser audio processing. Echo cancellation, noise suppression, and automatic gain control are useful for voice calls, but they're unnecessary for recording and can even degrade the audio quality.

this.stream = await navigator.mediaDevices.getUserMedia({
  audio: { channelCount: 1, echoCancellation: false,
           noiseSuppression: false, autoGainControl: false },
});
Enter fullscreen mode Exit fullscreen mode

We set channelCount to 1 to require mono audio. To achieve "clean recording" as specified, it's crucial to explicitly set these flags to false.

Step 2: Collecting Raw Samples with ScriptProcessor

We create a MediaStreamSource from the microphone and capture the samples flowing from it. Here, we use a ScriptProcessorNode.

this.ctx = new AudioContext();
this.sampleRate = this.ctx.sampleRate;       // usually 48000 (>= 44.1kHz)
this.source = this.ctx.createMediaStreamSource(this.stream);
this.node = this.ctx.createScriptProcessor(4096, 1, 1);  // buffer, in=1, out=1
this.chunks = [];
this.node.onaudioprocess = (e) => {
  // getChannelData uses an internal buffer, so we must copy and escape it
  this.chunks.push(new Float32Array(e.inputBuffer.getChannelData(0)));
};
this.source.connect(this.node);
this.node.connect(this.ctx.destination);     // required for some browsers to trigger
Enter fullscreen mode Exit fullscreen mode

There are a few key points to note:

  • Copy the samples: The Float32Array returned by getChannelData(0) is an internal buffer that will be overwritten on the next callback. If we don't copy it using new Float32Array(...), the entire recording will be overwritten with the last frame.
  • Connect to destination: Some browsers require the ScriptProcessorNode to be connected to the ctx.destination to trigger the onaudioprocess event. Even if we don't need to play the audio, we connect it to ensure the event is triggered.
  • Sample rate is device-dependent: ctx.sampleRate is dependent on the environment, and most devices use 48000 (which is greater than or equal to 44.1kHz). We record the actual sample rate here and use it to write the correct header later. Using the actual recorded sample rate in the header is crucial for creating a compliant file without any resampling.

Note: ScriptProcessorNode is deprecated, and its successor is AudioWorklet. However, for simple use cases like collecting samples, ScriptProcessorNode is still sufficient. If you need low latency or heavy DSP processing, consider migrating to AudioWorklet.

When stopping the recording, we concatenate the collected chunks into a single Float32Array, release the microphone and AudioContext, and then proceed to the next encoding step.

Step 3: Manually Building the 16-bit PCM WAV

Now, let's create the WAV byte array. The WAV format consists of a 44-byte header followed by the PCM data. We use an ArrayBuffer and DataView to write the fields one by one.

export function encodeWav(samples: Float32Array, sampleRate: number): Blob {
  const buffer = new ArrayBuffer(44 + samples.length * 2);  // 16bit = 2byte/sample
  const view = new DataView(buffer);
  const writeStr = (o: number, s: string) => {
    for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i));
  };
  writeStr(0, 'RIFF');
  view.setUint32(4, 36 + samples.length * 2, true);  // file size - 8
  writeStr(8, 'WAVE');
  writeStr(12, 'fmt ');
  view.setUint32(16, 16, true);            // fmt chunk size
  view.setUint16(20, 1, true);             // format = 1 (PCM uncompressed)
  view.setUint16(22, 1, true);             // channel count = mono
  view.setUint32(24, sampleRate, true);    // sample rate
  view.setUint32(28, sampleRate * 2, true);// bytes per second = sample rate * block align
  view.setUint16(32, 2, true);             // block align = mono * 16bit/8
  view.setUint16(34, 16, true);            // bit depth = 16
  writeStr(36, 'data');
  view.setUint32(40, samples.length * 2, true);       // data length
  // ... body ...
}
Enter fullscreen mode Exit fullscreen mode

The key points in the header are:

  • Format number 1 means PCM (uncompressed).
  • Multi-byte values are little-endian, so we write them with true as the third argument to DataView.
  • Block align and byte rate are calculated from the channel count and bit depth (for mono 16-bit, block align = 2, byte rate = sample rate * 2). By writing the actual sample rate here, we ensure that the WAV file plays at the correct speed.

The body of the WAV file is where we quantize the Float32 samples into 16-bit integers. Note that the scale is asymmetric:

let off = 44;
for (let i = 0; i < samples.length; i++) {
  const s = Math.max(-1, Math.min(1, samples[i]));         // clamp to range
  view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7fff, true); // asymmetric scale
  off += 2;
}
return new Blob([view], { type: 'audio/wav' });
Enter fullscreen mode Exit fullscreen mode

The 16-bit signed range is asymmetric: -32768 to +32767. For negative values, we multiply by 0x8000 (32768), and for positive values, we multiply by 0x7fff (32767) to utilize the full scale correctly. Some implementations may multiply both by 0x7fff, but this would lose some dynamic range on the negative side. Although it's a minor detail, it's essential for creating a compliant file. Clamping the input to the -1 to 1 range before quantization also prevents overflow.

Finally, we create a Blob (with type: 'audio/wav') that can be used as needed, such as downloading it using URL.createObjectURL or sending it to a server via FormData.

Conclusion

To achieve clean audio recording for preprocessing, we must:

  • Use Web Audio to record raw PCM instead of MediaRecorder.
  • Disable all browser audio processing using getUserMedia.
  • Collect Float32 samples using ScriptProcessorNode and copy them to avoid overwriting.
  • Connect the ScriptProcessorNode to the destination to ensure the onaudioprocess event is triggered.
  • Use the actual recorded sample rate in the WAV header.
  • Build the WAV file manually using ArrayBuffer and DataView, considering little-endian and asymmetric quantization.

By following these steps, you'll be able to create high-quality, degradation-free audio recordings that meet the required specifications for machine learning preprocessing.

Top comments (0)