Skip to content

HumanSignal/audio-file-decoder

 
 

Repository files navigation

@humansignal/audio-file-decoder

npm version

A high-performance library for decoding audio files and timestamp ranges in browser environments. Powered by FFmpeg and compiled to WebAssembly via Emscripten.


Key Features

  • Standard Decoding: Decodes entire audio files or specific timestamp ranges synchronously or asynchronously (via Web Workers).
  • Streaming Mode: Built to handle extremely large audio files (e.g., >24 hours) by dynamically requesting byte ranges over the network without buffering the entire file in memory.
  • 2MB Range Cache: Minimizes network overhead in streaming mode by batch-fetching 2MB chunks and satisfying subsequent decodes from memory.
  • PTS-Aligned Sample Accuracy: Leverages frame Presentation Timestamps (PTS) to guarantee sample-accurate waveform alignment, automatically padding gaps or discarding offsets.
  • Multi-Channel Support: Supports returning interleaved multi-channel samples or auto-downmixing to mono.
  • Dynamic URL Refreshing: Automatically handles expiring presigned URLs (e.g., AWS S3, GCS) by allowing hot-swapping of the stream URL in-flight without interrupting active decoders.
  • Production Optimized: Statically built with Emscripten -O3 and -DNDEBUG optimizations to suppress debug prints and reduce the worker bundle size by 30%.

Supported Formats

  • MP3 (via libmp3lame)
  • WAV (PCM 8/16/24/32-bit, Float 32-bit)
  • FLAC
  • AAC / M4A
  • OGG / Vorbis / Opus (via libopus)

Installation

npm install @humansignal/audio-file-decoder

Usage / API

1. Standard Synchronous Decoder

Loads the entire file array buffer into WebAssembly memory. Useful for quick synchronous operations on smaller files.

import { getAudioDecoder } from '@humansignal/audio-file-decoder';
import decodeWasmUrl from '@humansignal/audio-file-decoder/decode-audio.wasm?url';

const fileOrArrayBuffer = await fetch('audio.mp3').then(r => r.arrayBuffer());

const decoder = await getAudioDecoder(decodeWasmUrl, fileOrArrayBuffer);

console.log({
  sampleRate: decoder.sampleRate,
  channelCount: decoder.channelCount,
  encoding: decoder.encoding,
  duration: decoder.duration // In seconds
});

// Decode the first 10 seconds (returns interleaved Float32Array)
const samples = decoder.decodeAudioData(0, 10, { multiChannel: true });

// Always dispose to free WASM heap resources
decoder.dispose();

2. Asynchronous Worker Decoder (Standard)

Runs the decoder inside a background Web Worker, keeping the main UI thread completely responsive.

import { getAudioDecoderWorker } from '@humansignal/audio-file-decoder';
import decodeWasmUrl from '@humansignal/audio-file-decoder/decode-audio.wasm?url';

const worker = await getAudioDecoderWorker(decodeWasmUrl, audioFileBlob, { stream: false });

// Decode a 30-second range asynchronously
const samples = await worker.decodeAudioData(60, 30, { multiChannel: false });

worker.dispose();

3. Asynchronous Worker Decoder (Streaming Mode)

Enables streaming chunk retrieval for large files. Instead of loading the full file, it uses HTTP Range requests in the worker.

import { getAudioDecoderWorker } from '@humansignal/audio-file-decoder';
import decodeWasmUrl from '@humansignal/audio-file-decoder/decode-audio.wasm?url';

// Passes a URL instead of a file/buffer to automatically enable streaming
const worker = await getAudioDecoderWorker(decodeWasmUrl, 'https://example.com/long-podcast.mp3', {
  stream: true
});

// Decodes a slice. The worker only downloads the bytes needed for this range.
const samples = await worker.decodeAudioData(3600, 10);

4. Dynamic URL Refreshing (Presigned URL Expiration)

