Skip to main content

Audio Pipeline

The #1 user complaint across all browser emulators is audio crackling. We fix it permanently.


The Problem

Every browser N64 emulator suffers from audio issues:

EmulatorAudio Issues
EmulatorJSChrome/Safari static, Firefox degradation after 5 seconds
N64WasmSome users get no audio at all
mupen64plus-web"Audio is experimental"

Root cause: They all use ScriptProcessorNode (deprecated since 2014) or the Emscripten OpenAL bridge, both of which process audio on the main thread with unpredictable timing.

When the main thread is busy rendering a frame or handling input, audio processing gets delayed → pops, crackles, static.


Our Architecture: AudioWorklet

Why AudioWorklet Is Superior

ScriptProcessorNodeAudioWorklet
ThreadMain threadDedicated audio thread
TimingIrregular (event-based)Deterministic (128 samples)
LatencyHigh (2048+ samples)Low (128 samples)
PriorityCompetes with renderingOS real-time priority
StatusDeprecatedStandard
Buffer underrunAudible popGraceful interpolation

Implementation

Ring Buffer (SharedArrayBuffer)

// Shared between emulation worker and audio worklet
class AudioRingBuffer {
private buffer: Float32Array; // SharedArrayBuffer-backed
private writeHead: Int32Array; // Atomic write position
private readHead: Int32Array; // Atomic read position
private capacity: number;

write(samples: Float32Array): void {
const head = Atomics.load(this.writeHead, 0);
// Copy samples into ring buffer
for (let i = 0; i < samples.length; i++) {
this.buffer[(head + i) % this.capacity] = samples[i];
}
Atomics.store(this.writeHead, 0, (head + samples.length) % this.capacity);
Atomics.notify(this.writeHead, 0);
}

read(output: Float32Array): number {
const write = Atomics.load(this.writeHead, 0);
const read = Atomics.load(this.readHead, 0);
const available = (write - read + this.capacity) % this.capacity;
const toRead = Math.min(available, output.length);
for (let i = 0; i < toRead; i++) {
output[i] = this.buffer[(read + i) % this.capacity];
}
Atomics.store(this.readHead, 0, (read + toRead) % this.capacity);
return toRead;
}
}

AudioWorklet Processor

// audio-processor.js (runs on audio thread)
class N64AudioProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.ringBuffer = null;
}

process(inputs, outputs, parameters) {
const output = outputs[0];
const left = output[0]; // 128 samples
const right = output[1]; // 128 samples

if (this.ringBuffer) {
// Read interleaved stereo from ring buffer
const stereo = new Float32Array(256);
const read = this.ringBuffer.read(stereo);

for (let i = 0; i < 128; i++) {
left[i] = i < read / 2 ? stereo[i * 2] : left[i - 1] || 0;
right[i] = i < read / 2 ? stereo[i * 2 + 1] : right[i - 1] || 0;
}
}

return true; // Keep processor alive
}
}

registerProcessor('n64-audio', N64AudioProcessor);

Audio-Driven Frame Pacing

The audio system becomes the master clock for emulation. This is the same technique used by the most polished native emulators (RetroArch, Ares):

Why this works:

  • Audio hardware has a fixed sample rate (48kHz) — it's the most consistent clock in the system
  • When the audio buffer runs low, emulation runs faster to fill it
  • When the buffer is full, emulation pauses naturally
  • Result: perfectly smooth audio AND correct emulation speed

Sample Rate Conversion

The N64 outputs audio at variable rates (typically 22050Hz or 32000Hz depending on the game). We resample to the system rate (typically 48000Hz):

N64 Audio Output (22050Hz) → Blip Buffer Resampler → Ring Buffer (48000Hz) → AudioWorklet → Speakers

The resampler uses band-limited interpolation (blip_buf algorithm) for high-quality upsampling without aliasing artifacts.


Graceful Degradation

If the emulator can't keep up (low-end device):

  1. Buffer underrun → Interpolate last known samples (smooth fade, not silence pop)
  2. Sustained underrun → Drop emulation to 55fps (90% speed) — better than choppy audio
  3. Audio context suspended (user hasn't interacted) → Show "Tap to start" overlay

This ensures audio never pops or crackles, even on slow devices. The worst case is slightly slow gameplay — not broken audio.