Porting C++ Audio Code to WebAssembly: A Field Guide
More and more audio companies want their desktop tools to run in the browser. The path from native C/C++/Rust DSP to a browser-based product goes through WebAssembly — and while the concept is straightforward, the details matter.
I’ve ported several audio codebases to WASM. Here’s what to expect.
Why port to WebAssembly?
Section titled “Why port to WebAssembly?”The business case is usually one of:
- Reach — A browser-based version reaches anyone with a URL, no install required
- Demos — Let potential customers try your DSP before downloading a native app
- Collaboration — Browser-based tools enable real-time collaboration features
- Distribution — No app store reviews, no signing certificates, no platform fragmentation
The technical case: WebAssembly runs at 80-95% of native speed for number-crunching code like DSP. For most audio applications, this is fast enough.
The build pipeline
Section titled “The build pipeline”Emscripten for C/C++
Section titled “Emscripten for C/C++”Emscripten is the most mature toolchain for compiling C/C++ to WASM. The basic flow:
emcc -O3 \ -s WASM=1 \ -s EXPORTED_FUNCTIONS='["_process_audio", "_init", "_free"]' \ -s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' \ -o dsp.js \ dsp.cKey flags for audio:
-O3— Aggressive optimisation is critical for real-time performance-s ALLOW_MEMORY_GROWTH=0— Fix memory size to avoid GC pauses (pre-allocate what you need)-s TOTAL_MEMORY=16MB— Set based on your buffer sizes and state requirements-s SINGLE_FILE=1— Embeds the WASM binary in the JS file for simpler deployment
wasm-pack for Rust
Section titled “wasm-pack for Rust”For Rust codebases, wasm-pack handles the compilation and generates JavaScript bindings:
wasm-pack build --target web --releaseRust’s wasm-bindgen generates TypeScript-friendly bindings automatically, which is a significant advantage over Emscripten’s manual export declarations.
use wasm_bindgen::prelude::*;
#[wasm_bindgen]pub fn process_audio(input: &[f32], output: &mut [f32], gain: f32) { for (out, inp) in output.iter_mut().zip(input.iter()) { *out = *inp * gain; }}Memory management
Section titled “Memory management”This is where most ports go wrong. The browser and your WASM module have separate memory spaces, and audio data needs to cross that boundary every 2.9ms (128 samples at 44.1kHz).
The copy problem
Section titled “The copy problem”The naive approach copies audio data between JavaScript and WASM memory on every process call:
// Slow: copies data twice per blockconst inputPtr = module._malloc(blockSize * 4);module.HEAPF32.set(inputData, inputPtr / 4);module._process(inputPtr, outputPtr, blockSize);const result = module.HEAPF32.slice(outputPtr / 4, outputPtr / 4 + blockSize);module._free(inputPtr);This allocates and frees memory in the audio callback — exactly what you shouldn’t do.
The shared-buffer approach
Section titled “The shared-buffer approach”Instead, allocate persistent buffers once and reuse them:
class WasmDSP { constructor(module, blockSize) { this.module = module; this.blockSize = blockSize; // Allocate once, reuse forever this.inputPtr = module._malloc(blockSize * 4); this.outputPtr = module._malloc(blockSize * 4); this.inputView = new Float32Array( module.HEAPF32.buffer, this.inputPtr, blockSize ); this.outputView = new Float32Array( module.HEAPF32.buffer, this.outputPtr, blockSize ); }
process(input, output) { this.inputView.set(input); this.module._process(this.inputPtr, this.outputPtr, this.blockSize); output.set(this.outputView); }
dispose() { this.module._free(this.inputPtr); this.module._free(this.outputPtr); }}Zero allocations in the audio callback. The Float32Array views point directly into WASM linear memory, so set() is a memcpy — as fast as it gets.
AudioWorklet integration
Section titled “AudioWorklet integration”The WASM module needs to run inside an AudioWorklet processor to access the real-time audio thread. The challenge: AudioWorklet files are loaded as separate modules, and you need to get the WASM binary to the audio thread.
Approach 1: Compile to the worklet
Section titled “Approach 1: Compile to the worklet”Bundle the WASM module directly into the worklet file (using SINGLE_FILE=1 with Emscripten, or base64-encoding the WASM binary):
// worklet.js — self-contained with embedded WASMconst wasmBinary = /* base64-encoded WASM */;let dsp;
class DSPProcessor extends AudioWorkletProcessor { constructor() { super(); // Instantiate WASM in the constructor (runs once) WebAssembly.instantiate( Uint8Array.from(atob(wasmBinary), c => c.charCodeAt(0)), { env: { /* imports */ } } ).then(module => { dsp = new WasmDSP(module.instance, 128); }); }
process(inputs, outputs) { if (!dsp) return true; dsp.process(inputs[0][0], outputs[0][0]); return true; }}Approach 2: Transfer via MessagePort
Section titled “Approach 2: Transfer via MessagePort”Load the WASM binary on the main thread and transfer it to the worklet:
// Main threadconst response = await fetch('dsp.wasm');const wasmModule = await WebAssembly.compile(await response.arrayBuffer());node.port.postMessage({ type: 'init', module: wasmModule });Compiled WebAssembly.Module objects are transferable, so this is efficient.
Performance benchmarks
Section titled “Performance benchmarks”Real-world numbers from porting a dynamics processor (compressor with lookahead):
| Metric | Native (C++) | WASM (same code) | Overhead |
|---|---|---|---|
| Process 128 samples | 0.8μs | 1.1μs | ~38% |
| Process 1024 samples | 5.2μs | 6.1μs | ~17% |
| Memory usage | 48KB | 52KB + runtime | Minimal |
The overhead decreases with larger block sizes because the fixed cost of the JS↔WASM boundary becomes proportionally smaller. For audio applications, this level of performance is well within real-time budgets.
Common pitfalls
Section titled “Common pitfalls”1. SIMD support
Section titled “1. SIMD support”Native audio code often uses SSE/AVX intrinsics. WASM SIMD is available in all major browsers now, but the instruction set is different. Emscripten can auto-vectorize with -msimd128, or you can use WASM SIMD intrinsics directly:
// Emscripten SIMD#include <wasm_simd128.h>v128_t process_4_samples(v128_t input, v128_t gain) { return wasm_f32x4_mul(input, gain);}2. Thread model differences
Section titled “2. Thread model differences”Native audio code often uses threads for parallel processing. WASM threads require SharedArrayBuffer, which needs specific HTTP headers (Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy). Many hosting setups don’t enable these by default.
3. File system access
Section titled “3. File system access”If your native code reads configuration files or presets from disk, you’ll need to replace that with an in-memory approach or Emscripten’s virtual filesystem.
4. Floating-point determinism
Section titled “4. Floating-point determinism”WASM guarantees IEEE 754 floating-point semantics, which is actually more predictable than native code (where the compiler might use 80-bit x87 registers). This means your WASM output may differ slightly from native — usually this is a non-issue for audio.
When it makes sense
Section titled “When it makes sense”Porting to WASM is worth it when:
- You have existing, well-tested C/C++/Rust DSP code
- You want to reach browsers without rewriting in JavaScript
- Real-time performance matters (not just offline processing)
- You need identical DSP behaviour across web and native platforms
It’s less appropriate when:
- The DSP logic is simple enough to write directly in JavaScript
- You don’t need real-time performance (use Web Workers instead)
- The native code has heavy OS dependencies (file I/O, hardware access)
Have a native audio codebase you want to bring to the web? I specialise in WebAssembly audio ports — from C/C++/Rust DSP to production browser applications. View services → or let’s talk about your project →.