When loading files from S3 or proxy redirectors, signed URLs expire. The worker will propagate an HTTP_STATUS_403 or HTTP_STATUS_401 error which can be caught in the main thread to refresh the URL dynamically.

import { getAudioDecoderWorker } from '@humansignal/audio-file-decoder';
import decodeWasmUrl from '@humansignal/audio-file-decoder/decode-audio.wasm?url';

const originalUrl = '/api/media/redirect-to-s3';

const worker = await getAudioDecoderWorker(decodeWasmUrl, originalUrl, { stream: true });

async function safeDecode(start: number, duration: number) {
  try {
    return await worker.decodeAudioData(start, duration);
  } catch (err: any) {
    const isExpired = err?.message?.includes("HTTP_STATUS_403") || err?.message?.includes("HTTP_STATUS_401");
    
    if (isExpired) {
      console.warn("Presigned URL expired. Refreshing...");
      
      // Fetch the redirect URL again to get a fresh presigned URL
      const response = await fetch(originalUrl);
      if (response.body) {
        response.body.cancel().catch(() => {});
      }
      const freshPresignedUrl = response.url;
      
      // Push the fresh URL to the worker in-place
      worker.updateUrl(freshPresignedUrl);
      
      // Retry the decode request
      return await worker.decodeAudioData(start, duration);
    }
    throw err;
  }
}

Configuration Options

DecodeAudioOptions

Passed to decodeAudioData:

interface DecodeAudioOptions {
  // Whether to preserve multiple channels.
  // - If true: returns interleaved samples: [L0, R0, L1, R1, ...]
  // - If false (default): downmixes to mono by averaging all channel samples
  multiChannel?: boolean;
}

WasmAudioStreamConfig

Can be passed to getAudioDecoderWorker as the source parameter instead of a string to provide advanced details:

interface WasmAudioStreamConfig {
  url?: string;         // The stream URL
  fileOrBlob?: Blob;    // Local file handle (falls back to local FileReaderSync streaming)
  size: number;         // Total size of the file in bytes (avoids initial fetch)
}

Bundler Integration

Vite

Import the WASM file with the ?url query suffix:

import decodeWasmUrl from '@humansignal/audio-file-decoder/decode-audio.wasm?url';

Webpack 5

Enable WebAssembly experiments in your webpack.config.js:

module.exports = {
  experiments: {
    asyncWebAssembly: true,
  },
  module: {
    rules: [
      {
        test: /\.wasm$/,
        type: 'asset/resource',
      }
    ]
  }
};

Local Development & Contribution

1. Build Requirements

  • Docker (recommended for containerized building)
  • Or, local Emscripten SDK (v2.0.1) and PKG_CONFIG dependencies.

2. Building WASM & JS

To compile the C++ FFmpeg code and package the Javascript bundles:

# Build the WebAssembly binaries and JS bundles inside Docker (handles Emscripten)
./docker-build.sh quick

# Extract the compiled artifacts to the local ./dist folder
./docker-build.sh artifacts

3. Local Development Integration (bun link / npm link)

To test edits instantly in a local consuming project (e.g. hs-platform) without manually copying files:

  1. Register this package globally:
    # Inside /code/audio-file-decoder
    bun link
  2. Link it in your consumer project:
    # Inside the web app folder of your project
    bun link @humansignal/audio-file-decoder
  3. Now, whenever you run ./docker-build.sh quick, the changes will immediately reflect in the consumer app.

4. Build Configuration (Debug vs. Release)

By default, builds compile in optimized Release mode (-O3 -DNDEBUG), removing debug symbols and removing console log overhead.

If you need to debug the C++ core or Web Worker:

# Compile a debug build with -g and -O0
DEBUG=1 ./docker-build.sh quick

License

Licensed under LGPL v2.1 or later. See the LICENSE file for details.

About

decode audio files using FFmpeg and WebAssembly

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages

  • C++ 31.7%
  • TypeScript 21.9%
  • JavaScript 15.6%
  • Dockerfile 13.7%
  • Makefile 12.8%
  • Shell 4.3%