📝 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:
- Recording: Collecting Float32 raw samples from the microphone (using Web Audio)
- 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 },
});
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
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 usingnew Float32Array(...), the entire recording will be overwritten with the last frame. - Connect to destination: Some browsers require the
ScriptProcessorNodeto be connected to thectx.destinationto trigger theonaudioprocessevent. 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.sampleRateis 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:
ScriptProcessorNodeis deprecated, and its successor isAudioWorklet. However, for simple use cases like collecting samples,ScriptProcessorNodeis still sufficient. If you need low latency or heavy DSP processing, consider migrating toAudioWorklet.
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 ...
}
The key points in the header are:
- Format number 1 means PCM (uncompressed).
- Multi-byte values are little-endian, so we write them with
trueas the third argument toDataView. - 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' });
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
ScriptProcessorNodeand copy them to avoid overwriting. - Connect the
ScriptProcessorNodeto thedestinationto ensure theonaudioprocessevent is triggered. - Use the actual recorded sample rate in the WAV header.
- Build the WAV file manually using
ArrayBufferandDataView, 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)