FFmpeg in your browser — and the 2 GB WAV ceiling
OmniViewer's MP3 EXPORT tab converts your audio to WAV, FLAC, M4A, Opus or Ogg Vorbis without uploading a single byte — it runs a full build of FFmpeg compiled to WebAssembly right inside the page. That design is what makes it private and offline-capable; it is also why converting a very long file to WAV can fail with a cryptic Array buffer allocation failed. Here is what WebAssembly is, how the converter works, why the whole output file lives in memory, and the arithmetic for exactly when a WAV crosses the wall.
What OmniViewer is
OmniViewer is a single static web app that inspects and works with a growing set of file formats entirely in your browser — you drop a file on the page and it is never uploaded. Under the hood it is pure client-side JavaScript, Web Workers and WebAssembly, served as static files with no backend to send your data to. Today it covers, among others:
- JSON — a viewer built to open gigabyte-plus files: a dependency-free, byte-level parser streams the file through a pool of workers in 256 KB blocks and renders the pretty-printed output with virtual scrolling.
- CSV — the same streaming philosophy, backed by an in-browser SQLite worker for querying.
- HTML and JS — syntax-aware viewers with worker-side tokenising.
- MP3 — a five-tab audio workbench: a PLAYER with a worker-built waveform, a METADATA editor for ID3 tags, a per-frame STATS table, a byte-level HEX view, and the EXPORT tab this article is about.
The common thread: heavy work happens off the main thread, memory stays bounded where it can, and your file never leaves your machine. The MP3 converter is the one place that last promise is hardest to keep cheaply — because transcoding audio is exactly the kind of job the web platform historically shipped to a server.
Converting an MP3, without a server
Open an MP3 and pick the EXPORT tab, and you can transcode it to five targets — each with a quality control and a plain-language note about the trade-off:
| Target | Kind | Good for |
|---|---|---|
| WAV | Uncompressed PCM | Editing, maximum compatibility, an exact decode — but very large files. |
| FLAC | Lossless, compressed | Archival / further editing at roughly half the size of WAV, with no further loss. |
| M4A / AAC | Lossy | Apple devices and small files (with a licensing asterisk). |
| Opus | Lossy | The best modern size-to-quality ratio for web and mobile. |
| Ogg Vorbis | Lossy | An open-format lossy option. |
Everything about that conversion — decoding the MP3, resampling, re-encoding, muxing the container, carrying your edited tags across — runs on your device. There is no POST of your audio anywhere. The only network traffic is a one-time ~31 MB download of the converter itself the first time you convert; after that it is cached and reused. That is a deliberate privacy and cost decision, and it is made possible by WebAssembly.
What WebAssembly actually is
WebAssembly (wasm) is a compact binary instruction format that runs in the same sandbox as JavaScript, at close to native speed. It is not a language you write by hand; it is a compilation target. FFmpeg is ~1.5 million lines of C. A toolchain called Emscripten compiles that C to a .wasm module plus a small JavaScript “glue” file that loads it and bridges calls in and out. The result — @ffmpeg/core, which OmniViewer vendors at version 0.12.10 — is a ~31 MB wasm binary that is FFmpeg, running in the tab.
Two properties of wasm matter for the rest of this article:
- It has one big, flat block of memory. A wasm module works against a single contiguous array of bytes called its linear memory. On the JavaScript side that memory is exposed as one
ArrayBuffer(viaWebAssembly.Memory). Every pointer inside the compiled C program is an index into that one array. - Today's wasm is 32-bit. The widely shipped
wasm32target uses 32-bit pointers, so the entire address space — all of a module's memory at once — tops out at 232 bytes = 4 GiB, and in practice browsers can rarely grow that single backing buffer past ~2 GB. (A 64-bit “memory64” proposal exists but is not something a portable, vendored FFmpeg build can rely on.)
Hold onto those two facts. Together they are the whole story of the WAV ceiling.
How OmniViewer drives FFmpeg
The converter lives in a dedicated Web Worker (mp3-export-worker.js), so the ~31 MB parse and the encode never freeze the UI thread. A few non-obvious engineering choices:
- We drive the wasm core directly. The usual
@ffmpeg/ffmpegwrapper spins up its ownblob:worker andblob:core URL, which fights the site's strict Content-Security-Policy. Running the ESM core inside our own module worker — which, served with no CSP header, carries none of its own — sidesteps that entirely, with no policy change to the main page. - We stream the wasm ourselves to show a real download progress bar (Emscripten gives none), then hand the bytes to the core via
Module.wasmBinary. - The core is loaded once and reused across conversions; nothing is fetched until your first Convert click.
The actual conversion is close to a command line you would type locally:
// main thread → worker
worker.postMessage({ type:'convert', format:'wav', buf, … }, [buf]);
// worker, driving @ffmpeg/core
core.FS.writeFile('input.mp3', bytes); // (1) input → in-memory FS
core.exec('-i','input.mp3','-c:a','pcm_s16le', // (2) decode + re-encode
'-y','out.wav');
const data = core.FS.readFile('out.wav'); // (3) output ← in-memory FS
postMessage({ type:'done', data }, [data.buffer]);
Look closely at steps (1) and (3): FS.writeFile and FS.readFile. FFmpeg thinks it is reading and writing files — but there is no disk in a browser tab.
Why the whole file is “in memory”
Emscripten gives the compiled program a fake filesystem called MEMFS. It looks like files and directories to FFmpeg, but every byte of every “file” is stored inside the wasm module's single linear-memory ArrayBuffer — the same 32-bit array from the WebAssembly section. So during a WAV export, that one buffer is simultaneously holding:
wasm linear memory (one ArrayBuffer, wasm32 → must fit under ~2 GB)
┌───────────────────────────────────────────────────────────────────┐
│ FFmpeg code + heap MEMFS /input.mp3 MEMFS /out.wav (GROWING)│
│ ┌────────────────┐ ┌──────────────┐ ┌────────────────────┐ │
│ │ ~tens of MB │ │ your MP3 │ │ decoded PCM, │ │
│ │ │ │ (compressed, │ │ uncompressed — │──┼──▶ grows
│ │ │ │ smallish) │ │ ~11× the MP3 │ │ every
│ └────────────────┘ └──────────────┘ └────────────────────┘ │ frame
└───────────────────────────────────────────────────────────────────┘
│
as out.wav grows, the runtime must memory.grow() the whole buffer;
when the browser can't hand back a contiguous block that big ✗ ─────┘
→ "Array buffer allocation failed"
The input MP3 is compressed, so it is comparatively small. The output WAV is not — it is raw PCM samples, and it grows with every frame FFmpeg decodes. As it grows, the runtime has to enlarge the entire linear memory (WebAssembly.Memory.prototype.grow), which means asking the JavaScript engine for a bigger single contiguous ArrayBuffer and copying into it. When that requested size approaches the 2 GB range, the allocation fails — and because MEMFS is that buffer, the encode dies exactly where it stands.
“Is a Web Worker the fix?” No — the converter is already in a worker, and moving it to another one wouldn't help. Each wasm instance gets its own linear memory with the same 32-bit ceiling; a second worker just gives you a second 2 GB wall. The limit is per-instance, not per-thread. And note that even reading the finished file out with FS.readFile makes a second full copy in the JS heap before the Blob — so the practical ceiling is lower still.
The 2 GB wall, and the error you saw
When a WAV would exceed what the linear memory can grow to, the wasm runtime aborts and the browser surfaces:
RangeError: Array buffer allocation failed
It is not a corrupt file or a bug in the audio path — it is the JavaScript engine refusing to allocate a 2 GB-class contiguous ArrayBuffer for the growing MEMFS. FLAC, M4A, Opus and Ogg almost never hit it because their output is compressed — a fraction of the PCM size. WAV is the one uncompressed target, so it is the only one that realistically reaches the wall, and only for long recordings.
The math: when does a WAV cross 2 GB?
An uncompressed WAV's size is fixed by the audio's shape, not its loudness or content:
bytes = duration(s) × sampleRate(Hz) × channels × (bitDepth / 8) + 44-byte header
For the ubiquitous 44.1 kHz, stereo, 16-bit case:
bytes/sec = 44100 × 2 channels × 2 bytes = 176,400 B/s
= 172.3 KiB/s
= 1,411,200 bits/s → 1411.2 kbit/s ← the "bitrate" FFmpeg logs for a WAV
That 1411.2 kbits/s is precisely the figure in the FFmpeg progress lines when a long MP3 export was climbing toward the wall. Divide the ceiling by that rate to get the duration at which WAV output reaches 2 GiB:
2 GiB / 176,400 B/s = 2,147,483,648 / 176,400 ≈ 12,174 s ≈ 3 h 23 min
| Audio | WAV size per hour | Reaches 2 GiB at… |
|---|---|---|
| 44.1 kHz · stereo · 16-bit | ~605 MiB/h | ~3 h 23 min |
| 44.1 kHz · stereo · 24-bit | ~908 MiB/h | ~2 h 15 min |
| 48 kHz · stereo · 24-bit | ~989 MiB/h | ~2 h 04 min |
| 44.1 kHz · mono · 16-bit | ~303 MiB/h | ~6 h 46 min |
Relative to the source MP3, the expansion is dramatic: a 128 kbps MP3 becomes a 1411 kbps WAV — about 11× larger (1,411,200 / 128,000 ≈ 11.0). A three-hour podcast that is a ~165 MB MP3 decodes to a ~1.8 GB WAV; push past ~3½ hours and 16-bit WAV alone no longer fits. This is a property of PCM audio and 32-bit wasm — not of OmniViewer specifically. Any purely in-browser FFmpeg converter shares the same ceiling.
What OmniViewer does about it
Rather than let you wait through a long encode only to have it abort at the finish line, the EXPORT tab now estimates the WAV size before starting. It reads the first MP3 frame header for the sample rate and channel count, estimates the duration from the file size and bitrate, and computes the formula above for your selected bit depth. If the result would exceed ~2 GB, the WAV card is disabled with a plain explanation — and it points you at FLAC, which gives you lossless audio (the MP3's exact decoded samples, no further loss) in a compressed file that comfortably fits.
Rule of thumb. If you need a lossless copy of a long recording in the browser, choose FLAC, not WAV. It sidesteps the memory wall while preserving exactly the same audio. Reserve WAV for shorter clips or when a downstream tool truly requires uncompressed PCM.
The real, unbounded fix — segmenting the encode and streaming each piece straight to disk via the File System Access API, so peak memory stays small no matter the length — is tracked in the backlog. The size guard is the honest interim: a clear “can't do that here, do this instead” beats a RangeError two hours in.
References & specs
- WebAssembly Core Specification — the linear-memory model (§ Memory Instances).
- WebAssembly JS API —
WebAssembly.Memoryandgrow(), which back theArrayBuffer. - wasm feature status — including the memory64 proposal (why 4 GiB+ isn't portable yet).
- Emscripten Filesystem API (MEMFS) — the in-memory filesystem
FS.writeFile/FS.readFileuse. - ECMAScript — ArrayBuffer objects — the JS side of the allocation that fails.
- MDN —
Memory.prototype.grow(). - ffmpeg.wasm and
@ffmpeg/core— the vendored FFmpeg-to-wasm build (single-thread, GPL-2.0-or-later). - WAV / RIFF and the PCM sizing that drives the math above.