Encoding Audio in the Browser with Wasm
This page answers one task: users record voice notes, podcasts or music in the browser, and the recordings should be encoded to a compact, widely playable format — Opus or MP3 — before upload, with predictable quality and file size on every browser.
Prerequisites
- [ ] Microphone access via
getUserMedia, on a secure context. - [ ] A Wasm build of an encoder: libopus (with libogg for containers) or LAME for MP3, compiled with Emscripten, or a Rust crate such as
opusbindings. - [ ] Familiarity with Web Audio and
AudioWorklet.
MediaRecorder versus a Wasm encoder
Browsers include MediaRecorder, which encodes a media stream natively, and for many apps it is enough. Its limitations show up in production. The
output format varies by browser — WebM/Opus in Chrome and Firefox, MP4/AAC in Safari — so the server must accept and possibly transcode several formats.
Bitrate and quality controls are coarse and not honoured consistently. And you cannot encode audio that did not come from a live stream, such as a mix
rendered with Web Audio or samples edited in the page, without routing it through a stream in real time.
A Wasm encoder gives full control: the same codec, settings and container everywhere; exact bitrate, frame size and complexity; encoding from any PCM source, faster than real time if needed. The cost is download size — an Opus encoder is a few hundred kilobytes, LAME similar — and the work of capturing PCM and running the encoder off the main thread.
Step 1 — capture PCM with an AudioWorklet
An AudioWorkletProcessor receives the microphone signal in 128-frame blocks on the audio thread. Forward the samples to an encoder worker — through a
MessagePort for simplicity, or a shared ring buffer for the lowest overhead:
// capture-processor.js
class Capture extends AudioWorkletProcessor {
constructor() { super(); this.buf = new Float32Array(4096); this.n = 0; }
process(inputs) {
const ch = inputs[0][0];
if (ch) {
this.buf.set(ch, this.n); this.n += ch.length;
if (this.n === this.buf.length) { this.port.postMessage(this.buf.slice(0)); this.n = 0; } // ~85 ms at 48 kHz
}
return true;
}
}
registerProcessor("capture", Capture);
Batching 4,096 samples per message keeps message overhead low. For guaranteed real-time behaviour under load, replace postMessage with the ring buffer
from
implementing a lock-free ring buffer in shared memory.
Step 2 — run the encoder in a worker
// encoder.worker.js
import createOpus from "./opus-encoder.mjs"; // Emscripten build of libopus + libogg wrapper
const m = await createOpus();
const enc = m._enc_create(48000, 1, 32000); // sample rate, channels, bitrate (bps)
const pcmPtr = m._malloc(4096 * 4);
const chunks = [];
self.onmessage = ({ data }) => {
if (data === "end") {
const n = m._enc_flush(enc);
chunks.push(m.HEAPU8.slice(m._enc_out_ptr(enc), m._enc_out_ptr(enc) + n));
self.postMessage(new Blob(chunks, { type: "audio/ogg; codecs=opus" }));
return;
}
m.HEAPF32.set(data, pcmPtr >> 2);
const n = m._enc_write(enc, pcmPtr, data.length); // returns bytes of Ogg pages produced
if (n > 0) chunks.push(m.HEAPU8.slice(m._enc_out_ptr(enc), m._enc_out_ptr(enc) + n));
};
The encoder consumes float samples, buffers them into codec frames (20 ms for Opus), and emits Ogg pages as they fill. Copy each output chunk out with
slice before the next call reuses the buffer. Uploading chunks as they are produced — rather than one blob at the end — lets long recordings stream to
the server and survive a crashed tab.
Step 3 — choose codec and settings
For speech, Opus at 24–32 kbps mono in VoIP mode sounds clear and produces about 0.2 MB per minute. For music, Opus at 96–128 kbps stereo is transparent for
most listeners. MP3 is the choice when files must play in old software or devices that lack Opus; LAME at 128 kbps CBR or V2 VBR is a sensible default,
at roughly four times the size of Opus for similar speech quality. Opus natively runs at 48 kHz; if the AudioContext runs at 44.1 kHz, resample to 48 kHz
first (libopus’s companion resampler or a Wasm resampler) rather than mislabelling the rate.
Step 4 — wrap in the right container
Raw Opus packets need a container to be playable: Ogg (.opus/.ogg) is simplest and plays in all major browsers, including Safari since version 17;
WebM is an alternative for video-oriented pipelines. MP3 frames are self-contained and need no container. Write correct headers — Ogg’s OpusHead and
OpusTags pages, including the pre-skip value — so durations and seeking work in players. Test the output in the browsers and apps your users will play it
in, and with ffprobe on the server.
Step 5 — encode offline renders faster than real time
The same worker can encode audio that never passes through a live stream: render a mix with OfflineAudioContext, take its AudioBuffer, and feed the
channel data to the encoder in chunks. Encoding then runs as fast as the CPU allows — a five-minute mix typically encodes in a few seconds — and the result
is identical across browsers, which MediaRecorder cannot offer for offline renders.
Building the encoder module
Both libopus and LAME build cleanly with Emscripten, and a thin C wrapper keeps the JavaScript side simple. Expose a handful of functions — create an
encoder with sample rate, channels and bitrate; write float samples; flush; return a pointer to the output buffer — and keep Ogg page assembly inside C,
using libogg, so JavaScript only ever handles finished bytes. Build with -O3 -flto -msimd128 for speed, -sMODULARIZE -sEXPORT_ES6 so the worker can
import a factory, and -sALLOW_MEMORY_GROWTH with a modest initial memory, since the encoder’s working set is small. Exclude Emscripten’s file-system
support (-sFILESYSTEM=0) because the encoder never touches files, which trims the output noticeably. libopus’s own build options matter too: enabling
floating-point API support and disabling the decoder if you only encode cut the binary further. The compiled module is then typically 250–350 KB, or
about 120 KB with Brotli.
Handling long recordings safely
Recordings can run for an hour or more, and the failure modes grow with duration. Memory: holding every encoded chunk in an array is fine for Opus at speech bitrates (about 12 MB per hour) but not for uncompressed PCM, so never buffer raw samples beyond the encoder’s frame. Durability: a tab can crash, a laptop can sleep, a user can close the window by accident; upload chunks as they are produced, or write them to the Origin Private File System, so the recording can be recovered. Mobile background behaviour: some browsers suspend audio capture when the page is hidden, so warn users or use a wake lock. And clock drift: the audio hardware’s sample clock and the system clock diverge slightly over long recordings, which matters only if you synchronise audio with separately captured video. Ogg pages carry granule positions that let the server verify the recording’s length against the number of samples encoded, a cheap integrity check worth adding on upload.
Expected output
A one-minute voice note records in the browser and encodes in real time in a worker; the resulting .ogg file is about 230 KB at 32 kbps, plays in Chrome,
Firefox and Safari, and has the same format whichever browser recorded it.
Gotchas
- Encoding on the main thread. Can cause audio dropouts and UI jank. Use a worker.
- Wrong sample rate in the header. Audio plays at the wrong pitch. Resample to the encoder’s rate.
- Reusing the output buffer before copying. Chunks get overwritten.
sliceeach output. - Missing container headers. Files play without seeking or duration. Write proper Ogg headers.
- Holding hours of audio in memory. Upload or persist chunks as you go.
Performance note
Encoding one minute of 48 kHz mono speech with the Wasm Opus encoder took about 0.9 s of CPU time in a worker on a laptop (66× faster than real time) and about 3.5 s on a mid-range phone, so live recording never fell behind.
Frequently Asked Questions
Is a Wasm encoder licensed safely? libopus is BSD-licensed. LAME is LGPL; shipping it as a separate Wasm file generally satisfies the licence, but check with your legal advisers.
Can I encode AAC instead?
AAC encoders with permissive licences are rarer; WebCodecs’ AudioEncoder can produce AAC or Opus natively where supported.
What about WebCodecs?
AudioEncoder gives native Opus encoding in Chromium and others; use it where available and keep the Wasm encoder as the consistent fallback.
Does echo cancellation still work?
Yes — it happens in getUserMedia before your worklet sees the samples. Configure it with the media constraints.
Can the same module decode for playback?
It can, but browsers already decode Opus and MP3 natively in <audio> and decodeAudioData; ship only the encoder.
Related
- Running a DSP kernel in an AudioWorklet — processing before encoding.
- Passing audio samples without copying — worklet data handling.
- Transcoding video in the browser with ffmpeg.wasm — the heavyweight alternative.
- Reporting progress from Wasm to the UI — progress for offline encodes.
← Back to Media Processing & Codecs in Wasm