Skip to main content

Exporting MP3 in the Browser Without Freezing the Page: lamejs in a Web Worker, Transferable Buffers and a Progress Bar

Updated 8 min readMarkdown
Exporting MP3 in the Browser Without Freezing the Page: lamejs in a Web Worker, Transferable Buffers and a Progress Bar

The audio trimmer on Meme Tap, the meme soundboard I built, ends with a choice: download the clip as WAV or as MP3. WAV is easy. It's raw samples with a 44-byte header, and writing one is a loop. MP3 is a real encoder doing real work: a psychoacoustic model, frame packing, bit reservoir. Run it on the main thread on a mid-range phone and the page can stop responding while it works. The progress bar you so carefully added doesn't move, because the thread that would repaint it is busy encoding.

This post is the pattern I use to keep that work off the main thread: decode with the Web Audio API, hand the samples to a Web Worker without copying them, encode with lamejs in chunks, and stream progress back. The code is a generic version, with the encoder core tested in Node, not a copy of Meme Tap's production source.

The pieces

  1. Main thread: read the file, decode it to PCM with decodeAudioData, slice the selected range, post it to a worker.
  2. Worker: convert float samples to 16-bit integers, feed lamejs in blocks, post progress, post the finished MP3 bytes.
  3. Main thread: wrap the bytes in a Blob and trigger a download.

Decoding has to happen on the main thread (or in an OfflineAudioContext, which is also main-thread only), because AudioContext isn't available in workers. That's fine: decoding is done by the browser's native code and is quick for short clips. The expensive part is encoding, and that's what moves.

Decoding and slicing

// main.js
async function decodeFile(file) {
  const ctx = new AudioContext();
  try {
    const buf = await ctx.decodeAudioData(await file.arrayBuffer());
    return buf;
  } finally {
    ctx.close();
  }
}

function sliceChannels(audioBuffer, startSec, endSec) {
  const sr = audioBuffer.sampleRate;
  const from = Math.max(0, Math.floor(startSec * sr));
  const to = Math.min(audioBuffer.length, Math.ceil(endSec * sr));
  const channels = [];
  // MP3 is mono or stereo, so keep at most two channels
  for (let c = 0; c < Math.min(2, audioBuffer.numberOfChannels); c++) {
    // slice() copies, so the worker owns its own buffer
    channels.push(audioBuffer.getChannelData(c).slice(from, to));
  }
  return { channels, sampleRate: sr };
}

Two details matter here. getChannelData() returns a view into the AudioBuffer's memory, and you can't transfer that. slice() gives you a fresh Float32Array you can transfer. And MP3 only does mono or stereo, so a 5.1 file gets its first two channels. For meme clips that has never come up, but it's one line to be safe.

Transfer, don't copy

postMessage normally copies data with the structured clone algorithm. For a stereo clip of a few seconds, that's a few megabytes copied for nothing. Pass the underlying ArrayBuffers in the transfer list instead, and ownership moves to the worker instantly:

const worker = new Worker(new URL("./mp3-worker.js", import.meta.url), { type: "module" });

function encodeInWorker({ channels, sampleRate }, kbps, onProgress) {
  return new Promise((resolve, reject) => {
    const id = crypto.randomUUID();
    const onMessage = ({ data }) => {
      if (data.id !== id) return;
      if (data.type === "progress") onProgress(data.value);
      if (data.type === "done") { cleanup(); resolve(new Blob(data.parts, { type: "audio/mpeg" })); }
      if (data.type === "error") { cleanup(); reject(new Error(data.message)); }
    };
    const cleanup = () => worker.removeEventListener("message", onMessage);
    worker.addEventListener("message", onMessage);
    worker.postMessage(
      { id, channels, sampleRate, kbps },
      channels.map((ch) => ch.buffer) // transfer list
    );
  });
}

After this call, channels[0].length on the main thread is 0. The buffers are detached. If you still need the samples (to redraw a waveform, say), keep the AudioBuffer and slice again later rather than holding on to these arrays.

The id lets one worker handle several requests without mixing up their messages, which matters if someone taps "Download MP3" twice.

The worker

I use @breezystack/lamejs, a maintained fork of lamejs that ships an ES module and types. The original package on npm has a known MPEGMode is not defined error in some bundler setups.

// mp3-worker.js
import { Mp3Encoder } from "@breezystack/lamejs";

