Background: The Allure of WASM Threads + SIMD for Audio

Modern browsers expose WebAssembly (WASM) with two extensions that look perfect for audio‑signal work: threads and SIMD. The idea is simple—offload heavy DSP kernels to a background worker, run vectorised operations on 128‑bit registers, and achieve sub‑millisecond latency. Many developers start a prototype and see impressive benchmark numbers on a single desktop Chrome instance. That success, however, masks a set of hidden liabilities that surface once the code leaves the lab.

Step 1 — Preparing the Build Toolchain

To experiment you need a recent version of emscripten (≥ 3.1.20) with the -sUSE_PTHREADS and -msimd128 flags. The following Makefile snippet pulls everything together:

# Makefile
EMCC=emcc
CFLAGS=-O3 -msimd128 -pthread -s USE_PTHREADS=1 \
       -s PTHREAD_POOL_SIZE=4 -s MODULARIZE=1 \
       -s EXPORT_NAME="createAudioModule" \
       -s ALLOW_MEMORY_GROWTH=1

all: audio.wasm

audio.wasm: audio.c
	$(EMCC) $(CFLAGS) $< -o $@

The PTHREAD_POOL_SIZE flag creates a pool of native threads that the WASM runtime can schedule. Note that each thread consumes a separate OS‑level stack, which quickly eats memory on low‑end devices.

Step 2 — Writing a SIMD‑Accelerated Filter

Below is a minimal high‑pass filter that processes 128‑sample blocks using SIMD intrinsics. The function is deliberately simple so the focus stays on the surrounding infrastructure.

#include <emscripten.h>
#include <wasm_simd128.h>

#define BLOCK_SIZE 128

EMSCRIPTEN_KEEPALIVE
void highpass_f32(float *input, float *output, int length, float alpha) {
    v128_t prev = wasm_f32x4_splat(0.0f);
    for (int i = 0; i < length; i += BLOCK_SIZE) {
        for (int j = 0; j < BLOCK_SIZE; j += 4) {
            v128_t cur = wasm_v128_load(&input[i + j]);
            v128_t diff = wasm_f32x4_sub(cur, prev);
            v128_t hp = wasm_f32x4_mul(diff, wasm_f32x4_splat(alpha));
            wasm_v128_store(&output[i + j], hp);
            prev = cur;
        }
    }
}

The function is exported automatically because of EMSCRIPTEN_KEEPALIVE. In a real‑world audio pipeline you would also need to handle channel interleaving and sample‑rate conversion, but those details are omitted for brevity.

Step 3 — Hooking the Module into an AudioWorklet

The browser side creates an AudioWorkletNode that forwards audio buffers to the WASM thread pool via postMessage. The following JavaScript glue code demonstrates the wiring:

// audio-worklet.js
class WasmAudioProcessor extends AudioWorkletProcessor {
  constructor(options) {
    super();
    this.port.onmessage = this.handleMessage.bind(this);
    this.ready = this.loadWasm(options.wasmUrl);
  }

  async loadWasm(url) {
    const response = await fetch(url);
    const bytes = await response.arrayBuffer();
    const module = await createAudioModule({
      wasmBinary: bytes,
      locateFile: () => url
    });
    this.wasm = module;
  }

  handleMessage(event) {
    // In a full implementation you would route data to a specific thread.
  }

  process(inputs, outputs) {
    const input = inputs[0][0];
    const output = outputs[0][0];
    if (!input || !output) return true;
    // Convert Float32Array to WASM memory.
    const ptrIn = this.wasm._malloc(input.length * 4);
    const ptrOut = this.wasm._malloc(output.length * 4);
    this.wasm.HEAPF32.set(input, ptrIn / 4);
    this.wasm._highpass_f32(ptrIn, ptrOut, input.length, 0.5);
    const result = new Float32Array(this.wasm.HEAPF32.buffer, ptrOut, output.length);
    output.set(result);
    this.wasm._free(ptrIn);
    this.wasm._free(ptrOut);
    return true;
  }
}

registerProcessor('wasm-audio-processor', WasmAudioProcessor);

The glue code looks clean, but each process call allocates and frees native memory, which adds unpredictable latency. Moreover, the AudioWorklet thread runs at the audio rendering rate (≈ 128 samples per 2.9 ms on 44.1 kHz), leaving little room for GC pauses or thread‑scheduling jitter.

Hidden Liability #1 — Thread‑Scheduling Jitter on the Main UI Thread

Browsers schedule WASM threads on the same thread pool used for JavaScript, layout, and painting. When a page loads additional scripts, the scheduler may pre‑empt your audio threads, causing occasional buffer underruns. This is especially true on mobile Safari where the thread pool size is capped at two. Even with the PTHREAD_POOL_SIZE set to four, the runtime silently collapses the pool, and you lose parallelism without warning.

// Detecting dropped frames (debug only)
let dropped = 0;
self.port.onmessage = (e) => {
  if (e.data.type === 'underrun') dropped++;
  console.log(`Audio underruns so far: ${dropped}`);
};

The diagnostic code above shows that underruns can appear sporadically, making it hard to guarantee a deterministic latency budget for live‑performance apps.

Hidden Liability #2 — SIMD Compatibility Gaps

While Chrome, Edge, and recent versions of Firefox ship with SIMD128, Safari still lags behind. A feature‑detecting snippet is therefore mandatory:

if (!WebAssembly.validate(
  new Uint8Array([0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00])
)) {
  console.warn('WASM SIMD not supported – falling back to scalar code');
}

Falling back to scalar code forces you to maintain two code paths, increasing maintenance burden. In many cases developers simply abort on unsupported browsers, unintentionally alienating a large user base.

Hidden Liability #3 — Memory‑Growth Overhead

The flag ALLOW_MEMORY_GROWTH=1 permits the WASM heap to expand at runtime. Each growth triggers a full copy of the linear memory, which can stall the audio thread for several milliseconds. On devices with 2 GB RAM, repeated growth can also lead to out‑of‑memory crashes.

// Pre‑allocate a generous heap (in bytes) to avoid growth
var wasmModule = await createAudioModule({
  wasmBinary: wasmBytes,
  wasmMemory: new WebAssembly.Memory({
    initial: 256, // 256 MiB
    maximum: 512,
    shared: true
  })
});

Over‑allocating memory reduces the chance of growth but wastes RAM on low‑end