AAC is a more efficient audio codec than MP3 — same quality at lower bitrate. Converting to MP3 means lossy-to-lossy re-encoding, so the goal is to match (or slightly exceed) source bitrate to minimize compounding loss. In JavaScript, the question is mostly: do you want the conversion in-browser (privacy, no upload) or server-side (faster, smaller bundle)?

Method 1: ffmpeg.wasm (browser, client-side)

ffmpeg.wasm runs entirely in the browser — no server, no upload. Best for privacy-sensitive use cases.

npm install @ffmpeg/ffmpeg @ffmpeg/util
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile, toBlobURL } from "@ffmpeg/util";

const ffmpeg = new FFmpeg();
const baseURL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd";

async function loadFfmpeg() {
  await ffmpeg.load({
    coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
    wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"),
  });
}

async function aacToMp3(aacFile, bitrateKbps = 192) {
  if (!ffmpeg.loaded) await loadFfmpeg();

  await ffmpeg.writeFile("input.aac", await fetchFile(aacFile));
  await ffmpeg.exec([
    "-i", "input.aac",
    "-c:a", "libmp3lame",
    "-b:a", `${bitrateKbps}k`,
    "output.mp3",
  ]);
  const data = await ffmpeg.readFile("output.mp3");
  return new Blob([data], { type: "audio/mpeg" });
}

// Wire to a file input:
document.querySelector("input[type=file]").addEventListener("change", async (e) => {
  const mp3Blob = await aacToMp3(e.target.files[0], 192);
  const url = URL.createObjectURL(mp3Blob);
  const a = Object.assign(document.createElement("a"), {
    href: url,
    download: "output.mp3",
  });
  a.click();
  URL.revokeObjectURL(url);
});

Three things to know:

  • ~30MB WASM core download. Lazy-load this — never bundle into your initial page.
  • Cross-origin headers required. Set COOP/COEP headers or ffmpeg.wasm fails to initialize.
  • Match source bitrate. AAC at 256k → MP3 at 256k. Going lower compounds quality loss.

Method 2: fluent-ffmpeg (Node)

For server-side conversion in Node, fluent-ffmpeg wraps the FFmpeg binary with a fluent API.

npm install fluent-ffmpeg
apt install ffmpeg  # required
import ffmpeg from "fluent-ffmpeg";

function aacToMp3(inPath, outPath, bitrateKbps = 192) {
  return new Promise((resolve, reject) => {
    ffmpeg(inPath)
      .audioCodec("libmp3lame")
      .audioBitrate(`${bitrateKbps}k`)
      .on("end", resolve)
      .on("error", reject)
      .save(outPath);
  });
}

await aacToMp3("track.aac", "track.mp3", 192);

fluent-ffmpeg is the standard Node wrapper. Cleaner than child_process.spawn but ultimately just runs the FFmpeg binary.

Method 3: ChangeThisFile API (no WASM, no Node FFmpeg)

If 30MB of WASM is too much for your page, or you don't want FFmpeg in your Node container, the API runs FFmpeg server-side. Free tier covers 1,000 conversions/month.

const API_KEY = "ctf_sk_your_key_here";

async function aacToMp3(aacBuffer, filename = "track.aac", bitrate = 192) {
  const form = new FormData();
  form.append("file", new Blob([aacBuffer], { type: "audio/aac" }), filename);
  form.append("source", "aac");
  form.append("target", "mp3");
  form.append("bitrate", String(bitrate));

  const response = await fetch("https://changethisfile.com/v1/convert", {
    method: "POST",
    headers: { Authorization: `Bearer ${API_KEY}` },
    body: form,
  });

  if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  return await response.arrayBuffer();
}

// Cloudflare Worker example:
export default {
  async fetch(request) {
    const aac = await request.arrayBuffer();
    const mp3 = await aacToMp3(new Uint8Array(aac));
    return new Response(mp3, {
      headers: { "Content-Type": "audio/mpeg" },
    });
  },
};

The API auto-matches source bitrate when you don't specify one. Tags (artist, album, title, cover art) are preserved automatically.

When to use each

ApproachBest forTradeoff
ffmpeg.wasmPrivacy-first, no upload, MediaRecorder output~30MB WASM, COOP/COEP headers required
fluent-ffmpeg (Node)Server-side batch, native FFmpeg speedFFmpeg binary install required
ChangeThisFile APIEdge runtimes, multi-tenant, no infraNetwork call, file size limit (25MB free)

Production tips

  • Match source bitrate. Re-encoding from AAC at 128k to MP3 at 96k compounds quality loss. Always match or exceed (e.g., AAC 128k → MP3 192k).
  • For ffmpeg.wasm, lazy-load the core. 30MB on first page load is brutal. Load only when the user clicks 'Convert'.
  • Set Cross-Origin-Opener-Policy + Cross-Origin-Embedder-Policy. ffmpeg.wasm requires SharedArrayBuffer, which requires these headers. Cloudflare Pages: configure in _headers; Vercel: vercel.json.
  • Run ffmpeg.wasm in a Web Worker. Audio encoding blocks the main thread for several seconds on long files. Worker-thread keeps the UI responsive.
  • Consider keeping AAC. Most modern devices play AAC. The conversion to MP3 is only needed for old hardware or downstream tools that require MP3.

For client-side privacy, ffmpeg.wasm in a Web Worker. For Node services, fluent-ffmpeg. For edge runtimes or no-infra, the API. Free tier covers 1,000 conversions/month.