function floatTo16(input) {
  const out = new Int16Array(input.length);
  for (let i = 0; i < input.length; i++) {
    const s = Math.max(-1, Math.min(1, input[i]));
    out[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
  }
  return out;
}

self.onmessage = ({ data }) => {
  const { id, channels, sampleRate, kbps = 128 } = data;
  try {
    const left = floatTo16(channels[0]);
    const right = channels[1] ? floatTo16(channels[1]) : undefined;
    const encoder = new Mp3Encoder(right ? 2 : 1, sampleRate, kbps);

    const BLOCK = 1152 * 20; // a multiple of the MP3 frame size
    const parts = [];
    let lastReport = 0;

    for (let i = 0; i < left.length; i += BLOCK) {
      const l = left.subarray(i, i + BLOCK);
      const r = right ? right.subarray(i, i + BLOCK) : undefined;
      const chunk = r ? encoder.encodeBuffer(l, r) : encoder.encodeBuffer(l);
      if (chunk.length) parts.push(new Uint8Array(chunk));

      const progress = Math.min(1, (i + BLOCK) / left.length);
      if (progress - lastReport >= 0.02 || progress === 1) {
        self.postMessage({ id, type: "progress", value: progress });
        lastReport = progress;
      }
    }

    const tail = encoder.flush();
    if (tail.length) parts.push(new Uint8Array(tail));

    self.postMessage({ id, type: "done", parts }, parts.map((p) => p.buffer));
  } catch (err) {
    self.postMessage({ id, type: "error", message: String(err?.message || err) });
  }
};

Notes on the choices:

  • Clamping before converting. Decoded audio can contain samples slightly above 1.0, especially from loud, clipped meme clips. Without the clamp, 1.02 * 0x7fff wraps around in an Int16Array and you get a loud click.
  • Asymmetric scaling. 16-bit audio runs from -32768 to 32767, so negatives multiply by 0x8000 and positives by 0x7fff.
  • Block size. An MP3 frame is 1152 samples per channel. Feeding a multiple of that keeps the encoder's internal buffering simple. Twenty frames per call is a good balance between overhead and how often you can report progress.
  • Throttled progress. Posting a message for every block of a long file floods the main thread with tiny tasks. Reporting every 2% is plenty for a progress bar.
  • new Uint8Array(chunk). lamejs returns each chunk as an Int8Array. A Blob would accept that as it is, but bytes are unsigned, and the conversion keeps the values honest if you ever inspect, hash or concatenate the parts yourself.
  • flush(). It writes the final partial frame. Forget it and the last fraction of a second is missing, which on a two-second meme clip is the punchline.

The download

async function downloadMp3(file, startSec, endSec) {
  const decoded = await decodeFile(file);
  const sliced = sliceChannels(decoded, startSec, endSec);
  const blob = await encodeInWorker(sliced, 128, (p) => {
    progressBar.value = p;
  });
  const url = URL.createObjectURL(blob);
  const a = Object.assign(document.createElement("a"), {
    href: url,
    download: file.name.replace(/\.[^.]+$/, "") + "-trim.mp3",
  });
  a.click();
  setTimeout(() => URL.revokeObjectURL(url), 10_000);
}

Revoke the object URL after a delay, not immediately. Some browsers start the download asynchronously, and revoking in the same tick can cancel it.

Picking a bitrate

For short meme clips and voice notes, 128 kbps stereo is transparent enough that nobody will notice, and the files stay small. Voice-only content can go to 64 kbps mono. Above 192 kbps you're mostly making the file bigger. I keep it at a fixed 128 rather than adding a setting, because people downloading a two-second clip don't want to make an encoding decision.

One thing lamejs doesn't do is resample. If the decoded sampleRate is unusual, the encoder might not accept it. In browsers, decodeAudioData resamples to the AudioContext's rate, which is normally 44100 or 48000 Hz, both valid MP3 rates. If you ever create the context with a custom sampleRate, stick to standard values.

Testing outside the browser

Because the encoder core is just a function from float arrays to byte arrays, you can test it in Node and check the result with ffprobe:

import { writeFileSync } from "node:fs";
// encodeMp3 is the loop from the worker, pulled into its own module
const sr = 44100, n = sr * 2;
const left = new Float32Array(n).map((_, i) => 0.5 * Math.sin((2 * Math.PI * 440 * i) / sr));
const parts = encodeMp3({ channels: [left, left], sampleRate: sr, kbps: 128 });
writeFileSync("test.mp3", Buffer.concat(parts));
ffprobe -v error -show_entries format=duration:stream=channels,sample_rate -of compact test.mp3
# stream|sample_rate=44100|channels=2
# format|duration=2.037500

The extra few milliseconds are normal MP3 padding (encoder delay plus the last partial frame). For gapless playback you'd need a LAME/Xing header with delay information, which is overkill for a meme clip but worth knowing if someone asks why the MP3 is a tiny bit longer than the WAV.

Try it

You can see the result in the Meme Tap audio trimmer: drop an MP3, WAV or OGG, drag the region on the waveform, and download WAV or MP3. The file is read and encoded in the tab and never uploaded. The pattern itself works for any browser tool that exports audio: decode on the main thread, transfer to a worker, encode in blocks, report progress sparingly, flush at the end.


I'm Abid Ali, a freelance full-stack developer in Karachi working with TypeScript, Next.js, React and Node.js. I built Meme Tap, a free meme soundboard and meme maker that runs in the browser.

About the author

  • I'm Abid Ali, a full stack developer from Karachi with 3+ years of experience in TypeScript, Next.js, Gatsby, React and React Native. I build web apps, mobile apps and interactive UIs with solid API integration. Side projects: OG Scope (ogscope.pk), a free Open Graph checker; Digital Dialogue (digitaldialogue.pk), freelancing guides for Upwork and Fiverr beginners; and Meme Tap (memetap.pk), a free browser meme soundboard.

No comments yet. Be the first to comment.