Skip to content

Streamed Opus File

Streamed Opus File solves a common problem in web audio: how do you start playing a compressed audio file before it finishes downloading? Instead of fetching the entire Opus file and decoding it in one shot, this project streams chunks through a WebAssembly-based Opus decoder running in a Web Worker, then pipes the decoded PCM samples into an AudioWorklet for gapless real-time playback. The result is near-instant playback start with minimal memory overhead — critical for music apps, game audio, and interactive experiences on bandwidth-constrained connections.

The architecture is a three-stage pipeline that keeps the main thread completely free:

  1. Fetch + stream — The browser’s ReadableStream API pulls chunks from the network as they arrive
  2. Decode in a Worker — A dedicated Web Worker runs OpusStreamDecoder, a C-to-WebAssembly compiled Opus decoder (via Emscripten), decoding each chunk into stereo Float32Array PCM
  3. Playback via AudioWorklet — Decoded samples are transferred (zero-copy via Transferable objects) to a custom AudioWorkletProcessor that writes them straight into the audio output buffer
// index.ts — Main thread orchestration
const decoder = new Worker(decoderUrl, { type: 'module' });
await context.audioWorklet.addModule(processorUrl);
const node = new AudioWorkletNode(context, 'stream-audio-processor', {
numberOfInputs: 0,
numberOfOutputs: 1,
outputChannelCount: [2]
});
decoder.addEventListener('message', (ev) => {
// Transfer decoded buffers to the worklet — no copying
node.port.postMessage(
{ left: ev.data.left, right: ev.data.right, index: ev.data.index },
[ev.data.left, ev.data.right]
);
});

The worklet processor maintains pre-allocated Int16Array ring buffers (sized for ~390 seconds of audio at 48 kHz) and converts to float on the fly during the process() callback — keeping allocations out of the real-time audio thread:

// stream-audio-processor.ts — Runs on the audio rendering thread
for (let i = 0; i < leftChannel.length; i++) {
const sourceIndex = this.index + i;
leftChannel[i] = (this.leftBuffer[sourceIndex] || 0) / 0x7FFF;
rightChannel[i] = (this.rightBuffer[sourceIndex] || 0) / 0x7FFF;
}
this.index += leftChannel.length;
  • Streaming decode — Playback begins as soon as the first Opus page is decoded, not after the full file downloads
  • Off-main-thread architecture — Decoding runs in a Web Worker, playback runs in an AudioWorklet; the main thread does zero audio processing
  • WebAssembly Opus decoder — Uses a compiled-from-C libopus via Emscripten for native-speed decoding in the browser
  • Zero-copy buffer transfer — Decoded PCM buffers are sent between Worker → Worklet using Transferable objects, eliminating expensive data copies
  • Stereo 48 kHz output — Matches the Opus codec’s native sample rate for artifact-free playback
  • Bun-powered dev serverserve.ts transpiles TypeScript on the fly with Bun.build, enabling a zero-config development workflow
  • GitHub Pages deploy — CI workflow builds with Bun and deploys to GitHub Pages automatically
LanguageTypeScript, JavaScript
Audio CodecOpus (via Emscripten/WebAssembly)
Web APIsAudioWorklet, Web Workers, ReadableStream, Transferable Objects
Build / DevBun (Bun.build, Bun.serve)
DeployGitHub Pages via GitHub Actions

A live demo is deployed at jadujoel.github.io/streamed-opus-file.

The source code is available on the project’s GitHub repository.